All projects
February 2025 – March 2025 Shipped

STM32 Synthesiser, running DOOM

An embedded synthesiser on an STM32, and the 3D game engine I wrote to run on it

  • 45 fps game frame rate, with a reduced render distance
  • 48 ms worst-case full-screen refresh, about 18 ms typically
  • ~49% worst-case CPU utilisation across all tasks

Coursework for the Embedded Systems module at Imperial, built with Zakariyyaa Chachia and Hector Oga: a polyphonic synthesiser on the StackSynth keyboard module, which pairs an STM32 microcontroller with 12 keys, four knobs, a joystick and a 128 by 32 OLED display. The firmware is C++ on FreeRTOS. Hector built the waveform generation (a DAC fed by a circular DMA buffer), and Zak built the CAN bus link between keyboard modules, the recording task and most of the timing analysis. I built the user interface, the system state machine and a DOOM-style game engine that runs on the same board.

The synthesiser board with its OLED display showing the game: trees, an enemy and the player's gun, with an FPS counter reading 19.9.
The game running on the synthesiser, with the frame-rate counter in the top left.

Interface and state machine

I wrote the system state machine that sits behind every screen: home, waveform selection, recording and playback, and the game. The key-scanning task uses the current state to decide which inputs to read, so it only does the work each screen needs. I also added the joystick input, volume control and the screens for each feature, and integrated the interface with Hector’s DAC output.

The main menu slides between icons. The transition positions are precomputed at compile time, so the display task never evaluates trigonometric functions while animating, and the record icon is drawn at run time rather than stored as a bitmap.

The OLED showing a two-by-two grid of waveform icons: sine, square, sawtooth and triangle, with sine selected.
The waveform selection screen.

Writing a game engine for the synthesiser

The smallest existing port of DOOM is 1.4 MB, far more than the microcontroller has, so I wrote my own engine and game.

The graphics come from a Python script I wrote with OpenCV. It thresholds each source image, resizes it to the 128 by 32 display with nearest-neighbour sampling, and writes the lit pixels into a C header as coordinates. I first tried Canny edge detection to extract outlines, but a hand-tuned threshold gave much cleaner sprites. The gun’s outline is also precomputed, since drawing it at run time cost too much frame time.

The engine projects objects onto the screen with a simple perspective model, scaling each one by focal length over distance. The world is divided into 100 by 100 chunks, each holding one enemy and one obstacle. Chunks are generated as they come within render distance and dropped as they leave it, which gives an unbounded world in a fixed amount of memory. The player moves with the joystick (scaled linearly, with a dead zone), every move is checked for collisions first, and bullets carry their own depth so they shrink as they travel. Killing an enemy plays a random note through the synthesiser’s note queue without blocking the game.

The game logic runs inside the display-update task, and the controls are read by the key-scanning task into shared state guarded by a mutex, so the game sits inside the same real-time schedule as the synthesiser.

Performance

My first version took over 80 ms to draw a frame. Switching the arithmetic from double to single precision (the microcontroller’s FPU only handles single precision), comparing squared distances in collision checks instead of taking square roots, and replacing the dynamically sized chunk list with a preallocated array brought the average to around 25 frames per second with a render distance of three chunks. With a reduced render distance the game runs at 45 frames per second, with a full-screen refresh of about 18 ms typically and 48 ms in the worst case.

Diagram of the firmware's tasks (scan keys, transmit, decode, signal generator, record, update display), their interrupts, mutexes and queues, and the DAC and DMA buffers.
The firmware's tasks and shared resources. The team's rate-monotonic analysis put worst-case CPU utilisation at about 49%.