> Scope: API: The picogame API only — the reference cheat sheet, the full engine API, and every helper library. This is what an agent needs to WRITE a game (signatures, no marketing). > Generated 2026-07-31 | docs commit fbd2d56 | picogame-libs 0.1.20 | API_LEVEL 1 # picogame — 2D game engine for PicoPad (CircuitPython) > picogame is a retained-mode 2D game engine built as a CircuitPython C module. It targets the Pajenicko PicoPad (RP2040, 320×240 ST7789) and similar boards. `picogame` is a retained-mode 2D game engine built as a CircuitPython C module. It targets the Pajenicko PicoPad (RP2040, 320×240 ST7789) and similar boards. It is a more complete successor to `_stage`: arbitrary-size sprites, a retained `Scene` with dirty-region rendering, tilemaps, particles, drawing surfaces, and an optional asynchronous DMA display backend. * **Reference target:** the PicoPad firmware and SPI path are tested on hardware. Other targets have individual status notes in [Supported hardware](/supported-hardware/). * **Performance:** on an SPI display, `Scene` transfers up to six changed regions separately. Localized motion can therefore cost less than a full repaint; scattered changes and camera movement can still approach a full-screen update. *** ## Contents [Section titled “Contents”](#contents) 1. [Where this page fits](#where-this-page-fits) 2. [Quick start](#quick-start) 3. [API reference](#api-reference) 4. [Asset pipeline](#asset-pipeline) 5. [Engine costs & constraints](#engine-costs--constraints) 6. [Under the hood](#under-the-hood) 7. [Building the firmware](#building-the-firmware) 8. [Examples](#examples) *** ## Where this page fits [Section titled “Where this page fits”](#where-this-page-fits) This is the **deep guide to the native `picogame` C module**: exact behaviour, contracts and costs of the engine types. It assumes you know what you’re looking for. * New here? [Your first game](/start/first-game/), then [How picogame works](/concepts/how-it-works/). * “Which layer/surface do I use?” → [Drawing paths](/concepts/drawing-paths/); task index in [FEATURES.md](/features/). * Bare signatures of everything → [REFERENCE.md](/reference/). * The pure-Python `picogame_*` helpers (input, timing, audio, UI, pools, save…) have their own guides under *Helpers* — this page covers the C module only. (Helpers keep the `picogame_*` file prefix, **not** a `picogame/` package: that name is the C module and can’t be shadowed.) **Two contracts everything relies on:** colours are always the display’s **wire byte order** — build them with `pg.rgb565(r, g, b)`; a naïve `0xRRGGBB` or host-endian RGB565 renders wrong colours. Coordinates are top-left origin; render-call rectangles are **half-open** (`x0,y0` inclusive to `x1,y1` exclusive), while `collide()` hitboxes are **inclusive** (different domains: pixels vs hitboxes). *** ## Quick start [Section titled “Quick start”](#quick-start) ```python import time, array import board import picogame as pg import picogame_game BG = pg.rgb565(20, 24, 40) scene, _, _ = picogame_game.setup(background=BG) W, H = board.DISPLAY.width, board.DISPLAY.height # read the screen size from the board # Simple 16×16 paletted sprite (index 0 is transparent) pal = array.array("H", [pg.rgb565(0, 0, 0), pg.rgb565(230, 80, 80)]) data = bytearray(16 * 16) for y in range(16): for x in range(16): if 3 <= x < 13 and 3 <= y < 13: data[y * 16 + x] = 1 hero_bmp = pg.Bitmap(data, 16, 16, format=pg.PAL8, palette=pal, transparent=0) hero = pg.Sprite(hero_bmp, 150, 110) scene.add(hero) while True: hero.x = (hero.x + 1) % (W - 16) scene.refresh() time.sleep(1 / 60) ``` *** ## API reference [Section titled “API reference”](#api-reference) ### Module `picogame` [Section titled “Module picogame”](#module-picogame) | Name | Description | | ---------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `RGB565` | format constant (16-bit color, wire order) | | `PAL8` | format constant (8-bit palette index) | | `rgb565(r, g, b) -> int` | build a wire-order RGB565 color from 8-bit components | | `collide(x1, y1, x2, y2, ax1, ay1, ax2, ay2) -> bool` | AABB box↔box overlap; inclusive bounds, so boxes collide when they touch (pass sprite boxes as `(x, y, x+w, y+h)`; fires on contact). `collide` is inclusive, unlike render’s half-open pixel ranges (different domains: hitboxes vs pixels) | | `collide(x1, y1, x2, y2, px, py) -> bool` | box↔point (6 args) | | `render(display, sprites, buffer, x0, y0, x1, y1, *, background=0)` | immediate draw of a sprite list to a compatible display target | | `value2d(x, y, *, seed=0) -> float` | smooth 2-D value noise, 0..1 (fast C) | | `value1d(x, *, seed=0) -> float` | smooth 1-D value noise, 0..1 | | `fbm2d(x, y, *, octaves=4, seed=0, lacunarity=2.0, gain=0.5) -> float` | fractal (fBm) 2-D noise, 0..1 — terrain/clouds/caves | | `fbm1d(x, *, octaves=4, seed=0, lacunarity=2.0, gain=0.5) -> float` | fractal (fBm) 1-D noise, 0..1 | Noise is **fixed-point** (Q16.16) under the hood, fast on the RP2040 (no FPU) and meant for one-shot terrain/cloud gen, not per-frame. There are no separate `_fx` exports; `value2d`/`value1d`/`fbm2d`/`fbm1d` are the canonical functions, called directly on the `picogame` module (`pg.value2d`, `pg.fbm2d`, …); the simulator provides a matching Python implementation. ### Conditional / build-flag API [Section titled “Conditional / build-flag API”](#conditional--build-flag-api) Presence depends on the firmware build - feature-test with `hasattr` / `getattr`: | Name | Present when | Purpose | | ------------------ | ------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------- | | `Display` | `CIRCUITPY_PICOGAME_FAST_DISPLAY` (RP2/ESP ports) | async-DMA render backend; absent on portable ports (pass the plain busdisplay to `Scene` instead) | | `Framebuffer` | `CIRCUITPY_PICOGAME_FRAMEBUFFER` (scanout-buffer platforms, e.g. the WASM playground) | RAM render target instead of a panel | | `RGB444_SUPPORTED` | always (bool) | whether this board’s panel can drive 12-bit RGB444 | | `STRIP_H` | always (int) | the board’s default render-strip height (`picogame_game.setup` uses it) | | `API_LEVEL` | newer firmware (use `getattr(pg, "API_LEVEL", 0)`) | engine API generation, for up-front version checks | ### `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)”](#bitmapdata-width-height--formatrgb565-palettenone-frames1-stride0-transparentnone) An image atlas of one or more equal-size frames, **any** width/height. * `data` — readable buffer: `PAL8` = 1 byte/pixel index; `RGB565` = 2 bytes/pixel (LE wire). * `palette` — for `PAL8`, a buffer of wire-order RGB565 entries (e.g. `array("H", [...])`). * `frames` — animation frames laid out **horizontally**; frame `f` is at column `f*width`. * `stride` — atlas width in pixels (default `width*frames`). * `transparent` — palette index (`PAL8`) or wire color (`RGB565`) to skip; `None` = opaque. * Read-only properties: `width`, `height`, `frames`, `format`, `stride`, `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)”](#spritebitmap-x0-y0--frame0-visibletrue-flip_xfalse-flip_yfalse) A positioned, animatable instance of a `Bitmap`. * Properties: `x`, `y` (integer pixel; setter also accepts a float), `fx`, `fy` (sub-pixel float position), `frame`, `visible`, `flip_x`, `flip_y`, `transpose`, `data`, `bitmap`, `scale`, `angle`, `shadow`, `flash`, `tint`, `dither`. * `move(x, y)` — set position (accepts int or float). * `overlaps(other, inset=0) -> bool` / `near(other, r) -> bool` — native collision tests (anchor/scale/rotation aware, zero-allocation). `overlaps` is an inclusive AABB box test (`other` = a `Sprite`, an `(x, y)` point, or an `(x1, y1, x2, y2)` rect); `inset` shrinks **this** sprite’s box by N px for a fairer hitbox. `near` is a circular test (centres within `r`, no sqrt; `other` = `Sprite` or point). * `scale` — uniform draw scale (float, nearest-neighbour). `1.0` = native (fast 1:1 path); `2.0` = double size; fractional allowed (e.g. a coin pulsing `1.0..1.3`, a powerup growing). Scales about the `anchor`. * `angle` — rotation in **degrees** about the anchor (float). `0` = none (fast path); any other value uses the affine (inverse-mapped) blit. Integer scales stay crisp; rotation shimmers slightly (pixel-art trade-off). `scale` + `angle` compose. * `shadow` — when `True`, the sprite’s opaque pixels **darken** the destination instead of drawing their color (drop shadows: an offset silhouette below the sprite; or a dim/vignette overlay). Combine with `scale`/`angle` freely. * Blit effects `flash`, `tint`, `dither` — cheap per-pixel recolour/translucency, one at a time (setting one clears the others; `0` = off), no extra art or RAM. `flash = WHITE` paints opaque pixels a flat colour (a 1–3-frame hit blink); `tint = RED` multiplies the colour, **keeping** shading (lighting/damage/freeze; it can only darken); `dither = 0..16` is a Bayer stipple (ghost/fog/fade, no alpha; animate the level to fade in/out). * `transpose` — when `True`, swaps the X/Y axes (a diagonal mirror); combined with `flip_x`/`flip_y` it gives all **8** orientations as a crisp fast-path blit (scale 1, angle 0). The footprint swaps width/height. * `bitmap` — read/write the source `Bitmap`. Assigning a new one swaps graphics at runtime and may change size (powerups, resizable HUD bars, text labels); the scene repaints both the old and new bounds on the next `refresh`. * `touch()` — mark the sprite dirty after an **in-place** `bitmap`/palette edit (e.g. a `picogame_palette` recolour), so the change repaints on the next `refresh`. * `anchor` — pivot as `(fx, fy)` fractions of the bitmap size: `(0, 0)` top-left (default), `(0.5, 0.5)` center, `(0.5, 1.0)` bottom-center. `x`/`y` then refer to this point, so growing/shrinking via a `bitmap` swap stays aligned around the pivot. The dirty-rect tracks the resulting top-left. * Use `fx`/`fy` for smooth physics (`ball.fx += 2.4`) instead of a parallel Python float + `int(round())`; `x`/`y` return the floored pixel for tile/collision math. Dirty-rect triggers only when the pixel changes (sub-pixel jitter under 1 px is free). * `data` — an arbitrary user payload (any object) for per-sprite game state, so you don’t need a parallel wrapper class: `hero.data = {"vy": 0, "dead": False}`. ### `Display(busdisplay, *, rgb444=False)` [Section titled “Display(busdisplay, \*, rgb444=False)”](#displaybusdisplay--rgb444false) Fast async-DMA backend wrapping an existing `busdisplay.BusDisplay` (e.g. `board.DISPLAY`). Reuses its SPI bus, pins, window commands and dimensions. * `rgb444=True` drives the panel in 12-bit RGB444 instead of 16-bit RGB565: \~25 % less SPI traffic (3 bytes per 2 pixels) at the cost of colour depth. * `render(sprites, buffer_a, buffer_b, x0, y0, x1, y1, *, background=0)` — draw a sprite list into the region with double-buffered DMA. * `picogame.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 buffer and no redraw. Wrapped by `picogame_fx.InvertFlash`. ### `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)”](#scenedisplay-buffer_a-buffer_b--background0-top0-bottom0-left0-right0) Retained-mode scene with dirty-rectangle rendering. `display` is a `picogame.Display` (the fast backend) **or** a plain `busdisplay.BusDisplay` (the portable path). * `add(item, *, fixed=False) -> item` — add a `Sprite`/`Tilemap`/`Particles`/`Canvas`/`StripDraw` and return it (so `spr = scene.add(Sprite(...))` works). Insertion order is **bottom-to-top**. `fixed=True` (keyword-only) pins the item to the screen (ignores the view offset); use it for HUD / score / dialog that must stay put while the world scrolls via `set_view`. (add tilemap backgrounds first, sprites after, foreground tilemaps last). * `add_all(items)` — add several items at once (same bottom-to-top order). * `refresh() -> [x1, y1, x2, y2] | None` — diff vs. the previous frame and repaint only the changed region; returns the bounding dirty rect as a REUSED list (read it immediately - the next call overwrites it), or `None` if nothing changed. The first refresh repaints the whole screen (covers leftover console pixels). * `invalidate()` — force a full-screen repaint on the next refresh (e.g. on level change). * `set_view(ox, oy)` — view offset = screen position of the scene origin. Set a constant offset to centre a small game (e.g. a 128×128 game on 320×240); update it each frame to scroll a larger world (scrolling repaints the whole screen). Sprites/tilemaps then live in plain scene coordinates regardless of placement. * `view` — read-only `(ox, oy)` current view offset. * `display` — read-only: the render backend this scene draws through (the `pg.Display` wrapper where enabled, else the plain busdisplay). `pg.render()`/`pg.invert()` accept either form, so `pg.render(scene.display, ...)` always works. ### `Tilemap(tileset, cols, rows)` [Section titled “Tilemap(tileset, cols, rows)”](#tilemaptileset-cols-rows) A grid of tile indices into `tileset` (a `Bitmap` whose frames are the tiles). * `tile(tx, ty) -> int` / `tile(tx, ty, value, *, flip_x=False, flip_y=False, transpose=False)` — get / set a tile (set marks dirty); the orientation flags are keyword-only. * `move(x, y)` — move the whole map (pixel position of tile 0,0). * `fill(value)` — set every tile. * Out-of-range `tile()` reads as `0` and ignores writes (no exception). * Read-only properties: `x`, `y`, `cols`, `rows`. ### `Canvas(width, height, *, transparent=None, buffer=None)` [Section titled “Canvas(width, height, \*, transparent=None, buffer=None)”](#canvaswidth-height--transparentnone-buffernone) A RAM drawing surface composited as a Scene layer — the general home for shapes. Add it to a `Scene`; draw into it; only redrawn areas repaint. Colors are wire-order. Pass an existing `buffer` (a `width*height*2`-byte writable buffer) to back the surface with your own RAM instead of letting the Canvas allocate one. * Primitives (all take wire colors): `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)` (bevelled box: light top/left, dark bottom/right), `text(x, y, s, fg, font, bg=None)` (composite font glyphs in C; `bg=None` = transparent, and also works in a `StripDraw` view without a retained text bitmap), `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. * Read-only properties: `x`, `y`, `width`, `height`. * `transparent` (a wire color) lets the surface be a shaped overlay (HUD bar, gauge, vector art) over other layers. Costs `width*height*2` bytes of RAM, so size it to what you need (e.g. a 320×16 status bar = \~10 KB). * **RAM warning:** a full-screen `Canvas(320, 240)` is **150 KB**, too big for the RP2040 (\~190 KB heap, \~130 KB contiguous). Keep Canvases small, or use a `Tilemap` for large scrolling fields. See [the hardware notes](/hardware/). For a *full-frame animated* surface, consider `StripDraw` below; it does not retain a pixel surface. ### `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)”](#stripdrawcallback-x0-y0-width0-height0--always_dirtytrue) An **immediate-mode** draw layer with **no pixel buffer at all**. Added to a `Scene` like any layer, but instead of retaining pixels it calls your `callback` once per render strip that overlaps its rect: ```python def draw(view, vx, vy, vw, vh): # `view` is a Canvas pointing straight at the live strip, clipped to the part that # overlaps the layer's rect; its local (0,0) is screen pixel (vx, vy); (vw, vh) is # that view's size. Draw with normal Canvas primitives -- a full-view fill stays # inside the layer's rect (the callback only fires for strips the rect touches). for ly in range(vh): Y = vy + ly # screen row view.fill_rect(0, ly, vw, 1, sky_or_road(Y)) scene.add(pg.StripDraw(draw, 0, 0, 320, 240)) ``` * **RAM:** the layer retains no `width*height*2` pixel surface. A full-screen pseudo-3D road therefore avoids the **150 KB** pixel buffer required by a full-screen `Canvas`; the `StripDraw` object, callback, and game state still use memory. * With the default `always_dirty=True` its rect is **repainted every frame** (no dirty-rect skip), so use it for *animated* content: pseudo-3D roads, gradient skies, raycasters, plasma, procedural backgrounds, or shapes that change each frame. For *static* art that mostly sits still, a `Canvas` is cheaper CPU (it repaints only when changed); pick by motion, not by size. * `always_dirty=False` makes it an **on-demand** layer: it repaints only when you call `.invalidate()` (otherwise the dirty-rect skips it, like a Canvas), a buffer-less in-scene panel with no retained pixel surface that repaints only on change. This is how `picogame_ui.SceneBox`/`SceneMenu` draw their panels. * **Keep the inner loop light:** the callback issues C primitives, so a handful of `fill_rect`/`hline`-style calls per strip is cheap; avoid heavy per-pixel Python. * Read/write properties `x`, `y`, `width`, `height` move or resize the layer at runtime; after shrinking it, call `scene.invalidate()` so the vacated area repaints. * Drawn in **screen space** (it ignores the camera/view offset). In a **scrolling** scene (one that calls `set_view`) add it **fixed** (`scene.add(sd, fixed=True)`) so its dirty rect matches where it draws; in a static-camera scene it doesn’t matter. Inside the callback, map a screen point to the strip via `(screen_x - vx, screen_y - vy)`. Composites over lower layers and under higher ones, like any layer. See `examples/picogame_stripdraw_example.py`, and `examples/journey_hw/journey_mono.py` (racer road, intro shapes, RPG dialog box). ### `Particles(capacity, *, size=1, gravity=0.0, fade=False)` [Section titled “Particles(capacity, \*, size=1, gravity=0.0, fade=False)”](#particlescapacity--size1-gravity00-fadefalse) A pooled particle layer (many small moving dots) drawn as a single Scene layer, far cheaper than one `Sprite` per particle. Add it to a `Scene`. With `fade=True` each particle dims toward black over its life (sparks/embers/smoke look). * `emit(x, y, count, speed=1, life=30, color=0xFFFF)` — spawn `count` particles at (x, y) with random velocity up to `speed` px/tick, living `life` ticks, in a wire-order color (use `picogame.rgb565`). * `tick()` — advance one step (move, gravity, ageing); call once per frame. * `clear()` — remove all particles. * Positions are sub-pixel (fixed-point); the layer repaints only where particles are (and were), so they leave no trails. v1 draws solid `size`×`size` dots. *** ## Asset pipeline [Section titled “Asset pipeline”](#asset-pipeline) `tools/png2picogame.py` (host-side, needs Pillow) converts PNG/BMP into importable asset modules whose colors are already in wire order. ```bash # A sprite / horizontal animation atlas (auto picks PAL8 or RGB565): python3 tools/png2picogame.py hero.png -o hero.py --frames 6 # A vertical / grid tile sheet -> horizontal atlas Bitmap (16x16 tiles): python3 tools/png2picogame.py tiles.bmp -o tiles.py --tile 16x16 --transparent-index 15 # A tilemap (image palette indices ARE tile indices) -> Tilemap data module: python3 tools/png2picogame.py level.bmp -o level.py --map ``` On the device: ```python import hero, tiles, level spr = pg.Sprite(hero.bitmap(pg), 40, 120) tileset = tiles.bitmap(pg) tm = pg.Tilemap(tileset, level.WIDTH, level.HEIGHT) level.fill(tm) # load the map data ``` Options: `--format auto|pal8|rgb565`, `--frames N`, `--tile WxH`, `--map`, `--transparent-index N` (treat a P-mode palette index as transparent), `--rle` (RLE-compress a single-frame PAL8 background). Size-saving options (PAL8): * `--dither` (+ `--colors N`, default 255): Floyd–Steinberg dither when reducing to PAL8; hides gradient banding (skies, lighting). A low `--colors` (e.g. 16–32) + `--dither` = a retro look. * `--dedup` (with `--tile WxH`) — fold tiles that are identical **up to orientation** (all 8: 4 rotations × mirror) into a smaller tileset → less tileset RAM. Emits a `REMAP` table; rebuild your map with `v, fx, fy, tp = REMAP[old_index]; tm.tile(x, y, v, flip_x=fx, flip_y=fy, transpose=tp)` (it carries the per-tile flip/transpose; the orientation flags are keyword-only). Typical hand-drawn levels are 40–70 % duplicate. Pairs with the Tilemap per-cell orientation. *** ## Engine costs & constraints [Section titled “Engine costs & constraints”](#engine-costs--constraints) > For deployment, read [Run on hardware](/hardware/) (`.mpy`, firmware, and device testing) and [Fit it in RAM](/memory/) (costs and measurement). * **Plan retained surfaces against the measured heap.** A full-screen `Canvas(320,240)` is 150 KB and exceeds the largest contiguous block in the current RP2040 PicoPad build. Keep Canvases small, use a `Tilemap` for big fields and a `StripDraw` for animated full-frame content. Costs and the decision matrix: [Drawing paths](/concepts/drawing-paths/) + [MEMORY.md](/memory/). * **Dirty-region rendering reduces SPI traffic for localized motion.** A full-screen repaint still pays both composition and transfer costs; which dominates depends on the scene, firmware, and SPI clock. * **Up to six dirty regions:** overlapping changes are combined first. If more than six regions remain, `Scene` merges the pair that adds the least extra area until six are left. Localized motion stays cheap; changes scattered across the screen can still approach a full redraw. `refresh()` returns their bounding union for diagnostics, although the renderer processes the regions separately. * **Native types (`Sprite`, `Bitmap`, …) can’t hold custom attributes** - use `sprite.data` for per-entity state. * **PAL8 uses half the pixel storage of RGB565** (1 B/px vs 2). For larger assets, also consider frozen data, ROMFS, or streaming; see [Where assets live](/memory/). *** ## Under the hood [Section titled “Under the hood”](#under-the-hood) How a `refresh()` or `render()` reaches the output: * **SPI targets render in horizontal strips.** The engine reuses one or two small buffers; for each strip it clears the background, composites overlapping layers, and sends the result. * **Framebuffer targets composite into the scanout buffer.** They do not allocate the SPI strip buffers. A large dirty region still means more pixels to composite, but there is no SPI transfer step. * **Fast SPI path (`pg.Display`)**: two strip buffers + asynchronous DMA - the CPU composites the next strip while the previous one is still on the SPI bus. **Portable path** (plain busdisplay): one buffer and blocking `bus.send`. * **Strip height** comes from the buffer size you allocate (`buffer_len / (width*2)`). Smaller strips give finer CPU/transfer overlap on the DMA path; larger strips mean fewer blocking sends on the portable path - which is why the board default `STRIP_H` differs (8 with DMA, 24 without). * **Dirty tracking** diffs each layer against a per-item snapshot (position, frame, scale, angle, effects, a `seq` bumped by `touch()`); Canvas/Tilemap/Particles accumulate their own dirty rects internally and hand them over on refresh. * **Rotation/scaling** is a fixed-point inverse-mapped blit (no floats in the hot path); the per-sprite transform (bbox + inverse-map steps) is cached and recomputed only when angle/scale/bitmap/anchor change. *** ## Building the firmware [Section titled “Building the firmware”](#building-the-firmware) The engine is a native module inside a CircuitPython fork; building it is its own guide - see **[The firmware build](/firmware/)** (toolchain, board configs, flags). Prebuilt firmware for supported boards: [Supported hardware](/supported-hardware/). *** ## Examples [Section titled “Examples”](#examples) In the project root (copy to `CIRCUITPY/code.py`): | File | Shows | | ------------------------------------- | ----------------------------------------------------------------------------------------------------------------------- | | `examples/picogame_demo_code.py` | arbitrary-size sprites, fast Display, FPS/timing breakdown | | `examples/picogame_scene_demo.py` | retained Scene + dirty-rect (static field + movers) | | `examples/picogame_play_demo.py` | D-pad input → Scene (frame-capped movement) | | `examples/picogame_hud_demo.py` | HUD text via the bundled font (`picogame_font.py`) | | `examples/picogame_tilemap_demo.py` | tilemap background + sprite over it | | `examples/picogame_audio_demo.py` | PWM audio: overlapping beeps via the mixer (`picogame_audio.py`) | | `examples/picogame_scroll_demo.py` | camera/scrolling: a 640×480 world with the view following the player (`scene.set_view`) | | `examples/picogame_particles_demo.py` | particle layer: burst (A) + fountain (B) with gravity (`pg.Particles`) | | `demos/picogame_arkanoid.py` | a full Breakout/Arkanoid game: Tilemap bricks + sprites + collide + particles + HUD | | `games/squest/code.py` | a Seaquest-style shooter: pooled sprites via `sprite.data`, projectiles + collide + particles, O2 HUD gauge, tone audio | ### Project layout [Section titled “Project layout”](#project-layout) ```text lib/ engine Python helpers (picogame_*) -> copy needed ones to CIRCUITPY/lib/ examples/ games, demos, per-game assets -> a game becomes code.py at the root tools/ asset converters (png2picogame, ...) ``` Deploying a game to the device (helpers, `.mpy`, assets) is covered by [Run on hardware](/hardware/). # Animation & sequencing > Guide to picogame_anim, picogame_seq and picogame_cutscene: time-based sprite frames, coroutine timelines, and low-RAM cutscene images. These three Python helpers cover sprite animation, timed sequences, and full-screen still images. `picogame_anim` advances animation from elapsed time, `picogame_seq` expresses timelines as generators, and `picogame_cutscene` streams an image without keeping the full frame in the Python heap. See [/reference/](/reference/) for their signatures. ## picogame\_anim [Section titled “picogame\_anim”](#picogame_anim) Give `FrameAnim` a sequence and an `fps`, then call `tick(dt)` once per game frame with the elapsed time in seconds. Sequence entries can be frame indices for a sprite sheet or separate `Bitmap` objects. The animation follows elapsed time instead of loop count. There are two classes: * **`FrameAnim(sprite, frames, *, fps=8, loop=True)`** - plays one sequence. `frames` is a list or tuple of frame indices, or of `Bitmap` objects. The sequence is referenced rather than copied, so treat it as read-only. `fps` sets the animation speed and `loop` is keyword-only. Construction displays `frames[0]` when the sequence is not empty. * **`.tick(dt)`** - advance by `dt` real seconds. Accumulates time, steps the frame when enough has passed. No-op once a non-looping anim is finished or if `frames` is empty. No return value. * **`.configure(frames, fps=8, loop=True)`** - re-point this same instance at a new sequence and reset it. Returns `self`. Lets you reuse one `FrameAnim` instead of allocating a new one on every switch. * **`.reset()`** - back to frame 0, clears the `done` flag and time accumulator. * Attributes you can read: **`.done`** (True when a non-looping anim has reached its last frame), `.i` (current index into `frames`), `.frames`, `.fps`, `.loop`. * **`AnimatedSprite(sprite, anims)`** - a sprite with named states. `anims` is a dictionary `{name: (frames, fps, loop)}`. It keeps one reusable `FrameAnim`, so switching an animation does not allocate another driver. * **`.play(name)`** - switch to that named animation. Looks up `anims[name]` and reconfigures. Calling `play` with the name already playing is a no-op, so it is safe to call every frame. * **`.tick(dt)`** - advance the current animation. ```python import picogame_anim hero = picogame_anim.AnimatedSprite(self.spr, { "run": (DINO_RUN, 12, True), "jump": ((DINO_JUMP,), 1, False), }) hero.play("run") # each frame, with dt = real seconds since last frame: hero.play("jump" if self.jumping else "run") # cheap to call every frame hero.tick(dt) ``` For one sequence, such as a spinning coin, use `FrameAnim` directly: `spin = picogame_anim.FrameAnim(sprite, list(range(COIN_FRAMES)), fps=15)`. ## picogame\_seq [Section titled “picogame\_seq”](#picogame_seq) `picogame_seq` expresses timed logic as generators. Each `yield` pauses until the next game frame, and each `tick()` advances one generator to its next `yield`. Use it for intros, staged AI, and other ordered actions. Compose smaller sequences with `yield from`. Generator helpers (call them with `yield from`): * **`wait(frames)`** - pause for `frames` frames (yields that many times, doing nothing). * **`over(frames, fn)`** - generic tween: calls `fn(t)` each frame with `t` ramping `0..1` (specifically `i/frames` for `i` in `1..frames`) over `frames` frames. * **`move_over(sprite, x, y, frames)`** - glide a sprite from its current position to `(x, y)` over `frames` frames, linearly, via `sprite.move(...)`. The driver: * **`Seq(gen=None)`** - wraps one generator. If `gen` is `None` it starts already `.done`. * **`.start(gen)`** - point it at a (new) generator and clear `done`. Returns `self`, so it is reusable. * **`.tick()`** - advance to the next `yield`. Catches `StopIteration` and sets `.done`. **Returns** the `done` flag (True once finished), so you can branch on it. * **`.done`** - True when the sequence has finished. ```python import picogame_seq as seq def intro(hero, label): yield from seq.wait(30) label.set("GO!") yield from seq.move_over(hero, 120, hero.y, 20) # glide over 20 frames s = seq.Seq(intro(player, hud)) # each frame: if not s.tick(): # advances one step; True once the intro is over ... # still running ``` ## picogame\_cutscene [Section titled “picogame\_cutscene”](#picogame_cutscene) `picogame_cutscene` displays a full-screen still without loading the complete image into the Python heap. A 320x240 source occupies 153,600 bytes in RGB565 or 76,800 bytes in PAL8. The module instead reads a raw, row-major file from flash one **band** at a time and renders each band to the display backend. The temporary source band costs `w * band` bytes for PAL8 or `w * band * 2` bytes for RGB565. With the default `w=320` and `band=24`, that is 7,680 or 15,360 bytes in addition to the render buffer passed to `show()`. Reduce `band` if the allocation does not fit. Once rendered, the still image needs no additional Python object containing the full frame. See [/memory/](/memory/) for the other memory costs. Bake the raw file first with `tools/bake_cutscene.py` (PNG to PAL8 + a palette module, or wire-order RGB565 rows). See [/scene-format/](/scene-format/) for engine bitmap formats and [/hardware/](/hardware/) for the display and buttons. * **`palette(pg, rgb)`** - build the device palette (`array('H')` of wire colours) from a `bake_cutscene.py` palette module, a list of `(r, g, b)` triplets, or wire ints. Build it ONCE at setup and reuse it - `show()` would otherwise rebuild it per call. * **`show(pg, display, buffer, path, pal=None, w=320, h=240, scale=None, band=24, bg=0)`** - stream the image at `path` one band at a time and render each band with the supplied engine strip `buffer`. `pal` selects PAL8 input (1 B/px); `None` selects RGB565 (2 B/px). `scale=None` derives an integer scale from the display (`width // w`) and rejects a source size that does not fill both axes. A short final band is cleared with `bg`. Returns the scale used. * **`play(pg, display, buffer, btn, path, pal=None, w=320, h=240, scale=None, band=24, caption=None, caption_lines=None, auto_hold=0, clock=None, bg=0)`** - show the image, add optional caption lines in a dark bar, then block until **A** or **B** is pressed. With `auto_hold > 0`, it advances after that many loop ticks and `btn` may be `None`. Pass a `picogame_clock` instance as `clock` to pace the wait loop. ```python import picogame_cutscene as cut import board PAL = cut.palette(pg, intro_pal) # once, at setup cut.play(pg, board.DISPLAY, bufA, btn, "intro.raw", pal=PAL, caption="Chapter 1", clock=clock) scene.invalidate() # the image clobbered the LCD ``` # Audio & music > Play WAV samples, synthesize SFX and MIDI with synthio, or use the ready-made picogame_sfx kit. Where audio plays: playground, simulator, device * **Device** — everything (real synthio + PWM/I2S). * **Browser playground** — `tone()` beeps and the `picogame_sfx` kit play via WebAudio (press a key first to unlock sound); custom `picogame_synth` voices don’t (no synthio backend in the browser). * **Desktop simulator, live window** (`--backend pygame`, the default) — `picogame_synth` SFX and music **do** play through `pygame.mixer`: an approximation for tuning by ear, not bit-exact. Headless runs (`--shot` / CI) and `picogame_audio` sample playback stay silent. * Audition synth SFX offline with `tools/synth_preview.py`, and confirm the real mix on hardware. picogame offers four audio layers. `picogame_audioout` picks the board’s output device (PWM or I2S DAC). `picogame_audio` mixes `.wav` files and generated PCM tones. `picogame_synth` creates notes in real time with synthio and avoids a PCM buffer for every effect. `picogame_sfx` adds a ready-made set of named game sounds over the synthesizer. See [/hardware/](/hardware/) for board audio setup and [/reference/](/reference/) for signatures. ## picogame\_audioout — one output for every board [Section titled “picogame\_audioout — one output for every board”](#picogame_audioout--one-output-for-every-board) `picogame_audio` and `picogame_synth` both get their output device from `picogame_audioout.make_output()`, so a game needs **no board-specific audio code**. It auto-selects: * an **I2S DAC** (e.g. the Fruit Jam’s TLV320) when the board exposes `board.I2S_BCLK`, * otherwise a **PWM** output on the board’s speaker pin (or a pin you pass). You normally never call it directly — construct `Audio()` / `Synth()` and it happens for you. `make_output(sample_rate=22050, pin=None)` is there if you want the raw device; passing an explicit `pin` **forces PWM**. **Volume (I2S DAC boards):** the TLV320 driver’s defaults are deliberately very quiet, so on a Fruit Jam sound can seem absent until you raise them in `settings.toml` — `PICOGAME_AUDIO_OUT` (`headphone`/`speaker`/`both`), `PICOGAME_DAC_VOLUME`, `PICOGAME_HP_VOLUME`, `PICOGAME_SPK_VOLUME` (integer dB, keep `<= 0`). See the [settings.toml reference](/custom-board/). Fruit Jam: install the DAC driver or it’s silent I2S audio needs `adafruit_tlv320` **and** `adafruit_bus_device` in `CIRCUITPY/lib` (from the Adafruit bundle / `circup`) — they are **not** bundled with picogame. If they’re missing, audio silently falls back to a PWM pin the DVI board doesn’t have, so there’s no sound. Set `PICOGAME_DEBUG = 1` to print the reason (`[picogame] audioout: I2S DAC driver missing …`) to the serial console. ## picogame\_audio [Section titled “picogame\_audio”](#picogame_audio) A convenience layer over CircuitPython’s audio stack (`audiocore` + `audiomixer`) on top of the `picogame_audioout` output. Reach for it when you have `.wav` assets to play, or want simple beeps without bundling files. It sets up a mixer with several voices so a shot, an explosion, and looping music can all sound at once. The defaults (22050 Hz, mono, 16-bit signed) match typical ugame `.wav` assets - any sample you play must match that format. ### `Audio(pin=None, voices=4, sample_rate=22050, channels=1, bits=16, signed=True)` [Section titled “Audio(pin=None, voices=4, sample\_rate=22050, channels=1, bits=16, signed=True)”](#audiopinnone-voices4-sample_rate22050-channels1-bits16-signedtrue) Constructs the audio output and starts the mixer playing immediately. `pin=None` lets `picogame_audioout` pick the device (I2S DAC or the board’s PWM speaker pin); an explicit `pin` forces PWM on that pin. `voices` is the number of simultaneous channels; voice 0 is reserved for music and voices 1..N-1 are used round-robin for sound effects. The other args define the sample format every clip must match. * `load(path)` - opens a `.wav` file as a reusable `WaveFile` sample. Build it once and keep the returned object alive (it holds the open file); replaying it is cheap. * `play(sample, *, voice=None, loop=False, volume=1.0)` - plays a sample. `voice` is keyword-only; `None` picks the next round-robin sfx voice. `volume` sets that voice’s level (0.0-1.0). Returns the voice index it used. * `sfx(sample, volume=1.0)` - fire-and-forget effect on a free sfx voice (calls `play` with `loop=False`). Returns the voice index. * `music(sample, loop=True, volume=1.0)` - plays on the reserved music voice (voice 0), looping by default. * `stop(voice=None)` - stops one voice, or every voice if `voice` is `None`. * `stop_music()` - stops just the music voice. * `is_playing` (property) - `True` if any voice is currently playing. * `deinit()` - releases the audio output. The output device is a singleton, so call this before constructing a second `Audio()` (or a `Synth()` sharing the pin) - otherwise the next one raises *pin in use*. ### `tone(frequency=440, ms=120, sample_rate=22050, volume=0.6)` [Section titled “tone(frequency=440, ms=120, sample\_rate=22050, volume=0.6)”](#tonefrequency440-ms120-sample_rate22050-volume06) Builds a short square-wave `RawSample` in RAM - a beep with no `.wav` file needed. Good for prototyping and simple blips. Pass it straight to `sfx`/`play`. ```python import picogame_audio import picogame_input audio = picogame_audio.Audio() # PWM audio, 4 voices pew = picogame_audio.tone(880, 90) # high blip, built once boom = picogame_audio.tone(140, 200) # low thud btns = picogame_input.Buttons() while True: btns.poll() if btns.just_pressed(btns.A): audio.sfx(pew) # overlaps on a free voice if btns.just_pressed(btns.B): audio.sfx(boom, volume=0.8) ``` ## picogame\_synth [Section titled “picogame\_synth”](#picogame_synth) `picogame_synth` wraps synthio oscillators, ADSR envelopes, pitch-bend LFOs, and low-pass filters. It avoids storing a PCM buffer for every effect, but still allocates the audio output buffer, mixer, waveforms, and note objects. Use it for a larger SFX set or MIDI music when WAV assets would consume too much RAM or flash. The module is safe to import on firmware without audio. If audio initialisation fails, its API becomes a silent no-op; check `AVAILABLE` or `synth.available` only when the UI needs to expose audio settings. The desktop simulator’s live pygame window plays these `picogame_synth` voices through `pygame.mixer` (an approximation for tuning by ear); headless runs stay silent. Either way the same game path runs unchanged. Built-in waveform constants (one-cycle, signed 16-bit arrays you share across notes): `SINE`, `SAW`, `TRIANGLE`, `SQUARE`, `NOISE`. The functions `sine()`, `saw()`, `triangle()`, `square()`, `noise()` build fresh copies if you need them. For a crisp arcade blip reach for a short `SQUARE` note (`SINE` and `TRIANGLE` read softer and rounder). ### `note(midi, waveform=None, attack=0.005, decay=0.06, sustain=0.0, release=0.08, amplitude=0.6, bend=None, cutoff=None)` [Section titled “note(midi, waveform=None, attack=0.005, decay=0.06, sustain=0.0, release=0.08, amplitude=0.6, bend=None, cutoff=None)”](#notemidi-waveformnone-attack0005-decay006-sustain00-release008-amplitude06-bendnone-cutoffnone) Builds a reusable note/SFX/instrument - the core building block. `midi` is a MIDI note number (60 = middle C, 72 = C5). `waveform` is one of the constants above. `attack`/`decay`/`sustain`/`release` shape the ADSR envelope in seconds (a short decay with `sustain=0.0` gives a percussive blip). `amplitude` is loudness (0.0-1.0). `bend` takes a `pitch_bend` LFO for a pitch sweep (a quick wobble; see `pitch_bend`); `cutoff` adds a low-pass filter at that many Hz to round off harsh tones. Build each note once and replay it. ### `pitch_bend(semitones, ms, waveform=None, once=True)` [Section titled “pitch\_bend(semitones, ms, waveform=None, once=True)”](#pitch_bendsemitones-ms-waveformnone-oncetrue) Returns a `synthio.LFO` for a note’s `bend`. With `once=True` it is a **one-shot sine sweep**: over `ms` the pitch swings toward `semitones` and back - a *wobble/swoop*, not a clean monotonic glide. Keep `ms` short (about the note’s `attack+decay`) so mostly the rise (positive = zap up) or fall (negative = drop) is heard; a long `ms` lets the return swing show and sounds wobbly. ### `Synth(pin=None, sample_rate=22050, buffer_size=2048, music_level=0.4, sfx_level=0.7)` [Section titled “Synth(pin=None, sample\_rate=22050, buffer\_size=2048, music\_level=0.4, sfx\_level=0.7)”](#synthpinnone-sample_rate22050-buffer_size2048-music_level04-sfx_level07) Sets up the output (via `picogame_audioout` — I2S DAC or PWM, same as `Audio`) and a 2-voice mixer: voice 0 for music (a `MidiTrack`), voice 1 for the live synth used by SFX. `pin=None` auto-selects the device; an explicit `pin` forces PWM. `music_level`/`sfx_level` are the starting mix levels for those two voices. * `sfx(n)` - plays note `n` as a one-shot effect. It retriggers the note’s LFOs first (so a repeated zap sounds identical every time), then calls `release_all_then_press`, so back-to-back SFX cut cleanly. * `press(n)` / `release(n)` - hold and release a note manually, for sounds that last as long as a button is held rather than firing once. * `music(midi_track)` - plays a `MidiTrack` (from `load_midi`) on voice 0, looping. * `stop_music()` - stops the music voice. ### `Drone(synth, waveform=None, amplitude=0.35, attack=0.03, release=0.12)` [Section titled “Drone(synth, waveform=None, amplitude=0.35, attack=0.03, release=0.12)”](#dronesynth-waveformnone-amplitude035-attack003-release012) A continuously **held** note for engine, siren, or drone-style sounds - press it once, then steer its pitch/volume live each frame. Where `note()`/`sfx` fire a one-shot, a `Drone` keeps a single note sounding on the SFX voice; synthio reads the note’s live `.frequency`/`.amplitude` per audio buffer, so the tone tracks whatever you feed it (e.g. an engine note driven by car speed). `synth` is a live `Synth`; `waveform` defaults to `SAW`. * `.start()` - press the held note (idempotent - safe to call again while playing). * `.set(frequency, amplitude=None)` - update the live pitch (Hz) every frame, and optionally the volume. * `.stop()` - release the note (e.g. on the title or results screen). ```python eng = snd.Drone(s, waveform=snd.SAW) eng.start() # at race start # each frame, rev = speed / max_speed in 0..1: eng.set(70 + 270 * rev, amplitude=0.2 + 0.5 * rev) eng.stop() # on the title / results screen ``` ### `load_midi(path, sample_rate=22050, waveform=None, envelope=None, tempo=120, ppqn=240)` [Section titled “load\_midi(path, sample\_rate=22050, waveform=None, envelope=None, tempo=120, ppqn=240)”](#load_midipath-sample_rate22050-waveformnone-envelopenone-tempo120-ppqn240) Loads a `.mid` file as a `synthio.MidiTrack` for `Synth.music`. `waveform` and `envelope` select the instrument voice for the track; `tempo` (BPM) and `ppqn` set playback speed. The loader accepts a standard format-0 SMF header. ```python import picogame_synth as snd import picogame_input s = snd.Synth() # Each SFX is a Note built ONCE (envelope + optional bend + filter). blip = snd.note(72, snd.SQUARE, decay=0.10) zap = snd.note(60, snd.SAW, decay=0.18, cutoff=4000, bend=snd.pitch_bend(12, 180)) boom = snd.note(45, snd.NOISE, attack=0.0, decay=0.30, amplitude=0.55, cutoff=2800) btn = picogame_input.Buttons() while True: btn.poll() if btn.just_pressed(btn.A): s.sfx(zap) ``` ## picogame\_sfx [Section titled “picogame\_sfx”](#picogame_sfx) `picogame_sfx` provides one cohesive set of named effects over `picogame_synth`. Use it when the built-in arcade-style sounds fit the game. Build custom `picogame_synth.note()` objects when the kit does not cover an event or the game needs a different audio identity. With no audio, every call is a silent no-op. ### `Kit(synth)` [Section titled “Kit(synth)”](#kitsynth) Build the theme’s voices **once** from a live `picogame_synth.Synth()`, then fire effects by name (and call `tick()` once per frame). Each call maps to a game *event*; the kit arbitrates them by priority through the single SFX voice, so a big scene sound briefly holds off the small feedback blips instead of being cut by them. | Call | Fires on | Preview | | ------------- | ------------------------------------ | ---------------------------- | | `blip()` | UI tick — menu move, select, confirm | [](/audio/sfx_blip.mp3) | | `coin()` | a pickup / gained value | [](/audio/sfx_coin.mp3) | | `powerup()` | level / wave clear, upgrade | [](/audio/sfx_powerup.mp3) | | `zap()` | **your** shot or dash | [](/audio/sfx_zap.mp3) | | `pew()` | an **enemy** fired | [](/audio/sfx_pew.mp3) | | `jump()` | a jump / launch | [](/audio/sfx_jump.mp3) | | `hit()` | a hit landed on an enemy | [](/audio/sfx_hit.mp3) | | `hurt()` | **you** took damage | [](/audio/sfx_hurt.mp3) | | `boom()` | something destroyed / ended | [](/audio/sfx_boom.mp3) | | `explosion()` | a big scene blast | [](/audio/sfx_explosion.mp3) | `hit()` varies its brightness across a rapid burst so repeats don’t grate; pass `rotate=False` for identical hits. Call `tick()` **once per frame** — it plays out the coin/powerup arpeggios and counts down the protected windows. Volume/mute live on the `Synth`: `synth.set_levels(music=None, sfx=None)` sets either mixer level (0.0-1.0), and `synth.mute(on)` silences everything while remembering the levels. Wire these to a `picogame_options` menu + `picogame_save` for a persisted volume control. ```python import picogame_synth as snd import picogame_sfx as sfx import picogame_input s = snd.Synth() kit = sfx.Kit(s) # builds the signature voices once (silent no-op if no audio) btn = picogame_input.Buttons() while True: btn.poll() if btn.just_pressed(btn.A): kit.zap() # your shot if btn.just_pressed(btn.B): kit.jump() # ... on a kill: kit.boom(); on taking damage: kit.hurt(); on a pickup: kit.coin() kit.tick() # once per frame - drives sequences + priority windows ``` # Boot & game loop > Guide to picogame_game, picogame_clock and picogame_input: take over the display, pace the frame, and read the buttons. These three modules set up the display, pace the game loop, and read buttons. A typical `code.py` calls `picogame_game.setup()` once, creates `Buttons()` and `Clock()`, then repeats four steps: poll input, update state, refresh the scene, and tick the clock. See [/reference/](/reference/) for the signatures. ## picogame\_game [Section titled “picogame\_game”](#picogame_game) Call `setup()` once before creating the game objects. It resolves the display backend, stops `displayio` from refreshing independently where applicable, and returns a new `Scene` with the memory it needs. * `setup(display=None, strip_h=None, background=0, fast=True, top=0, bottom=0, left=0, right=0, rgb444=False)` - returns `(scene, buffer_a, buffer_b)`. On an SPI display it disables automatic refresh, clears `root_group`, and allocates two full-width render strips. On a framebuffer target such as Fruit Jam DVI or the browser playground, the scene composites into the framebuffer and both returned buffers are `None`. The `Scene` keeps any allocated strip buffers alive. A game that only calls `scene.refresh()` can ignore the returned buffers: `scene, _, _ = picogame_game.setup(...)`. * `display` - explicit display object. If omitted, setup tries `board.DISPLAY` and then `supervisor.runtime.display`. * `strip_h` - height of each render strip on the SPI path. It defaults to the board’s compiled `picogame.STRIP_H` value. The two buffers occupy `2 * width * strip_h * 2` bytes: about 10 KiB at 320×8 or 30 KiB at 320×24. On the measured RP2040 DMA path, a smaller strip also improved overlap between rendering and transfer; without DMA, larger strips reduce the number of blocking transfers. Override the value per call or at firmware build time with `-DPICOGAME_STRIP_H=N`. Framebuffer targets ignore it. See [/memory/](/memory/) for the trade-off. * `background` - fill colour behind the scene, an RGB565 int. Build one with `pg.rgb565(r, g, b)`. * `top` / `bottom` / `left` / `right` - reserve a border (px) the scene won’t render into, so it paints only the inner play rect. You draw that border yourself (a HUD bar, side panels) once, and it is never recomputed per frame. * `fast` - on an SPI display, `True` selects `pg.Display` when available; `False` uses the portable busdisplay renderer. Setup falls back automatically when the fast backend is absent. * `rgb444` - `True` requests 12-bit colour on a compatible fast SPI backend. Use `"auto"` to enable it only when the board reports support. Framebuffer targets ignore this setting. See [/hardware/](/hardware/). ```python import picogame as pg import picogame_game BG = pg.rgb565(20, 24, 30) scene, buffer_a, buffer_b = picogame_game.setup(background=BG, strip_h=16, top=12) # scene is a pg.Scene; add sprites and refresh it each frame # a simple game that never draws in immediate mode can skip the buffers: scene, _, _ = ... scene.add(sprite) scene.refresh() ``` ## picogame\_clock [Section titled “picogame\_clock”](#picogame_clock) `Clock` caps the loop to a target FPS and returns elapsed time as `dt`, so movement can be independent of frame rate. `FixedStep` runs game logic in equal time steps when physics or collision must be reproducible. `Clock`: * `Clock(fps=30, max_dt=0.1)` - cap the loop to `fps` (use `0` for uncapped) and clamp the returned `dt` to at most `max_dt` seconds, so a pause or stall can’t produce a giant `dt` that teleports everything. * `tick()` - sleeps until the frame boundary, then returns the real `dt` in seconds since the last `tick()`. Anchors to the ideal schedule so a small oversleep can’t accumulate into drift; if you ran over budget it anchors to real time instead, keeping `dt` accurate. Call once per frame. * `tick_async()` - awaitable variant that yields to other `asyncio` tasks during the idle wait instead of blocking. Needs the `asyncio` library available (raises `RuntimeError` otherwise). Note rendering itself is blocking, so async only helps in the cap-sleep gap. * `set_fps(fps)` - change the target FPS on the fly (e.g. menus at 30, action at 60). `0` uncaps. `FixedStep`: * `FixedStep(step_fps=60, max_steps=5)` - fixed timestep of `1/step_fps` seconds, running at most `max_steps` logic steps per frame (the cap avoids a “spiral of death” when rendering can’t keep up - backlog is dropped). * `step_count()` - returns how many fixed steps to run this frame (`0..max_steps`). Loop `for _ in range(step_count())` and use the constant `self.dt`; this form allocates nothing, good for hot loops. * `dt` - the constant step duration in seconds. Pass it to your update. * `steps()` - generator form yielding `self.dt` per step. Convenient, but allocates a generator each call; prefer `step_count()` in the main loop. ```python import picogame_clock clock = picogame_clock.Clock(30) # cap to 30 FPS while True: dt = clock.tick() # sleeps to the frame boundary, returns real dt player.x += player.vx * dt # frame-rate independent movement scene.refresh() ``` ## picogame\_input [Section titled “picogame\_input”](#picogame_input) `Buttons` maps physical buttons to a logical bitmask with pressed and released edges plus auto-repeat. It uses CircuitPython’s background-scanned `keypad` event queue when available and falls back to `digitalio` polling. The `Timer` class provides frame-based windows for coyote time and jump buffering. Logical buttons are exposed both as module constants and as attributes on the instance: `UP`, `DOWN`, `LEFT`, `RIGHT`, `A`, `B`, `X`, `Y`, `L1`, `L2`, `R1`, `R2`, `START`, `SELECT`, plus `ALL`. The PicoPad maps the eight face buttons; absent buttons (no shoulders) simply never fire. They are bit flags, so you can OR them: `btn.A | btn.B`. `Buttons`: * `Buttons(profile=None, pull=None, prefer_keypad=True, debounce_s=0.02, matrix=None, usb=None, sources=None)` - build the reader. With `profile=None` the pin map is resolved highest-wins: an explicit `profile`, then `settings.toml` `PICOGAME_BUTTONS = "UP=GP2 A=GP12 ..."` (remap a custom Pico with no reflash), then a built-in profile by `board.board_id`, then the `PICOPAD` fallback. `pull` defaults to `Pull.UP` (or `PICOGAME_PULL` in `settings.toml`). `debounce_s` is the keypad scan window; `prefer_keypad=False` forces polling. `matrix=` adds a scanned key matrix and `usb=` adds USB HID sources — see below. * **More than one input source:** `Buttons` ORs several sources into one mask — on-board GPIO buttons, a scanned key matrix (`matrix=`, or the `PICOGAME_MATRIX_*` keys), and USB gamepads/keyboards on USB-host boards (`usb=`, auto-attached). A game reads them all with no code change. Full guide: [Input & controls](/helpers/input/). * `poll()` - sample all buttons once and return the current pressed bitmask. Call once per frame, before any query. Drains the keypad event queue (catching sub-frame taps) or reads the pins directly, and updates held-frame counts for `repeat()`. * `is_pressed(mask=ALL)` - `True` if any button in `mask` is currently down (level). * `just_pressed(mask=ALL)` - `True` on the rising edge (the frame a button went down). On the keypad backend this comes from the event queue, so a tap shorter than a frame still registers. * `just_released(mask=ALL)` - `True` on the falling edge (the frame a button came up). * `has(mask=ALL)` - `True` if this board physically wires the given button(s). Use it to adapt controls/UI to boards without shoulders or START/SELECT. * `repeat(button, delay=15, interval=4)` - auto-repeat for a SINGLE button: `True` the frame it’s pressed, then every `interval` frames once held `delay` frames. Ideal for menu and grid movement. * `clear()` - reset state and flush pending input. Call on scene or menu transitions so a held button doesn’t leak across. `Timer`: * `Timer(frames)` - a counter that decays one frame at a time over `frames` frames. * `feed(condition)` - recharge to full when `condition` is true, else count down one frame; returns whether still active. Use for coyote time (`feed(on_ground)`). * `charge()` - force the timer to full. * `is_active` (property) - `True` while the counter is above zero. * `consume()` - `True` once if active, then clears it, so a buffered press fires exactly once (jump buffering). ```python import picogame_input btn = picogame_input.Buttons() # auto profile by board while True: btn.poll() dx = btn.is_pressed(btn.RIGHT) - btn.is_pressed(btn.LEFT) # -1, 0 or +1 if btn.just_pressed(btn.A): # rising edge: fire once per tap jump() scene.refresh() ``` Coyote time and jump buffering, straight from the platformer example: ```python coyote = picogame_input.Timer(5) # still jump a few frames after a ledge jbuf = picogame_input.Timer(6) # honour a jump pressed just before landing # each frame: coyote.feed(on_ground) jbuf.feed(btn.just_pressed(btn.A)) if coyote.is_active and jbuf.consume(): jump() ``` # Building scenes > Guide to the picogame scene-building helpers: load baked scenes, flag tiles, bake placeholder art, and pool sprites. These four modules load baked scenes, attach properties to tiles, create simple bitmap art in code, and reuse a fixed set of sprites. See [/reference/](/reference/) for the signatures. ## picogame\_scene [Section titled “picogame\_scene”](#picogame_scene) The loader turns a baked `SCENE` dictionary into a `pg.Scene` and a set of named handles. Use it for a level produced by the editor or `scene_build.py`. The same data and loader run on hardware and in the simulator. See [/scene-format/](/scene-format/) for the input format. `load(pg, scene, display=None, strip_h=None, font=None, bank=None)` returns a `View`. It resolves the display in the same way as `picogame_game.setup()`, constructs the scene, and adds each tilemap, sprite, group, particle system, and HUD label. On an SPI display it allocates two `width * strip_h * 2` render buffers, available as `view.bufA` and `view.bufB`. On a framebuffer target both are `None`. Pass a font such as `terminalio.FONT` if the data contains a HUD label. See [/hardware/](/hardware/) for display backends and [/memory/](/memory/) for buffer costs. `load_bank(pg, bank)` builds shared bitmaps/sounds/anims ONCE; pass the result as `load(..., bank=...)` for each level so unchanged art is not rebuilt per level. The returned `View` is your handle to everything: * `view.scene` - the live `pg.Scene`. Call `view.scene.refresh()` each frame and `view.scene.set_view(ox, oy)` to scroll. * `view.named[name]` - dict of name -> sprite / particles / HUD label for any layer given a `name`. * `view.group(tag)` - list of sprites for a group layer (returns `[]` if the tag is unknown, so it is safe to iterate). * `view.tick(dt)` - advance all auto-animated sprites; call once per frame with `dt` in seconds. * `view.tile_xy(px, py)` - world pixel -> `(tx, ty)` cell of the primary (first) tilemap. * `view.is_solid(tx, ty)` - shorthand for `tile_has(tx, ty, "solid")`. * `view.tile_has(tx, ty, prop)` - True if the primary tilemap’s tile at that cell has the named property (from the baked `tileprops`). * `view.point(name)` - `(x, y)` for a named point, or None. * `view.in_zone(x, y, tag=None)` - first zone `(tag, x, y, w, h)` containing the point (optionally filtered by tag), else None. * `view.play(sound_id)` - play a baked sfx by id (no-op if audio/sample is missing). * `view.tilemap` / `view.camera` / `view.zones` / `view.points` / `view.anims` - the primary tilemap object, the camera tuple, and the raw collections. ```python import board, terminalio import picogame as pg import picogame_scene as pgs import world1_scene view = pgs.load(pg, world1_scene.SCENE, font=terminalio.FONT) player = view.named["player"] enemies = view.group("enemies") while True: view.tick(1 / 30) # advance auto-animations tx, ty = view.tile_xy(player.x, player.y) if not view.is_solid(tx, ty): player.move(player.x + 2, player.y) view.scene.refresh() ``` ## picogame\_tiles [Section titled “picogame\_tiles”](#picogame_tiles) `TileFlags` stores one metadata bitfield for each tile index, so every cell using that tile shares the same properties. Use it with a hand-built `pg.Tilemap`. A scene loaded through `picogame_scene` already exposes the higher-level `view.is_solid()` and `view.tile_has()` methods. Eight named bits and their masks: `B_SOLID, B_HAZARD, B_LADDER, B_PLATFORM, B_WATER, B_COIN, B_EXIT, B_CUSTOM` are bit indices 0..7; `SOLID, HAZARD, LADDER, PLATFORM, WATER, COIN, EXIT, CUSTOM` are the matching `1 << bit` masks. Use the masks when building the table, the `B_*` indices when querying. `TileFlags(flags=None, tile_px=8)` builds the table. `flags` is either a `{tile_index: bitfield}` dict or a list/bytes indexed by tile index; `tile_px` is the tile size used by the pixel helper. * `tf.get(tile, bit=None)` - the full bitfield of a tile, or one bool flag if `bit` (a `B_*` index) is given. * `tf.set(tile, bit, value=True)` - flag (or clear) a bit on a tile at runtime. * `tf.at(tilemap, tx, ty, bit)` - flag `bit` of the tile at cell `(tx, ty)`. * `tf.at_px(tilemap, px, py, bit)` - flag `bit` of the tile under MAP-LOCAL pixel `(px, py)`; the common collision probe. ```python import picogame as pg import picogame_tiles as tiles TILE = 8 tf = tiles.TileFlags({1: tiles.SOLID, 2: tiles.COIN, 3: tiles.EXIT}, tile_px=TILE) def blocked(level, tx, ty): # level is a pg.Tilemap return tf.at(level, tx, ty, tiles.B_SOLID) if tf.at_px(level, px, py, tiles.B_SOLID): # is the tile under pixel (px, py) solid? stop() ``` ## picogame\_shapes [Section titled “picogame\_shapes”](#picogame_shapes) These functions build single-colour PAL8 `Bitmap` objects in code. They suit prototypes and geometric art such as balls, bricks, or ships. Unlike `Canvas`, they return a reusable bitmap for a `Sprite` or `Tilemap`. Palette index 0 is transparent and index 1 contains the requested colour. * `rect(w, h, color)` - a filled `w x h` rectangle. * `circle(d, color)` - a filled disc of diameter `d`. * `ring(d, color, thickness=2)` - a circle outline of diameter `d`. * `from_mask(mask, color)` - a bitmap from a list of strings; `#`, `X`, or `1` sets a pixel. Sized to the mask. * `atlas(frames_data, w, h, color)` - pack a list of `w*h` 0/1 buffers into one horizontal multi-frame bitmap (one colour). The general “frame sheet” builder. * `color_frames(w, h, colors)` - a multi-frame bitmap where frame `i` is a solid fill of `colors[i]`; frame 0 is already a colour. Index 0 transparent. * `tileset_colors(w, h, colors)` - a tileset where frame 0 is EMPTY (transparent) and frame `i` is a solid fill of `colors[i-1]`. So a Tilemap reads tile value 0 as empty and 1..N as coloured tiles. * `poly_frames(size, points, nframes, color, fill=True)` - bake `nframes` rotations of a polygon (points around centre, +y down) into a `size x size` atlas. The engine also rotates at runtime (`Sprite.angle`); baked frames trade a little RAM for a cheaper per-frame blit and pixel-stable art, so pick them for many always-rotating objects (asteroids), and `angle` for one-off or continuous rotation. Set `fill=False` for an outline. ```python import picogame as pg import picogame_shapes as shp ball = shp.circle(4, pg.rgb565(255, 255, 120)) bricks = shp.tileset_colors(16, 8, [pg.rgb565(220, 70, 70), pg.rgb565(80, 140, 240)]) # value 0 empty, 1..2 coloured ship = shp.poly_frames(16, [(0, -8), (6, 7), (0, 4), (-6, 7)], 16, pg.rgb565(200, 220, 255)) # 16 pre-rotated frames sprite = pg.Sprite(ball, 100, 60) ``` ## picogame\_pool [Section titled “picogame\_pool”](#picogame_pool) `Pool` pre-allocates a fixed number of sprites for short-lived objects such as bullets, enemies, or pickups. `sprite.visible` marks an occupied slot and `sprite.data` can hold entity state. Spawning and freeing slots allocate no new sprites. See [/memory/](/memory/) for why stable allocation matters on the device. `Pool(scene, bitmap, capacity, anchor=None, fixed=False)` pre-allocates `capacity` hidden sprites sharing `bitmap`, sets each `anchor` (if given) and `data = None`, and adds them all to `scene` (`fixed=` passes through to `scene.add`). * `pool.items` - the underlying list of sprites; iterate it directly for zero-alloc updates. * `pool.spawn()` - make the first free (hidden) sprite visible and return it, or None if the pool is full. * `pool.free(s)` - hide sprite `s` (return it to the pool). * `pool.free_all()` - hide every sprite (use on level reset). * `pool.count()` - count of live (visible) sprites; cheap, but iterate `items` for the sprites themselves. ```python import picogame as pg import picogame_pool bullets = picogame_pool.Pool(scene, bullet_bm, 6, anchor=(0.5, 0.5)) b = bullets.spawn() # a now-visible sprite, or None if full if b: b.data = {"vx": 6} b.move(x, y) for s in bullets.items: # zero-alloc iteration if not s.visible: continue s.move(s.x + s.data["vx"], s.y) if off_screen(s): bullets.free(s) ``` # Saving & memory > Persist scores and settings to NVM, dodge heap fragmentation with a pre-allocated arena, and stream big sprite sheets from flash. These helpers persist small values, reserve reusable memory, and stream animation frames from flash. See [/reference/](/reference/) for the signatures and [/memory/](/memory/) for the memory model behind the last two modules. ## picogame\_save [Section titled “picogame\_save”](#picogame_save) This structured key-value store uses `microcontroller.nvm`, a reserved flash region writable from `code.py`. Use it for a high score, unlocked level, or settings that must survive power loss. The region is 4 KiB on the supported RP2040 builds; the class checks the actual available length when it is constructed. NVM is a single region shared by every program on the device, so each game passes its own `key`. The key is hashed into the header and checked on load - if another game or stale data wrote the slot, `load()` returns your defaults instead of misreading foreign bytes. You describe your data as a schema: an ordered dict of `name -> (struct format char, default)`. Common chars: `"B"` 0-255, `"H"` 0-65535, `"I"` 0 to 2^32-1; lowercase `b`/`h`/`i` are signed. * `Save(key, schema, *, offset=0)` - create a store. `key` is your game’s name (str or bytes). `offset` is keyword-only; bump it only if two coexisting games must use different NVM regions. Raises `RuntimeError` if NVM is unavailable, or `ValueError` if the schema does not fit NVM. * `load()` - return a dict of the stored values, or a fresh copy of the defaults if the slot is blank, corrupt, or written by a different game (key mismatch). Never raises on bad data. * `save(values)` - persist a dict. Missing keys fall back to their schema default. Writes a checksum so a later `load()` can detect corruption. * `reset()` - write the defaults back under this game’s key. * `defaults()` - a fresh dict of just the default values, no NVM read. ```python import picogame_save # persist the best lap time (seconds) across reboots store = picogame_save.Save("ghostrace", {"best_t": ("H", 0)}) best_t = store.load()["best_t"] # 0 = no record yet # ...later, on a new best run: if best_t == 0 or secs < best_t: best_t = secs store.save({"best_t": best_t}) # survives power-off ``` ## picogame\_arena [Section titled “picogame\_arena”](#picogame_arena) A buffer arena reserves one contiguous block early and hands out slices of it later. Use it when several scenes or modes need large `Canvas` buffers at different times. MicroPython’s garbage collector does not compact the heap, so repeatedly creating and discarding large buffers can leave enough free memory in total but no sufficiently large contiguous block. * `Arena(pixels)` - allocate the arena. Size is in pixels; it reserves `pixels * 2` bytes (RGB565). Do this early, before the heap fragments. * `canvas(w, h, transparent=None)` - a `pg.Canvas` backed by the next slice (no per-canvas heap alloc); 16-bit aligned automatically. Returns the `Canvas`. * `alloc(nbytes, align=1)` - a generic `memoryview` slice of `nbytes`: reuse it as a file/network read buffer, parse scratch, audio block, etc. `align` rounds the slice start up (use `align=2` for 16-bit data, `align=4` for word access). Raises `MemoryError` if the arena is full. Valid until the next `reset()`. * `mark()` - return the current allocation offset. Keep it when entering a temporary mode or scene. * `release(mark)` - rewind the arena to a previous mark and make every later slice available again. Marks should be released in reverse order. Objects backed by released slices must no longer be used. * `reset()` - free all slices handed out so far. Call at the start of each scene that reuses the arena. Any `Canvas` from before the reset must no longer be drawn. * `free()` - bytes still available in the arena. ```python import picogame_arena # one arena for the big canvases, grabbed once while the heap is contiguous; # scenes that never run at the same time share the bytes (reset each). ARENA = picogame_arena.Arena(320 * 80) # 320x80 px = 51 200 bytes def big_canvas(w, h, transparent=None, first=False): if first: ARENA.reset() # reuse the arena for this scene return ARENA.canvas(w, h, transparent=transparent) ``` ## picogame\_stream [Section titled “picogame\_stream”](#picogame_stream) `StreamSheet` keeps one PAL8 animation frame in RAM and reads the requested frame from a file on flash. A 64x100 sheet with 11 frames therefore needs a 6,400-byte pixel buffer instead of 70,400 bytes for all frame pixels. The file must be frame-major, with each frame’s `w*h` bytes contiguous. Create it with `tools/pack_sheet.py`. * `StreamSheet(pg, path, w, h, frames, palette, transparent=None)` - open `path`, allocate one frame buffer, build a `pg.Bitmap` (PAL8) over it, and load frame 0. `palette` is the color table for the PAL8 data. * `.bitmap` - the single `pg.Bitmap` whose pixels get overwritten in place. Build your `pg.Sprite` from this. * `use(i)` - load frame `i` (wrapped modulo `frames`) into the shared buffer and return the bitmap. Cached: re-reads from flash only when `i` actually changes. * `close()` - close the underlying file. ```python import picogame_stream sheet = picogame_stream.StreamSheet(pg, "jill.bin", 64, 100, 11, PAL, transparent=0) player = pg.Sprite(sheet.bitmap, x, y) # ...each frame the animation advances: sheet.use(frame_index) # stream that frame into the shared buffer player.touch() # tell the scene to repaint it (pixels changed in place) ``` # Effects & feedback > Screen shake, fades, tweens, cameras, raster effects, particles, and PAL8 palette changes. These effects add visual feedback and movement without full-screen surfaces. Their memory costs differ: `Fade` draws through a `StripDraw` callback, `Sky` keeps a colour lookup table, `Scanlines` keeps one bitmap row, and palette helpers modify existing PAL8 palettes. See [/reference/](/reference/) for the signatures. Reaching for feedback on a game event? This table routes you to the right effect; the sections below have the details. | Event | Reach for | | ------------------------- | --------------------------------------------------------------------------------------- | | Small hit / took damage | sprite `flash` (1-3 frames) + a small `Shake` (`add(~0.15)`) | | Enemy killed / big impact | `Particles` burst + a bigger `Shake` (`add(~0.6)`) + [hit-stop](#hit-stop-frame-freeze) | | Pickup / score pop | a `Tween` pop (the blip is on the [audio page](/helpers/audio/)) | | Screen / scene change | `Fade` | | Menu / UI motion | `Tween` | ## picogame\_fx [Section titled “picogame\_fx”](#picogame_fx) Create these helpers for a `Scene` and update the time-based ones once per game frame. `Shake` and `InvertFlash` emphasize an impact, `Fade` handles transitions, `Tween` smooths a changing value, `Camera` follows the world, and `Sky` or `Scanlines` draw raster-style backgrounds and overlays. `import picogame_fx as fx` ### `fx.Shake` - decaying screen shake [Section titled “fx.Shake - decaying screen shake”](#fxshake---decaying-screen-shake) ![Screen shake - a kick that decays](/img/fx_shake.gif) `Shake` stores an intensity called trauma. Calling `add()` raises it; each `tick()` applies a random offset and reduces the stored value. Pass the normal camera offset to `tick()` so both effects use one `scene.set_view()` call. * `Shake(scene, max_offset=6, decay=0.03, seed=0x9E37)` - `max_offset` is the peak pixel offset (about 6 suits 320x240; over 10 hides the action). `decay` is trauma lost per frame (about 0.03 reads as a “kick”, not a “rumble”). * `.add(amount)` - add trauma in the 0..1 range, clamped to 1.0. About 0.6 for a hit or explosion, 0.15 for a small bump. Trauma is squared before use, so small events barely shake and big ones slam. * `.tick(cam_x=0, cam_y=0)` - adds a decaying random offset on top of `(cam_x, cam_y)` and calls `scene.set_view` with the sum. Returns `True` while still shaking. Pass your camera offset here so shake and a moving camera don’t both call `set_view` and stomp each other. ```python import picogame_fx as fx shaker = fx.Shake(scene, max_offset=6) # ...on impact: shaker.add(0.8) # ...every frame (no camera, so feed 0,0): shaker.tick(0, 0) ``` ### Hit-stop (frame freeze) [Section titled “Hit-stop (frame freeze)”](#hit-stop-frame-freeze) Freeze-framing on a big impact is a common juice technique, but there is **no engine primitive** for it - you skip N logic / `scene.refresh()` ticks yourself. Hold a `freeze` counter and, while it is positive, decrement and `continue` the loop (still calling `clock.tick()` so timing stays steady). ```python freeze = 0 # ...on a big impact: freeze = 4 # ...at the top of the game loop: if freeze > 0: freeze -= 1 clock.tick() continue ``` ### `fx.Fade` - dither screen fade / dim / flash [Section titled “fx.Fade - dither screen fade / dim / flash”](#fxfade---dither-screen-fade--dim--flash) ![A dither fade to black and back](/img/fx_fade.gif) A `StripDraw` overlay stipples a colour over a rectangle with ordered Bayer dithering. It keeps no pixel surface. At level 0 its drawing rectangle collapses to 0×0, so it adds no region to repaint. * `Fade(scene, width, height, x=0, y=0, color=0, cell=8)` - covers the rect `(x, y, width, height)`; defaults cover the whole screen. A sub-rect dims just that area (a panel behind a dialog, a sidebar). `color=0` is black; `cell` is the dither block size. Added to the scene `fixed=True` so it ignores the camera. * `.set(level)` - jump instantly to `level` (0 = clear .. 16 = solid). Use 16 to start opaque before a fade-in. * `.to(target, speed=2.0)` - head toward `target` at `speed` levels per frame. Returns `self`. * `.out(speed=2.0)` / `.into(speed=2.0)` - shortcuts for `to(16)` (to opaque) and `to(0)` (to clear). * `.dim(level=8)` - jump to a partial hold, e.g. a 50% dim behind a menu. `.clear()` jumps to 0. * `.pulse(level=12, speed=2.0)` - ramp up to `level` then automatically back to 0; the smooth full-screen flash. Keep `level` under 16 so it stays a see-through dither, never a solid wall. * `.tick()` - step `level` toward `target` by `speed`. Returns `True` when the target is reached. * `.is_done` (property) - `True` when `level == target`. ```python import picogame_fx as fx fader = fx.Fade(scene, W, H) # ...trigger a fade to black: fader.out(speed=2) # ...each frame, fade back in once we hit black: if fading and fader.tick(): fader.into(speed=2) ``` ### `fx.Tween` - ease a scalar toward a target [Section titled “fx.Tween - ease a scalar toward a target”](#fxtween---ease-a-scalar-toward-a-target) ![A value easing toward its target](/img/fx_tween.gif) A per-frame exponential ease-out for a single value: UI slides, pop-up scales, a number that should “catch up” smoothly. No keyframes or schedule. * `Tween(value=0.0, speed=0.2)` - `speed` is the fraction of the remaining gap closed each frame (0..1). * `.to(target, speed=None)` - set a new target (and optionally a new speed). Returns `self`. * `.set(value)` - snap value and target to `value` immediately. * `.tick()` - move value a `speed` fraction toward target and return the new value. Snaps exactly when within 0.01. * `.is_done` (property) - `True` once value equals target. ```python import picogame_fx as fx y = fx.Tween(0) y.to(100) # slide a panel down to y=100 # ...each frame: panel.y = int(y.tick()) ``` ### `fx.Camera` - smoothed follow camera [Section titled “fx.Camera - smoothed follow camera”](#fxcamera---smoothed-follow-camera) ![The camera following across a wider world](/img/fx_camera.gif) Tracks a world point and produces the scene view offset, centred and optionally clamped to a world size. Use it when the level is bigger than the screen. * `Camera(scene, w, h, lerp=0.18, world_w=0, world_h=0)` - `w`/`h` are the screen size; `lerp` is the follow smoothing per frame; `world_w`/`world_h` (if non-zero) clamp so the view never shows past the world edge. * `.follow(tx, ty, snap=False)` - move the camera centre toward `(tx, ty)` by `lerp`, or jump there with `snap=True`. Returns `self`, so you can chain. * `.apply()` - compute the offset and call `scene.set_view` directly. Allocation-free; returns `None`. Use when there is no shake. * `.offset()` - compute and return the offset as an `(ox, oy)` tuple (allocates). Feed this into `Shake.tick(ox, oy)` to compose the two. ```python import picogame_fx as fx cam = fx.Camera(scene, W, 240, world_w=bounds_w) # ...each frame: cam.follow(player.x, 120).apply() ``` ### `fx.Sky` - vertical gradient background [Section titled “fx.Sky - vertical gradient background”](#fxsky---vertical-gradient-background) A per-scanline gradient drawn through `StripDraw`. It retains a lookup table of `h` wire-order colours, or `2 * h` bytes, and redraws the requested rows when its region is repainted. Add it before the gameplay layers. * `Sky(scene, x, y, w, h, top, bottom)` - fills the rect, lerping each scanline from the `top` wire-RGB565 colour to `bottom`. Added `fixed=True`. Change `.top`/`.bottom` over time for a day-night cycle. ```python import picogame as pg import picogame_fx as fx sky = fx.Sky(scene, 0, 0, W, HORIZON, pg.rgb565(60, 120, 240), pg.rgb565(200, 230, 255)) ``` ### `fx.Scanlines` - CRT scanline overlay [Section titled “fx.Scanlines - CRT scanline overlay”](#fxscanlines---crt-scanline-overlay) Darkens every Nth row for a CRT or LCD-grid look. It retains one PAL8 row of `w` bytes plus a two-entry palette, then blits that row through `StripDraw`. Add it after gameplay layers so it remains visible. * `Scanlines(scene, x, y, w, h, step=2, dark=pg.rgb565(0, 0, 0))` - `step=2` darkens every other line; `dark` is the overlay colour. Precomputes a 1px dither row and blits it once per darkened line (one blit instead of a per-pixel loop). ```python import picogame_fx as fx scanlines = fx.Scanlines(scene, 0, 0, W, H) # add LAST, on top of everything ``` ### `fx.InvertFlash` - controller inversion flash [Section titled “fx.InvertFlash - controller inversion flash”](#fxinvertflash---controller-inversion-flash) ![Full-screen colour-inversion flash (sim-emulated)](/img/fx_invertflash.gif) Flips a compatible SPI panel to its negative for a few frames using controller colour inversion (`pg.invert`). It does not repaint the scene or allocate a pixel buffer. Use it with ST7789/ST7735-class controllers; it is not available on framebuffer outputs such as Fruit Jam DVI. The simulator emulates the effect. * `InvertFlash(display, frames=3, normal=None)` - `display` is e.g. `board.DISPLAY`; `frames` is the flash length. `normal` is the panel’s resting invert state. The PicoPad sends INVON in its init, so its resting state is `normal=None` (the default); pass `normal=False` only for a panel whose init does not invert. * `.pulse(frames=None)` - flip away from the resting state now (optionally for a custom frame count). * `.tick()` - count down and restore the resting state when the flash ends. Returns `True` while flashing. Call it after `scene.refresh()` so the INVON/INVOFF is the frame’s last bus op. ```python import board import picogame_fx as fx flash = fx.InvertFlash(board.DISPLAY, frames=6) # ...on hit: flash.pulse() # ...after scene.refresh(): flash.tick() ``` **Seizure safety:** for any full-screen flasher (`InvertFlash`, `Fade.pulse`, rapid sprite flash), avoid sustained flashing above \~3 Hz (at least 10 frames apart at 30 fps); keep full-screen inverts to 1-3 frames, one-shot. ## picogame.Particles [Section titled “picogame.Particles”](#picogameparticles) ![A particle burst expanding and fading](/img/fx_particles.gif) `pg.Particles` is a core engine layer for sparks, explosions, pickup bursts, and dust. It pre-allocates a fixed-capacity pool and does not create new particle objects during `emit()` or `tick()`. * `pg.Particles(capacity, *, size=1, gravity=0.0, fade=False)` - a pool of up to `capacity` dots, each `size` px. `gravity` pulls them down per tick (0 = free drift); `fade=True` dims a dot as it ages. `size`/`gravity`/`fade` are keyword-only (pass them by name). * `.emit(x, y, count, speed=1, life=30, color=0xFFFF)` - burst `count` dots from `(x, y)` with random velocity up to `speed` px/tick, each living `life` ticks. * `.tick()` - age and move the live dots; call once per frame. ```python import picogame as pg ps = pg.Particles(180, size=2, gravity=0.0, fade=True) scene.add(ps) # add once, like any layer # ...on a hit / kill / pickup: ps.emit(x, y, 16, 4, 24, pg.rgb565(255, 210, 120)) # 16 sparks, speed 4, 24-tick life # ...every frame: ps.tick() ``` ## picogame\_palette [Section titled “picogame\_palette”](#picogame_palette) ![Palette cycling - a reserved colour band flows](/img/fx_palette.gif) PAL8 art can change colour without duplicating its pixel indices. `cycle` rotates a range of palette entries, `swap` copies another palette, and `fade` interpolates entries toward a target. Palette entries are wire-order RGB565 integers returned by `pg.rgb565()`. `import picogame_palette as palette` * `snapshot(palette)` - return an `array('H')` copy of a palette. Save the original once, before any fading or cycling, so you can fade relative to it or `restore` it. * `restore(palette, base)` - copy `base` back into `palette` in place. * `cycle(palette, lo, hi, step=1)` - rotate entries `[lo..hi]` inclusive by `step` (wraps), in place with no allocation, so it is safe to call every frame. Reserve a run of indices for flowing colours, paint your art with them, and they animate. * `swap(dst_palette, src_palette)` - copy one palette over another (up to the shorter length). GBC-style recolour: keep one PAL8 bitmap and hand it a different palette per variant. Cheaper than a second bitmap. * `fade(palette, base, t, target=0, skip=None)` - lerp every entry of `palette` from the saved `base` toward the `target` wire colour by `t` (0.0 = base .. 1.0 = target). `target=0` (black) fades out; a white target fades to white. `skip` leaves one index untouched (e.g. a transparent index). ```python import picogame_palette as palette # ...each frame, flow a reserved band of water colours: palette.cycle(water_bmp.palette, 1, 6) water.touch() # tell the renderer the palette changed ``` One blit slot on device A sprite’s `flash`, `tint`, `dither`, and `shadow` effects share **one** blit slot on hardware — setting one clears the others (last-set-wins). The desktop simulator does **not** reproduce this; it shows them as independent. Combine them by sequencing frame-by-frame or using a separate effect layer; always confirm on hardware. Recolour a sprite **brighter / to a new hue** — what `sprite.tint` can’t do (multiply only darkens). Keep one PAL8 bitmap and give it a warm palette (e.g. a green enemy → a hot amber “elite”): ```python import array, picogame_palette as palette WARM = array.array("H", [0, pg.rgb565(150, 40, 30), pg.rgb565(255, 130, 45), pg.rgb565(120, 45, 25), pg.rgb565(255, 215, 130), pg.rgb565(235, 90, 45), pg.rgb565(255, 245, 190)]) # one entry per index palette.swap(enemy.bitmap.palette, WARM) # copy WARM into the bitmap's palette, in place enemy.touch() # the renderer won't notice a palette change on its own # Per-variant instead of in-place? Build a 2nd `pg.Bitmap(DATA, ..., palette=WARM)` and assign it to # the sprite's `.bitmap` — see examples/picogame_picowing.py, which recolours the Kenney enemy this way. ``` # Input & controls > Read the D-pad and buttons with picogame_input, and add USB gamepad or keyboard control on USB-host boards — with zero game-code changes. `picogame_input.Buttons` is the one input object every game uses. It reads the board’s physical buttons into a **bitmask** with edge detection, so game code never re-does the wiring: ```python import picogame_input as pi btn = pi.Buttons() # each frame: btn.poll() if btn.is_pressed(pi.LEFT): px -= 2 if btn.just_pressed(pi.A): jump() if btn.repeat(pi.DOWN): menu_move(+1) # PICO-8 btnp auto-repeat, good for menus ``` ## One virtual controller, mapped to real hardware [Section titled “One virtual controller, mapped to real hardware”](#one-virtual-controller-mapped-to-real-hardware) The engine gives every game the **same virtual controller** — a fixed set of logical buttons — and you **map your board’s real hardware onto it**. Games always code against the logical names (`pi.A`, `pi.LEFT`), never against pins, so the *same `code.py`* runs on a PicoPad, a breadboard Pico, a keypad matrix, or a USB gamepad; only the mapping changes. The logical set: * **Baseline (every game can rely on these):** the 4-way D-pad — `UP` `DOWN` `LEFT` `RIGHT` — plus `A` and `B`. * **Optional extras (map them if your hardware has them):** `X` `Y`, shoulders `L1` `L2` `R1` `R2`, and `START` `SELECT`. A board maps whatever subset it physically has; absent buttons simply never fire. Query availability with `btn.has(pi.L1)` so a game can hide a control the board lacks. The physical→logical mapping lives in `settings.toml` (below) — one file, no reflash — resolved per source: `PICOGAME_BUTTONS` for GPIO, `PICOGAME_MATRIX_*` for a key matrix, `PICOGAME_USBPAD` / `PICOGAME_USBKBD` for USB. `Buttons` methods: `poll() -> mask`, `is_pressed(mask)`, `just_pressed(mask)`, `just_released(mask)`, `has(mask)`, `repeat(button, delay=15, interval=4)`, `clear()`. For input-leniency windows (coyote time, jump buffering) use `pi.Timer(frames)` with `.feed(cond)` / `.is_active` / `.consume()`. ## Input sources are OR’d together [Section titled “Input sources are OR’d together”](#input-sources-are-ord-together) ```python Buttons(profile=None, pull=None, prefer_keypad=True, debounce_s=0.02, matrix=None, usb=None, sources=None) ``` A `Buttons` object can read from **several sources at once** and ORs them into one mask, so a game reads them all identically: * **On-board GPIO buttons** — the default; the pin map is a *profile* resolved from `settings.toml` (`PICOGAME_BUTTONS`) or a board default. * **A scanned key matrix** (`matrix=`, or the `PICOGAME_MATRIX_*` settings keys) — for keypad-style boards. * **USB HID gamepad / keyboard** (`usb=`, auto-attached on USB-host builds) — see below. Because everything ORs, **you don’t branch on the source**: the same `btn.just_pressed(pi.A)` fires whether A came from a GPIO button, a matrix cell, a USB pad, or a keyboard key. ## Map your hardware in `settings.toml` [Section titled “Map your hardware in settings.toml”](#map-your-hardware-in-settingstoml) Each source has its own mapping key. Edit `settings.toml` on the `CIRCUITPY` drive and reset — no firmware rebuild. The [settings.toml reference](/custom-board/) lists every key and its exact format; the common cases: **Direct GPIO buttons** — `NAME=GPpin` tokens. Map only what you have: ```toml # a Pico wired D-pad + A/B, plus optional X/Y PICOGAME_BUTTONS = "UP=GP2 DOWN=GP3 LEFT=GP4 RIGHT=GP5 A=GP6 B=GP7 X=GP8 Y=GP9" PICOGAME_PULL = "up" # buttons to GND, pressed reads low (the default) ``` **A scanned key matrix** — give the row/column pins, then map matrix cells to logical buttons by `NAME=row,col`. picogame scans the grid and de-bounces it via the `keypad` module: ```toml PICOGAME_MATRIX_ROWS = "GP0 GP1 GP2 GP3" PICOGAME_MATRIX_COLS = "GP4 GP5 GP6 GP7" PICOGAME_MATRIX_MAP = "UP=0,1 DOWN=2,1 LEFT=1,0 RIGHT=1,2 A=3,3 B=3,2 START=0,0 SELECT=0,3" # PICOGAME_MATRIX_ANODES = "cols" # flip to "rows" if the diode direction is reversed ``` **USB gamepad / keyboard** — `PICOGAME_USBPAD` / `PICOGAME_USBKBD` (see the USB sections below). A partial map merges over the source’s defaults, so you only name the buttons you’re changing. Unmapped logical buttons stay inactive (`btn.has(...)` reports them absent). ## USB gamepad (USB-host boards, e.g. Fruit Jam) [Section titled “USB gamepad (USB-host boards, e.g. Fruit Jam)”](#usb-gamepad-usb-host-boards-eg-fruit-jam) On a USB-host CircuitPython build, `Buttons()` **auto-attaches** a plugged-in USB HID gamepad — so a pad works with **zero game changes**. On boards without USB host (PicoPad, …) the driver is simply never loaded (no RAM cost). * Default layout = the ubiquitous DragonRise `081f:e401` SNES-style pad. * Remap any pad from `settings.toml` (no reflash): `PICOGAME_USBPAD = "A=5:0x20 B=5:0x40 X=5:0x10 Y=5:0x80 START=6:0x20 SELECT=6:0x10"` (`NAME=report-byte:bitmask`; a partial list merges over the defaults). Discover a new pad’s report bytes with `tools/usbpad_probe.py`, or run the interactive `tools/usbpad_calibrate.py` — it prompts you to press each button and prints the ready-to-paste `PICOGAME_USBPAD` / `PICOGAME_USBPAD_ID` line. * Turn it off with `PICOGAME_USB = 0`; pin a specific device with `PICOGAME_USBPAD_ID = "vid:pid"`. The driver is `picogame_usbpad.UsbPad`; you rarely touch it directly — `Buttons` attaches it for you. ## USB keyboard (USB-host boards) [Section titled “USB keyboard (USB-host boards)”](#usb-keyboard-usb-host-boards) The keyboard twin of the gamepad — also auto-attached, also OR’d in. Works with wired keyboards and 2.4 GHz-dongle wireless ones (not Bluetooth — CircuitPython has no BT host stack). * Default layout: **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 = "A=0x2C B=0x1B START=0x28"` (`NAME=HID-keycode`, hex or decimal; merges over the defaults). * Disable the keyboard only (keep the pad) with `PICOGAME_KBD = 0`. * Some combo dongles present a boot-keyboard interface that stays silent while keystrokes flow on a sibling interface. Point the driver at the live channel: `PICOGAME_USBKBD_EP = "2:0x83"` (`interface:IN-endpoint`). Find the value by running `tools/usbkbd_probe.py` as `code.py` — it prints the exact line. The driver is `picogame_usbkbd.UsbKbd`. ## Local multiplayer [Section titled “Local multiplayer”](#local-multiplayer) By default a `Buttons` merges every source (above). To give each player their own controller, build **one `Buttons` per player** and bind each to its own device with `sources=`: ```python import picogame_input as pi pads = pi.find_pads() # every connected USB gamepad, in bus order p1 = pi.Buttons(sources=pads[0:1]) # player 1 = first pad p2 = pi.Buttons(sources=pads[1:2]) # player 2 = second pad # or mix devices — pi.Buttons(usb=False) is a player on the on-board buttons. ``` Each player is independent: poll and read them separately (`p1.just_pressed(pi.A)` / `p2.just_pressed(pi.A)`). `find_pads()` returns `[]` on a board with no USB host. Two identical pads come back in enumeration order — if players want them the other way round, they swap controllers (the engine tracks no per-pad identity). See the two-player pattern above. See the [full `settings.toml` reference](/custom-board/) for every input key and its format. # Math, random & collision > Numeric helpers, reproducible random sequences, and the collision tests built into Sprite. This page covers numeric helpers, a seedable random generator, and the collision methods on `Sprite`. Neither helper module requires engine setup. See [/reference/](/reference/) for the signatures. ## picogame\_math [Section titled “picogame\_math”](#picogame_math) `picogame_math` provides `clamp`, `lerp`, `approach`, `wrap`, 2D vector functions, and trigonometry expressed in turns. Functions ending in `_t` use the interval `[0,1)` for one full rotation, which is convenient for stored headings and aiming. The module also keeps the vector helpers formerly provided by `picogame_vec`. API: * `clamp(v, lo, hi)` - returns `v` pinned into `[lo, hi]`. The everyday helper for keeping a position on screen or HP in range. * `mid(a, b, c)` - median of three; behaves like a clamp when the middle arg is the value. * `lerp(a, b, t)` - linear blend `a + (b-a)*t`. With a small `t` each frame it gives a smooth follow/ease. * `inv_lerp(a, b, v)` - inverse of `lerp`: where `v` sits in `[a,b]` as `0..1`. Returns `0.0` if `a == b`. * `remap(v, a, b, c, d)` - map `v` from range `[a,b]` onto `[c,d]`. Safe when `a == b` (maps to `c`). * `sgn(x)` - sign as `-1`, `0`, or `1`. * `approach(v, target, step)` - move `v` toward `target` by at most `step`, never overshooting. Great for friction/acceleration toward a value. * `wrap(v, lo, hi)` - wrap `v` into the half-open range `[lo, hi)`. Degenerate or inverted ranges return `lo` (no division by zero, never out of range). * `sin_t(turns)` / `cos_t(turns)` - sine/cosine of an angle in TURNS (`1.0` = full circle). Positive is clockwise on a y-down screen. * `atan2_t(dy, dx)` - angle of vector `(dx, dy)` in turns, normalized to `0..1`. Use for aiming. * `length(dx, dy)` - magnitude of a vector. * `distance(x1, y1, x2, y2)` - distance between two points. * `normalize(dx, dy)` - unit vector; returns `(0.0, 0.0)` for a zero-length input. * `angle_rad(dx, dy)` - angle in RADIANS (raw `atan2`); `from_angle_rad(a, mag=1.0)` - vector of length `mag` at radian angle `a`. * `TAU` - the constant `2*pi`, used internally by the `_t` trig. Example (rotate a ship and thrust along its nose, as in asteroids): ```python import picogame_math as m ang = 0.0 # heading, in turns 0..1 TURN = 0.01 ang = (ang + TURN) % 1.0 # rotate right dx, dy = m.sin_t(ang), -m.cos_t(ang) # nose-up direction vx += dx * 0.25 vy += dy * 0.25 sp = m.length(vx, vy) # current speed x = m.clamp(x, 8, W - 8) # keep on screen ``` ## picogame\_rand [Section titled “picogame\_rand”](#picogame_rand) This seedable xorshift32 generator supports weighted choices, in-place shuffling, and a shuffle bag. Use a fixed seed for reproducible replays, ghost data, tests, or level layouts. `Rand()` without an argument seeds itself from the clock. `Bag` emits each item once per shuffled cycle, which prevents long streaks caused by independent choices. `Rand(seed=None)`: * `Rand(1234)` seeds from an int (reproducible); `Rand()` seeds from the clock. `seed=0` is remapped internally (xorshift cannot start at zero). * `seed(s)` - reseed an existing generator. * `below(n)` - integer in `0 .. n-1`. Returns `0` if `n <= 0`. * `randint(a, b)` - integer in `a .. b` inclusive. Raises `ValueError` if `b < a`. * `random()` - float in `[0.0, 1.0)`. * `chance(p)` - `True` with probability `p` (where `p` is `0..1`). * `choice(seq)` - one element of `seq`. Raises `ValueError` on an empty sequence. * `shuffle(lst)` - Fisher-Yates shuffle of `lst`, in place (returns `None`). * `weighted(weights)` - return an index `0..len-1` picked proportionally to `weights`. Raises `ValueError` if the total is `<= 0`. No streak control (independent draws). `Bag(items, rng)`: * A shuffle-bag / “7-bag”: yields every item once per cycle in shuffled order, so no long streaks or droughts. Fairer than independent picks for spawns and pieces. * `next()` - return the next item, reshuffling automatically at the start of each cycle. Raises `ValueError` at construction if `items` is empty. Example (one seeded RNG for the whole game, plus an anti-streak spawn bag): ```python import picogame_rand rng = picogame_rand.Rand(0x1234) # seeded -> reproducible x = rng.randint(40, W - 40) # 40 .. W-40 inclusive if rng.chance(0.25): spawn_powerup(x) kind = rng.weighted([5, 3, 1]) # index 0 most likely bag = picogame_rand.Bag([0, 1, 2, 3, 4, 5, 6], rng) piece = bag.next() # every value once per cycle ``` ## Sprite collision [Section titled “Sprite collision”](#sprite-collision) Every `Sprite` has box and radius collision methods. They use the drawn bounds after anchor, scale, and rotation without allocating a result object. Use `overlaps()` for sprite or rectangular intersections and `near()` for a circular distance check. API (methods on any `Sprite`): * `a.overlaps(b, inset=0)` - inclusive AABB box overlap (they collide the moment they touch). `b` may be another `Sprite`, a point `(x, y)`, or a rect `(x1, y1, x2, y2)` - e.g. a trigger zone, or `(0, 0, W, H)` to test whether the sprite is still on screen. `inset` shrinks THIS sprite’s box by N px on each side, for a hitbox smaller than the art. Returns a bool. * `a.near(b, r)` - circular test: is this sprite’s centre within `r` px of `b`’s centre? Uses squared distance, so no `sqrt`. `b` may be a `Sprite` or a point `(x, y)`. Boxes and centres come from the sprite’s drawn rectangle, so both methods are anchor/scale/rotation aware - correct for any anchor (unlike a hand-rolled `x`-based test). See [/scene-format/](/scene-format/) for anchors. For arbitrary boxes with no sprite (two computed regions), drop to the raw primitive `pg.collide(x1, y1, x2, y2, ax1, ay1[, ax2, ay2])`. For tile-grid walls/terrain, probe `picogame_tiles` flags rather than overlapping every tile. Example (bullets vs rocks circular, player vs enemy boxed - from asteroids and a platformer): ```python for b in bullets: for r in rocks: if b.visible and b.near(r, 18): # circular, no sqrt kill(b, r) if player.overlaps(enemy): # box overlap, anchor-correct take_damage() if not ship.overlaps((0, 0, W, H)): # off-screen? despawn it pool.free(ship) ``` # Pseudo-3D (Mode-7 & raycasting) > Fake-3D ground planes and wall corridors drawn into a buffer-less StripDraw view - a perspective floor via the C Canvas.mode7 primitive, and a Python DDA raycaster. Two ways to get a 3D-looking world out of a 2D engine, both rendered into a **buffer-less `StripDraw` view** so they cost **zero retained RAM**. `picogame_mode7` gives you a receding **ground plane** (a racer track, a flying-carpet floor); `picogame_ray` gives you **walls and corridors** (a dungeon, a maze). See [/reference/](/reference/) for the signatures. | You want | Reach for | How it draws | | -------------------------------------------- | ----------------------------------------------------- | ------------------------------------ | | A ground / floor that recedes to the horizon | `picogame_mode7.Camera` (drives the C `Canvas.mode7`) | C primitive, per-scanline - fast | | Walls / a first-person corridor | `picogame_ray.Raycaster` | native DDA caster + temporal repaint | Both draw into the below-horizon rows and leave the sky to you; you fill the rows above the horizon yourself (a flat colour, a gradient, or [`fx.Sky`](/helpers/effects/)). ## picogame\_mode7 [Section titled “picogame\_mode7”](#picogame_mode7) ![A Mode-7 racer - the road recedes, rumble stripes rush toward you](/img/mode7.gif) A **Mode-7 perspective floor**: each screen row below the horizon samples one distance into a texture, so a flat top-down image reads as a ground plane stretching to the horizon. All the maths lives in this Python helper; the per-scanline fill is the C engine primitive `Canvas.mode7`, so it is cheap. `import picogame_mode7 as m7` ### `m7.Camera` - perspective ground plane [Section titled “m7.Camera - perspective ground plane”](#m7camera---perspective-ground-plane) * `Camera(fov=0.66)` - holds the field of view (higher = wider, more fish-eye). Reuse one camera; pass the pose to `draw()` each frame. * `.draw(canvas, texture, x, y, angle, horizon, height, y_off=0)` - fill `canvas` (a `Canvas` **or** a `StripDraw` view) below `horizon` with a receding view of `texture`. `x`/`y` are the camera position in **world (tile) units**, `angle` is the heading in radians, `horizon` is the screen row of the horizon line, and `height` is how high the camera sits (bigger = the ground recedes slower, so you see further). In a `StripDraw` callback pass `y_off=vy` so the perspective divide uses the absolute screen row. `texture` must have **power-of-2 width and height**, and **one world unit = one full texture tile**. A texture that tiles seamlessly (grass, a road with rumble stripes) can use a large `height` and wrap forever; a single non-tiling image (one closed circuit) needs a small `height` or the far distance repeats it. ```python import picogame as pg import picogame_mode7 as m7 cam = m7.Camera(fov=0.9) def ground(view, vx, vy, vw, vh): for r in range(vh): # sky: fill rows above the horizon yourself if vy + r < HORIZON: view.fill_rect(0, r, vw, 1, SKY) cam.draw(view, TRACK, car.x, car.y, car.heading, HORIZON, 5.0, y_off=vy) scene.add(pg.StripDraw(ground, 0, 0, W, H)) # 0 retained bytes # ...each frame: move car.x/y/heading, then scene.refresh() ``` ## picogame\_ray [Section titled “picogame\_ray”](#picogame_ray) ![A first-person raycaster - a dungeon corridor with depth-shaded walls](/img/raycaster.gif) A **Wolfenstein-style raycaster**: one DDA ray per screen column finds the nearest wall, and each column is drawn as a distance-shaded vertical bar. The per-column DDA is the native engine caster `pg.raycast` (integer 16.16 C on device, a Python version in the sim); the lib does the trig, paint and dirty-band bookkeeping, and the walls are solid `fill_rect` columns into a `StripDraw` view. `import picogame_ray` ### `picogame_ray.Raycaster` - first-person wall caster [Section titled “picogame\_ray.Raycaster - first-person wall caster”](#picogame_rayraycaster---first-person-wall-caster) * `Raycaster(world, wall_colors, sky, floor, fov=0.66, stride=2)` - `world` is a list of equal-length strings (`'0'` = empty, `'1'`..`'9'` = wall types); `wall_colors` maps each wall type to a `(face_colour, side_colour)` pair (the side colour a touch darker = a free depth cue); `sky`/`floor` are wire-RGB565 backgrounds. `stride` casts one ray per N columns - the **perf/quality knob** (see below). * `.cast(px, py, ang, sw, sh)` - cast the rays for camera position `(px, py)` and heading `ang`, caching each column’s wall span. Call once per frame **before** `scene.refresh()`. * `.draw(view, vx, vy, vw, vh)` - the `StripDraw` callback: paints the sky/floor bands and the wall columns. Adjacent identical columns are merged into one `fill_rect`. * `.solid(x, y)` - is the map cell at integer `(x, y)` a wall? Use it for movement collision (out of bounds counts as a wall). * `.attach(stripdraw)` - opt into **temporal rendering**: pass the `StripDraw` (created `always_dirty=False`) that draws this raycaster, and `cast()` invalidates only the column band that changed since the previous frame (a still camera repaints nothing). Big win when standing / moving slowly. * `.zbuf` - after `cast()`, each column’s wall distance (16.16 fixed-point; the depth buffer `project_sprite` tests against). * `.project_sprite(sx, sy, margin=0.2)` - project a world-space point `(sx, sy)` onto the screen for the last `cast()`. Returns `(screen_x, size, depth)`, or `None` if the point is behind the camera or hidden by a nearer wall. `size` is the on-screen height in px at the wall scale - set `sprite.scale = size / bitmap_height`; sort your sprites by `depth` far-to-near so nearer ones draw on top. `margin` is z-test slack so a sprite flush against a wall is not culled. ```python import picogame as pg import picogame_ray MAP = ["1111111111", "1000000001", "1011100201", "1000000001", "1111111111"] WALLS = {1: (pg.rgb565(150, 150, 160), pg.rgb565(95, 95, 110)), 2: (pg.rgb565(170, 90, 60), pg.rgb565(110, 55, 35))} rc = picogame_ray.Raycaster(MAP, WALLS, pg.rgb565(30, 30, 48), pg.rgb565(40, 34, 30), stride=2) scene.add(pg.StripDraw(rc.draw, 0, 0, W, H)) # ...each frame: rc.cast(px, py, ang, W, H) # cast, then paint scene.refresh() # ...to move without walking through walls: if not rc.solid(int(nx), int(py)): px = nx ``` ### Enemies & pickups - billboard sprites [Section titled “Enemies & pickups - billboard sprites”](#enemies--pickups---billboard-sprites) The raycaster draws only walls; enemies and pickups are ordinary `Sprite`s scaled and placed each frame from `project_sprite`, depth-tested against the walls so they hide behind corners. Add the sprites to the Scene **after** the `StripDraw` so they layer on top, and reuse a [`picogame_pool.Pool`](/helpers/math/) rather than creating them per frame. ```python GUYS = [Enemy(3.5, 2.5), Enemy(6.5, 3.5)] # world (tile) positions for g in GUYS: g.spr = pg.Sprite(DEMON_BMP, -40, -40) # DEMON_BMP is 8 px tall g.spr.anchor = (0.5, 0.5) scene.add(g.spr) # AFTER the StripDraw -> on top of walls def frame(): rc.cast(px, py, ang, W, H) # cast walls (fills rc.zbuf) GUYS.sort(key=lambda g: -((g.x - px) ** 2 + (g.y - py) ** 2)) # far-to-near for g in GUYS: p = rc.project_sprite(g.x, g.y) if p: sx, size, _ = p g.spr.move(sx, HORIZON) # centre on the horizon row g.spr.scale = size / 8.0 # bitmap is 8 px tall g.spr.visible = True else: g.spr.visible = False # off-screen or behind a wall scene.refresh() ``` # Text & UI > Bitmap text, HUD labels, dialog boxes, cursor menus, and editable options. These modules render bitmap text and provide HUD, dialog, menu, grid-cursor, and options controls. See the [reference](/reference/) for signatures and the tutorials for complete examples. ## picogame\_font [Section titled “picogame\_font”](#picogame_font) This module renders a `fontio` font, commonly `terminalio.FONT`, into a PAL8 `picogame.Bitmap`. Use it for text that needs to become a sprite or an immediate label. If you just want a score in a corner, `Label` is the simplest choice; the fuller widget picture (scene layers vs. immediate widgets) is explained below under [picogame\_ui](#picogame_ui). * `render_text(pg, font, text, fg, bg=None)` - composes `text` into a `PAL8` bitmap and returns the tuple `(bmp, w, h)` (bitmap plus pixel size). `fg`/`bg` are wire colours from `pg.rgb565(...)`. `bg=None` leaves the background transparent (palette index 0); an opaque `bg` means a redraw fully overwrites the old text, so HUD updates need no separate clear. * `render_text_pal(pg, font, text, fg, bg=None)` - same as above but returns `(bmp, w, h, palette)`. Keep the `palette` (an `array('H')`) and mutate `palette[1]` (the fg colour) for a live colour shimmer without rebuilding the bitmap - the C `Bitmap` reads the same buffer. * `Label(pg, font, x, y, fg, bg)` - a positioned label drawn immediately (good for a HUD over a screen you render yourself). * `.set(text)` - re-renders only if the text changed; returns `True` if it did, `False` if skipped. Coerces non-strings via `str()`. * `.move(x, y)` - repositions and forces a re-render at the new spot on the next `set`/`draw`. * `.draw(display, buffer)` - repaints just the label’s rectangle via `pg.render` (a single present). * `.w`, `.h` - pixel size of the last rendered text. ```python import picogame_font, terminalio hud = picogame_font.Label(pg, terminalio.FONT, 4, 4, pg.rgb565(255, 255, 255), BG) shown_score = -1 # shadow int: last value the label shows # each frame, after you draw the screen: if score != shown_score: # only format when the number changes shown_score = score hud.set("SCORE %06d" % score) hud.draw(board.DISPLAY, bufA) # repaints just its rect ``` ![picogame\_font.Label — immediate HUD text chips](/img/ui_label.png) ### Extra glyphs: `ExtraFont` [Section titled “Extra glyphs: ExtraFont”](#extra-glyphs-extrafont) `terminalio.FONT` is ASCII only. `picogame_font.ExtraFont` extends it with glyphs from one or more small BDF files, looked up as **fallbacks** — the built-in font first, then each BDF in order, so extra files only ever *add* glyphs and blend seamlessly with normal text. Two subsets ship in `lib/fonts/` (cut from CircuitPython’s own Terminus build, the same one `terminalio.FONT` comes from): * **`picogame_cz.bdf`** — Czech diacritics (á č ď é ě í ň ó ř š ť ú ů ý ž and capitals). * **`picogame_symbols.bdf`** — game symbols: arrows, hearts, block/shade fills, triangles, ✓/✗, ♥ ♫ ☼, ° ½ × ÷ and more: ![The picogame\_symbols.bdf game-glyph set, with each glyph’s Unicode code point](/img/extrafont_symbols.png) ```python import picogame_font font = picogame_font.ExtraFont("/lib/fonts/picogame_cz.bdf", "/lib/fonts/picogame_symbols.bdf") bmp, w, h = picogame_font.render_text(pg, font, "Život 3 ♥♥♥ →", fg) ``` Pass the `ExtraFont` anywhere this module takes a font (`render_text`, `render_text_pal`, `Label`, and the `picogame_ui` widgets built on them). Glyphs load eagerly (\~20 B each; a 30-glyph set is under 1 KB). **Limitation:** `ExtraFont` is a *Python-side* font for this module’s render paths only. The **native C text path** (`picogame.Canvas.text`, and therefore `picogame_ui.SceneLabel` / `HudBar` and a `StripDraw` `view.text`) validates a `fontio.BuiltinFont` in firmware and will **not** accept an `ExtraFont` — use the `render_text`/`Label` path when you need the extra glyphs. To make your own subset, `tools/make_bdf_subset.py`. ## picogame\_bitfont [Section titled “picogame\_bitfont”](#picogame_bitfont) This 8×8 bitmap font uses four shades and includes arrows, hearts, a star, a note, and box-drawing symbols in codes 0–31; codes 32 and above cover ASCII. Its outline can keep transparent text legible over the game world. * `render_text(pg, text, fg=None, outline=None, mid=None, bg=None)` - renders `text` to a `PAL8` bitmap and returns `(bitmap, w, h)`. The four shades map to: `0 -> bg`/transparent, `1 -> outline`, `2 -> mid`, `3 -> fg`. Colour defaults: `fg` white, `outline` black, `mid` mid-grey; all are `rgb565` wire colours. Supports `\n` for multi-line. Pass `bg` for an opaque background (else index 0 is transparent). * Symbol constants (1-char strings you concatenate into text): `ARROW_U`, `ARROW_D`, `ARROW_R`, `ARROW_L`, `BOXX`, `STAR`, `HEART`, `BALL`, `NOTE`. * `GLYPH_W`, `GLYPH_H` - both `8`, the per-glyph cell size. ```python import picogame_bitfont as bf bmp, w, h = bf.render_text(pg, "LIVES " + bf.HEART * 3) # white, outlined, transparent spr = pg.Sprite(bmp, x, y) # place anywhere spr.scale = 2 # scale up for big text ``` ![picogame\_bitfont — outlined transparent text over the world](/img/ui_bitfont.png) ## picogame\_ui [Section titled “picogame\_ui”](#picogame_ui) Choose widgets by who owns the affected pixels: * `SceneLabel`, `SceneBox`, and `SceneMenu` are fixed scene layers. `scene.refresh()` repaints them when needed, so use them inside a live or scrolling scene. * `picogame_font.Label`, `TextBox`, and `Menu` draw immediately through `pg.render()`. Use them on a screen whose redraw you control. `HudBar` is also immediate, but belongs in a border reserved outside the scene. Pick by what owns the pixels: | Situation | Class | | ----------------------------------------------------------------------- | ------------ | | Static screen you redraw yourself (title, game-over, a HUD you repaint) | `Label` | | A live, scrolling scene where the HUD must **not** scroll | `SceneLabel` | | A reserved edge bar / status strip | `HudBar` | | A transient dialog / message box over the live world | `SceneBox` | | A dialog / battle / menu box on a static screen | `TextBox` | `tick()`-based widgets return: a chosen index/cell on **A** (confirm), `ui.CANCEL` (`-2`) on **B** (back), or `None` while navigating. See [scene-format](/scene-format/) for the `fixed` layer and [hardware](/hardware/) for the buttons. **`SceneLabel(scene, pg, font, x, y, fg, bg)`** - one line of text pinned over a scrolling world. * `.set(text)` - re-renders only on change (swaps the sprite’s bitmap; dirty-rect handles old/new bounds). A blank/empty string hides the sprite, leaving no leftover bg patch. * `.reserve(chars)` - reserve the label’s text buffer **now**, on the fresh startup heap, for up to `chars` characters, so a long line first shown *later* (e.g. a game-over banner) isn’t allocated on a fragmented heap (a `MemoryError`). Renders nothing visible. See [memory](/memory/). **`SceneBox(scene, pg, font, x, y, w, h, fg, bg, nlines=3, key=None, border=None)`** - a multi-line dialog or status panel over a live scene. Its `StripDraw` callback composites the panel, border, and text without retaining a pixel surface. Pass `border` for a raised frame. * `.show(lines)` - fill the panel and set text, then reveal. **Call once**, not per frame. * `.hide()` - make the panel fully transparent and blank the rows. * `.set_line(i, text)` - update one row in place (no Canvas/border redraw). ![picogame\_ui.SceneBox — a bordered dialog box over a live scene](/img/ui_dialog.png) **`HudBar(pg, display, buffer, x, y, w, h, bg)`** - an immediate HUD drawn in a border reserved with `Scene(..., top=/bottom=)`. Call `draw()` only after its contents change. It stores label strings and icon references but no panel-sized pixel surface. `buffer` is the render buffer from setup on SPI targets and may be `None` on framebuffer targets. * `.add(sprite)` - store an icon sprite (hearts, gauges) in the bar; returns it. It’s blitted at its own x/y on `draw()`. * `.label(font, x, y, fg, text=" ")` - add a text field; returns a `_HudLabel` **handle** (not a sprite) which you update with `handle.set(text)` (the same `.set` verb as `SceneLabel`). The text is composited directly, no per-label sprite. * `.draw()` - repaint the bar (flat bg + icons + text) and push it in one `pg.render`. Takes no arguments - the bar stores the display/buffer/geometry at construction. Call only on HUD changes. ```python hud = ui.HudBar(pg, board.DISPLAY, bufA, 0, 0, W, BAR, pg.rgb565(10, 12, 24)) hud_l = hud.label(terminalio.FONT, 4, 3, INK, "SCORE 0 LIVES 3") hud.draw() # later, only when it changes: hud_l.set("SCORE %d LIVES %d" % (score, lives)) hud.draw() ``` ![picogame\_ui.HudBar — a reserved-band status bar over gameplay](/img/ui_hudbar.png) **`TextBox(pg, font, x, y, w, h, fg, bg, maxlines=6)`** - a screen-space multi-line box (filled rect + text rows) for static dialog/battle/menu screens. * `.draw(display, buffer, lines, force=False)` - skips the repaint when `lines` are unchanged; when it does draw, the bg and every row go out in **one** `pg.render` (no blank-fill flash). Pass `force=True` after the screen under it was wiped (e.g. a full-screen `pg.render`). * `.draw_line(display, buffer, i, text)` - repaint a single row in place, atomically. **`Menu(pg, font, x, y, items, fg, bg, *, title=None, rows=None, width=None, paged=True)`** - an immediate cursor menu built on `TextBox`. UP and DOWN use auto-repeat. `rows=None` shows all items; a smaller value creates a scrolling window. With `paged=True`, crossing an edge moves by a page instead of one row. Arguments after `*` are keyword-only. * `.tick(btn)` - returns the chosen index on A, `ui.CANCEL` on B, else `None`. * `.draw(display, buffer, force=False)` - repaints only what changed (nothing / the 2 affected rows on a cursor move / the whole box on scroll). `force=True` repaints unconditionally after a wipe. ```python bmenu = ui.Menu(pg, terminalio.FONT, 8, H - 72, ["ATTACK", "MAGIC", "HEAL", "FLEE"], WHITE, NAVY) # each frame: act = bmenu.tick(btn) # index on A, ui.CANCEL on B, else None bmenu.draw(board.DISPLAY, bufA) ``` **`SceneMenu(scene, pg, font, x, y, items, fg, bg, title=None, rows=None, width=None, border=None, paged=True)`** - the same menu but built on `SceneBox`, for use **over a live scene** (battle actions, an in-game popup). Same navigation/paging as `Menu`. * `.show(sel=0)` - reveal it (resets the cursor). The scene paints it from then on - no `draw()` call. * `.hide()` - hide it. * `.tick(btn)` - same return contract as `Menu`; repaints only the rows that changed. ![picogame\_ui.SceneMenu — a cursor menu over a live scene](/img/ui_menu.png) **`GridCursor(cols, rows, tx=0, ty=0, wrap=False)`** - logic-only 2D cursor for a battlefield, tile inventory, or match-3. It owns movement (D-pad auto-repeat) and confirm/cancel; *you* draw the grid and a highlight at `(cursor.tx, cursor.ty)`. `wrap=True` wraps at edges, else it clamps. * `.tick(btn)` - returns the `(tx, ty)` tuple on A, `ui.CANCEL` on B, else `None`. * `.tx`, `.ty` - current cell. * `.index` (property) - `ty * cols + tx`, handy for indexing a flat list. ```python cur = ui.GridCursor(N, N) # N x N board # each frame: pick = cur.tick(btn) # (tx, ty) on A, ui.CANCEL on B, else None # you draw the highlight yourself at (cur.tx, cur.ty) ``` ## picogame\_options [Section titled “picogame\_options”](#picogame_options) `OptionsMenu` adds editable rows to a `SceneBox`. Use it for settings, shops, or selection screens that combine choices, numeric steps, toggles, and actions. It is a scene-layer widget, so `scene.refresh()` displays value changes. * `OptionsMenu(scene, pg, font, x, y, w, rows, fg, bg, title=None, border=None)` - `rows` is a list of dicts, each with a `kind`: * `choice` - `{"key", "label", "kind": "choice", "choices": [...]}`; cycles through the list. (A non-empty `choices` is required - it raises `ValueError` up front otherwise.) * `stepper` - `{"key", "label", "kind": "stepper", "value", "min", "max"}`; also honours an optional `"step"` (default 1). Clamps to `min`/`max`. * `toggle` - `{"key", "label", "kind": "toggle", "value": True/False}`. * `action` - `{"key", "label", "kind": "action"}`; no value, just returns its key on A. * `.show(sel=0)` - reveal and render (call once); `scene.refresh()` paints it after. * `.hide()` - hide the panel. * `.tick(btn)` - UP/DOWN move the cursor; LEFT/RIGHT change the selected row’s value live (steppers/choices auto-repeat while held; a toggle flips only on a fresh press, so it can’t oscillate). Returns the selected row’s `key` on **A**, `ui.CANCEL` on **B**, else `None`. * `.value(key)` - read a row’s current value any time: a `choice` returns the chosen string, a `stepper` an int, a `toggle` a bool; `None` if no such key. ```python import picogame_options as opt menu = opt.OptionsMenu(scene, pg, font, 40, 40, 240, [ {"key": "diff", "label": "Difficulty", "kind": "choice", "choices": ["Easy", "Normal", "Hard"]}, {"key": "vol", "label": "Volume", "kind": "stepper", "value": 7, "min": 0, "max": 10}, {"key": "snd", "label": "Sound", "kind": "toggle", "value": True}, {"key": "done", "label": "Start", "kind": "action"}, ], WHITE, NAVY, title="OPTIONS") menu.show() while True: btn.poll() k = menu.tick(btn) if k == "done": diff = menu.value("diff") # read live values on the action row elif k == opt.CANCEL: menu.hide() scene.refresh() # paints the menu - no draw() call ``` ![picogame\_options.OptionsMenu — editable settings rows](/img/ui_options.png) # 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/. 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](/engine/). **See also:** [Fit it in RAM](/memory/) · [Drawing paths](/concepts/drawing-paths/) · [Performance](/performance/) · [Run on hardware](/hardware/) · [Coming from another engine](/concepts/coming-from/). *** ## Native module: `picogame` (`import picogame as pg`) [Section titled “Native module: picogame (import picogame as pg)”](#native-module-picogame-import-picogame-as-pg) ### Constants & colour [Section titled “Constants & colour”](#constants--colour) * `RGB565`, `PAL8` — bitmap pixel formats. * `API_LEVEL` — `int`; 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_SUPPORTED` — `bool`; whether this board’s panel can drive 12-bit RGB444 (lets one game opt into `Display(rgb444=True)` only where it works). * `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)”](#bitmapdata-width-height--formatrgb565-palettenone-frames1-stride0-transparentnone) 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`](/concepts/glossary/) (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)”](#spritebitmap-x0-y0--frame0-visibletrue-flip_xfalse-flip_yfalse) 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). * 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; setting one clears the others; 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). * `dither` — `0` (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). ### `Display(busdisplay, *, rgb444=False)` [Section titled “Display(busdisplay, \*, rgb444=False)”](#displaybusdisplay--rgb444false) 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)”](#scenedisplay-buffer_a-buffer_b--background0-top0-bottom0-left0-right0) 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 (no ghost — next refresh repaints over it, like `invalidate()`); the item survives and can be `add()`ed again. `ValueError` if not in the scene. * `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. ### `Tilemap(tileset, cols, rows)` [Section titled “Tilemap(tileset, cols, rows)”](#tilemaptileset-cols-rows) A grid of tile indices into a tileset Bitmap (each frame = one tile); a Scene layer. * `tile(tx, ty, value=None, *, flip_x=False, flip_y=False, transpose=False) -> int` — get tile, or set it (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`. ### `Particles(capacity, *, size=1, gravity=0.0, fade=False)` [Section titled “Particles(capacity, \*, size=1, gravity=0.0, fade=False)”](#particlescapacity--size1-gravity00-fadefalse) 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)”](#canvaswidth-height--transparentnone-buffernone) 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: 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; 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). * 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)”](#stripdrawcallback-x0-y0-width0-height0--always_dirtytrue) 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)`. In a scrolling scene add it `fixed`. * `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). * Read/write props: `x`, `y`, `width`, `height` — move or resize the layer (after shrinking, call `scene.invalidate()`) · `always_dirty`. ### Low-level draw functions [Section titled “Low-level draw functions”](#low-level-draw-functions) Most games never call these (`picogame_game.setup` + `Scene` use them internally), but they are exposed for hand-built render loops. * `render(display, sprites, buffer, x0, y0, x1, y1, *, background=0)` — render a sprite list 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`. ### Procedural noise (coherent value noise, 0..1) [Section titled “Procedural noise (coherent value noise, 0..1)”](#procedural-noise-coherent-value-noise-01) * `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)”](#helper-libraries-libpicogame_py-pure-python) ### `picogame_game` — one-call boot [Section titled “picogame\_game — one-call boot”](#picogame_game--one-call-boot) * `setup(display=None, strip_h=None, background=0, fast=True, top=0, bottom=0, left=0, right=0, rgb444=False) -> (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`. * `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. * `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 `board.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). ### `picogame_clock` — frame pacing [Section titled “picogame\_clock — frame pacing”](#picogame_clock--frame-pacing) * `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()`. ### `picogame_input` — buttons [Section titled “picogame\_input — buttons”](#picogame_input--buttons) * 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). * `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()` · `.is_active` · `.consume()` (true once, then clears). ### `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)”](#picogame_usbpad--usb-hid-gamepad-source-usb-host-boards-eg-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](/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)”](#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_font` — text bitmaps (external font module) [Section titled “picogame\_font — text bitmaps (external font module)”](#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](/concepts/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) [Section titled “picogame\_bitfont — built-in font (no font module needed)”](#picogame_bitfont--built-in-font-no-font-module-needed) * `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)”](#picogame_ui--hud--menu-widgets-line_h--12) * `SceneLabel(scene, pg, font, x, y, fg, bg)` · `.set(text)` · `.destroy()` — camera-independent text label (a fixed Scene layer); 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)` · `.destroy()` — a multi-line in-scene panel (dialog/log); 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()` · `.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)` · `.index` · `.tick(btn)` — D-pad cursor over a grid (inventory / board). ### `picogame_options` — settings menu [Section titled “picogame\_options — settings menu”](#picogame_options--settings-menu) * `OptionsMenu(scene, pg, font, x, y, w, rows, fg, bg, title=None, border=None)` · `.value(key)` · `.show(sel=0)` · `.hide()` · `.tick(btn)` — an in-scene options screen of toggles/choices. ### `picogame_shapes` — single-colour bitmap generators [Section titled “picogame\_shapes — single-colour bitmap generators”](#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 string mask (`'#'` = set). * `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)` — tileset: frame 0 empty, frames 1..N coloured. * `poly_frames(size, points, nframes, color, fill=True)` — bake `nframes` rotations of a polygon. ### `picogame_pool` — reusable sprite pool [Section titled “picogame\_pool — reusable sprite pool”](#picogame_pool--reusable-sprite-pool) * `Pool(scene, bitmap, capacity, anchor=None, fixed=False)` · `.spawn() -> sprite | None` · `.free(s)` · `.free_all()` · `.count() -> int`. (`.items` = all sprites.) ### Sprite collision (native methods) [Section titled “Sprite collision (native methods)”](#sprite-collision-native-methods) 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”](#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)”](#picogame_tiles--per-tile-metadata-flags-pico-8-fgetfset) * 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_seq` — generator-driven sequences (coroutine pattern) [Section titled “picogame\_seq — generator-driven sequences (coroutine pattern)”](#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”). ### `picogame_anim` — frame animation over time [Section titled “picogame\_anim — frame animation over time”](#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)`. ### `picogame_fx` — juice & raster effects [Section titled “picogame\_fx — juice & raster effects”](#picogame_fx--juice--raster-effects) * `Shake(scene, max_offset=6, decay=0.03, seed=0x9E37)` · `.add(amount)` (0.6 hit, 0.15 bump) · `.tick(cam_x=0, cam_y=0)` — trauma screen shake composed on top of the camera. * `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)` · `.follow(tx, ty, snap=False)` · `.offset() -> (ox,oy)` · `.apply()` — smoothed follow + world clamp (compose with `Shake` via `shake.tick(*cam.offset())`). * `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)”](#picogame_palette--game-boy-palette-tricks-on-pal8-art-call-spritetouch-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)`. ### `picogame_rand` — seedable RNG [Section titled “picogame\_rand — seedable RNG”](#picogame_rand--seedable-rng) * `Rand(seed=None)` (deterministic xorshift; `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. ### `picogame_save` — NVM persistence [Section titled “picogame\_save — NVM persistence”](#picogame_save--nvm-persistence) * `Save(key, schema, *, offset=0)` · `.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”](#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`, else a PWM output on `pin` (or the board default). 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](/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)”](#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. ### `picogame_synth` — synthio music & SFX [Section titled “picogame\_synth — synthio music & SFX”](#picogame_synth--synthio-music--sfx) * 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)` · `.press(n)` · `.release(n)` · `.music(midi_track)` · `.stop_music()` · `.set_levels(music=None, sfx=None)` · `.mute(on)` · `.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)”](#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”](#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”](#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”](#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)”](#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] : 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”](#picogame_scene--declarative-level-loader) * `load(pg, scene, display=None, strip_h=None, font=None, bank=None) -> View` — build a scene from a baked SCENE dict. * `load_bank(pg, bank)` — build a shared asset bank once (reuse across levels). * `View`: `.tile_xy(px, py)` · `.group(tag)` · `.point(name)` · `.in_zone(x, y, tag=None)` · `.is_solid(tx, ty)` · `.tile_has(tx, ty, prop)` · `.play(sound_id)` · `.tick(dt)`. ### `picogame_mode7` — Mode-7 perspective floor [Section titled “picogame\_mode7 — Mode-7 perspective floor”](#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/](/helpers/pseudo-3d/). ### `picogame_ray` — first-person raycaster [Section titled “picogame\_ray — first-person raycaster”](#picogame_ray--first-person-raycaster) * `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) · `.solid(x, y)` (wall test) · `.attach(sd)` (temporal repaint) · `.project_sprite(sx, sy)` (billboard) — DDA walls via the native `pg.raycast` caster (integer 16.16 C on device, Python in the sim) into a 0-RAM `StripDraw` view (\~22-30 fps). `stride` = perf/quality knob; `attach(sd)` + `always_dirty=False` repaints only the changed column band (still/slow \~30 fps). See [/helpers/pseudo-3d/](/helpers/pseudo-3d/).