32blit

the embedded game development SDK

Episode 5: Final touches

Every game needs an objective, along with something which adds difficulty in completing the objective. With this in mind, in this episode we will allow the player to collect coins and gems, and once all the coins are collected, the next level can start. We will also add the ability for the player to die, either by touching an enemy or by falling off the bottom of the screen. Finally, we will add some polish to our game, in the form of visual feedback when the player dies or completes a level, along with adding more interest to the background.

Adding coins and gems

Collecting coins and gems

The first feature we will add is the ability for coins and gems to be collected if the player is touching them. In preparation for this, we will need to add some more values to our constants file.

// constants.hpp, inside Constants namespace

// Data for "Collectables" (gems and coins), such as value of each
namespace Collectable {
    // The coin and gem sprites are smaller in size
    const uint8_t SIZE = 4;
    // Calculate the gap between the edge of the sprite and the edge of the actual coin/gem
    const uint8_t BORDER = (SPRITE_SIZE - SIZE) / 2;

    // Point value of each
    const uint8_t COIN_SCORE = 2;
    const uint8_t GEM_SCORE = 5;
}
// constants.hpp, inside Constants namespace

// Data for "Collectables" (gems and coins), such as value of each
namespace Collectable {
    // The coin and gem sprites are smaller in size
    const uint8_t SIZE = 4;
    // Calculate the gap between the edge of the sprite and the edge of the actual coin/gem
    const uint8_t BORDER = (SPRITE_SIZE - SIZE) / 2;

    // Point value of each
    const uint8_t COIN_SCORE = 2;
    const uint8_t GEM_SCORE = 5;
}
# constants.py

# Data for "Collectables" (gems and coins), such as value of each
class Collectable:
    # The coin and gem sprites are smaller in size
    SIZE = 4
    # Calculate the gap between the edge of the sprite and the edge of the actual coin/gem
    BORDER = (SPRITE_SIZE - SIZE) // 2

    # Point value of each
    COIN_SCORE = 2
    GEM_SCORE = 5

We will need to create a function for coin and gem collision detection, which we will add to the Ninja class. It will be similar to the handle_platform and handle_ladder functions, although it only needs to be implemented in the PlayerNinja class. For this reason, the C++ code will declare it as virtual.

// ninja.hpp

class Ninja {
public:
    // ...

protected:
    // ...

private:
    // ...

    // Only implemented by PlayerNinja
    virtual void handle_scoring(Constants::LevelData& level_data, uint8_t x, uint8_t y);
}
// ninja.hpp

class Ninja {
public:
    // ...

protected:
    // ...

private:
    // ...

    // Only implemented by PlayerNinja
    virtual void handle_scoring(Constants::LevelData& level_data, uint8_t x, uint8_t y);
}
# ninja.py

import constants as Constants

class Ninja:
    # ...

    def handle_scoring(self, level_data, x, y):
        # Only implemented by PlayerNinja
        pass

For C++, the definition of the function in the Ninja class will be empty:

// ninja.cpp

void Ninja::handle_scoring(Constants::LevelData& level_data, uint8_t x, uint8_t y) {
    // Only implemented by PlayerNinja
}

First of all, we will declare the overriding function in the PlayerNinja class. We will also add a new attribute to the class called score, which we will use to keep track of the points the player has collected in the current level.

// player_ninja.hpp

class PlayerNinja : public Ninja {
public:
    // ...

private:
    void handle_scoring(Constants::LevelData& level_data, uint8_t x, uint8_t y);

    uint8_t score = 0;
};
// player_ninja.hpp

class PlayerNinja : public Ninja {
public:
    // ...

private:
    void handle_scoring(Constants::LevelData& level_data, uint8_t x, uint8_t y);

    uint8_t score = 0;
};
# player_ninja.py

from ninja import Ninja
import constants as Constants

class PlayerNinja(Ninja):

    def __init__(self, x, y):
        super().__init__(Ninja.Colour.BLUE, x, y)

        self.score = 0

    def handle_scoring(self, level_data, x, y):
        pass

The handle_scoring function will check if the player is colliding with a coin or gem, and remove it from the level array if so. It will then increase the current score by the point value of the item collected (these point values are declared in the constants file).

// player_ninja.cpp

void PlayerNinja::handle_scoring(Constants::LevelData& level_data, uint8_t x, uint8_t y) {
    // Calculate the position of tile in array
    uint8_t array_position = y * Constants::GAME_WIDTH_TILES + x;

    // Get tile's sprite index from level data
    uint8_t tile_id = level_data.extras[array_position];

    // Check the tile is a coin or gem
    if (tile_id == Constants::Sprites::COIN || tile_id == Constants::Sprites::GEM) {

        // Calculate the actual position of the tile from the grid position
        float tile_x = x * Constants::SPRITE_SIZE;
        float tile_y = y * Constants::SPRITE_SIZE;

        // Check if the ninja is colliding with the tile
        // We use a smaller object_size since the coins and gems are smaller, which also means we have to offset the tile_position
        if (check_colliding(tile_x + Constants::Collectable::BORDER, tile_y + Constants::Collectable::BORDER, Constants::Collectable::SIZE)) {

            // Add the correct amount of score if it's a coin or gem tile
            if (tile_id == Constants::Sprites::COIN) {
                score += Constants::Collectable::COIN_SCORE;
            }
            else if (tile_id == Constants::Sprites::GEM) {
                score += Constants::Collectable::GEM_SCORE;
            }

            // Remove item from level data
            level_data.extras[array_position] = Constants::Sprites::BLANK_TILE;
        }
    }
}
// player_ninja.cpp

void PlayerNinja::handle_scoring(Constants::LevelData& level_data, uint8_t x, uint8_t y) {
    // Calculate position of tile in array
    uint8_t array_position = y * Constants::GAME_WIDTH_TILES + x;

    // Get tile's sprite index from level data
    uint8_t tile_id = level_data.extras[array_position];

    // Check the tile is a coin or gem
    if (tile_id == Constants::Sprites::COIN || tile_id == Constants::Sprites::GEM) {

        // Calculate the actual position of the tile from the grid position
        float tile_x = x * Constants::SPRITE_SIZE;
        float tile_y = y * Constants::SPRITE_SIZE;

        // Check if the ninja is colliding with the tile
        // We use a smaller object_size since the coins and gems are smaller, which also means we have to offset the tile_position
        if (check_colliding(tile_x + Constants::Collectable::BORDER, tile_y + Constants::Collectable::BORDER, Constants::Collectable::SIZE)) {

            // Add the correct amount of score if it's a coin or gem tile
            if (tile_id == Constants::Sprites::COIN) {
                score += Constants::Collectable::COIN_SCORE;
            }
            else if (tile_id == Constants::Sprites::GEM) {
                score += Constants::Collectable::GEM_SCORE;
            }

            // Remove item from level data
            level_data.extras[array_position] = Constants::Sprites::BLANK_TILE;
        }
    }
}
# player_ninja.py

def handle_scoring(self, level_data, x, y):
    # Calculate position of tile in array
    array_position = y * Constants.GAME_WIDTH_TILES + x

    # Get tile's sprite index from level data
    tile_id = level_data.extras[array_position]

    # Check the tile is a coin or gem
    if tile_id == Constants.Sprites.COIN or tile_id == Constants.Sprites.GEM:

        # Calculate the actual position of the tile from the grid position
        tile_x = x * Constants.SPRITE_SIZE
        tile_y = y * Constants.SPRITE_SIZE

        # Check if the ninja is colliding with the tile
        # We use a smaller object_size since the coins and gems are smaller, which also means we have to offset the tile_position
        if self.check_object_colliding(tile_x + Constants.Collectable.BORDER, tile_y + Constants.Collectable.BORDER, Constants.Collectable.SIZE):

            # Add the correct amount of score if it's a coin or gem tile
            if tile_id == Constants.Sprites.COIN:
                self.score += Constants.Collectable.COIN_SCORE
            
            elif tile_id == Constants.Sprites.GEM:
                self.score += Constants.Collectable.GEM_SCORE

            # Remove item from level data
            level_data.extras[array_position] = Constants.Sprites.BLANK_TILE

Finally, we need to make the Ninja class call our new method from within its handle_collisions method:

// ninja.cpp

void Ninja::handle_collisions(Constants::LevelData& level_data) {
    // ...

    if (x < Constants::GAME_WIDTH_TILES && y < Constants::GAME_HEIGHT_TILES && position_x >= -Constants::Ninja::BORDER && position_y >= 0) {
        // ...

        for (uint8_t y_offset = 0; y_offset < (y == Constants::GAME_HEIGHT_TILES - 1 ? 1 : 2); y_offset++) {

            for (uint8_t x_offset = 0; x_offset < (x == Constants::GAME_WIDTH_TILES - 1 ? 1 : 2); x_offset++) {
                // ...
                
                // Handle scoring
                handle_scoring(level_data, new_x, new_y);
            }
        }
    }

    // ...
}
// ninja.cpp

void Ninja::handle_collisions(Constants::LevelData& level_data) {
    // ...

    if (x < Constants::GAME_WIDTH_TILES && y < Constants::GAME_HEIGHT_TILES && position_x >= -Constants::Ninja::BORDER && position_y >= 0) {
        // ...

        for (uint8_t y_offset = 0; y_offset < (y == Constants::GAME_HEIGHT_TILES - 1 ? 1 : 2); y_offset++) {

            for (uint8_t x_offset = 0; x_offset < (x == Constants::GAME_WIDTH_TILES - 1 ? 1 : 2); x_offset++) {
                // ...
                
                // Handle scoring
                handle_scoring(level_data, new_x, new_y);
            }
        }
    }

    // ...
}
# ninja.py

def handle_collisions(self, level_data):
    # ...

    if x < Constants.GAME_WIDTH_TILES and y < Constants.GAME_HEIGHT_TILES and self.position_x >= -Constants.Ninja.BORDER and self.position_y >= 0:
        # ...

        for y_offset in range(1 if y == Constants.GAME_HEIGHT_TILES - 1 else 2):

            for x_offset in range(1 if x == Constants.GAME_WIDTH_TILES - 1 else 2):
                # ...

                # Handle scoring
                self.handle_scoring(level_data, new_x, new_y)

    # ...

If you now run the code, you will see that the coins and gems disappear when the player comes into contact with them:

Displaying the score

You many notice that the “Score: 0” text doesn’t update - it’s still our placeholder text! We will need to create a way for the Level class to access the score attribute of the PlayerNinja class. We will do this by creating a getter function called get_score.

In Python, the score attribute isn’t private, so you could do player.score (without needing an extra method). In order to keep the code as similar as possible between languages, we will still create the getter function.

// player_ninja.hpp

class PlayerNinja : public Ninja {
public:
    // ...

    uint8_t get_score();

private:
    // ...
};

// player_ninja.cpp

uint8_t PlayerNinja::get_score() {
    return score;
}
// player_ninja.hpp

class PlayerNinja : public Ninja {
public:
    // ...

    uint8_t get_score();

private:
    // ...
};

// player_ninja.cpp

uint8_t PlayerNinja::get_score() {
    return score;
}
# player_ninja.py

class PlayerNinja(Ninja):
    # ...

    def get_score(self):
        return self.score

We can now use this function in the render method of the Level class in order to get the score, and with this prepare a string which can be rendered. Don’t forget to remove the old placeholder text which displayed “Score: 0”.

// level.cpp

