Pre-Order Kelboy 2.0 Kit

Ayuda al proyecto y aprovéchate temporalmente del código de descuento GBZEROKELBOY del 10% en pre-orders
kelboy research

Making videogames with pygame - Part 2

In the first part, we learned, as an introduction, the basic foundations of a library like pygame and the power that a language like python can bring to video game programming. This time we will take action by performing the second part of the video game step by step in which the character is placed in an environment and interacts with him by following some simple steps.

Advance
5


Let's start with real world

These lessons are intended to be learned without going into the previous part. Anyway if you are not familiar with this manual you should look at the following link

If you have already gone through that link you will be able to remember that we learned to draw a background, a cursor and interact with it from the main.py according to the keyboard.

This is not bad to start with, because it gives us a top-down view of what is being programmed in a tutorial, but we are learning to understand all the parts and get it right.

Like all the programs that are growing, we have to structure our code well from the beginning, since it can end up causing us to make our program unmanageable.

We are going to transform our main into something as simple as the following:

import os,sys
import pygame

from data.main import main

if __name__=='__main__':

    main()
    pygame.quit()
    sys.exit()

As you can see there is no color, it is clean and we lack those parts that when opening the program harass us with lines and lines of code, although it is nothing more than masking our code in a structure that will be called from our main script in data / main.py with a main () method.


The main is the beginning of everything

We need to create a functional skeleton so that our video game can be programmed modally and efficiently.

Our main will call this controller and from there it will load the screens that we are going to program.

Each screen has a programmed behavior, so we can generate different screens.

from data import setup,tools
from data.states import main_menu,load_screen,first_level
import profile

def main():
    run = tools.Control(setup.ORIGINAL_CAPTION)
    state_dict = {
        "MAIN_MENU": main_menu.Menu(),
        "LOAD_SCREEN": load_screen.Load_Screen(),
        "FIRST_LEVEL": first_level.First_Level()
    }
    run.setup_states(state_dict,"MAIN_MENU")
    run.main()

What the code does is load a controller (script tools, Control class) that has the following structure:

class Control(object):

    #builder for init vars like fonts and captions
    def __init__(self, caption):
        pass

    #control input states after builer
    def setup_states(self, state_dict, start_state):
        pass

    #decide which step is next
    def update(self):
        pass

    #input events
    def event_loop(self):
        for event in pg.event.get():
            pass

    #main loop
    def main(self):
        while not self.done:
            pass

Let's say that our main is capable of calling this class, which controls as an interceptor what it should execute at each step.

It has a structure that defines a series of important parts that control the main loop (main with while True), the events (event_loop) the status update (update) that is responsible for saying what should be executed next (for example, show a screen or go to the next).

In our first example we can see that this Control class (filled in) manages the loading screen from the menu. This is useful in case we had to download or perform heavier tasks (reading a large data file, loading memory resources, downloading files from the internet, waiting for another player to connect to the network ...)

In the following github checkout you can find the following:

Image

We have transformed our code that we had in the branch of lesson-01 that was in the main script to one script for each of the screens that we have, and we have generated two others (a loading screen and the first level) :

  • states/first_level.py
  • states/load_screen.py
  • states/main_menu.py

Our new main_menu has to look like this:

import pygame
import os
from data import setup, tools
from data.constants import *

