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/.
picogame_save
Section titled “picogame_save”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.keyis your game’s name (str or bytes).offsetis keyword-only; bump it only if two coexisting games must use different NVM regions. RaisesRuntimeErrorif NVM is unavailable, orValueErrorif 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 laterload()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 rebootsstore = 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-offGotchas: 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.
picogame_arena
Section titled “picogame_arena”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 reservespixels * 2bytes (RGB565). Do this early, before the heap fragments.canvas(w, h, transparent=None)- apg.Canvasbacked by the next slice (no per-canvas heap alloc); 16-bit aligned automatically. Returns theCanvas.alloc(nbytes, align=1)- a genericmemoryviewslice ofnbytes: reuse it as a file/network read buffer, parse scratch, audio block, etc.alignrounds the slice start up (usealign=2for 16-bit data,align=4for word access). RaisesMemoryErrorif the arena is full. Valid until the nextreset().reset()- free all slices handed out so far. Call at the start of each scene that reuses the arena. AnyCanvasfrom 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.
picogame_stream
Section titled “picogame_stream”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)- openpath, allocate one frame buffer, build apg.Bitmap(PAL8) over it, and load frame 0.paletteis the color table for the PAL8 data..bitmap- the singlepg.Bitmapwhose pixels get overwritten in place. Build yourpg.Spritefrom this.use(i)- load framei(wrapped moduloframes) into the shared buffer and return the bitmap. Cached: re-reads from flash only wheniactually 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 bufferplayer.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.