Skip to main content

Command Palette

Search for a command to run...

Building a fantasy top-down game in Python (Part 1)

The map and project setup

Updated
12 min read
Building a fantasy top-down game in Python (Part 1)

I was required to submit a real-world python project for my grade 11 Computer Science class this year, and what better project to build than a game! Not only are games fun, but they help visualize exactly what complex pieces of code do, for example classes and objects. The game I originally wanted to make was a Geronimo Stilton inspired fantasy top-down with 5 levels corresponding to 5 of the lands in ‘Geronimo Stilton and the Kingdom of Fantasy’, like the land of sweets, the land of fairies etc. But, I soon came to realize that I was in way over my head cause it took me the entirety of the allotted time just to build Level 1, which in this series, we will learn to build together!

Designing the map

Okay, so before we even get started with the code, we’ll need to design the map of the level. Now if this was a simpler level, we could’ve gotten away with creating a 2D array to store map information, but since we have multiple layers (which I will soon discuss), we’ll need something more large-scale. For that purpose, we’ll be using Tiled as our editor!

To get started, download the latest version of Tiled (http://www.mapeditor.org/download.html) on your device and follow the installation guide. Once you have it installed, go to ‘New’ and select ‘New Map’, then create a 40×30 map with 64×64px tiles. These are the dimensions I used for mine, but you can adjust them to make yours larger or smaller. Now, we can get to the layers.

Layers in a map are used to group elements of the same type, or rather elements that serve the same purpose, together. In our game, we’ll have 7 layers :

  • Background : As the name suggests, the background contains paths and greenery etc., basically anything the player can walk on. You can use an image for this or design it manually by placing tiles.

  • Boundaries : This layer will contain the tiles lining the edge of the map to prevent the player going out of the screen. Also, it will have the solid objects like trees, rocks etc. , that the player cannot walk on.

  • Collectibles : This layer will contain gems that the player can pick up along the way.

  • Enemies : We’ll position the enemies throughout the map and put the attack logic in our code.

  • Hazards : Traps or obstacles that can harm the player.

  • Magic Objects : This layer will store the position of the 5 magic objects the player will have to collect in the game.

  • Portal : The portal is the gateway to crystal castle (the game destination). This layer will only contain 1 element and that is the portal placed at the end of the map.

The reason I’ve separated these elements into layers is because each layer will be loaded as objects of its corresponding class in my code later on. For example, all gems will be objects of the Collectible class. Remember, these layers should be TILE layers.

This is what your background should somewhat look like at this point. On top of this, we’ll then place our objects in their corresponding layers.

To add boundaries, hazards, collectibles etc., you will first have to import their images as a tile set. You can do this by heading over to file and creating a new tile set, selecting the image of your choice as the source. Once you have these tile sets ready, you can start placing them all over the map.

Now the only thing left to do is to export this map so we can use it in our code. To do this, we’ll export the background as an image separately and the rest of the layers together as a .tmj file.

Project setup

To start setting up the project, create two folders, one to store level information and the other for assets, such as the sprite sheets. The directory should look something like this :

In this part we’ll deal only with level.py, map_loader.py and main.py so you can ignore the rest for now. But first, remember to import all the image files you used to create your tile sets in Tiled into your assets or levels folder. My tile sets are stored in levels (this will be important later on). The main file is fairly straightforward, we import the libraries we need along with the Level class from our level.py file, and initialize the game loop.

import sys, pygame
from pygame import *
from level import Level

class Game:
    def __init__(self):
        pygame.init()
        self.screen = pygame.display.set_mode((1000, 500))
        self.clock = time.Clock()
        self.level = Level(self.screen)

    def run(self):
            while True:
                for evt in event.get():
                    if evt.type == QUIT:
                        quit()
                        sys.exit()
                self.level.run() #we'll write this function later
                pygame.display.update()
                self.clock.tick(60)

if __name__ == '__main__':
    game = Game()
    game.run()

Next, we can start writing our level class. This will initialize two sprite groups, visible sprites, and obstacle sprites. As the names suggest, visible sprites contains all the sprites that will get drawn on the screen whereas obstacle sprites are the sprites we will detect player collision with.

from pygame import *
from map_loader import Map

class Level:
    def __init__(self, screen):
        self.screen = screen
        #visible sprites is the only group that I'll draw on the screen. Obstacles are sprites that can collide with the player
        self.visible = Camera()
        self.obstacles = Camera()
        self.attackable = sprite.Group()
        self.hidden = sprite.Group()
        self.player = None #we havent made the player yet
        self.map = Map(self.visible, self.obstacles, self.player, self.attackable, self.hidden) #we'll do this next
        self.ui = UI()


    def run(self):
        self.visible.draw(self.player)
        self.visible.update()
        self.hidden.update() #update groups
        return

You will notice that both visible and obstacle are not initialized as sprite groups, but as instances of the Camera class. This is because I wanted to create the illusion of a camera following the player, and to do that you need to move everything else around the player in a way that centers it. We can accomplish this by offsets.

In level.py, create another class called Camera that inherits from Pygame’s sprite group.

class Camera(sprite.Group):
    def __init__(self):
        super().__init__() #initialize the parent class of sprite.Group()
        self.screen = display.get_surface()
        self.offset = math.Vector2()
        self.background = image.load('levels\\level_1\\level_data\\background.png').convert()
        #this is the background we exported as an image from tiled
        self.bg_rect = self.background.get_rect(topleft = (0,0))

    def draw(self, player): #overwriting the inbuilt draw function
        #To create the illusion of a camera following the player, I add an offset to the position of the sprites surrounding the player based off the player's position
        #2528 = (64*40 => size of 1 tile x no. of horizontal tiles) - 64//2 (tilesize//2)
        if player.rect.x < 2528 - WIDTH//2: #right out of bounds
            self.offset.x = player.rect.centerx - (self.screen.get_width()//2) #to center the player amidst the camera
        #1888 = (64*30 => size of 1 tile x no. of vertical tiles) - 64//2 (tilesize//2)
        if player.rect.y < 1888 - HEIGHT//2: #bottom out of bounds
            self.offset.y = player.rect.centery - (self.screen.get_height()//2)

        if player.rect.x < WIDTH//2: #left. if the player moves left past half of the current screen, there's no offset otherwise we get a bit of the black screen
            self.offset.x = 0
        if player.rect.y < HEIGHT//2: #top
            self.offset.y = 0
        #create the background here so its always below the sprites
        self.screen.blit(self.background, (self.bg_rect.topleft - self.offset))
        for sprite in self.sprites():
            offset_pos = sprite.rect.topleft - self.offset
            self.screen.blit(sprite.image, offset_pos)

Now, you have your player centered amidst the screen. However, you don’t have a player yet, so this won’t work. In order to see what we have built so far, you can use a placeholder image for self.player.

Loading the map

Finally, we get to the main part, or at least the one I struggled with quite a bit. But, before we get to it, you’ll need to initialize empty classes for enemy, player, hazard, magic object and portal, along with a general class ‘Tile’ which’ll act as the base for any tile on our map. We’ll use the following template for all these classes-

from pygame import *

class Tile(sprite.Sprite):
    def __init__(self, pos, groups, type, surface = surface.Surface((TILESIZE, TILESIZE), SRCALPHA)):
        super().__init__(groups) #adds the sprite to the given groups. 
        self.sprite_type = type  
        self.image = surface
        self.rect = self.image.get_rect(topleft = pos)
        self.mask = mask.from_surface(self.image) #I'll explain this later on

Now, in the main file, create a class called Map and initialize self.visible, self.obstacles, and self.hidden with the passed-in params. If you open up your map’s .tmj file, you’ll see that it contains JSON code, which we’ll need to read properly using the python JSON module.

Next, we’ll need to create 3 functions. The first will format the JSON code into a 2D array containing the locations (GIDs) of objects in each layer. The second, will create a tile set in the form of a dictionary, associating each tile’s GID (global index) to its corresponding tile image (our json file kinda does this already, but it associates each GID to its tile set file which is a .tmx file, we’ll clean this up so that it actually renders in pygame). The third will utilize these functions to create the actual map.

Let’s start with the first one.

 def format(self,data):
        data_formatted = []
        c = 0
        for i in range(30): #30 rows in 1 column
            row = data[c:40+c] #40 columns in 1 row
            data_formatted.append(row)
            c += 40
        return data_formatted

In our tmj file, the data for each layer isn’t stored as a 2d array of 30 rows and 40 columns, but instead as 1 massive 1D array of 1200 values. This function breaks it down into a 2D array with the same dimensions as our map.

Then, we need to write a function to create the tile set within python :

def create_tileset(self, data):

        def image_path(path):
            return f"levels\\level_1\\tilesets\\{path.split('/')[2].split('.')[0]}.png"

        tileset = {} 
        for tile in data:
            tileset[tile['firstgid']] = image_path(tile['source'])

        return tileset

Okay I lied, its actually 1 nested function, but its pretty simple nonetheless. Let’s breakdown create_tileset() first. Our .tmj file contains an object literal (similar to a dictionary in python) where one of the keys is ‘tilesets’. It looks something like :

"tilesets":[
        {
         "firstgid":1,
         "source":"..\/tilesets\/forest.tsx"
        },
        ... ]

Each tile set as you can see contains a ‘first GID’ and a ‘source’ which is the path to the tileset’s .tsx file in Tiled. Our loop creates a python dictionary setting this firstgid as a key and the source as its corresponding value after formatting it properly using image_path(), which creates a path to the tile’s image file by taking in the path to its .tsx file. In the tileset above, for example, image_path() will take in the source "..\/tilesets\/forest.tsx", remove the part after / and before .tsx which is forest, aka, the name of the tile, and position it between “levels\level_1\tilesets\ .png” which is where its image is stored. You can adjust this location according to where your tilesets are stored.

Finally, we get to the main function. You’ll need to initialize empty arrays for each of the layers, so we can extract information from our tiled file into them. Then, we’ll use the json module to actually load the file and read information from it, like so :

def create_map(self):
        magic =  ['key', 'amulet', 'potion', 'cheese', 'scroll'] #the 5 magical objects we placed around the map
        shuffle(magic) #randomize the positions everytime 
        boundary = []
        collectible = []
        hazard = []
        enemy = []
        portal = []
        magic_obj = []
                    #path to your .tmj file
        with open('levels\\level_1\\level_data\\level1_3.tmj', 'r') as file:
            data = json.load(file)
            self.tileset = self.create_tileset(data['tilesets'])

            for layer in data['layers']:
                if layer['name'] == "Boundaries":
                    boundary = layer['data']
                if layer['name'] == "collectible":
                    collectible = layer['data']
                if layer['name'] == 'enemies':
                    enemy = layer['data']
                if layer['name'] == 'hazards':
                    hazard = layer['data']
                if layer['name'] == 'portal':
                    portal = layer['data']
                if layer['name'] == 'magic_objects':
                    magic_obj = layer['data']
        layers = {
                     'boundaries' : self.format(boundary),
                     'collectibles' :   self.format(collectible),
                     'enemies' :   self.format(enemy),
                     'hazards' :   self.format(hazard),
                     'portal' :   self.format(portal),
                     'magic_objs' :   self.format(magic_obj)
                    }

Make sure to match the names of the layers exactly to how they are in your json file, or you won’t be reading in any data. Now, we can loop over each layer in layers and create objects for each tile.

#the following loop iterates over all the layers inside my map, and creates an instance of the appropriate class based
        #on what the layer contains. for example, the portal is an instance of the Portal class. 
        #based on whether or not i want them to show up on screen, i add them to the visible sprites group (self.visible) and all the obstacles
        #are in self.obstacles. 
        for layer, map in layers.items():
            for rind, row in enumerate(map):
                for cind, col in enumerate(row):
                    if col != 0:
                        i = 0
                        x = cind * TILESIZE
                        y = rind * TILESIZE
                        if layer == "boundaries":
                            if col in self.tileset.keys():
                                surf = image.load(f'{self.tileset[col]}').convert_alpha()
                                Tile((x,y), [self.visible, self.obstacles], 'boundary',surf) 
                        if layer == "collectibles":
                            if col in self.tileset.keys():
                                surf = image.load(f'{self.tileset[col]}').convert_alpha()
                                #y + 64-16 because the images are 16x16px not 64x64
                                Tile((x,y+52), [self.visible, self.obstacles], 'collectible',surf)
                        if layer == "hazards":
                            if col in self.tileset.keys():
                                surf = image.load(f'{self.tileset[col]}').convert_alpha()
                                #y + 64-16 because the images are 16x16px not 64x64
                                Hazard((x,y+32), [self.visible, self.obstacles], 'hazard',surf)
                        if layer == "enemies":
                            if col in self.tileset.keys():
                                #y + 64-16 because the images are 16x16px not 64x64
                                enemy = Enemy((x,y+52), [self.visible, self.obstacles], 'enemy',self.tileset[col], self.player, self.obstacles)

                        if layer == "portal":
                            if col in self.tileset.keys():
                                surf = image.load(f'{self.tileset[col]}').convert_alpha()
                                #y + 64-16 because the images are 16x16px not 64x64
                                Portal((x,y+52), [self.visible, self.obstacles], 'portal',surf)

                        if layer == "magic_objs":
                            print(col)
                            surf = image.load(f'assets\\magic_objs\\{magic[col-1]}.png').convert_alpha()
                            #y + 64 because the images are 16x16px not 64x64
                            magic_object((x,y), [self.hidden], f'{magic[col-1]}',surf)

You’ll notice that magic objects are not added to either of self.visible or self.obstacles, but rather to self.hidden. This is because we’ll reveal them with a special command, and then make them visible and collide able. Therefore they are hidden for the time being.

Now another interesting thing, is that enemies are not passed in the image as a surface, rather they are passed the path directly. This is because the enemies are animated, and their image is actually a spritesheet, not a single image. Later when we write our enemy class, we’ll write a function to break down this spritesheet and add animations but you can ignore that for now.

Let me explain what this line does : surf = image.load(f'{self.tileset[col]}').convert_alpha()

Remember how we created a tileset corresponding firstgids to sources earlier? well, we use that same tileset to access the image path at ‘col’ , the value of the layer’s 2D array at that index, and we know this array contains GIDs of the tiles, so all we’re doing is getting the image for that specific tile on the map and passing it into the tile’s object to draw on the screen.

Conclusion

I know this seems like a lot to take in but just experiment with the data file and you’ll see why and how our functions work. In the meantime, try working on animating the player and enemies yourself.
See you in part 2, happy coding!