Skip to content

picogame — quick reference

A one-page cheat sheet of the engine’s everyday API: the native picogame C module and the pure-Python picogame_* helper libraries in lib/. Signatures show parameter names and defaults; * marks keyword-only arguments. Colours are wire-order RGB565 ints (build them with rgb565). For longer explanations see the engine guide.

See also: Fit it in RAM · Drawing paths · Performance · Run on hardware · Coming from another engine.


Native module: picogame (import picogame as pg)

Section titled “Native module: picogame (import picogame as pg)”
  • RGB565, PAL8 — bitmap pixel formats.
  • API_LEVELint; engine API generation, bumped when the Python-visible surface grows. Libraries check getattr(pg, "API_LEVEL", 0) >= N to diagnose a too-old firmware up front instead of failing later on a missing attribute.
  • RGB444_SUPPORTEDbool; whether this board’s panel can drive 12-bit RGB444 (lets one game opt into Display(rgb444=True) only where it works).
  • FPUbool; True when the 3D math primitives (pg.project) run the hardware-float path (RP2350, ESP32-S3), False on the RP2040 (16.16 fixed-point). Pack project buffers to match: array("f") when pg.FPU else array("i") with values int(v * 65536).
  • rgb565(r, g, b) -> int — wire-order colour from 8-bit channels.
  • collide(x1, y1, x2, y2, ax1, ay1, ax2, ay2) -> bool — AABB overlap (8 args = box vs box) or point-in-box (6 args: collide(x1, y1, x2, y2, px, py)). Inclusive AABB, so boxes collide when they touch (pass sprite boxes as (x, y, x+w, y+h); fires on contact).

Bitmap(data, width, height, *, format=RGB565, palette=None, frames=1, stride=0, transparent=None)

Section titled “Bitmap(data, width, height, *, format=RGB565, palette=None, frames=1, stride=0, transparent=None)”

An image atlas of equal-size frames (any size). data is a buffer; palette (array of wire colours) is required for PAL8. transparent = the index/colour skipped when blitting.

  • Read-only props: width, height, frames, format, stride (pixels per source row; leave 0 for tightly-packed data, set it only for a sub-window of a larger image), palette (the PAL8 palette buffer or None), transparent (the transparent value or None).

Sprite(bitmap, x=0, y=0, *, frame=0, visible=True, flip_x=False, flip_y=False)

Section titled “Sprite(bitmap, x=0, y=0, *, frame=0, visible=True, flip_x=False, flip_y=False)”

A positioned, animatable instance of a Bitmap.

  • Position/anim props: x, y (int px) · fx, fy (float sub-pixel) · frame · visible · flip_x, flip_y · bitmap (swap) · data (your payload). Never mix the two on one axis. They are ONE stored value: writing x REPLACES it, discarding the sub-pixel remainder, so the classic “accumulate in fx, then clamp with x” freezes slow motion dead (a 0.4 px/frame drift never survives the clamp). Clamp with fx/fy too. Same on device - the firmware stores 24.8 fixed point and its x setter overwrites it identically, so the sim is not lying to you here.
  • Transform props (nearest-neighbour, about the anchor):
    • scale — float draw scale; 1.0 = native (fast path), 2.0 = double size, fractional allowed (e.g. a pulse).
    • angle — rotation in degrees; 0 = none (fast path). Combines with scale.
    • transpose — bool; swaps X/Y axes. On its own that is a diagonal mirror, not a rotation; combine with a flip for a crisp, shimmer-free quarter-turn. With flip_x/flip_y it reaches all 8 orientations. Fast path only (scale 1, angle 0); footprint swaps w/h. Recipes (screen y-down): 90° CW = transpose+flip_y · 180° = flip_x+flip_y · 270° CW = transpose+flip_x.
    • anchor = (fx, fy) — pivot as fractions of the bitmap (0..1): (0.5, 0.5) = centre, (0.5, 1.0) = bottom-centre. x/y and rotation are about this point.
  • Blit-effect props (one at a time - a TRUTHY write to any of them clears the other three; a FALSY write (spr.flash = 0) clears ONLY that effect, so spr.dither = 0 will NOT switch off an active flash - clear the effect you actually set; cheap, no extra bitmaps):
    • shadow — bool; opaque pixels darken the destination (drop-shadow / dim overlay).
    • flash — wire-RGB565 colour (or 0/None = off); opaque pixels drawn as that flat colour (hit-flash). Pulse 1–3 frames.
    • tint — wire-RGB565 colour (or 0 = off); opaque pixels multiplied by it, colouring the sprite while keeping its shading (damage-red, freeze-blue, glow).
    • dither0 (opaque) .. 16 (invisible); Bayer-stipple translucency, no alpha (ghosts, fog, fade-in/out).
  • move(x, y) — set position. · touch() — mark dirty after an in-place bitmap/palette edit.
  • overlaps(other, inset=0) -> bool · near(other, r) -> bool — native collision tests (see Sprite collision below).

Fast DMA backend wrapping a board’s busdisplay (FourWire SPI). Pass to Scene. rgb444=True drives the panel in 12-bit RGB444 (~25% less SPI traffic) on panels that support it; gate with RGB444_SUPPORTED.

Scene(display, buffer_a, buffer_b, *, background=0, top=0, bottom=0, left=0, right=0)

Section titled “Scene(display, buffer_a, buffer_b, *, background=0, top=0, bottom=0, left=0, right=0)”

Retained-mode scene with dirty-rectangle rendering; buffer_a/b are strip buffers.

  • add(item, *, fixed=False) -> item — add a Sprite/Tilemap/Particles/Canvas/StripDraw (insertion order = bottom→top) and return it (so spr = scene.add(Sprite(...)) works). fixed=True (keyword-only) pins it to the screen (ignores the camera) for HUD/dialog.
  • add_all(items) — add several (bottom→top).
  • remove(item) — unlink a previously added item; the removal forces a FULL repaint next refresh (no ghost). Scene.invalidate() takes NO arguments — the sub-rect form belongs to StripDraw.invalidate(x, y, w, h) (all four positionally, or none).
  • set_view(ox, oy) — camera offset (screen position of the scene origin); changing it repaints all.
  • view — read-only (ox, oy) camera offset.
  • invalidate() — force a full repaint next refresh.
  • refresh() -> list | None — diff & repaint changed regions; returns the dirty rect [x1,y1,x2,y2] (reused) or None. The SIMULATOR has no dirty-rect: it clears and redraws everything, then returns the full play rect - or None when the finished frame is pixel-identical to the last one (firmware parity for the if scene.refresh(): idiom). So the rect’s SIZE is not a measurement there (an instrumented “94% of the screen dirty” is a constant, not your game). Run sim/run.py --strict-dirty to at least catch the StripDraw half of it - a layer you forgot to invalidate() then freezes in the sim as it does on device.

