---
title: A 50 FPS Game Engine on an 8-Bit Microcontroller
description: 'A handheld game built from the PCB up: ATtiny85V, OLED, IR receiver. 8 MHz, 512 bytes of RAM, and a charming atmosphere.'
date: 2020-03-26
period: 'Spring 2020'
thumbnail:
  src: ./_assets/ad-astra.jpg
  alt: The Ad Astra game running on a small OLED display.
links:
  - label: Source
    url: https://git.schmelczer.dev/andras/ad_astra
article:
  tags: ['embedded', 'games', 'systems']
  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
  outcome: A handheld built from schematic to firmware, with a 50 FPS game on it
  media:
    - type: video
      poster: ./_assets/ad-astra.jpg
      webm: /media/video/ad_astra.webm
      mp4: /media/video/ad_astra.mp4
      alt: Video demonstration of the embedded game running on a small OLED display.
project:
  title: Ad Astra
  selected: true
  technologies: ['C', 'ATtiny85V', 'OLED', 'EEPROM', 'PCB design']
---

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 game

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.

## 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:

```c
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 to the art level. Lastly, the ATtiny bit-bangs the 8 pixels to the display, as there's no dedicated SPI peripheral.

## Game logic

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.

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. 

## 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.

## Was it worth it?

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.

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. 

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.   
