Seminar 10 Memory game, part 2

By the time we finished our previous seminar, you had the code that created a single “card” with a given image at a given location and you were able to interact with it (flip-flopping it). Now, we need to extend the code so that we have eight cards that we can open only two at a time. If cards match (have same "filename" field), we remove them. If cards do not match, we simply turn them over.

Before we start, create a new folder Memory02 for exercise files. Copy Images subfolder, as well as the utilities.py and exercise11.py (rename it to exercise01.py, as we will use the latter as the staring point). Also, download the Jupyter notebook, which you will use to experiment with functions and do exercises. You will need to upload it along with other files.

10.1 Getting list of image files.

For a single card, we simply hard-coded the name of the image file, as well as its location. However, for a real game (or an experiment) we would like to be more flexible and automatically determine which files we have in the Images folder. For this, you need to use os.listdir(path=“.”) function that, you’ve guess it, returns a list with filenames of all the files in a folder specified by path. By default, it is a current path (path="."). However, you can use either a relative path - os.listdir("Images"), assuming that Images is a subfolder in your current directory - or an absolute path os.listdir("E:/Teaching/Python/MemoryGame/Images") (in my case). In Jupyter, write a single line code to get the list of files in the Images folder. Use relative path, if it is in a subfolder relative to your Jupyter notebook, or use an absolute path, if it is in a different folder. Do not forget to import os before you run the code, of course!

Do exercise #1 in Jupyter notebook.

You should have gotten a list of 8 files that are coded as [r|l][index].png, where r or l denote the side the chicken is looking at. However, for our game we need only four images (4 × 2 = 8 cards). Therefore, we need to select a subset of them. For example, a random four or chicken looking to the left or to the right only. Here, let us work with chicken looking to the left, meaning that we need only to pick files that start with “l”. To make this list filtering more efficient, we will use list comprehensions.

10.2 List comprehension

List comprehension provide an elegant and easy-to-read way to create, modify and/or filter elements of the list creating a new list. The general structure is

new_list = [<transform-the-item> for item in old_list if <condition-given-the-item>]

Let us look at examples to understand how it works. Imagine that you have a list numbers = [1, 2, 3] and you need increment each number by 16. You can do it by creating a new list and adding 1 to each item in the part:

numbers = [1, 2, 3]
numbers_plus_1 = [item + 1 for item in numbers]

Note that this is equivalent to

numbers = [1, 2, 3]
numbers_plus_1 = []
for item in numbers:
    numbers_plus_1.append(item + 1)

Or, imagine that you need to convert each item to a string. You can do it simply as

numbers = [1, 2, 3]
numbers_as_strings = [str(item) for item in numbers]

What would be an equivalent form using a normal for loop? Write both versions of code in Jupiter cells and check that the results are the same.

Do exercise #2 in Jupyter notebook.

Now, implement the code below using list comprehension. Check that results match.

strings = ['1', '2', '3']
numbers = []
for astring in strings:
    numbers.append(int(astring) + 10)

Do exercise #3 in Jupyter notebook.

As noted above, you can also use conditional statement to filter which items are passed to the new list. In our numbers example, we can retain numbers that are greater than 1

numbers = [1, 2, 3]
numbers_greater_than_1 = [item for item in numbers if item > 1]

Sometimes, the same statement is written in three lines, instead of one, to make reading easier:

numbers = [1, 2, 3]
numbers_greater_than_1 = [item 
                          for item in numbers
                          if item > 1]

You can of course combine the transformation and filtering in a single statement. Create code that filters out all items below 2 and adds 4 to them.

Do exercise #4 in Jupyter notebook.

10.3 Getting list of relevant files

Now that you know about list comprehensions, you can easily create a list of files of chicken looking left, i.e. with filenames that start with “l”. Use str.startswith() for filtering, store the list in filenames variable. Put you code into a Jupyter cell.

Do exercise #5 in Jupyter notebook.

This gives us a nice list of eight files but we need each name twice. There are several ways of making but we will use list operations for this.

10.3.1 List operations

Python lists implement two operations:

  • Adding two lists together: <list1> + <list2>.
a = [1, 2, 3]
b = [4, 5, 6]
a + b

gives a new list with items [1, 2, 3, 4, 5, 4, 5, 6]. Note that this is not equivalent to extend method a.extend(b)! The + creates a new list, .extend() extends the original list a.

  • List multiplication/replication: <list> * <integer-value> creates a new list by replicating the original one <integer-value> times. For example:
a = [1, 2, 3]
b = 4
a * b

will give you [1, 2, 3, 1, 2, 3, 1, 2, 3, 1, 2, 3].

Use either operations or .extend() method to create the new list where each filename is repeated twice. Hint, you can apply list multiplication directly to the filenames list you created via list comprehension.

Do exercise #6 in Jupyter notebook.

10.4 Lots of cards, using list enumeration

Now that we have a list of filenames, we can create a list of cards out of it. This list will replace your single card variable. Name the new list cards and create it either using an empty list + for loop or using list comprehensions. Inside the loop you creating each card using its filename and index (see below) and append it to the cards list (or it is added automatically, if you use comprehensions).

You can get the index of each filename (and, therefore, it position on the screen) via enumerate(), which is a generator that returns a tuple of (index, item). For example, test the example below that prints one letter at a time with its index:

letters = ['a', 'b', 'c']
for index, letter in enumerate(letters):
    print('%d: %s'%(index, letter))

Once you created the list of cards, you should draw all of them inside the main loop. You already have a line that draws a single single_card. You should wrap this call with a for loop that loops over all cards and uses single_card as an iterator variable. Your code should look roughly as this