A grid of tile indices into a tileset Bitmap (each frame = one tile); a Scene layer.

  • get_tile(tx, ty) -> int — read a tile. · set_tile(tx, ty, value, *, flip_x=False, flip_y=False, transpose=False) — write one (with optional keyword-only per-cell orientation: flip_x/flip_y/transpose give all 8 orientations of a tile; pair with a deduplicated tileset, see png2picogame.py --dedup). Out-of-range ignored. The orientation plane is allocated lazily (RAM only if a map uses it).
  • fill(value) — set every tile (clears orientation).
  • move(x, y) — position the map.
  • Read-only props: x, y, cols, rows. Breaking change: replaced tile(tx, ty[, value]) (firmware after 2026-08-23); old code raises AttributeError. The firmware is what must be new enough.

Particles(capacity, *, size=1, gravity=0.0, fade=False)

Section titled “Particles(capacity, *, size=1, gravity=0.0, fade=False)”

gravity is px/tick² added to vy each tick, clamped to -128..127.99; NEGATIVE gravity is legal and makes particles RISE (embers, bubbles). Particle positions are world coordinates - scene.set_view moves them like any other layer. A pooled particle layer (small moving dots) drawn as one Scene layer.

  • emit(x, y, count, speed=1, life=30, color=0xFFFF) — burst count dots, random velocity ≤ speed px/tick, living life ticks.
  • tick() — advance one step (move, gravity, ageing). Call each frame.
  • clear() — remove all.

Canvas(width, height, *, transparent=None, buffer=None)

Section titled “Canvas(width, height, *, transparent=None, buffer=None)”

A RAM RGB565 drawing surface composited as a Scene layer (width*height*2 bytes). transparent makes it a shaped overlay; buffer backs it with external memory (e.g. an arena slice). For animated full-frame surfaces prefer StripDraw (no buffer).

  • clear(color) · pixel(x, y, color) · fill_rect(x, y, w, h, color) · rect(x, y, w, h, color)
  • line(x0, y0, x1, y1, color) · circle(cx, cy, r, color) · fill_circle(cx, cy, r, color) · ring(cx, cy, r, thickness, color)
  • triangle(x0,y0, x1,y1, x2,y2, color) · fill_triangle(...) · ellipse(cx, cy, rx, ry, color) · fill_ellipse(...)
  • fill_round_rect(x, y, w, h, r, color) · frame3d(x, y, w, h, light, dark) (beveled box) · move(x, y)
  • blit(bitmap, x, y, frame=0, flip_x=False, flip_y=False) — stamp a bitmap frame into the surface (honours its transparent key; the retained way to bake an icon/portrait/rendered text into a panel).
  • text(x, y, s, fg, font, bg=None) — composite a string in C, rasterizing each glyph of font (a fontio.BuiltinFont) on the fly. The built-in terminalio.FONT is a fixed 6×12 cell, so a string is len(s) * 6 px wide and centring is (W - len(s) * 6) // 2 (both fonts here are fixed-width: picogame_bitfont is 8×8). At 6 px/char a 320 px screen holds 53 characters and a 240 px one holds 40 — budget text against the smaller. Details: no Python glyph cache and no per-call Bitmap/Sprite. bg=None → transparent glyph background. ASCII/built-in font only. Works on a Canvas or a StripDraw view; the latter does not retain a separate text surface.
  • mode7(texture, horizon, y_off, z, rx0, ry0, rsx, rsy, cam_x, cam_y) — fill the rows below horizon with a Mode-7 perspective floor of texture (power-of-2 dims; texture is a Bitmap, horizon/y_off are pixel rows, the remaining 7 args are 16.16 fixed-point; one world unit = one tile). 10 fixed-point (16.16) args — you normally let picogame_mode7.Camera compute them from a camera pose. Draws into a Canvas or a 0-RAM StripDraw view (pass y_off = the strip top).
  • vspans(x0s, x1s, tops, bots, colors, n, x_off=0, y_off=0) — fill n vertical colour spans in one call: span i covers x0s[i]..x1s[i] × tops[i]..bots[i] (both exclusive) in colors[i]; all five are uint16 arrays. The batch primitive for column renderers — picogame_ray paints its merged wall runs with one call per strip (x_off=-vx, y_off=-vy replay, off-band spans rejected with a couple of compares), which made its per-strip cost independent of the run count (measured: a full-screen stride-1 raycast frame 203–275 ms → ~27 ms (~36 fps)).
  • fill_triangles(verts, colors, n, x_off=0, y_off=0) — fill n triangles in one call: verts = int16 x0,y0,x1,y1,x2,y2 per triangle, colors = wire-RGB565 uint16 per triangle. Same rasteriser as fill_triangle, but the whole batch crosses the Python/C boundary once — the win for many small triangles (blocky 3D, low-poly, isometric), where the ~10 µs per-call overhead otherwise dominates. x_off/y_off translate every vertex before clipping: pass y_off=-vy in a StripDraw callback to replay one screen-space batch into each render strip (off-band triangles are rejected with a few compares) — full-res 3D with no retained canvas at all, the preferred path on framebuffer boards. Companion of pg.project and picogame_iso.emit_blocks.
  • road(ri0, tab, rl, rr, d05_q8, d07_q8, colors) — draw one OutRun-style racing-road strip from precomputed tables: the whole per-scanline loop (sky/road/rumble/dash colour picks) in one call. ri0 = road-table row at this surface’s row 0 (negative = sky rows); tab = int16 rows of {edge_w, dash_hw, wb05_q8, wb07_q8, flags}; rl/rr = int16 per-row edges from pg.road_edges; d05/d07 = Q8 scroll phases; colors = 6× uint16 {sky, road_a, road_b, rumble_a, rumble_b, dash}. Designed as a StripDraw callback body (0-RAM road).
  • Read-only props: x, y, width, height.

StripDraw(callback, x=0, y=0, width=0, height=0, *, always_dirty=True)

Section titled “StripDraw(callback, x=0, y=0, width=0, height=0, *, always_dirty=True)”

Immediate-mode layer with no pixel buffer: each refresh it calls callback(view, vx, vy, vw, vh) once per render strip inside its rect. view is a Canvas pointing at the live strip (use Canvas primitives, incl. view.text); view-local (0,0) = screen (vx, vy). It is screen-space ALWAYS, so fixed=True on it is a no-op - it neither scrolls with the camera nor smears in a scrolling scene.

  • always_dirty=True (default) repaints every frame → for animated/scanline content (pseudo-3D, gradients). always_dirty=False repaints only when invalidated or overlapped by another change → for static/on-change panels (it still renders once on first refresh).
  • invalidate() — mark it dirty so the next refresh repaints it (the way to update an always_dirty=False panel when its content changes).

A retained screen-space triangle batch the compositor rasterises entirely in C per render strip (cheap band reject + the Canvas rasteriser) — no pixel buffer AND no Python per strip. verts = int16 array (x0,y0,x1,y1,x2,y2 per triangle), colors = uint16 wire-RGB565 per triangle — both caller-owned (fill them in place each frame). This is the 3D-scene layer: pg.project into the arrays, painter’s-order the faces, set count, scene.refresh(). Because no Python runs during compose, it stays composable by the core1 band split — unlike a StripDraw callback.

  • count — how many triangles draw next refresh (clamped to the buffer capacity); assigning marks the layer dirty for a full repaint (set it every frame in a live 3D scene).
  • Measured (roadhop lab): replaces the fill_triangles-in-StripDraw replay with ~30 % less refresh time at 320×240 and unlocks the dual-core compose (640×480 at a locked 20 fps on an RP2350 with a free second core).
  • The ONLY property is count — how many triangles the next refresh draws (assignment marks the layer dirty). There is no x/y/width/height/always_dirty on this type.

