Skip to content

picogame — 2D game engine for PicoPad (CircuitPython)

picogame is an experimental CircuitPython C module: a fast, retained-mode 2D game engine for the Pajenicko PicoPad (RP2040, 320×240 ST7789). It harvests rendering techniques from PicoLibSDK and is conceptually a “more complete _stage” — arbitrary-size sprites, a retained scene with dirty-rectangle rendering, tilemaps, and an asynchronous DMA display backend.

  • Status: experimental. Core (sprites, async-DMA display, retained scene + dirty-rect, tilemaps, collision) is built and validated on hardware.
  • Performance: worst case (48 moving sprites, full-screen) ~33 FPS; typical (static background + localized motion via dirty-rect) 140+ FPS.

  1. Concepts
  2. Quick start
  3. API reference
  4. Text & fonts
  5. Asset pipeline
  6. Performance & constraints
  7. Building the firmware
  8. Examples

Retained mode. You build a Scene once (add sprites and tilemaps), then each frame you only mutate objects (sprite.x = ...) and call scene.refresh(). The engine diffs against the previous frame and repaints only the changed region (dirty rectangle), transferring just those pixels over SPI. Nothing changed → nothing sent.

Wire-order colors. All colors (backgrounds, palette entries, RGB565 pixels) are stored in the display’s wire byte order. Always build colors with picogame.rgb565(r, g, b) — don’t pass raw 0xRRGGBB or naïve RGB565.

Two display backends:

  • picogame.Displayfast: wraps an existing busdisplay, pushes pixels via asynchronous double-buffered DMA (overlaps CPU blit with SPI transfer). Works with any MIPI SPI TFT. Use this with Scene.
  • picogame.render(...)universal: immediate-mode draw through any busdisplay (any controller displayio supports), blocking/slower. Fallback / for quick HUD draws.

Strip buffers. Rendering works in horizontal strips. You provide reusable buffers (bytearrays). The fast path needs two buffers (double buffering); each must be at least region_width * 2 bytes. A common size is full width × 24 rows = 320 * 24 * 2 = 15360 bytes each.

Coordinates. Top-left origin. Rectangles passed to render are x0,y0 (inclusive) to x1,y1 (exclusive).


import time, array, board
import picogame as pg
display = board.DISPLAY
display.auto_refresh = False # the engine drives the display itself
display.root_group = None
W, H = 320, 240
bufA = bytearray(W * 24 * 2)
bufB = bytearray(W * 24 * 2)
BG = pg.rgb565(20, 24, 40)
# A 16x16 paletted sprite (index 0 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)
disp = pg.Display(display)
scene = pg.Scene(disp, bufA, bufB, background=BG)
hero = pg.Sprite(hero_bmp, 150, 110)
scene.add(hero)
while True:
hero.x = (hero.x + 1) % (W - 16) # mutate
scene.refresh() # only the hero's area is repainted
time.sleep(1 / 60)

NameDescription
RGB565format constant (16-bit color, wire order)
PAL8format constant (8-bit palette index)
rgb565(r, g, b) -> intbuild a wire-order RGB565 color from 8-bit components
collide(x1, y1, x2, y2, ax1, ay1, ax2, ay2) -> boolAABB box↔box overlap; inclusive bounds — 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) -> boolbox↔point (6 args)
render(display, sprites, buffer, x0, y0, x1, y1, *, background=0)universal immediate draw of a sprite list to a busdisplay
value2d(x, y, *, seed=0) -> floatsmooth 2-D value noise, 0..1 (fast C)
value1d(x, *, seed=0) -> floatsmooth 1-D value noise, 0..1
fbm2d(x, y, *, octaves=4, seed=0, lacunarity=2.0, gain=0.5) -> floatfractal (fBm) 2-D noise, 0..1 — terrain/clouds/caves
fbm1d(x, *, octaves=4, seed=0, lacunarity=2.0, gain=0.5) -> floatfractal (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.

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

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, data, bitmap, scale, angle, shadow.
  • move(x, y) — set position (accepts int or float).
  • 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.
  • 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.
  • 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. Stored in 1/256 steps; the dirty-rect tracks the resulting top-left.
  • Position is stored as 24.8 fixed-point. 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}.

