Make true

This commit is contained in:
Andras Schmelczer 2026-06-25 15:45:06 +01:00
parent 8064972c15
commit 4ac244b3ef

View file

@ -1,7 +1,7 @@
--- ---
title: A 50 FPS Game Engine on an 8-Bit Microcontroller title: A 50 FPS Game Engine on an 8-Bit Microcontroller
description: 'A handheld game built from the PCB up: ATtiny85V, OLED, IR receiver, EEPROM. 8 MHz, 8-bit ALU, a locked 20 ms frame, and a 52-byte save file.' description: 'A handheld game built from the PCB up: ATtiny85V, OLED, IR receiver. 8 MHz, 512 bytes of RAM, and a charming atmosphere.'
date: 2026-05-06 date: 2020-03-26
period: 'Spring 2020' period: 'Spring 2020'
thumbnail: thumbnail:
src: ./_assets/ad-astra.jpg src: ./_assets/ad-astra.jpg
@ -11,59 +11,64 @@ links:
url: https://github.com/schmelczer/ad_astra url: https://github.com/schmelczer/ad_astra
article: article:
tags: ['embedded', 'games', 'systems'] tags: ['embedded', 'games', 'systems']
role: Hardware and firmware author
stack: ['C', 'ATtiny85V', 'SPI OLED', 'IR receiver', 'EEPROM', 'KiCad'] stack: ['C', 'ATtiny85V', 'SPI OLED', 'IR receiver', 'EEPROM', 'KiCad']
scale: 8 MHz, 8-bit ALU, 64×32 rendered pixels at a locked 20 ms frame, ~31 mW at full brightness, ~1.5 mA standby scale: 8 MHz, 8-bit ALU, 64×32 rendered pixels at a locked 20 ms frame, ~31 mW at full brightness, ~1.5 mA standby
outcome: A handheld built from schematic to firmware, with a 50 FPS game on it outcome: A handheld built from schematic to firmware, with a 50 FPS game on it
audience: technical
media: media:
- type: video - type: video
poster: ./_assets/ad-astra.jpg poster: ./_assets/ad-astra.jpg
webm: /media/video/ad_astra.webm webm: /media/video/ad_astra.webm
mp4: /media/video/ad_astra.mp4 mp4: /media/video/ad_astra.mp4
captions: /media/video/ad_astra.vtt
alt: Video demonstration of the embedded game running on a small OLED display. alt: Video demonstration of the embedded game running on a small OLED display.
caption: The whole thing, from board and firmware to sprites and game loop, runs on a single ATtiny85V at 8 MHz.
transcript: No spoken dialogue. The handheld board runs its OLED game; the player moves through the small display while the IR input controls gameplay.
project: project:
title: Ad Astra title: Ad Astra
description: 'A handheld game built from a custom PCB up: ATtiny85V, OLED, IR, EEPROM. 8-bit ALU at 8 MHz, locked 50 FPS.'
selected: true selected: true
technologies: ['C', 'ATtiny85V', 'OLED', 'EEPROM', 'PCB design'] technologies: ['C', 'ATtiny85V', 'OLED', 'EEPROM', 'PCB design']
thumbnail:
alt: The Ad Astra handheld game running on its OLED display.
--- ---
I'd done microcontroller work before, but always on someone else's dev board, and it always felt a little like renting. In spring 2020 I wanted to know what it would feel like to own the whole thing, so I drew a schematic in KiCad, ordered the PCB, and soldered an ATtiny85V onto a board with an OLED, an IR receiver, and not much else. The feeling turned out to be very specific: once the copper was mine, a glitch was never just a software inconvenience again. Every bug was the consequence of a decision I had personally made, somewhere between the board layout and the C. Four years after [my first hardware project](/articles/lights-synchronized-to-music/), this was the one where debugging stopped being guesswork about other people's choices. I grew up playing with little widgets; fake tamagotchis, gameboy imitations with 100+ built-in games and the alike. It's just natural that I wanted to make my own.
The budget the chip gives you: an 8-bit ALU at 8 MHz, 8 KB of flash, 512 bytes of RAM, no FPU, no dedicated SPI peripheral. The game had to fit inside that, hold 50 FPS (the main loop locks every frame to 20 ms), and still leave room for the art. ## The game
## Eight pixels at a time You keep a broken spaceship alive. Asteroids drift past; you mine them and spend the points gained on upgrades, each of which unlocks a new station to tend, or a new crewmate.
The OLED is 128×64, but the firmware never sees that resolution. It renders at 64×32 and lets the panel double every pixel, which nobody notices at that physical size and which quarters the work per frame. ## Reality check
I settled on the ATtiny85 as my platform, connected to a common 128×64 resolution 1-bit OLED display. I strived to keep the footprint small, so intead of a built-in controller as the input device, I opted for an IR receiver which is compatible with a wide-range of remote controllers. I ended up using a generic HiFi system's little plastic remote which was incredibly cheap to get on AliExpress. Routing the PCB was as simple as connecting the square peg with the square hole. The real challenge was the firmware.
I had aspired to build a game with an open world, "advanced" AI, etc. but was quickly got the reality check when I had to realise that even after bitpacking the framebuffer, it would take up twice the space of the microcontroller's 512 byte (S)RAM. Meanwhile, the 8KB program memory wasn't generous enough either to support grandiose ambitions. I still made it work though.
## Rendering engine
Let's look at the rendering engine. To achieve 50 FPS, the rendering happens at 1/4 resolution of 64 by 32 pixels and the OLED panel scales up the image by a factor of 2 on its own so we don't need to waste bandwidth transferring the extra pixels. Even though the screen's pixels are either on or off, a basic expectation of a rendering engine is compositing. So we can't just store the frame as a 1 bit buffer but we have to assign an additional transparency bit per pixel. This way, we can express that in the spaceship's sprite, the black pixels on the inside are meant to hide the stars behind, but the outside black pixels are transparent, so the stars should show through.
> We should realise that 2 bits per pixel is wasteful as the valid states are: black, white, and transparent black; as there's no difference between transparent and non-transparent white, both would result in a white pixel in the end regardless of what was behind it.
> So in practice, I made an arbitrary choice for the 4th state to use it as a "flickering" state which changes between black & white every frame allowing for simple animations without having to mutate the sprites to achieve this.
Keeping the entire framebuffer in memory would be impossible as 64\*32\*2 is exactly the amount of memory available on the microcontroller. A 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 on 8 bits at a time processing 8 pixels simultenously in a SIMD manner.
Sprites are stored as 16-bit columns: the high byte is an inverted mask, the low byte the fill bits. Compositing eight vertical pixels of sprite onto the framebuffer is one expression: Sprites are stored as 16-bit columns: the high byte is an inverted mask, the low byte the fill bits. Compositing eight vertical pixels of sprite onto the framebuffer is one expression:
```c ```c
newColumn = oldColumn & ~invertedMask | fill; newColumn = oldColumn & transparencyMask | fill;
``` ```
One AND, one OR, eight pixels handled. The chip has no SIMD, but every byte-wide operation already works on eight pixels at once if you arrange the data so that it can. Arranging for it (in the sprite format, in the compositing window, in the Python script that generates the C arrays) is most of what the display driver is. The panel itself speaks SPI, which the ATtiny doesn't have, so the driver bit-bangs it through the USI peripheral, four register writes per bit. The sprites are C arrays injected into the source code by a python script, so the somewhat strange layout doesn't leak to the art level. Lastly, the ATtiny bit-bangs the 8 pixels to the display, as there's no dedicated SPI peripheral.
## An object model that fits on one screen ## Game logic
Every game object points at a `Prototype`, a struct holding exactly three things: a `tick` function, a `draw` function, and a size. Dispatch means following a pointer. There are at most ten live objects (one spaceship, one astronaut you steer between stations, up to four asteroids drifting past), so the "engine" is a fixed array and a for loop, and that is genuinely all the engine this game needs. I was inspired by JavaScript's prototype system and Unity's object system for scripting the game engine. Of course, only a lite version was practicable due to the size constraints and the final design ended up with a single game object array which holds a Union of object states and a pointer to the prototype of each object which is a special struct, essentially a vft, pointing to the object's `tick` and `draw` functions. Even though the simulation and rendering follow the same 50 Hz ticks, the functions are separate as `draw` has to support rendering a 8 pixel stripe of the object an thus may get called multiple times. This quasi-vft approach doesn't become a performance bottleneck due to the beauty of the AtTiny only having to deal with SRAM and no complex caching and line-faults.
## The game, since you ask In the end, the game does have "AI" too, in the form of a FSM where your astronaut crewmates have a priority list of which activity to do if possible based on the player's current activity.
You keep a broken spaceship alive. Asteroids drift past; you mine them and spend the proceeds on upgrades (beds, then a turret, then a table), each of which unlocks a new station to tend. Firing the turret costs health, so defending the ship is a real decision rather than a reflex. The table's interaction handler is a function called `showLove`, which is the kind of thing you find in your own four-year-old firmware and decide to leave exactly as it is. ## Saves
## EEPROM holds the art and the saves The program memory is only 8 KB, so the sprites have to live in the EEPROM. However, there's enough space to cram a few dozen bytes in there too for the save files. To avoid corrupting the saved state with an unexpected shutdown, there are two buffers maintained for them inside the EEPROM with a flag to tell which one is the last completed one. The flag is only flipped once the saved data is put into the least recently use save buffer ensuring atomicity during saves. This all happens in the background through interrupts to avoid jitters in the rendering loop.
Flash was for code, so the sprites live in EEPROM, packed into the 16-bit column format by a Python script that eats PNG sheets and emits C arrays; the spaceship alone is 216 bytes. Save state is 52 bytes: hull health, astronaut count, progression, screen contrast. Writes happen byte by byte from an interrupt handler and skip any byte that hasn't changed, so saving never stalls the frame loop and the EEPROM's limited write endurance gets spared. ## Was it worth it?
## What I'd change Absolutely. Don't get me wrong, the gameplay gets old after 15 mins, of which you spend the first 5 completing the game and the rest is just watching your crewmates operate the spaceship automomously.
- **A host-side emulator.** Debugging on the hardware was character-building and slow. A small SDL simulator linking the same C files would have turned "reflash and hope" into "rebuild and run". 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 different scale than usually. On the other hand, having to decide which part of the code to disable to make space for the UART implementation used for printing logs as by the end of the project, it wouldn't have fit into program memory.
- **Power-loss-safe saves.** The byte-by-byte writes are gentle on the EEPROM but they aren't atomic; pull the battery mid-save and you can get a torn save. A double-buffered save with a commit byte would have cost a handful of the bytes I wasn't using.
- **Battery numbers I'd actually trust.** I measured peak and standby draw, but never a full gameplay session, so the honest answer to "how long does it last" is that it outlasted my patience. Redoing the project, I believe writing a simulator for the console would've been the right call to allow faster iteration without going through the flashing process and better debug the "there's no output, what now" types of issues. Adding a simulator would also mean E2E testing in CI. Fortunately, there's only so much complexity that an 8KB program, but anything bigger would be prohibitivly expensive to develop without a better testing setup.