Most games never call these (picogame_game.setup + Scene use them internally), but they are exposed for hand-built render loops.

  • render(display, layers, buffer, x0, y0, x1, y1, *, background=0) — render a layer list (any scene-layer kind) into the region [x0,x1) × [y0,y1) and push it to display. buffer is a reusable strip buffer (≥ region-width × 2 bytes). Mixing with a retained scene: the scene doesn’t know render() changed the pixels — if the region overlaps the scene’s play rect, call scene.invalidate() after (or use picogame_game.overlay, which does both); HUD bands outside the play rect don’t need it.
  • invert(display, on) — toggle the panel’s hardware colour inversion. Changes the panel’s inversion state without sending pixel data, so a brief invert makes a full-screen negative flash (a 1-bit “hit” look) with no redraw. See picogame_fx.InvertFlash.
  • project(cam, pts, n, out_sx, out_sy)batch perspective projection of n 3D points to screen in C. cam = 15 camera params (ex,ey,ez, rx,rz, ux,uy,uz, fx,fy,fz, focal, cx0, cy0, near), pts = n×3 world coords, out_sx/out_sy = int16 screen coords (a point behind the near plane gets the sentinel -32768 — skip its faces). Buffer format follows pg.FPU (float32 on FPU boards, 16.16 int32 on the RP2040 — a format mismatch culls everything = black screen). One call per frame + Canvas.fill_triangles = real flat-shaded polygon 3D (Elite-class): project your vertices, painter’s-sort faces, fill. ~0.7 ms/480 pts on an RP2350, ~2.2 ms on an RP2040.
  • road_edges(rl, rr, hw, n, cx0, dist, cfg) — one racing-road frame’s curve accumulator + integer edge tables in a single call (the OutRun-genre compute_road loop). rl/rr = int16 outputs for Canvas.road, hw = int32 Q16 per-row half-widths, cx0 = Q16 screen centre (incl. lateral offset), dist = integer world distance, cfg = int32[7] curve config (f1,f2 Q20 frequencies, a1k,a2k Q16 amp×gain, world step, curve step, row offset) — there is NO hill term: road_edges emits horizontal edges only, and hills come from moving the horizon you pass as Canvas.road’s ri0. Pairs with Canvas.road for a 0-RAM 30 fps road on the RP2040.
  • vblank(framebuffer) — (DVI boards, RP2350; fork-only, see the note under core1) pass the picodvi.Framebuffer the display scans out of; blocks until its next vertical blanking (≤ ~16.7 ms). Starting a full-frame compose right after vblank keeps the publish front consistently behind the beam, so each sweep shows one whole frame — removes single-buffer tearing while the compose fits within two sweeps. Costs the wait: budget it against your FPS cap.
  • core1(on) -> bool — (RP2 boards) route splittable engine kernels (Canvas.mode7 rows, the framebuffer compose bands) through the second core. Returns the resulting state: False when core1 is unavailable — e.g. a USB-host board (Fruit Jam) runs its USB service on core1 permanently, so the engine refuses rather than stomping it. Dual-core compose measured ~1.75× on an RP2350 with a free core1.
  • core1 and vblank are NOT in a CircuitPython release. Both live on the fork’s picogame-core1 branch and have not gone upstream, so pg.core1 and pg.vblank raise AttributeError on any firmware you download from circuitpython.org. Guard them with hasattr(pg, "core1") / hasattr(pg, "vblank") and treat the dual-core and tear-free paths as optimisations you may not have.

Procedural noise (coherent value noise, 0..1)

Section titled “Procedural noise (coherent value noise, 0..1)”
  • value2d(x, y, *, seed=0) -> float · value1d(x, *, seed=0) -> float
  • fbm2d(x, y, *, octaves=4, seed=0, lacunarity=2.0, gain=0.5) -> float · fbm1d(x, *, octaves=4, seed=0, lacunarity=2.0, gain=0.5) -> float — fractal (summed octaves).

Helper libraries (lib/picogame_*.py, pure Python)

Section titled “Helper libraries (lib/picogame_*.py, pure Python)”
  • setup(display=None, strip_h=None, background=0, fast=True, top=0, bottom=0, left=0, right=0, rgb444=None) -> (scene, buffer_a, buffer_b) — take over the display, build a Scene + two strip buffers. top/bottom/left/right reserve fixed HUD margins; rgb444=True opts into 12-bit colour on a supporting SPI panel, and rgb444="auto" enables it only where the board reports picogame.RGB444_SUPPORTED. The default None follows PICOGAME_RGB444 in settings.toml (1 = "auto", unset = RGB565).
  • overlay(scene, display, items, buffer, x0, y0, x1, y1, *, background=0) — immediate-draw items over a live scene (pause / menu / cutscene / banner) = pg.render + scene.invalidate(), so the next refresh() repaints the full frame instead of leaving overlay fragments.
  • screen() -> (width, height) — the screen size, from whichever display the board provides. Lay every game out from this instead of hardcoding 320×240.
  • display() — that same display object (for pg.render, picogame_fx.InvertFlash, …). Both read supervisor.runtime.display — the board’s primary display, which CircuitPython picks right after board init and which a boot.py, a launcher or open_framebuffer() publishes with supervisor.runtime.display = disp. That is the one way a display reaches a game, so the same file runs on a PicoPad, a Fruit Jam, a bare Pico, in the simulator and in the browser playground (the last two ship a small supervisor shim).
  • open_framebuffer(width, height, color_depth=None) -> display — set the resolution from inside a game on a framebuffer board (Fruit Jam DVI), e.g. open_framebuffer(640, 480); a no-op that returns the current display on a fixed SPI panel. Pass the result to setup(display=…).
  • resolve_display(display=None) -> (display, is_framebuffer) — normalise a display/framebuffer handle (used by the HUD / immediate-render helpers).
  • Clock(fps=30, max_dt=0.1) · .set_fps(fps) · .tick() -> dt (sleep to frame, return seconds) · .tick_async() (the same, for asyncio loops).
  • FixedStep(step_fps=60, max_steps=5) · .steps() — generator yielding a constant dt per fixed step · .step_count().
  • Masks: UP DOWN LEFT RIGHT A B X Y L1 L2 R1 R2 START SELECT ALL (a superset; each board maps the subset it has); profile PICOPAD.
  • Buttons(profile=None, pull=None, prefer_keypad=True, debounce_s=0.02, matrix=None, usb=None, sources=None) · .poll() -> mask · .is_pressed(mask=ALL) · .just_pressed(mask=ALL) · .just_released(mask=ALL) · .has(mask=ALL) (is the mask present in the profile) · .repeat(button, delay=15, interval=4) — PICO-8 btnp auto-repeat (menus / grid move) · .clear() (drop held state) · .attach(source) / .detach(source) — OR another input source in/out at runtime (a picogame_seq.Script attract demo, a late USB pad) · .deinit() — release the pins this Buttons owns (keypad scanner / matrix / digitalio inputs) so a NEW Buttons() can claim them in the same program (launcher, test harness); attached sources are only dropped, not deinit’d; idempotent.
    • matrix= — a scanned key-matrix source (also configured board-wide via the PICOGAME_MATRIX_* settings keys); usb= — one or more extra button sources (USB pad/keyboard, below). Buttons ORs every source together, so a game reads them all with no code change.
  • Timer(frames) — input-leniency window (coyote time / jump buffering): .feed(condition) (recharge while true, else decay) · .charge() (fill) · .is_active · .consume() (true once, then clears) · .t (frames left; there is no reset()consume() or .t = 0 clears it, e.g. the coyote timer right after a jump so it can’t fire twice).