# import libraries
# open window and create the mouse
# get filenames for chicken looking left 
# create all cards

gameover = False
while not gameover:
    # draw all cards
    
    # check keys
    
    # mouse processing - COMMENT IT OUT FOR A MOMENT

Put your code into exercise01.py.

10.5 Mouse interaction for every card

Our mouse interaction was based on the index of our only card and checking that it matches the mouse click. However, now we have eight cards that cover the entire window. Thus, it does not matter where exactly the player clicked, there is a card which needs to be turned over. Modify your mouse click processing code so that if mouse left button is pressed, it computes an index of that location (it would be from 0 to 7), flips the card with that index in the list, waits for 0.1 seconds (our hack from last time, we won’t need it soon).

Put your code into exercise02.py.

10.6 Limiting flipping to just two cards

In the actual game, we are allow to only turn around two cards at a time. We will either need an extra variable to know how many cards are face up already or do an on-the-fly scan through the list. Let us implement the extra variable solution. Create a new variable faceup_n = 0 before the loop. Inside the mouse-click processing code, you need to implement the following logic:

if mouse.getPressed()[0]:
    # compute index of the mouse click
    # if card with this index is not face up already:
        # turn the card face up
        # increment the faceup_n variable
        # if faceup_n is equal to 2:
            # --- draw all cards ---
            # insert a pause of 0.5 seconds
            # turn all cards back
            # reset faceup_n to zero (as all cards are now with their backs up)

Note that we need to explicitly draw the cards again inside the condition (and don’t forget to flip the window)! Otherwise, we turn the card back before we ever get to the drawing stage.

Put your code into exercise03.py.

10.7 Remembering which cards were turned

Our initial implementation was to count the number of cards that player turned. This, however, means that we don’t know which cards these were. Let us now store face-up cards in a new list called faceup that would replace the faceup_n variable. Initialize faceup with an empty list (just like you initialized faceup_n with 0). And, once the card is flipped, add it to this list. Once faceup list is 2 items long (use len() function to get the length of the list), show the cards, wait for 0.5 seconds, turn cards in faceup list back, and clear the list.

if mouse.getPressed()[0]:
    # compute index of the mouse click
    # if card with this index is not face up already:
        # turn the card face up
        # add card to faceup list
        # if faceup list length is equal to 2:
            # --- draw all cards ---
            # insert a pause of 0.5 seconds
            # turn cards in faceup list back
            # clear faceup list

Put your code into exercise04.py.

10.8 “Visible” card flag

In our next step, we will remove the matching cards. However, in reality, we won’t actually remove them, we just won’t draw them and won’t allow user to interact with them once they are “removed”. To know whether we need to draw a particular card, we will add a new field visible. There are three (well, four) pieces of code which needs to be modified.

  1. Add visible field to the dictionary in the create_card() function when making the card and set it to True by default.
  2. When drawing cards, draw it only if visible field is True. By now you draw card in two places, so both need to be appended.
  3. In the code that processes the mouse click, when checking if the card hasn’t been turned over yet, add “and it is visible” to the condition (in Python syntax, of course). So that it can be turned face up only if it is current face down and visible.

You should see no difference in the game, because as long as all cards are visible (and, at the moment, they always are) it works just like before.

Put your code into utilities.py and exercise05.py.

10.9 “Removing” matching cards

By now we now which two cards were turned over (these are in faceup list) and if they match, which means that their filenames match, we can “remove” them by setting their visible field to False.

# if faceup list length is equal to 2:
    # --- draw all cards ---
    # insert a pause of 0.5 seconds

    # if cards match:
        # set cards visibility to False
    # else:
        # turn them back

    # clear faceup list

Test this and remember that we didn’t shuffle the faces so they repeat in an orderly fashion, making it easy to open the pairs.

Put your code into exercise06.py.

10.10 Game over, if you run out of cards

Currently, our game can be exited by pressing Escape. Let’s add a more positive game-over outcome when you win because you matched all the cards! For this, we need to count how many cards are left on the table and modify the loop, so that it iterates only as long as the number of cards left is more than zero. For this,

  1. Create a new variable cards_left before the loop start and set it to the total number of cards (length of the cards list).
  2. Modify the while loop, adding the second condition that cards_left must be more than zero.
  3. Decrease cards_left by 2, every time two cards are matched and “removed”.

Put your code into exercise07.py.

10.11 Game over message

Currently, our game abruptly closes once all cards are removed. It would be much friendlier to add a simple “Game over” message and show it for couple of seconds. After the main loop but before closing the window, create and draw the [TextStim]((https://psychopy.org/api/visual/textstim.html) and use either clock.wait(2) or event.waitKeys() (your choice). However, make sure that this message is only shown if there were no cards left. Otherwise it was the escape button press and so no “game over” message for people who are too lazy to finish the game!

Put your code into exercise08.py.

10.12 Counting attempts

It would be even more fun to check how many attempts it required for the player to match all cards. Create a new variable attempts and initialize it to zero before the main loop. Increase it by 1 every time the player turned two cards over (irrespective of whether they matched). Add the number of attempts to the “Game over” message.

Put your code into exercise09.py.

10.13 Record time

Let us record the time it took the player to complete the task. For this we will use PsychoPy’s Clock class, which is very straightforward. Simply create the timer = clock.Clock() before the main loop and then call its getTime() method to get the elapsed time in seconds. Add this information to the “game over” message.

Put your code into exercise10.py.

10.14 Randomness

The only boring thing about our game is that we know exactly where each chicken is. Shuffle the filenames list before you create the cards to turn it into a proper memory game.

Put your code into exercise11.py.

Yay! Awesome game!


  1. A very arbitrary example!↩︎