picogame — feature guide (when to use what)
A task-oriented tour of the engine: for each feature, what it is, a short example, and when to use it — and what to reach for instead. It complements the other docs:
- REFERENCE.md — one-page signature cheat sheet.
- PICOGAME.md — full API description.
- HARDWARE.md / MEMORY.md — RAM budget & fragmentation.
- ENGINE_ERGONOMICS.md — why the engine is shaped this way.
Colours are wire-order RGB565 ints (C = pg.rgb565). Examples assume a scene from
picogame_game.setup() (or pg.Scene(...)).
1. Which drawing surface?
Section titled “1. Which drawing surface?”The most common decision. Everything visible is one of these scene layers (added with
scene.add(item, fixed=False), bottom→top; fixed=True ignores the camera; add returns the item).
| I need to draw… | Use | Why / watch out |
|---|---|---|
| a moving object (player, enemy, bullet, coin) | Sprite | the basic building block; use a Pool for many identical ones |
| a big grid (map, tiled background, brick wall) | Tilemap | 1 byte/cell vs 2 bytes/pixel; only changed cells repaint |
| shapes / a panel / HUD art that changes rarely | Canvas | keeps pixels → dirty-rect skips it while static. Costs w*h*2 bytes |
| an animated full-frame effect (road, gradient, sky, plasma) | StripDraw | draws straight into the strip, 0 bytes. Repaints every frame |
| a one-off blit with no retained Scene | render() | immediate mode, outside the Scene |
Rule of thumb: pick by motion, not size. Static/accumulating → Canvas (holds
pixels, near-zero CPU when unchanged). Animated full-frame → StripDraw (zero RAM,
redraws each frame). Never allocate a full-screen Canvas(320, 240) (150 KB) on the
RP2040 — band it, tile it, or use StripDraw.
# Static HUD panel → Canvas (repaints only when you draw into it)panel = pg.Canvas(120, 40); panel.move(4, 4); scene.add(panel, fixed=True)panel.fill_round_rect(0, 0, 120, 40, 6, INK); panel.frame3d(0, 0, 120, 40, LT, DK)
# Animated pseudo-3D road → StripDraw (no buffer); view-local (0,0) == screen (vx, vy)def road(v, vx, vy, vw, vh): for ly in range(vh): v.fill_rect(0, ly, vw, 1, shade(vy + ly))scene.add(pg.StripDraw(road, 0, SKY_H, W, ROAD_H)) # add fixed in a scrolling scene
StripDraw — a full-frame animated surface (pseudo-3D road + sky), drawn straight into the strips with zero buffer RAM.
Tilemap — a grid of tile bitmaps (1 byte/cell); the cheap way to fill a large/scrolling area.
See: examples/picogame_stripdraw_demo.py, examples/journey_hw/journey240.py (road +
intro + dialog all StripDraw), picogame_microrace (StripDraw road), picogame_tetris/_pacman (Tilemap).
2. Sprite transforms: runtime scale/angle vs pre-baked frames
Section titled “2. Sprite transforms: runtime scale/angle vs pre-baked frames”A Sprite can scale and rotate at draw time (scale, angle, about anchor) — or you can
bake rotated frames into the bitmap and just step frame.
spr.anchor = (0.5, 0.5)spr.scale = 1.6 # 1.0 = native (fast path); fractional ok (a pulse)spr.angle = 30 # degrees, about the anchor| Situation | Use | Why |
|---|---|---|
| occasional / smooth scale (coin pulse, boss grow, depth) | runtime scale | one bitmap, any factor |
| occasional rotation, any angle (banking car, spin) | runtime angle | one bitmap; nearest-neighbour so it shimmers a little |
| a constantly-spinning object at fixed steps (asteroid, ship facing 16 ways) | pre-baked frames (shapes.poly_frames, or art frames) | crisper, and cheaper per frame than re-rotating |
| many objects rotating every frame | pre-baked frames | runtime affine is per-pixel; baked frames are a plain blit |
The same bitmap: top row = runtime angle (0/30/60/90°), bottom row = runtime scale (0.6→2.6×).
Rule: angle/scale for a few sprites or smooth/arbitrary values; baked frames for many
or always-rotating sprites. Integer scale stays pixel-crisp; rotation always shimmers
(pixel-art trade-off). scale=1.0, angle=0 is the fast blit path — leave them there when idle.
See: examples/journey_hw/journey240.py (coin scale, car bank), tutorials/02-starship &
picogame_asteroids (pre-baked rotation frames).
Sprite blit effects — recolour/translucency for free (no extra bitmaps)
Section titled “Sprite blit effects — recolour/translucency for free (no extra bitmaps)”Beyond geometry, a Sprite has cheap per-pixel blit effects — one at a time (setting one clears the others). They cost no extra art and no extra RAM:
| Want | Use | Notes |
|---|---|---|
| drop shadow / dim a sprite | spr.shadow = True | opaque pixels darken the destination |
| hit-flash (white blink on damage) | spr.flash = WHITE (pulse 1–3 frames; 0=off) | flat colour, loses detail — perfect for a 1-frame pop |
| coloured lighting / damage / freeze / glow | spr.tint = RED (0=off) | multiplies — keeps the sprite’s shading (unlike flash) |
| ghost / fog / fade a sprite in-out | spr.dither = 0..16 | Bayer stipple, no alpha; animate the level to fade |
| cheap 90° rotation (crisp, no shimmer) | spr.transpose = True (+flip_x/y = all 8 orientations) | fast path only (scale 1, angle 0); footprint swaps w/h |
These are the cheapest juice on the device (see also picogame_fx for screen-level Shake/Fade
and picogame_palette for animated water/recolour). flash/tint/dither/transpose are
demonstrated together in examples/picogame_fxdemo.py. After an in-place bitmap or palette
edit call spr.touch() so the dirty-rect repaints.
Tilemaps carry the same orientation per cell:
tm.tile(x, y, idx, flip_x=True, flip_y=False, transpose=False)— pair it with a deduplicated tileset (png2picogame.py --dedup) so rotated/mirrored tiles cost one stored tile (big RAM win; the orientation plane is allocated only if a map uses it).

