Skip to content

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 = a Label, not a Canvas bar; build the display backend yourself; ship .mpy, not big .py; store big RGB565 data as PAL8 bytes, not an array literal; 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:

ThingCost
picogame_game.setup() strip buffers (2 × 320×strip_h×2)strip_h=2430 KB, strip_h=1215 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 bar12.8 KB
a tile/sprite Bitmapwidth×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 Tilemap for 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_h to 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.

The sim is permissive; the C firmware is stricter. These all “work in the sim, crash on device”:

Sim acceptsDevice needsSymptom 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 pg
d = board.DISPLAY
d.auto_refresh = False
try: d.root_group = None
except Exception: pass
DISP = pg.Display(d) # the backend Scene(...) wants as its first arg
bufA = bytearray(320 * 12 * 2); bufB = bytearray(320 * 12 * 2)
scene = pg.Scene(DISP, bufA, bufB, background=background)

  • Big .py as code.py → MemoryError on import. CircuitPython compiles code.py source at boot; a large file’s parse tree is a big transient RAM spike. Ship everything as .mpy (compiled with mpy-cross matching the firmware — currently mpy v6.3 / CircuitPython 10.3.0-alpha) and use a tiny code.py launcher (import my_scene). .mpy is also smaller and lighter on RAM.
  • Huge list literals → RuntimeError: pystack exhausted. A array.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 with DATA = b'...' (a single bytes constant: 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.mpy

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_bar as 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 own while 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 .mpy files) 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).


  • Noise is C, and fixed-point. Pure-Python noise was the slow part (a ~1–2 s hitch generating a sky); the C fbm2d makes it negligible. After benchmarking the fixed-point variant ~1.8× faster than float on-device (float 1.186 s vs fixed 0.649 s for 5000 fbm2d), the float version was retired (it freed ~1.8 KB flash) — value2d/ value1d/fbm2d/fbm1d are now the fixed-point impl (Q16.16 coords, Q0.16 values). The old *_fx names are gone (there’s nothing to contrast with). The float reference is kept disabled behind #if 0 in 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) use pg.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). See PICOGAME.mdStripDraw and examples/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).