Skip to content

Building scenes

These four modules load baked scenes, attach properties to tiles, create simple bitmap art in code, and reuse a fixed set of sprites. See /reference/ for the signatures.

The loader turns a baked SCENE dictionary into a pg.Scene and a set of named handles. Use it for a level produced by the editor or scene_build.py. The same data and loader run on hardware and in the simulator. See /scene-format/ for the input format.

load(pg, scene, display=None, strip_h=None, font=None, bank=None) returns a View. It resolves the display in the same way as picogame_game.setup(), constructs the scene, and adds each tilemap, sprite, group, particle system, and HUD label. On an SPI display it allocates two width * strip_h * 2 render buffers, available as view.bufA and view.bufB. On a framebuffer target both are None. Pass a font such as terminalio.FONT if the data contains a HUD label. See /hardware/ for display backends and /memory/ for buffer costs.

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

A whole game is one game.json (see scene format); Game opens it the way the device does, streaming and baking every level at start, and load(name, at) builds one level’s View:

import picogame_scene as pgs, terminalio
game = pgs.Game(pg, "game.json", font=terminalio.FONT) # or Game(pg, "game_bank") after build --mpy
view = game.load(game.start)
# later, on a goto: drop the old view, gc.collect(), then
view = game.load("cave", "entry")

Zone stories and per-level effects from the file run through picogame_story.Story on top of the Director — no code is generated from the JSON.

Tile properties come with the scene. Do not reach for picogame_tiles after loading a scene — the View already answers per-tile questions: view.is_solid(tx, ty), and view.tile_has(tx, ty, "name") for any other flag. The name is whatever the editor painted, so it is not limited to the four the editor offers by default: add a glass flag in the editor and the game reads it as view.tile_has(tx, ty, "glass"). Falling back to a bitfield here means re-deriving data the loader already holds.

view.camera is data, not behaviour. The loader hands you (mode, target, axis, x, y, w, h) and the game applies it — nothing follows the player on its own. axis is "x", "y" or "xy"; honour it when you call scene.set_view(), or a level authored to scroll vertically silently will not.

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.set_tile_prop(tile, prop, on=True) - flip a flag for a tile TYPE at runtime: every cell holding that tile changes meaning at once (a lever makes all gate tiles walkable, ice melts). One cell instead: swap its tile with the native tilemap.set_tile. Changes last until the next load() - loading a level resets its tile meanings, even with a shared bank.
  • 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()

TileFlags stores one metadata bitfield for each tile index, so every cell using that tile shares the same properties. Use it with a hand-built pg.Tilemap. A scene loaded through picogame_scene already exposes the higher-level view.is_solid() and view.tile_has() methods.

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

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

  • tf.get(tile, bit=None) - the full bitfield of a tile, or one bool flag if bit (a B_* index) is given.
  • tf.set(tile, bit, value=True) - flag (or clear) a bit on a tile at runtime.
  • tf.at(tilemap, tx, ty, bit) - flag bit of the tile at cell (tx, ty).
  • tf.at_px(tilemap, px, py, bit) - flag bit of the tile under MAP-LOCAL pixel (px, py); the common collision probe.
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()

These functions build single-colour PAL8 Bitmap objects in code. They suit prototypes and geometric art such as balls, bricks, or ships. Unlike Canvas, they return a reusable bitmap for a Sprite or Tilemap. Palette index 0 is transparent and index 1 contains the requested colour.

  • rect(w, h, color) - a filled w x h rectangle.
  • circle(d, color) - a filled disc of diameter d.
  • ring(d, color, thickness=2) - a circle outline of diameter d.
  • from_mask(mask, color) - a bitmap from a list of strings; #, X, or 1 sets a pixel. Sized to the mask.
  • atlas(frames_data, w, h, color) - pack a list of w*h 0/1 buffers into one horizontal multi-frame bitmap (one colour). The general “frame sheet” builder.
  • color_frames(w, h, colors) - a multi-frame bitmap where frame i is a solid fill of colors[i]; frame 0 is already a colour. Index 0 transparent.
  • tileset_colors(w, h, colors) - a tileset where frame 0 is EMPTY (transparent) and frame i is a solid fill of colors[i-1]. So a Tilemap reads tile value 0 as empty and 1..N as coloured tiles.
  • poly_frames(size, points, nframes, color, fill=True) - bake nframes rotations of a polygon (points around centre, +y down) into a size x size atlas. The engine also rotates at runtime (Sprite.angle); baked frames trade a little RAM for a cheaper per-frame blit and pixel-stable art, so pick them for many always-rotating objects (asteroids), and angle for one-off or continuous rotation. Set fill=False for an outline.
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)

Pool pre-allocates a fixed number of sprites for short-lived objects such as bullets, enemies, or pickups. sprite.visible marks an occupied slot and sprite.data can hold entity state. Spawning and freeing slots allocate no new sprites. See /memory/ for why stable allocation matters on the device.

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

  • pool.items - the underlying list of sprites; iterate it directly for zero-alloc updates.
  • pool.spawn() - make the first free (hidden) sprite visible and return it, or None if the pool is full. The slot comes back in its baseline look - blit effect (flash/tint/dither/shadow), scale/angle, frame and flips restored to how the sprites looked at the first spawn() - so a hit-flash or a death scale-up never leaks into the next life. Set-up right after construction (for e in enemies.items: e.flip_y = True) is therefore kept; only .data and the position are left to you.
  • pool.baseline() - re-snapshot the baseline after reconfiguring the sprites later in the game (bigger rocks on level 3).
  • 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)