class Menu(tools._State):
    def __init__(self):
        tools._State.__init__(self)
        self.refresh = True
        self.next = "LOAD_SCREEN"
        self.surface = pygame.Surface(SCREEN_SIZE)
        self.rect = self.surface.get_rect()
        text = 'Main Menu placeholder'
        self.font = pygame.font.Font(setup.FONTS['Fixedsys500c'], 15)
        self.rendered_text = self.font.render(text, 1, BLACK)
        self.text_rect = self.rendered_text.get_rect()
        self.text_rect.center = self.rect.center

        self.menuX = 225
        #this part will decide which coordinate (just Y axe)
        self.lastMenuY = 405
        self.firstMenuY = 360
        self.menuY = self.firstMenuY

        self.screen = pygame.display.set_mode([SCREEN_WIDTH,SCREEN_HEIGHT])
        self.backdrop = pygame.image.load(BACKGROUND_PATH).convert()
        self.mushroom = pygame.image.load(MUSHROOM_PATH).convert()
        self.backScreen = self.screen.get_rect()


    def update(self, surface, keys, current_time):
        if self.refresh:
            self.backdrop = pygame.image.load(BACKGROUND_PATH).convert() #refresh with background image
            self.backdrop.blit(self.mushroom,(self.menuX,self.menuY))
            self.refresh = False

        #draw part
        self.screen.blit(self.backdrop, self.backScreen)
        pygame.display.flip()


    def get_event(self, event):
        if event.type == pygame.QUIT:
            self.done = True

        #push keyboard button event
        elif event.type == pygame.KEYDOWN:
            if event.key == pygame.K_LEFT or event.key == ord('a'):
                self.menuY = self.lastMenuY if self.menuY==self.firstMenuY else self.firstMenuY
                self.refresh = True
            elif event.key == pygame.K_RIGHT or event.key == ord('d'):
                self.menuY = self.lastMenuY if self.menuY==self.firstMenuY else self.firstMenuY
                self.refresh = True
            elif event.key == pygame.K_UP or event.key == ord('w'):
                self.menuY = self.lastMenuY if self.menuY==self.firstMenuY else self.firstMenuY
                self.refresh = True
            elif event.key == pygame.K_DOWN or event.key == ord('s'):
                self.menuY = self.lastMenuY if self.menuY==self.firstMenuY else self.firstMenuY
                self.refresh = True

        # release key q for exit
        elif event.type == pygame.KEYUP:
            if event.key == ord('q'): #exit
                self.done = True

As you can see the code has been significantly reduced. With this we managed to atomize our code and as an advantage to know where we are going to find ourselves on different screens.

It should be noted as a difference that the constructor is the place where I color both the title and the next screen (self.next) to load, from the screens that stayed in the constructor of the new main (and that we have passed to our Control that does controller tasks)

We have also created a simple and intermediate page (loading ...) that in the future could extend its functionality while waiting for someone to connect a controller or connect over the internet, anyway so that you can see how simple a Screen is attached as follows:

import pygame as pg
from data import setup, tools
from data.constants import *


class Load_Screen(tools._State):
    def __init__(self):
        tools._State.__init__(self)

    def startup(self, current_time, persistant):
        self.next = "LEVEL1"
        self.surface = pg.Surface(SCREEN_SIZE)
        self.rect = self.surface.get_rect()
        text = "Cargando..."
        self.font = pg.font.Font(setup.FONTS['Fixedsys500c'], 15)
        self.rendered_text = self.font.render(text, 1, BLACK)
        self.text_rect = self.rendered_text.get_rect()
        self.text_rect.center = self.rect.center

    def update(self, surface, keys, current_time):
        self.current_time = current_time
        surface.fill(BACKGROUND_BLUE)
        surface.blit(self.rendered_text, self.text_rect)

        if (self.current_time - self.start_time) > 4000:
            self.done = True


    def get_event(self, event):
        if event.type == pg.KEYDOWN:
            self.done = True

A couple of events have been added so that the user can continue pressing any key on the next screen. As you can see, we would go to the next screen (self.next = next_screen or state).

import pygame as pg
from data import setup, tools
from data import constants as c
from data.components import mario

class First_Level(tools._State):

    def __init__(self):
        tools._State.__init__(self)

    def startup(self, current_time, persistant):
        self.setup_ground()
        self.mario = mario.Mario()
        self.setup_mario_location()
        self.all_sprites = pg.sprite.Group(self.mario)

    def setup_mario_location(self):
        self.mario.rect.x = 10
        self.mario.rect.bottom = c.SCREEN_HEIGHT - self.mario.rect.height

    def setup_ground(self):
        pass

    """
    Updates level
    """
    def update(self, surface, keys, current_time):
        self.current_time = current_time
        setup.SCREEN.fill(c.BGCOLOR)
        self.all_sprites.update(keys, current_time)
        self.all_sprites.draw(surface)

On this screen we add the figure of Mario (component):

import pygame as pg
from data import setup, tools
from data.constants import *

