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.
Contents
Section titled “Contents”- Concepts
- Quick start
- API reference
- Text & fonts
- Asset pipeline
- Performance & constraints
- Building the firmware
- Examples
Concepts
Section titled “Concepts”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.Display— fast: wraps an existingbusdisplay, pushes pixels via asynchronous double-buffered DMA (overlaps CPU blit with SPI transfer). Works with any MIPI SPI TFT. Use this withScene.picogame.render(...)— universal: immediate-mode draw through anybusdisplay(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).
Quick start
Section titled “Quick start”import time, array, boardimport picogame as pg
display = board.DISPLAYdisplay.auto_refresh = False # the engine drives the display itselfdisplay.root_group = None
W, H = 320, 240bufA = 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] = 1hero_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)API reference
Section titled “API reference”Module picogame
Section titled “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 — 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) | universal immediate draw of a sprite list to a busdisplay |
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.
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— forPAL8, a buffer of wire-order RGB565 entries (e.g.array("H", [...])).frames— animation frames laid out horizontally; framefis at columnf*width.stride— atlas width in pixels (defaultwidth*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 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.
- 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 pulsing1.0..1.3, a powerup growing). Scales about theanchor.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+anglecompose.shadow— whenTrue, 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 withscale/anglefreely.bitmap— read/write the sourceBitmap. 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 nextrefresh.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/ythen refer to this point, so growing/shrinking via abitmapswap 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/fyfor smooth physics (ball.fx += 2.4) instead of a parallel Python float +int(round());x/yreturn 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)
Section titled “Display(busdisplay)”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 aSprite/Tilemap/Particles/Canvas/StripDrawand return it (sospr = 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 viaset_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, orNoneif 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.
Tilemap(tileset, cols, rows)
Section titled “Tilemap(tileset, 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 as0and ignores writes (no exception). - Read-only properties:
x,y,cols,rows.
Canvas(width, height, transparent=None)
Section titled “Canvas(width, height, transparent=None)”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. Costswidth*height*2bytes 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 aTilemapfor large scrolling fields. SeeHARDWARE.md. For a full-frame animated surface, preferStripDrawbelow — 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’swidth*height*2bytes. A full-screen pseudo-3D road is 0 B as aStripDrawvs 150 KB as aCanvas. - 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
Canvasis 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,heightmove or resize the layer at runtime; after shrinking it, callscene.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)(correct even when the dirty rect is merged wider than the layer). Composites over lower layers and under higher ones, like any layer. Seeexamples/picogame_stripdraw_demo.py, andexamples/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)— spawncountparticles at (x, y) with random velocity up tospeedpx/tick, livinglifeticks, in a wire-order color (usepicogame.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×sizedots.
Text & fonts
Section titled “Text & fonts”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_audioaudio = picogame_audio.Audio() # PWM on board.AUDIO, 4 voices, 22050 Hz mono 16-bitpew = audio.load("pew.wav") # reusable WaveFile (keep the reference)audio.sfx(pew) # fire-and-forget on a round-robin sfx voiceaudio.music(audio.load("song.wav")) # looping music on the reserved voice 0beep = 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.
Timing & game loop
Section titled “Timing & game loop”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_clockclock = picogame_clock.Clock(30) # cap to 30 FPSwhile 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 otherasynciotasks 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.)
Input & scaffold helpers
Section titled “Input & scaffold helpers”picogame_input.Buttons— reads the PicoPad buttons; callpoll()once per frame, thenis_pressed(mask),just_pressed(mask),just_released(mask)withUP DOWN LEFT RIGHT A B X Yconstants. No more per-gamedigitalioplumbing.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=Falsedrives the barebusdisplaythrough the portablebus.sendrenderer instead of the DMADisplay— the same path used on ports without a DMA backend (correct everywhere, slower).Sceneaccepts either apicogame.Displayor a plainbusdisplay.picogame_math—length/distance/normalize/angle_rad/from_angle_rad/clampmath helpers.picogame_anim—FrameAnim/AnimatedSprite: drive a sprite’sframefrom a frame sequence on a time basis (tick(dt)), instead of hand-rollingframe//4 % n.picogame_pool.Pool(scene, bitmap, capacity, anchor=None, fixed=False)— a reusable fixed-size sprite pool: pre-allocatescapacitysprites (all hidden) added to the scene.spawn()(no args) reveals a free sprite and returns it (orNoneif full),free(s)hides one,free_all()hides all,count()returns the live count. Iterate the live ones withfor s in pool.items:(skipif not s.visible). Usessprite.visibleas the alive flag andsprite.datafor per-entity state — replaces hand-rolled alive-flags.picogame_save.Save(key, schema)— tiny persistent store for highscores/settings, backed bymicrocontroller.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 noboot.pyremount needed.schemaisname -> (struct char, default), e.g.{"hiscore": ("I", 0), "level": ("B", 1)}. NVM is a single shared region, so pass a per-gamekey(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 distinctoffset=. Eachsave()writes a flash sector — call it on game-over / new-record, not per frame.picogame_shapes—rect/circle/ring/from_mask/atlas/poly_framesgenerate single-colour PAL8 bitmaps (and baked rotation frames), so games stop hand-rolling pixel buffers and frame-atlas packing.picogame_ui—SceneLabel(camera-independent text via afixedscene 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_clockscene, 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()Asset pipeline
Section titled “Asset pipeline”tools/png2picogame.py (host-side, needs Pillow) converts PNG/BMP into importable
asset modules whose colors are already in wire order.
# 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 --mapOn the device:
import hero, tiles, levelspr = 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 dataOptions: --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 aREMAPtable; rebuild your map withv, 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.
Performance & constraints
Section titled “Performance & constraints”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), PAL8bytestilesets (not giantarrayliterals →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 aTilemapfor big fields; HUD = aLabel, 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 itsdt) — 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
constmodules; preferPAL8(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.
Building the firmware
Section titled “Building the firmware”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.
cd circuitpython/ports/raspberrypi. ../../../.venv/bin/activateexport PATH="<arm-gnu-toolchain-14.x>/bin:$PATH"make BOARD=pajenicko_picopad -j$(nproc)# -> build-pajenicko_picopad/firmware.uf2Flash: 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.
Examples
Section titled “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) |
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.py | a full Breakout/Arkanoid game: Tilemap bricks + sprites + collide + particles + HUD |
examples/picogame_squest.py | a Seaquest-style shooter: pooled sprites via sprite.data, projectiles + collide + particles, O2 HUD gauge, tone audio |
Project layout & deployment
Section titled “Project layout & deployment”lib/ engine Python helpers (picogame_*) -> copy needed ones to CIRCUITPY/lib/examples/ games, demos, per-game assets -> a game becomes code.py at the roottools/ 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.