---
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.'
date: 2026-05-06
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://github.com/schmelczer/ad_astra
article:
  tags: ['embedded', 'games', 'systems']
  role: Hardware and firmware author
  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
  audience: technical
  media:
    - type: video
      poster: ./_assets/ad-astra.jpg
      webm: /media/video/ad_astra.webm
      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.
      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:
  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
  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.

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.

## Eight pixels at a time

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.

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 & ~invertedMask | 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.

## An object model that fits on one screen

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.

## The game, since you ask

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.

## EEPROM holds the art and the saves

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.

## What I'd change

- **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".
- **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.