3. Animation: frame-by-frame vs picogame_anim
Section titled “3. Animation: frame-by-frame vs picogame_anim”The bitmap holds N frames; you advance sprite.frame.
spr.frame = (frame // 6) % spr.bitmap.frames # manual: a new frame every 6 ticksimport picogame_animwalk = picogame_anim.FrameAnim(spr, [0, 1, 2, 1], fps=8) # time-based# each frame: walk.tick(dt)| Situation | Use |
|---|---|
| a couple of sprites, fixed game tick | manual frame = (f // n) % N — zero overhead, totally clear |
| many sprites, or you want time-based (dt) playback, or named states | picogame_anim (FrameAnim / AnimatedSprite) |
Most arcade games run at a fixed FPS, so the manual form is the norm; reach for
picogame_anim when you have several animations or want walk/idle/jump states by name.

4. Backgrounds: tilemap-shade vs scrolling image vs noise
Section titled “4. Backgrounds: tilemap-shade vs scrolling image vs noise”A full-screen background as a pixel buffer is huge (320×240 = 150 KB). Pick by content:
| Background | Approach | Cost |
|---|---|---|
| flat colour | the Scene background | free |
| tiled / repeating scenery | Tilemap of tile bitmaps | 1 B/cell |
| chunky shaded sky / terrain | noise → shade Tilemap (1 B/cell) | ~5 KB for a screen |
| a painted scrolling band (parallax) | two Sprites of the band that wrap | band_w*band_h*2, shared bitmap |
| an animated gradient / scanline field | StripDraw | 0 B |
# Noise sky as a shade tilemap (cheap): map fbm -> one of N colour framessky = pg.Tilemap(shp.color_frames(8, 8, SHADES), gw, gh)for gy in range(gh): for gx in range(gw): sky.tile(gx, gy, int(pg.fbm2d(gx*0.15, gy*0.13, octaves=3, seed=11) * (N-1)))# Parallax band: two copies of one bitmap, scroll and wrap (the bitmap is shared = 1x RAM)bg = [pg.Sprite(band, 0, Y), pg.Sprite(band, 320, Y)]; scene.add_all(bg)for b in bg: b.fx -= 1 if b.fx <= -320: b.fx += 640
A background from fbm2d noise mapped to a shade Tilemap — organic variation for ~5 KB instead of a 150 KB pixel buffer.
Rule: scrolling world → Tilemap; procedural look → noise tilemap; painted parallax → wrapping band sprites; animated field → StripDraw. A 320×960 noise sky is 600 KB as a Canvas but ~5 KB as a shade Tilemap.
See: examples/journey_hw/journey240.py (shmup noise sky, racer StripDraw road, pictor
band scroll), examples/picogame_pictor.py (wrapping meadow band).
5. Collision: collide (AABB) vs collide (circle)
Section titled “5. Collision: collide (AABB) vs collide (circle)”if pg.collide(x1, y1, x2, y2, ax1, ay1, ax2, ay2): ... # box vs boxif pg.collide(x1, y1, x2, y2, px, py): ... # box vs point (6 args)Inclusive AABB — boxes collide when they touch (pass sprite boxes as (x, y, x+w, y+h);
fires on contact). Note: collide is inclusive, unlike render’s half-open pixel ranges —
different domains (hitboxes vs pixels).
import picogame_collide as collideif collide.hit(a, b): ... # AABB straight from two sprites' positions/sizesif collide.is_within(a, b, 22): ... # circular: centres within 22 px (no sqrt)if collide.hit_point(a, px, py): ... # point inside sprite a| Situation | Use |
|---|---|
| boxy things (paddle/ball, platforms, tile cells) | pg.collide (or collide.hit) — AABB |
| round things / bullets vs blobs / pickups | collide.is_within — circular, forgiving, fast (no sqrt) |
| reading straight from sprites (no manual rects) | collide.* — zero-allocation, uses sprite pos+size |
| tile-grid collision (walls) | look up tilemap.tile(tx, ty) directly |
Rule: AABB for boxes and tiles; circle (within) for bullets/pickups and anything that
should feel round. collide.* is the convenient form (no temp rects); pg.collide when you
already have explicit coordinates.
See: tutorials/01-bounce (AABB), examples/picogame_pictor.py & tutorials/02-starship
(circular bullet hits), picogame_pacman (tile lookup).

6. Spawners: picogame_pool vs a manual list
Section titled “6. Spawners: picogame_pool vs a manual list”Bullets, enemies, particles-as-sprites — things created and destroyed constantly. Never create/destroy sprites at runtime (that churns the heap → fragmentation). Pre-allocate and reuse.
import picogame_poolbullets = picogame_pool.Pool(scene, bullet_bm, 12, anchor=(0.5, 0.5))b = bullets.spawn() # first free sprite, made visible (None if full)if b: b.move(x, y); b.data = {"vy": -6}...for b in bullets.items: if b.visible: b.fy += b.data["vy"] if b.fy < -8: bullets.free(b)| Situation | Use |
|---|---|
| many of the same bitmap (bullets, sparks, orbs) | picogame_pool.Pool |
| a few items of different bitmaps (mixed enemy types) | a manual list of reusable sprites; swap .bitmap on spawn |
| short-lived dots with physics (debris, splash) | Particles (§7), not sprites |
Rule: same bitmap → Pool; mixed bitmaps → manual reuse list (swap .bitmap); cheap dots →
Particles. The golden rule is pre-allocate, never alloc per frame.
See: examples/picogame_pictor.py (shuriken Pool + manual mixed-enemy list),
tutorials/02-starship, picogame_flappy.
7. Particles — when yes, when no
Section titled “7. Particles — when yes, when no”ps = pg.Particles(220, size=2, gravity=0.12, fade=True); scene.add(ps)ps.emit(x, y, count=14, speed=3, life=22, color=C(245, 220, 70)) # a burst# each frame: ps.tick()
Three emit() bursts a few tick()s later — hundreds of fading dots in one cheap layer.
Use for cheap, non-interactive dots: explosions, sparks, dust, coin shimmer, trails. One layer handles hundreds of dots far cheaper than sprites.
Don’t use Particles for things that need a bitmap, collision, or individual control (those are Sprites/Pool). Particles are fire-and-forget squares.
See: examples/picogame_pictor.py (explosions), tutorials/01-bounce, picogame_missile.
8. Camera & scrolling
Section titled “8. Camera & scrolling”scene.set_view(ox, oy) # screen position of the scene originA world bigger than the screen: keep entities in world coords, move the camera each frame.
Non-fixed layers shift with the camera; fixed=True layers (HUD, dialog) stay put.
ox = int(max(W - WORLD_W, min(0, W // 2 - hero_x))) # follow + clamp to world edgesoy = int(max(H - WORLD_H, min(0, H // 2 - hero_y)))scene.set_view(ox, oy)Watch out: changing the view repaints the whole screen (no dirty-rect win while
scrolling) — fine for active scrolling, but a static camera is much cheaper. StripDraw layers
are screen-space, so add them fixed in a scrolling scene.
See: tutorials/03-quest, examples/picogame_platformer, picogame_soccer.

9. HUD / status bar — four ways to build one
Section titled “9. HUD / status bar — four ways to build one”A status bar (score, lives, a caption, a gauge) is the most common HUD, and picogame gives you four ways to draw one. They differ on the two things that matter on an RP2040: how much RAM the bar costs, and how much work it does per frame. Pick by what the bar contains and how often it changes. The quick map:
| Your bar is… | Build it with | RAM | Per-frame cost |
|---|---|---|---|
| score / lives / a caption (text) | SceneLabel — text as a fixed scene layer | one small text bitmap | ~0 (repaints only on text change) |
| a fixed strip/panel the scene shouldn’t touch | reserved zone + HudBar | 0 (scratch buffer) | 0 (scene never paints it) |
| a framed widget / gauge that changes rarely | a small Canvas (fixed layer) | w*h*2 bytes | ~0 while static (dirty-rect skips it) |
| an animated bar (gradient, pulsing meter) | StripDraw (zero buffer) | 0 | repaints every frame |
The rest of this section is the how and the why for each.

A. Text bar → SceneLabel (a fixed scene layer)
Section titled “A. Text bar → SceneLabel (a fixed scene layer)”import picogame_ui as uiscore = ui.SceneLabel(scene, pg, FONT, 6, 4, TEXT_FG, BAR_BG) # x, y, fg, bgscore.set("SCORE %05d" % n) # re-renders ONLY when the string changesHow: SceneLabel adds a text Sprite to the scene as a fixed layer (camera-independent —
it stays put while the world scrolls). .set() swaps the sprite’s bitmap only when the
string differs; scene.refresh() draws it like any other layer. The opaque bg makes the
new text fully overwrite the old, so no separate clear.
Pros: trivial; the cheapest moving-text HUD; scrolls correctly (stays pinned); it’s what all the HW-verified games use. Cons: text only (no frame, gradient, or shape); it’s a scene layer, so a sprite passing under it costs a dirty-rect repaint of that overlap.
Use for: score, lives count, timer, a one-line caption over a scrolling world.
B. Reserved zone + HudBar (the bar lives OUTSIDE the scene)
Section titled “B. Reserved zone + HudBar (the bar lives OUTSIDE the scene)”scene, bufA, bufB = picogame_game.setup(background=SKY, top=18, bottom=22) # carve strips outbar = ui.HudBar(pg, board.DISPLAY, bufA, 0, H - 22, W, 22, BAR_BG) # the bottom striphearts = [bar.add(pg.Sprite(heart_bm, W - 16 - i * 20, H - 11)) for i in range(3)]score = bar.label(FONT, 6, 4, WHITE, "SCORE 0")bar.redraw() # paint once; then redraw() ONLY when the HUD changes# ... later, on a life lost / score tick:hearts[2].visible = False; bar.set_text(score, "SCORE %d" % n); bar.redraw()How: Scene(..., top=/bottom=/left=/right=) (exposed by picogame_game.setup) tells the
scene to render only the inner rect and clip every dirty rect to it — so the scene
never paints the reserved border. You own that border. HudBar draws into it with
pg.render using a scratch strip buffer (e.g. bufA) — it keeps no buffer of its
own — filling a flat background and blitting its sprites/labels. You call redraw() only
when something changes.
Pros: the “set once, repaint on change” answer — 0 retained RAM and 0 per-frame
cost (the scene literally can’t touch those rows). Best for landscape side panels
(left=/right= → a Tetris/Fruitris column), a framed playfield (all four), or a fixed
top/bottom strip. Cons: background is a flat colour (gradient → use C or D); the bar
is static-by-default (you drive every change with redraw()); it eats screen space out of
the play area.
| You want | Reserve | Why |
|---|---|---|
| score / lives / status strip | top= / bottom= | a fixed bar with zero per-frame work |
| side panels (score, next-piece) | left= / right= | landscape column layouts |
| a framed playfield | all four | the scene is the inner rect; the frame is yours |
C. Canvas bar (a retained pixel buffer, for a framed/gauge widget)
Section titled “C. Canvas bar (a retained pixel buffer, for a framed/gauge widget)”bar = pg.Canvas(120, 20) # a w*h*2-byte buffer you keepbar.frame3d(0, 0, 120, 20, LIGHT, DARK) # bevel; also fill_rect/line/circle/round_rect...bar.fill_rect(2, 2, hp * 116 // 100, 16, GREEN)scene.add(bar, fixed=True) # fixed = stays put while the world scrollsbar.move(8, 6)# redraw the gauge only when hp changes:bar.fill_rect(2, 2, 116, 16, DARK); bar.fill_rect(2, 2, hp * 116 // 100, 16, GREEN)How: a Canvas is a layer that holds its pixels. The scene’s dirty-rect logic skips
it while nothing changes, so a static Canvas costs nothing per frame; when you draw into it
(shapes, bevels, a gauge fill), that rect repaints. Add it fixed=True so it doesn’t scroll.
Pros: full vector primitives (frame3d, fill_round_rect, circle, line, …) → real
framed panels, bevelled gauges, shaped readouts; cheap while static. Cons: it retains
w*h*2 bytes — a full-width 320×20 bar is ~13 KB, dead weight on a RAM-tight board.
Size the Canvas to the widget, never to the full screen (that’s what B is for).
Use for: a small bevelled gauge, a portrait box, a framed minimap — anything with shape that changes only occasionally.
D. StripDraw bar (zero buffer, for an ANIMATED bar)
Section titled “D. StripDraw bar (zero buffer, for an ANIMATED bar)”def draw_energy(view, vx, vy, vw, vh): # view = a Canvas onto the live strip view.clear(BAR_BG) # view-local (0,0) == screen (vx, vy) for x in range(vw): # e.g. a gradient / pulsing meter view.fill_rect(x, 0, 1, vh, ramp(x, energy))scene.add(pg.StripDraw(draw_energy, 0, H - 16, W, 16), fixed=True) # fixed in a scrolling sceneHow: StripDraw holds no pixel buffer. Each refresh, for every render strip
overlapping its rect, it calls your callback(view, vx, vy, vw, vh) with a Canvas view
pointing straight at the live strip — you draw into the frame directly (all the Canvas
primitives work). Its rect is repainted every frame.
Pros: 0 RAM even for a full-width bar, and it can change every frame for free
(no buffer to keep in sync) — gradients, scanline meters, a pulsing energy bar, a
procedural background strip. Cons: it pays CPU every frame (it’s always dirty), and
it’s screen-space — in a scrolling scene add it fixed=True or it’ll smear.
Use for: any bar whose pixels genuinely change each frame, or a full-width animated strip you don’t want to spend 13 KB on.
Deciding in one breath
Section titled “Deciding in one breath”- Just text (score/lives/caption)? → A
SceneLabel. - Fixed strip/panel, flat colour, changes on events? → B reserved zone +
HudBar(0 RAM, 0 per-frame — the default for a real status bar). - Needs shape/bevel/gauge but changes rarely? → C a small
Canvas(sized to the widget). - Pixels change every frame (gradient, animation)? → D
StripDraw.
The trap to avoid: a full-width Canvas bar for static text/flat content — it burns
~13 KB for nothing. Use A for the text or B for the strip instead; reach for C only when the
widget genuinely has shape, and D when it genuinely animates.
For non-bar HUD pieces: a multi-line message box → ui.TextBox; a cursor menu → ui.Menu.
See, one per approach:
A tutorials/01-bounce/step7_hud.py + examples/picogame_hud_demo.py (text via font) ·
B examples/picogame_pictor.py (reserved top score bar + bottom lives bar),
examples/picogame_playarea_demo.py (centred column + side panels) ·
C examples/picogame_canvas_demo.py (an animated gauge on a Canvas) ·
D examples/journey_hw/journey_mono.py (StripDraw road + dialog box; status_bar() is the SceneLabel form).
10. Noise — procedural variation
Section titled “10. Noise — procedural variation”h = pg.fbm2d(tx * 0.1, ty * 0.1, octaves=4, seed=7) # 0..1 fractal height fieldv = pg.value2d(x * 0.6, y * 0.6, seed=3) # 0..1 smooth value noiseUse for organic, repeatable variation: terrain/height maps, cave layouts, cloud/sky
shading, scattered decoration, texture graining. Deterministic per seed — same seed, same
world. It’s the fast fixed-point C implementation on device.
Don’t use it for gameplay randomness (use a PRNG/LCG for spawns/drops) — noise is smooth
and correlated, not uniform. fbm2d (multi-octave) for natural detail; value2d (one
octave) when you just want a smooth field.
See: examples/journey_hw/journey240.py (RPG grass, shmup sky), picogame_demo_showcase.
11. Audio: sampled WAV vs synthesized vs MIDI
Section titled “11. Audio: sampled WAV vs synthesized vs MIDI”Two engines, picked by where the sound comes from — a recorded sample or generated on the fly — plus MIDI for sequenced music. They share the PWM output but trade RAM for fidelity.
A. picogame_audio — sampled WAV (PWM + Mixer). Plays .wav files (and a square-wave
tone() for test beeps). A multi-voice Mixer overlaps SFX under one music voice.
import picogame_audioaudio = picogame_audio.Audio() # PWMAudioOut -> Mixer (4 voices)audio.sfx(picogame_audio.tone(660, ms=80)) # fire-and-forget beep (no file)audio.music(audio.load("theme.wav")) # voice 0, loopingPros: any sound you can record; exact reproduction; dead simple. Cons: each sample is
resident RAM (load() keeps the WaveFile alive) — costly for a full SFX set; samples
must match the Mixer format (22050 Hz / mono / 16-bit signed by default).
B. picogame_synth — synthesized (synthio). Generates audio in real time (oscillators
- ADSR + LFOs + filters), so a whole SFX bank costs almost no RAM — you build
Noteobjects, not buffers.
import picogame_synth as snds = snd.Synth() # PWMAudioOut -> Mixer -> Synthesizerzap = snd.note(72, snd.SQUARE, decay=0.08, # an SFX, built once bend=snd.pitch_bend(-12, 120)) # optional pitch slide (laser)s.sfx(zap) # retriggers cleanly on repeatPros: tiny RAM regardless of how many sounds; tweak pitch/envelope/filter in code; great
for chiptune SFX. Cons: device-only (no simulator — guard the import); it’s synthesis,
so it won’t sound like a recorded sample; needs synthio in the firmware (both our boards have it).
C. MIDI music (picogame_synth.load_midi). A .mid file → a synthio.MidiTrack on the
synth’s music voice. A whole tune for the cost of the synth — no audio sample at all.
s.music(snd.load_midi("tune.mid")) # sequenced background, ~0 sample RAM| You want | Use | RAM |
|---|---|---|
| recorded SFX / a real music track | A picogame_audio (WAV) | each sample resident |
| a big bank of chiptune SFX | B picogame_synth (synthio) | ~0 (generated) |
| sequenced background music | C load_midi on the synth voice | ~0 (generated) |
| just a test beep | picogame_audio.tone() | one short RawSample |
Rule of thumb: synth for many small effects + sequenced music (RAM-cheap, device-only);
WAV when a specific recorded sound matters and you can afford the bytes. Note: bundled
demos run silent on device (no audio wired) — sound is opt-in per game. Don’t bake sound
hooks into a no-audio build (the journey device file dropped its dead sfx()/music=
calls; the sim/video version keeps them for the recorded soundtrack).
See: picogame_audio.py (WAV) · picogame_synth.py + examples/picogame_train.py (synthio
SFX) · tools/sfx_synth.py (offline chiptune for videos).
12. Saving (NVM)
Section titled “12. Saving (NVM)”import picogame_savesave = picogame_save.Save("arkanoid", {"hi": ("I", 0), "level": ("B", 1)})data = save.load() # defaults if blank/corrupt/key-mismatchdata["hi"] = max(data["hi"], score); save.save(data)Use picogame_save for high scores, progress, settings — it writes
microcontroller.nvm, which survives reboot and a filesystem wipe. Schema-based, so a
new build with a changed schema falls back to defaults cleanly.
Don’t persist to a file on CIRCUITPY for game state — NVM is the right store (no FS wear,
survives reflash). See picogame_save_demo, MEMORY.md.
13. Levels: declarative scene vs code
Section titled “13. Levels: declarative scene vs code”import picogame_sceneview = picogame_scene.load(pg, SCENE) # SCENE = a baked dict (sprites, tilemaps, zones…)solid = view.is_solid(tx, ty); pt = view.point("spawn")| Situation | Use |
|---|---|
| hand-authored levels, a level editor, data-driven maps | picogame_scene + the web editor → baked SCENE dict |
| one-off / highly dynamic / procedural scenes | plain code (build the Scene directly) |
Rule: many similar levels or non-coders authoring them → declarative; a single bespoke
scene → just code it. See SCENE_FORMAT.md, editor/,
picogame_platformer_scene.
14. RAM & performance — the decisions that matter on RP2040
Section titled “14. RAM & performance — the decisions that matter on RP2040”The RP2040 has ~138 KB usable Python heap. Most “why doesn’t it fit / why did it crash” comes down to a few choices:
| Problem | Reach for |
|---|---|
| a big animated full-frame surface | StripDraw (0 B) instead of a Canvas |
| a big static surface you can’t avoid | a Canvas sized to content; or back it with picogame_arena |
| lots of art / many frames you want resident | freeze into flash (FROZEN_MPY_DIRS) → referenced from flash, ~0 heap (the PicoLibSDK approach) |
| heap fragments over a long run (free ≠ contiguous) | grab big buffers early; arena; don’t churn |
| spawns/levels churning objects | Pool + reuse; gc.collect() between scenes |
| sprite/bitmap too big | PAL8 = 1 B/px; size = w*h*frames; drop spare frames; crop margins |
import crash on a big .py | ship .mpy; big data as bytes, not an array literal |
Rules:
- Pick by motion (Canvas vs StripDraw), pre-allocate (pools/arena), never
create/free big or many objects per frame,
gc.collect()at scene boundaries. gc.mem_free()is total free, not the largest contiguous block — see MEMORY.md.- On a tight build drop
strip_h(e.g. 12) for headroom; ship.mpy.
See: HARDWARE.md, MEMORY.md, lib/picogame_arena.py,
examples/journey_hw/ (StripDraw + no-arena monolith).
15. Where the art & sound live: frozen vs file-in-RAM vs streaming
Section titled “15. Where the art & sound live: frozen vs file-in-RAM vs streaming”A bitmap’s pixels have to be somewhere, and on a 138 KB heap the choice decides whether the
game fits. The trap first: CIRCUITPY is a FAT filesystem in flash, but it is NOT
memory-mapped — importing a big .mpy or reading a file copies it to the heap. Only
frozen data is zero-copy (executed/read in place from flash). So “it’s on flash” ≠ “it’s
free”. Three tiers:
A. Frozen into flash (FROZEN_MPY_DIRS). The art is a module with a bytes literal
(DATA = b'...') frozen into the firmware; pg.Bitmap(DATA, ...) references it in place —
~0 heap. This is the PicoLibSDK approach: the bulk of resident art on a tight build.
import dino_art # frozen module: DATA=b'...', WIDTH, FRAMES, ...spr = pg.Sprite(dino_art.bitmap(pg), x, y) # bitmap() wraps DATA in flash, no copyCons: changing the art means a reflash; it grows the firmware image.
B. File → RAM (readinto once). Ship a .bin on CIRCUITPY, read it into a pre-sized
bytearray at load, slice that blob into Bitmaps. The whole sheet is resident
(w*h*frames bytes; PAL8 = 1 B/px).
size = os.stat(BIN)[6]; blob = bytearray(size)with open(BIN, "rb") as f: f.readinto(blob) # ONE clean alloc -- NOT read() (it fragments)mv = memoryview(blob)spr_bmp = pg.Bitmap(mv[off:off + stride * h], w, h, format=pg.PAL8, palette=pal, frames=n, stride=stride, transparent=0)Pros: swap art without a reflash; iterate fast. Cons: holds the entire sheet in heap.
C. Streaming (picogame_stream.StreamSheet). Open the .bin once and keep one frame
in RAM; use(i) seeks + readintos that frame on demand. RAM ≈ one frame regardless of
frame count — the way to show a sprite sheet too big to hold resident.
sheet = picogame_stream.StreamSheet(pg, "jill.bin", 64, 100, 11, PAL, transparent=0)spr = pg.Sprite(sheet.bitmap, x, y) # shared one-frame buffersheet.use(frame) # flash read on change -> sprite shows itCons: a flash read per frame change (fine for a few big sprites at animation rates, wasteful
for hundreds of tiny ones); the .bin must be frame-major (tools/pack_sheet.py).
| Approach | Heap cost | Swap art w/o reflash? | Best for |
|---|---|---|---|
Frozen (FROZEN_MPY_DIRS) | ~0 (XIP from flash) | no | the bulk of resident art on a tight build |
File → RAM (readinto) | whole sheet w*h*frames | yes | sheets that fit + quick art iteration |
Streaming (StreamSheet) | ~one frame | yes | a few BIG sprites/backgrounds that won’t fit |

Rule: freeze what you always need, stream the few big things that don’t fit, keep small often-used sheets in RAM — mix all three in one game.
See: examples/dino_art.py (frozen) · examples/cavern_assets.py (file→RAM via readinto) ·
examples/pic_jill.py + examples/picogame_pictor.py (streaming) · tools/pack_sheet.py.
16. Input: picogame_input (auto keypad / digitalio backend)
Section titled “16. Input: picogame_input (auto keypad / digitalio backend)”There’s one input module — picogame_input — with a stable public API (Buttons +
UP/DOWN/.../ALL + board profiles + is_pressed/just_pressed/just_released/clear). It
auto-selects its backend per board: CircuitPython’s built-in keypad module where it
exists, else digitalio polling. Your game code is identical either way:
import picogame_input as inpbtns = inp.Buttons() # PicoPad profile by default; pass your own for another boardbtns.poll()if btns.is_pressed(btns.LEFT): ...if btns.just_pressed(btns.A): ...Backend A — keypad (the default where available). Backed by CircuitPython’s keypad
module: the C layer scans the keys in the background with hardware debounce and queues
press/release events, which poll() drains. just_pressed comes from the press events,
so fast taps are never missed and bounce can’t fire a ghost edge. This is what fixed the
Train’s “ghost moves”.
Backend B — digitalio polling (the automatic fallback). Where keypad is absent (the
simulator, a board without it), Buttons transparently falls back to sampling the pins
raw each poll() — per-frame sampling already filters bounce shorter than a frame. No
dependency, fully works in the simulator. Pass prefer_keypad=False to force this path.
| You’re running on… | Backend you get |
|---|---|
real HW with keypad (PicoPad etc.) | keypad — hardware debounce + event queue, no missed taps |
the simulator or a board without keypad | digitalio polling — automatic fallback, same API |
Rule: you don’t choose — picogame_input picks the best backend for the board and the game
runs unchanged everywhere. Call clear() on scene/menu transitions so a held button doesn’t
leak an edge into the next screen.
See: examples/picogame_train.py and most other examples (all use picogame_input).
17. Timing & movement: Clock (dt) vs FixedStep
Section titled “17. Timing & movement: Clock (dt) vs FixedStep”picogame_clock gives two loop shapes for “don’t let frame rate change the game”:
A. Clock(fps) — frame cap + delta time. tick() sleeps to the frame boundary and
returns the real dt; scale movement by dt so a tap doesn’t fling a sprite at high FPS.
clock = picogame_clock.Clock(30)while True: dt = clock.tick() # caps to 30 FPS, returns seconds elapsed x += speed * dt # frame-rate-independent motion scene.refresh()Pros: dead simple; smooth variable motion. Cons: physics steps vary in size, so collision / stacking can behave slightly differently at different frame rates (non-deterministic).
B. FixedStep(step_fps) — fixed-timestep accumulator. Runs game logic in equal steps
regardless of render time; you render once after catching up. Deterministic physics.
fixed = picogame_clock.FixedStep(60)while True: for dt in fixed.steps(): # 0..max_steps constant-dt steps for the elapsed time update(dt) # dt is always 1/60 -> reproducible scene.refresh()Pros: deterministic, stable collisions/stacking (platformers, pinball, anything physical); won’t “explode” on a frame spike (it caps steps to avoid a spiral of death). Cons: a touch more plumbing; render and logic rates are decoupled.
| Your game is… | Use |
|---|---|
| casual / arcade, motion just needs to be smooth | Clock (dt-scaled) |
| physics-y — jumps, bounces, stacking must be reproducible | FixedStep |
| fine at a steady cap, no dt math wanted | Clock and ignore dt (fixed feel) |
Rule: Clock for smooth, FixedStep for deterministic. Both are pure Python (no
firmware needed); Clock.tick_async() exists if you run an asyncio loop.
See: examples/picogame_pinball.py, examples/picogame_tetris.py,
examples/picogame_arkanoid240.py (all use Clock) · FixedStep lives in
lib/picogame_clock.py with a worked example in its docstring.
18. Rendering backend: fast DMA Display vs portable bus.send
Section titled “18. Rendering backend: fast DMA Display vs portable bus.send”Scene can push pixels two ways; picogame_game.setup(fast=…) picks one. Same drawing code,
different transport:
scene, a, b = picogame_game.setup(fast=True) # fast DMA Display where the port provides it# scene, a, b = picogame_game.setup(fast=False) # portable bus.send (works on any port)A. Fast Display (async/queued DMA, double-buffered). A port-specific backend
(pg.Display) blits the next strip while the current one transfers over SPI, and streams raw
DMA with the GRAM window held open. Provided by the raspberrypi port (PicoPad, RP2350,
PicoSystem) and the espressif port (ESP32-S3, via esp-idf queued DMA). This is the default
and what you want on those boards.
B. Portable bus.send. Blits a strip, sends it via the display’s blocking bus.send,
repeat — single buffer, no CPU/transfer overlap. Correct on any CircuitPython port; it’s
the automatic fallback where no fast backend exists, and setup() selects it transparently
(pg.Display(display) if fast and hasattr(pg, "Display") else display).
| Situation | Backend |
|---|---|
| PicoPad / RP2350 / PicoSystem / ESP32-S3 | fast (fast=True, the default) |
a port without a common-hal/picogame/Display.c | portable (automatic) |
| A/B-measuring the two on one board | flip fast= (see examples/picogame_bench.py) |
How much does it actually buy? The fast path’s only advantage is overlapping the
per-strip CPU blit with the SPI transfer (both backends already DMA the transfer itself), so
it hides at most min(blit, transfer) per strip — and only when a repaint spans multiple
strips. Measured on real hardware (picogame_bench.py):
| Workload | FPS gain (fast vs portable) |
|---|---|
| typical game — small dirty rects (single-strip) | ~0 % (no-op; one strip = the blocking first send) |
| full-frame redraw, light composition | ~5–10 % (ESP32-S3 +4 %, PicoPad +8 %) |
| full-frame, heavy per-pixel blit (big scaled/rotated sprites) | up to ~25–30 % (PicoPad +27 %) |
So the gain grows with per-strip blit cost — it’s largest exactly on the heaviest scenes, not a flat 5–10 %. It peaks when blit ≈ transfer and tapers off once the scene is so blit-bound that the hidden transfer is the smaller term. For a normal dirty-rect game expect essentially nothing; the win shows up on full-frame effects and transform-heavy composition.
Practical note: a board enables the fast path with CIRCUITPY_PICOGAME_FAST_DISPLAY = 1 in
its mpconfigboard.mk (it gates both the pg.Display type and the common-hal file). You
rarely touch fast= — leave it True; it self-downgrades to portable where needed (it costs
~1 KB of flash and never hurts).
See: examples/picogame_bench.py (N bouncing sprites + FPS; FAST/STRESS/HEAVY toggles
reproduce the three rows above).
Quick index — “I want to…”
Section titled “Quick index — “I want to…””| Goal | Section |
|---|---|
| draw a map / scrolling world | §1 Tilemap, §4, §8 |
| draw an animated full-screen effect cheaply | §1 StripDraw, §4 |
| draw a status bar / HUD / panel / gauge | §9 (four ways: SceneLabel · reserved zone · Canvas · StripDraw) |
| rotate / scale a sprite | §2 |
| recolour / flash / ghost / 90°-rotate a sprite (blit effects) | §2 (shadow/flash/tint/dither/transpose) |
| screen shake / fade / smooth camera / gradient sky | picogame_fx (REFERENCE.md) |
| animated water/lava or recolour via palette | picogame_palette (REFERENCE.md) |
| seeded/deterministic random, fair spawns (7-bag) | picogame_rand (REFERENCE.md) |
| coyote time / jump buffering | picogame_input.Timer (REFERENCE.md) |
| shrink a tileset (dedupe rotated/mirrored tiles) | png2picogame.py --dedup (PICOGAME.md) |
| animate a sprite | §3 |
| detect hits | §5 |
| fire lots of bullets / spawn enemies | §6, §7 |
| follow the player with a camera | §8 |
| make terrain / sky vary naturally | §10 |
| play sounds (WAV vs synth vs MIDI) | §11 |
| save a high score | §12 |
| author many levels | §13 |
| make it fit in RAM | §14 |
| store big art / many frames (frozen vs RAM vs streaming) | §15 |
| read the buttons (auto keypad / polling backend) | §16 |
| keep motion frame-rate independent (dt vs fixed step) | §17 |
| understand fast DMA vs portable rendering | §18 |