Fast async-DMA backend wrapping an existing busdisplay.BusDisplay (e.g. board.DISPLAY). Reuses its SPI bus, pins, window commands and dimensions.

  • render(sprites, buffer_a, buffer_b, x0, y0, x1, y1, *, background=0) — draw a sprite list into the region with double-buffered DMA.

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. display is a picogame.Display.

  • 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 dirty rect, 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.

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.

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.

  • 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), move(x, y).
  • 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). Keep Canvases small, or use a Tilemap for large scrolling fields. See HARDWARE.md. For a full-frame animated surface, prefer StripDraw below — it costs 0 bytes.

StripDraw(callback, x=0, y=0, width=0, height=0)

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

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:

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: zero — versus a Canvas’s width*height*2 bytes. A full-screen pseudo-3D road is 0 B as a StripDraw vs 150 KB as a Canvas.
  • 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.
  • 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.
  • A callback that raises prints one traceback (then that layer draws nothing) — it can’t wedge the display, but fix it.
  • 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 fixedscene.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) (correct even when the dirty rect is merged wider than the layer). Composites over lower layers and under higher ones, like any layer. See examples/picogame_stripdraw_demo.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)”

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.

picogame_font.py (pure-Python helper, copy to the device) renders text into a Bitmap using any fontio font, including the firmware’s bundled terminalio.FONT — no font assets required.

import terminalio, picogame as pg, picogame_font
label = picogame_font.Label(pg, terminalio.FONT, x=4, y=2,
fg=pg.rgb565(255, 210, 60), bg=pg.rgb565(0, 0, 0))
# each frame / on change:
if label.set("SCORE %06d" % score): # re-renders only when the text changes
label.draw(display, bufA) # repaints just its rectangle (opaque bg)

Also picogame_font.render_text(pg, font, text, fg, bg=None) -> (bitmap, w, h) for one-off rendering (bg=None → transparent). Text is composed in Python, so update it only when it changes; a C Font type is a planned acceleration.


picogame_audio.py (pure-Python helper, copy to the device) wraps CircuitPython’s audio stack (audiopwmio + audiocore + audiomixer) for the PicoPad’s PWM audio. A Mixer lets sound effects overlap and play under music. No firmware change is needed — those modules are enabled by default on RP2040.

import picogame_audio
audio = picogame_audio.Audio() # PWM on board.AUDIO, 4 voices, 22050 Hz mono 16-bit
pew = audio.load("pew.wav") # reusable WaveFile (keep the reference)
audio.sfx(pew) # fire-and-forget on a round-robin sfx voice
audio.music(audio.load("song.wav")) # looping music on the reserved voice 0
beep = picogame_audio.tone(880, 90) # generate a tone (no .wav needed)
audio.sfx(beep)

Every sample must match the Mixer format (sample_rate, channel_count, bits_per_sample, samples_signed). Defaults are 22050 Hz / mono / 16-bit signed (typical ugame .wav). Voice 0 is reserved for music; sfx round-robin over the rest so they can overlap.

picogame_clock.py (pure-Python helper) provides frame pacing so motion is frame-rate independent (otherwise a quick D-pad tap flings a sprite across the screen at 140 FPS). Needs nothing in firmware — it uses time.monotonic_ns().

import picogame_clock
clock = picogame_clock.Clock(30) # cap to 30 FPS
while True:
dt = clock.tick() # sleep to the frame boundary; real dt (s)
update(dt) # ignore dt for fixed-feel, or scale by it
scene.refresh()
  • Clock(fps, max_dt=0.1)tick() caps and returns dt (clamped after a stall); tick_async() yields to other asyncio tasks during the idle wait.
  • FixedStep(step_fps, max_steps=5)for dt in steps(): update(dt) runs equal logic steps regardless of render time (deterministic physics), render once/frame.

