Desktop console · Adafruit Fruit Jam · RP2350

MicroPython is fast enough to make games.

A desktop game console built from an Adafruit Fruit Jam board, a 15-inch monitor and a gamepad. No operating system, no GPU, no game engine — the games are Python, and they hold 60 frames a second because the software is written around the frame, not around the language.

One frame · 16.67 ms Shown at 1 / 100 speed
0 µs Headroom left over: not instrumented yet — see Deep dive 16,670 µs

Every game on this console is built to finish its work before the display asks for the next frame. Shown here is the most common of three ways the games split that work across two cores — see how the two cores split the frame. Audio refill isn't on either track as drawn: it runs from an I2S IRQ, and that IRQ fires on core 0 — briefly interrupting whatever core 0's loop is doing rather than running independently of it.

System story

What this thing is

It is a small desktop console. A Fruit Jam board sits next to a 15-inch monitor and drives it directly over DVI. A gamepad breakout plugs into the STEMMA QT header and talks I2C, not USB. Power is a single USB-C cable. There is no case fan, no storage beyond onboard flash, and nothing between the game code and the hardware.

Games are written in MicroPython and copied onto the board as .py files. A launcher (menu.py) lists whatever is in /apps and starts the one you pick. Turning it on takes about a second.

No operating system

MicroPython is the whole runtime. Nothing schedules against you, so frame timing is yours to control.

No GPU

Pixels are written into a framebuffer in RAM by the CPU, then handed to the display by DMA.

No engine

Each game carries its own loop, its own renderer, and its own idea of what a sprite is.

One language

Python all the way down, with hot paths recompiled by the Viper emitter or hand-written in Thumb-2 assembly.

Why it is worth documenting

The usual advice is that Python is too slow for real-time graphics on a microcontroller. That is true of Python written the usual way. It stops being true once allocation is pushed out of the main loop, hot pixel work is compiled rather than interpreted, and the frame is treated as a hard budget instead of a hope.

The current build backs that up at scale rather than in a single demo: thirteen arcade ports share one launcher and one DVI/audio driver stack, plus a fourteenth — a dual-core wireframe renderer in the style of Battlezone — still mid-build.

Architecture

How the system holds together

Six things have to happen every frame: read input, advance the world, clear the framebuffer, draw it, keep audio fed, and hand the finished frame to the display at the moment it is wanted. Each one is handled by a different part of the chip.

SubsystemHandled byNotes
Video outHSTX + 3× DMATMDS-encoded DVI at a fixed 640×480; a dispatcher channel re-arms an executer channel through a wrapping ring buffer, chaining HSTX commands then pixel data with no CPU in the pixel path. A hard IRQ on the executer marks the frame boundary
FramebufferSRAM320×240 RGB565 or RGB332 for detailed games, 640×480 RGB332 for vector-style ones; the driver line/pixel-doubles anything smaller than 640×480 up to full screen. Every game in this build keeps its buffer in internal SRAM — the driver's optional PSRAM streaming path exists but isn't exercised yet
DrawingCore 0 (or Core 1), Viper / asmSprite blits, fills and line work compiled to native code; clipping resolved once, before the inner loops
Game logicCore 0 + Core 1 via _threadNearly every game splits input/update on core 0 from drawing on core 1; the split is per-game, not a framework rule
AudioI2S + DMATLV320DAC3100 codec fed by IRQ-driven double buffers; an 8-voice mixer sums voices in Viper and never allocates
InputI2CGamepad breakout polled at a fixed interval (typically every 15–30 ms, not tied to the frame boundary) over I2C — STEMMA QT, address 0x53. Two ADC axes plus a GPIO bulk register decoded into a small integer state word, not a USB HID report. The driver itself is minimal and lightweight, with no dynamic allocation
TimingDMA frame flagThe main loop spin-waits on display.wait_frame(), a flag the frame-boundary IRQ writes, so drawing never races the scan-out

The shape of a game

Every game follows the same skeleton. Allocate everything up front — framebuffers, sprite tables, particle pools, sound buffers — then enter a loop that allocates nothing at all. Once the loop is running, the garbage collector has no reason to wake up, and frame times stop jittering.

