Skip to content

Boot & game loop

These three modules are the spine of every PicoPad game: picogame_game takes over the display and hands you a Scene, picogame_clock paces the frame and gives you dt, and picogame_input turns the board’s buttons into a bitmask with edge detection. A typical code.py calls picogame_game.setup() once, makes a Buttons() and a Clock(), then loops: poll, update, refresh, tick. For the bare signatures see /reference/.

The boot scaffold. Call it once at the top of your game to disable displayio auto-refresh, clear the root group, allocate the two strip buffers, and build a Scene for you - so you skip the rendering boilerplate. Reach for it whenever you want a game-style takeover of the screen rather than a normal displayio UI.

  • setup(display=None, strip_h=24, background=0, fast=True, top=0, bottom=0, left=0, right=0, rgb444=False) - takes over the display and returns the tuple (scene, buffer_a, buffer_b). It sets display.auto_refresh = False, clears root_group, allocates two full-width x strip_h strip buffers (each width * strip_h * 2 bytes), and builds the Scene. Keep all three returned values alive for the whole game - the buffers must not be garbage-collected.
    • display - the displayio display; defaults to board.DISPLAY.
    • strip_h - height in pixels of each render strip. Smaller uses less RAM, larger can mean fewer SPI transactions. See /memory/ for the trade-off.
    • background - fill colour behind the scene, an RGB565 int. Build one with pg.rgb565(r, g, b).
    • top / bottom / left / right - reserve a border (px) the scene won’t render into, so it paints only the inner play rect. You draw that border yourself (a HUD bar, side panels) once, and it is never recomputed per frame.
    • fast - True uses the platform fast pg.Display (async DMA where available); False drives the plain busdisplay via the portable renderer (correct everywhere, slower). On a port without the DMA backend, setup falls back automatically.
    • rgb444 - True sends 12-bit RGB444 instead of 16-bit RGB565 (~25% less SPI traffic, 4096 colours), fast Display only and needs a 12-bit-capable controller (ST7789/ST7735, not ILI9341). rgb444="auto" enables it only where the board reports pg.RGB444_SUPPORTED, so one codebase runs optimally on ST7789 and safely on ILI9341.
import picogame as pg
import picogame_game
BG = pg.rgb565(20, 24, 30)
scene, bufA, bufB = picogame_game.setup(background=BG, strip_h=16, top=12)
# scene is a pg.Scene; add sprites and refresh it each frame
scene.add(sprite)
scene.refresh()

Gotchas: hold onto scene, bufA and bufB for the life of the game - if the buffers are collected, rendering breaks. The border you reserve with top/bottom/left/right is yours to paint; setup only stops the scene drawing there. See /scene-format/ for what Scene expects and /hardware/ for per-board display notes.

Frame timing. Clock caps your loop to a target FPS and returns the real dt (seconds elapsed), so movement can be frame-rate independent and a quick D-pad tap doesn’t fling a sprite across the screen at high FPS. FixedStep is the alternative when you want deterministic logic: it runs your update in equal-sized steps regardless of render time. Use Clock for most games; reach for FixedStep when physics or collision must be reproducible.

Clock:

  • Clock(fps=30, max_dt=0.1) - cap the loop to fps (use 0 for uncapped) and clamp the returned dt to at most max_dt seconds, so a pause or stall can’t produce a giant dt that teleports everything.
  • tick() - sleeps until the frame boundary, then returns the real dt in seconds since the last tick(). Anchors to the ideal schedule so a small oversleep can’t accumulate into drift; if you ran over budget it anchors to real time instead, keeping dt accurate. Call once per frame.
  • tick_async() - awaitable variant that yields to other asyncio tasks during the idle wait instead of blocking. Needs the asyncio library available (raises RuntimeError otherwise). Note rendering itself is blocking, so async only helps in the cap-sleep gap.
  • set_fps(fps) - change the target FPS on the fly (e.g. menus at 30, action at 60). 0 uncaps.

FixedStep:

  • FixedStep(step_fps=60, max_steps=5) - fixed timestep of 1/step_fps seconds, running at most max_steps logic steps per frame (the cap avoids a “spiral of death” when rendering can’t keep up - backlog is dropped).
  • step_count() - returns how many fixed steps to run this frame (0..max_steps). Loop for _ in range(step_count()) and use the constant self.dt; this form allocates nothing, good for hot loops.
  • dt - the constant step duration in seconds. Pass it to your update.
  • steps() - generator form yielding self.dt per step. Convenient, but allocates a generator each call; prefer step_count() in the main loop.