picogame_usbpad — USB HID gamepad source (USB-host boards, e.g. Fruit Jam)

Section titled “picogame_usbpad — USB HID gamepad source (USB-host boards, e.g. Fruit Jam)”
  • UsbPad(buttons=None) — a button source for Buttons(usb=…) (auto-attached by default on a USB-host build). Reads a USB HID gamepad and ORs it into the button mask, so a plugged-in pad works with zero game code changes. Needs a USB-host CircuitPython build (usb.core); a no-op on boards without it.
  • Default map = the ubiquitous DragonRise 081f:e401 SNES-style pad; remap per pad from settings.toml (PICOGAME_USBPAD, no reflash — see Custom board). Discover a new pad’s report bytes with tools/usbpad_probe.py.
  • .mapped — mask of buttons this pad can report; VERSION, MAPPED module constants.

picogame_usbkbd — USB HID keyboard source (USB-host boards)

Section titled “picogame_usbkbd — USB HID keyboard source (USB-host boards)”
  • UsbKbd(keys=None) — the keyboard twin of UsbPad, a Buttons(usb=…) source. Found by its boot-keyboard HID interface (no fixed VID/PID); works with wired and 2.4 GHz-dongle keyboards (not Bluetooth).
  • Default map: arrows + WASD → D-pad, Z/Space → A, X → B, C → X, V → Y, Q → L1, E → R1, Enter → START, Esc → SELECT. Remap from settings.toml (PICOGAME_USBKBD, NAME=HID-keycode). For a combo dongle whose real keystrokes flow on a sibling interface, point it at the right channel with PICOGAME_USBKBD_EP = "iface:endpoint" (find it with tools/usbkbd_probe.py).

picogame_i2cpad — I2C gamepad source (any board with I2C, incl. the PicoPad)

Section titled “picogame_i2cpad — I2C gamepad source (any board with I2C, incl. the PicoPad)”
  • I2CPad(recipe, i2c=None, address=None) — a button source for Buttons(sources=[…]) reading a “dumb” I2C button device: GPIO expanders (TCA9555, PCF8574, MCP23017) and vendor pads built on them. Needs no USB host, so it is the way to put an external controller on a PicoPad or a bare Pico. .read() → logical mask, .mapped → the buttons it can report, .led(n, on) → player LED (presets that have one).
  • Opt-in via settings.toml — an expander has no identity register, so auto-probing could bind an unrelated device: PICOGAME_I2CPAD = "qwstpad" (preset — "gamepadqt" is the Adafruit Mini I2C Gamepad, whose thumbstick is read as the directions), "qwstpad@0x23" (address), "qwstpad;qwstpad@0x23" (several pads), or a full recipe "addr=0x20 read=:1 inv=1 UP=0 A=4 …". PICOGAME_I2C = "SDA,SCL" names the bus pins on a bare board (or a single token names a board bus, e.g. "I2C0"). Buttons() attaches whatever is listed — see Input.
  • attach(spec, i2c=None) — the pads for a settings value (what Buttons calls). find_pads(preset="qwstpad", i2c=None) — every pad of a preset on the bus, in address order, one source per player (the QwSTPad preset covers four addresses). parse_recipe(text) / PRESETS — the recipe format and the shipped presets.
  • A poll is one short transaction (~0.5 ms at 100 kHz); a failed poll holds the last state and reports all-released after 8 misses, and the bus is clocked free after a soft reload.

picogame_shiftpad — buttons on a parallel-in shift register (74HC165 and friends)

Section titled “picogame_shiftpad — buttons on a parallel-in shift register (74HC165 and friends)”
  • ShiftPad(recipe) — a button source for Buttons(sources=[…]) reading eight (or more) switches clocked out of a 74HC165 over three GPIOs. A handful of handhelds wire their buttons this way to save pins; without it a board like the Adafruit PyBadge has no usable input at all. .read() → logical mask, .mapped → the buttons it can report.
  • Opt-in via settings.toml — clocking three unknown GPIOs is not a read-only act, so it is never probed: PICOGAME_SHIFTPAD = "pybadge" (preset), or a full recipe "latch=BUTTON_LATCH clock=BUTTON_CLOCK data=BUTTON_OUT bits=8 LEFT=7 UP=6 DOWN=5 RIGHT=4 SELECT=3 START=2 A=1 B=0". inv=1 when the register reads 1 for a RELEASED button; msb=0 clocks the low bit out first. Buttons() attaches whatever is listed — see Input.
  • attach(spec) — the pad for a settings value (what Buttons calls). parse_recipe(text) / PRESETS — the recipe format and the shipped presets.

picogame_tiltpad — the board’s accelerometer as a D-pad

Section titled “picogame_tiltpad — the board’s accelerometer as a D-pad”
  • TiltPad(recipe, i2c, addr=None) — a button source whose directions are OR’ed with the real D-pad, so a handheld with a motion sensor steers by tilting and games need no changes. .read() → logical mask. It reads the sensor’s registers directly: there is no driver to install.
  • Opt-in via settings.toml — an accelerometer answering on the bus is not a request to steer with it: PICOGAME_TILTPAD = "pybadge", or a preset plus overrides, "lis3dh on=4000 off=2500". Two thresholds, because with one the direction chatters while the hand rests near it (on engages, off releases, off < on; raw counts, ~16384 = 1 g at ±2 g). swap=1 for a portrait board, invx=1 / invy=1 flip an axis, calib=0 uses the sensor’s own zero instead of measuring level at attach.
  • attach(spec, i2c=None) — the pad for a settings value; this is the one that defaults the bus (TiltPad itself wants an explicit i2c). parse_recipe(text) / PRESETS.

picogame_font — text bitmaps (external font module)

Section titled “picogame_font — text bitmaps (external font module)”

Which text path to use (Canvas.text vs a rendered Bitmap vs a StripDraw view — and what each costs): see the decision matrix in Drawing paths.

  • render_text(pg, font, text, fg, bg=None) -> (bitmap, w, h) — render a string to a PAL8 Bitmap (bg=None → transparent).
  • render_text_pal(pg, font, text, fg, bg=None) -> (bitmap, w, h, palette) — same, plus the palette array; mutate palette[1] to recolour the text in place (no rebuild).
  • Label(pg, font, x, y, fg, bg) · .move(x, y) · .set(text) -> changed · .draw(display, buffer).

picogame_bitfont — built-in font (no font module needed, fixed 8×8 cell: GLYPH_W/GLYPH_H)