void Level::render() {
    // ...

    // Render the score text
    std::string score_string = "Score: " + std::to_string(player.get_score());
    screen.text(score_string, minimal_font, Point(2, 2));
}
// level.cpp

void Level::render() {
    // ...

    // Render the score text
    std::string score_string = "Score: " + std::to_string(player.get_score());
    text(score_string, 2, 2);
}
# level.py

def render(self):
    # ...

    # Render the score text
    score_string = "Score: " + str(self.player.get_score())
    text(score_string, 2, 2)

When you run the code, the game will run the same as before, but this time the score text now displays the points that the player has collected:

Adding the ability to win and lose

If the player dies, we will reset the level by creating a new Level instance with the same level number. This will have the effect of resetting the position of the player and enemies, and will also reset the score along with all the coins and gems.

If the player collects all the coins in the level, the level is complete and the next level can be started by creating a new level instance with the level number incremented by one.

Since the new level instance must be created from outside the Level class, there must be a way for the current level instance to signal when it needs to be reset, or the next level needs to be started. We will create a function called level_failed, which will return true if the level needs to be reset. We will also add the level_complete function, which will return true if the next level can be started.

The current level can have multiple states:

  • The player is in the middle of a level.
  • The player has died.
  • The player has completed the level.

We can represent these states with an enum (in Python, we can use a class instead). It will have the following possible states:

  • PLAYING - the player is in the middle of a level.
  • PLAYER_DEAD - the player has died, but the level is not ready to be reset (we will use this later on).
  • PLAYER_WON - the player has won, but the level is not ready to be reset (we will use this later on).
  • FAILED - the level is ready to be reset.
  • COMPLETE - the next level can be started.

The extra two states (PLAYER_DEAD and PLAYER_WON) will be used to add visual feedback that the level has been completed, or that the player touched an enemy.

We will add this to our Level class, along with the functions level_failed and level_complete:

// level.hpp

class Level {
public:
    // ...

    bool level_failed();
    bool level_complete();

private:
    // ...

    enum class LevelState {
        PLAYING,
        PLAYER_DEAD,
        PLAYER_WON,
        FAILED,
        COMPLETE
    };

    LevelState level_state = LevelState::PLAYING;
};

// level.cpp

bool Level::level_failed() {
    return level_state == LevelState::FAILED;
}

bool Level::level_complete() {
    return level_state == LevelState::COMPLETE;
}
// level.hpp

class Level {
public:
    // ...

    bool level_failed();
    bool level_complete();

private:
    // ...

    enum class LevelState {
        PLAYING,
        PLAYER_DEAD,
        PLAYER_WON,
        FAILED,
        COMPLETE
    };

    LevelState level_state = LevelState::PLAYING;
};

// level.cpp

bool Level::level_failed() {
    return level_state == LevelState::FAILED;
}

bool Level::level_complete() {
    return level_state == LevelState::COMPLETE;
}
# level.py

class Level:
    class LevelState:
        PLAYING = 0
        PLAYER_DEAD = 1
        PLAYER_WON = 2
        FAILED = 3
        COMPLETE = 4

    def __init__(self, level_number):
        self.level_number = level_number
        self.level_data = Constants.LEVELS[level_number].copy()

        self.level_state = Level.LevelState.PLAYING

        # ...

    def level_failed(self):
        return self.level_state == Level.LevelState.FAILED

    def level_complete(self):
        return self.level_state == Level.LevelState.COMPLETE

Detecting when the player has died

There are two ways the player can die: by falling off the bottom of the screen, and by touching an enemy. If either of these occur, then level_state must be updated to FAILED.

Before we can detect collisions between two ninja instances, we need to be able to access the position of each instance. To do this, we will add functions which return the x and y coordinates of the ninja:

// ninja.cpp

float Ninja::get_x() {
    return position_x;
}

float Ninja::get_y() {
    return position_y;
}
// ninja.cpp

float Ninja::get_x() {
    return position_x;
}

float Ninja::get_y() {
    return position_y;
}
# ninja.py

class Ninja:
    # ...
    
    def get_x(self):
        return self.position_x

    def get_y(self):
        return self.position_y

The C++ function declarations also need to be added to the corresponding header file:

// ninja.hpp

class Ninja {
public:
    // ...

    float get_x();
    float get_y();

protected:
    // ...

private:
    // ...
};

In order to detect collisions between the player and other enemy ninjas, we will add a check_colliding function to the Ninja class, which tests for an intersection between the two visible ninja images (in Python this function will be called check_ninja_colliding). This function will work in the same way as the check_colliding function (or check_object_colliding function for Python), but adapted for the shape of the visible ninja sprites.

// ninja.cpp

bool Ninja::check_colliding(Ninja& ninja) {
    float ninja_x = ninja.get_x();
    float ninja_y = ninja.get_y();

    return (position_x + Constants::SPRITE_SIZE - Constants::Ninja::BORDER > ninja_x + Constants::Ninja::BORDER &&
            position_x + Constants::Ninja::BORDER < ninja_x + Constants::SPRITE_SIZE - Constants::Ninja::BORDER &&
            position_y + Constants::SPRITE_SIZE > ninja_y &&
            position_y < ninja_y + Constants::SPRITE_SIZE);
}
// ninja.cpp

bool Ninja::check_colliding(Ninja& ninja) {
    float ninja_x = ninja.get_x();
    float ninja_y = ninja.get_y();

    return (position_x + Constants::SPRITE_SIZE - Constants::Ninja::BORDER > ninja_x + Constants::Ninja::BORDER &&
            position_x + Constants::Ninja::BORDER < ninja_x + Constants::SPRITE_SIZE - Constants::Ninja::BORDER &&
            position_y + Constants::SPRITE_SIZE > ninja_y &&
            position_y < ninja_y + Constants::SPRITE_SIZE);
}
# ninja.py

class Ninja:
    # ...

    def check_ninja_colliding(self, ninja):
        ninja_x = ninja.get_x()
        ninja_y = ninja.get_y()

        return (self.position_x + Constants.SPRITE_SIZE - Constants.Ninja.BORDER > ninja_x + Constants.Ninja.BORDER and self.position_x + Constants.Ninja.BORDER < ninja_x + Constants.SPRITE_SIZE - Constants.Ninja.BORDER and
                self.position_y + Constants.SPRITE_SIZE > ninja_y and self.position_y < ninja_y + Constants.SPRITE_SIZE)

If you are using C++, you also need to add function declaration to the header file:

// ninja.hpp

class Ninja {
public:
    // ...

    bool check_colliding(Ninja& ninja);

protected:
    // ...

private:
    // ...
};

We are ready to use our function to detect collisions with enemies. We need to check for collisions between the player and every enemy, which requires iterating through the enemies array. In order to avoid unnecessary repeated iteration, we can add the collision check after we call the update function for each enemy instance (since we are already iterating through the array here). We will then set the level state to FAILED if any of these collision tests returns true.

If the player’s position is greater than the height of the game area, then they have fallen off the screen, so we will also set the level state to FAILED, so that the level can be reset.

At the same time as adding these checks, we will move the current update code into a switch statement to choose between the current level state (in Python, we use a series of if...elif blocks instead). For now, this simply results in our player and enemies only being updated if the level is currently playing.

The update function in the Level class should now look like this:

// level.cpp

void Level::update(float dt) {
    switch (level_state) {
    case LevelState::PLAYING:

        // Update player
        player.update(dt, level_data);

        // Update enemies
        for (EnemyNinja& enemy : enemies) {
            enemy.update(dt, level_data);

            if (player.check_colliding(enemy)) {
                // Player touched an enemy, so they're dead
                level_state = LevelState::FAILED;
            }
        }

        if (player.get_y() > Constants::GAME_HEIGHT) {
            // Player has gone off the bottom of the screen, so they're dead
            level_state = LevelState::FAILED;
        }

        break;

    default:
        break;
    }
}
// level.cpp

void Level::update(float dt) {
    switch (level_state) {
    case LevelState::PLAYING:

        // Update player
        player.update(dt, level_data);

        // Update enemies
        for (EnemyNinja& enemy : enemies) {
            enemy.update(dt, level_data);

            if (player.check_colliding(enemy)) {
                // Player touched an enemy, so they're dead
                level_state = LevelState::FAILED;
            }
        }

        if (player.get_y() > Constants::GAME_HEIGHT) {
            // Player has gone off the bottom of the screen, so they're dead
            level_state = LevelState::FAILED;
        }

        break;

    default:
        break;
    }
}
# level.py

def update(self, dt):
    if self.level_state == Level.LevelState.PLAYING:
        # Update player
        self.player.update(dt, self.level_data)

        # Update enemies
        for enemy in self.enemies:
            enemy.update(dt, self.level_data)

            if self.player.check_ninja_colliding(enemy):
                # Player touched an enemy, so they're dead
                self.level_state = Level.LevelState.FAILED

        if self.player.get_y() > Constants.GAME_HEIGHT:
            # Player has gone off the bottom of the screen, so they're dead
            self.level_state = Level.LevelState.FAILED

Detecting when a level is completed

A level is completed when there are no coins remaining. To signify this, we can then set level_state to COMPLETE.

In order to detect when a level is completed, we will need to create a function called coins_left which counts the number of coins remaining. We can iterate through the level_data.extras array and use a variable to track the number of coins we encounter:

// level.hpp

class Level {
public:
    // ...

private:
    uint8_t coins_left();

    // ...
};

// level.cpp

uint8_t Level::coins_left() {
    uint8_t total = 0;

    for (uint8_t i = 0; i < Constants::GAME_WIDTH_TILES * Constants::GAME_HEIGHT_TILES; i++) {
        if (level_data.extras[i] == Constants::Sprites::COIN) {
            total++;
        }
    }

    return total;
}
// level.hpp

class Level {
public:
    // ...

private:
    uint8_t coins_left();

    // ...
};

// level.cpp

uint8_t Level::coins_left() {
    uint8_t total = 0;

    for (uint8_t i = 0; i < Constants::GAME_WIDTH_TILES * Constants::GAME_HEIGHT_TILES; i++) {
        if (level_data.extras[i] == Constants::Sprites::COIN) {
            total++;
        }
    }

    return total;
}
# level.py

class Level:
    # ...
    
    def coins_left(self):
        total = 0

        for i in range(Constants.GAME_WIDTH_TILES * Constants.GAME_HEIGHT_TILES):
            if self.level_data.extras[i] == Constants.Sprites.COIN:
                total += 1

        return total

We can now call this function each frame, and if there are no coins remaining, we can update the level_state variable accordingly:

// level.cpp

void Level::update(float dt) {
    switch (level_state) {
    case LevelState::PLAYING:

        // Update player
        player.update(dt, level_data);

        if (coins_left() == 0) {
            // No more coins left, so the player has won!
            level_state = LevelState::COMPLETE;
        }

        // ...

        break;

    default:
        break;
    }
}
// level.cpp

void Level::update(float dt) {
    switch (level_state) {
    case LevelState::PLAYING:

        // Update player
        player.update(dt, level_data);

        if (coins_left() == 0) {
            // No more coins left, so the player has won!
            level_state = LevelState::COMPLETE;
        }

        // ...

        break;

    default:
        break;
    }
}
# level.py

def update(self, dt):
    if self.level_state == Level.LevelState.PLAYING:
        # Update player
        self.player.update(dt, self.level_data)

        if self.coins_left() == 0:
            # No more coins left, so the player has won!
            self.level_state = Level.LevelState.COMPLETE

        # ...