class Mario(pg.sprite.Sprite):

    def __init__(self):
        pg.sprite.Sprite.__init__(self)
        self.sprite_sheet = setup.GFX['mario_bros']

        self.right_frames = []
        self.left_frames = []
        self.frame_index = 0
        self.x_vel = 0
        self.y_vel = 0
        self.max_x_vel = 4
        self.x_accel = .15
        self.max_y_vel = 4
        self.gravity = GRAVITY
        self.load_from_sheet()

        self.state = STAND
        self.image = self.right_frames[self.frame_index]
        self.rect = self.image.get_rect()

        self.facing_right = True
        self.walking_timer = 0


    def update(self, keys, current_time):
        self.handle_state(keys, current_time)
        self.update_position()
        self.animation()


    def update_position(self):
        self.rect.x += self.x_vel
        self.rect.y += self.y_vel


    def handle_state(self, keys, current_time):
        if self.state == STAND:
            self.standing(keys, current_time)
        elif self.state == WALK:
            self.walking(keys, current_time)
        elif self.state == JUMP:
            self.jumping(keys, current_time)
        elif self.state == FALL:
            self.falling(keys)


    def animation(self):
        if self.facing_right:
            self.image = self.right_frames[self.frame_index]
        else:
            self.image = self.left_frames[self.frame_index]


    def get_image(self, x, y, width, height):
        image = pg.Surface([width, height]).convert()
        rect = image.get_rect()

        image.blit(self.sprite_sheet, (0, 0), (x, y, width, height))
        image.set_colorkey(BLACK)
        image = pg.transform.scale(image,
                                   (int(rect.width*SIZE_MULTIPLIER),
                                    int(rect.height*SIZE_MULTIPLIER)))
        return image


    def load_from_sheet(self):
        self.right_frames.append(
            self.get_image(178, 32, 12, 16)) #right
        self.right_frames.append(
            self.get_image(80,  32, 15, 16)) #right walking 1
        self.right_frames.append(
            self.get_image(99,  32, 15, 16)) #right walking 2
        self.right_frames.append(
            self.get_image(114, 32, 15, 16)) #right walking 3
        self.right_frames.append(
            self.get_image(144, 32, 16, 16)) #right jump
        self.right_frames.append(
            self.get_image(130, 32, 14, 16)) #right skid

        #The left image frames are numbered the same as the right
        #frames but are simply reversed.

        for frame in self.right_frames:
            new_image = pg.transform.flip(frame, True, False)
            self.left_frames.append(new_image)


    """
    This function is called if Mario is standing still
    """
    def standing(self, keys, current_time):

        self.frame_index = 0

        if keys[pg.K_LEFT]:
            self.facing_right = False
            self.state = WALK
        elif keys[pg.K_RIGHT]:
            self.facing_right = True
            self.state = WALK
        elif keys[pg.K_a]:
            self.state = JUMP
            self.y_vel = -10
        else:
            self.state = STAND


    """
    This function is called when Mario is in a walking state
    It changes the frame, checks for holding down the run button,
    checks for a jump, then adjusts the state if necessary
    """
    def walking(self, keys, current_time):

        if self.frame_index == 0:
            self.frame_index += 1
            self.walking_timer = current_time
        else:
            if (current_time - self.walking_timer >
                    self.calculate_animation_speed()):
                if self.frame_index < 3:
                    self.frame_index += 1
                else:
                    self.frame_index = 1

                self.walking_timer = current_time


        if keys[pg.K_s]:
            self.max_x_vel = 6
        else:
            self.max_x_vel = 4


        if keys[pg.K_a]:
            self.state = JUMP
            self.y_vel = -10


        if keys[pg.K_LEFT]:
            self.facing_right = False
            if self.x_vel > 0:
                self.frame_index = 5
            if self.x_vel > (self.max_x_vel * -1):
                self.x_vel -= self.x_accel

        elif keys[pg.K_RIGHT]:
            self.facing_right = True
            if self.x_vel < 0:
                self.frame_index = 5
            if self.x_vel < self.max_x_vel:
                self.x_vel += self.x_accel


        else:
            if self.facing_right:
                if self.x_vel > 0:
                    self.x_vel -= self.x_accel
                else:
                    self.x_vel = 0
                    self.state = STAND
            else:
                if self.x_vel < 0:
                    self.x_vel += self.x_accel
                else:
                    self.x_vel = 0
                    self.state = STAND


    def jumping(self, keys, current_time):
        self.frame_index = 4
        self.y_vel += self.gravity
        if (self.rect.bottom > (600 - self.rect.height)):
            self.y_vel = 0
            self.state = WALK


        if keys[pg.K_LEFT]:
            self.facing_right = False
            if self.x_vel > (self.max_x_vel * - 1):
                self.x_vel -= self.x_accel

        elif keys[pg.K_RIGHT]:
            self.facing_right = True
            if self.x_vel < self.max_x_vel:
                self.x_vel += self.x_accel



    def calculate_animation_speed(self):
        if self.x_vel == 0:
            animation_speed = 115
        elif self.x_vel > 0:
            animation_speed = 115 - (self.x_vel * 12)
        else:
            animation_speed = 115 - (self.x_vel * 12 * -1)

        return animation_speed

