The firmware build
The PicoPad firmware is a CircuitPython build with the native picogame module enabled.
Building & flashing
Section titled “Building & flashing”Prerequisites: ARM GCC ≥ 14 and initialized submodules.
# from the CircuitPython fork rootmake -C ports/raspberrypi BOARD=pajenicko_picopad -j"$(nproc)"Output: ports/raspberrypi/build-pajenicko_picopad/firmware.uf2. Flash it over
BOOTSEL (hold BOOTSEL while plugging in, then drag the .uf2 onto the RPI-RP2 drive)
like any CircuitPython firmware.
See Run on hardware for the device side and Fit it in RAM for the RAM budget.
Where picogame lives in the tree
Section titled “Where picogame lives in the tree”The engine is part of CircuitPython itself — shared-bindings/picogame/ and
shared-module/picogame/ live in adafruit/circuitpython, off by default and turned on per board.
The MakerClass fork carries only work that is not upstream yet (experimental feature branches and
boards awaiting a PR). Two module directories contain the implementation, and build flags select
the module and optional backends.
| Path | What |
|---|---|
shared-bindings/picogame/ |
the Python-facing API + docstrings: __init__, Scene, Sprite, Bitmap, Tilemap, Canvas, Particles, Display, Framebuffer |
shared-module/picogame/ |
the portable C core — the blit / scene / tilemap / particles / canvas implementation, no port dependencies |
ports/*/common-hal/picogame/Display.c |
the optional per-port fast display backend (raspberrypi, espressif and atmel-samd provide one) |
Build-system hooks:
| File | Change |
|---|---|
py/circuitpy_mpconfig.mk |
registers the CIRCUITPY_PICOGAME* flags — the four switches default 0; CIRCUITPY_PICOGAME_FPU is unset and follows the architecture |
py/circuitpy_defns.mk |
compiles picogame/% only when CIRCUITPY_PICOGAME = 1; adds common-hal/picogame/Display.c only when FAST_DISPLAY = 1 |
ports/*/boards/<board>/mpconfigboard.mk |
the boards that opt in — PicoPad, PicoSystem, Fruit Jam, Pico, Pico W (raspberrypi) and PyBadge (atmel-samd); config below |
The port intervention: the fast display path
Section titled “The port intervention: the fast display path”The portable core in shared-module/ never touches hardware — it blits pixels into a strip
buffer and hands that buffer to a busdisplay through bus.send. This is the portable path
for ports that compile picogame and expose a compatible SPI display. Each bus.send blocks
the CPU until the strip has finished
transferring over SPI, so rendering and transfer happen strictly one after the other.
ports/raspberrypi/common-hal/picogame/Display.c is the one place the engine reaches into
the port, to remove that stall. It drives the RP2040’s SPI and DMA directly:
- Overlap. It double-buffers strips and DMAs one strip out over SPI while the CPU blits the next — render and transfer run concurrently instead of serially. It waits on the previous DMA only just before reusing that buffer.
- Raw streaming. It opens the panel’s GRAM window once (via
busdisplay, which drives DC high for the first data strip), then streams the remaining strips as raw DMA with no per-strip reconfiguration or DC toggling. - One DMA channel, reused. The channel is claimed once and kept across soft resets and game launches; the pico-SDK doesn’t free it on soft reset, so claiming per-construct would leak channels until DMA is exhausted.
This is what CIRCUITPY_PICOGAME_FAST_DISPLAY gates — both the pg.Display type and the
common-hal file. The flag is optional. With it off, picogame still runs everywhere via the
portable bus.send renderer; it’s just slower because each strip transfer blocks. The overlap
only helps when a repaint spans multiple strips, so the win grows with per-strip blit cost:
near zero for a small dirty-region update and larger for a full-frame, transform-heavy
scene. Treat the 25–30% measured on the PicoPad benchmark as configuration-specific; see
Clocks, SPI & display limits for the test setup.
Board configuration
Section titled “Board configuration”A board turns the engine on in its mpconfigboard.mk:
CIRCUITPY_PICOGAME = 1 # compile the engine inCIRCUITPY_PICOGAME_FAST_DISPLAY = 1 # async-DMA display backend (raspberrypi port)CIRCUITPY_PICOGAME_RGB444 = 1 # panel COLMOD capability (see Build flags)CFLAGS += -DCIRCUITPY_FIRMWARE_SIZE='(1536 * 1024)' # + a matching linker-script changeDisplay SPI clock (in board.c). Request 62.5 MHz (125/2) for the ST7789, not 60 —
the PL022’s even-only divider rounds 60 down to half speed. See
Clocks, SPI & display limits.
Keep the image general-purpose. Leave the full module set on; only disable what this device physically can’t use.
| Module | State | Why |
|---|---|---|
picogame (+ fast DMA display) |
on | the engine |
native _stage (CIRCUITPY_STAGE) |
on | runs the original ugame/stage games head-to-head with the picogame-stage shim |
ulab, synthio, audio, displayio, bitmaptools, vectorio, Wi-Fi, keypad, … |
on | general-purpose, they fit |
picodvi, _eve |
off | no DVI / FT8xx hardware on this device |
qrio |
off | QR decode needs a camera the PicoPad lacks; also drops its ~32 KB quirc backend (QR generation via adafruit_miniqr is unaffected) |
The measured build used about 88% of its 1.5 MB firmware region; this changes with the CircuitPython version and enabled modules.
Build flags
Section titled “Build flags”| Flag | Default | What it does |
|---|---|---|
CIRCUITPY_PICOGAME |
0 |
compile the engine in |
CIRCUITPY_PICOGAME_FAST_DISPLAY |
0 |
use the port’s async-DMA Display (raspberrypi, espressif, atmel-samd); other boards fall back to the portable bus.send renderer |
CIRCUITPY_PICOGAME_RGB444 |
0 |
board declares its panel supports 12-bit RGB444 (COLMOD), exposed as picogame.RGB444_SUPPORTED so a game can enable Display(rgb444=True) only where it helps. Both PicoPad boards and the PyBadge set it to 1 (the capability IS compiled in); whether a game uses it is a runtime choice — picogame_game.setup(rgb444=…), or PICOGAME_RGB444 in settings.toml as the per-device default. Measured a win on both (PicoPad and the PyBadge’s 24 MHz bus). |
CIRCUITPY_PICOGAME_FRAMEBUFFER |
0 |
full-frame RAM-framebuffer backend for scanout platforms (RP2350 DVI/HSTX, the desktop sim, the WASM playground) instead of an SPI strip bus |
Mapping a flash file for 0-copy access is no longer a picogame flag: the old pg.xip_map(path)
was replaced by storage.map_file(file), which takes an open file object and is proposed
upstream rather than built into the engine.
Render-strip height. On an SPI display, the screen is painted in horizontal strips of
STRIP_H rows. picogame_game.setup() allocates two width × STRIP_H × 2-byte buffers;
the framebuffer path returns None for both and does not allocate them. The SPI default is
keyed to FAST_DISPLAY: 8 rows with DMA and 24 without. These are performance defaults
for the measured paths; a smaller value always uses less buffer RAM. Override per board with
-DPICOGAME_STRIP_H=N, or per game via picogame_game.setup(strip_h=N); read it at runtime
as picogame.STRIP_H. More in Fit it in RAM.
Appendix: compiler optimization (tuned −O2)
Section titled “Appendix: compiler optimization (tuned −O2)”The rp2 port used to default to -O3. On a Cortex-M0+ (no SIMD, no FPU, 16 KB XIP cache) most of
what -O3 adds is dead weight — the auto-vectorizers have no SIMD to target, and function cloning
and heavy loop unrolling only grow flash. Since CircuitPython 10.4 the port default is two
cheap passes on top of -O2 (ports/raspberrypi/Makefile), so no board carries an
OPTIMIZATION_FLAGS line of its own:
OPTIMIZATION_FLAGS ?= -O2 -funswitch-loops -fvect-cost-model=dynamicThe two do not overlap. -funswitch-loops owns the sprite blit and mode7 (blit 113 → 87 µs) and
costs 16 KB; -fvect-cost-model=dynamic — which is the one thing -O3 changes about
vectorization, from very-cheap to dynamic — owns the fills, the memory copies and AES
(fill_rect 1163 → 732 µs, a 4 KB bytearray copy 3857 → 2441 µs) and costs 4 KB. Together they
reach -O3 speed on every kernel measured, for +20,664 bytes over -O2 instead of -O3’s
+151,020 — about 130 KB of flash back on every rp2 board, picogame or not.
An earlier revision of this page recommended -O2 plus five passes. Measuring the five one at a
time (GCC 15.2, raspberry_pi_pico) showed that only -funswitch-loops earned its place;
-fpredictive-commoning, -fgcse-after-reload, -ftree-partial-pre and -fsplit-paths produced
code that timed the same as plain -O2. The table below is from that earlier opt-level sweep
and is kept for the level comparison; its O2+ row is the retired five-pass build.
The MicroPython interpreter core (gc.o, vm.o) stays at -O3 via CircuitPython’s
SUPEROPT_* settings, so Python execution speed is unaffected. The single hottest loop (the
plain sprite blit) additionally carries a #pragma GCC unroll 4 — ~6 % faster on the M0+ for
+0.6 KB; -funroll-loops firmware-wide would overflow the flash region.
Measured on-device, all builds on CircuitPython 10.3.0; lower is faster, best in each
column bold. Engine = picogame_bench_hotpath.py (108 sprites of 32×32 over 120
frames at 320×240, ms/frame min); Python = bench_optlevel.py (ms/op); flash = full
image, KB. The O2+ row is the baseline (the five-pass build this page used to recommend); each
🟢/🟡/🔴 marks how far a cell sits from it (better / ≤5 % worse / >5 % worse).
| bg-fill ms |
plain ms |
plain+bg ms |
tint ms |
transpose ms |
bignum ms |
int ms |
float ms |
fib ms |
ulab-py ms |
ulab-np ms |
flash KB |
|
|---|---|---|---|---|---|---|---|---|---|---|---|---|
-O3 (rp2 default) |
24.9 🟢−1.2% | 36.3 🟡+0.8% | 37.0 🟡+0.1% | 80.2 🟢−0.5% | 46.1 🟡+0.3% | 98.8 🔴+8.8% | 20.84 🟡+2.1% | 40.80 🟡+1.3% | 657.8 🟢−0.0% | 2.63 🟢−2.2% | 0.76 🔴+17% | 1499 🔴+11% |
-O2 |
25.3 🟡+0.4% | 38.2 🔴+5.9% | 39.1 🔴+5.7% | 81.2 🟡+0.8% | 53.9 🔴+17% | 92.0 🟡+1.3% | 20.29 🟢−0.6% | 40.34 🟡+0.1% | 660.2 🟡+0.3% | 3.77 🔴+40% | 0.66 🟡+1.5% | 1326 🟢−1.4% |
-Os |
27.0 🔴+7.4% | 43.6 🔴+21% | 46.4 🔴+25% | 121.9 🔴+51% | 103.0 🔴+124% | 113.3 🔴+25% | 21.18 🟡+3.7% | 44.07 🔴+9.4% | 667.3 🟡+1.4% | 3.00 🔴+11% | 0.73 🔴+12% | 1167 🟢−13% |
O3− (−O3 minus vectorizers + cloning) |
25.2 🟡+0.1% | 36.2 🟡+0.5% | 37.2 🟡+0.6% | 81.5 🟡+1.1% | 46.1 🟡+0.2% | 96.1 🔴+5.8% | 20.32 🟢−0.5% | 40.95 🟡+1.7% | 662.8 🟡+0.7% | 2.75 🟡+2.2% | 0.68 🟡+4.6% | 1480 🔴+10% |
O2+ (five passes — baseline) |
25.2 | 36.0 | 37.0 | 80.6 | 46.0 | 90.8 | 20.42 | 40.28 | 658.0 | 2.69 | 0.65 | 1345 |
-Os shrinks flash most but wrecks the affine/blend loops (tint +51 %, transpose +124 %);
O3− matches engine speed yet stays +134 KB because -O3’s firmware-wide inlining survives;
the Python columns barely move since the interpreter core is -O3 in every build.
(bench_displayio.py was flat across opt levels, so it’s omitted.)