Section titled “picogame_bitfont — built-in font (no font module needed, fixed 8×8 cell: GLYPH_W/GLYPH_H)”
  • render_text(pg, text, fg=None, outline=None, mid=None, bg=None) -> (bitmap, w, h) — render with the bundled bitmap font; optional outline/mid give a cheap 2-tone outlined look.

picogame_ui — HUD & menu widgets (LINE_H = 12)

Section titled “picogame_ui — HUD & menu widgets (LINE_H = 12)”
  • SceneLabel(scene, pg, font, x, y, fg, bg=None, scale=1, fixed=True) · .set(text) · .reserve(chars) · .show(on) · .color(fg) · .destroy() — camera-independent text label (a fixed Scene layer). reserve(chars) switches it to a FIXED-width buffer built once: set() then composes glyphs in place — no growth and no Bitmap/palette rebuilds (the compose leaves ~0.5-1.5 KB of short-lived slice churn per CHANGED update (device-measured: RP2040 ~0.6 KB, ESP32-S3 ~1.4 KB), freed at the next GC — update on change, not per frame), and the label cannot grow-realloc on a fragmented heap. Assign .x / .y to MOVE it (the scene repaints the old and the new rect); there is no width metric, so CENTRE a changing value by reserving the widest string and padding with spaces (the font cell is fixed-width, 6 px/char - ui.text_width / ui.centred). scale=2 doubles the glyphs (a title banner needs no second sprite); show(False) HIDES it - use that to clear a reserved label, because set(" ") repaints its FULL reserved width in bg (a visible strip). color() recolours without a rebuild. destroy() detaches a ONE-SHOT label so GC reclaims it (recurring HUD: build once + set/hide instead).
  • SceneBox(scene, pg, font, x, y, w, h, fg, bg, nlines=3, key=None, border=None) · .show(lines) · .hide() · .set_line(i, text) · .lift() — move the box to the top of the draw order (a layer added later, e.g. a Fade, would paint over it; returns the box) · .destroy() — a multi-line in-scene panel (dialog/log); a row holds (w - 16) // 6 chars of the 6 px font (8 px padding each side), rows 12 px apart (h >= 14 + 12 * nlines); longer lines are not clipped (the sim warns). destroy() = one-shot teardown (needs firmware with Scene.remove).
  • HudBar(pg, display, buffer, x, y, w, h, bg) · .add(sprite) (an icon Sprite) · .label(font, x, y, fg, text=" ") → a text handle; update it with handle.set(text) · .draw() (repaint the bar, call on HUD changes) — a fixed bar that composites sprites + labels (0 retained RAM).
  • TextBox(pg, font, x, y, w, h, fg, bg, maxlines=6) · .draw(display, buffer, lines, force=False).
  • Menu(pg, font, x, y, items, fg, bg, *, title=None, rows=None, width=None, paged=True) · .tick(btn) → index ≥0 on A, CANCEL (= -2) on B, None while navigating · .draw(display, buffer, force=False).
  • SceneMenu(scene, pg, font, x, y, items, fg, bg, title=None, rows=None, width=None, border=None, paged=True) · .show(sel=0) · .hide() · .set_items(items, sel=0) (swap the entries and resize WITHOUT a new scene layer) · .tick(btn)index ≥0 on A, CANCEL (= -2) on B, None while navigating — the same menu as an in-scene layer.
  • GridCursor(cols, rows, tx=0, ty=0, wrap=False, delay=15, interval=4) · .index · .tick(btn) -> (tx, ty) | None | ui.CANCELdelay/interval tune the held-direction repeat (frames before the first repeat / between repeats) — D-pad cursor over a grid (inventory / board). tick moves on held D-pad (auto-repeat) and returns the selected cell on A, ui.CANCEL on B, else None; guard with if pick is not None and pick is not ui.CANCEL: (the tuple does not support >= 0).
  • OptionsMenu(scene, pg, font, x, y, w, rows, fg, bg, title=None, border=None, visible=None) · .value(key) · .show(sel=0) · .hide() · .set_rows(rows, sel=0) · .tick(btn) — an in-scene options screen of toggles/choices. Its tick() returns the row’s KEY where SceneMenu/Menu return an INDEX - the one difference that bites when you move a menu from one to the other. Use OptionsMenu for named settings with values, SceneMenu for a plain list of actions. visible is a row count, not a flag: it fixes the window height up front (a list that grows and shrinks keeps one panel size); omitted, the panel fits the rows it was given.

picogame_shapes — single-colour bitmap generators

Section titled “picogame_shapes — single-colour bitmap generators”
  • rect(w, h, color) · circle(d, color) · ring(d, color, thickness=2)
  • from_mask(mask, color) — Bitmap from a list of strings, one per row ('#', 'X' or '1' = set); sized to the mask. color may also be a {character: colour} dict - then every character is its own palette entry (unlisted characters, e.g. . or space, stay transparent), so ONE mask carries body + outline + highlight: from_mask([" ## ", " #oo# ", "#o..o#"], {"#": OUTLINE, "o": BODY, ".": GLINT}). That is how you get the shape AND colour identity the design bar asks for without hand-building a PAL8 atlas. Passing a single string is not an error — each CHARACTER becomes a row, so you get a 1-pixel-wide sprite and no exception.
  • masks(mask_list, color)multi-frame from_mask: a list of string masks → one horizontal atlas Bitmap (frame i = mask_list[i]), sized to the largest. The step between from_mask (single frame) and atlas (raw 0/1 buffers, which nothing else produces) — animated or multi-state mask art needed it and every game re-derived the same ten lines.
  • atlas(frames_data, w, h, color) — pack w×h buffers into a multi-frame Bitmap.
  • color_frames(w, h, colors) — frame i = solid colors[i].
  • tileset_colors(w, h, colors, gap=0) — tileset: frame 0 empty, frames 1..N coloured; gap=N carves an N-px transparent right+bottom edge into each tile, so touching same-colour tiles read as individual tiles (brick walls show mortar, not stripes).
  • poly_frames(size, points, nframes, color, fill=True) — bake nframes rotations of a polygon.
  • Pool(scene, bitmap, capacity, anchor=None, fixed=False) · .spawn() -> sprite | None (the slot comes back in its baseline look: blit effect, scale/angle, frame and flips as at the first spawn(); .data and position are yours) · .baseline() (re-snapshot after a later reconfiguration) · .free(s) · .free_all() · .count() -> int. (.items = all sprites.)
  • visible means only “draw this” — the pool keeps its own in-use bit (.alive, one byte per slot), so blinking a pooled sprite through .visible is safe: its slot stays taken. spawn() shows the sprite it hands out and free() hides it again, so if not s.visible: continue stays a correct liveness guard. While a sprite is blinked off that guard skips it, so it doesn’t move for those frames — guard on pool.alive[i] instead if that matters.

Collision lives on the Sprite itself: zero-alloc, anchor/scale/rotation aware (no separate module).

  • Sprite.overlaps(other, inset=0) -> bool — inclusive AABB box overlap (touch = hit). other = another Sprite, a point (x, y), or a rect (x1, y1, x2, y2) (trigger zone / screen-cull). inset shrinks THIS sprite’s box by N px per side for a fair hitbox.
  • Sprite.near(other, r) -> bool — circular: this sprite’s centre within r px of other’s centre (squared distance, no sqrt). other = a Sprite or a point (x, y).
  • Raw primitive (any coords, no sprite): pg.collide(x1, y1, x2, y2, ax1, ay1[, ax2, ay2]) — 8 args box-vs-box, 6 args box-vs-point.
  • Tile-grid collision (walls/terrain): probe picogame_tiles flags (at_px(tm, x, y, SOLID)), not AABB.