This is where the greatest complexity lies, Mario belongs to the 2d platforms genre so he has a series of physics (times, positions and speeds for a certain action), states (walking, jumping, falling and remaining still), animations (effect to its left and right), as well as its appearance as files (image succession or sprite_sheet).

In the next part of the learning we will complete the behaviors, but with this load we can recreate the effect of walking, running, jumping and turning around on our screen.

También veremos las limitaciones que tiene, ya que no interactúa con ninguna parte del escenario (es un fondo azul) y que la cámara se mantiene fija mostrando una sección del escenario en la que Mario puede no estar.


Turning our program into a true video game

In this part of the programming of our video game we are going to assemble in a simple way code that allows us to understand, in an agile and effective way, a complete video game to which we can add elements with which to interact, a stage, a camera, and more precise behaviors

For this part you can already use the last code of the checkout of the branch lesson-02.
In this part we have to remember the parts that we have programmed in the previous steps, components (Mario), states (First Level).
At the moment we will focus on these two and add some more component with which to interact in our game.

Environment

Our "First Level" was basically nothing, a blue and empty stage painted the color of the sky.
We want to play on a totally more open stage, so we need to bring it to life.
We could do two things:
  • Program each component that appears in our video game (such as more "Mario" s).
  • Save time and use a background image on our stage.

For our part, since we want to understand how to program a video game in a simple way, we will use an image where our stage is painted:

Image

It may seem to someone that this should not be so simple, and he is right, this image occupies the astronomical figure of 19.7 kb of memory. That in 2020 is not a problem since it is very easy to find devices that have more memory than this, and we are programming a video game in python, even current microproython microcontrollers have enough memory to load an image of this size (relatively small for the little compression it has).

There was a time when each byte of memory needed to be saved so that the program could load into memory, and even create algorithms to draw elements that could not fit, so each image had to be painted and managed with a function that occupied the least possible number of bytes. In future more complex tutorials such as a 3d video game in which the stage is loaded dynamically and destroyed as you go along, you will be taught how to implement a stage in that way (the most efficient, but the most burden on the processor). With this we would be able to dynamically paint elements in programmed positions (we will do it later in this same video game). The important thing in this lesson is to learn to program video games in a simple way, understanding the basic concepts step by step, applying the concepts necessary to understand the entire "puzzle".

Well, with this we have an image (much larger than the screen) that we can load as the background of our game. But we need to adjust the proportions. The aspect ratio of this image with the screen (800x600) is 1: 2,679, if we were to use a different image we would have to adjust that image to the current size.

We are working on the First_Level class (data / states / first_level.py script), which extends from _State (a class that acts as abstract):

class _State(object):
    def __init__(self):
        self.start_time = 0.0
        self.current_time = 0.0
        self.done = False
        self.quit = False
        self.next = None
        self.previous = None
        self.persist = {}

    def get_event(self, event):
        pass

    def startup(self, current_time, persistant):
        self.persist = persistant
        self.start_time = current_time

    def cleanup(self):
        self.done = False
        return self.persist

    def update(self, surface, keys, current_time):
        pass

In this class we can understand that there are 4 quite simple methods and a constructor, but they indicate the structure that our "level" First_Level must have. The following code explains a simple behavior to be interpreted by our "Control":

class First_Level(tools._State):

    def __init__(self):
        tools._State.__init__(self)

    def startup(self, current_time, persistant):
        self.persistant = persistant
        self.background = setup.GFX['first_level']
        self.back_rect = self.background.get_rect()
        self.back_rect.x = 0
        self.back_rect.y = 0
        self.background = pg.transform.scale(self.background,
                                   (int(self.back_rect.width * BACK_SIZE_MULTIPLER),
                                    int(self.back_rect.height * BACK_SIZE_MULTIPLER)))
        self.mario = mario.Mario()
        self.setup_mario_location()
        self.all_sprites = pg.sprite.Group(self.mario)
        self.camera_adjust = 0

    def setup_mario_location(self):
        self.mario.rect.x = 110
        self.mario.rect.bottom = GROUND_HEIGHT

...

Something as simple as this allows us to load our background:

setup.GFX['first_level']

That at the time of starting our program has gone through our script data / setup.py that contains a line:

GFX = tools.load_all_gfx(os.path.join("resources","graphics"))

What does this line do? Something as simple as the following (script data / tools.py):