Creating new Level instances

When we add multiple levels to our game, we will need to keep track of which level the player is currently on. This is necessary for us to be able to select the correct level when we need to restart a level or move on to the next one. The current level number is stored in the Level class, so we will add a getter function to access it:

// level.hpp

class Level {
public:
    // ...

    uint8_t get_level_number();

private:
    // ...
};

// level.cpp

uint8_t Level::get_level_number() {
    return level_number;
}
// level.hpp

class Level {
public:
    // ...

    uint8_t get_level_number();

private:
    // ...
};

// level.cpp

uint8_t Level::get_level_number() {
    return level_number;
}
# level.py

class Level:
    # ...

    def get_level_number(self):
        return self.level_number

In the update function of our main game code, we need to check if the level is completed or failed, and handle the result accordingly. If the level was completed, the next level number can be calculated by adding one to the current level number. However, when the final level is completed, we would then attempt to load the next level, which doesn’t exist. To avoid this, we will use the modulo operator (%) to wrap the level number around, so that it is always below the LEVEL_COUNT constant.

To improve the game, instead of sending the player back to the first level once they have completed all the levels, a screen congratulating the player could be displayed, before allowing them to return to the main menu.

// ninja_thief.cpp

void update(uint32_t time) {
    // ...

    level.update(dt);

    if (level.level_failed()) {
        // Restart the same level
        uint8_t level_number = level.get_level_number();

        level = Level(level_number);
    }
    else if (level.level_complete()) {
        // Start the next level
        uint8_t level_number = level.get_level_number() + 1;
        level_number %= Constants::LEVEL_COUNT;

        level = Level(level_number);
    }
}
// ninja_thief.cpp

void update(uint32_t tick) {
    // ...

    level.update(dt);

    if (level.level_failed()) {
        // Restart the same level
        uint8_t level_number = level.get_level_number();

        level = Level(level_number);
    }
    else if (level.level_complete()) {
        // Start the next level
        uint8_t level_number = level.get_level_number() + 1;
        level_number %= Constants::LEVEL_COUNT;
        
        level = Level(level_number);
    }
}
# ninja_thief.py

def update(tick):
    global last_time, level

    # ...

    level.update(dt)

    if level.level_failed():
        # Restart the same level
        level_number = level.get_level_number()

        level = Level(level_number)
    
    elif level.level_complete():
        # Start the next level
        level_number = level.get_level_number() + 1
        level_number %= Constants.LEVEL_COUNT

        level = Level(level_number)

In the Python code, we need to declare level as a global variable because we are now re-assigning it when we create a new instance of the Level class.

When you run the code, you will notice that the level instantly resets if you die or collect all the coins. It would be good if we could provide some visual feedback to the user when they collect all the coins or touch an enemy, because it currently feels very abrupt.

Adding visual feedback for dying

In order to make it more obvious when the player dies by touching an enemy, we will make the player jump and fall off the screen, similar to the death animation in Super Mario Bros. This will require adding a variable to the Ninja class called dead:

// ninja.hpp

class Ninja {
public:
    // ...

protected:
    // ...

    // If a ninja is dead, they don't collide with any tiles, but are still affected by gravity
    bool dead = false;

private:
    // ...
};
// ninja.hpp

class Ninja {
public:
    // ...

protected:
    // ...

    // If a ninja is dead, they don't collide with any tiles, but are still affected by gravity
    bool dead = false;

private:
    // ...
};
# ninja.py

class Ninja:
    # ...

    def __init__(self, colour, x, y):
        # ...

        self.dead = False

The collision detection and resolution code should only be called if dead is false, so that the player can fall through platforms during the death animation. We can achieve this by encasing the call to handle_collisions in an if-statement:

// ninja.cpp

void Ninja::update(float dt, Constants::LevelData& level_data) {
    // ...

    // Detect and resolve any collisions with platforms, ladders, coins etc, only if the ninja isn't dead
    if (!dead) {
        handle_collisions(level_data);
    }

    // ...
}
// ninja.cpp

void Ninja::update(float dt, Constants::LevelData& level_data) {
    // ...

    // Detect and resolve any collisions with platforms, ladders, coins etc, only if the ninja isn't dead
    if (!dead) {
        handle_collisions(level_data);
    }

    // ...
}
# ninja.py

def update(self, dt, level_data):
    # ...

    # Detect and resolve any collisions with platforms, ladders, coins etc, only if the ninja isn't dead
    if not self.dead:
        self.handle_collisions(level_data)

    # ...

Since the dead variable is private, we will also need to add a set_dead function which can be called when the ninja dies. It is only possible for the player to die, so we will add the function to the PlayerNinja class. In this function, we will call the jump function, so that the player jumps before falling through the platforms.

// player_ninja.hpp

class PlayerNinja : public Ninja {
public:
    // ...

    void set_dead();

private:
    // ...
};

// player_ninja.cpp

void PlayerNinja::set_dead() {
    dead = true;
    jump(Constants::Player::DEATH_JUMP_SPEED);
}
// player_ninja.hpp

class PlayerNinja : public Ninja {
public:
    // ...

    void set_dead();

private:
    // ...
};

// player_ninja.cpp

void PlayerNinja::set_dead() {
    dead = true;
    jump(Constants::Player::DEATH_JUMP_SPEED);
}
# player_ninja.py

class PlayerNinja(Ninja):
    # ...

    def set_dead(self):
        self.dead = True
        self.jump(Constants.Player.DEATH_JUMP_SPEED)

We’ve introduced a new constant called DEATH_JUMP_SPEED because we want to separate the normal jump height of the player from the jump height when they die. We will make the death jump height slightly less than the normal jump height:

// constants.hpp, inside Constants namespace

namespace Player {
    // ...

    const float DEATH_JUMP_SPEED = 100.0f;

    // ...
}
// constants.hpp, inside Constants namespace

namespace Player {
    // ...

    const float DEATH_JUMP_SPEED = 100.0f;

    // ...
}
# constants.py

class Player:
    # ...
    
    DEATH_JUMP_SPEED = 100

    # ...

While our death animation is running, we don’t want the enemies to update or move, but we still need to move the player. For this reason, if the player touches an enemy, we will set the level state to PLAYER_DEAD, and only set it to FAILED when the player falls off the screen at the end of the animation. This will require us to add a new case to our switch statement:

// level.cpp

void Level::update(float dt) {
    switch (level_state) {
    case LevelState::PLAYING:
        // ...

        // Update enemies
        for (EnemyNinja& enemy : enemies) {
            // ...

            if (player.check_colliding(enemy)) {
                // Player touched an enemy, so they're dead
                level_state = LevelState::PLAYER_DEAD;

                // Trigger "jump and fall" animation before restarting level
                player.set_dead();
            }
        }

        // ...

        break;

    case LevelState::PLAYER_DEAD:

        // Update player
        player.update(dt, level_data);

        if (player.get_y() > Constants::GAME_HEIGHT) {
            // Player has gone off the bottom of the screen, so we can reset the level
            level_state = LevelState::FAILED;
        }

        break;

    default:
        break;
    }
}
// level.cpp

void Level::update(float dt) {
    switch (level_state) {
    case LevelState::PLAYING:
        // ...

        // Update enemies
        for (EnemyNinja& enemy : enemies) {
            // ...

            if (player.check_colliding(enemy)) {
                // Player touched an enemy, so they're dead
                level_state = LevelState::PLAYER_DEAD;

                // Trigger "jump and fall" animation before restarting level
                player.set_dead();
            }
        }

        // ...

        break;

    case LevelState::PLAYER_DEAD:

        // Update player
        player.update(dt, level_data);

        if (player.get_y() > Constants::GAME_HEIGHT) {
            // Player has gone off the bottom of the screen, so we can reset the level
            level_state = LevelState::FAILED;
        }

        break;

    default:
        break;
    }
}
# level.py

def update(self, dt):
    if self.level_state == Level.LevelState.PLAYING:
        # ...

        # Update enemies
        for enemy in self.enemies:
            # ...

            if self.player.check_ninja_colliding(enemy):
                # Player touched an enemy, so they're dead
                self.level_state = Level.LevelState.PLAYER_DEAD

                # Trigger "jump and fall" animation before restarting level
                self.player.set_dead()
        
        # ...

    elif self.level_state == Level.LevelState.PLAYER_DEAD:
        # Update player
        self.player.update(dt, self.level_data)

        if self.player.get_y() > Constants.GAME_HEIGHT:
            # Player has gone off the bottom of the screen, so we can reset the level
            self.level_state = Level.LevelState.FAILED

Finally, we need to disable the controls while the death animation is being shown. This requires much of the update function in PlayerNinja to be encased in an if-statement. The function should now look like this:

// player_ninja.cpp

void PlayerNinja::update(float dt, Constants::LevelData& level_data) {
    // If nothing is pressed, the player shouldn't move
    velocity_x = 0.0f;
    
    if (!dead) {
        // Handle any buttons the user has pressed

        // Note: "else if" isn't used, because otherwise the sprite will still move when both buttons are pressed
        // Instead, we add/subtract the velocity, so if both are pressed, nothing happens
        if (pressed(Button::DPAD_LEFT)) {
            velocity_x -= Constants::Player::MAX_SPEED;
        }
        if (pressed(Button::DPAD_RIGHT)) {
            velocity_x += Constants::Player::MAX_SPEED;
        }

        // Handle climbing
        if (can_climb) {
            bool up = pressed(Button::DPAD_UP);
            bool down = pressed(Button::DPAD_DOWN);

            if (up != down) {
                // Only one of up and down are selected
                climbing_state = up ? ClimbingState::UP : ClimbingState::DOWN;
            }
            else if (climbing_state != ClimbingState::NONE) {
                // Player has already been climbing the ladder, and either none or both of up and down are pressed
                climbing_state = ClimbingState::IDLE;
            }
        }

        // Handle jumping
        // Note that we use buttons.pressed, which only contains the buttons just pressed (since the last frame)
        if (buttons.pressed & Button::A) {
            if (can_jump) {
                jump(Constants::Player::JUMP_SPEED);
            }
        }
    }

    // Call parent update method
    Ninja::update(dt, level_data);
}
// player_ninja.cpp

void PlayerNinja::update(float dt, Constants::LevelData& level_data) {
    // If nothing is pressed, the player shouldn't move
    velocity_x = 0.0f;
    
    if (!dead) {
        // Handle any buttons the user has pressed

        // Note: "else if" isn't used, because otherwise the sprite will still move when both buttons are pressed
        // Instead, we add/subtract the velocity, so if both are pressed, nothing happens
        if (button(LEFT)) {
            velocity_x -= Constants::Player::MAX_SPEED;
        }
        if (button(RIGHT)) {
            velocity_x += Constants::Player::MAX_SPEED;
        }

        // Handle climbing
        if (can_climb) {
            bool up = button(UP);
            bool down = button(DOWN);

            if (up != down) {
                // Only one of up and down are selected
                climbing_state = up ? ClimbingState::UP : ClimbingState::DOWN;
            }
            else if (climbing_state != ClimbingState::NONE) {
                // Player has already been climbing the ladder, and either none or both of up and down are pressed
                climbing_state = ClimbingState::IDLE;
            }
        }

        // Handle jumping
        // Note that we use the pressed function, which returns true if the button was just pressed (since the last frame)
        if (pressed(A)) {
            if (can_jump) {
                // Player is on platform so is allowed to jump
                jump(Constants::Player::JUMP_SPEED);
            }
        }
    }

    // Call parent update method
    Ninja::update(dt, level_data);
}
# player_ninja.py