import picogame_clock
clock = picogame_clock.Clock(30) # cap to 30 FPS
while True:
dt = clock.tick() # sleeps to the frame boundary, returns real dt
player.x += player.vx * dt # frame-rate independent movement
scene.refresh()

Gotchas: call tick() exactly once per frame, at the bottom of the loop. If you ignore the return value you still get the FPS cap, but lose frame-rate independence - fine for a fixed-feel grid game, not for smooth motion. tick_async() only buys you anything if rendering can overlap other tasks; the render call still blocks.

Buttons. Buttons reads the board’s physical buttons into a logical bitmask with edge detection (just-pressed / just-released) and auto-repeat, picking a backend per board automatically: the CircuitPython keypad module (hardware debounce, background scan, event queue, so quick taps are never missed) where present, else digitalio polling - same API either way. Timer is a small decaying frame counter for input-leniency tricks (coyote time, jump buffering). Reach for Buttons in every game; add Timer when an action game needs to feel forgiving.

Logical buttons are exposed both as module constants and as attributes on the instance: UP, DOWN, LEFT, RIGHT, A, B, X, Y, L1, L2, R1, R2, START, SELECT, plus ALL. The PicoPad maps the eight face buttons; absent buttons (no shoulders) simply never fire. They are bit flags, so you can OR them: btn.A | btn.B.

Buttons:

  • Buttons(profile=None, pull=None, prefer_keypad=True, debounce_s=0.02) - build the reader. With profile=None the pin map is resolved highest-wins: an explicit profile, then settings.toml PICOGAME_BUTTONS = "UP=GP2 A=GP12 ..." (remap a custom Pico with no reflash), then a built-in profile by board.board_id, then the PICOPAD fallback. pull defaults to Pull.UP (or PICOGAME_PULL in settings.toml). debounce_s is the keypad scan window; prefer_keypad=False forces polling.
  • poll() - sample all buttons once and return the current pressed bitmask. Call once per frame, before any query. Drains the keypad event queue (catching sub-frame taps) or reads the pins directly, and updates held-frame counts for repeat().
  • is_pressed(mask=ALL) - True if any button in mask is currently down (level).
  • just_pressed(mask=ALL) - True on the rising edge (the frame a button went down). On the keypad backend this comes from the event queue, so a tap shorter than a frame still registers.
  • just_released(mask=ALL) - True on the falling edge (the frame a button came up).
  • has(mask=ALL) - True if this board physically wires the given button(s). Use it to adapt controls/UI to boards without shoulders or START/SELECT.
  • repeat(button, delay=15, interval=4) - auto-repeat for a SINGLE button: True the frame it’s pressed, then every interval frames once held delay frames. Ideal for menu and grid movement.
  • clear() - reset state and flush pending input. Call on scene or menu transitions so a held button doesn’t leak across.

Timer:

  • Timer(frames) - a counter that decays one frame at a time over frames frames.
  • feed(condition) - recharge to full when condition is true, else count down one frame; returns whether still active. Use for coyote time (feed(on_ground)).
  • charge() - force the timer to full.
  • is_active (property) - True while the counter is above zero.
  • consume() - True once if active, then clears it, so a buffered press fires exactly once (jump buffering).
import picogame_input
btn = picogame_input.Buttons() # auto profile by board
while True:
btn.poll()
dx = btn.is_pressed(btn.RIGHT) - btn.is_pressed(btn.LEFT) # -1, 0 or +1
if btn.just_pressed(btn.A): # rising edge: fire once per tap
jump()
scene.refresh()

Coyote time and jump buffering, straight from the platformer example:

coyote = picogame_input.Timer(5) # still jump a few frames after a ledge
jbuf = picogame_input.Timer(6) # honour a jump pressed just before landing
# each frame:
coyote.feed(on_ground)
jbuf.feed(btn.just_pressed(btn.A))
if coyote.is_active and jbuf.consume():
jump()

Gotchas: always poll() once per frame before querying, or is_pressed / just_pressed read stale state. repeat() takes a single button, not an OR-mask. The digitalio polling backend is undebounced (per-frame sampling already filters sub-frame bounce); for a noisy switch on a keypad-less build, debounce upstream. See /hardware/ for board profiles and the settings.toml remap keys.