def load_all_gfx(directory, colorkey=(255,0,255), accept=('.png', 'jpg', 'bmp')):
    graphics = {}
    for pic in os.listdir(directory):
        name, ext = os.path.splitext(pic)
        if ext.lower() in accept:
            img = pg.image.load(os.path.join(directory, pic))
            if img.get_alpha():
                img = img.convert_alpha()
            else:
                img = img.convert()
                img.set_colorkey(colorkey)
            graphics[name]=img
    return graphics

With this what we achieve is to modally load all the images (png, jpg and bmp) that are in "resources / graphics" with pygame (pygame.image.load) to be able to work directly with them from an array of easy accessibility.

As you have been able to verify, this happens with any type of resources, since in tools there are more functions and in setup they are invoked for the different load of resources that we will use later:

FONTS = tools.load_all_fonts(os.path.join("resources","fonts"))
MUSIC = tools.load_all_music(os.path.join("resources","music"))
GFX   = tools.load_all_gfx(os.path.join("resources","graphics"))
SFX   = tools.load_all_sfx(os.path.join("resources","sound"))
MAP   = tools.load_all_resources(os.path.join("resources","maps"))

Well, once this part is understood, in the constructor of First_Level we can find the following line:

self.background = pg.transform.scale(self.background,
  (
    int(self.back_rect.width * BACK_SIZE_MULTIPLER),
    int(self.back_rect.height * BACK_SIZE_MULTIPLER))
  )

This line is what turns our enormous image into the ideal proportion to be able to play with our surroundings.

The next part we should look at is the camera. We need an element that chases Mario and does not let it go back, we will call this camera, capable of moving forward and not going back so that it does not load parts that Mario has already been in:

def camera(self):
        if self.mario.rect.right > SCREEN_WIDTH / 3:
            self.camera_adjust = self.mario.rect.right - SCREEN_WIDTH / 3
        else:
            self.camera_adjust = 0

        self.back_rect.x -= self.camera_adjust
        for sprite in self.all_sprites:
            sprite.rect.x -= self.camera_adjust

With this we get the camera to adjust to Mario's position (when Mario reaches 1/3 of the screen):

Image


Interact with the elements around us

Sprites

In the language of the video game programmer all the visible elements or objects that interact with video games are called "sprites".
Some sprites interact with the user causing them to crash and stop, others simply appear in the background, such as the clouds or coins that Mario takes out when colliding with a box (with which he does interact).
These shocks have a verb, collide (to crash in English), so we can manage this behavior dynamically if we generate a component called Collider:
import pygame as pg
from data.constants import *

class Collider(pg.sprite.Sprite):
    def __init__(self, x, y, width, height):
        pg.sprite.Sprite.__init__(self)
        self.image = pg.Surface((width, height)).convert()
        self.image.set_alpha(0)
        #self.image.fill(RED)
        self.rect = self.image.get_rect()
        self.rect.x = x
        self.rect.y = y

This dynamic component has coordinates and a size (like any sprite).

Depending on whether we want to see it or not we can put a color:

Image

If we are sure that its position is correct, we simply set the alpha to 0 and eliminate the fill color:

Image

But it is not enough, we need pygame to make our "sprite" collide "with any object, for this we must create on our screen" First_Level "the elements of type Collider (sprites) and check if they collide with the" pygame "function. sprite.spritecollideany ".

This function is who decides whether to perform the magic or not, with this we can adjust Mario's position (since he is the one who interacts with our environment, and in this case with the Collider).

An example of collider is the following (ground):

self.ground_rect1 = collider.Collider(0, GROUND_HEIGHT,    2940, 60)
self.ground_rect2 = collider.Collider(3040, GROUND_HEIGHT,  629, 60)
self.ground_rect3 = collider.Collider(3811, GROUND_HEIGHT, 2724, 60)
self.ground_rect4 = collider.Collider(6631, GROUND_HEIGHT, 2992, 60)
self.ground_group = pg.sprite.Group(self.ground_rect1, self.ground_rect2, self.ground_rect3, self.ground_rect4)

With this we will have grouped in self.ground_group all the colliders that make Mario not fall to the ground, but we have programmed the positions in which Mario should fall to activate a behavior such as dying or restarting the level.

The one in charge to verify this is the function update. If you remember the previous step, our Control calls the update function until it exits the level, which in the case of First_Level should have a code such as:

def update(self, surface, keys, current_time):
    self.current_time = current_time
    self.all_sprites.update(keys, current_time)
    self.update_mario_position(keys)
    self.camera()
    surface.blit(self.background, self.back_rect)
    self.all_sprites.draw(surface)
    self.step_group.draw(surface)

