Embedded game development, part 2

So, the pulse2d engine is done, and honestly, the physics was the easiest part. In a way, it didn't feel much different from writing code for the LC86000. But my goal was to make the game development feel fresh and modern, regardless of the .bss or .rodata, or flash memory size constraints of the microcontroller.

#include PULSE2D_HEADER
#include PULSE2D_GRAPHICS

#include "../include/dust-bg.h"
#include "../include/nebula-bg.h"
#include "../include/planet-bg.h"
#include "../include/stars-bg.h"

PULSE2D_START_PULSE();

/*
 * Define scene:
 * 1 object (physics body)
 * 4 sprites (backgrounds)
 */
PULSE2D_DEFINE_SCENE(Level_One, 1, 4);   

// Total accessible scenes in the game
PULSE2D_GAME_SCENES(Level_One);

PULSE2D_ON_GAMESCENE_START(Level_One)
{
    PULSE2D_SET_SPRITE_FLASH(sprite_nebula,  bg_1, 320, 240);
    PULSE2D_SET_SPRITE_FLASH(sprite_stars,   bg_2, 320, 240);
    PULSE2D_SET_SPRITE_FLASH(sprite_planets, bg_3, 320, 240);
    PULSE2D_SET_SPRITE_FLASH(sprite_dust,    bg_4, 320, 240);

    PULSE2D_ADD_PARALLAX_LAYER(sprite_nebula,  320.0f, 10.0f);
    PULSE2D_ADD_PARALLAX_LAYER(sprite_stars,   320.0f,  3.0f);
    PULSE2D_ADD_PARALLAX_LAYER(sprite_planets, 320.0f, 25.0f);
    PULSE2D_ADD_PARALLAX_LAYER(sprite_dust,    320.0f, 65.0f);
}

PULSE2D_ON_GAMESCENE(Level_One)
{
    PULSE2D_TICK_WORLD(Level_One);
    PULSE2D_RENDER_BACKGROUNDS();
    PULSE2D_RENDER(active_scene);
}

PULSE2D_ON_GAMESTART()
{
    Serial.begin(115200);
    // Do not start the game until a serial connection is established
    PULSE2D_POLL_SERIAL_CONNECTION();
    PULSE2D_INIT(0.0f, 0.0f, 10);
    // Initial scene
    PULSE2D_SET_SCENE(Level_One);
}

PULSE2D_ON_GAMELOOP()
{
    PULSE2D_TICK_GAMESCENE();
}

Doesn't that look brilliant? The DSL is hiding so much nasty raw function pointer dispatch, delayed hardware initialization, and data section attributes that if I couldn't come up with a syntax I was happy with, I probably would have quit the project.

Take a look at the layered parallax outcome of that code:

0:00
/0:16

Breakdown 🎸

Let's break down what's happening, step by step:

For starters, I didn't want game development with the engine to feel like writing plain C or C++. I even went so far as to hide the necessary header includes and provide a nice, teensy makefile that includes most of the tools you need to get started. The first two lines translate into #include s for the engine and physics.

The next couple of lines were, so far, the most difficult problem to solve. I created a bunch of tools in Python to manage assets such as images and backgrounds, and to debug the generated elf files. One of those tools is to build C headers for large images to be stored in flash memory, very much like the work in older chips.

#pragma once

#include <cstdint>

static constexpr uint16_t bg_1_width = 320;
static constexpr uint16_t bg_1_height = 240;

__attribute__((
    section(".progmem"))) static constexpr uint16_t bg_1[320 * 240] = { 0x0863, 
  ... 
};

I ran into some trouble figuring out how to store the backgrounds in the right section and even had to look at Arduino's linker script to see where it was stored. Initially, you'd think of storing in static constexpr or static const space to be the right spot, but that still depends on the compiler. If the images are stored anywhere EXCEPT flash memory, the Teensy would immediately crash. Which is why I created tools to generate the assembly, check your local Arduino linker assigned data regions, and show the memory section use of your game to verify:

And boom, there it is! By far the largest assets of any game of this type should be the backgrounds. To access the flash memory section, we needed:

__attribute__((section(".progmem"))) static constexpr

which should work for any compiler.


The next few lines initialize the engine and our game scenes, the PULSE2D_START_PULSE is where the magic begins:

static pulse2d::HARDWARE_Deferred_Init<pulse2d::Pulse2d> engine;
static pulse2d::HARDWARE_Deferred_Init<pulse2d::graphics::World> world;
void (*pending_transition)() = nullptr;
void (*active_scene_fn)() = nullptr;
static constexpr float PULSE = 1.0f / 60.0f;


It is important to note that the Teensy does not have an OS - there is no heap, and allocating data on the stack inside functions is probably a bad idea. Most data and variables are stored in global static space, which in our case should be the .bss data section.

We use a deferred initialization pattern to wait for the Arduino's runtime before initializing code that depends on hardware states, such as the display, audio, and SD storage. Then we set up our transition and scene dispatch function pointers, and the frame delta.

The code to dispatch scenes and their memory management is probably the most clever in the whole engine:

struct Level_One : pulse2d::teensy::Pulse2d_Level<1, 4>
{};