picogame_math — numeric helpers, vectors & turn-based trig

Section titled “picogame_math — numeric helpers, vectors & turn-based trig”
  • clamp(v, lo, hi) · mid(a, b, c) · lerp(a, b, t) · inv_lerp(a, b, v) · remap(v, a, b, c, d) · sgn(x) · approach(v, target, step) · wrap(v, lo, hi).
  • sin_t(turns) · cos_t(turns) · atan2_t(dy, dx) -> turns — angles as 0..1 turns (standard, not PICO-8’s inverted sin).
  • length(dx, dy) · distance(x1, y1, x2, y2) · normalize(dx, dy) · angle_rad(dx, dy) (radians) · from_angle_rad(a, mag=1.0) — vector helpers.

picogame_tiles — per-tile metadata flags (PICO-8 fget/fset)

Section titled “picogame_tiles — per-tile metadata flags (PICO-8 fget/fset)”
  • Bits/masks: B_SOLID B_HAZARD B_LADDER … (indices) and SOLID HAZARD LADDER … (masks).
  • TileFlags(flags=None, tile_px=8)flags = {tile_index: bitfield} or a list. .get(tile, bit=None) · .set(tile, bit, value=True) · .at(tilemap, tx, ty, bit) · .at_px(tilemap, px, py, bit) (collision one-liner). Keyed by tile index (shared by all cells using it).

picogame_script — story scripts as generators (Director)

Section titled “picogame_script — story scripts as generators (Director)”
  • Director(pg, scene, buttons, font, box=None, nlines=3, fg=0xFFFF, bg=0x0000) — runs ONE story script at a time over a live scene; box = the dialog panel rect (default: a bottom strip sized from picogame_game.screen()).
  • .on(name, genfunc) (register) · .has(name) · .start(script) (a name, a generator, or a zone’s story DATA dict when picogame_story is attached) · .active · .tick() -> bool — call once per frame after buttons.poll(); returns True while a script runs, INCLUDING the finishing step.
  • Waiting primitives (use with yield from inside a script): .text(lines) (dialog page, A advances) · .ask(lines) -> sets .answer (A/B choice) · .wait(frames) · .fade_out(speed) / .fade_in(speed) · .goto(level, at=None) (fade out, set .pending, wait for the game loop to swap the level, fade in).
  • Story flags: .ev(name) / .set(name) (sets AND replays the level’s effects through .on_flag) / .ev_set(name) (plain) / .ev_clear(name), kept in .events (a set — persist it via your save schema). .view = the current View (kept fresh by picogame_story). .retarget(scene) re-points the Director after a map change; .pending = (level, at) a script asked for.

picogame_story — a game.json’s story data through the Director

Section titled “picogame_story — a game.json’s story data through the Director”
  • Story(director, game=None, module=None) — installs itself on the Director; module = your story.py (def name(d) scripts a zone names with {"script": "name"}; a missing def shows a visible stub).
  • .enter(view, x, y) -> bool — call as the player moves: starts the story of the zone just ENTERED (edge-latched, no restart while standing in it). .leave() after a level change.
  • .zone(data) -> generator — what a zone’s data plays: say (a list of lines, or variants {if, lines, set} — first match wins), ask (lines, set, done, yes, no), goto [level, point] with if / denied, script.
  • .effects(view) — replay the level’s effects rules whose if holds (swap [a, b], solid / unsolid tiles, hide / show named sprites); called after every load and, through Director.set, after every flag change.

picogame_seq — generator-driven sequences (coroutine pattern)

Section titled “picogame_seq — generator-driven sequences (coroutine pattern)”
  • wait(frames) · over(frames, fn) (fn(t), t 0..1) · move_over(sprite, x, y, frames) — all are generators; compose with yield from.
  • Seq(gen=None) · .start(gen) · .tick() -> done — advance one step per frame (cutscenes, “do X over N frames”).
  • Script(play, loop=False)scripted input: a game that plays itself. play(s) is a generator pressing Buttons masks over frames (yield from s.tap(B.A) · s.hold(B.RIGHT | B.UP, n) · s.rest(n); tap(..., base=mask) keeps base held throughout). It is a Buttons source: btn.attach(script), then script.tick() each frame before btn.poll() — the demo runs through the game’s own input path (just_pressed/repeat all fire), on device and in the sim alike. Attract mode: attach on an idle title, loop=True to run forever, and hand the controls back on a human press — if btn.state & ~script.mask: btn.detach(script); script.stop(). The same script doubles as a scripted verification run.

picogame_anim — frame animation over time

Section titled “picogame_anim — frame animation over time”
  • FrameAnim(sprite, frames, *, fps=8, loop=True) · .configure(frames, fps=8, loop=True) · .reset() · .tick(dt).
  • AnimatedSprite(sprite, anims) · .play(name) · .tick(dt).
  • Shake(scene, max_offset=6, decay=0.03, seed=0x9E37) · .add(amount) (0.6 small kick, 0.8 hit, 1.0 big; under ~0.5 is sub-pixel at max_offset=6, i.e. invisible) · .tick(cam_x=0, cam_y=0) — trauma screen shake composed on top of the camera. scene=None = offset-only mode for strip-rendered games (road/raycaster/mode-7, which set_view never moves): tick() just updates .ox/.oy and you spend them in your renderer’s params — road.tick(dist, lateral + sh.ox), a jittered horizon.
  • Fade(scene, width, height, x=0, y=0, color=0, cell=8) · .to(target, speed=2.0) · .out()/.into()/.set(level)/.dim(level=8)/.clear()/.pulse(level=12, speed=2.0) · .is_done · .tick() -> done — dither fade / dim / flash, full-screen or a region. Uses StripDraw without a retained pixel surface.
  • Tween(value=0.0, speed=0.2) · .to(target, speed=None) · .set(value) · .tick() -> value · .is_done — ease a scalar (UI/pop-ups).
  • Camera(scene, w, h, lerp=0.18, world_w=0, world_h=0, top=0, bottom=0, left=0, right=0) · .follow(tx, ty, snap=False) · .apply(shake=None) · .offset() -> (ox,oy) · .ox/.oy — smoothed follow + world clamp; cam.apply(shaker) composes with a Shake allocation-free (one set_view); .offset() allocates a tuple per call, so don’t use it per frame. w/h = the screen; a reserved HUD band goes in top/bottom/left/right (the same band as setup()), which centres and clamps inside the visible rect.
  • Sky(scene, x, y, w, h, top, bottom) — vertical gradient with a 2*h-byte colour table. · Scanlines(scene, x, y, w, h, step=2, dark=0) — CRT overlay retaining one w-byte PAL8 row and its palette.
  • InvertFlash(display, frames=3, normal=None) · .pulse(frames=None) · .tick() — hardware-invert hit flash for a supported SPI panel. It does not redraw the scene and is not a framebuffer effect.

