Skip to content

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:

Colours are wire-order RGB565 ints (C = pg.rgb565). Examples assume a scene from picogame_game.setup() (or pg.Scene(...)).


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…UseWhy / watch out
a moving object (player, enemy, bullet, coin)Spritethe basic building block; use a Pool for many identical ones
a big grid (map, tiled background, brick wall)Tilemap1 byte/cell vs 2 bytes/pixel; only changed cells repaint
shapes / a panel / HUD art that changes rarelyCanvaskeeps pixels → dirty-rect skips it while static. Costs w*h*2 bytes
an animated full-frame effect (road, gradient, sky, plasma)StripDrawdraws straight into the strip, 0 bytes. Repaints every frame
a one-off blit with no retained Scenerender()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 scanline road StripDraw — a full-frame animated surface (pseudo-3D road + sky), drawn straight into the strips with zero buffer RAM.

Tilemap grid 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
SituationUseWhy
occasional / smooth scale (coin pulse, boss grow, depth)runtime scaleone bitmap, any factor
occasional rotation, any angle (banking car, spin)runtime angleone 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 framepre-baked framesruntime affine is per-pixel; baked frames are a plain blit

Sprite rotation and scale 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:

WantUseNotes
drop shadow / dim a spritespr.shadow = Trueopaque 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 / glowspr.tint = RED (0=off)multiplies — keeps the sprite’s shading (unlike flash)
ghost / fog / fade a sprite in-outspr.dither = 0..16Bayer 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).


The same sprite blitted five ways: normal, flash, tint, dither, shadow

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 ticks
import picogame_anim
walk = picogame_anim.FrameAnim(spr, [0, 1, 2, 1], fps=8) # time-based
# each frame: walk.tick(dt)
SituationUse
a couple of sprites, fixed game tickmanual frame = (f // n) % N — zero overhead, totally clear
many sprites, or you want time-based (dt) playback, or named statespicogame_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.


A four-frame explosion cycle that picogame_anim advances over time

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:

BackgroundApproachCost
flat colourthe Scene backgroundfree
tiled / repeating sceneryTilemap of tile bitmaps1 B/cell
chunky shaded sky / terrainnoise → shade Tilemap (1 B/cell)~5 KB for a screen
a painted scrolling band (parallax)two Sprites of the band that wrapband_w*band_h*2, shared bitmap
an animated gradient / scanline fieldStripDraw0 B
# Noise sky as a shade tilemap (cheap): map fbm -> one of N colour frames
sky = 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

Noise shade tilemap background 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 box
if 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 collide
if collide.hit(a, b): ... # AABB straight from two sprites' positions/sizes
if collide.is_within(a, b, 22): ... # circular: centres within 22 px (no sqrt)
if collide.hit_point(a, px, py): ... # point inside sprite a
SituationUse
boxy things (paddle/ball, platforms, tile cells)pg.collide (or collide.hit) — AABB
round things / bullets vs blobs / pickupscollide.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).


AABB box overlap (left) vs circle/within radius overlap (right)

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_pool
bullets = 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)
SituationUse
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.


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()

Particle bursts 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.


scene.set_view(ox, oy) # screen position of the scene origin

A 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 edges
oy = 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.


A world bigger than the screen scrolled via set_view, with a fixed HUD bar on top

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 withRAMPer-frame cost
score / lives / a caption (text)SceneLabel — text as a fixed scene layerone small text bitmap~0 (repaints only on text change)
a fixed strip/panel the scene shouldn’t touchreserved zone + HudBar0 (scratch buffer)0 (scene never paints it)
a framed widget / gauge that changes rarelya small Canvas (fixed layer)w*h*2 bytes~0 while static (dirty-rect skips it)
an animated bar (gradient, pulsing meter)StripDraw (zero buffer)0repaints every frame

The rest of this section is the how and the why for each.

The four status-bar approaches stacked: SceneLabel text, a flat HudBar strip with score + lives, a bevelled Canvas gauge, a StripDraw gradient bar


A. Text bar → SceneLabel (a fixed scene layer)

