picogame — quick reference
A one-page cheat sheet of the engine’s everyday API: the native picogame C module
and the pure-Python picogame_* helper libraries in lib/. Signatures show parameter
names and defaults; * marks keyword-only arguments. Colours are wire-order RGB565 ints
(build them with rgb565). For longer explanations see the engine guide.
See also: Fit it in RAM · Drawing paths · Performance · Run on hardware · Coming from another engine.
Native module: picogame (import picogame as pg)
Section titled “Native module: picogame (import picogame as pg)”Constants & colour
Section titled “Constants & colour”RGB565,PAL8— bitmap pixel formats.API_LEVEL—int; engine API generation, bumped when the Python-visible surface grows. Libraries checkgetattr(pg, "API_LEVEL", 0) >= Nto 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 intoDisplay(rgb444=True)only where it works).FPU—bool;Truewhen the 3D math primitives (pg.project) run the hardware-float path (RP2350, ESP32-S3),Falseon the RP2040 (16.16 fixed-point). Packprojectbuffers to match:array("f")whenpg.FPUelsearray("i")with valuesint(v * 65536).rgb565(r, g, b) -> int— wire-order colour from 8-bit channels.collide(x1, y1, x2, y2, ax1, ay1, ax2, ay2) -> bool— AABB overlap (8 args = box vs box) or point-in-box (6 args:collide(x1, y1, x2, y2, px, py)). Inclusive AABB, so boxes collide when they touch (pass sprite boxes as(x, y, x+w, y+h); fires on contact).
Bitmap(data, width, height, *, format=RGB565, palette=None, frames=1, stride=0, transparent=None)
Section titled “Bitmap(data, width, height, *, format=RGB565, palette=None, frames=1, stride=0, transparent=None)”An image atlas of equal-size frames (any size). data is a buffer; palette (array of wire colours) is required for PAL8. transparent = the index/colour skipped when blitting.
- Read-only props:
width,height,frames,format,stride(pixels per source row; leave0for tightly-packed data, set it only for a sub-window of a larger image),palette(the PAL8 palette buffer orNone),transparent(the transparent value orNone).
Sprite(bitmap, x=0, y=0, *, frame=0, visible=True, flip_x=False, flip_y=False)
Section titled “Sprite(bitmap, x=0, y=0, *, frame=0, visible=True, flip_x=False, flip_y=False)”A positioned, animatable instance of a Bitmap.
- Position/anim props:
x,y(int px) ·fx,fy(float sub-pixel) ·frame·visible·flip_x,flip_y·bitmap(swap) ·data(your payload). Never mix the two on one axis. They are ONE stored value: writingxREPLACES it, discarding the sub-pixel remainder, so the classic “accumulate infx, then clamp withx” freezes slow motion dead (a 0.4 px/frame drift never survives the clamp). Clamp withfx/fytoo. Same on device - the firmware stores 24.8 fixed point and itsxsetter overwrites it identically, so the sim is not lying to you here. - Transform props (nearest-neighbour, about the anchor):
scale— float draw scale;1.0= native (fast path),2.0= double size, fractional allowed (e.g. a pulse).angle— rotation in degrees;0= none (fast path). Combines withscale.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. Withflip_x/flip_yit 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/yand rotation are about this point.
- Blit-effect props (one at a time - a TRUTHY write to any of them clears the other three; a FALSY write (
spr.flash = 0) clears ONLY that effect, sospr.dither = 0will NOT switch off an active flash - clear the effect you actually set; cheap, no extra bitmaps):shadow— bool; opaque pixels darken the destination (drop-shadow / dim overlay).flash— wire-RGB565 colour (or0/None= off); opaque pixels drawn as that flat colour (hit-flash). Pulse 1–3 frames.tint— wire-RGB565 colour (or0= 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)”Fast DMA backend wrapping a board’s busdisplay (FourWire SPI). Pass to Scene. rgb444=True drives the panel in 12-bit RGB444 (~25% less SPI traffic) on panels that support it; gate with RGB444_SUPPORTED.
Scene(display, buffer_a, buffer_b, *, background=0, top=0, bottom=0, left=0, right=0)
Section titled “Scene(display, buffer_a, buffer_b, *, background=0, top=0, bottom=0, left=0, right=0)”Retained-mode scene with dirty-rectangle rendering; buffer_a/b are strip buffers.
add(item, *, fixed=False) -> item— add a Sprite/Tilemap/Particles/Canvas/StripDraw (insertion order = bottom→top) and return it (sospr = scene.add(Sprite(...))works).fixed=True(keyword-only) pins it to the screen (ignores the camera) for HUD/dialog.add_all(items)— add several (bottom→top).remove(item)— unlink a previously added item; the removal forces a FULL repaint next refresh (no ghost).Scene.invalidate()takes NO arguments — the sub-rect form belongs toStripDraw.invalidate(x, y, w, h)(all four positionally, or none).set_view(ox, oy)— camera offset (screen position of the scene origin); changing it repaints all.view— read-only(ox, oy)camera offset.invalidate()— force a full repaint next refresh.refresh() -> list | None— diff & repaint changed regions; returns the dirty rect[x1,y1,x2,y2](reused) or None. The SIMULATOR has no dirty-rect: it clears and redraws everything, then returns the full play rect - orNonewhen the finished frame is pixel-identical to the last one (firmware parity for theif scene.refresh():idiom). So the rect’s SIZE is not a measurement there (an instrumented “94% of the screen dirty” is a constant, not your game). Runsim/run.py --strict-dirtyto at least catch the StripDraw half of it - a layer you forgot toinvalidate()then freezes in the sim as it does on device.
Tilemap(tileset, cols, rows)
Section titled “Tilemap(tileset, cols, rows)”A grid of tile indices into a tileset Bitmap (each frame = one tile); a Scene layer.
get_tile(tx, ty) -> int— read a tile. ·set_tile(tx, ty, value, *, flip_x=False, flip_y=False, transpose=False)— write one (with optional keyword-only per-cell orientation:flip_x/flip_y/transposegive all 8 orientations of a tile; pair with a deduplicated tileset, seepng2picogame.py --dedup). Out-of-range ignored. The orientation plane is allocated lazily (RAM only if a map uses it).fill(value)— set every tile (clears orientation).move(x, y)— position the map.- Read-only props:
x,y,cols,rows. Breaking change: replacedtile(tx, ty[, value])(firmware after 2026-08-23); old code raisesAttributeError. The firmware is what must be new enough.
Particles(capacity, *, size=1, gravity=0.0, fade=False)
Section titled “Particles(capacity, *, size=1, gravity=0.0, fade=False)”gravity is px/tick² added to vy each tick, clamped to -128..127.99; NEGATIVE gravity is legal and makes particles RISE (embers, bubbles). Particle positions are world coordinates - scene.set_view moves them like any other layer.
A pooled particle layer (small moving dots) drawn as one Scene layer.
emit(x, y, count, speed=1, life=30, color=0xFFFF)— burstcountdots, random velocity ≤speedpx/tick, livinglifeticks.tick()— advance one step (move, gravity, ageing). Call each frame.clear()— remove all.
Canvas(width, height, *, transparent=None, buffer=None)
Section titled “Canvas(width, height, *, transparent=None, buffer=None)”A RAM RGB565 drawing surface composited as a Scene layer (width*height*2 bytes). transparent makes it a shaped overlay; buffer backs it with external memory (e.g. an arena slice). For animated full-frame surfaces prefer StripDraw (no buffer).
clear(color)·pixel(x, y, color)·fill_rect(x, y, w, h, color)·rect(x, y, w, h, color)line(x0, y0, x1, y1, color)·circle(cx, cy, r, color)·fill_circle(cx, cy, r, color)·ring(cx, cy, r, thickness, color)triangle(x0,y0, x1,y1, x2,y2, color)·fill_triangle(...)·ellipse(cx, cy, rx, ry, color)·fill_ellipse(...)fill_round_rect(x, y, w, h, r, color)·frame3d(x, y, w, h, light, dark)(beveled box) ·move(x, y)blit(bitmap, x, y, frame=0, flip_x=False, flip_y=False)— stamp a bitmap frame into the surface (honours its transparent key; the retained way to bake an icon/portrait/rendered text into a panel).text(x, y, s, fg, font, bg=None)— composite a string in C, rasterizing each glyph offont(afontio.BuiltinFont) on the fly. The built-interminalio.FONTis a fixed 6×12 cell, so a string islen(s) * 6px wide and centring is(W - len(s) * 6) // 2(both fonts here are fixed-width:picogame_bitfontis 8×8). At 6 px/char a 320 px screen holds 53 characters and a 240 px one holds 40 — budget text against the smaller. Details: no Python glyph cache and no per-call Bitmap/Sprite.bg=None→ transparent glyph background. ASCII/built-in font only. Works on a Canvas or aStripDrawview; 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 belowhorizonwith a Mode-7 perspective floor oftexture(power-of-2 dims;textureis a Bitmap,horizon/y_offare pixel rows, the remaining 7 args are 16.16 fixed-point; one world unit = one tile). 10 fixed-point (16.16) args — you normally letpicogame_mode7.Cameracompute them from a camera pose. Draws into a Canvas or a 0-RAMStripDrawview (passy_off= the strip top).vspans(x0s, x1s, tops, bots, colors, n, x_off=0, y_off=0)— fillnvertical colour spans in one call: span i coversx0s[i]..x1s[i]×tops[i]..bots[i](both exclusive) incolors[i]; all five are uint16 arrays. The batch primitive for column renderers —picogame_raypaints its merged wall runs with one call per strip (x_off=-vx, y_off=-vyreplay, off-band spans rejected with a couple of compares), which made its per-strip cost independent of the run count (measured: a full-screen stride-1 raycast frame 203–275 ms → ~27 ms (~36 fps)).fill_triangles(verts, colors, n, x_off=0, y_off=0)— fillntriangles in one call:verts= int16x0,y0,x1,y1,x2,y2per triangle,colors= wire-RGB565 uint16 per triangle. Same rasteriser asfill_triangle, but the whole batch crosses the Python/C boundary once — the win for many small triangles (blocky 3D, low-poly, isometric), where the ~10 µs per-call overhead otherwise dominates.x_off/y_offtranslate every vertex before clipping: passy_off=-vyin aStripDrawcallback to replay one screen-space batch into each render strip (off-band triangles are rejected with a few compares) — full-res 3D with no retained canvas at all, the preferred path on framebuffer boards. Companion ofpg.projectandpicogame_iso.emit_blocks.road(ri0, tab, rl, rr, d05_q8, d07_q8, colors)— draw one OutRun-style racing-road strip from precomputed tables: the whole per-scanline loop (sky/road/rumble/dash colour picks) in one call.ri0= road-table row at this surface’s row 0 (negative = sky rows);tab= int16 rows of{edge_w, dash_hw, wb05_q8, wb07_q8, flags};rl/rr= int16 per-row edges frompg.road_edges;d05/d07= Q8 scroll phases;colors= 6× uint16{sky, road_a, road_b, rumble_a, rumble_b, dash}. Designed as aStripDrawcallback body (0-RAM road).- Read-only props:
x,y,width,height.
StripDraw(callback, x=0, y=0, width=0, height=0, *, always_dirty=True)
Section titled “StripDraw(callback, x=0, y=0, width=0, height=0, *, always_dirty=True)”Immediate-mode layer with no pixel buffer: each refresh it calls callback(view, vx, vy, vw, vh) once per render strip inside its rect. view is a Canvas pointing at the live strip (use Canvas primitives, incl. view.text); view-local (0,0) = screen (vx, vy). It is screen-space ALWAYS, so fixed=True on it is a no-op - it neither scrolls with the camera nor smears in a scrolling scene.
always_dirty=True(default) repaints every frame → for animated/scanline content (pseudo-3D, gradients).always_dirty=Falserepaints 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 analways_dirty=Falsepanel when its content changes).
Triangles(verts, colors)
Section titled “Triangles(verts, colors)”A retained screen-space triangle batch the compositor rasterises entirely in C per render strip (cheap band reject + the Canvas rasteriser) — no pixel buffer AND no Python per strip. verts = int16 array (x0,y0,x1,y1,x2,y2 per triangle), colors = uint16 wire-RGB565 per triangle — both caller-owned (fill them in place each frame). This is the 3D-scene layer: pg.project into the arrays, painter’s-order the faces, set count, scene.refresh(). Because no Python runs during compose, it stays composable by the core1 band split — unlike a StripDraw callback.
count— how many triangles draw next refresh (clamped to the buffer capacity); assigning marks the layer dirty for a full repaint (set it every frame in a live 3D scene).- Measured (roadhop lab): replaces the
fill_triangles-in-StripDraw replay with ~30 % less refresh time at 320×240 and unlocks the dual-core compose (640×480 at a locked 20 fps on an RP2350 with a free second core). - The ONLY property is
count— how many triangles the next refresh draws (assignment marks the layer dirty). There is no x/y/width/height/always_dirty on this type.
Low-level draw functions
Section titled “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, layers, buffer, x0, y0, x1, y1, *, background=0)— render a layer list (any scene-layer kind) into the region[x0,x1) × [y0,y1)and push it todisplay.bufferis a reusable strip buffer (≥ region-width × 2 bytes). Mixing with a retained scene: the scene doesn’t knowrender()changed the pixels — if the region overlaps the scene’s play rect, callscene.invalidate()after (or usepicogame_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. Seepicogame_fx.InvertFlash.project(cam, pts, n, out_sx, out_sy)— batch perspective projection ofn3D points to screen in C.cam= 15 camera params(ex,ey,ez, rx,rz, ux,uy,uz, fx,fy,fz, focal, cx0, cy0, near),pts=n×3world coords,out_sx/out_sy= int16 screen coords (a point behind the near plane gets the sentinel-32768— skip its faces). Buffer format followspg.FPU(float32 on FPU boards, 16.16 int32 on the RP2040 — a format mismatch culls everything = black screen). One call per frame +Canvas.fill_triangles= real flat-shaded polygon 3D (Elite-class): project your vertices, painter’s-sort faces, fill. ~0.7 ms/480 pts on an RP2350, ~2.2 ms on an RP2040.road_edges(rl, rr, hw, n, cx0, dist, cfg)— one racing-road frame’s curve accumulator + integer edge tables in a single call (the OutRun-genrecompute_roadloop).rl/rr= int16 outputs forCanvas.road,hw= int32 Q16 per-row half-widths,cx0= Q16 screen centre (incl. lateral offset),dist= integer world distance,cfg= int32[7] curve config (f1,f2Q20 frequencies,a1k,a2kQ16 amp×gain, world step, curve step, row offset) — there is NO hill term:road_edgesemits horizontal edges only, and hills come from moving the horizon you pass asCanvas.road’sri0. Pairs withCanvas.roadfor a 0-RAM 30 fps road on the RP2040.vblank(framebuffer)— (DVI boards, RP2350) pass thepicodvi.Framebufferthe display scans out of; blocks until its next vertical blanking (≤ ~16.7 ms). Starting a full-frame compose right after vblank keeps the publish front consistently behind the beam, so each sweep shows one whole frame — removes single-buffer tearing while the compose fits within two sweeps. Costs the wait: budget it against your FPS cap.core1(on) -> bool— (RP2 boards) route splittable engine kernels (Canvas.mode7rows, the framebuffer compose bands) through the second core. Returns the resulting state:Falsewhen core1 is unavailable — e.g. a USB-host board (Fruit Jam) runs its USB service on core1 permanently, so the engine refuses rather than stomping it. Dual-core compose measured ~1.75× on an RP2350 with a free core1.core1is NOT in a CircuitPython release. It lives on the fork’spicogame-core1branch and has not gone upstream, sopg.core1raisesAttributeErroron any firmware you download from circuitpython.org. Guard it withhasattr(pg, "core1")and treat the dual-core path as an optimisation you may not have.
Procedural noise (coherent value noise, 0..1)
Section titled “Procedural noise (coherent value noise, 0..1)”value2d(x, y, *, seed=0) -> float·value1d(x, *, seed=0) -> floatfbm2d(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)”picogame_game — one-call boot
Section titled “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/rightreserve fixed HUD margins;rgb444=Trueopts into 12-bit colour on a supporting SPI panel, andrgb444="auto"enables it only where the board reportspicogame.RGB444_SUPPORTED.overlay(scene, display, items, buffer, x0, y0, x1, y1, *, background=0)— immediate-drawitemsover a live scene (pause / menu / cutscene / banner) =pg.render+scene.invalidate(), so the nextrefresh()repaints the full frame instead of leaving overlay fragments.screen() -> (width, height)— the screen size, from whichever display the board provides. Lay every game out from this instead of hardcoding 320×240.display()— that same display object (forpg.render,picogame_fx.InvertFlash, …). Both readsupervisor.runtime.display— the board’s primary display, which CircuitPython picks right after board init and which aboot.py, a launcher oropen_framebuffer()publishes withsupervisor.runtime.display = disp. That is the one way a display reaches a game, so the same file runs on a PicoPad, a Fruit Jam, a bare Pico, in the simulator and in the browser playground (the last two ship a smallsupervisorshim).open_framebuffer(width, height, color_depth=None) -> display— set the resolution from inside a game on a framebuffer board (Fruit Jam DVI), e.g.open_framebuffer(640, 480); a no-op that returns the current display on a fixed SPI panel. Pass the result tosetup(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”Clock(fps=30, max_dt=0.1)·.set_fps(fps)·.tick() -> dt(sleep to frame, return seconds) ·.tick_async()(the same, forasyncioloops).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”- 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); profilePICOPAD. 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-8btnpauto-repeat (menus / grid move) ·.clear()(drop held state) ·.attach(source)/.detach(source)— OR another input source in/out at runtime (apicogame_seq.Scriptattract demo, a late USB pad) ·.deinit()— release the pins this Buttons owns (keypad scanner / matrix / digitalio inputs) so a NEWButtons()can claim them in the same program (launcher, test harness); attached sources are only dropped, not deinit’d; idempotent.matrix=— a scanned key-matrix source (also configured board-wide via thePICOGAME_MATRIX_*settings keys);usb=— one or more extra button sources (USB pad/keyboard, below).ButtonsORs every source together, so a game reads them all with no code change.
Timer(frames)— input-leniency window (coyote time / jump buffering):.feed(condition)(recharge while true, else decay) ·.charge()(fill) ·.is_active·.consume()(true once, then clears) ·.t(frames left; there is noreset()—consume()or.t = 0clears it, e.g. the coyote timer right after a jump so it can’t fire twice).
picogame_usbpad — USB HID gamepad source (USB-host boards, e.g. Fruit Jam)
Section titled “picogame_usbpad — USB HID gamepad source (USB-host boards, e.g. Fruit Jam)”UsbPad(buttons=None)— a button source forButtons(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:e401SNES-style pad; remap per pad fromsettings.toml(PICOGAME_USBPAD, no reflash — see Custom board). Discover a new pad’s report bytes withtools/usbpad_probe.py. .mapped— mask of buttons this pad can report;VERSION,MAPPEDmodule constants.
picogame_usbkbd — USB HID keyboard source (USB-host boards)
Section titled “picogame_usbkbd — USB HID keyboard source (USB-host boards)”UsbKbd(keys=None)— the keyboard twin ofUsbPad, aButtons(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 withPICOGAME_USBKBD_EP = "iface:endpoint"(find it withtools/usbkbd_probe.py).
picogame_i2cpad — I2C gamepad source (any board with I2C, incl. the PicoPad)
Section titled “picogame_i2cpad — I2C gamepad source (any board with I2C, incl. the PicoPad)”I2CPad(recipe, i2c=None, address=None)— a button source forButtons(sources=[…])reading a “dumb” I2C button device: GPIO expanders (TCA9555, PCF8574, MCP23017) and vendor pads built on them. Needs no USB host, so it is the way to put an external controller on a PicoPad or a bare Pico..read()→ logical mask,.mapped→ the buttons it can report,.led(n, on)→ player LED (presets that have one).- Opt-in via
settings.toml— an expander has no identity register, so auto-probing could bind an unrelated device:PICOGAME_I2CPAD = "qwstpad"(preset),"qwstpad@0x23"(address),"qwstpad;qwstpad@0x23"(several pads), or a full recipe"addr=0x20 read=:1 inv=1 UP=0 A=4 …".PICOGAME_I2C = "SDA,SCL"names the bus pins on a bare board (or a single token names a board bus, e.g."I2C0").Buttons()attaches whatever is listed — see Input. attach(spec, i2c=None)— the pads for a settings value (whatButtonscalls).find_pads(preset="qwstpad", i2c=None)— every pad of a preset on the bus, in address order, one source per player (the QwSTPad preset covers four addresses).parse_recipe(text)/PRESETS— the recipe format and the shipped presets.- A poll is one short transaction (~0.5 ms at 100 kHz); a failed poll holds the last state and reports all-released after 8 misses, and the bus is clocked free after a soft reload.
picogame_font — text bitmaps (external font module)
Section titled “picogame_font — text bitmaps (external font module)”Which text path to use (Canvas.text vs a rendered Bitmap vs a StripDraw view — and what each costs): see the decision matrix in Drawing paths.
render_text(pg, font, text, fg, bg=None) -> (bitmap, w, h)— render a string to a PAL8 Bitmap (bg=None→ transparent).render_text_pal(pg, font, text, fg, bg=None) -> (bitmap, w, h, palette)— same, plus the palette array; mutatepalette[1]to recolour the text in place (no rebuild).Label(pg, font, x, y, fg, bg)·.move(x, y)·.set(text) -> changed·.draw(display, buffer).
picogame_bitfont — built-in font (no font module needed, fixed 8×8 cell: GLYPH_W/GLYPH_H)
Section titled “picogame_bitfont — built-in font (no font module needed, fixed 8×8 cell: GLYPH_W/GLYPH_H)”render_text(pg, text, fg=None, outline=None, mid=None, bg=None) -> (bitmap, w, h)— render with the bundled bitmap font; optionaloutline/midgive a cheap 2-tone outlined look.
picogame_ui — HUD & menu widgets (LINE_H = 12)
Section titled “picogame_ui — HUD & menu widgets (LINE_H = 12)”SceneLabel(scene, pg, font, x, y, fg, bg=None, scale=1, fixed=True)·.set(text)·.reserve(chars)·.show(on)·.color(fg)·.destroy()— camera-independent text label (a fixed Scene layer).reserve(chars)switches it to a FIXED-width buffer built once:set()then composes glyphs in place — no growth and no Bitmap/palette rebuilds (the compose leaves ~0.5-1.5 KB of short-lived slice churn per CHANGED update (device-measured: RP2040 ~0.6 KB, ESP32-S3 ~1.4 KB), freed at the next GC — update on change, not per frame), and the label cannot grow-realloc on a fragmented heap. Assign.x/.yto MOVE it (the scene repaints the old and the new rect); there is no width metric, so CENTRE a changing value by reserving the widest string and padding with spaces (the font cell is fixed-width, 6 px/char -ui.text_width/ui.centred).scale=2doubles the glyphs (a title banner needs no second sprite);show(False)HIDES it - use that to clear a reserved label, becauseset(" ")repaints its FULL reserved width inbg(a visible strip).color()recolours without a rebuild. destroy() detaches a ONE-SHOT label so GC reclaims it (recurring HUD: build once + set/hide instead).SceneBox(scene, pg, font, x, y, w, h, fg, bg, nlines=3, key=None, border=None)·.show(lines)·.hide()·.set_line(i, text)·.lift()— move the box to the top of the draw order (a layer added later, e.g. aFade, would paint over it; returns the box) ·.destroy()— a multi-line in-scene panel (dialog/log); a row holds(w - 16) // 6chars of the 6 px font (8 px padding each side), rows 12 px apart (h >= 14 + 12 * nlines); longer lines are not clipped (the sim warns). destroy() = one-shot teardown (needs firmware withScene.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 withhandle.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,Nonewhile navigating ·.draw(display, buffer, force=False).SceneMenu(scene, pg, font, x, y, items, fg, bg, title=None, rows=None, width=None, border=None, paged=True)·.show(sel=0)·.hide()·.set_items(items, sel=0)(swap the entries and resize WITHOUT a new scene layer) ·.tick(btn)→ index ≥0 on A,CANCEL(= -2) on B,Nonewhile navigating — the same menu as an in-scene layer.GridCursor(cols, rows, tx=0, ty=0, wrap=False, delay=15, interval=4)·.index·.tick(btn) -> (tx, ty) | None | ui.CANCEL—delay/intervaltune the held-direction repeat (frames before the first repeat / between repeats) — D-pad cursor over a grid (inventory / board).tickmoves on held D-pad (auto-repeat) and returns the selected cell on A,ui.CANCELon B, elseNone; guard withif pick is not None and pick is not ui.CANCEL:(the tuple does not support>= 0).
picogame_options — settings menu
Section titled “picogame_options — settings menu”OptionsMenu(scene, pg, font, x, y, w, rows, fg, bg, title=None, border=None, visible=True)·.value(key)·.show(sel=0)·.hide()·.set_rows(rows, sel=0)·.tick(btn)— an in-scene options screen of toggles/choices. Itstick()returns the row’s KEY whereSceneMenu/Menureturn an INDEX - the one difference that bites when you move a menu from one to the other. UseOptionsMenufor named settings with values,SceneMenufor a plain list of actions.
picogame_shapes — single-colour bitmap generators
Section titled “picogame_shapes — single-colour bitmap generators”rect(w, h, color)·circle(d, color)·ring(d, color, thickness=2)from_mask(mask, color)— Bitmap from a list of strings, one per row ('#','X'or'1'= set); sized to the mask.colormay also be a {character: colour} dict - then every character is its own palette entry (unlisted characters, e.g..or space, stay transparent), so ONE mask carries body + outline + highlight:from_mask([" ## ", " #oo# ", "#o..o#"], {"#": OUTLINE, "o": BODY, ".": GLINT}). That is how you get the shape AND colour identity the design bar asks for without hand-building a PAL8 atlas. Passing a single string is not an error — each CHARACTER becomes a row, so you get a 1-pixel-wide sprite and no exception.masks(mask_list, color)— multi-framefrom_mask: a list of string masks → one horizontal atlas Bitmap (frame i =mask_list[i]), sized to the largest. The step betweenfrom_mask(single frame) andatlas(raw 0/1 buffers, which nothing else produces) — animated or multi-state mask art needed it and every game re-derived the same ten lines.atlas(frames_data, w, h, color)— pack w×h buffers into a multi-frame Bitmap.color_frames(w, h, colors)— frame i = solidcolors[i].tileset_colors(w, h, colors, gap=0)— tileset: frame 0 empty, frames 1..N coloured;gap=Ncarves an N-px transparent right+bottom edge into each tile, so touching same-colour tiles read as individual tiles (brick walls show mortar, not stripes).poly_frames(size, points, nframes, color, fill=True)— bakenframesrotations of a polygon.
picogame_pool — reusable sprite pool
Section titled “picogame_pool — reusable sprite pool”Pool(scene, bitmap, capacity, anchor=None, fixed=False)·.spawn() -> sprite | None(the slot comes back in its baseline look: blit effect, scale/angle, frame and flips as at the firstspawn();.dataand position are yours) ·.baseline()(re-snapshot after a later reconfiguration) ·.free(s)·.free_all()·.count() -> int. (.items= all sprites.)visiblemeans only “draw this” — the pool keeps its own in-use bit (.alive, one byte per slot), so blinking a pooled sprite through.visibleis safe: its slot stays taken.spawn()shows the sprite it hands out andfree()hides it again, soif not s.visible: continuestays a correct liveness guard. While a sprite is blinked off that guard skips it, so it doesn’t move for those frames — guard onpool.alive[i]instead if that matters.
Sprite collision (native methods)
Section titled “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= anotherSprite, a point(x, y), or a rect(x1, y1, x2, y2)(trigger zone / screen-cull).insetshrinks THIS sprite’s box by N px per side for a fair hitbox.Sprite.near(other, r) -> bool— circular: this sprite’s centre withinrpx ofother’s centre (squared distance, no sqrt).other= aSpriteor 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_tilesflags (at_px(tm, x, y, SOLID)), not AABB.
picogame_math — numeric helpers, vectors & turn-based trig
Section titled “picogame_math — numeric helpers, vectors & turn-based trig”clamp(v, lo, hi)·mid(a, b, c)·lerp(a, b, t)·inv_lerp(a, b, v)·remap(v, a, b, c, d)·sgn(x)·approach(v, target, step)·wrap(v, lo, hi).sin_t(turns)·cos_t(turns)·atan2_t(dy, dx) -> turns— angles as 0..1 turns (standard, not PICO-8’s inverted sin).length(dx, dy)·distance(x1, y1, x2, y2)·normalize(dx, dy)·angle_rad(dx, dy)(radians) ·from_angle_rad(a, mag=1.0)— vector helpers.
picogame_tiles — per-tile metadata flags (PICO-8 fget/fset)
Section titled “picogame_tiles — per-tile metadata flags (PICO-8 fget/fset)”- Bits/masks:
B_SOLID B_HAZARD B_LADDER …(indices) andSOLID HAZARD LADDER …(masks). TileFlags(flags=None, tile_px=8)—flags={tile_index: bitfield}or a list..get(tile, bit=None)·.set(tile, bit, value=True)·.at(tilemap, tx, ty, bit)·.at_px(tilemap, px, py, bit)(collision one-liner). Keyed by tile index (shared by all cells using it).
picogame_script — story scripts as generators (Director)
Section titled “picogame_script — story scripts as generators (Director)”Director(pg, scene, buttons, font, box=None, nlines=3, fg=0xFFFF, bg=0x0000)— runs ONE story script at a time over a live scene;box= the dialog panel rect (default: a bottom strip sized frompicogame_game.screen())..on(name, genfunc)(register) ·.has(name)·.start(script)(a name, a generator, or a zone’s story DATA dict whenpicogame_storyis attached) ·.active·.tick() -> bool— call once per frame afterbuttons.poll(); returns True while a script runs, INCLUDING the finishing step.- Waiting primitives (use with
yield frominside a script):.text(lines)(dialog page, A advances) ·.ask(lines) -> sets .answer(A/B choice) ·.wait(frames)·.fade_out(speed)/.fade_in(speed)·.goto(level, point=None)(fade out, set.pending, wait for the game loop to swap the level, fade in). - Story flags:
.ev(name)/.set(name)(sets AND replays the level’s effects through.on_flag) /.ev_set(name)(plain) /.ev_clear(name), kept in.events(a set — persist it via your save schema)..view= the current View (kept fresh bypicogame_story)..retarget(scene)re-points the Director after a map change;.pending=(level, point)a script asked for.
picogame_story — a game.json’s story data through the Director
Section titled “picogame_story — a game.json’s story data through the Director”Story(director, game=None, module=None)— installs itself on the Director;module= yourstory.py(def name(d)scripts a zone names with{"script": "name"}; a missing def shows a visible stub)..enter(view, x, y) -> bool— call as the player moves: starts the story of the zone just ENTERED (edge-latched, no restart while standing in it)..leave()after a level change..zone(data) -> generator— what a zone’s data plays:say(a list of lines, or variants{if, lines, set}— first match wins),ask(lines,set,done,yes,no),goto [level, point]withif/denied,script..effects(view)— replay the level’seffectsrules whoseifholds (swap [a, b],solid/unsolidtiles,hide/shownamed sprites); called after every load and, throughDirector.set, after every flag change.
picogame_seq — generator-driven sequences (coroutine pattern)
Section titled “picogame_seq — generator-driven sequences (coroutine pattern)”wait(frames)·over(frames, fn)(fn(t), t 0..1) ·move_over(sprite, x, y, frames)— all are generators; compose withyield from.Seq(gen=None)·.start(gen)·.tick() -> done— advance one step per frame (cutscenes, “do X over N frames”).Script(play, loop=False)— scripted input: a game that plays itself.play(s)is a generator pressing Buttons masks over frames (yield from s.tap(B.A)·s.hold(B.RIGHT | B.UP, n)·s.rest(n);tap(..., base=mask)keepsbaseheld throughout). It is a Buttons source:btn.attach(script), thenscript.tick()each frame beforebtn.poll()— the demo runs through the game’s own input path (just_pressed/repeatall fire), on device and in the sim alike. Attract mode: attach on an idle title,loop=Trueto run forever, and hand the controls back on a human press —if btn.state & ~script.mask: btn.detach(script); script.stop(). The same script doubles as a scripted verification run.
picogame_anim — frame animation over time
Section titled “picogame_anim — frame animation over time”FrameAnim(sprite, frames, *, fps=8, loop=True)·.configure(frames, fps=8, loop=True)·.reset()·.tick(dt).AnimatedSprite(sprite, anims)·.play(name)·.tick(dt).
picogame_fx — juice & raster effects
Section titled “picogame_fx — juice & raster effects”Shake(scene, max_offset=6, decay=0.03, seed=0x9E37)·.add(amount)(0.6 small kick, 0.8 hit, 1.0 big; under ~0.5 is sub-pixel atmax_offset=6, i.e. invisible) ·.tick(cam_x=0, cam_y=0)— trauma screen shake composed on top of the camera.scene=None= offset-only mode for strip-rendered games (road/raycaster/mode-7, whichset_viewnever moves): tick() just updates.ox/.oyand you spend them in your renderer’s params —road.tick(dist, lateral + sh.ox), a jittered horizon.Fade(scene, width, height, x=0, y=0, color=0, cell=8)·.to(target, speed=2.0)·.out()/.into()/.set(level)/.dim(level=8)/.clear()/.pulse(level=12, speed=2.0)·.is_done·.tick() -> done— dither fade / dim / flash, full-screen or a region. UsesStripDrawwithout a retained pixel surface.Tween(value=0.0, speed=0.2)·.to(target, speed=None)·.set(value)·.tick() -> value·.is_done— ease a scalar (UI/pop-ups).Camera(scene, w, h, lerp=0.18, world_w=0, world_h=0, top=0, bottom=0, left=0, right=0)·.follow(tx, ty, snap=False)·.apply(shake=None)·.offset() -> (ox,oy)·.ox/.oy— smoothed follow + world clamp;cam.apply(shaker)composes with aShakeallocation-free (oneset_view);.offset()allocates a tuple per call, so don’t use it per frame.w/h= the screen; a reserved HUD band goes intop/bottom/left/right(the same band assetup()), which centres and clamps inside the visible rect.Sky(scene, x, y, w, h, top, bottom)— vertical gradient with a2*h-byte colour table. ·Scanlines(scene, x, y, w, h, step=2, dark=0)— CRT overlay retaining onew-byte PAL8 row and its palette.InvertFlash(display, frames=3, normal=None)·.pulse(frames=None)·.tick()— hardware-invert hit flash for a supported SPI panel. It does not redraw the scene and is not a framebuffer effect.
picogame_palette — Game-Boy palette tricks on PAL8 art (call sprite.touch() after)
Section titled “picogame_palette — Game-Boy palette tricks on PAL8 art (call sprite.touch() after)”cycle(palette, lo, hi, step=1)— rotate entries (animated water/lava/portals; ~0 extra art).swap(dst_palette, src_palette)— recolour a shared bitmap (GBC-style; cheaper than a 2nd bitmap).fade(palette, base, t, target=0, skip=None)— lerp toward a colour (smooth brightness fade;base=snapshot()of the original).snapshot(palette)/restore(palette, base).
picogame_rand — seedable RNG
Section titled “picogame_rand — seedable RNG”Rand(seed=None)(deterministic combined 30-bit Lehmer PRNG;None= time-seeded) ·.below(n)·.randint(a, b)·.random()·.chance(p)·.choice(seq)·.shuffle(lst)·.weighted(weights) -> index·.seed(s).Bag(items, rng)·.next()— shuffle-bag (7-bag) anti-streak randomizer.
picogame_save — NVM persistence
Section titled “picogame_save — NVM persistence”Save(key, schema, *, offset=0)—schema= an ordered dict ofname -> (struct format char, default); worked example → /helpers/data/. ·.defaults()·.load() -> dict·.save(values)·.reset(). Survives reboot/filesystem wipe.
picogame_audioout — one output device for any board
Section titled “picogame_audioout — one output device for any board”make_output(sample_rate=22050, pin=None)— returns this board’s audio output, chosen automatically: an I2S DAC (Fruit Jam TLV320) when the board hasI2S_BCLK, else a PWM output onpin(or the board default). Used by bothpicogame_audioandpicogame_synth, so a game needs no board-specific audio code. RaisesRuntimeErrorif no output exists.- The TLV320’s output select + the three volume trims are set from
settings.toml(PICOGAME_AUDIO_OUT,PICOGAME_DAC_VOLUME,PICOGAME_HP_VOLUME,PICOGAME_SPK_VOLUME— see Custom board); the driver’s defaults are deliberately quiet, so raise them toward 0 dB.PICOGAME_DEBUG = 1prints why a DAC failed to init.
picogame_audio — sample playback (PWM or I2S DAC)
Section titled “picogame_audio — sample playback (PWM or I2S DAC)”Audio(pin=None, voices=4, sample_rate=22050, channels=1, bits=16, signed=True)·.load(path)·.play(sample, *, voice=None, loop=False, volume=1.0)·.sfx(sample, volume=1.0)·.music(sample, loop=True, volume=1.0)·.stop(voice=None)·.stop_music()·.deinit()·.is_playing.tone(frequency=440, ms=120, sample_rate=22050, volume=0.6)— square-wave beep sample.
picogame_synth — synthio music & SFX
Section titled “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 (midi60 = middle C;cutoff= low-pass Hz).pitch_bend(semitones, ms, waveform=None, once=True)— an LFO for a note’sbend(slide / laser zap).Synth(pin=None, sample_rate=22050, buffer_size=2048, music_level=0.4, sfx_level=0.7)·.sfx(n)·.sfx_seq(events, priority=0, window=0)(timed chain —events=[(delay_frames, voice), …], the trigger goes throughsfx()arbitration and the tail is read from your list, no per-fire allocation) ·.press(n)·.release(n)·.music(midi_track)·.stop_music()·.set_levels(music=None, sfx=None)·.mute(on)·.deinit()— release the output (PWM pin / I2S bus), mixer and synthesizer so a laterSynth()/Audio()in the same program can claim them; idempotent, the instance becomes the silent no-op ·.available— self-guarding init: on audio-less firmware or a failed init (tight heap, claimed pin) the instance runs as silent no-ops instead of raising; no try/except needed in games.Drone(synth, waveform=None, amplitude=0.35, attack=0.03, release=0.12)·.start()·.set(frequency, amplitude=None)·.stop()— a continuously-held note (engine/siren/drone): press once, then feedset(freq, amp)each frame so synthio tracks the live pitch/amplitude.load_midi(path, sample_rate=22050, waveform=None, envelope=None, tempo=120, ppqn=240)— load a MIDI file into a playable track.
picogame_sfx — signature SFX kit (over picogame_synth)
Section titled “picogame_sfx — signature SFX kit (over picogame_synth)”Kit(synth)— build a ready-made, hardware-tuned SFX set once from a liveSynth(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 theSynth:.set_levels()/.mute().
picogame_cutscene — full-screen image / story-scene player
Section titled “picogame_cutscene — full-screen image / story-scene player”palette(pg, rgb)— build the wire palette once (from a bake_cutscene.py palette module, RGB triplets, or wire ints).show(pg, display, buffer, path, pal=None, w=320, h=240, scale=None, band=24, bg=0)— stream an image in row bands. The source band usesw*bandbytes for PAL8 orw*band*2for RGB565, in addition to any render buffer passed in.scale=Nonederives 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 afterauto_holdticks).
picogame_stream — stream sprite frames from flash
Section titled “picogame_stream — stream sprite frames from flash”StreamSheet(pg, path, w, h, frames, palette, transparent=None)·.use(i)(select a frame, loaded on demand) ·.close()— keep big sheets on flash instead of RAM.
picogame_arena — anti-fragmentation buffer
Section titled “picogame_arena — anti-fragmentation buffer”Arena(pixels)·.alloc(nbytes, align=1) -> memoryview·.canvas(w, h, transparent=None) -> Canvas·.reset()·.mark() -> m/.release(m)(nested LIFO lifetimes: mark on entering a mode, release on leaving — run-level buffers survive) ·.free() -> int. Grab one big buffer up front, hand out slices.
picogame_debug — RAM watermarks + FPS overlay (testing aid)
Section titled “picogame_debug — RAM watermarks + FPS overlay (testing aid)”enabled— module flag (default False: calls ship as no-ops; flip True while testing).ram(tag)— gc.collect() + print[RAM] <tag>: free N alloc Mat 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 cornerFPS 30 FREE 31koverlay (one live text bitmap, re-rendered only on change). Pass yourClockasclock=for a true FPS reading;every/x/yare keyword args.
picogame_scene — declarative level loader
Section titled “picogame_scene — declarative level loader”Game(pg, src, display=None, strip_h=None, font=None, lazy=False)— the whole game from ONE source:src="game.json"(streamed one level at a time and baked at boot: RP2040 40–60 ms, ~4.5 kB per level, then the baker is released;lazy=Truebakes a level when it is loaded) or the name of a baked bank module ("game_bank"fromscene_build.py build, levels imported per load and dropped again)..levels·.start·.size·.name·.bank.Game.load(name=None, at=None) -> View— build a level’s View (default: the start level);at= a point name the player is moved to. Drop every reference to the previous View andgc.collect()before calling it again (the runner templates show the two-phase goto). Strip buffers are allocated once and shared across levels.load(pg, scene, display=None, strip_h=None, font=None, bank=None, bufs=None) -> View— build a scene from a baked SCENE / LEVEL dict.load_bank(pg, bank)— build a shared asset bank once.read_pal8(path)— a.pal8sidecar’s palette + index bytes.View:.tile_size(the primary tilemap’s(tile_w, tile_h)— what probes and offsets need) ·.tile_xy(px, py)·.group(tag)(tagged single sprites included) ·.point(name)·.in_zone(x, y, tag=None)·.is_solid(tx, ty)·.tile_has(tx, ty, prop)·.play(sound_id)·.tick(dt)·.set_tile_prop(tile, prop, on=True)·.swap_tiles(a, b)·.effects(story rules) ·.world(authored world size) ·.name.load_json(pg, path, ...) -> View— the old single-scene entry point; agame.jsongiven here returns its start level throughGame.
picogame_scenebake — on-device baker (the one baker; scene_build.py is a CLI over it)
Section titled “picogame_scenebake — on-device baker (the one baker; scene_build.py is a CLI over it)”bake_sounds(sounds, base=None) -> {id: path}(wavs stay wav, loaded at runtime) ·bake_bank(assets, sounds=None, base=None) -> BANK·bake_level(level, size, assets) -> LEVEL·bake(scene) -> SCENE(a standalone v1 scene). Colour assets are generated; a PNG asset becomes apal8freference to its<name>.pal8sidecar (no PIL on the device);pal8_inline(playground handoff) passes through.walk(f, on_level=None, stop_after=None, top=None)— read agame.jsontop level one value at a time (json.loadper value, one byte of lookahead seeked back);levels[]stream intoon_level(level, index, byte_offset).head(path, want)— just the small keys (what the launcher reads, ~3 ms).level_at(f, offset, size, assets)— one level from a remembered offset.encode_pal8(data, fw, fh, frames, palette, transparent) -> bytes— a.pal8file (16-byte header, palette, indices; the constantsPAL8_MAGIC/PAL8_HDR/PAL8_HDR_LENare that header, spelled out in Scene format). Costs ~3 kB while imported;Gamedrops it after boot.
picogame_mode7 — Mode-7 perspective floor
Section titled “picogame_mode7 — Mode-7 perspective floor”Camera(fov=0.66)·.draw(canvas, texture, x, y, angle, horizon, height, y_off=0)— drive the CCanvas.mode7floor from a friendly camera pose (position in world/tile units, heading in radians,height= how high the camera sits).texturedims must be powers of two, one world unit = one tile. Draw into a 0-RAMStripDrawview. See /helpers/pseudo-3d/.
picogame_road — the OutRun scanline road
Section titled “picogame_road — the OutRun scanline road”Road(pg, width, height, horizon, colors, *, half_width=0.47, hw_min=6.0, depth=600.0, curves=((16384, 90.0), (4096, 30.0)), world_step=6, curve_step=2, hill_amp=0, edge_frac=0.12, dash_frac=0.07, dash_min_hw=7.0, band=20.0, dash_band=14.0)·.tick(dist, lateral_px=0)(once/frame; positive lateral = the car moved right, the road shifts left) ·.draw(view, vy)(StripDraw callback body) ·.set_grade(g)(hills: −1..+1 moves the horizon; needshill_amp) ·.horizon_now(this frame’s effective horizon = horizon + hill pitch — the y overlays and roadside sprites anchor to) ·.curve_at(dist)(signed curvature −1..+1 for centrifugal pull / AI — the same two-sine model the C runs, zero-alloc) ·.row_of(z)/.half_of(row)/.edges_of(row)(place and scale sprites ON the road — the rows are linear, so scale byhalf_of, notF/(F+z)) — drives the nativepg.road_edges+Canvas.roadpair from human units: curveperiodsin world units (rounded up to powers of two so the int32 phase wrap stays continuous — see the calling contract),swingin px of lateral bend, colors as a dict of sixpg.rgb565values. Builds every fixed-point table once;tick()allocates nothing. See /helpers/pseudo-3d/.
picogame_iso — isometric projection
Section titled “picogame_iso — isometric projection”IsoView(ox, oy, tw, th)(tw/th= tile half-width/half-height; 2:1 diamond →th = tw//2) ·.to_screen(gx, gy, h=0)·.depth(gx, gy, h=0)(back-to-front painter’s key) ·.screen_to_grid(sx, sy)·.cube_faces(gx, gy, height_px)(top/right/left faces of a raised block) ·.emit_blocks(cells, tv, tc)(alloc-free batch: writes flat-shaded cube triangles for many blocks straight into int16/uint16 buffers for ONECanvas.fill_trianglescall; returns the triangle count) — the cheapest pseudo-3D there is: integer add/shift only, no divide, no C dependency, which is why it runs well on the RP2040. Unlocks iso RPG / strategy / tactics / builder. Static boards: render once + dirty-rect the movers (30 fps);emit_blocksis for rebuild-every-frame scenes (~2× faster than a Pythoncube_facesloop). See /helpers/pseudo-3d/.
picogame_ray — first-person raycaster
Section titled “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; row 0 is the top of the VIEW, not the screen — a layer starting below y=0, e.g. under a reserved HUD band, must passvy - band) ·.solid(x, y)(wall test) ·.set_cell(x, y, v)(change ONE world cell at runtime — a door opening, a wall dropping; v = wall type 0-9, 0 = empty; keeps the caster grid,solid()and.mapconsistent and re-casts even for a standing camera. For events, not animation — each call forces one full re-cast) ·.attach(sd)(temporal repaint) ·.project_sprite(sx, sy)(billboard) — fully native render: thepg.raycastcaster (integer 16.16 C on device, Python in the sim) also emits the RLE-merged wall runs, painted with oneCanvas.vspansbatch per strip into a 0-RAMStripDrawview (~36 fps uncapped at stride 1 full-screen on RP2040, flat across view angles).stride= perf/quality knob;attach(sd)+always_dirty=Falserepaints only the changed column band (still/slow ~30 fps). See /helpers/pseudo-3d/.