Running picogame on real hardware (PicoPad / RP2040)
The simulator (sim/) has unlimited RAM, a forgiving pure-Python API, and is the
fastest way to iterate — but it hides several things that bite on the device. This
doc is the checklist of everything you need to get a game running on a real PicoPad,
learned the hard way. Read it before you flash.
TL;DR of the gotchas: no full-screen
Canvas; HUD = aLabel, not a Canvas bar; build the display backend yourself; ship.mpy, not big.py; store big RGB565 data as PAL8bytes, not anarrayliteral;gc.collect()between scenes; and the firmware must actually contain the feature you’re calling.
Clock / SPI / display-speed limits (how the core clock drives the display SPI, the ST7789 ceiling, overclocking the RP2350, how to test) live in HARDWARE_LIMITS.md.
1. The RAM budget (this is the real constraint)
Section titled “1. The RAM budget (this is the real constraint)”RP2040 has 264 KB SRAM. The firmware uses ~72 KB static (~27%), leaving roughly ~150–190 KB of Python heap for your game. That fills up fast:
| Thing | Cost |
|---|---|
picogame_game.setup() strip buffers (2 × 320×strip_h×2) | strip_h=24 → 30 KB, strip_h=12 → 15 KB |
full-screen Canvas(320, 240) | 150 KB ⚠️ basically the whole heap |
Canvas(320, 130) (e.g. a pseudo-3D road) | 83 KB — OK alone (see microrace), but not on top of much else |
Canvas(320, 20) status bar | 12.8 KB |
| a tile/sprite Bitmap | width×height×frames × (1 B PAL8 / 2 B RGB565) |
Consequences:
- Never allocate a full-screen Canvas. If you need a custom raster (road, shape
field), keep it as small as the content (band it), or use a
Tilemapfor large scrolling areas (1 byte/cell instead of 2 bytes/pixel — a 320×960 noise sky is 600 KB as a Canvas but ~5 KB as a shade Tilemap). - HUD = a
picogame_font.Label/picogame_ui.SceneLabel(text), not a full-width Canvas bar. The HW-verified games do it this way. - Drop
strip_hto 12 in tight scenes for ~15 KB headroom (slightly more refresh passes, still fine). gc.collect()between scenes/levels so the previous scene’s buffers are freed before the next allocates.- If a game is too big as one program, split it (see §4).
MemoryError: memory allocation failed, allocating N bytes = you’re over budget;
note which scene/line and shrink the biggest buffer there (usually a Canvas).
Fragmentation, not just total free. A long session that allocates and frees big
buffers fragments the heap: gc.mem_free() can read ~90 KB while a 51 KB allocation
still fails (no contiguous run). If a monolith dies on a big Canvas even though “there’s
plenty free”, that’s this. The fix is a pre-allocated arena (lib/picogame_arena.py
- the firmware
Canvas(..., buffer=)arg) — grab one big buffer up front and slice it. The general writeup (with the largest-contiguous-block probe and a networking example) is MEMORY.md.
2. Device vs. simulator API differences
Section titled “2. Device vs. simulator API differences”The sim is permissive; the C firmware is stricter. These all “work in the sim, crash on device”:
| Sim accepts | Device needs | Symptom on device |
|---|---|---|
scene0.display (sim Scene has it) | build the backend: DISP = pg.Display(board.DISPLAY) | AttributeError: 'Scene' object has no attribute 'display' |
The C Scene has no .display. Scene.add’s fixed flag is keyword-only
(scene.add(item, fixed=True)) on both sim and device.
To build the display backend yourself (what picogame_game.setup does internally):
import board, picogame as pgd = board.DISPLAYd.auto_refresh = Falsetry: d.root_group = Noneexcept Exception: passDISP = pg.Display(d) # the backend Scene(...) wants as its first argbufA = bytearray(320 * 12 * 2); bufB = bytearray(320 * 12 * 2)scene = pg.Scene(DISP, bufA, bufB, background=background)3. Import-time / compile-time traps
Section titled “3. Import-time / compile-time traps”- Big
.pyascode.py→ MemoryError on import. CircuitPython compilescode.pysource at boot; a large file’s parse tree is a big transient RAM spike. Ship everything as.mpy(compiled withmpy-crossmatching the firmware — currently mpy v6.3 / CircuitPython 10.3.0-alpha) and use a tinycode.pylauncher (import my_scene)..mpyis also smaller and lighter on RAM. - Huge list literals →
RuntimeError: pystack exhausted. Aarray.array('H', [7168, ...])literal pushes thousands of elements onto the VM stack (and builds a ~28 KB transient list). For big RGB565 tilesets, bake them as PAL8 withDATA = b'...'(a singlebytesconstant: half the size, no list, and byte-data is alignment-safe on Cortex-M0+). Reserve palette index 0 = transparent.
Compile a module:
circuitpython/mpy-cross/build/mpy-cross mymodule.py -o mymodule.mpy4. Deploy pattern: one program per scene
Section titled “4. Deploy pattern: one program per scene”A “show everything” demo that holds all assets + all scene code in one module won’t fit. The pattern that works:
dj_common.mpy— shared scaffolding + helpers (display setup,new_scene,status_baras a Label,play()with no wipe-cover Canvas, bitmap helpers).import *from it.scene_<name>.mpy— one program per scene; imports only the assets it needs, so only one scene’s RAM is live at a time. Runs its ownwhile True: seg(); gc.collect().code.py— a one-line launcher (import scene_intro); edit/rename to switch scenes, or build a small button menu.
The sim/video build can stay a single monolith (it has the RAM); keep the HW split
separate. Concrete example: examples/journey_hw/ (dj_common.py + scene_*.py,
plus journey_mono.py — a StripDraw single-file variant, zero pixel buffer / no arena) vs. the sim/video
monolith examples/picogame_demo_journey.py (with sound + wipe). See
examples/journey_hw/README.md.
On-device layout:
CIRCUITPY/ code.py # import scene_<name> scene_*.mpy # one per scene dj_common.mpy # shared helpers <assets>.mpy # dj_hero, dj_town, ... (only what the scenes import) lib/picogame_*.mpy # engine Python helpers(No sound on device unless you wire up picogame_audio; the demo’s chiptune is
offline-only, baked into the recorded video.)
5. The firmware must contain the feature you call
Section titled “5. The firmware must contain the feature you call”Symptoms like AttributeError: ... has no attribute 'X' or can't set attribute 'X'
usually mean the flashed firmware is older than the code that uses X. E.g. an old
build had Sprite.scale as read-only → can't set attribute 'scale'.
- Build: see
PICOGAME.md§“Building the firmware” and[[picopad-build-env]](ARM GCC ≥ 14 toolchain + venv;make BOARD=pajenicko_picopad -j$(nproc)). Output:circuitpython/ports/raspberrypi/build-pajenicko_picopad/firmware.uf2. - Verify a symbol is present without flashing:
arm-none-eabi-nm build-.../firmware.elf | grep sprite_set_scale. - Flash: enter the RP2040 bootloader (RPI-RP2 drive), copy
firmware.uf2. The CIRCUITPY filesystem (your.mpyfiles) is preserved across a firmware flash.
Current firmware has: Sprite.scale/angle/shadow setters, the full Canvas primitive
set (triangle/ellipse/ring/fill_round_rect/frame3d), and C noise
(value2d/value1d/fbm2d/fbm1d + _fx fixed-point variants).
6. Performance notes
Section titled “6. Performance notes”- Noise is C, and fixed-point. Pure-Python noise was the slow part (a ~1–2 s hitch
generating a sky); the C
fbm2dmakes it negligible. After benchmarking the fixed-point variant ~1.8× faster than float on-device (float1.186 svs fixed0.649 sfor 5000fbm2d), the float version was retired (it freed ~1.8 KB flash) —value2d/value1d/fbm2d/fbm1dare now the fixed-point impl (Q16.16 coords, Q0.16 values). The old*_fxnames are gone (there’s nothing to contrast with). The float reference is kept disabled behind#if 0in the C source if it’s ever needed again. - Canvas drawing is C (
fill_rect,frame3d, …) — Python only issues the calls. What costs is the Canvas buffer (RAM), not the drawing. StripDraw= immediate mode, zero buffer. For full-frame animated surfaces (pseudo-3D road, gradient sky, procedural background) usepg.StripDraw(callback, …)instead of a Canvas: it draws straight into each render strip, so it costs 0 bytes of surface RAM (vs 150 KB for a full-screen Canvas). It repaints every frame, so it’s for animated content, not static art. Added in firmware for +216 B of flash (it reuses the Canvas primitives as the per-strip view). SeePICOGAME.md→StripDrawandexamples/picogame_stripdraw_demo.py. This is the real fix for the big-buffer fragmentation problem (MEMORY.md) — the buffer simply doesn’t exist.- Dirty-rect means a mostly-static scene is cheap; full-screen scrolling repaints everything each frame. ~50 moving sprites ≈ 25 FPS on RP2040.
See also: PICOGAME.md (API), ENGINE_ERGONOMICS.md (design rationale),
SCENE_FORMAT.md (declarative levels), tutorials/ (step-by-step), examples/
(genre ports — microrace proves an 83 KB Canvas is OK on device).