def update(self, dt, level_data):
    # If nothing is pressed, the player shouldn't move
    self.velocity_x = 0
    
    if not self.dead:
        # Handle any buttons the user has pressed
        
        # Note: "else if" isn't used, because otherwise the sprite will still move when both buttons are pressed
        # Instead, we add/subtract the velocity, so if both are pressed, nothing happens
        if button(LEFT):
            self.velocity_x -= Constants.Player.MAX_SPEED
        
        if button(RIGHT):
            self.velocity_x += Constants.Player.MAX_SPEED

        # Handle climbing
        if self.can_climb:
            up = button(UP)
            down = button(DOWN)

            if up != down:
                # Only one of up and down are selected
                self.climbing_state = Ninja.ClimbingState.UP if up else Ninja.ClimbingState.DOWN
            
            elif self.climbing_state != Ninja.ClimbingState.NONE:
                # Player has already been climbing the ladder, and either none or both of up and down are pressed
                self.climbing_state = Ninja.ClimbingState.IDLE

        # Handle jumping
        # Note that we use the pressed function, which returns true if the button was just pressed (since the last frame)
        if pressed(A):
            if self.can_jump:
                self.jump(Constants.Player.JUMP_SPEED)
    

    # Call parent update method
    super().update(dt, level_data)

If you now run the code, you will notice that whenever the player dies by touching an enemy, they jump and fall off the screen before the level resets:

Adding visual feedback for winning

Currently, when the player collects all the coins, the level ends instantly. It would be good to provide an indication that the level has been completed, and we will do this in a similar manner to the previous section. Instead of one large jump, we will make the player perform several small jumps in way of celebration.

To do this, we will need to add a variable called won to the PlayerNinja class, along with a method to set this variable to true. In addition, we will need a variable to keep track of how many jumps we have remaining in the celebration animation, along with a function to detect when the animation has completed.

// player_ninja.hpp

class PlayerNinja : public Ninja {
public:
    // ...

    void set_won();

    bool finished_celebrating();

private:
    // ...

    bool won = false;

    uint8_t celebration_jumps_remaining = Constants::Player::CELEBRATION_JUMP_COUNT;
};

// player_ninja.cpp

void PlayerNinja::set_won() {
    won = true;
}

bool PlayerNinja::finished_celebrating() {
    return can_jump && celebration_jumps_remaining == 0;
}
// player_ninja.hpp

class PlayerNinja : public Ninja {
public:
    // ...

    void set_won();

    bool finished_celebrating();

private:
    // ...

    bool won = false;

    uint8_t celebration_jumps_remaining = Constants::Player::CELEBRATION_JUMP_COUNT;
};

// player_ninja.cpp

void PlayerNinja::set_won() {
    won = true;
}

bool PlayerNinja::finished_celebrating() {
    return can_jump && celebration_jumps_remaining == 0;
}
# player_ninja.py

class PlayerNinja(Ninja):

    def __init__(self, x, y):
        # ...

        self.won = False

        self.celebration_jumps_remaining = Constants.Player.CELEBRATION_JUMP_COUNT

    def set_won(self):
        self.won = True

    def finished_celebrating(self):
        return self.can_jump and self.celebration_jumps_remaining == 0

In order to avoid using hard-coded values in our code, we will store the number of jumps to do for the celebration animation in our constants file, as the CELEBRATION_JUMP_COUNT constant. We will also add a CELEBRATION_JUMP_SPEED constant, so that the player does smaller jumps when the level is complete.

// constants.hpp, inside Constants namespace

namespace Player {
    // ...

    const float CELEBRATION_JUMP_SPEED = 75.0f;
    const uint8_t CELEBRATION_JUMP_COUNT = 3;
}
// constants.hpp, inside Constants namespace

namespace Player {
    // ...

    const float CELEBRATION_JUMP_SPEED = 75.0f;
    const uint8_t CELEBRATION_JUMP_COUNT = 3;
}
# constants.py

class Player:
    # ...

    CELEBRATION_JUMP_SPEED = 75
    CELEBRATION_JUMP_COUNT = 3

We will then modify the update function of the PlayerNinja class so that the player will jump (if it is on a platform) when the level is completed. Each time, it will decrement celebration_jumps_remaining until it reaches 0.

// player_ninja.cpp

void PlayerNinja::update(float dt, Constants::LevelData& level_data) {
    // ...

    if (won) {
        // Jump in celebration!

        if (can_jump && celebration_jumps_remaining > 0) {
            jump(Constants::Player::CELEBRATION_JUMP_SPEED);
            celebration_jumps_remaining--;
        }
    }
    // Note that this is now an else-if statement:
    else if (!dead) {
        // ...
    }
    
    // ...
}
// player_ninja.cpp

void PlayerNinja::update(float dt, Constants::LevelData& level_data) {
    // ...

    if (won) {
        // Jump in celebration!

        if (can_jump && celebration_jumps_remaining > 0) {
            jump(Constants::Player::CELEBRATION_JUMP_SPEED);
            celebration_jumps_remaining--;
        }
    }
    // Note that this is now an else-if statement:
    else if (!dead) {
        // ...
    }
    
    // ...
}
# player_ninja.py

def update(self, dt, level_data):
    # ...

    if self.won:
        # Jump in celebration!

        if self.can_jump and self.celebration_jumps_remaining > 0:
            self.jump(Constants.Player.CELEBRATION_JUMP_SPEED)
            self.celebration_jumps_remaining -= 1
    
    # Note that this is now an elif statement:
    elif not self.dead:
        # ...

    # ...

Finally, we need to add a new case to our switch statement, so that only the player is updated, and the level state is set to COMPLETE once the animation is finished. We must also change the value assigned to level_state when there are no coins left.

// level.cpp

void Level::update(float dt) {
    switch (level_state) {
    case LevelState::PLAYING:
        // ...

        if (coins_left() == 0) {
            // No more coins left, so the player has won!
            level_state = LevelState::PLAYER_WON;

            player.set_won();
        }

        // ...

        break;

    case LevelState::PLAYER_DEAD:
        // ...

    case LevelState::PLAYER_WON:

        // Update player
        player.update(dt, level_data);

        if (player.finished_celebrating() || player.get_y() > Constants::GAME_HEIGHT) {
            // Player has finished doing victory jumps, or has fallen off the screen
            level_state = LevelState::COMPLETE;
        }

        break;

    default:
        break;
    }
}
// level.cpp

void Level::update(float dt) {
    switch (level_state) {
    case LevelState::PLAYING:
        // ...

        if (coins_left() == 0) {
            // No more coins left, so the player has won!
            level_state = LevelState::PLAYER_WON;

            player.set_won();
        }

        // ...

        break;

    case LevelState::PLAYER_DEAD:
        // ...

    case LevelState::PLAYER_WON:

        // Update player
        player.update(dt, level_data);

        if (player.finished_celebrating() || player.get_y() > Constants::GAME_HEIGHT) {
            // Player has finished doing victory jumps, or has fallen off the screen
            level_state = LevelState::COMPLETE;
        }

        break;

    default:
        break;
    }
}
# level.py

def update(self, dt):
    if self.level_state == Level.LevelState.PLAYING:
        # ...

        if self.coins_left() == 0:
            # No more coins left, so the player has won!
            self.level_state = Level.LevelState.PLAYER_WON

            self.player.set_won()

        # ...

    elif self.level_state == Level.LevelState.PLAYER_DEAD:
        # ...

    elif self.level_state == Level.LevelState.PLAYER_WON:
        # Update player
        self.player.update(dt, self.level_data)

        if self.player.finished_celebrating() or self.player.get_y() > Constants.GAME_HEIGHT:
            # Player has finished doing victory jumps, or has fallen off the screen
            self.level_state = Level.LevelState.COMPLETE

If the last coin the player collects is over a gap in the platforms, they will fall down, so we need to complete the game even if they fall off the screen after collecting all the coins.

When you run the code and collect all the coins, the player bounces three times before the next level is started:

Adding more levels

Currently, there is only one level, so even if you win, the level appears to restart. In the following section, we will add two more levels to the game by adding more data to the constants file. If you want to design your own levels, then you can add even more, by adding more elements to the LEVELS array.

Don’t forget to test your own levels, to make sure that they are actually completable, and aren’t too difficult! Also, bear in mind that if you are repeatedly testing your game during development, you may end up being much better at playing the game than a new player. This can result in designing levels which are much too difficult for most players, but which seem an alright difficulty to you.

Adding the level data

Whenever we add a new level to our game, we need to remember to update the LEVEL_COUNT constant to keep track of the total number of levels available:

// constants.hpp, inside Constants namespace

// Update this line so that we can have 3 levels:
const uint8_t LEVEL_COUNT = 3;
// constants.hpp, inside Constants namespace

// Update this line so that we can have 3 levels:
const uint8_t LEVEL_COUNT = 3;
# constants.py

# Update this line so that we can have 3 levels:
LEVEL_COUNT = 3

Now we can add two more items to our LEVELS array, each corresponding to a new level in the game. Each of these levels is an instance of LevelData, and contains several arrays which determine the platform, ladder, coin, and gem positions, along with the spawn locations of the player and all the enemies.

// constants.hpp, inside Constants namespace

