Playdate-Pi architecture — component walkthrough
Playdate-Pi architecture — component walkthrough
This document explains how the emulator works, component by component, so
another developer understands the flow and extends it. Every section follows
the same pattern: goal → data → diagram → code pointers → traps.
1. Game loading (pd/pdx.py)
Goal
Bring the compiled game (.pdx) to three usable things: the bytecode of
main.pdz (and submodules like dialog.pdz, options.pdz), the assets
(images/fonts/audio) and the metadata (pdxinfo, palette).
Input data
.pdx= a folder withmain.pdz,pdxinfo, and subfolders
(images/,sounds/, ...) containing.pdi/.pdt/.pft/.pda..pdz= binary chunk container. Each chunk has a header withid
(4 bytes),flagsanddata_size. Flags:FLAG_COMPRESSED = 0x80→ payload is compressed (zlib).FLAG_ENCRYPTED = 0x40000000→ AES-block encrypted (not supported;
Catalog games carry this bit and are not attempted).
Flow
.pdx game folder
│ main.pdz ──► pd/pdx.py: deserialize chunks
│ ├─ flag COMPRESSED → zlib.decompress
│ └─ flag ENCRYPTED → [unsupported, soft abort]
│ pdxinfo ──► name, version, bundle id, content
│ images/a.pdi ──► decoder (see §4) -> pygame.Surface with
│ alpha mask (SRCALPHA)
│ sounds/x.pda ──► .pda decoder (see §3) -> numpy samples
│ fonts/f.pft ──► .pft decoder -> glyphs (poverty/B40)
└──► FileStore (name -> bytes) resolved WITHOUT EXTENSION
Asset resolution (key point)
Games request playdate.graphics.image.new("images/fondo") without an
extension. The FileStore in pd/filestore.py keeps an index
name_without_extension → bytes and returns whatever asset matches. If the
exact asset is missing, it reports a warning (so you see what's missing)
and returns something harmless (or nil depending on the game build).
Running compiled code
main.pdz is not text: it is Lua 5.4 / 32-bit bytecode. pd/luavm.py
makes lupa load it with a patched deserializer (patch_lundump) that fixes the
32/64-bit differences. Submodule resolution via import "CoreLibs/*" is
forwarded to the SDK folders when the .pdx does not ship them. Full detail in
docs/COMPILED_GAMES.md.
Traps
- Never add an extension when requesting an asset (the game doesn't).
- Do not read the
.pdzas text; use the chunk deserializer. - An encrypted
.pdzis NOT "fixed by trying to decompress": soft-abort with a
warning.
2. Bytecode interpretation and loop (pd/luavm.py, pd/runtime.py)
Goal
Run the game's update/draw 30 times per second inside the same Lua 5.4
interpreter as the console, translating each playdate.* call to Python.
Data
- A lupa
LuaRuntimewith the builtins games request
(math,string,table,os,bit...). - The loop lives in
pd/runtime.py:
call_update()→ runs the coroutineplaydate.update();
call_draw()→ runs the coroutineplaydate.draw().
Per-frame loop
for frame in range(N):
feed buttons (input) # pd/input.py
feed crank # if the user rotates it / joystick
call_update() # runs playdate.update() in Lua (coroutine)
call_draw() # runs playdate.draw()
screen.render(scale) # 400x240 -> window (nearest-neighbor)
button.end_frame() # clears "pressed this frame"
update() and draw() run as coroutines; lupa resumes them just like Playdate:
they call playdate.graphics.* which writes to the canvas.
Coroutines
Playdate games are written as function playdate.update() which is resumed
by the runtime, not called anew. The emulator stores the coroutine
update/draw created the first time and resumes it each frame.
Traps
- Any
LuaErrormust be propagated (not silenced) to debug the exact frame. - The bytecode is 32-bit; lupa without
LUA_32BITShangs or crashes on
load_buffer. Seedocs/COMPILED_GAMES.md.
3. Audio reading (pd/pda.py, pd/sound.py)
Goal
Play the .pda effects embedded in the game and the music.
.pda format (Playdate Audio ~AUDD)
Header: 12-byte marker ("Playdate AUDD") + rate (3 bytes little endian) +
fmt (1 byte). The format (fmt field) can be:
- PCM 16-bit mono (e.g. the confirm of Fishing Simulator).
- IMA ADPCM stereo (e.g. the pop of Shrimp Boom).
pd/pda.py decodes both to a numpy float32 sample array (values in
[-1, 1]). For ADPCM the standard IMA step table and nibble expansion are
implemented (you must de-interleave the two channels).
Synthesizer (note-driven music/effects)
Many games (Kickflip, ...) carry no .pda: the music is synthesized with
playdate.sound, creating Synth objects and scheduling notes:
Synth:playNote(freq, volume, length, when)
'when' is ABSOLUTE in seconds since the audio engine started
(not relative to the call). Scheduled in a heap by
(absolute_time, synth).
Default waveform = NOISE (the console sounds "crackly"), not sine.
ADSR (attack/sustain/decay) applied over each note.
Example of the note structure (provided by the user): each song is
{id, bpm, name, notes[channels][...], splits, loopFrom, ticks}. notes per
channel are [duration, frequency] pairs; loopFrom is the tick where the
loop starts; ticks the total length in 1/16th ticks.
Diagram
.pda ──► pd/pda.py ─► PCM strided / IMA ADPCM ─► float32 samples
│
Synth/playNote ──────────────────────────────────────► heap queue
│ (absolute when)
▼
pygame.mixer.Sound(samples) -> play()
Traps
- ADPCM stereo: do not forget the two interleaved channels, or the sound
comes out slowed down. whenis absolute: treating it as relative makes everything play at
startup.- The short ADSR (
attack=0.0,release=0.02s) is what gives the typical
"plop". - With a dummy mixer on the Pi (no audio out),
play()must not crash.
4. Building / reading the API (pd/runtime.py, pd/graphics.py)
Goal
The game finds its WHOLE API under playdate.*. The pattern is 2 steps:
- Implement the method in Python (in the corresponding module).
- Register it in
Runtime._build_api().
The playdate table
runtime.build_api()
playdate = {
graphics = { clear, fillRect, drawText, drawImage, newImage,
getImageFromRect, setColor, setDrawOffset, ... },
sound = { sound:newSynth, FilePlayer, playNote, ... },
sprite = { newSprite, addSprites, update, CollisionType... },
geometry = { vector2D, vec2, rect, polygon, affineTransform },
display = { setInverted, setFlipped, ... },
button = { isPressed, wasPressed, ... },
file = { open, read, write, ... },
... callbacks: setUpdateCallback, setCrankCallback, update, draw
}
Each submodule is a flat Lua table whose leaves are Python functions.
The emulator resolves playdate.graphics.foo at call time.
How to find which API a game uses
In tools/ there is a debugger (find_missing_api.py) that runs the game in
isolation and lists what needs to be implemented, sorted by real usage.
Big trap
- lupa turns Python functions into
userdata, notfunction. Any check
withtype(f)=="function"inside Lua skips your functions. Use
if f ~= nilor lupa as a bridge (see §5).
5. Python wrapping — the tricky part (pd/luaobj.py)
Goal / golden rule
Expose the Playdate API to Lua while respecting the type contract that
games actually check. Four rules:
R1. Playdate objects = USERDATA, not tables.
Games do if type(sprite) == "userdata" then ... else (the "user/table" branch
usually handles Lua). If you expose a Python object as a lupa object directly,
lupa delivers it as userdata and the flows take the right branch. Examples:
newSprite(), image.new(), polygon.new(), Synth.
R2. Flat tables with functions on the leaves; never nested objects.
Exposing a Python object nested inside a table sometimes makes lupa lose the
attribute when accessed from Lua after many reads (known lupa bug with chained
objects). Solution: each sub-API is a Lua table with Python functions on
the leaves. No g["playdate"] = python_object.
R3. Lua metamethods, not Python operators.
lupa does not map Python __mul__, __index, __call automatically to
Lua metamethods. For polygon * affineTransform, imagetable[i],
synth:playNote you install metamethods with debug.setmetatable and a
Lua function as __index/__mul. Preserve the existing lupa metatable (do
not replace it wholesale) or the object's methods are lost.
-- pattern (in pd/lib/playdate_ext.lua)
local mt = debug.getmetatable(obj) or {}
local old_index = mt.__index
mt.__mul = function(a, b) return playdate_ext.polygon_mul(a, b) end
mt.__index = function(self, k)
if old_index and old_index ~= mt then return old_index(self, k) end
return playdate_ext.polygon_field(self, k) -- bound Python function
end
debug.setmetatable(obj, mt)
R4. A Python function exposed to Lua is userdata. This showed up already
in tests above: type(polygon.new)=="userdata". Any Lua wrapper that checks
type(f)=="function" skips yours.
Lifetime management
An exposed object is registered in a LuaObjectRegistry (dict
id -> python). Lua keeps userdata with the id; on GC the object is marked
to free the Python side.
lupa gotchas when returning values
- A Python
tupleis unpacked as multiple return values in Lua
(unpack_returned_tuples=True). For ONE value, return a list or wrap it. - A Python list/dict is NOT converted to a Lua table (it is userdata).
To return a real table useself.lua.table_from([...]). - Python
None→ Luanil.
6. Sprites, colors and collisions (summary; see modules)
pd/sprite.py:Spritein Python withimage,center,moveTo,
moveWithCollisions, masks (groupMask,collisionsEnabled).
The callbacksprite:draw(x,y,w,h)receives a translate to the top-left of
itsbounds()and the ink color (the game draws in local "0,0").- Group collision:
setGroups({2,3})receives a Lua table → you must
accept any iterable (not onlylist/tuple), or the masks stay at 0 and the
sprites pass through the walls. moveWithCollisionsreturns 4 values (x, y, collided, overlapCount).- Real constants:
kCollisionType(Slide=0, Freeze=1, Overlap=2, Bounce=3). - Color:
kColorBlack=0(ink),kColorWhite=1(paper).setColor
accepts booleans, 0/1 and literals0x000000/0xffffff(mapped to
ink/paper). Default resolution is400x240, not 320x320. - 1-bit images filled with paper (the cursor hand) are invisible on a
light background:draw_spritedraws them in ink when opaque-paper ≥ 2×
ink so the cursor is visible.
7. Tricks and common bugs at a glance
| Symptom | Root cause | Fix |
|---|---|---|
type(polygon.new)=="userdata" all around |
lupa exposes Python functions as userdata | checks f ~= nil, not type(f)=="function" |
attempt to index a nil value (field 'affineTransform') in vectors |
missing geometry metatable on vector2D |
install a __index Lua function over the userdata |
| sprites pass through walls | setGroups with a Lua table did not enter _groups_to_mask |
accept generic iterables |
| sprites don't draw | the game does not call sprite.update() or the runtime over-called it |
real semantics: only draws if the game calls update() |
| fish/score invisible | imagetable without numeric __index / global white color |
Lua __index metatable; reset color per sprite |
| sounds "beepy" | synth defaults to sine | default = noise (waveform 4) + short ADSR |
| music doesn't play | notes scheduled with relative when |
when absolute in seconds since engine start |
| cursor (hand) missing | 1-bit paper image invisible on light background | draw in ink when opaque-paper ≥ 2× ink |
Development tools
tools/api_coverage.py— which API your game uses and which is missing.tools/headless_test.py— runs N frames and reports the first error with
its frame.tools/find_missing_api.py <game>— lists pending APIs by usage.tools/build_lua32.sh— builds lupa against Lua 5.4 with LUA_32BITS.