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 gadgets: fake Tamagotchis, Game Boy imitations with 100+ built-in games, and the like. Naturally, 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 upgrade unlocks either a new station to tend or a new crewmate to tend it for you.

The hardware

I settled on the ATtiny85V1, driving a common 128×64 1-bit OLED2. To keep the footprint small, I skipped a built-in controller and used an IR receiver that works with almost any remote. Mine was a generic hi-fi remote I bought on AliExpress for pennies. Connecting the components was quite simple, even if my breadboard suggested otherwise:

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

Reality check

I had planned an open world, “advanced” AI, and more, but I quickly got a reality check. Even after bit-packing, the framebuffer alone would’ve needed twice the microcontroller’s 512 bytes of RAM. The 8 KB of program memory left little room for grand ambitions either. Still, I made it work.

Rendering engine

To achieve 50 FPS, rendering happens at quarter resolution (64 by 32 pixels). The OLED’s zoom mode doubles the image vertically, while each byte is simply sent twice horizontally, 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 to represent 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 only three states are distinct: black, white, and transparent black. Transparent and opaque white both produce a white pixel regardless of what’s behind it.

Keeping the entire framebuffer in memory was impossible: 64 × 32 × 2 bits is exactly the amount of RAM the microcontroller has, leaving nothing for the game itself. The pragmatic choice would’ve been a bigger microcontroller, but hobby projects don’t always have to be pragmatic. I carried on and made the renderer stream 8 bits at a time, processing 8 pixels in parallel in a SIMD-like fashion.

Sprites are stored as 16-bit columns (matching the word size of the EEPROM): the high byte is a mask, and the low byte contains 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.

Game logic

For the game engine’s scripting model, I borrowed ideas from JavaScript prototypes3 and Unity objects4. The size constraints allowed only a lightweight version. All game objects live in one array. Each holds a union of its possible states and a pointer to its prototype: essentially a virtual function table5 containing the object’s tick (to update the object and interact with others on every game tick) and draw functions. Although simulation and rendering share the same 50 Hz tick, they need separate functions because draw renders one 8-pixel stripe at a time and may run several times per frame. This quasi-vtable doesn’t become a bottleneck either. The beauty of the ATtiny is that it only has SRAM, so there’s no complex caching or line faults that would make jumping around in memory expensive.

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

Saves

The program memory is only 8 KB, so the sprites live in the EEPROM. There is still enough room for a save file: a few dozen bytes containing the serialised game-object list. To prevent an unexpected shutdown from corrupting it, the EEPROM holds two save buffers and a flag identifying the last completed one. The flag flips only after the older buffer has been fully overwritten, making saves atomic. It all happens in the background through interrupts to avoid jitter 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 five completing the game, then watch your crewmates run the spaceship as their state machines converge into a soothing loop.

Reading the datasheets cover to cover, understanding the limits, and learning how to push them was incredibly fun. This is about as full-stack as a project gets, and it made me think about performance on a completely different scale from what I was used to.

By the end, though, the game and my software-UART logging code no longer fit in program memory together, so I had to disable parts of the game whenever I needed logs. So if I built it again, I’d start with a simulator. Skipping the flashing step would make iteration faster and make “there’s no output, what now?” failures much easier to debug. It would also enable end-to-end tests in CI. An 8 KB program can only hold so much complexity, but developing anything larger without better testing would’ve been prohibitively slow.

  1. ATtiny85V: 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://www.olimex.com/Products/Modules/LCD/MOD-OLED-128x64/resources/SSD1306.pdf
  3. JavaScript prototypes: https://developer.mozilla.org/en-US/docs/Web/JavaScript/Inheritance_and_the_prototype_chain
  4. Unity objects: https://docs.unity3d.com/Manual/Components.html
  5. virtual function table: https://eli.thegreenplace.net/2013/12/05/the-cost-of-dynamic-virtual-calls-vs-static-crtp-dispatch-in-c
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

Searches titles, descriptions, and the full text of every article.

    to move to open Esc to close