App & Game Development Guide — API 3 (Lua)
This is the complete beginner-to-release guide for software running on ESP32 DoomsDay firmware 4.2.6 / Phase 4.2. It covers creating a project, understanding its files, writing real Lua 5.4, drawing a usable 128 × 128 interface, requesting permissions, adding sprites and streaming backgrounds, saving data, building an .eft package, installing and updating it — on-device or straight from the online App Store — testing it, and diagnosing common errors.
API 3 is the current app contract, and it runs real Lua, not a custom bytecode language. An app is distributed as one .eft ZIP-STORE archive containing a stable UUID manifest, a main.lua entry script, a graphical icon, and optional sprite or streaming-background resources. Installation validates and extracts the package transactionally into the protected /apps tree on the SD card, commits the registry, and leaves the portable source archive untouched. Each installed UUID also receives isolated storage under /appdata. Apps stay sandboxed: hardware and storage services are available only through the doomsday.* Lua API and the user's per-app permission policy — apps never receive raw SD paths, GPIO, sockets, or native memory.
This is the reference used to build the 65 example apps and games shipped in examples/apps. If you only want a working template fast, copy the closest existing game and adapt it — see Reference apps at the end.
What you need
App development does not require compiling or flashing the firmware.
- A checkout of this firmware repository.
- Python 3.10 or newer available as
python. - A plain-text editor that preserves UTF-8 (VS Code, Notepad++, etc.).
- A FAT32 SD card for installation on the device.
- Firmware 4.2.6 / Phase 4.2 or newer (API 3 only — see API contract below).
Open a shell in the repository root, the directory containing tools, examples, and this guide.
python --version
python tools/new_eft.py --help
No pip install is required for packaging. PlatformIO is only needed to change the firmware itself, not to write an app.
Ten-minute quick start
Create a project. The slug becomes the source directory and archive name:
python tools/new_eft.py field-notes --name "Field Notes" --category Utilities --description "Compact field counter and notebook" --permissions storage
The scaffold creates:
examples/apps/field-notes/
manifest.ini identity, metadata, permissions, entry point
main.lua your Lua source
assets/
icon.sprite editable 16 x 16 icon source
Build it:
python tools/build_eft.py examples/apps/field-notes
Successful output looks like:
Built dist/apps/field-notes.eft [Field Notes 0.1.0 | <uuid>]
The builder compiles assets/icon.sprite into assets/icon.dsi, validates the manifest and script, writes a deterministic ZIP-STORE package, and verifies its CRCs. Do not manually zip or rename a normal ZIP as .eft.
Get it onto a device in either of two ways:
- Offline: copy
dist/apps/field-notes.eftto a user-managed SD folder (never/apps,/appdata, or/system), then open it from File Manager on the device and choose Install. - Online, once published: submit it through the developer portal at esp32dooms.day and, once approved, any device can install it straight from App Store over Wi-Fi with no SD card handling at all. See App Store & online updates for how that pipeline works end to end.
An installed app's management screen (Apps) provides Launch, Permissions, Reset private data, Details, and Uninstall. If it requests a capability, choose Allow Once, Always Allow, or Deny Once at launch.
Your first Lua app, explained
The generated main.lua is already runnable:
local score = 0
while true do
display.clear()
display.title_bar("Field Notes")
display.text_c(46, colors.ACCENT, "Hello from API 3")
display.text_c(61, colors.PRIMARY, "Edit main.lua")
display.num(58, 82, colors.GREEN, score)
if btn.pressed("SELECT") then
score = score + 1
end
sys.yield(50)
end
Read it as a real event loop, because it is one:
- The whole script runs inside a Lua coroutine that the firmware resumes every frame; there is no separate
init/tickcallback pair to implement. display.clear()redraws the active theme background.display.title_bar("Field Notes")opts into the firmware-style title bar. This is optional — unlike the old DAS contract, a Lua app gets the full 128 × 128 canvas by default with no chrome reserved unless you draw it yourself.display.text_c(y, color, text)centers text at an absolute y coordinate.display.num(x, y, color, value)draws an integer.btn.pressed("SELECT")consumes one debounced press;btn.held("SELECT")reads the live level instead.sys.yield(ms)presents the frame and pauses. Every app must yield regularly — the runtime allows 50,000 interpreted instructions per resume before it force-yields, so tight loops without a yield inside them will stall input handling even if they don't crash.
Edit a string, rebuild, replace the .eft on the card (or bump version and republish to the App Store), and reinstall. An update is recognized by the manifest's id (UUID), never by filename or display name.
Identity rule: keep the generated
idforever for updates to this app. Changing it creates a different app. Never copy another app's UUID.
Recommended edit/build/test loop
- Edit
manifest.ini,main.lua, and any.sprite/asset sources. - Save as UTF-8.
- Build with
python tools/build_eft.py <project-directory>. - Fix every builder error rather than hand-editing the archive.
- Run the interactive emulator for fast iteration:
python -m App_Emulator examples/apps/<slug>(see Testing without hardware). - Stress-test loop/interactive logic:
python tools/stress_lua.py examples/apps/<slug> --frames 6000 --seed 1. - Copy the built
.eftto SD and reinstall, or push a new version through the App Store. - Test denied, one-time, and always-allowed permission paths if the app uses services.
- Increase
versionfor every release while preservingid.
Filesystem and lifecycle
Portable archive examples:
/Downloads/orbit-runner.eft
/My Apps/climate-deck.eft
Framework-owned state:
/apps/
registry.txt
<uuid>/
manifest.ini
main.lua
assets/...
/appdata/
<uuid>/
values.ini
save.bin
/system/
app_permissions.ini
Do not place archives inside /apps, /appdata, or /system — File Manager protects those trees from rename, delete, paste, and installation mutations. Installation stages into a temporary path, commits the registry atomically, and cleans up interrupted transactions after a reset, power loss, or card removal. Uninstall removes the installed payload, private values, and the save blob, but never deletes the portable .eft archive that produced it.
Installed applications have no fixed count limit — /apps/registry.txt is an SD-backed catalog, and firmware keeps only a small rotating manifest cache (12 entries) in RAM. SD capacity and package validation are the practical limits.
Manifest
id=46edbae1-ddbc-40cb-affc-9b9061e65551
name=Orbit Runner
version=1.0.0
author=Doomsday SDK
category=Games
description=Sprite runner with persistent scores
icon=assets/icon.dsi
api=3
permissions=storage
entry=main.lua
id: canonical lowercase RFC-4122 UUID. This is the sole install/update identity. Generate it once; never reuse it.name: 1–24 printable characters.version: 1–16 printable characters. Semantic versioning is recommended and is what the App Store uses to decide whether an install shows as Update.author,category: 1–24 / 1–16 printable characters.description: 1–48 printable characters.api:3. The current firmware'sAppManagerrequires API 3 — there is no API 2 compatibility path in this build.permissions:none, or a comma-separated subset ofstorage,sensors,system,wifi,radio,internet,bluetooth,files.entry: normalized relative path to your Lua script, up to 48 characters — almost alwaysmain.lua.icon: normalized relative.dsipath, up to 48 characters, resolving to exactly one 16×16 frame.
manifest.ini must sit at the project/archive root and stay below 1536 bytes. Paths use /, are relative to the root, and cannot contain absolute prefixes, .., backslashes, drive prefixes, or control characters.
Generate a fresh UUID any time you need one:
python -c "import uuid; print(uuid.uuid4())"
internet is a live capability (the async net.* GET API — see below). bluetooth and files are reserved permission names for future services — they expose no Lua functions in API 3, so a new app should not request them.
Permission model
Every declared capability appears under Apps → select app → Permissions. The device stores a policy by UUID on that SD card:
- Deny never grants the service.
- Ask (default) prompts at every launch and grants only for that launch session.
- Allow grants the service whenever that UUID launches from that card.
When a capability is Ask, launch presents Allow Once, Always Allow, or Deny Once. BACK cancels the whole launch. An operation reached without a launch-time grant stops the app with a controlled Permission denied fault — it never silently substitutes a value.
| Capability | Grants | Lua namespace |
|---|---|---|
storage | This UUID's private key/value store, calendar events, and the save blob | kv.*, save.* |
sensors | Read-only AHT20/BMP280 environmental readings | sensor.read() |
system | Read-only bounded device stats | sys.info() |
wifi | Read-only results of the firmware's latest Wi-Fi scan | wifi.* |
radio | Current Wi-Fi channel and controlled channel selection | radio.* |
internet | Read-only HTTP(S) GET requests over the Wi-Fi station link | net.* |
bluetooth | Reserved policy slot | none in API 3 |
files | Reserved policy slot | none in API 3 |
none | Drawing, input, date/time, sprites, math | no service calls |
No permission grants raw SD paths, flash writes, GPIO, or firmware-code access.
The Lua API
Apps run as a Lua 5.4 coroutine (built with LUA_32BITS: 32-bit integers, single-precision floats). The standard library is deliberately narrowed: base, coroutine, table, string, math, and utf8 are available; io, os, package, debug, require, dofile, loadfile, load, collectgarbage, and string.dump are stripped. There is no module system — every app is one self-contained main.lua.
Everything device-specific lives under the doomsday table, with short global aliases for the common ones: display, draw, sprite, background, btn, sensor, kv, save, sys, wifi, radio, colors.
Display
display.clear()
display.size() -- 128, 128
display.metrics() -- text cell width/height, title height
display.title_bar("Title"[, show_back]) -- optional; apps own the full canvas otherwise
display.text(x, y, color, "text")
display.text_c(y, color, "centered text")
display.text_r(x, y, color, "right-aligned text")
display.text_width("text")
display.num(x, y, color, value)
Drawing primitives
draw.rect(x, y, w, h, color)
draw.fill_rect(x, y, w, h, color) -- args 3/4 are width/height, not x2/y2
draw.line(x1, y1, x2, y2, color)
draw.circle(x, y, r, color)
draw.fill_circle(x, y, r, color)
draw.fillRect/draw.fillCircle exist as camelCase compatibility aliases.
Buttons
btn.pressed("UP"|"DOWN"|"LEFT"|"RIGHT"|"SELECT") -- edge-triggered, consumes the press
btn.held("UP"|"DOWN"|"LEFT"|"RIGHT"|"SELECT") -- live level
Names are case-insensitive; "OK" aliases "SELECT". BACK is intercepted by the firmware before Lua ever resumes — a short press always exits to the launching screen and cannot be read by the app.
Sprites
Fixed-size 16×16-to-128×128 art with up to 32 frames, loaded fully into RAM:
sprite.load(slot, "assets/player.dsi") -- slot 1..6
sprite.size(slot) -- width, height, frame_count
sprite.draw(slot, x, y, frame[, flip][, hue])
sprite.unload(slot)
Up to 6 slots, 512 KiB combined budget, 192 KiB per resource. flip and hue are optional — flip mirrors the frame, hue rotates its color without needing separate art. Load once during setup, not every frame.
Streaming backgrounds
For wide scrolling world art that would never fit the sprite RAM budget:
background.load("assets/world.dsib")
background.size() -- width, height
background.draw(camera_x[, y])
background.unload()
background.* streams only the visible column window from SD on each draw call, so one asset can be up to 8192 × 128 px at near-zero RAM cost — this is a separate system from sprite.* and doesn't count against its budget. Build one with tools/png_sprite_import.py in background mode.
Sensors, system, and radio services
local s = sensor.read() -- { temp, humidity, pressure, altitude, dewpoint, feels_like, valid }
local info = sys.info() -- { heap_kb, psram_kb, sd_free_kb, uptime_s }
sys.millis()
sys.date()
sys.yield(ms)
sys.led(r, g, b)
local n = wifi.count()
local ap = wifi.get(index) -- { ssid, bssid, rssi, channel, security }
wifi.congestion(channel)
wifi.best_channel()
radio.get_channel()
radio.set_channel(1) -- 1-13, requires the `radio` permission
sensor.read() requires hardware to actually be present — check s.valid before trusting temperature/humidity/pressure fields. wifi.* is read-only over the firmware's most recent scan; an app cannot trigger a scan itself.
Persistent storage
Two independent stores, both gated by the storage permission:
-- integer key/value store: up to 64 keys, 4096-byte quota per app
kv.set("best_score", 1200)
local v = kv.get("best_score", 0) -- second arg is the default
kv.delete("best_score")
-- calendar-style events
kv.set_event(year, month, day, type) -- type 0 removes the event
kv.get_event(year, month, day)
-- one opaque blob, up to 2048 bytes — the right place for save-game state
-- that doesn't map cleanly onto individual integer keys
save.write(serialized_string)
local data = save.read()
save.exists()
save.clear()
save and kv are separate files on SD; updating an app preserves both, and Reset private data in Apps clears both for that UUID.
Internet (internet permission)
Apps can make read-only HTTP(S) GET requests. The API is asynchronous — you start a request, then poll it every frame — because the per-frame loop must never block on the network.
if not net.online() then -- true only when a Wi-Fi STA link is up
display.text_c(60, colors.DIM, "No connection")
return
end
-- start a request: handle is 1..2, or nil + a reason string
local handle, reason = net.get("https://api.example.com/data.json")
-- reason is "offline" | "busy" | "bad url" | "denied"
-- poll the handle every frame until it resolves:
local state, code, body = net.poll(handle)
if state == "pending" then
-- still in flight, keep drawing your UI
elseif state == "ok" then
-- code = HTTP status, body = response text (<= 32 KB); the slot is freed
elseif state == "error" then
-- code = message ("denied" | "timeout" | ...); the slot is freed
end
- At most 2 requests in flight at once;
net.getreturnsnil,"busy"beyond that. Response bodies are capped at 32 KB — request a narrower query rather than a large body. Onlyhttp://andhttps://are allowed, and the URL must be ≤ 296 bytes. - Fetches run on a background task, so rendering never stalls. Poll every frame until
ok/error; both free the slot for reuse. - Denial is graceful, not fatal. With the permission declined,
net.online()returnsfalse,net.getreturnsnil,"denied", andnet.pollreturns"error","denied"— never a Lua error. An app that checksnet.online()each frame simply falls back to its offline screen. Tell "denied" from "offline" by the reason string, not bypcall. - Trust posture: these fetches use TLS without certificate pinning (like the built-in weather widget), so a man-in-the-middle can feed bad data but never code — unlike the OTA/App Store path, which pins a root. Treat every fetched byte as untrusted and validate it before use. There is no JSON parser in the sandbox; anchor on
"field":and pull values withstring.match.
The instrument reference apps (flight-radar, weather-station, spectrum-analyzer, signal-compass) all lean on this API — read them for real polling, viewport-driven fetch, and offline-fallback patterns.
Colors
colors.BG, BG2, PRIMARY, DIM, ACCENT, SELECT, BORDER track the user's active theme; colors.WHITE, BLACK, RED, GREEN, YELLOW, ORANGE, CYAN are fixed. Prefer theme-aware colors for ordinary UI — the firmware ships 80 themes, and there is no in-app theming API, so a hardcoded color scheme will clash with some of them. Reserve fixed colors for meanings that must not shift, like a hard "danger" red.
Icons and sprites
Every app needs an icon. The scaffold writes assets/icon.sprite; the builder converts every .sprite source into a same-path .dsi binary (format DSI1: a little-endian header followed by frame-major RGB565 pixels) inside the archive.
width=4
height=4
transparent=.
palette .=000000
palette W=FFFFFF
palette C=79E8FF
pixels:
....
.W..
....
....
---
....
.C..
CCC.
.C..
width/height: 1–128, required.palette X=RRGGBB: one character maps to one 24-bit color. Every pixel character must be defined, and each palette character must be exactly one character — a multi-character key silently corrupts the fixed-width row parser, one of the more common mistakes when hand-editing sprite source.transparent=X(optional): pixels using that character are skipped when drawing.pixels:starts rows; each frame needs exactlyheightrows of exactlywidthcharacters.---on its own line starts the next frame (up to 32).
An app icon must be exactly 16×16, one frame. General sprites can be 1–128 px and up to 32 frames. For pixel art beyond hand-typed source, tools/png_sprite_import.py imports a real PNG into .dsi/.dsib, and tools/ascii_sprite_gen.py provides a small procedural-drawing toolkit (rectangles, gradients, brick/scale/shine textures) for generating art from code.
Package format and builder checks
An .eft is a standard ZIP with a deliberately streamable profile:
- ZIP method 0 (
STORE) only — no compression, encryption, or data descriptors. - Every entry verified against ZIP CRC-32 on extraction.
- Absolute paths,
.., backslashes, drive prefixes, and control characters are rejected. - Limits: 64 entries max, 4 MiB per entry, 8 MiB expanded, 10 MiB archive, entry script up to 256 KiB.
manifest.iniat archive root, under 1536 bytes.
The builder produces byte-identical output from identical source (fixed timestamps, sorted entries) and writes to a temp file before atomically replacing the target .eft.
python tools/build_eft.py examples/apps/field-notes
python tools/build_eft.py --all
python tools/build_eft.py examples/apps/climate-deck -o release/apps
--allcurrently stops partway through if it encounters an older example directory with nomain.lua— that's an expected artifact of the examples tree, not a bug to chase. Build a curated list of directories directly when that happens.
Runtime limits
| Limit | Value |
|---|---|
| Instructions per resume before forced yield | 50,000 |
| Lua heap ceiling | 768 KiB |
| Entry script size | 256 KiB |
| Sprite slots | 6 |
| Combined sprite memory | 512 KiB |
save blob | 2048 bytes |
kv store | 64 keys / 4096-byte quota |
| Local variables per Lua function | 200 (Lua 5.4 hard limit) |
Stay comfortably under these, especially the heap ceiling — the runtime tightens Lua's garbage-collector pause specifically to buy real apps more headroom against it, but a script that allocates unboundedly per frame (building new tables in a hot loop, for instance) will still exhaust it.
Testing without hardware
The repository ships a real Lua-VM-backed emulator, not a parser stub:
python -m pip install -r App_Emulator/requirements.txt
python -m App_Emulator examples/apps/field-notes
It runs your actual main.lua under lupa (a real embedded Lua 5.4), renders the live 128×128 output, and accepts mouse/keyboard input mapped to the device buttons, with live reload on save and a one-click packaging button.
For headless/CI-style checks:
python tools/test_app_sdk.py # SDK regression suite
python tools/test_app_emulator.py # emulator harness self-test
python tools/stress_lua.py examples/apps/field-notes --frames 6000 --seed 1
Run the stress runner with a few different --seed values before release — it simulates randomized input and service values and will surface out-of-range sprite slots, frame indices, or unhandled edge cases that a single manual playtest can miss.
Device test checklist
- Install from a FAT32 SD card and launch through Apps — and separately, once published, through App Store over Wi-Fi.
- Test first install and a same-UUID update (bump
version, rebuild, reinstall or republish). - Confirm
kv/savedata survives exit, restart, and update when it should. - Test Reset private data and confirmed Uninstall.
- Test every requested capability as Deny, Ask → Allow Once, Ask → Always Allow, and Deny Once.
- Remove the SD card while the app is running — the firmware should stop it safely.
- Check all text/graphics on the real 128×128 display, including with
title_bar()on and off. - Test at least one ordinary theme and Rainbow; prefer theme-aware colors.
Troubleshooting
| Symptom | Cause and correction |
|---|---|
manifest.ini is missing | Run the builder on the app's project directory, not main.lua or a parent folder. |
| Missing API 3 field | Add category, description, and permissions alongside the common fields. |
| UUID is invalid/noncanonical | Generate a lowercase UUID4 once; no braces or uppercase. |
| Duplicate UUID | Two projects passed to one build share an identity — assign a new UUID to the genuinely different app. |
| Entry/icon missing | Manifest path must match the packaged, case-sensitive relative output; a .sprite source is referenced as .dsi. |
| Icon must be exactly 16×16 | Set the icon source to width 16, height 16, one frame. |
| Sprite row/frame error | Every frame needs exactly height rows of exactly width palette characters — check for a multi-character palette key first. |
| Script exceeds instruction budget | A path resumed without reaching sys.yield() inside 50,000 instructions — check for a tight loop missing a yield. |
Permission denied on device | The launch session didn't grant a declared capability. Change policy under Apps → app → Permissions, or relaunch and allow it. |
Sensor service unavailable | Check sensor.read().valid before trusting hardware-specific fields. |
| Invalid Wi-Fi result index | Call wifi.count(), handle zero, and stay within 0..count-1. |
| Private app data quota exceeded | Use fewer kv keys or trim the save blob; delete unused keys. |
| App installs as a second app instead of updating | Its UUID changed. Restore the original UUID if it's really the same app. |
| Device still runs old behavior | Rebuild, reinstall/republish, and confirm the version shown on the Package/App Store screen actually changed. |
| Package doesn't appear/install | Confirm the .eft extension, a FAT32 card, and that the archive lives outside /apps, /appdata, . |
Design and release best practices
- Start from
new_eft.pyor the closest reference app; don't handcraft archives. - Keep one permanent UUID per app across every update.
- Increment
versionbefore distributing a changed package. - Request the smallest permission set the script actually uses.
- Load sprites/backgrounds once during setup, not every frame.
- Write
kv/savedata only on real changes, not every frame. - Probe hardware/Wi-Fi availability and handle the empty case honestly.
- Use theme colors for ordinary UI so the app stays legible across all 80 firmware themes.
- Preserve editable source in version control; distribute only the built
.eft.
Reference apps
examples/apps contains 65 complete, buildable projects — games and utilities alike — that between them exercise every API surface in this guide: sprite animation, streaming backgrounds, persistent kv/save state, sensor probes, and Wi-Fi/radio permission handling. Reading a couple of the closest matches to what you're building is the fastest way to get productive, faster than working from this reference alone.
Readable sources live under examples/apps; built archives land in dist/apps once you run the builder.
Highlights worth reading first:
- Games —
neon-serpent,carrom,pinball,sonic, and dozens more exercise sprites, streaming backgrounds, and packed save state. - Instruments —
flight-radar(live ADS-B + geolocation),weather-station,spectrum-analyzer, andsignal-compassshow theinternetAPI, sensor and Wi-Fi readouts, and honest offline fallbacks. - Utilities —
focus-clock,stopwatch, andpulse-monitorare the smallest complete templates to copy from.
For the deep, game-specific craft knowledge that sits below this API reference — proven patterns, the exact bug classes this project has hit repeatedly (RNG overflow under 32-bit Lua, sprite palette parsing, frame-independent timing, SD-read-per-frame performance traps), and house conventions for save-game structure — read GAME_CONVENSIONS.md at the repository root before starting a new game. It's the accumulated, field-tested companion to this guide.
Next steps
- App Store & online updates — publish this app and reach every device without touching an SD card.
- Firmware overview — how the runtime that hosts your app actually fits together.
- Getting started — the owner-side install/update flow your users will follow.