static std::variant<std::monostate, Level_One> current_scene;

std::variant let's us store the size of the largest item, and with its emplace does not allocate on the heap. What that means is from now on in the game, we simply need to std::visit the current active scene, and we've solved one of the hardest problems of memory management for embedded game development.

Take a quick look at the scene class and note that each scene has its own pools of objects, sprites, and backgrounds, which in total will not exceed the hardcoded object limits. I was told that this precision of pool management is very close to Aerospace engineering. Later, once a scene completes a game tick, the entire scene pool is cleared.


Now that we have our scene pools set up, we can write what happens on scene initialization:

PULSE2D_ON_GAMESCENE_START(Level_One)
{
    PULSE2D_SET_SPRITE_FLASH(sprite_nebula,  bg_1, 320, 240);
    PULSE2D_SET_SPRITE_FLASH(sprite_stars,   bg_2, 320, 240);
    PULSE2D_SET_SPRITE_FLASH(sprite_planets, bg_3, 320, 240);
    PULSE2D_SET_SPRITE_FLASH(sprite_dust,    bg_4, 320, 240);

    PULSE2D_ADD_PARALLAX_LAYER(sprite_nebula,  320.0f, 10.0f);
    PULSE2D_ADD_PARALLAX_LAYER(sprite_stars,   320.0f,  3.0f);
    PULSE2D_ADD_PARALLAX_LAYER(sprite_planets, 320.0f, 25.0f);
    PULSE2D_ADD_PARALLAX_LAYER(sprite_dust,    320.0f, 65.0f);
}


The bg_1, bg_2, ... are the data pointers of the flash section headers from earlier. This looks something like this:

void pulse2d_scene_enter_Level_One()
{
    std::visit(
        [](auto& scene) {
            if constexpr (!std::is_same_v<std::decay_t<decltype(scene)>,
                              std::monostate>) {
                scene.set_from_flash("sprite_nebula", bg_1, 320, 240);
            }
        },
        current_scene);
    /// ...
    std::visit(
        [](auto& scene) {
            if constexpr (!std::is_same_v<std::decay_t<decltype(scene)>,
                              std::monostate>) {
                scene.background_layers.push_back(
                    { "sprite_nebula", 320.0f, 10.0f, 0.0f });
            }
        },
        current_scene);   
   /// ...        
}

Cool, right? Now we have all the sprites and backgrounds set up for the scene, and can begin the tick code, where dreams come true:

PULSE2D_ON_GAMESCENE(Level_One)
{
    PULSE2D_TICK_WORLD(Level_One);
    PULSE2D_RENDER_BACKGROUNDS();
    PULSE2D_RENDER(active_scene);
}

Becomes:

void pulse2d_scene_fn_Level_One()
{
    [[maybe_unused]] auto& active_scene = std::get<Level_One>(current_scene);
    world->step(PULSE);
    [[maybe_unused]] auto& renderer = engine->renderer();

    std::visit(
        [](auto& scene) {
            if constexpr (!std::is_same_v<std::decay_t<decltype(scene)>,
                              std::monostate>) {
                auto& _bg_renderer = engine->renderer();
                scene.update_and_draw_parallax(_bg_renderer, PULSE);
            }
        },
        current_scene);

    engine->tick(*world);
}

Tick tick, boom 💣. Incredibly clean scene management and gameworld engine tick.


The final bits of code are where we tie everything to the Arduino runtime, which uses a void setup call and a void loop:

PULSE2D_ON_GAMESTART()
{
    Serial.begin(115200);
    PULSE2D_POLL_SERIAL_CONNECTION();
    PULSE2D_INIT(0.0f, 0.0f, 10);
    PULSE2D_SET_SCENE(Level_One);
}

PULSE2D_ON_GAMELOOP()
{
    PULSE2D_TICK_GAMESCENE();
}

Becomes:

void setup()
{
    Serial.begin(115200);
    while (!Serial)
        ;
    Serial.println("[DEBUG] setup: serial OK");

    engine.emplace(); // calls emplace on HARDWARE_Deferred_Init
    world.emplace(pulse2d::graphics::Vec2{ 0.0f, 0.0f }, 10); // calls emplace on HARDWARE_Deferred_Init
    engine->init();

    // Clear the scene pools and storage, initiate and set current scene.

    world->clear();
    engine->storage().reset();
    pulse2d::teensy::Pulse2d_Scene_Base::total_bodies = 0;
    pulse2d::teensy::Pulse2d_Scene_Base::total_sprites = 0;
    current_scene.emplace<Level_One>();
    pulse2d_scene_enter_Level_One();
    active_scene_fn = pulse2d_scene_fn_Level_One;
}

void loop()
{
  // The primary scene and transition loop.
  if (active_scene_fn != nullptr)
      active_scene_fn();
  if (pending_transition != nullptr) {
      pending_transition();
      pending_transition = nullptr;
  }
}

And we're done! Spawning the physics bodies and their movement, velocity, and collisions is the easy part, which I'll cover in my next post as I get closer to completion on the pilot game. An auto-scroller asteroid space shooter. 🎮