Section titled “A. Text bar → SceneLabel (a fixed scene layer)”
import picogame_ui as ui
score = ui.SceneLabel(scene, pg, FONT, 6, 4, TEXT_FG, BAR_BG) # x, y, fg, bg
score.set("SCORE %05d" % n) # re-renders ONLY when the string changes

How: 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 out
bar = ui.HudBar(pg, board.DISPLAY, bufA, 0, H - 22, W, 22, BAR_BG) # the bottom strip
hearts = [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 wantReserveWhy
score / lives / status striptop= / bottom=a fixed bar with zero per-frame work
side panels (score, next-piece)left= / right=landscape column layouts
a framed playfieldall fourthe 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 keep
bar.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 scrolls
bar.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 scene

How: 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.


  1. Just text (score/lives/caption)? → A SceneLabel.
  2. Fixed strip/panel, flat colour, changes on events? → B reserved zone + HudBar (0 RAM, 0 per-frame — the default for a real status bar).
  3. Needs shape/bevel/gauge but changes rarely? → C a small Canvas (sized to the widget).
  4. 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).


h = pg.fbm2d(tx * 0.1, ty * 0.1, octaves=4, seed=7) # 0..1 fractal height field
v = pg.value2d(x * 0.6, y * 0.6, seed=3) # 0..1 smooth value noise

Use 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_audio
audio = 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, looping

Pros: 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 Note objects, not buffers.
import picogame_synth as snd
s = snd.Synth() # PWMAudioOut -> Mixer -> Synthesizer
zap = 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 repeat

Pros: 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 wantUseRAM
recorded SFX / a real music trackA picogame_audio (WAV)each sample resident
a big bank of chiptune SFXB picogame_synth (synthio)~0 (generated)
sequenced background musicC load_midi on the synth voice~0 (generated)
just a test beeppicogame_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).


import picogame_save
save = picogame_save.Save("arkanoid", {"hi": ("I", 0), "level": ("B", 1)})
data = save.load() # defaults if blank/corrupt/key-mismatch
data["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.


import picogame_scene
view = picogame_scene.load(pg, SCENE) # SCENE = a baked dict (sprites, tilemaps, zones…)
solid = view.is_solid(tx, ty); pt = view.point("spawn")
SituationUse
hand-authored levels, a level editor, data-driven mapspicogame_scene + the web editor → baked SCENE dict
one-off / highly dynamic / procedural scenesplain 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:

ProblemReach for
a big animated full-frame surfaceStripDraw (0 B) instead of a Canvas
a big static surface you can’t avoida Canvas sized to content; or back it with picogame_arena
lots of art / many frames you want residentfreeze 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 objectsPool + reuse; gc.collect() between scenes
sprite/bitmap too bigPAL8 = 1 B/px; size = w*h*frames; drop spare frames; crop margins
import crash on a big .pyship .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 copy

Cons: 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 buffer
sheet.use(frame) # flash read on change -> sprite shows it

Cons: 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).

ApproachHeap costSwap art w/o reflash?Best for
Frozen (FROZEN_MPY_DIRS)~0 (XIP from flash)nothe bulk of resident art on a tight build
File → RAM (readinto)whole sheet w*h*framesyessheets that fit + quick art iteration
Streaming (StreamSheet)~one frameyesa few BIG sprites/backgrounds that won’t fit

Relative heap cost of the three tiers: frozen is a sliver (~0 bytes), streaming a small bar (~one frame), file-to-RAM a full-width bar (whframes)

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 inp
btns = inp.Buttons() # PicoPad profile by default; pass your own for another board
btns.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 keypaddigitalio 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 smoothClock (dt-scaled)
physics-y — jumps, bounces, stacking must be reproducibleFixedStep
fine at a steady cap, no dt math wantedClock 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).

SituationBackend
PicoPad / RP2350 / PicoSystem / ESP32-S3fast (fast=True, the default)
a port without a common-hal/picogame/Display.cportable (automatic)
A/B-measuring the two on one boardflip 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):

WorkloadFPS 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).


GoalSection
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 skypicogame_fx (REFERENCE.md)
animated water/lava or recolour via palettepicogame_palette (REFERENCE.md)
seeded/deterministic random, fair spawns (7-bag)picogame_rand (REFERENCE.md)
coyote time / jump bufferingpicogame_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