Skip to content

Building scenes

These four modules cover the static “world” side of a game: loading a baked scene, asking what a tile means, making art with no PNG assets, and recycling a fixed set of sprites. For bare signatures see /reference/; the snippets below explain behaviour and the traps.

A one-time loader that turns a baked SCENE dict (see /scene-format/) into a live pg.Scene plus named handles. Reach for it when your level is built by the editor/scene_build.py rather than hand-coded, and you want the same loader to run on device and in the simulator. Loading is not a hot path, so it lives in Python.

load(pg, scene, display=None, strip_h=24, font=None, bank=None) builds and returns a View. It sets display.auto_refresh = False and clears root_group for you, allocates two strip buffers (bufA/bufB, sized width * strip_h * 2), constructs the pg.Scene, then adds every layer (tilemap, sprite, group, particles, hudlabel). display defaults to board.DISPLAY. Pass font (e.g. terminalio.FONT) if any layer is a HUD label. See /hardware/ for display details and /memory/ on the strip-buffer cost.

load_bank(pg, bank) builds shared bitmaps/sounds/anims ONCE; pass the result as load(..., bank=...) for each level so unchanged art is not rebuilt per level.

The returned View is your handle to everything:

  • view.scene - the live pg.Scene. Call view.scene.refresh() each frame and view.scene.set_view(ox, oy) to scroll.
  • view.named[name] - dict of name -> sprite / particles / HUD label for any layer given a name.
  • view.group(tag) - list of sprites for a group layer (returns [] if the tag is unknown, so it is safe to iterate).
  • view.tick(dt) - advance all auto-animated sprites; call once per frame with dt in seconds.
  • view.tile_xy(px, py) - world pixel -> (tx, ty) cell of the primary (first) tilemap.
  • view.is_solid(tx, ty) - shorthand for tile_has(tx, ty, "solid").
  • view.tile_has(tx, ty, prop) - True if the primary tilemap’s tile at that cell has the named property (from the baked tileprops).
  • view.point(name) - (x, y) for a named point, or None.
  • view.in_zone(x, y, tag=None) - first zone (tag, x, y, w, h) containing the point (optionally filtered by tag), else None.
  • view.play(sound_id) - play a baked sfx by id (no-op if audio/sample is missing).
  • view.tilemap / view.camera / view.zones / view.points / view.anims - the primary tilemap object, the camera tuple, and the raw collections.
import board, terminalio
import picogame as pg
import picogame_scene as pgs
import world1_scene
view = pgs.load(pg, world1_scene.SCENE, font=terminalio.FONT)
player = view.named["player"]
enemies = view.group("enemies")
while True:
view.tick(1 / 30) # advance auto-animations
tx, ty = view.tile_xy(player.x, player.y)
if not view.is_solid(tx, ty):
player.move(player.x + 2, player.y)
view.scene.refresh()

Gotchas: the FIRST tilemap layer is the “primary” one - tile_xy, is_solid, and tile_has only query that map. view.named only holds layers that were given a name; an un-named layer is added to the scene but unreachable. Sounds load best-effort: on the simulator (no wavs) view.sounds entries are None and play() silently does nothing.

A per-tile metadata bitfield keyed by tile INDEX. The flags belong to the tile graphic, so every cell drawn with that tile shares them. Reach for it when you hand-build a pg.Tilemap (not via picogame_scene) and want collision/coin/exit logic as one-liners instead of a side table. If you load scenes with picogame_scene, use view.is_solid / view.tile_has instead - this module is the lower-level alternative.

Eight named bits and their masks: B_SOLID, B_HAZARD, B_LADDER, B_PLATFORM, B_WATER, B_COIN, B_EXIT, B_CUSTOM are bit indices 0..7; SOLID, HAZARD, LADDER, PLATFORM, WATER, COIN, EXIT, CUSTOM are the matching 1 << bit masks. Use the masks when building the table, the B_* indices when querying.

TileFlags(flags=None, tile_px=8) builds the table. flags is either a {tile_index: bitfield} dict or a list/bytes indexed by tile index; tile_px is the tile size used by the pixel helper.

  • tf.get(tile, bit=None) - the full bitfield of a tile, or one bool flag if bit (a B_* index) is given.
  • tf.set(tile, bit, value=True) - flag (or clear) a bit on a tile at runtime.
  • tf.at(tilemap, tx, ty, bit) - flag bit of the tile at cell (tx, ty).
  • tf.at_px(tilemap, px, py, bit) - flag bit of the tile under MAP-LOCAL pixel (px, py); the common collision probe.
import picogame as pg
import picogame_tiles as tiles
TILE = 8
tf = tiles.TileFlags({1: tiles.SOLID, 2: tiles.COIN, 3: tiles.EXIT}, tile_px=TILE)
def blocked(level, tx, ty): # level is a pg.Tilemap
return tf.at(level, tx, ty, tiles.B_SOLID)
if tf.at_px(level, px, py, tiles.B_SOLID): # is the tile under pixel (px, py) solid?
stop()