tick_async() needs the asyncio library, which is not bundled in the firmware — copy the asyncio folder into CIRCUITPY/lib (from the Adafruit CircuitPython library bundle) when you want it. The sync Clock/FixedStep need nothing extra. (Rendering is blocking, so async only helps during the cap idle.)

  • picogame_input.Buttons — reads the PicoPad buttons; call poll() once per frame, then is_pressed(mask), just_pressed(mask), just_released(mask) with UP DOWN LEFT RIGHT A B X Y constants. No more per-game digitalio plumbing.
  • picogame_game.setup(background=0, strip_h=24, fast=True) — one call takes over the display and returns (scene, buffer_a, buffer_b) (auto-refresh off, two strip buffers, Display+Scene). fast=False drives the bare busdisplay through the portable bus.send renderer instead of the DMA Display — the same path used on ports without a DMA backend (correct everywhere, slower). Scene accepts either a picogame.Display or a plain busdisplay.
  • picogame_mathlength/distance/normalize/angle_rad/from_angle_rad/clamp math helpers.
  • picogame_animFrameAnim / AnimatedSprite: drive a sprite’s frame from a frame sequence on a time basis (tick(dt)), instead of hand-rolling frame//4 % n.
  • picogame_pool.Pool(scene, bitmap, capacity, anchor=None, fixed=False) — a reusable fixed-size sprite pool: pre-allocates capacity sprites (all hidden) added to the scene. spawn() (no args) reveals a free sprite and returns it (or None if full), free(s) hides one, free_all() hides all, count() returns the live count. Iterate the live ones with for s in pool.items: (skip if not s.visible). Uses sprite.visible as the alive flag and sprite.data for per-entity state — replaces hand-rolled alive-flags.
  • picogame_save.Save(key, schema) — tiny persistent store for highscores/settings, backed by microcontroller.nvm (4 KB reserved flash on RP2040). load() returns a values dict (defaults if blank/corrupt), save(dict) persists it; survives a reboot and a filesystem wipe, with no boot.py remount needed. schema is name -> (struct char, default), e.g. {"hiscore": ("I", 0), "level": ("B", 1)}. NVM is a single shared region, so pass a per-game key (e.g. "arkanoid") — it’s stored in the header and verified on load, so another game’s data fails the key check and you get your own defaults instead of misreading foreign bytes. Coexisting games can also use distinct offset=. Each save() writes a flash sector — call it on game-over / new-record, not per frame.
  • picogame_shapesrect/circle/ring/from_mask/atlas/poly_frames generate single-colour PAL8 bitmaps (and baked rotation frames), so games stop hand-rolling pixel buffers and frame-atlas packing.
  • picogame_uiSceneLabel (camera-independent text via a fixed scene layer), TextBox (screen-space multi-line box), Menu (cursor menu). Fills the UI gap for dialog/menu/HUD-driven games (RPG, strategy).
import picogame as pg, picogame_game, picogame_input, picogame_clock
scene, bufA, bufB = picogame_game.setup(background=pg.rgb565(16, 18, 32))
btn = picogame_input.Buttons()
clock = picogame_clock.Clock(60)
while True:
btn.poll()
if btn.is_pressed(btn.LEFT):
hero.move(hero.x - 2, hero.y)
if btn.just_pressed(btn.A):
fire()
scene.refresh()
clock.tick()

tools/png2picogame.py (host-side, needs Pillow) converts PNG/BMP into importable asset modules whose colors are already in wire order.

Terminal window
# 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:

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.