const LevelData LEVELS[LEVEL_COUNT] = {
    // Level 1
    {
        // ...
    },
    // Level 2
    {
        // Platform data
        {
            0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff,
            0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff,
            0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff,
            0x01, 0x02, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0x00, 0x01,
            0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0x00, 0x01, 0x02, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff,
            0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff,
            0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff,
            0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff,
            0xff, 0xff, 0xff, 0x00, 0x01, 0x02, 0xff, 0xff, 0xff, 0x00, 0x01, 0x02, 0xff, 0xff, 0xff,
            0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff,
            0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff,
            0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff,
            0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff,
            0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff,
            0x01, 0x01, 0x02, 0xff, 0xff, 0xff, 0x00, 0x01, 0x02, 0xff, 0xff, 0xff, 0x00, 0x01, 0x01
        },

        // Coin, gem and ladder data
        {
            0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0x13, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff,
            0x12, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0x12,
            0x0b, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0x0b,
            0x0b, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0x0b, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0x0b,
            0x0b, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0x0b, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0x0b,
            0x0b, 0xff, 0xff, 0xff, 0x13, 0xff, 0xff, 0x0b, 0xff, 0xff, 0x13, 0xff, 0xff, 0xff, 0x0b,
            0x0b, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0x0b, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0x0b,
            0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0x0b, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff,
            0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0x0b, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff,
            0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0x0b, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff,
            0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0x0b, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff,
            0xff, 0x13, 0xff, 0xff, 0xff, 0xff, 0xff, 0x0b, 0xff, 0xff, 0xff, 0xff, 0xff, 0x13, 0xff,
            0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0x0b, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff,
            0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0x0b, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff,
            0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff
        },

        // Entity spawn data
        {
            0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff,
            0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff,
            0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff,
            0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0x24, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff,
            0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff,
            0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff,
            0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff,
            0xff, 0xff, 0xff, 0xff, 0x24, 0xff, 0xff, 0xff, 0xff, 0xff, 0x24, 0xff, 0xff, 0xff, 0xff,
            0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff,
            0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff,
            0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff,
            0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff,
            0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff,
            0xff, 0x24, 0xff, 0xff, 0xff, 0xff, 0xff, 0x20, 0xff, 0xff, 0xff, 0xff, 0xff, 0x24, 0xff,
            0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff
        }
    },

    // Level 3
    {
        // Platform data
        {
            0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff,
            0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff,
            0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff,
            0xff, 0xff, 0xff, 0x00, 0x01, 0x02, 0xff, 0xff, 0xff, 0x00, 0x01, 0x02, 0xff, 0xff, 0xff,
            0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff,
            0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff,
            0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff,
            0x02, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0x00,
            0xff, 0xff, 0xff, 0xff, 0xff, 0x00, 0x01, 0x01, 0x01, 0x02, 0xff, 0xff, 0xff, 0xff, 0xff,
            0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff,
            0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff,
            0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff,
            0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff,
            0x01, 0x01, 0x02, 0xff, 0xff, 0xff, 0x00, 0x01, 0x02, 0xff, 0xff, 0xff, 0x00, 0x01, 0x01,
            0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff
        },

        // Coin, gem and ladder data
        {
            0xff, 0xff, 0xff, 0xff, 0x13, 0xff, 0xff, 0xff, 0xff, 0xff, 0x13, 0xff, 0xff, 0xff, 0xff,
            0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff,
            0xff, 0xff, 0xff, 0xff, 0xff, 0x0b, 0xff, 0xff, 0xff, 0x0b, 0xff, 0xff, 0xff, 0xff, 0xff,
            0xff, 0xff, 0xff, 0xff, 0xff, 0x0b, 0xff, 0xff, 0xff, 0x0b, 0xff, 0xff, 0xff, 0xff, 0xff,
            0xff, 0xff, 0xff, 0xff, 0xff, 0x0b, 0xff, 0x13, 0xff, 0x0b, 0xff, 0xff, 0xff, 0xff, 0xff,
            0x12, 0xff, 0xff, 0xff, 0xff, 0x0b, 0xff, 0xff, 0xff, 0x0b, 0xff, 0xff, 0xff, 0xff, 0x12,
            0xff, 0xff, 0xff, 0xff, 0xff, 0x0b, 0xff, 0xff, 0xff, 0x0b, 0xff, 0xff, 0xff, 0xff, 0xff,
            0xff, 0xff, 0xff, 0xff, 0xff, 0x0b, 0xff, 0x0b, 0xff, 0x0b, 0xff, 0xff, 0xff, 0xff, 0xff,
            0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0x0b, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff,
            0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0x0b, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff,
            0xff, 0x13, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0x13, 0xff,
            0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff,
            0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff,
            0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff,
            0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff
        },

        // Entity spawn data
        {
            0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff,
            0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff,
            0xff, 0xff, 0xff, 0xff, 0x24, 0xff, 0xff, 0xff, 0xff, 0xff, 0x24, 0xff, 0xff, 0xff, 0xff,
            0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff,
            0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff,
            0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff,
            0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff,
            0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0x24, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff,
            0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff,
            0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff,
            0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff,
            0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff,
            0xff, 0x24, 0xff, 0xff, 0xff, 0xff, 0xff, 0x20, 0xff, 0xff, 0xff, 0xff, 0xff, 0x24, 0xff,
            0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff,
            0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff
        }
    }
};
// constants.hpp, inside Constants namespace

const LevelData LEVELS[LEVEL_COUNT] = {
    // Level 1
    {
        // ...
    },
    // Level 2
    {
        // Platform data
        {
            0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff,
            0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff,
            0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff,
            0x01, 0x02, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0x00, 0x01,
            0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0x00, 0x01, 0x02, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff,
            0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff,
            0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff,
            0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff,
            0xff, 0xff, 0xff, 0x00, 0x01, 0x02, 0xff, 0xff, 0xff, 0x00, 0x01, 0x02, 0xff, 0xff, 0xff,
            0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff,
            0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff,
            0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff,
            0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff,
            0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff,
            0x01, 0x01, 0x02, 0xff, 0xff, 0xff, 0x00, 0x01, 0x02, 0xff, 0xff, 0xff, 0x00, 0x01, 0x01
        },

        // Coin, gem and ladder data
        {
            0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0x13, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff,
            0x12, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0x12,
            0x0b, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0x0b,
            0x0b, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0x0b, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0x0b,
            0x0b, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0x0b, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0x0b,
            0x0b, 0xff, 0xff, 0xff, 0x13, 0xff, 0xff, 0x0b, 0xff, 0xff, 0x13, 0xff, 0xff, 0xff, 0x0b,
            0x0b, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0x0b, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0x0b,
            0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0x0b, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff,
            0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0x0b, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff,
            0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0x0b, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff,
            0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0x0b, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff,
            0xff, 0x13, 0xff, 0xff, 0xff, 0xff, 0xff, 0x0b, 0xff, 0xff, 0xff, 0xff, 0xff, 0x13, 0xff,
            0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0x0b, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff,
            0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0x0b, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff,
            0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff
        },

        // Entity spawn data
        {
            0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff,
            0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff,
            0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff,
            0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0x24, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff,
            0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff,
            0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff,
            0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff,
            0xff, 0xff, 0xff, 0xff, 0x24, 0xff, 0xff, 0xff, 0xff, 0xff, 0x24, 0xff, 0xff, 0xff, 0xff,
            0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff,
            0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff,
            0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff,
            0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff,
            0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff,
            0xff, 0x24, 0xff, 0xff, 0xff, 0xff, 0xff, 0x20, 0xff, 0xff, 0xff, 0xff, 0xff, 0x24, 0xff,
            0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff
        }
    },

    // Level 3
    {
        // Platform data
        {
            0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff,
            0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff,
            0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff,
            0xff, 0xff, 0xff, 0x00, 0x01, 0x02, 0xff, 0xff, 0xff, 0x00, 0x01, 0x02, 0xff, 0xff, 0xff,
            0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff,
            0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff,
            0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff,
            0x02, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0x00,
            0xff, 0xff, 0xff, 0xff, 0xff, 0x00, 0x01, 0x01, 0x01, 0x02, 0xff, 0xff, 0xff, 0xff, 0xff,
            0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff,
            0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff,
            0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff,
            0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff,
            0x01, 0x01, 0x02, 0xff, 0xff, 0xff, 0x00, 0x01, 0x02, 0xff, 0xff, 0xff, 0x00, 0x01, 0x01,
            0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff
        },

        // Coin, gem and ladder data
        {
            0xff, 0xff, 0xff, 0xff, 0x13, 0xff, 0xff, 0xff, 0xff, 0xff, 0x13, 0xff, 0xff, 0xff, 0xff,
            0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff,
            0xff, 0xff, 0xff, 0xff, 0xff, 0x0b, 0xff, 0xff, 0xff, 0x0b, 0xff, 0xff, 0xff, 0xff, 0xff,
            0xff, 0xff, 0xff, 0xff, 0xff, 0x0b, 0xff, 0xff, 0xff, 0x0b, 0xff, 0xff, 0xff, 0xff, 0xff,
            0xff, 0xff, 0xff, 0xff, 0xff, 0x0b, 0xff, 0x13, 0xff, 0x0b, 0xff, 0xff, 0xff, 0xff, 0xff,
            0x12, 0xff, 0xff, 0xff, 0xff, 0x0b, 0xff, 0xff, 0xff, 0x0b, 0xff, 0xff, 0xff, 0xff, 0x12,
            0xff, 0xff, 0xff, 0xff, 0xff, 0x0b, 0xff, 0xff, 0xff, 0x0b, 0xff, 0xff, 0xff, 0xff, 0xff,
            0xff, 0xff, 0xff, 0xff, 0xff, 0x0b, 0xff, 0x0b, 0xff, 0x0b, 0xff, 0xff, 0xff, 0xff, 0xff,
            0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0x0b, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff,
            0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0x0b, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff,
            0xff, 0x13, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0x13, 0xff,
            0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff,
            0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff,
            0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff,
            0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff
        },

        // Entity spawn data
        {
            0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff,
            0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff,
            0xff, 0xff, 0xff, 0xff, 0x24, 0xff, 0xff, 0xff, 0xff, 0xff, 0x24, 0xff, 0xff, 0xff, 0xff,
            0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff,
            0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff,
            0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff,
            0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff,
            0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0x24, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff,
            0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff,
            0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff,
            0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff,
            0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff,
            0xff, 0x24, 0xff, 0xff, 0xff, 0xff, 0xff, 0x20, 0xff, 0xff, 0xff, 0xff, 0xff, 0x24, 0xff,
            0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff,
            0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff
        }
    }
};
# constants.py

