Skip to content

Saving & memory

These three helpers handle the parts of a PicoPad game that fight with the device’s tight RAM and flash: keeping a little state across reboots (picogame_save), avoiding MemoryError from a fragmented heap (picogame_arena), and showing sprite sheets too big to load whole (picogame_stream). For the bare signatures see /reference/; for the RAM budget that motivates the last two, see /memory/.

A tiny structured key-value store backed by microcontroller.nvm - a reserved 4 KB flash region on the RP2040 that you can write straight from code.py. Reach for it when you want a high score, unlocked level, or settings to survive a power-off. Unlike the CIRCUITPY filesystem (read-only from code unless you remount in boot.py), NVM needs no boot.py, no reflash, and survives a filesystem wipe.

NVM is a single region shared by every program on the device, so each game passes its own key. The key is hashed into the header and checked on load - if another game or stale data wrote the slot, load() returns your defaults instead of misreading foreign bytes.

You describe your data as a schema: an ordered dict of name -> (struct format char, default). Common chars: "B" 0-255, "H" 0-65535, "I" 0 to 2^32-1; lowercase b/h/i are signed.

  • Save(key, schema, *, offset=0) - create a store. key is your game’s name (str or bytes). offset is keyword-only; bump it only if two coexisting games must use different NVM regions. Raises RuntimeError if NVM is unavailable, or ValueError if the schema does not fit NVM.
  • load() - return a dict of the stored values, or a fresh copy of the defaults if the slot is blank, corrupt, or written by a different game (key mismatch). Never raises on bad data.
  • save(values) - persist a dict. Missing keys fall back to their schema default. Writes a checksum so a later load() can detect corruption.
  • reset() - write the defaults back under this game’s key.
  • defaults() - a fresh dict of just the default values, no NVM read.
import picogame_save
# persist the best lap time (seconds) across reboots
store = picogame_save.Save("ghostrace", {"best_t": ("H", 0)})
best_t = store.load()["best_t"] # 0 = no record yet
# ...later, on a new best run:
if best_t == 0 or secs < best_t:
best_t = secs
store.save({"best_t": best_t}) # survives power-off

Gotchas: every save() erases and rewrites a flash sector, so flash wears out if you call it every frame - save only on meaningful events (game over, new high score, settings change). Keep the schema small; the whole blob (header + fields + checksum) must fit in NVM.

A pre-allocated buffer arena that hands out slices of one big buffer so large surfaces never alloc or free at runtime. Reach for it when a long-running game repeatedly creates big Canvas surfaces and eventually hits MemoryError even though gc.mem_free() looks fine. MicroPython’s GC never compacts the heap, so churning big buffers fragments it: lots of total free RAM, no single contiguous block big enough. The arena grabs one block once, early, while the heap is fresh.

  • Arena(pixels) - allocate the arena. Size is in pixels; it reserves pixels * 2 bytes (RGB565). Do this early, before the heap fragments.
  • canvas(w, h, transparent=None) - a pg.Canvas backed by the next slice (no per-canvas heap alloc); 16-bit aligned automatically. Returns the Canvas.
  • alloc(nbytes, align=1) - a generic memoryview slice of nbytes: reuse it as a file/network read buffer, parse scratch, audio block, etc. align rounds the slice start up (use align=2 for 16-bit data, align=4 for word access). Raises MemoryError if the arena is full. Valid until the next reset().
  • reset() - free all slices handed out so far. Call at the start of each scene that reuses the arena. Any Canvas from before the reset must no longer be drawn.
  • free() - bytes still available in the arena.
import picogame_arena
# one arena for the big canvases, grabbed once while the heap is contiguous;
# scenes that never run at the same time share the bytes (reset each).
ARENA = picogame_arena.Arena(320 * 80) # 320x80 px = 51 200 bytes
def big_canvas(w, h, transparent=None, first=False):
if first:
ARENA.reset() # reuse the arena for this scene
return ARENA.canvas(w, h, transparent=transparent)

Gotchas: this needs firmware Canvas with the buffer= argument - on the simulator that argument is ignored and the sim allocates its own buffer, so the anti-fragmentation benefit only shows on real hardware. After reset(), every surface from the previous batch is dead; drawing one corrupts the new contents.

Plays a big sprite sheet straight from a file on flash, keeping only one frame in RAM. Reach for it when a sheet is too big to import whole - a .mpy import copies the entire sheet to the heap, but StreamSheet opens the file once and readinto()s a single-frame buffer each time you ask for a frame (no per-frame allocation). For a 64x100, 11-frame sheet that is ~6.4 KB resident instead of ~70 KB. The sheet must be frame-major (each frame’s w*h bytes contiguous) - pack it with tools/pack_sheet.py.

  • StreamSheet(pg, path, w, h, frames, palette, transparent=None) - open path, allocate one frame buffer, build a pg.Bitmap (PAL8) over it, and load frame 0. palette is the color table for the PAL8 data.
  • .bitmap - the single pg.Bitmap whose pixels get overwritten in place. Build your pg.Sprite from this.
  • use(i) - load frame i (wrapped modulo frames) into the shared buffer and return the bitmap. Cached: re-reads from flash only when i actually changes.
  • close() - close the underlying file.
import picogame_stream
sheet = picogame_stream.StreamSheet(pg, "jill.bin", 64, 100, 11, PAL, transparent=0)
player = pg.Sprite(sheet.bitmap, x, y)
# ...each frame the animation advances:
sheet.use(frame_index) # stream that frame into the shared buffer
player.touch() # tell the scene to repaint it (pixels changed in place)

Gotchas: always call sprite.touch() after use(). use() overwrites one bitmap buffer in place, but the scene’s dirty-rect engine only repaints a sprite when a tracked property changes (position, frame index, scale, angle, bitmap object). An in-place pixel change is invisible to it, so without touch() a frame change with no movement (a jump apex, walking into a wall) leaves a stale or torn sprite. Sprite.touch() needs firmware from picogame engine 2026-06 or later; on the simulator it is a no-op (the sim repaints fully). See /scene-format/ for how dirty-rect repainting works.