# set up once, outside the loop
fb = bytearray(SCREEN_W * SCREEN_H * 2)
display = DVI_RP2_HSTX()
display.begin(fb, rv_colors.COLOR_MODE_BGR565, height=SCREEN_H, width=SCREEN_W, bytes_per_pixel=2)
gamepad = Gamepad()                                     # I2C breakout, not USB
spr = array('i', [0] * (MAX_SPRITES * 6))   # x, y, dx, dy, frame, flags
ctl = array('i', [0] * 8)                    # packed args for the asm blitter

while running:
    gamepad.read()                 # one I2C transaction, no allocation
    update(spr, gamepad)           # viper, no allocation
    display.wait_frame()           # spin on the frame-boundary flag
    fill_asm(fb, 0)                 # asm_thumb clear
    draw_sprites(fb, spr, ctl)     # asm_thumb inner loop
    mixer.service()                # tops up the I2S double buffer

Everything above the loop is allowed to be slow and readable. Everything inside it is not.

How the two cores split the frame

All 13 games (plus the in-progress Battlezone) start core 1 with _thread.start_new_thread(core1, ()) and run their own loop as core 0. But what each core does splits three distinct ways, not one.

1 — Render, then present

Core 0 reads input, updates the world, and renders freely into a back buffer (fb2), then flags it ready. Core 1 waits for vsync, then copies fb2 → fb — and skips the copy rather than tear if core 0 hasn't finished.

2 — Present, then render

The vsync wait moves to core 0's draw step instead: it renders into fb2 only once display.wait_frame() returns. Core 1 just copies fb2 → fb as fast as it can, sometimes gated by its own ready-flag handshake.

3 — Split logic / draw, no back buffer

Core 0 does input and physics only — never touches a pixel. Core 1 runs while: draw(), and draw() itself waits for vsync and paints straight into the single live framebuffer. No second buffer, no copy.

GameMethodCore 0Core 1
Arkanoid1Logic + renderVsync + copy
Defender1Logic + renderVsync + copy
Pac-Man1Logic + renderVsync + copy
Centipede1Logic + renderVsync + copy
Zaxxon2Logic + vsync-gated renderFree-running copy
Qix2Logic + vsync-gated renderFree-running copy
Pole Position2Logic + vsync-gated renderCopy on handshake flag
Scramble2Logic + vsync-gated renderVsync-gated copy (both sides wait)
Asteroids3Logic onlyVsync + draw, no copy
Lunar Lander3Logic onlyVsync + draw, no copy
Missile Command3Logic onlyVsync + draw, no copy
Gravitar3Logic onlyVsync + draw, no copy
Star Castle3Logic onlyVsync + draw, no copy
Battlezone WIP3Logic onlyVsync + draw, no copy

Read directly from each game's core0()/core1() functions. Scramble is the one outlier: its core0 draw step and its core1 copy step each independently call display.wait_frame() — the two aren't coordinated, which method 2's other three titles avoid.

Games

What runs on it

Mostly arcade remakes, because arcade games are honest tests: fixed frame rate, many moving objects, and an audience that notices a dropped frame immediately.

Arkanoid320×240 · RGB565
Asteroids640×480 · RGB332
Centipede320×240 · RGB332
Defender320×240 · scrolling
Gravitar640×480 · vector
Lunar Lander640×480 · RGB332
Missile Command640×480 · trails, explosions
Pac-Man320×240 · RGB565
Pole Position320×240 · pseudo-3D road
Qix320×240 · area fill
Scrambletilemap · scrolling
Star Castle640×480 · RGB332
Zaxxon320×240 · axonometric

Thirteen titles, one per launcher slot — each name above links to its source. Two more sit on the flash outside that rotation: a voxel-landscape flyover is on the board but has no menu slot pointing at it, and a dual-core wireframe renderer in the style of Battlezone is mid-build — main.py refuses to launch it until its top-level script is wrapped in a proper main().

Gravitar and Star Castle are two-stage — the links above go straight to the real game, in apps/Gravitar/ and apps/Starcastle/. What's actually in /apps on the launcher (gravitar_boot.py, starcastle_boot.py) is a thin loader that sets up the framebuffer first, then imports the module linked here.

Deep dive

Where the time actually goes

Three tiers of code do the work, and knowing which tier a function belongs in is most of the performance story.

