Skip to main content
Engineering LibreTexts

10.3: The Initial Setup

  • Page ID
  • # Star Pusher (a Sokoban clone)
    # By Al Sweigart
    # Released under a "Simplified BSD" license
    import random, sys, copy, os, pygame
    from pygame.locals import *
    FPS = 30 # frames per second to update the screen
    WINWIDTH = 800 # width of the program's window, in pixels
    WINHEIGHT = 600 # height in pixels
    # The total width and height of each tile in pixels.
    TILEWIDTH = 50
    CAM_MOVE_SPEED = 5 # how many pixels per frame the camera moves
    # The percentage of outdoor tiles that have additional
    # decoration on them, such as a tree or rock.
    BRIGHTBLUE = (  0, 170, 255)
    WHITE      = (255, 255, 255)
    UP = 'up'
    DOWN = 'down'
    LEFT = 'left'
    RIGHT = 'right'

    These constants are used in various parts of the program. The TILEWIDTH and TILEHEIGHT variables show that each of the tile images are 50 pixels wide and 85 pixels tall. However, these tiles overlap with each other when drawn on the screen. (This is explained later.) The TILEFLOORHEIGHT refers to the fact that the part of the tile that represents the floor is 45 pixels tall. Here is a diagram of the plain floor image:

    Figure 55

    The grassy tiles outside of the level’s room will sometimes have extra decorations added to them (such as trees or rocks). The OUTSIDE_DECORATION_PCT constant shows what percentage of these tiles will randomly have these decorations.

    def main():
        # Pygame initialization and basic set up of the global variables.
        FPSCLOCK = pygame.time.Clock()
        # Because the Surface object stored in DISPLAYSURF was returned
        # from the pygame.display.set_mode() function, this is the
        # Surface object that is drawn to the actual computer screen
        # when pygame.display.update() is called.
        DISPLAYSURF = pygame.display.set_mode((WINWIDTH, WINHEIGHT))
        pygame.display.set_caption('Star Pusher')
        BASICFONT = pygame.font.Font('freesansbold.ttf', 18)

    This is the usual Pygame setup that happens at the beginning of the program.

        # A global dict value that will contain all the Pygame
        # Surface objects returned by pygame.image.load().
        IMAGESDICT = {'uncovered goal': pygame.image.load('RedSelector.png'),
                      'covered goal': pygame.image.load('Selector.png'),
                      'star': pygame.image.load('Star.png'),
                      'corner': pygame.image.load('Wall_Block_Tall.png'),
                      'wall': pygame.image.load('Wood_Block_Tall.png'),
                      'inside floor': pygame.image.load('Plain_Block.png'),
                      'outside floor': pygame.image.load('Grass_Block.png'),
                      'title': pygame.image.load('star_title.png'),
                      'solved': pygame.image.load('star_solved.png'),
                      'princess': pygame.image.load('princess.png'),
                      'boy': pygame.image.load('boy.png'),
                      'catgirl': pygame.image.load('catgirl.png'),
                      'horngirl': pygame.image.load('horngirl.png'),
                      'pinkgirl': pygame.image.load('pinkgirl.png'),
                      'rock': pygame.image.load('Rock.png'),
                      'short tree': pygame.image.load('Tree_Short.png'),
                      'tall tree': pygame.image.load('Tree_Tall.png'),
                      'ugly tree': pygame.image.load('Tree_Ugly.png')} 

    The IMAGESDICT is a dictionary where all of the loaded images are stored. This makes it easier to use in other functions, since only the IMAGESDICT variable needs to be made global. If we stored each of these images in separate variables, then all 18 variables (for the 18 images used in this game) would need to be made global. A dictionary containing all of the Surface objects with the images is easier to handle.

        # These dict values are global, and map the character that appears
        # in the level file to the Surface object it represents.
        TILEMAPPING = {'x': IMAGESDICT['corner'],
                       '#': IMAGESDICT['wall'],
                       'o': IMAGESDICT['inside floor'],
                       ' ': IMAGESDICT['outside floor']}

    The data structure for the map is just a 2D list of single character strings. The TILEMAPPING dictionary links the characters used in this map data structure to the images that they represent. (This will become more clear in the drawMap() function’s explanation.)

                              '2': IMAGESDICT['short tree'],
                              '3': IMAGESDICT['tall tree'],
                              '4': IMAGESDICT['ugly tree']}

    The OUTSIDEDECOMAPPING is also a dictionary that links the characters used in the map data structure to images that were loaded. The "outside decoration" images are drawn on top of the outdoor grassy tile.

        # PLAYERIMAGES is a list of all possible characters the player can be.
        # currentImage is the index of the player's current player image.
        currentImage = 0
        PLAYERIMAGES = [IMAGESDICT['princess'],

    The PLAYERIMAGES list stores the images used for the player. The currentImage variable tracks the index of the currently selected player image. For example, when currentImage is set to 0 then PLAYERIMAGES[0], which is the "princess" player image, is drawn to the screen.

        startScreen() # show the title screen until the user presses a key
        # Read in the levels from the text file. See the readLevelsFile() for
        # details on the format of this file and how to make your own levels.
        levels = readLevelsFile('starPusherLevels.txt')
        currentLevelIndex = 0

    The startScreen() function will keep displaying the initial start screen (which also has the instructions for the game) until the player presses a key. When the player presses a key, the startScreen() function returns and then reads in the levels from the level file. The player starts off on the first level, which is the level object in the levels list at index 0.

        # The main game loop. This loop runs a single level, when the user
        # finishes that level, the next/previous level is loaded.
        while True: # main game loop
            # Run the level to actually start playing the game:
            result = runLevel(levels, currentLevelIndex)

    The runLevel() function handles all the action for the game. It is passed a list of level objects, and the integer index of the level in that list to be played. When the player has finished playing the level, runLevel() will return one of the following strings: 'solved' (because the player has finished putting all the stars on the goals), 'next' (because the player wants to skip to the next level), 'back' (because the player wants to go back to the previous level), and 'reset' (because the player wants to start playing the current level over again, maybe because they pushed a star into a corner).

            if result in ('solved', 'next'):
                # Go to the next level.
                currentLevelIndex += 1
                if currentLevelIndex >= len(levels):
                    # If there are no more levels, go back to the first one.
                    currentLevelIndex = 0
            elif result == 'back':
                # Go to the previous level.
                currentLevelIndex -= 1
                if currentLevelIndex < 0:
                    # If there are no previous levels, go to the last one.
                    currentLevelIndex = len(levels)-1

    If runLevel() has returned the strings 'solved' or 'next', then we need to increment levelNum by 1. If this increments levelNum beyond the number of levels there are, then levelNum is set back at 0.

    The opposite is done if 'back' is returned, then levelNum is decremented by 1. If this makes it go below 0, then it is set to the last level (which is len(levels)-1).

            elif result == 'reset':
                pass # Do nothing. Loop re-calls runLevel() to reset the level

    If the return value was 'reset', then the code does nothing. The pass statement does nothing (like a comment), but is needed because the Python interpreter expects an indented line of code after an elif statement.

    We could remove lines 1 [119] and 2 [120] from the source code entirely, and the program will still work just the same. The reason we include it here is for program readability, so that if we make changes to the code later, we won’t forget that runLevel() can also return the string 'reset'.

    def runLevel(levels, levelNum):
        global currentImage
        levelObj = levels[levelNum]
        mapObj = decorateMap(levelObj['mapObj'], levelObj['startState']['player'])
        gameStateObj = copy.deepcopy(levelObj['startState'])

    The levels list contains all the level objects that were loaded from the level file. The level object for the current level (which is what levelNum is set to) is stored in the levelObj variable. A map object (which makes a distinction between indoor and outdoor tiles, and decorates the outdoor tiles with trees and rocks) is returned from the decorateMap() function. And to track the state of the game while the player plays this level, a copy of the game state object that is stored in levelObj is made using the copy.deepcopy() function.

    The game state object copy is made because the game state object stored in levelObj['startState'] represents the game state at the very beginning of the level, and we do not want to modify this. Otherwise, if the player restarts the level, the original game state for that level will be lost.

    The copy.deepcopy() function is used because the game state object is a dictionary of that has tuples. But technically, the dictionary contains references to tuples. (References are explained in detail at Using an assignment statement to make a copy of the dictionary will make a copy of the references but not the values they refer to, so that both the copy and the original dictionary still refer to the same tuples.

    The copy.deepcopy() function solves this problem by making copies of the actual tuples in the dictionary. This way we can guarantee that changing one dictionary will not affect the other dictionary.

        mapNeedsRedraw = True # set to True to call drawMap()
        levelSurf = BASICFONT.render('Level %s of %s' % (levelNum + 1, len(levels)), 1, TEXTCOLOR)
        levelRect = levelSurf.get_rect()
        levelRect.bottomleft = (20, WINHEIGHT - 35)
        mapWidth = len(mapObj) * TILEWIDTH
        mapHeight = (len(mapObj[0]) - 1) * TILEFLOORHEIGHT + TILEHEIGHT
        MAX_CAM_X_PAN = abs(HALF_WINHEIGHT - int(mapHeight / 2)) + TILEWIDTH
        MAX_CAM_Y_PAN = abs(HALF_WINWIDTH - int(mapWidth / 2)) + TILEHEIGHT
        levelIsComplete = False
        # Track how much the camera has moved:
        cameraOffsetX = 0
        cameraOffsetY = 0
        # Track if the keys to move the camera are being held down:
        cameraUp = False
        cameraDown = False
        cameraLeft = False
        cameraRight = False

    More variables are set at the start of playing a level. The mapWidth and mapHeight variables are the size of the maps in pixels. The expression for calculating mapHeight is a bit complicated since the tiles overlap each other. Only the bottom row of tiles is the full height (which accounts for the + TILEHEIGHT part of the expression), all of the other rows of tiles (which number as (len(mapObj[0]) - 1)) are slightly overlapped. This means that they are effectively each only (TILEHEIGHT - TILEFLOORHEIGHT) pixels tall.

    The camera in Star Pusher can be moved independently of the player moving around the map. This is why the camera needs its own set of "moving" variables: cameraUp, cameraDown, cameraLeft, and cameraRight. The cameraOffsetX and cameraOffsetY variables track the position of the camera.

        while True: # main game loop
            # Reset these variables:
            playerMoveTo = None
            keyPressed = False
            for event in pygame.event.get(): # event handling loop
                if event.type == QUIT:
                    # Player clicked the "X" at the corner of the window.

    The playerMoveTo variable will be set to the direction constant that the player intends to move the player character on the map. The keyPressed variable tracks if any key has been pressed during this iteration of the game loop. This variable is checked later when the player has solved the level.

                elif event.type == KEYDOWN:
                    # Handle key presses
                    keyPressed = True
                    if event.key == K_LEFT:
                        playerMoveTo = LEFT
                    elif event.key == K_RIGHT:
                        playerMoveTo = RIGHT
                    elif event.key == K_UP:
                        playerMoveTo = UP
                    elif event.key == K_DOWN:
                        playerMoveTo = DOWN
                    # Set the camera move mode.
                    elif event.key == K_a:
                        cameraLeft = True
                    elif event.key == K_d:
                        cameraRight = True
                    elif event.key == K_w:
                        cameraUp = True
                    elif event.key == K_s:
                        cameraDown = True
                    elif event.key == K_n:
                        return 'next'
                    elif event.key == K_b:
                        return 'back'
                    elif event.key == K_ESCAPE:
                        terminate() # Esc key quits.
                    elif event.key == K_BACKSPACE:
                        return 'reset' # Reset the level.
                    elif event.key == K_p:
                        # Change the player image to the next one.
                        currentImage += 1
                        if currentImage >= len(PLAYERIMAGES):
                            # After the last player image, use the first one.
                            currentImage = 0
                        mapNeedsRedraw = True
                elif event.type == KEYUP:
                    # Unset the camera move mode.
                    if event.key == K_a:
                        cameraLeft = False
                    elif event.key == K_d:
                        cameraRight = False
                    elif event.key == K_w:
                        cameraUp = False
                    elif event.key == K_s:
                        cameraDown = False

    This code handles what to do when the various keys are pressed.

            if playerMoveTo != None and not levelIsComplete:
                # If the player pushed a key to move, make the move
                # (if possible) and push any stars that are pushable.
                moved = makeMove(mapObj, gameStateObj, playerMoveTo)
                if moved:
                    # increment the step counter.
                    gameStateObj['stepCounter'] += 1
                    mapNeedsRedraw = True
                if isLevelFinished(levelObj, gameStateObj):
                    # level is solved, we should show the "Solved!" image.
                    levelIsComplete = True
                    keyPressed = False 

    If the playerMoveTo variable is no longer set to None, then we know the player intended to move. The call to makeMove() handles changing the XY coordinates of the player’s position in the gameStateObj, as well as pushing any stars. The return value of makeMove() is stored in moved. If this value is True, then the player character was moved in that direction. If the value was False, then the player must have tried to move into a tile that was a wall, or push a star that had something behind it. In this case, the player can’t move and nothing on the map changes.

            if mapNeedsRedraw:
                mapSurf = drawMap(mapObj, gameStateObj, levelObj['goals'])
                mapNeedsRedraw = False

    The map does not need to be redrawn on each iteration through the game loop. In fact, this game program is complicated enough that doing so would cause a slight (but noticeable) slowdown in the game. And the map really only needs to be redrawn when something has changed (such as the player moving or a star being pushed). So the Surface object in the mapSurf variable is only updated with a call to the drawMap() function when the mapNeedsRedraw variable is set to True.

    After the map has been drawn on line 4 [225], the mapNeedsRedraw variable is set to False. If you want to see how the program slows down by drawing on each iteration through the game loop, comment out line 5 [226] and rerun the program. You will notice that moving the camera is significantly slower.

            if cameraUp and cameraOffsetY < MAX_CAM_X_PAN:
                cameraOffsetY += CAM_MOVE_SPEED
            elif cameraDown and cameraOffsetY > -MAX_CAM_X_PAN:
                cameraOffsetY -= CAM_MOVE_SPEED
            if cameraLeft and cameraOffsetX < MAX_CAM_Y_PAN:
                cameraOffsetX += CAM_MOVE_SPEED
            elif cameraRight and cameraOffsetX > -MAX_CAM_Y_PAN:
                cameraOffsetX -= CAM_MOVE_SPEED

    If the camera movement variables are set to True and the camera has not gone past (i.e. panned passed) the boundaries set by the MAX_CAM_X_PAN and MAX_CAM_Y_PAN, then the camera location (stored in cameraOffsetX and cameraOffsetY) should move over by CAM_MOVE_SPEED pixels.

    Note that there is an if and elif statement on lines 1 [228] and 3 [230] for moving the camera up and down, and then a separate if and elif statement on lines 5 [232] and 7 [234]. This way, the user can move the camera both vertically and horizontally at the same time. This wouldn’t be possible if line 5 [232] were an elif statement.

            # Adjust mapSurf's Rect object based on the camera offset.
            mapSurfRect = mapSurf.get_rect()
   = (HALF_WINWIDTH + cameraOffsetX, HALF_WINHEIGHT + cameraOffsetY)
            # Draw mapSurf to the DISPLAYSURF Surface object.
            DISPLAYSURF.blit(mapSurf, mapSurfRect)
            DISPLAYSURF.blit(levelSurf, levelRect)
            stepSurf = BASICFONT.render('Steps: %s' % (gameStateObj['stepCounter']), 1, TEXTCOLOR)
            stepRect = stepSurf.get_rect()
            stepRect.bottomleft = (20, WINHEIGHT - 10)
            DISPLAYSURF.blit(stepSurf, stepRect)
            if levelIsComplete:
                # is solved, show the "Solved!" image until the player
                # has pressed a key.
                solvedRect = IMAGESDICT['solved'].get_rect()
                DISPLAYSURF.blit(IMAGESDICT['solved'], solvedRect)
                if keyPressed:
                    return 'solved'
            pygame.display.update() # draw DISPLAYSURF to the screen.

    Lines 1 [237] to 25 [261] position the camera and draw the map and other graphics to the display Surface object in DISPLAYSURF. If the level is solved, then the victory graphic is also drawn on top of everything else. The keyPressed variable will be set to True if the user pressed a key during this iteration, at which point the runLevel() function returns.

    def isWall(mapObj, x, y):
        """Returns True if the (x, y) position on
        the map is a wall, otherwise return False."""
        if x < 0 or x >= len(mapObj) or y < 0 or y >= len(mapObj[x]):
            return False # x and y aren't actually on the map.
        elif mapObj[x][y] in ('#', 'x'):
            return True # wall is blocking
        return False

    The isWall() function returns True if there is a wall on the map object at the XY coordinates passed to the function. Wall objects are represented as either a 'x' or '#' string in the map object.

    def decorateMap(mapObj, startxy):
        """Makes a copy of the given map object and modifies it.
        Here is what is done to it:
            * Walls that are corners are turned into corner pieces.
            * The outside/inside floor tile distinction is made.
            * Tree/rock decorations are randomly added to the outside tiles.
        Returns the decorated map object."""
        startx, starty = startxy # Syntactic sugar
        # Copy the map object so we don't modify the original passed
        mapObjCopy = copy.deepcopy(mapObj)

    The decorateMap() function alters the data structure mapObj so that it isn’t as plain as it appears in the map file. The three things that decorateMap() changes are explained in the comment at the top of the function.

        # Remove the non-wall characters from the map data
        for x in range(len(mapObjCopy)):
            for y in range(len(mapObjCopy[0])):
                if mapObjCopy[x][y] in ('$', '.', '@', '+', '*'):
                    mapObjCopy[x][y] = ' '

    The map object has characters that represent the position of the player, goals, and stars. These are necessary for the map object (they’re stored in other data structures after the map file is read) so they are converted to blank spaces.

        # Flood fill to determine inside/outside floor tiles.
        floodFill(mapObjCopy, startx, starty, ' ', 'o') 

    The floodFill() function will change all of the tiles inside the walls from ' ' characters to 'o' characters. It does this using a programming concept called recursion, which is explained in "Recursive Functions" section later in this chapter.

        # Convert the adjoined walls into corner tiles.
        for x in range(len(mapObjCopy)):
            for y in range(len(mapObjCopy[0])):
                if mapObjCopy[x][y] == '#':
                    if (isWall(mapObjCopy, x, y-1) and isWall(mapObjCopy, x+1, y)) or \
                       (isWall(mapObjCopy, x+1, y) and isWall(mapObjCopy, x, y+1)) or \
                       (isWall(mapObjCopy, x, y+1) and isWall(mapObjCopy, x-1, y)) or \
                       (isWall(mapObjCopy, x-1, y) and isWall(mapObjCopy, x, y-1)):
                        mapObjCopy[x][y] = 'x'
                elif mapObjCopy[x][y] == ' ' and random.randint(0, 99) < OUTSIDE_DECORATION_PCT:
                    mapObjCopy[x][y] = random.choice(list(OUTSIDEDECOMAPPING.keys()))
        return mapObjCopy

    The large, multi-line if statement on line 5 [301] checks if the wall tile at the current XY coordinates are a corner wall tile by checking if there are wall tiles adjacent to it that form a corner shape. If so, the '#' string in the map object that represents a normal wall is changed to a 'x' string which represents a corner wall tile.

    def isBlocked(mapObj, gameStateObj, x, y):
        """Returns True if the (x, y) position on the map is
        blocked by a wall or star, otherwise return False."""
        if isWall(mapObj, x, y):
            return True
        elif x < 0 or x >= len(mapObj) or y < 0 or y >= len(mapObj[x]):
            return True # x and y aren't actually on the map.
        elif (x, y) in gameStateObj['stars']:
            return True # a star is blocking
        return False

    There are three cases where a space on the map would be blocked: if there is a star, a wall, or the coordinates of the space are past the edges of the map. The isBlocked() function checks for these three cases and returns True if the XY coordinates are blocked and False if not.

    def makeMove(mapObj, gameStateObj, playerMoveTo):
        """Given a map and game state object, see if it is possible for the
        player to make the given move. If it is, then change the player's
        position (and the position of any pushed star). If not, do nothing.
        Returns True if the player moved, otherwise False."""
        # Make sure the player can move in the direction they want.
        playerx, playery = gameStateObj['player']
        # This variable is "syntactic sugar". Typing "stars" is more
        # readable than typing "gameStateObj['stars']" in our code.
        stars = gameStateObj['stars']
        # The code for handling each of the directions is so similar aside
        # from adding or subtracting 1 to the x/y coordinates. We can
        # simplify it by using the xOffset and yOffset variables.
        if playerMoveTo == UP:
            xOffset = 0
            yOffset = -1
        elif playerMoveTo == RIGHT:
            xOffset = 1
            yOffset = 0
        elif playerMoveTo == DOWN:
            xOffset = 0
            yOffset = 1
        elif playerMoveTo == LEFT:
            xOffset = -1
            yOffset = 0
        # See if the player can move in that direction.
        if isWall(mapObj, playerx + xOffset, playery + yOffset):
            return False
            if (playerx + xOffset, playery + yOffset) in stars:
                # There is a star in the way, see if the player can push it.
                if not isBlocked(mapObj, gameStateObj, playerx + (xOffset*2), playery + (yOffset*2)):
                    # Move the star.
                    ind = stars.index((playerx + xOffset, playery + yOffset))
                    stars[ind] = (stars[ind][0] + xOffset, stars[ind][1] + yOffset)
                    return False
            # Move the player upwards.
            gameStateObj['player'] = (playerx + xOffset, playery + yOffset)
            return True

    The makeMove() function checks to make sure if moving the player in a particular direction is a valid move. As long as there isn’t a wall blocking the path, or a star that has a wall or star behind it, the player will be able to move in that direction. The gameStateObj variable will be updated to reflect this, and the True value will be returned to tell the function’s caller that the player was moved.

    If there was a star in the space that the player wanted to move, that star’s position is also changed and this information is updated in the gameStateObj variable as well. This is how the "star pushing" is implemented.

    If the player is blocked from moving in the desired direction, then the gameStateObj is not modified and the function returns False.

    def startScreen():
        """Display the start screen (which has the title and instructions)
        until the player presses a key. Returns None."""
        # Position the title image.
        titleRect = IMAGESDICT['title'].get_rect()
        topCoord = 50 # topCoord tracks where to position the top of the text = topCoord
        titleRect.centerx = HALF_WINWIDTH
        topCoord += titleRect.height
        # Unfortunately, Pygame's font & text system only shows one line at
        # a time, so we can't use strings with \n newline characters in them.
        # So we will use a list with each line in it.
        instructionText = ['Push the stars over the marks.',
                           'Arrow keys to move, WASD for camera control, P to change character.',
                           'Backspace to reset level, Esc to quit.',
                           'N for next level, B to go back a level.']

    The startScreen() function needs to display a few different pieces of text down the center of the window. We will store each line as a string in the instructionText list. The title image (stored in IMAGESDICT['title'] as a Surface object (that was originally loaded from the star_title.png file)) will be positioned 50 pixels from the top of the window. This is because the integer 50 was stored in the topCoord variable on line 7 [383]. The topCoord variable will track the Y axis positioning of the title image and the instructional text. The X axis is always going to be set so that the images and text are centered, as it is on line 9 [385] for the title image.

    On line 10 [386], the topCoord variable is increased by whatever the height of that image is. This way we can modify the image and the start screen code won’t have to be changed.

        # Start with drawing a blank color to the entire window:
        # Draw the title image to the window:
        DISPLAYSURF.blit(IMAGESDICT['title'], titleRect)
        # Position and draw the text.
        for i in range(len(instructionText)):
            instSurf = BASICFONT.render(instructionText[i], 1, TEXTCOLOR)
            instRect = instSurf.get_rect()
            topCoord += 10 # 10 pixels will go in between each line of text.
   = topCoord
            instRect.centerx = HALF_WINWIDTH
            topCoord += instRect.height # Adjust for the height of the line.
            DISPLAYSURF.blit(instSurf, instRect)

    Line 5 [400] is where the title image is blitted to the display Surface object. The for loop starting on line 8 [403] will render, position, and blit each instructional string in the instructionText loop. The topCoord variable will always be incremented by the size of the previously rendered text (line 14 [409]) and 10 additional pixels (on line 11 [406], so that there will be a 10 pixel gap between the lines of text).

        while True: # Main loop for the start screen.
            for event in pygame.event.get():
                if event.type == QUIT:
                elif event.type == KEYDOWN:
                    if event.key == K_ESCAPE:
                    return # user has pressed a key, so return.
            # Display the DISPLAYSURF contents to the actual screen.

    There is a game loop in startScreen() that begins on line 1 [412] and handles events that indicate if the program should terminate or return from the startScreen() function. Until the player does either, the loop will keep calling pygame.display.update() and FPSCLOCK.tick() to keep the start screen displayed on the screen.