LEVELS = [
    # Level 1
    LevelData(
        # ...
    ),

    # Level 2
    LevelData(
        # Platform data
        [
            0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff,
            0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff,
            0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff,
            0x01, 0x02, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0x00, 0x01,
            0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0x00, 0x01, 0x02, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff,
            0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff,
            0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff,
            0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff,
            0xff, 0xff, 0xff, 0x00, 0x01, 0x02, 0xff, 0xff, 0xff, 0x00, 0x01, 0x02, 0xff, 0xff, 0xff,
            0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff,
            0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff,
            0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff,
            0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff,
            0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff,
            0x01, 0x01, 0x02, 0xff, 0xff, 0xff, 0x00, 0x01, 0x02, 0xff, 0xff, 0xff, 0x00, 0x01, 0x01
        ],

        # Coin, gem and ladder data
        [
            0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0x13, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff,
            0x12, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0x12,
            0x0b, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0x0b,
            0x0b, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0x0b, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0x0b,
            0x0b, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0x0b, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0x0b,
            0x0b, 0xff, 0xff, 0xff, 0x13, 0xff, 0xff, 0x0b, 0xff, 0xff, 0x13, 0xff, 0xff, 0xff, 0x0b,
            0x0b, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0x0b, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0x0b,
            0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0x0b, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff,
            0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0x0b, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff,
            0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0x0b, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff,
            0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0x0b, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff,
            0xff, 0x13, 0xff, 0xff, 0xff, 0xff, 0xff, 0x0b, 0xff, 0xff, 0xff, 0xff, 0xff, 0x13, 0xff,
            0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0x0b, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff,
            0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0x0b, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff,
            0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff
        ],

        # Entity spawn data
        [
            0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff,
            0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff,
            0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff,
            0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0x24, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff,
            0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff,
            0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff,
            0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff,
            0xff, 0xff, 0xff, 0xff, 0x24, 0xff, 0xff, 0xff, 0xff, 0xff, 0x24, 0xff, 0xff, 0xff, 0xff,
            0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff,
            0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff,
            0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff,
            0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff,
            0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff,
            0xff, 0x24, 0xff, 0xff, 0xff, 0xff, 0xff, 0x20, 0xff, 0xff, 0xff, 0xff, 0xff, 0x24, 0xff,
            0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff
        ]
    ),
    
    # Level 3
    LevelData(
        # Platform data
        [
            0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff,
            0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff,
            0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff,
            0xff, 0xff, 0xff, 0x00, 0x01, 0x02, 0xff, 0xff, 0xff, 0x00, 0x01, 0x02, 0xff, 0xff, 0xff,
            0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff,
            0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff,
            0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff,
            0x02, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0x00,
            0xff, 0xff, 0xff, 0xff, 0xff, 0x00, 0x01, 0x01, 0x01, 0x02, 0xff, 0xff, 0xff, 0xff, 0xff,
            0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff,
            0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff,
            0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff,
            0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff,
            0x01, 0x01, 0x02, 0xff, 0xff, 0xff, 0x00, 0x01, 0x02, 0xff, 0xff, 0xff, 0x00, 0x01, 0x01,
            0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff
        ],

        # Coin, gem and ladder data
        [
            0xff, 0xff, 0xff, 0xff, 0x13, 0xff, 0xff, 0xff, 0xff, 0xff, 0x13, 0xff, 0xff, 0xff, 0xff,
            0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff,
            0xff, 0xff, 0xff, 0xff, 0xff, 0x0b, 0xff, 0xff, 0xff, 0x0b, 0xff, 0xff, 0xff, 0xff, 0xff,
            0xff, 0xff, 0xff, 0xff, 0xff, 0x0b, 0xff, 0xff, 0xff, 0x0b, 0xff, 0xff, 0xff, 0xff, 0xff,
            0xff, 0xff, 0xff, 0xff, 0xff, 0x0b, 0xff, 0x13, 0xff, 0x0b, 0xff, 0xff, 0xff, 0xff, 0xff,
            0x12, 0xff, 0xff, 0xff, 0xff, 0x0b, 0xff, 0xff, 0xff, 0x0b, 0xff, 0xff, 0xff, 0xff, 0x12,
            0xff, 0xff, 0xff, 0xff, 0xff, 0x0b, 0xff, 0xff, 0xff, 0x0b, 0xff, 0xff, 0xff, 0xff, 0xff,
            0xff, 0xff, 0xff, 0xff, 0xff, 0x0b, 0xff, 0x0b, 0xff, 0x0b, 0xff, 0xff, 0xff, 0xff, 0xff,
            0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0x0b, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff,
            0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0x0b, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff,
            0xff, 0x13, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0x13, 0xff,
            0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff,
            0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff,
            0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff,
            0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff
        ],

        # Entity spawn data
        [
            0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff,
            0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff,
            0xff, 0xff, 0xff, 0xff, 0x24, 0xff, 0xff, 0xff, 0xff, 0xff, 0x24, 0xff, 0xff, 0xff, 0xff,
            0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff,
            0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff,
            0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff,
            0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff,
            0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0x24, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff,
            0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff,
            0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff,
            0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff,
            0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff,
            0xff, 0x24, 0xff, 0xff, 0xff, 0xff, 0xff, 0x20, 0xff, 0xff, 0xff, 0xff, 0xff, 0x24, 0xff,
            0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff,
            0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff
        ]
    )
]

Now that we have added the level data and updated the total number of levels, the game will automatically add the new levels in. After you complete the first level, you will notice that the next (new) level starts, rather than the same one being repeated. Since there are only three levels (unless you added some of your own), once you complete the third level then the first level will start again.

To extend this game, you could add text displaying the overall score, after all the levels have been completed (rather than restarting at level 1 immediately).

Displaying the current level

Now that we have multiple levels which the player can complete, it would be useful to display the current level that the player is on. We will draw the level number text in the same way as the score text, but we will need to move the score text over to the right side of the screen. This will require us to align it by the top right corner, instead of the top left (so that the distance from the text to the edges of the screen remains the same).

The method of aligning our text varies depending on whether you are using the 32blit or the PicoSystem. The 32blit SDK provides us with flags which we can pass to the screen.text function to specify the “anchor point” of the text. For the PicoSystem SDK, we have to use the measure function to calculate the height and width of the text which we will render, and then we can manually offset the text position by this amount.

// level.cpp

void Level::render() {
    // ...

    // Render UI text
    
    // Set the text colour to white
    screen.pen = Pen(255, 255, 255);

    // Render level number in top left corner
    std::string level_string = "Level: " + std::to_string(level_number + 1);
    screen.text(level_string, minimal_font, Point(2, 2));

    // Render score in top right corner
    std::string score_string = "Score: " + std::to_string(player.get_score());
    screen.text(score_string, minimal_font, Point(Constants::SCREEN_WIDTH - 2, 2), true, TextAlign::top_right);
}
// level.cpp

void Level::render() {
    // ...
    
    // Render UI text
    
    // Set the text colour to white
    pen(15, 15, 15);

    // Render level number in top left corner
    std::string level_string = "Level: " + std::to_string(level_number + 1);
    text(level_string, 2, 2);

    // Render score
    std::string score_string = "Score: " + std::to_string(player.get_score());

    // Get size of score text
    int32_t w, h;
    measure(score_string, w, h);

    // Render score in top right corner
    text(score_string, Constants::SCREEN_WIDTH - 2 - w, 2);
}
# level.py

def render(self):
    # ...

    # Render UI text
    
    # Set the text colour to white
    pen(15, 15, 15)

    # Render level number in top left corner
    level_string = "Level: " + str(self.level_number + 1)
    text(level_string, 2, 2)

    # Render score
    score_string = "Score: " + str(self.player.get_score())

    # Get size of score text
    w, h = measure(score_string)

    # Render score in top right corner
    text(score_string, Constants.SCREEN_WIDTH - 2 - w, 2)

We need to add one to the level number when creating the string to render, because internally, the game numbers the levels starting at zero, and “Level 0” wouldn’t make much sense to an uninformed user.

For the 32blit code, the order of the optional parameters in the screen.text function means that if we want to specify the alignment, then we also need to specify whether to use variable-width spacing, because it comes before the alignment parameter in the function definition.

When you run the code, the current level and score will now both be displayed on the screen:

Final touches

To add more variety to our game, we will randomly generate the initial direction and speed of the enemies. We will also add some semi-transparent pipes in the background, with some water at the bottom of the screen.

Randomising enemy direction

In order to select the initial direction the enemy should be facing, we can use the rand function (or choice in Python) in the EnemyNinja constructor:

// enemy_ninja.cpp

EnemyNinja::EnemyNinja(float x, float y) : Ninja(Colour::RED, x, y) {
    current_direction = std::rand() % 2 ? 1 : -1;
}
// enemy_ninja.cpp

EnemyNinja::EnemyNinja(float x, float y) : Ninja(Colour::RED, x, y) {
    current_direction = std::rand() % 2 ? 1 : -1;
}
# enemy_ninja.py

class EnemyNinja(Ninja):

    def __init__(self, x, y):
        super().__init__(Ninja.Colour.RED, x, y)

        self.current_direction = choice([-1, 1])

        # ...

Randomising enemy speed

For even more variation, we can randomise the speed of the enemies (within a certain range). We will need to add a MIN_SPEED constant to our constants file, which will be used to determine the lower limit for the randomly generated speed. We will also change the MAX_SPEED value, so that the average speed remains 15 pixels per second.

// constants.hpp, inside Constants namespace

namespace Enemy {
    const float MAX_SPEED = 20.0f;
    const float MIN_SPEED = 10.0f;

    // ...
}
// constants.hpp, inside Constants namespace

namespace Enemy {
    const float MAX_SPEED = 20.0f;
    const float MIN_SPEED = 10.0f;

    // ...
}
# constants.py

class Enemy:
    MAX_SPEED = 20
    MIN_SPEED = 10

    # ...

We will also need to add a new private speed attribute to the EnemyNinja class. This is because each enemy will travel at a different speed, so they each need to store their own speed. We will initialise this speed using code similar to what we used in random_bool: we will generate a random value between 0 and 1, scale it up by the difference between MAX_SPEED and MIN_SPEED, and then add on MIN_SPEED to the resulting value:

// enemy_ninja.cpp

EnemyNinja::EnemyNinja(float x, float y) : Ninja(Colour::RED, x, y) {
    // ...

    speed = Constants::Enemy::MIN_SPEED + (Constants::Enemy::MAX_SPEED - Constants::Enemy::MIN_SPEED) * std::rand() / static_cast<float>(RAND_MAX);
}
// enemy_ninja.cpp

EnemyNinja::EnemyNinja(float x, float y) : Ninja(Colour::RED, x, y) {
    // ...

    speed = Constants::Enemy::MIN_SPEED + (Constants::Enemy::MAX_SPEED - Constants::Enemy::MIN_SPEED) * std::rand() / static_cast<float>(RAND_MAX);
}
# enemy_ninja.py

class EnemyNinja(Ninja):

    def __init__(self, x, y):
        # ...

        self.speed = Constants.Enemy.MIN_SPEED + (Constants.Enemy.MAX_SPEED - Constants.Enemy.MIN_SPEED) * random()

If you are using C++, you will also need to declare the new variable in the header file:

// enemy_ninja.hpp

class EnemyNinja : public Ninja {
public:
    // ...

private:
    // ...

    float speed = 0.0f;
};

In the update function of the EnemyNinja class, we now need to use our speed variable instead of the MAX_SPEED constant:

// enemy_ninja.cpp

void EnemyNinja::update(float dt, Constants::LevelData& level_data) {
    if (ai_state == AIState::PATROLLING) {
        // ...

        // Change this line:
        // velocity_x = Constants::Enemy::MAX_SPEED * current_direction;
        // To this:
        velocity_x = speed * current_direction;

        // ...
    }

    // ...
}
// enemy_ninja.cpp

void EnemyNinja::update(float dt, Constants::LevelData& level_data) {
    if (ai_state == AIState::PATROLLING) {
        // ...

        // Change this line:
        // velocity_x = Constants::Enemy::MAX_SPEED * current_direction;
        // To this:
        velocity_x = speed * current_direction;

        // ...
    }

    // ...
}
# enemy_ninja.py

def update(self, dt, level_data):
    if self.ai_state == EnemyNinja.AIState.PATROLLING:
        # ...

        # Change this line:
        # self.velocity_x = Constants.Enemy.MAX_SPEED * self.current_direction
        # To this:
        self.velocity_x = self.speed * self.current_direction

        # ...

    # ...

If you run the code, you will see that each enemy now faces a random direction and all of them have a slightly different speed:

Adding pipes in the background

Next, we will add semi-transparent pipes in the background, which are unique to each level. This isn’t necessary, but it helps make the background look more interesting.

On the PicoSystem, MicroPython is significantly slower than C++, and drawing sprites is particularly time-consuming. Adding these pipes results in a significant performance drop when using MicroPython, so it’s probably a good idea to skip this section if you’re using MicroPython. Alternatively, you may want to try it out and then remove it afterwards.

We will need to add a new array to our LevelData data structure, which we will use to store the new tile data:

// constants.hpp, inside Constants namespace

struct LevelData {
    // ...

    // Background pipe data
    uint8_t pipes[GAME_WIDTH_TILES * GAME_HEIGHT_TILES];
};
// constants.hpp, inside Constants namespace

struct LevelData {
    // ...

    // Background pipe data
    uint8_t pipes[GAME_WIDTH_TILES * GAME_HEIGHT_TILES];
};
# constants.py

