SearchA-ZP › Playdate (Lua)

Playdate (Lua)

2022 MIT (fengari) · PD/CC0 games Online

This plays Playdate Lua games in your browser. A JavaScript Lua VM (fengari) runs the game's Lua, and the Playdate SDK API is reimplemented in JavaScript — 1-bit graphics, the mechanical crank, sound and a Lua-level debugger. It is a VM-level high-level emulation: it does not emulate the Cortex-M7 silicon, so there is no chip page here. It ships an original CC0 demo plus public-domain games.

Visit the official site ↗

Runs on: Web browser

Playdate (Lua) Online Emulator

Play Playdate (Lua) using JavaScript directly in your browser.

Configurations

ConfigurationEmulatorMachineOSLegal
CrankitPlaydate (Lua)Panic PlaydateopenOpen ⛶
TennisPlaydate (Lua)Panic PlaydateopenOpen ⛶
SnakePlaydate (Lua)Panic PlaydateopenOpen ⛶
Hello PlaydatePlaydate (Lua)Panic PlaydateopenOpen ⛶

Machines emulated

Notes

Embedding

The emulator is an authored, clean-room stack: a vendored Lua VM plus our own JavaScript implementation of the documented Playdate SDK. No proprietary Panic firmware or SDK code is used.

  • Lua VM. fengari — a pure-JavaScript port of Lua, vendored as vendor/fengari-web.js (MIT). Being pure JS with no wasm heap, it runs from file:// under the site's strict CSP. It implements Lua 5.3; the Playdate device runs a Lua 5.4-based build, so a small pdc-style preprocessor rewrites the two constructs games rely on that 5.3 lacks: the <const> variable attribute (stripped) and the += -= *= /= %= compound-assignment operators (rewritten to plain Lua).
  • The SDK, in JavaScript. playdate-runtime.js owns a 400x240 one-bit framebuffer, the drawing primitives, an 8x8 bitmap font, a small polyphonic synth, and the input/crank state, and exposes them to Lua as a table of native functions. playdate-prelude.js is our own Lua that builds the playdate (alias pd) surface on top of those primitives — graphics, a sprite system, timers, an in-memory datastore, the system menu, and a from-scratch class() object model. import is a no-op because every CoreLibs piece is preloaded here.
  • The loop. Each game update runs inside a fresh Lua coroutine; the host resumes it once per playdate.update(), mixes one video frame of audio to EmuAudio, and blits the framebuffer. Running each frame in a coroutine is what lets the debugger pause and single-step Lua (below).
MemberKindWhat it does
pd.loadGame(src,name)methodPreprocess + load + run a game's Lua source, defining playdate.update and building the source/line tables.
pd.resumeFrame()methodAdvance the current update's coroutine to completion, a breakpoint, or an error; returns 'done' | 'paused' | 'error'.
pd.stepLine()methodAdvance exactly one Lua SOURCE line (single-step), via a line hook that yields the coroutine.
pd.curLine / callDepth() / localsAt(0)stateThe line the VM is on, the call depth, and the current frame's live locals — read by the debugger.
pd.fbfieldThe 400x240 1-bit framebuffer (1 = black ink), presented to the canvas at 2x.

Debugger integration

playdate-lua-debug.js reads window.EMU_BOOT and hands the shared debugger a Lua-level machine: the Lua VM plays the role of the "CPU". Every feature is a host-side check around the coroutine, with no change to Lua semantics.

  • Single-step Lua. Step runs pd.stepLine(), which sets a LUA_MASKLINE hook that calls lua_yield after one source line, so each Step advances the game by exactly one line of Lua.
  • Breakpoints on Lua lines. The disassembly view shows the game's Lua source through the new lua decoder (debugger/src/cpus/lua.js), one line per row. Clicking a line toggles a breakpoint; the line hook compares the current line against that set and pauses on a match. Byte offsets map to line numbers through a source line-offset table.
  • The Lua stack & locals. While paused, registers() walks the paused coroutine with lua_getstack / lua_getinfo / lua_getlocal to show the current LINE, call DEPTH, and the current frame's numeric locals (L0..). The d-pad / A / B / dock state and the CRANK angle and FRAME count are shown live too.
  • Memory views. Two chips: the Lua source image (source-line disassembly + hex) and the 400x240 1-bit framebuffer (bits / hex / tiles), packed 1bpp so the tiles view renders the screen contents.
  • Not applicable. A Lua VM has no flat addressable RAM to watch, so hardware-style write watchpoints are intentionally empty; line breakpoints are the equivalent. There is no machine register file — the "registers" are Lua state.

Architecture

The Playdate is a small yellow handheld by Panic: a 400x240 one-bit (black/white) reflective screen with no backlight, a d-pad and A/B buttons, a mono/stereo speaker, and its signature feature — a mechanical crank that folds out of the side and reports its absolute angle. Games are written in Lua (or C) against the playdate library and run at a 30–50 fps refresh rate.

  • Display. One bit per pixel. This emulator keeps a 400x240 byte-per-pixel buffer, draws into it with rectangle / line / circle / text primitives, and presents it to the canvas at 2x with nearest-neighbour scaling and a Playdate-like light panel tint.
  • Crank. A rotary control reporting an absolute position in degrees and a per-frame change. Here it is driven by the mouse wheel, an on-screen dial you can drag in a circle, and a dock/undock toggle — surfaced to Lua as playdate.getCrankChange / getCrankPosition / isCrankDocked.
  • Sound. Games synthesise notes with playdate.sound.synth; this emulator implements a small polyphonic synth (sine / square / saw / triangle / noise with a decay envelope) and mixes it to the shared audio sink.
  • Coverage. The commonly-used graphics / sprite / timer / input / sound / datastore / menu surface is implemented; asset-loading (image / font / sample files), physics and the fuller sprite/collision engine are stubbed or simplified. See README.txt for the exact implemented-vs-stubbed list.

Sound

Sound uses Pattern S (an authored core): the SDK's synth is our own small polyphonic engine in playdate-runtime.js. Each active voice is a sine / square / sawtooth / triangle / noise oscillator with a linear decay envelope; playdate.sound.synth:playMIDINote(note, vol, len) (note number or name like "C4") pushes a voice. Every video frame the mixer produces exactly Math.round(EmuAudio.sampleRate / 60) interleaved-stereo Int16 sample pairs at the context rate (so no resampling) and calls EmuAudio.push().

Mute contract. window.EMU_BOOT.transport.isMuted() / setMute() delegate straight to EmuAudio; it starts muted (browsers block audio before a gesture) and the on-page Sound button unmutes from a real click.

Sound. Sound is fully supported and plays whatever the software makes. It starts muted, because browsers block audio before a gesture, so click the Sound button to hear it.