TierUsed forRelative cost
Interpreted PythonSetup, level loading, menus, anything off the frame pathBaseline — bytecode dispatch and boxed objects on every operation; fine once it never runs inside the loop
Viper (@micropython.viper)Per-object updates, collision, fixed-point math, pointer walks over arraysFaster on integer/array work — native machine ints and direct ptr8/ptr16/ptr32 access, no boxing or bytecode dispatch
Thumb-2 (@micropython.asm_thumb)Sprite blits, fills, span rendering — the innermost pixel loopsFastest — hand-scheduled machine code, no interpreter or type-checking overhead at all

Rules that came out of the measurements

  • The main loop allocates nothing. Pre-allocate flat array objects and index into them; do not build tuples or lists per frame.
  • Pass one packed int32 control array into an assembly primitive rather than several arguments — argument marshalling costs more than the work in short calls.
  • Clip once against the screen bounds before entering the loops, then run unsigned-only coordinates inside.
  • Write two pixels at a time with a 32-bit str, using strh only for an odd leading or trailing pixel.
  • Wait on the video DMA's frame flag before clearing, so the clear never overtakes scan-out and tears.
  • Keep the framebuffer in SRAM if it fits. PSRAM works, but the write cost shows up in every fill.

How much of each game is native code

No per-stage timing has been logged yet — time.ticks_us() instrumentation hasn't been added. What's countable today is how much of each game's own code has already been pushed down a tier, by function count.

GameLinesViper functionsThumb-2 functions
Defender1,594313
Scramble1,348233
Pac-Man1,531222
Gravitar1,493201
Star Castle1,017201
Qix908173
Centipede971162
Asteroids853144
Pole Position983112
Arkanoid1,44392
Lunar Lander92591
Missile Command86181
Zaxxon36162
Battlezone WIP1,978205

Counted directly from each game's source (grep -c '@micropython.viper' / '@micropython.asm_thumb'). Defender carries the most Viper functions of any title — its scrolling terrain and enemy-wave logic is almost entirely off the interpreter.

Video, in more detail