class LevelData:
    def __init__(self, platforms, extras, entity_spawns, pipes):
        # ...

        # Background pipe data
        self.pipes = pipes

    def copy(self):
        return LevelData(self.platforms.copy(), self.extras.copy(), self.entity_spawns.copy(), self.pipes.copy())

Now we can add the new information to the LEVELS array:

// constants.hpp, inside Constants namespace

const LevelData LEVELS[LEVEL_COUNT] = {
    // Level 1
    {
        // Platform data
        {
            // ...
        },
        
        // Coin, gem and ladder data
        {
            // ...
        },

        // Entity spawn data
        {
            // ...
        },

        // Background pipe data
        {
            0xff, 0xff, 0xff, 0xff, 0x0c, 0xff, 0xff, 0xff, 0x1c, 0x16, 0x17, 0xff, 0xff, 0xff, 0xff,
            0xff, 0xff, 0xff, 0xff, 0x0d, 0xff, 0xff, 0xff, 0x0c, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff,
            0x05, 0x16, 0x16, 0x16, 0x17, 0xff, 0xff, 0xff, 0x0c, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff,
            0x0c, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0x0c, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff,
            0x0c, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0x0c, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff,
            0x0c, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0x0c, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff,
            0x17, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0x0c, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff,
            0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0x0f, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff,
            0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0x14, 0x05, 0x16, 0x16, 0x16, 0x16, 0x16,
            0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0x0c, 0xff, 0xff, 0xff, 0xff, 0xff,
            0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0x0c, 0xff, 0xff, 0xff, 0xff, 0xff,
            0xff, 0xff, 0xff, 0xff, 0xff, 0x11, 0x05, 0x06, 0x16, 0x17, 0xff, 0xff, 0xff, 0xff, 0xff,
            0xff, 0xff, 0xff, 0xff, 0xff, 0x19, 0x0c, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff,
            0x06, 0x07, 0xff, 0xff, 0xff, 0x19, 0x0c, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff,
            0xff, 0x0c, 0xff, 0xff, 0xff, 0x19, 0x0c, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff
        }
    },
    // Level 2
    {
        // Platform data
        {
            // ...
        },

        // Coin, gem and ladder data
        {
            // ...
        },

        // Entity spawn data
        {
            // ...
        },

        // Background pipe data
        {
            0xff, 0xff, 0xff, 0xff, 0x0c, 0xff, 0xff, 0xff, 0x14, 0x16, 0x1d, 0xff, 0xff, 0x0c, 0xff,
            0xff, 0xff, 0xff, 0xff, 0x0c, 0xff, 0xff, 0xff, 0xff, 0xff, 0x0c, 0xff, 0xff, 0x0c, 0xff,
            0xff, 0xff, 0xff, 0xff, 0x0c, 0xff, 0xff, 0xff, 0xff, 0xff, 0x14, 0x06, 0x16, 0x1d, 0xff,
            0xff, 0xff, 0xff, 0xff, 0x0f, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0x0c, 0xff,
            0x16, 0x16, 0x16, 0x05, 0x17, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0x0f, 0xff,
            0xff, 0xff, 0xff, 0x0c, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0x14, 0x16,
            0xff, 0xff, 0xff, 0x0c, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff,
            0xff, 0xff, 0xff, 0x0c, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff,
            0xff, 0xff, 0xff, 0x0c, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff,
            0xff, 0xff, 0xff, 0x0d, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff,
            0x04, 0x16, 0x16, 0x15, 0x10, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff,
            0x0c, 0xff, 0xff, 0xff, 0x18, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff,
            0x0c, 0xff, 0xff, 0xff, 0x18, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0x11, 0x06, 0x07, 0xff,
            0x1c, 0x16, 0x06, 0x07, 0x18, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0x19, 0xff, 0x1c, 0x16,
            0x0c, 0xff, 0xff, 0x0c, 0x18, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0x19, 0xff, 0x0c, 0xff
        }
    },

    // Level 3
    {
        // Platform data
        {
            // ...
        },

        // Coin, gem and ladder data
        {
            // ...
        },

        // Entity spawn data
        {
            // ...
        },

        // Background pipe data
        {
            0xff, 0x0c, 0xff, 0xff, 0xff, 0xff, 0x0c, 0xff, 0xff, 0xff, 0xff, 0x0c, 0xff, 0x0c, 0xff,
            0xff, 0x0c, 0xff, 0xff, 0xff, 0xff, 0x0f, 0xff, 0xff, 0xff, 0xff, 0x14, 0x06, 0x1d, 0xff,
            0xff, 0x14, 0x07, 0xff, 0xff, 0xff, 0x0c, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0x14, 0x16,
            0xff, 0xff, 0x1c, 0x16, 0x16, 0x16, 0x17, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff,
            0xff, 0xff, 0x0c, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff,
            0xff, 0xff, 0x0c, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff,
            0x05, 0x16, 0x0e, 0x06, 0x10, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff,
            0x0c, 0xff, 0x0c, 0xff, 0x18, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0x04, 0x16, 0x16,
            0x14, 0x16, 0x1d, 0xff, 0x18, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0x0f, 0xff, 0xff,
            0xff, 0xff, 0x0c, 0xff, 0x18, 0xff, 0xff, 0xff, 0xff, 0xff, 0x04, 0x16, 0x15, 0x16, 0x16,
            0xff, 0xff, 0x0c, 0xff, 0x18, 0xff, 0xff, 0xff, 0xff, 0xff, 0x0c, 0xff, 0xff, 0xff, 0xff,
            0xff, 0xff, 0x0c, 0xff, 0x18, 0xff, 0xff, 0xff, 0x11, 0x06, 0x1d, 0xff, 0xff, 0xff, 0xff,
            0xff, 0xff, 0x0f, 0xff, 0x18, 0xff, 0xff, 0xff, 0x19, 0xff, 0x0c, 0xff, 0xff, 0xff, 0xff,
            0x16, 0x16, 0x15, 0x07, 0x18, 0xff, 0xff, 0xff, 0x19, 0xff, 0x0c, 0xff, 0xff, 0xff, 0xff,
            0xff, 0xff, 0xff, 0x0c, 0x18, 0xff, 0xff, 0xff, 0x19, 0xff, 0x0c, 0xff, 0xff, 0xff, 0xff
        }
    }
};
// constants.hpp, inside Constants namespace

const LevelData LEVELS[LEVEL_COUNT] = {
    // Level 1
    {
        // Platform data
        {
            // ...
        },
        
        // Coin, gem and ladder data
        {
            // ...
        },

        // Entity spawn data
        {
            // ...
        },

        // Background pipe data
        {
            0xff, 0xff, 0xff, 0xff, 0x0c, 0xff, 0xff, 0xff, 0x1c, 0x16, 0x17, 0xff, 0xff, 0xff, 0xff,
            0xff, 0xff, 0xff, 0xff, 0x0d, 0xff, 0xff, 0xff, 0x0c, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff,
            0x05, 0x16, 0x16, 0x16, 0x17, 0xff, 0xff, 0xff, 0x0c, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff,
            0x0c, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0x0c, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff,
            0x0c, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0x0c, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff,
            0x0c, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0x0c, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff,
            0x17, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0x0c, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff,
            0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0x0f, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff,
            0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0x14, 0x05, 0x16, 0x16, 0x16, 0x16, 0x16,
            0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0x0c, 0xff, 0xff, 0xff, 0xff, 0xff,
            0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0x0c, 0xff, 0xff, 0xff, 0xff, 0xff,
            0xff, 0xff, 0xff, 0xff, 0xff, 0x11, 0x05, 0x06, 0x16, 0x17, 0xff, 0xff, 0xff, 0xff, 0xff,
            0xff, 0xff, 0xff, 0xff, 0xff, 0x19, 0x0c, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff,
            0x06, 0x07, 0xff, 0xff, 0xff, 0x19, 0x0c, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff,
            0xff, 0x0c, 0xff, 0xff, 0xff, 0x19, 0x0c, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff
        }
    },
    // Level 2
    {
        // Platform data
        {
            // ...
        },

        // Coin, gem and ladder data
        {
            // ...
        },

        // Entity spawn data
        {
            // ...
        },

        // Background pipe data
        {
            0xff, 0xff, 0xff, 0xff, 0x0c, 0xff, 0xff, 0xff, 0x14, 0x16, 0x1d, 0xff, 0xff, 0x0c, 0xff,
            0xff, 0xff, 0xff, 0xff, 0x0c, 0xff, 0xff, 0xff, 0xff, 0xff, 0x0c, 0xff, 0xff, 0x0c, 0xff,
            0xff, 0xff, 0xff, 0xff, 0x0c, 0xff, 0xff, 0xff, 0xff, 0xff, 0x14, 0x06, 0x16, 0x1d, 0xff,
            0xff, 0xff, 0xff, 0xff, 0x0f, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0x0c, 0xff,
            0x16, 0x16, 0x16, 0x05, 0x17, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0x0f, 0xff,
            0xff, 0xff, 0xff, 0x0c, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0x14, 0x16,
            0xff, 0xff, 0xff, 0x0c, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff,
            0xff, 0xff, 0xff, 0x0c, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff,
            0xff, 0xff, 0xff, 0x0c, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff,
            0xff, 0xff, 0xff, 0x0d, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff,
            0x04, 0x16, 0x16, 0x15, 0x10, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff,
            0x0c, 0xff, 0xff, 0xff, 0x18, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff,
            0x0c, 0xff, 0xff, 0xff, 0x18, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0x11, 0x06, 0x07, 0xff,
            0x1c, 0x16, 0x06, 0x07, 0x18, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0x19, 0xff, 0x1c, 0x16,
            0x0c, 0xff, 0xff, 0x0c, 0x18, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0x19, 0xff, 0x0c, 0xff
        }
    },

    // Level 3
    {
        // Platform data
        {
            // ...
        },

        // Coin, gem and ladder data
        {
            // ...
        },

        // Entity spawn data
        {
            // ...
        },

        // Background pipe data
        {
            0xff, 0x0c, 0xff, 0xff, 0xff, 0xff, 0x0c, 0xff, 0xff, 0xff, 0xff, 0x0c, 0xff, 0x0c, 0xff,
            0xff, 0x0c, 0xff, 0xff, 0xff, 0xff, 0x0f, 0xff, 0xff, 0xff, 0xff, 0x14, 0x06, 0x1d, 0xff,
            0xff, 0x14, 0x07, 0xff, 0xff, 0xff, 0x0c, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0x14, 0x16,
            0xff, 0xff, 0x1c, 0x16, 0x16, 0x16, 0x17, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff,
            0xff, 0xff, 0x0c, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff,
            0xff, 0xff, 0x0c, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff,
            0x05, 0x16, 0x0e, 0x06, 0x10, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff,
            0x0c, 0xff, 0x0c, 0xff, 0x18, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0x04, 0x16, 0x16,
            0x14, 0x16, 0x1d, 0xff, 0x18, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0x0f, 0xff, 0xff,
            0xff, 0xff, 0x0c, 0xff, 0x18, 0xff, 0xff, 0xff, 0xff, 0xff, 0x04, 0x16, 0x15, 0x16, 0x16,
            0xff, 0xff, 0x0c, 0xff, 0x18, 0xff, 0xff, 0xff, 0xff, 0xff, 0x0c, 0xff, 0xff, 0xff, 0xff,
            0xff, 0xff, 0x0c, 0xff, 0x18, 0xff, 0xff, 0xff, 0x11, 0x06, 0x1d, 0xff, 0xff, 0xff, 0xff,
            0xff, 0xff, 0x0f, 0xff, 0x18, 0xff, 0xff, 0xff, 0x19, 0xff, 0x0c, 0xff, 0xff, 0xff, 0xff,
            0x16, 0x16, 0x15, 0x07, 0x18, 0xff, 0xff, 0xff, 0x19, 0xff, 0x0c, 0xff, 0xff, 0xff, 0xff,
            0xff, 0xff, 0xff, 0x0c, 0x18, 0xff, 0xff, 0xff, 0x19, 0xff, 0x0c, 0xff, 0xff, 0xff, 0xff
        }
    }
};
# constants.py