Here we have the invocation of the two functions that interest us, camera and update_mario_position:

def camera(self):
    if self.mario.rect.right > SCREEN_WIDTH / 3:
        self.camera_adjust = int(self.mario.rect.right - SCREEN_WIDTH / 3)
    else:
        self.camera_adjust = 0
    self.back_rect.x -= self.camera_adjust
    for collider in self.collide_group:
        collider.rect.x -= self.camera_adjust
    for sprite in self.all_sprites:
        sprite.rect.x -= self.camera_adjust


def update_mario_position(self, keys):
    self.mario.rect.y += self.mario.y_vel
    collider = pg.sprite.spritecollideany(self.mario, self.collide_group)
    if collider:
        if self.mario.y_vel > 0:
            self.mario.y_vel = 0
            self.mario.rect.bottom = collider.rect.top
            self.mario.state = WALK
    else:
        try:
            test_sprite = copy.deepcopy(self.mario)
        except:
            test_sprite = self.mario.copy() #TODO see where/how python3 miss decimals
            pass
        test_sprite.rect.y += 1
        if not pg.sprite.spritecollideany(test_sprite, self.collide_group):
            if self.mario.state != JUMP:
                self.mario.state = FALL
    self.mario.rect.x += self.mario.x_vel
    collider = pg.sprite.spritecollideany(self.mario, self.collide_group)
    if collider:
        if self.mario.x_vel > 0:
            self.mario.rect.right = collider.rect.left
        else:
            self.mario.rect.left = collider.rect.right
        self.mario.x_vel = 0
    if self.mario.rect.y > SCREEN_HEIGHT:
        self.startup(keys, self.persistant)
    if self.mario.rect.x < 5:
        self.mario.rect.x = 5

The most important of the two is update_mario_position, it is the one who has the call to pygame.sprite.spritecollideany, which returns a flag that will indicate if the objects are touched or not.

The if part stops a fall so it stays on the X axis.

The else part checks the predictions from the next step, to correct the state.

Later we will change this part to add more complexity, in addition to that depending on how we collide with the element we can program the destruction of a block, a bad one, collect a coin or even lose a life.

The same goes for the camera, which will adjust it to stop and not advance, just as it must be corrected. One detail that I wanted to share from this part is that this tutorial is intended to be executed in both python2 and python3. One of the differences between these two different versions is that the divisions of the first round them to integers and those of the second do not, for this we must convert to integer with a parser int (float). This will avoid differences in the adjustment of the camera between versions of python.

Collinders not hardcoded

We want to separate the code part from the collinders part. The classic way is to hardcode the coordinates of all the elements with their coordinates. An example is the ground, as we have indicated previously (the ground of the screen) has 4 constructors, but when the number is much bigger it is worth thinking about a new system.
For this, on our side we have thought of mapping the steps and pipes in a json that has the 4 values (x, y, width, height) to create them dynamically with very few lines:
def setup_pipes(self):
    pipes_path = setup.MAP['pipes']
    with open(pipes_path) as json_file:
       collinders = json.load(json_file)
       self.pipe_group = pg.sprite.Group()
        for col in collinders:
            x = col["x"]
            y = col["y"]
            width = col["width"]
            height = col["height"]
            element = collider.Collider(x, y, width, height)
            self.pipe_group.add(element)

def setup_steps(self):
    steps_path = setup.MAP['steps']
    with open(steps_path) as json_file:
        collinders = json.load(json_file)
        self.step_group = pg.sprite.Group()
        for col in collinders:
            x = col["x"]
            y = col["y"]
            width = col["width"]
            height = col["height"]
            element = collider.Collider(x, y, width, height)
            self.step_group.add(element)

And the pipes example (steps is bigger but it's the same syntax):

[{
        "x": 1202,
        "y": 452,
        "width": 83,
        "height": 82
    },
    {
        "x": 1631,
        "y": 409,
        "width": 83,
        "height": 140
    }, {
        "x": 1973,
        "y": 366,
        "width": 83,
        "height": 170
    },
    {
        "x": 2445,
        "y": 366,
        "width": 83,
        "height": 170
    }, {
        "x": 6989,
        "y": 452,
        "width": 83,
        "height": 82
    },
    {
        "x": 7675,
        "y": 452,
        "width": 83,
        "height": 82
    }
]

With this we get a cleaner and easier to maintain code (fewer lines of code).

The result

Image

I hope it was helpful, you have all the code in the company github and in the repository of learn the branch lesson-02