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.
| Game | Lines | Viper functions | Thumb-2 functions |
| Defender | 1,594 | 31 | 3 |
| Scramble | 1,348 | 23 | 3 |
| Pac-Man | 1,531 | 22 | 2 |
| Gravitar | 1,493 | 20 | 1 |
| Star Castle | 1,017 | 20 | 1 |
| Qix | 908 | 17 | 3 |
| Centipede | 971 | 16 | 2 |
| Asteroids | 853 | 14 | 4 |
| Pole Position | 983 | 11 | 2 |
| Arkanoid | 1,443 | 9 | 2 |
| Lunar Lander | 925 | 9 | 1 |
| Missile Command | 861 | 8 | 1 |
| Zaxxon | 361 | 6 | 2 |
| Battlezone WIP | 1,978 | 20 | 5 |
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.