picogame_palette — Game-Boy palette tricks on PAL8 art (call sprite.touch() after)

Section titled “picogame_palette — Game-Boy palette tricks on PAL8 art (call sprite.touch() after)”
  • cycle(palette, lo, hi, step=1) — rotate entries (animated water/lava/portals; ~0 extra art).
  • swap(dst_palette, src_palette) — recolour a shared bitmap (GBC-style; cheaper than a 2nd bitmap).
  • fade(palette, base, t, target=0, skip=None) — lerp toward a colour (smooth brightness fade; base = snapshot() of the original).
  • snapshot(palette) / restore(palette, base).
  • Rand(seed=None) (deterministic combined 30-bit Lehmer PRNG; None = time-seeded) · .below(n) · .randint(a, b) · .random() · .chance(p) · .choice(seq) · .shuffle(lst) · .weighted(weights) -> index · .seed(s).
  • Bag(items, rng) · .next() — shuffle-bag (7-bag) anti-streak randomizer.
  • Save(key, schema, *, offset=0)schema = an ordered dict of name -> (struct format char, default); worked example → /helpers/data/. · .defaults() · .load() -> dict · .save(values) · .reset(). Survives reboot/filesystem wipe.

picogame_audioout — one output device for any board

Section titled “picogame_audioout — one output device for any board”
  • make_output(sample_rate=22050, pin=None) — returns this board’s audio output, chosen automatically: an I2S DAC (Fruit Jam TLV320) when the board has I2S_BCLK and no pin was passed, else the analogue path on pin (or the board default) — PWM where the firmware has audiopwmio, the chip’s true DAC where it does not (SAMD51: PyBadge, PyGamer). Used by both picogame_audio and picogame_synth, so a game needs no board-specific audio code. Raises RuntimeError if no output exists.
  • The TLV320’s output select + the three volume trims are set from settings.toml (PICOGAME_AUDIO_OUT, PICOGAME_DAC_VOLUME, PICOGAME_HP_VOLUME, PICOGAME_SPK_VOLUME — see Custom board); the driver’s defaults are deliberately quiet, so raise them toward 0 dB. PICOGAME_DEBUG = 1 prints why a DAC failed to init.

picogame_audio — sample playback (PWM or I2S DAC)

Section titled “picogame_audio — sample playback (PWM or I2S DAC)”
  • Audio(pin=None, voices=4, sample_rate=22050, channels=1, bits=16, signed=True) · .load(path) · .play(sample, *, voice=None, loop=False, volume=1.0) · .sfx(sample, volume=1.0) · .music(sample, loop=True, volume=1.0) · .stop(voice=None) · .stop_music() · .deinit() · .is_playing.
  • tone(frequency=440, ms=120, sample_rate=22050, volume=0.6) — square-wave beep sample.
  • Waveforms: sine() · saw() · triangle() · square() · noise().
  • note(midi, waveform=None, attack=0.005, decay=0.06, sustain=0.0, release=0.08, amplitude=0.6, bend=None, cutoff=None) — build a reusable instrument note (midi 60 = middle C; cutoff = low-pass Hz).
  • pitch_bend(semitones, ms, waveform=None, once=True) — an LFO for a note’s bend (slide / laser zap).
  • Synth(pin=None, sample_rate=22050, buffer_size=2048, music_level=0.4, sfx_level=0.7) · .sfx(n) · .sfx_seq(events, priority=0, window=0) (timed chain — events = [(delay_frames, voice), …], the trigger goes through sfx() arbitration and the tail is read from your list, no per-fire allocation) · .press(n) · .release(n) · .music(midi_track) · .stop_music() · .set_levels(music=None, sfx=None) · .mute(on) · .deinit() — release the output (PWM pin / I2S bus), mixer and synthesizer so a later Synth() / Audio() in the same program can claim them; idempotent, the instance becomes the silent no-op · .available — self-guarding init: on audio-less firmware or a failed init (tight heap, claimed pin) the instance runs as silent no-ops instead of raising; no try/except needed in games.
  • Drone(synth, waveform=None, amplitude=0.35, attack=0.03, release=0.12) · .start() · .set(frequency, amplitude=None) · .stop() — a continuously-held note (engine/siren/drone): press once, then feed set(freq, amp) each frame so synthio tracks the live pitch/amplitude.
  • load_midi(path, sample_rate=22050, waveform=None, envelope=None, tempo=120, ppqn=240) — load a MIDI file into a playable track.

picogame_sfx — signature SFX kit (over picogame_synth)

Section titled “picogame_sfx — signature SFX kit (over picogame_synth)”
  • Kit(synth) — build a ready-made, hardware-tuned SFX set once from a live Synth (silent no-op without audio). Fire by event: .blip() · .coin() · .powerup() · .zap() (your fire) · .pew() (enemy fire) · .jump() · .hit(rotate=True) (brightness rotates on rapid fire) · .hurt() · .boom() · .explosion(). Call .tick() once per frame — drives the coin/powerup arpeggios and the priority + protected-window arbitration through the single SFX voice. Volume via the Synth: .set_levels() / .mute().

picogame_cutscene — full-screen image / story-scene player

Section titled “picogame_cutscene — full-screen image / story-scene player”
  • palette(pg, rgb) — build the wire palette once (from a bake_cutscene.py palette module, RGB triplets, or wire ints).
  • show(pg, display, buffer, path, pal=None, w=320, h=240, scale=None, band=24, bg=0) — stream an image in row bands. The source band uses w*band bytes for PAL8 or w*band*2 for RGB565, in addition to any render buffer passed in. scale=None derives an integer upscale from the display.
  • play(pg, display, buffer, btn, path, pal=None, ..., caption=None, caption_lines=None, auto_hold=0, clock=None) — show it, overlay an optional caption bar, and wait for A/B (or auto-advance after auto_hold ticks).

picogame_stream — stream sprite frames from flash

Section titled “picogame_stream — stream sprite frames from flash”
  • StreamSheet(pg, path, w, h, frames, palette, transparent=None) · .use(i) (select a frame, loaded on demand) · .close() — keep big sheets on flash instead of RAM.

picogame_arena — anti-fragmentation buffer

Section titled “picogame_arena — anti-fragmentation buffer”
  • Arena(pixels) · .alloc(nbytes, align=1) -> memoryview · .canvas(w, h, transparent=None) -> Canvas · .reset() · .mark() -> m / .release(m) (nested LIFO lifetimes: mark on entering a mode, release on leaving — run-level buffers survive) · .free() -> int. Grab one big buffer up front, hand out slices.

picogame_debug — RAM watermarks + FPS overlay (testing aid)

Section titled “picogame_debug — RAM watermarks + FPS overlay (testing aid)”
  • enabled — module flag (default False: calls ship as no-ops; flip True while testing).
  • ram(tag) — gc.collect() + print [RAM] <tag>: free N alloc M at a transition (boot/battle/menu) — the on-device leak/fit diagnostic.
  • Watch(scene, clock=None, every=30, x=2, y=2) · .step() each frame · .hide()/.show() · .remove() — a corner FPS 30 FREE 31k overlay (one live text bitmap, re-rendered only on change). Pass your Clock as clock= for a true FPS reading; every/x/y are keyword args.