The HSTX peripheral pulls pixel data from three chained DMA channels rather than a single ping-pong pair. A dispatcher channel feeds four-register control blocks into a 16-byte ring buffer that continuously re-arms an executer channel; the executer alternates between sending HSTX timing commands and a row of framebuffer pixels to the HSTX FIFO, chaining back to the dispatcher after each. (A third streamer channel exists for framebuffers that live in PSRAM, pulling one scanline at a time through the XIP interface — none of this build's games use it.) Once a frame's worth of control blocks has been consumed, a nested "restart frame" block re-triggers the dispatcher and fires a hard IRQ that sets a one-word frame-boundary flag. Games never touch DMA registers directly; they just call display.wait_frame(), which spins on that flag.

Video timing is fixed at 640×480 (16/96/48 front-porch/sync/back-porch horizontally, 10/2/33 vertically — standard 640×480 timing, ~60 Hz) — the driver only supports that one mode. Games with a smaller framebuffer, like the 320×240 titles, don't get their own timing; the driver pixel- and line-doubles their buffer up to fill the full 640×480 output.

Audio, in more detail

Sound goes out over I2S to a TLV320DAC3100 codec. The mixer allocates two interleaved stereo buffers up front — BUF_A and BUF_B, each chunk × 4 bytes (256-sample chunks × 2 channels × 16-bit samples = 1,024 bytes) — and alternates between them. Up to 8 voices can play at once; each is a plain PCM bytearray with its own position, volume and loop flag. Refilling a buffer is one Viper method, _fill_into(), that walks every active voice, sums into an accumulator, clamps, and duplicates the result into both stereo channels — a single native pass with no per-voice Python calls and no allocation, safe to run from the I2S write-complete IRQ. Default sample rate is 22,050 Hz; the codec itself supports 8,000–192,000 Hz.

Mixer.load() decodes a whole .wav into a PCM bytearray up front, not streamed — and these add up. Arkanoid alone carries roughly 745 KB across its sound set (its xlife.wav and warp.wav are each over 80 KB), well past what fits in an RP2350's internal SRAM alongside a framebuffer and game state. In practice that means sound data is usually the thing that ends up pushed out into the board's 8 MB of PSRAM. That's not a playback problem, unlike the framebuffer case above: at 8 voices × 22,050 Hz × 16-bit, the mixer's worst-case read rate is around 350 KB/s, under 1% of PSRAM's roughly 37.5 MB/s throughput — and those reads are sequential and paced by the I2S DMA, not the 16.67 ms frame clock, so PSRAM's higher per-access latency never has anywhere to show up.

Build guide

Making one

The parts list is short and nothing needs to be soldered.

PartDetailNotes
Adafruit Fruit JamRP2350B, 16 MB flash + 8 MB PSRAMDVI out, 2× USB host, onboard TLV320DAC3100 audio codec
Monitor15-inch, HDMI inAnything that accepts 640×480 over a DVI-to-HDMI cable — that's the driver's only supported timing, regardless of a game's internal resolution
GamepadI2C, STEMMA QTA joystick + button breakout speaking the Seesaw register protocol (STATUS/GPIO/ADC), fixed at address 0x53 — not a USB controller
CableHDMI, USB-CPower and video
01

Flash MicroPython

Hold BOOTSEL, plug in, drop the .uf2 on the drive that appears. This isn't the stock firmware from micropython.org/download — it's a custom build, MicroPython v1.27.0-dirty (built 2025-12-20) targeting the SparkFun IoT RedBoard RP2350. That target is what gives this build PSRAM support and access to a pre-release HSTX class; the DVI driver used throughout (dvi_rp2_hstx_frame_sync_v2.py) is SparkFun's own driver, lightly modified for this project.

02

Copy the drivers

Into /lib: dvi_rp2_hstx_frame_sync_v2.py (video), colors.py, gamepadfast.py (I2C gamepad), audio_mixer.py or audio_mixer2.py, draw_numberdvi.py (HUD digits). Into the root: TLV320.py (the audio codec driver — must keep that exact module name; the mixer imports it by name), menu.py, main.py.

03

Copy the games

Each game is a single .py file in /apps. The launcher finds them automatically.

04

Set the launcher to run at boot

Nothing to edit — main.py already defaults to menu.py whenever /boot_app.txt doesn't exist. That marker file only appears once you've launched a game from the menu (which writes it, then resets); delete it if you ever need to force a boot back to the menu.

05

Check the display mode

There's only one mode — the driver hardcodes 640×480 timing, so a blank screen isn't a resolution mismatch. Check the DVI-to-HDMI cable and EDID handshake first, then the two direct register pokes near the top of every game file (machine.mem32[0x40010058] and ...054, which set the HSTX serial clock divider) — those two lines have to run before display.begin().

StageRate
clk_sys (HSTX aux div = 2)246 MHz
clk_hstx (DDR → 2 bits per cycle)123 MHz
TMDS serial bits/lane (10 bits/pixel)246 Mbit/s
Pixel clock (TMDS clock lane)24.6 MHz

The overclock to machine.freq(246_000_000) at the top of every game file isn't just for headroom in game logic — it's the clock the whole HSTX chain above is derived from.

Index

Files and references

Everything on the console, and what it is for.

FilePurposeSource
dvi_rp2_hstx_frame_sync_v2.pyDVI/HSTX driver — 3-channel DMA chain, TMDS output, wait_frame()drivers/
colors.pyColor-mode constants and bytes-per-pixel table the DVI driver depends ondrivers/
gamepadfast.pyI2C gamepad reader — Seesaw-protocol joystick + buttons over STEMMA QTdrivers/
TLV320.pyTLV320DAC3100 codec driver (I2S init, clocking, volume)drivers/
audio_mixer.py8-voice I2S mixer, IRQ-driven double buffers, Viper mix passdrivers/
audio_mixer2.pyLater revision of the mixer above; some titles use one, some the otherdrivers/
menu.pyLauncher — lists /apps/*.py and starts one via a boot-marker + resetnot yet published in this repo
main.pyBoot trampoline — reads /boot_app.txt, clears it, runs the named app or falls back to menunot yet published in this repo
/apps/*.pyThe 13 launcher games, plus the hidden landscape demo — one file eachapps/

The drivers and every game under /apps are now published above. menu.py and main.py (the launcher and boot trampoline) aren't yet.

Further reading