A 50 FPS Game Engine on an 8-Bit Microcontroller

A handheld game built from the PCB up: ATtiny85V, OLED, IR receiver. 8 MHz, 512 bytes of RAM, and a charming atmosphere.

The Ad Astra game running on a small OLED display.

I grew up playing with little widgets: fake Tamagotchis, Game Boy imitations with 100+ built-in games, and the like. It was only natural that I wanted to make my own.

The game

You keep a broken spaceship alive. Asteroids drift past, you mine them and spend the points on upgrades, each of which unlocks a new station to tend or a new crewmate who does the tending for you.

The hardware

I settled on the ATtiny851, driving a common 128×64 1-bit OLED2. To keep the footprint small I skipped a built-in controller and used an IR receiver instead, which works with almost any remote; mine was a generic hi-fi remote I got on AliExpress for pennies. Connecting the components was quite simple, even if my breadboard might’ve led you to believe otherwise:

A breadboard threaded with jumper wires, carrying the ATtiny85, an IR receiver, a power module, and the OLED mid-game, with a small remote beside it

Routing the PCB from this design was also straightforward; the real hard part was the firmware.

Reality check

I had aspired to build a game with an open world, “advanced” AI, and so on, but I quickly got a reality check: even after bitpacking, the framebuffer alone would take up twice the microcontroller’s 512 bytes of RAM. The 8 KB of program memory wasn’t generous enough for grandiose ambitions either. I still made it work though.

Rendering engine

To achieve 50 FPS, rendering happens at quarter resolution (64 by 32 pixels). The OLED’s zoom mode doubles the image vertically on its own, and horizontally each byte is simply sent twice, so the engine doesn’t have to store extra pixels for the upscaling.

The screen’s pixels are either on or off, so 1 bit per pixel should be enough. But compositing is a basic expectation of any rendering engine, and it costs each pixel a second bit: transparency. That’s what lets the black pixels inside the spaceship’s sprite hide the stars behind it, while the black pixels outside it stay transparent and let the stars show through.

We should realise that 2 bits per pixel is wasteful, since the valid states are only black, white, and transparent black: there’s no difference between transparent and opaque white, as both end up as a white pixel regardless of what’s behind.

Keeping the entire framebuffer in memory would be impossible: 64 × 32 × 2 bits is exactly the amount of RAM the microcontroller has, leaving zero bytes for everything else. The pragmatic choice at this point would’ve been to just get a bigger microcontroller. But hobby projects are allowed to be non-pragmatic at times. So I carried on and optimised the renderer to work in a streaming manner, 8 bits at a time, processing 8 pixels simultaneously in a SIMD fashion.

Sprites are stored as 16-bit columns (matching the word-size of the EEPROM): the high byte is an inverted mask, the low byte the fill bits. Compositing eight vertical pixels of a sprite onto the framebuffer is a single expression:

newColumn = oldColumn & transparencyMask | fill;

The sprites are C arrays injected into the source code by a Python script, so the somewhat strange layout doesn’t leak into the art workflow. Lastly, the ATtiny bit-bangs each byte out to the display, as it has no dedicated SPI peripheral.

Game logic

For scripting the game engine, I took inspiration from JavaScript’s prototype system and Unity’s object model. Of course, the size constraints only allowed for a lite version. The final design is a single array of game objects, where each object holds a union of the possible object states and a pointer to its prototype: a special struct, essentially a virtual function table, pointing to the object’s tick and draw functions. Even though simulation and rendering follow the same 50 Hz tick, the two functions are separate because draw has to support rendering an 8-pixel stripe of the object, and thus may get called multiple times per frame. This quasi-vtable approach doesn’t become a performance bottleneck either; the beauty of the ATtiny is that it only deals with SRAM, so there’s no complex caching and there are no line faults so jumping around in memory doesn’t bring a penalty.

In the end, the game does have “AI” too, in the form of a finite state machine (FSM): your astronaut crewmates have a priority list of activities to attempt, based on the player’s current activity.

Saves

The program memory is only 8 KB, so the sprites have to live in the EEPROM. However, there’s still enough space in there to cram in a few dozen bytes for a save file too, which is just the serialised state of the game-object list. To avoid corrupting the saved state on an unexpected shutdown, the EEPROM holds two save buffers, along with a flag telling which one was completed last. The flag only flips once the data has been fully written into the older buffer, which makes saves atomic. It all happens in the background through interrupts, to avoid jitters in the rendering loop.

Was it worth it?

Absolutely. Don’t get me wrong, the gameplay gets old after 15 minutes: you spend the first 5 completing the game, and the rest just watching your crewmates operate the spaceship autonomously as their FSM logic converges.

It was incredibly fun to read the datasheets cover to cover and actually understand the limits, and how to push them. On the one hand, this is as full-stack as it gets, and it allowed me to think about performance on a very different scale than usual. On the other hand, by the end of the project the game and the UART logging code no longer fit into program memory together, so I had to pick which parts of the game to disable.

If I were to redo it, I strongly believe writing a simulator for the console would be the right call: it would allow faster iteration by skipping the flashing step, and it would make it easier to debug the “there’s no output, what now?” type of issue. A simulator would also mean E2E testing in CI. Fortunately, in this case, there’s only so much complexity an 8 KB program can hold, but anything bigger would’ve been prohibitively expensive to develop without a better testing setup.

  1. ATtiny85: https://ww1.microchip.com/downloads/en/DeviceDoc/Atmel-2586-AVR-8-bit-Microcontroller-ATtiny25-ATtiny45-ATtiny85_Datasheet.pdf
  2. 128×64 1-bit OLED: https://cdn-shop.adafruit.com/datasheets/SSD1306.pdf