picogame_scene — declarative level loader

Section titled “picogame_scene — declarative level loader”
  • Game(pg, src, display=None, strip_h=None, font=None, lazy=False) — the whole game from ONE source: src = "game.json" (streamed one level at a time and baked at boot: RP2040 40–60 ms, ~4.5 kB per level, then the baker is released; lazy=True bakes a level when it is loaded) or the name of a baked bank module ("game_bank" from scene_build.py build, levels imported per load and dropped again). .levels · .start · .size · .name · .bank.
  • Game.load(name=None, at=None) -> View — build a level’s View (default: the start level); at = a point name the player is moved to. Drop every reference to the previous View and gc.collect() before calling it again (the runner templates show the two-phase goto). Strip buffers are allocated once and shared across levels.
  • load(pg, scene, display=None, strip_h=None, font=None, bank=None, bufs=None) -> View — build a scene from a baked SCENE / LEVEL dict. load_bank(pg, bank) — build a shared asset bank once. read_pal8(path) — a .pal8 sidecar’s palette + index bytes.
  • View: .tile_size (the primary tilemap’s (tile_w, tile_h) — what probes and offsets need) · .tile_xy(px, py) · .group(tag) (tagged single sprites included) · .point(name) · .in_zone(x, y, tag=None) · .is_solid(tx, ty) · .tile_has(tx, ty, prop) · .play(sound_id) · .tick(dt) · .set_tile_prop(tile, prop, on=True) · .swap_tiles(a, b) · .effects (story rules) · .world (authored world size) · .name.
  • load_json(pg, path, ...) -> View — the old single-scene entry point; a game.json given here returns its start level through Game.

picogame_scenebake — on-device baker (the one baker; scene_build.py is a CLI over it)

Section titled “picogame_scenebake — on-device baker (the one baker; scene_build.py is a CLI over it)”
  • bake_sounds(sounds, base=None) -> {id: path} (wavs stay wav, loaded at runtime) · bake_bank(assets, sounds=None, base=None) -> BANK · bake_level(level, size, assets) -> LEVEL · bake(scene) -> SCENE (a standalone v1 scene). Colour assets are generated; a PNG asset becomes a pal8f reference to its <name>.pal8 sidecar (no PIL on the device); pal8_inline (playground handoff) passes through.
  • walk(f, on_level=None, stop_after=None, top=None) — read a game.json top level one value at a time (json.load per value, one byte of lookahead seeked back); levels[] stream into on_level(level, index, byte_offset). head(path, want) — just the small keys (what the launcher reads, ~3 ms). level_at(f, offset, size, assets) — one level from a remembered offset.
  • encode_pal8(data, fw, fh, frames, palette, transparent) -> bytes — a .pal8 file (16-byte header, palette, indices; the constants PAL8_MAGIC / PAL8_HDR / PAL8_HDR_LEN are that header, spelled out in Scene format). Costs ~3 kB while imported; Game drops it after boot.

picogame_mode7 — Mode-7 perspective floor

Section titled “picogame_mode7 — Mode-7 perspective floor”
  • Camera(fov=0.66) · .draw(canvas, texture, x, y, angle, horizon, height, y_off=0) — drive the C Canvas.mode7 floor from a friendly camera pose (position in world/tile units, heading in radians, height = how high the camera sits). texture dims must be powers of two, one world unit = one tile. Draw into a 0-RAM StripDraw view. See /helpers/pseudo-3d/.

picogame_road — the OutRun scanline road

Section titled “picogame_road — the OutRun scanline road”
  • Road(pg, width, height, horizon, colors, *, half_width=0.47, hw_min=6.0, depth=600.0, curves=((16384, 90.0), (4096, 30.0)), world_step=6, curve_step=2, hill_amp=0, edge_frac=0.12, dash_frac=0.07, dash_min_hw=7.0, band=20.0, dash_band=14.0) · .tick(dist, lateral_px=0) (once/frame; positive lateral = the car moved right, the road shifts left) · .draw(view, vy) (StripDraw callback body) · .set_grade(g) (hills: −1..+1 moves the horizon; needs hill_amp) · .horizon_now (this frame’s effective horizon = horizon + hill pitch — the y overlays and roadside sprites anchor to) · .curve_at(dist) (signed curvature −1..+1 for centrifugal pull / AI — the same two-sine model the C runs, zero-alloc) · .row_of(z) / .half_of(row) / .edges_of(row) (place and scale sprites ON the road — the rows are linear, so scale by half_of, not F/(F+z)) — drives the native pg.road_edges + Canvas.road pair from human units: curve periods in world units (rounded to the nearest power of two so the int32 phase wrap stays continuous — see the calling contract), swing in px of lateral bend, colors as a dict of six pg.rgb565 values. Builds every fixed-point table once; tick() allocates nothing. See /helpers/pseudo-3d/.
  • IsoView(ox, oy, tw, th) (tw/th = tile half-width/half-height; 2:1 diamond → th = tw//2) · .to_screen(gx, gy, h=0) · .depth(gx, gy, h=0) (back-to-front painter’s key) · .screen_to_grid(sx, sy) · .cube_faces(gx, gy, height_px) (top/right/left faces of a raised block) · .emit_blocks(cells, tv, tc) (alloc-free batch: writes flat-shaded cube triangles for many blocks straight into int16/uint16 buffers for ONE Canvas.fill_triangles call; returns the triangle count) — the cheapest pseudo-3D there is: integer add/shift only, no divide, no C dependency, which is why it runs well on the RP2040. Unlocks iso RPG / strategy / tactics / builder. Draw the blocks as Sprites (one baked Bitmap per block type, added in depth() order) unless the geometry is procedural: measured on a PicoPad with 150 blocks all moving, Sprites in a Scene cost 40 ms/frame against 89 ms for an emit_blocks + Triangles rebuild. Static boards are cheaper still: render once + dirty-rect the movers (30 fps). emit_blocks is the batch builder for scenes whose geometry really is computed per frame (~2× faster than a Python cube_faces loop). See /helpers/pseudo-3d/.
  • Raycaster(world, wall_colors, sky, floor, fov=0.66, stride=2) · .cast(px, py, ang, sw, sh) (once/frame) · .draw(view, vx, vy, vw, vh) (StripDraw callback; row 0 is the top of the VIEW, not the screen — a layer starting below y=0, e.g. under a reserved HUD band, must pass vy - band) · .solid(x, y) (wall test) · .set_cell(x, y, v) (change ONE world cell at runtime — a door opening, a wall dropping; v = wall type 0-9, 0 = empty; keeps the caster grid, solid() and .map consistent and re-casts even for a standing camera. For events, not animation — each call forces one full re-cast) · .attach(sd) (temporal repaint) · .project_sprite(sx, sy) (billboard) — fully native render: the pg.raycast caster (integer 16.16 C on device, Python in the sim) also emits the RLE-merged wall runs, painted with one Canvas.vspans batch per strip into a 0-RAM StripDraw view (~36 fps uncapped at stride 1 full-screen on RP2040, flat across view angles). stride = perf/quality knob; attach(sd) + always_dirty=False repaints only the changed column band (still/slow ~30 fps). See /helpers/pseudo-3d/.