LEVELS = [
    # Level 1
    LevelData(
        # Platform data
        [
            # ...
        ],
        
        # Coin, gem and ladder data
        [
            # ...
        ],

        # Entity spawn data
        [
            # ...
        ],

        # Background pipe data
        [
            0xff, 0xff, 0xff, 0xff, 0x0c, 0xff, 0xff, 0xff, 0x1c, 0x16, 0x17, 0xff, 0xff, 0xff, 0xff,
            0xff, 0xff, 0xff, 0xff, 0x0d, 0xff, 0xff, 0xff, 0x0c, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff,
            0x05, 0x16, 0x16, 0x16, 0x17, 0xff, 0xff, 0xff, 0x0c, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff,
            0x0c, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0x0c, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff,
            0x0c, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0x0c, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff,
            0x0c, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0x0c, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff,
            0x17, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0x0c, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff,
            0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0x0f, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff,
            0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0x14, 0x05, 0x16, 0x16, 0x16, 0x16, 0x16,
            0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0x0c, 0xff, 0xff, 0xff, 0xff, 0xff,
            0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0x0c, 0xff, 0xff, 0xff, 0xff, 0xff,
            0xff, 0xff, 0xff, 0xff, 0xff, 0x11, 0x05, 0x06, 0x16, 0x17, 0xff, 0xff, 0xff, 0xff, 0xff,
            0xff, 0xff, 0xff, 0xff, 0xff, 0x19, 0x0c, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff,
            0x06, 0x07, 0xff, 0xff, 0xff, 0x19, 0x0c, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff,
            0xff, 0x0c, 0xff, 0xff, 0xff, 0x19, 0x0c, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff
        ]
    ),

    # Level 2
    LevelData(
        # Platform data
        [
            # ...
        ],

        # Coin, gem and ladder data
        [
            # ...
        ],

        # Entity spawn data
        [
            # ...
        ],

        # Background pipe data
        [
            0xff, 0xff, 0xff, 0xff, 0x0c, 0xff, 0xff, 0xff, 0x14, 0x16, 0x1d, 0xff, 0xff, 0x0c, 0xff,
            0xff, 0xff, 0xff, 0xff, 0x0c, 0xff, 0xff, 0xff, 0xff, 0xff, 0x0c, 0xff, 0xff, 0x0c, 0xff,
            0xff, 0xff, 0xff, 0xff, 0x0c, 0xff, 0xff, 0xff, 0xff, 0xff, 0x14, 0x06, 0x16, 0x1d, 0xff,
            0xff, 0xff, 0xff, 0xff, 0x0f, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0x0c, 0xff,
            0x16, 0x16, 0x16, 0x05, 0x17, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0x0f, 0xff,
            0xff, 0xff, 0xff, 0x0c, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0x14, 0x16,
            0xff, 0xff, 0xff, 0x0c, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff,
            0xff, 0xff, 0xff, 0x0c, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff,
            0xff, 0xff, 0xff, 0x0c, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff,
            0xff, 0xff, 0xff, 0x0d, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff,
            0x04, 0x16, 0x16, 0x15, 0x10, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff,
            0x0c, 0xff, 0xff, 0xff, 0x18, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff,
            0x0c, 0xff, 0xff, 0xff, 0x18, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0x11, 0x06, 0x07, 0xff,
            0x1c, 0x16, 0x06, 0x07, 0x18, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0x19, 0xff, 0x1c, 0x16,
            0x0c, 0xff, 0xff, 0x0c, 0x18, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0x19, 0xff, 0x0c, 0xff
        ]
    ),

    # Level 3
    LevelData(
        # Platform data
        [
            # ...
        ],

        # Coin, gem and ladder data
        [
            # ...
        ],

        # Entity spawn data
        [
            # ...
        ],

        # Background pipe data
        [
            0xff, 0x0c, 0xff, 0xff, 0xff, 0xff, 0x0c, 0xff, 0xff, 0xff, 0xff, 0x0c, 0xff, 0x0c, 0xff,
            0xff, 0x0c, 0xff, 0xff, 0xff, 0xff, 0x0f, 0xff, 0xff, 0xff, 0xff, 0x14, 0x06, 0x1d, 0xff,
            0xff, 0x14, 0x07, 0xff, 0xff, 0xff, 0x0c, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0x14, 0x16,
            0xff, 0xff, 0x1c, 0x16, 0x16, 0x16, 0x17, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff,
            0xff, 0xff, 0x0c, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff,
            0xff, 0xff, 0x0c, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff,
            0x05, 0x16, 0x0e, 0x06, 0x10, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff,
            0x0c, 0xff, 0x0c, 0xff, 0x18, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0x04, 0x16, 0x16,
            0x14, 0x16, 0x1d, 0xff, 0x18, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0x0f, 0xff, 0xff,
            0xff, 0xff, 0x0c, 0xff, 0x18, 0xff, 0xff, 0xff, 0xff, 0xff, 0x04, 0x16, 0x15, 0x16, 0x16,
            0xff, 0xff, 0x0c, 0xff, 0x18, 0xff, 0xff, 0xff, 0xff, 0xff, 0x0c, 0xff, 0xff, 0xff, 0xff,
            0xff, 0xff, 0x0c, 0xff, 0x18, 0xff, 0xff, 0xff, 0x11, 0x06, 0x1d, 0xff, 0xff, 0xff, 0xff,
            0xff, 0xff, 0x0f, 0xff, 0x18, 0xff, 0xff, 0xff, 0x19, 0xff, 0x0c, 0xff, 0xff, 0xff, 0xff,
            0x16, 0x16, 0x15, 0x07, 0x18, 0xff, 0xff, 0xff, 0x19, 0xff, 0x0c, 0xff, 0xff, 0xff, 0xff,
            0xff, 0xff, 0xff, 0x0c, 0x18, 0xff, 0xff, 0xff, 0x19, 0xff, 0x0c, 0xff, 0xff, 0xff, 0xff
        ]
    )
]

Finally, we can render the new tiles using our render_tiles function in the Level class:

// level.cpp

void Level::render() {
    // Render border
    render_border();

    // Render background pipes
    screen.alpha = 0x80;
    render_tiles(level_data.pipes);
    screen.alpha = 0xff;

    // ...
}
// level.cpp

void Level::render() {
    // Render background pipes
    alpha(0x8);
    render_tiles(level_data.pipes);
    alpha();

    // ...
}
# level.py

def render(self):
    # Render background pipes
    self.render_tiles(self.level_data.pipes)

    # ...

In order to get semi-transparent tiles, we temporarily change the alpha transparency, but then we need to change it back for the rest of the render function. We can do this with MicroPython, but it would require us using the blend(ALPHA) mode instead of blend(MASK), which would make the game run even slower.

Adding water in the background

Finally, we will add some water tiles along the bottom of the screen, to give a reason for why the player dies when they fall off the bottom of the screen. We will create a new function for this called render_water, which draws the same tile repeatedly along the bottom of the screen:

// level.hpp

class Level {
public:
    // ...

private:
    // ...

    void render_water();

    // ...
};

// level.cpp

void Level::render_water() {
    for (uint8_t i = 0; i < Constants::GAME_WIDTH_TILES; i++) {
        screen.sprite(Constants::Sprites::WATER, Point(Constants::GAME_OFFSET_X + i * Constants::SPRITE_SIZE, Constants::GAME_OFFSET_Y + Constants::GAME_HEIGHT - Constants::SPRITE_SIZE));
    }
}
// level.hpp

class Level {
public:
    // ...

private:
    // ...

    void render_water();

    // ...
};

// level.cpp

void Level::render_water() {
    for (uint8_t i = 0; i < Constants::GAME_WIDTH_TILES; i++) {
        sprite(Constants::Sprites::WATER, Constants::GAME_OFFSET_X + i * Constants::SPRITE_SIZE, Constants::GAME_OFFSET_Y + Constants::GAME_HEIGHT - Constants::SPRITE_SIZE);
    }
}
# level.py

class Level:
    # ...

    def render_water(self):
        for i in range(Constants.GAME_WIDTH_TILES):
            sprite(Constants.Sprites.WATER, Constants.GAME_OFFSET_X + i * Constants.SPRITE_SIZE, Constants.GAME_OFFSET_Y + Constants.GAME_HEIGHT - Constants.SPRITE_SIZE)

Our render_water function references the WATER constant, which doesn’t exist yet. Before we can use our function, we need to add the new constant to our constants file:

// constants.hpp, inside Constants namespace

namespace Sprites {
    // ...

    const uint8_t WATER = 30;
}
// constants.hpp, inside Constants namespace

namespace Sprites {
    // ...

    const uint8_t WATER = 30;
}
# constants.py

class Sprites:
    # ...

    WATER = 30

We are now ready to call this function from within the render method of the Level class:

// level.cpp

void Level::render() {
    // Render border
    render_border();

    // Render background pipes
    screen.alpha = 0x80;
    render_tiles(level_data.pipes);
    screen.alpha = 0xff;

    // Render water
    render_water();

    // ...
}
// level.cpp

void Level::render() {
    // Render background pipes
    alpha(0x8);
    render_tiles(level_data.pipes);
    alpha();

    // Render water
    render_water();

    // ...
}
# level.py

def render(self):
    # Render background pipes
    self.render_tiles(self.level_data.pipes)
    
    self.render_water()

    # ...

When you now run the code, it will look something like this:

Wrapping up

In this episode, we’ve finally made our game completely playable. You can now complete levels by collecting all the coins, and can die if you fall off the screen or touch an enemy. We also added some small improvements such as death and completion animations, along with more randomness for the enemies and some additional background graphics.

You can access the source code for the project here:

What’s next?

There is almost always something else that you can improve or add to a game. “Ninja Thief” has deliberately been kept simple, but it provides a base with which you can experiment and add your own improvements and features. For example, you might want to add:

  • More levels.
  • A menu system.
  • Sound effects and music.
  • A maximum number of lives and a win/lose screen, which show the time taken and total score.
  • Animations for walking, jumping and climbing (the extra sprites are already in the spritesheet).
  • Animations for the water (the extra sprites are already in the spritesheet).
  • Saving of highscores and time taken.
  • Another type of enemy, with different behaviour.

Alternatively, you may be inspired to create your own game, possibly using some of the techniques we have covered. Often, a feature you want to add to your game can seem extremely complex to code, but decomposing the problem into smaller components and then solving each small problem individually can help a lot.

You can also check out the other tutorials if you need help or just want some ideas of features which you can add to your game (either Ninja Thief, or your own creation).