Gotchas: at_px assumes the map is at screen (0, 0) - if it is moved, subtract the map origin from px/py yourself before calling. Tiles missing from the table read as 0 (no flags). Build with the MASK constants (tiles.SOLID) but query with the BIT indices (tiles.B_SOLID); mixing them up silently checks the wrong bit.

Generators that BAKE single-colour PAL8 pg.Bitmaps in code - placeholder sprites and tilesets with no PNG assets and no firmware changes. Reach for it for prototypes, geometric art (balls, bricks, ships), or anything you would otherwise hand-roll a packing loop for. Note: this is NOT the C Canvas (which draws into a live surface) - shapes returns a reusable Bitmap you hand to a Sprite or Tilemap. Index 0 is transparent, 1 is the colour.

  • rect(w, h, color) - a filled w x h rectangle.
  • circle(d, color) - a filled disc of diameter d.
  • ring(d, color, thickness=2) - a circle outline of diameter d.
  • from_mask(mask, color) - a bitmap from a list of strings; #, X, or 1 sets a pixel. Sized to the mask.
  • atlas(frames_data, w, h, color) - pack a list of w*h 0/1 buffers into one horizontal multi-frame bitmap (one colour). The general “frame sheet” builder.
  • color_frames(w, h, colors) - a multi-frame bitmap where frame i is a solid fill of colors[i]; frame 0 is already a colour. Index 0 transparent.
  • tileset_colors(w, h, colors) - a tileset where frame 0 is EMPTY (transparent) and frame i is a solid fill of colors[i-1]. So a Tilemap reads tile value 0 as empty and 1..N as coloured tiles.
  • poly_frames(size, points, nframes, color, fill=True) - bake nframes rotations of a polygon (points around centre, +y down) into a size x size atlas. The engine has no runtime rotation, so this is the pre-rotated-frames pattern for ships/asteroids. Set fill=False for an outline.
import picogame as pg
import picogame_shapes as shp
ball = shp.circle(4, pg.rgb565(255, 255, 120))
bricks = shp.tileset_colors(16, 8, [pg.rgb565(220, 70, 70),
pg.rgb565(80, 140, 240)]) # value 0 empty, 1..2 coloured
ship = shp.poly_frames(16, [(0, -8), (6, 7), (0, 4), (-6, 7)], 16,
pg.rgb565(200, 220, 255)) # 16 pre-rotated frames
sprite = pg.Sprite(ball, 100, 60)

Gotchas: color_frames frame 0 is a visible colour, but tileset_colors frame 0 is transparent (empty) - pick the one matching how your Tilemap treats value 0. circle fills its bitmap edge-to-edge, so it looks flat-topped when scaled up. poly_frames with nframes=1 bakes a single un-rotated frame (use it for a fixed-direction shape).

A reusable fixed-size sprite pool - the pre-allocate / spawn / free / iterate pattern every spawner game (bullets, enemies, orbs) hand-rolled. It uses sprite.visible AS the alive flag (no parallel data["on"]) and sprite.data for per-entity state. Reach for it whenever you have a bounded number of transient sprites and want zero per-frame allocation. See /memory/ on why pre-allocation matters on device.

Pool(scene, bitmap, capacity, anchor=None, fixed=False) pre-allocates capacity hidden sprites sharing bitmap, sets each anchor (if given) and data = None, and adds them all to scene (fixed= passes through to scene.add).

  • pool.items - the underlying list of sprites; iterate it directly for zero-alloc updates.
  • pool.spawn() - make the first free (hidden) sprite visible and return it, or None if the pool is full.
  • pool.free(s) - hide sprite s (return it to the pool).
  • pool.free_all() - hide every sprite (use on level reset).
  • pool.count() - count of live (visible) sprites; cheap, but iterate items for the sprites themselves.
import picogame as pg
import picogame_pool
bullets = picogame_pool.Pool(scene, bullet_bm, 6, anchor=(0.5, 0.5))
b = bullets.spawn() # a now-visible sprite, or None if full
if b:
b.data = {"vx": 6}
b.move(x, y)
for s in bullets.items: # zero-alloc iteration
if not s.visible:
continue
s.move(s.x + s.data["vx"], s.y)
if off_screen(s):
bullets.free(s)

Gotchas: always skip hidden slots when iterating (if not s.visible: continue) - items holds every slot, alive or not. spawn() reuses the most-recently-freed slot, so if you free(s) and spawn() in the same step, read any state off s.data BEFORE freeing - the new spawn will overwrite it. A full pool returns None from spawn(); check for it. All sprites share one bitmap, so per-entity frame/animation must be set on each sprite after spawn().