Dual-Microcontroller DOOM Game Engine
An embedded game engine split across two processors, with custom inter-processor communication that cut rendering latency by 60 % against a single-processor implementation.
Evidence
- −60 %Render latencyvs single-processor
- 97/100Project mark
- 240 MHzESP32-S3
- 40 MHzFramebuffer linkSPI
- 921,600Sync linkbaud, UART
- 240 × 320DisplayILI9341, 2.8 in
Case study
The problem
- A raycaster on one microcontroller has to finish tracing the frame before it can start pushing pixels, and pushing pixels is slow.
- The obvious fix — a faster part — was not available inside the module budget.
The design
- Split the engine across two processors: an ESP32-S3 at 240 MHz alongside an STM32L476RG.
- Two links rather than one bus. Framebuffers move over SPI at 40 MHz; the pair stays in step over UART at 921,600 baud.
- Separating bulk from urgent traffic means a frame transfer never sits in front of a synchronisation message.
What I did
- Wrote the engine and the inter-processor protocol.
- Chose the link rates and the split, and defended both in the report.
- Built and debugged the hardware, including the 2.8-inch ILI9341 interface.
How it was tested
- Measured render latency against a single-processor implementation of the same engine on the same display.
- Verified sync-link error budget across the temperature range without an external crystal at both ends.
Result
- 60 % lower rendering latency than the single-processor build.
- 97/100 on the project. Module overall 87.
The hardware
Title screen
The title screen on the panel it was written for — 240 × 320, driven over the framebuffer link. - AudioLevel theme
The level theme, rebuilt from square waves and played back through the rig’s own speaker. There is no sample in the firmware — the tune is synthesised from the noise the hardware can already make. - On hardware
The engine running on the board, frame timer along the top edge. The demo further up this page is a reimplementation of this renderer in the browser; this is the hardware it was written on. A clip of the DOOM engine running on the small TFT panel: a textured corridor, a sprite enemy, and a status bar showing health and ammunition, with a frame-rate counter along the top edge.
Two processors, one engine: an ESP32-S3 at 240 MHz alongside an STM32L476RG. Framebuffers move over SPI at 40 MHz and the pair stays in step over UART at 921,600 baud, on a 2.8-inch ILI9341 at its native 240 × 320.
Two links rather than one was the decision that took the most arguing. A single bus carries both bulk pixel data and time-critical synchronisation, and the frame transfer then sits in front of every sync message. Separating them means the wide, slow-to-drain path never blocks the narrow, urgent one.
Decisions
- 01
Why two links instead of one bus?
Bulk and urgent traffic have opposite requirements. A framebuffer wants width and does not care about latency; a sync message wants latency and is a few bytes wide. Sharing a bus means every sync waits behind a frame.
- 02
Why 921,600 baud on the sync link?
It is the highest standard rate both parts clock cleanly without a custom divisor, which keeps the error budget inside spec across the temperature range without an external crystal on both ends.