Running on real hardware? Read HARDWARE.md first. It covers the RAM budget (~150–190 KB heap), the sim-vs-device API gotchas (no scene.display), shipping .mpy (not big .py), PAL8 bytes tilesets (not giant array literals → pystack exhausted), and the split-one-program-per-scene deploy pattern. The sim hides all of these.

  • RAM is the bottleneck (264 KB total, ~72 KB firmware): a full-screen Canvas(320,240) is 150 KB — too big. Keep Canvases small / use a Tilemap for big fields; HUD = a Label, not a Canvas bar; gc.collect() between scenes.
  • Use dirty-rect (Scene) for real games: static background + a few moving things → tiny transfers → 100+ FPS. Full-screen redraw every frame is SPI-bound (~20 ms @ 62.5 MHz ≈ 50 FPS ceiling).
  • Frame-rate independence: use picogame_clock.Clock(fps).tick() (or scale motion by its dt) — otherwise speed varies with FPS (a quick tap can fling a sprite across the screen at 140 FPS). See Timing & game loop.
  • Assets live in flash (core0 render reads them via XIP) — keep large art as frozen const modules; prefer PAL8 (8:1 vs RGB565).
  • Native types (Sprite, Bitmap, …) can’t hold custom attributes.
  • Single dirty rect (v1): the Scene unions all changes into one bounding rectangle — great for localized motion; sprites scattered across the whole screen degrade toward a full redraw.
  • Colors must be wire-order (rgb565()); raw RGB565 ints will look wrong.

Board: pajenicko_picopad (repurposed as the picogame engine firmware — Wi-Fi-heavy DVI/EVE off, CIRCUITPY_PICOGAME=1). Requires ARM GCC ≥ 14 (CircuitPython 10.x). See picopad-build-env notes for the toolchain.

Terminal window
cd circuitpython/ports/raspberrypi
. ../../../.venv/bin/activate
export PATH="<arm-gnu-toolchain-14.x>/bin:$PATH"
make BOARD=pajenicko_picopad -j$(nproc)
# -> build-pajenicko_picopad/firmware.uf2

Flash: hold BOOTSEL, plug in the PicoPad, copy firmware.uf2 to the RPI-RP2 drive. Then copy your game + helpers to CIRCUITPY. On a RAM-tight device, ship .mpy (not .py) and mind the memory rules — see HARDWARE.md. Verify a firmware actually has a feature: arm-none-eabi-nm build-.../firmware.elf | grep sprite_set_scale.


In the project root (copy to CIRCUITPY/code.py):

FileShows
examples/picogame_demo_code.pyarbitrary-size sprites, fast Display, FPS/timing breakdown
examples/picogame_scene_demo.pyretained Scene + dirty-rect (static field + movers)
examples/picogame_play_demo.pyD-pad input → Scene (frame-capped movement)
examples/picogame_hud_demo.pyHUD text via the bundled font (picogame_font.py)
examples/picogame_tilemap_demo.pytilemap background + sprite over it
examples/picogame_audio_demo.pyPWM audio: overlapping beeps via the mixer (picogame_audio.py)
examples/picogame_scroll_demo.pycamera/scrolling: a 640×480 world with the view following the player (scene.set_view)
examples/picogame_particles_demo.pyparticle layer: burst (A) + fountain (B) with gravity (pg.Particles)
examples/ugame_ports/jumper_port/a full game (jumper-wire) ported from _stage
examples/ugame_ports/vacuum_port/a full game (vacuum-invaders) ported from _stage
examples/picogame_arkanoid.pya full Breakout/Arkanoid game: Tilemap bricks + sprites + collide + particles + HUD
examples/picogame_squest.pya Seaquest-style shooter: pooled sprites via sprite.data, projectiles + collide + particles, O2 HUD gauge, tone audio
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, tinyjoypad_extract, cavern_pack)

The native engine is the built-in C module picogame (in the firmware). The Python helpers keep the picogame_* prefix — not a picogame/ package, since that name is the C module and can’t be shadowed on the filesystem.

Deploy a game: flash firmware.uf2, copy the helpers it imports into CIRCUITPY/lib/, copy the game as CIRCUITPY/code.py plus any assets it loads.

.mpy: the lib/ helpers can be precompiled to .mpy with mpy-cross (matching the CircuitPython version) to cut import time and RAM (no on-device source parse). Drop the .mpy files in CIRCUITPY/lib/ instead of the .py.