Lua · Reference
Every call.
An app is one Lua file. It defines up to four functions and talks to the device through one global table, snail. Everything the language itself offers is standard Lua 5.4, minus the parts listed at the bottom of this page.
The first two lines§
--!name Billy's Blackjack
--!icon game
The launcher reads only these two lines when it scans the card. Running the scripts to draw a menu would parse every app on the card every time you walked back to the launcher. Without --!name the label is the filename; without --!icon it is the document mark. Icon names are on the list below.
The four functions§
start()§
Runs once, when the app is opened. Load saved state here, and set the footer with snail.hint. Optional.
key(k)k is a string§
Runs on a button press. k is one of "up", "down", "left", "right", "ok" and "top". Optional, though an app without it is a page rather than a program.
BACK never arrives here. It always leaves, so an app cannot trap someone on a device that has no task manager.
tick()§
Runs every ms while snail.tick(ms) is in force, and the engine repaints afterwards. An app that never calls snail.tick never ticks. It is bounded by the same fuel and the same byte budget as key(), and an app that errors inside it has its clock switched off rather than being called again twice a second forever. Optional.
draw()§
Runs whenever the screen needs painting. It must be a pure function of the app's state. It can run at any moment and more than once for a single press, so decide things in key() and paint here. Required.
Drawing§
You never name a coordinate. Each call appends to a display list and the engine lays the list out, which is why a card app cannot paint over the header, escape the content band, or land a line half off the panel.
snail.title(s)§
Large type, for the thing the screen is about.
snail.text(s)§
Body text, in the size Settings chose. It wraps and never truncates.
snail.small(s)§
The small face, for labels and notes.
snail.row(label, selected, icon)§
One list row, drawn with the OS's own highlight. selected is a boolean and icon is optional.
The selection is an argument because the app keeps its own cursor. The engine will not invent one. An app that scrolls its own list can also filter it, reorder it, and put two of them on a screen. Draw only the rows that fit — about seven at the default text size — and scroll by moving your own window over the data.
snail.rule()§
A hairline, with air above and below.
snail.gap()§
Vertical space.
snail.image(name)§
One sprite from the art pack beside the script — billy.lua reads billy.art. One sprite is staged from the card at a time, so the pack is never in RAM. See art.
snail.qr(text)§
A QR code, drawn as large as the screen allows. The engine defers a de-ghosting full refresh until the keys go quiet, so codes scan clean without the app asking for anything.
snail.center(on)§
Everything appended after this call is centred, until the frame ends.
Three calls that draw a whole object§
A hand of cards, a game board and a trading card are shapes every app that needs one would otherwise draw itself, and none of them can name a pixel. The app says what the state is and the firmware knows what it looks like.
snail.cards(spec)one string, one row§
A row of playing-card faces. A code is a rank of A 2 3 4 5 6 7 8 9 10 J Q K followed by a suit of S H D C; ? is a card lying face down and - is an empty place, which is what a foundation with no ace in it looks like.
Five cards fill the column. A longer row is drawn narrower rather than off the edge of the panel, and a row that will not fit above the footer is not drawn at all. Spades and clubs are solid and hearts and diamonds are hollow, because the panel is one bit and the colour half of a real deck cannot survive it.
snail.cards("AS 10H ?") -- ace of spades, ten of hearts, one face down
snail.cards("- - - KD") -- three empty places and a king
snail.board(spec, size, banner)rows separated by /§
A tile grid. A cell is # for a solid tile, o for an outline, @ for a mark, * for a filled disc, . for empty ground, or {2048} for a numbered tile of up to five digits. Up to 32 cells a side. Spaces are ignored and rows may be ragged; the grid is as wide as the widest one. Pass "small" as the second argument for an inset grid, which is what a falling-block game draws its next-piece tray with, and a string as the third for a bordered box across the middle — the position stays readable under the words, because at the end of a game the last board is the thing worth looking at.
A board takes every pixel below it, so draw it last and put the score above it. Every tile keeps a gutter of ground a twelfth of its own width, because the panel is one bit and neighbours drawn edge to edge merge into one smear. Build the spec with table.concat: a 17x17 board is 289 cells, and appending to a string in a loop is 289 throwaway strings on every press.
The tile is the largest square that fits both the column and whatever is left of the content band, so one spec draws a 4x4 grid and a 17x17 grid correctly without either game knowing the width of the panel. The grid is centred and its size is an integer division of dimensions that do not move while a game is being played, so a board redrawn after a key press lands on the same pixels and a partial refresh leaves no shadow.
snail.board(".@./###/.o.") -- a mark, a solid row, an outline
snail.board("{2}{4}./..{8}", "small") -- numbered tiles, inset
snail.card{...}one table, one screen§
One full-bleed trading card composed across the whole content band: a two-pixel frame meeting the header and footer rules, the name knocked out of an inverted plate with num on its right, the sprite doubled by nearest neighbour inside a bordered art window, one inverted pill per entry in types, and up to seven stats pairs as bars against a fixed ceiling of 255, so the same stat is the same length on every card. info is one small line along the bottom edge. sprite and name are required.
It takes a table where cards and board take a string, because those two encode a sequence and this is six fields of six kinds. The card is the screen: anything appended after it is not drawn. If the kernel has a status line to show, the card shortens and the art falls back to 1:1 rather than being cropped. is the worked example.
snail.card{
sprite = "025", -- from the art pack, required
num = "No. 025",
name = "Pikachu", -- required, set in capitals
types = "Electric", -- "Water/Flying" draws two badges
stats = "HP 35,ATK 55,DEF 40,SPE 90", -- up to seven pairs, one bar each
info = "Mouse Pokemon HT 0.4 m WT 6.0 kg",
}
Everything else§
snail.tick(ms)returns the interval granted§
Asks for a tick() every ms. snail.tick(0) stops it, leaving the app stops it, and the ceiling is a minute.
The interval is clamped to 600 ms. A partial refresh costs a fixed ~511 ms of waveform whatever it covers and the kernel paints at most once per loop pass, so an app asking for 120 ms would get one painted frame and seven steps of state change nobody saw. The call returns what it granted, so an app can show its real pace.
Turn it off when there is nothing to step. Ticking repaints, a repaint holds the panel's rails up, and the kernel will not light-sleep the chip while they are — a game still ticking after it ends is a device at ~20 mA in a pocket. snake.lua and cascade.lua arm the clock on the first press and drop it when the game is over.
function start()
snail.tick(700) -- step me every 700ms; returns the interval granted
end
function tick()
step() -- change state here; the engine repaints afterwards
if over then snail.tick(0) end
end
snail.after(fn)§
Runs fn once, after this app's first frame is on the glass. start() runs before the kernel has painted anything, so a start() that fetches leaves the previous app on the panel through a Wi-Fi join, a TLS handshake and a request — four seconds in which the user pressed OK and nothing happened.
Load the cache in start() and hand the fetch to snail.after. The pending call is dropped when the app closes, so a fetch cannot outlive the screen that started it.
snail.ink("fast")§
Draws with the short waveform while this app is open. What it gives up is settling time: edges are softer and residue clears less completely. On a well of solid 30 px squares that is invisible; on a paragraph it is not. Call it from start() if your app is snail.board or snail.cards and little else.
The OS spends a full de-ghost refresh every twelfth frame instead of every fortieth while it is in force, plus one on the way in and the way out. The request is dropped when the app closes, so the reading apps are never affected by it.
snail.status(s)§
The wrapped band above the footer. The kernel draws it.
snail.hint(s)§
The footer text, which is where an app says what its buttons do. Usually set in start() and changed when the app changes mode.
snail.save(s)up to 1024 bytes§
Persists one string. snail.load() reads it back, or returns nil the first time.
The blob is keyed on a hash of the app's filename, so it follows the app rather than the slot it landed in, and one app cannot read another's. Renaming the file orphans the old blob.
local function save()
snail.save(string.format("%d %d %d %d", bank, wins, losses, pushes))
end
local function load()
local s = snail.load()
if not s then return end
local b, w, l, p = s:match("(%-?%d+) (%d+) (%d+) (%d+)")
if b then
bank, wins, losses, pushes = tonumber(b), tonumber(w), tonumber(l), tonumber(p)
end
end
snail.load()returns a string or nil§
Reads back what snail.save wrote. Handle nil: that is the first run, and it is also what a card that has been reformatted looks like.
snail.cache(key[, s])read returns value and age; write returns true, or false and why§
The keyed store for the thing an app re-fetches: a document, a listing, a week of readings. save holds one kilobyte, which is the size of a reading position and not the size of a file; a cache entry holds up to 24 KB — the same ceiling a fetch has — in /cache on the SD card.
One name, three calls. snail.cache(key) reads, returning the value and its age in seconds — the age is nil when the clock was not set at either end. snail.cache(key, s) writes. snail.cache(key, nil) forgets. Keys are up to 96 bytes and are namespaced to the app's filename exactly as save is, so one app can neither read nor overwrite another's.
An app gets eight entries and 128 KB. Past either limit the oldest-written entry is dropped to make room. Nothing expires on age: show the cached copy with its age in start(), hand the fetch to snail.after, and give the user a refresh key that fetches and re-caches. Every screen in the OS opens on the card copy this way.
snail.fetch(url)returns body, or nil and why§
An HTTPS GET. The body comes back as one string, and Lua's string patterns are all there is to parse it in that form. Fetching covers what that means for choosing a data source.
snail.fetch(url, "a,b,c", sink)returns head, count — or nil and why§
The same GET, parsed by the engine. sink is called once per row in the reply with those fields as positional strings, and the body is never charged to the app's budget. head is the reply's own top-level fields. A sink returning false stops the walk, which is how an app caps what it keeps. Up to eight field names.
Do not write a JSON reader in Lua. The eight apps that did were each paying about 2.7 KB of their 48 KB for it.
snail.rows(url, "a,b,c", sink)returns head, count — or nil and why§
The same parsed walk under its own name. It takes either a full URL or an a-gnt path, so one call site serves both, and the fields, the sink and the returns are exactly those of the parsed snail.fetch above. One field may wear [] to name an array whose elements the sink receives.
Connected apps§
snail.paired() says whether this device holds a token. snail.connect(name), called inside draw(), is the entire login screen — the wording, the QR code and the short code. The engine draws it rather than the app, because an app that composes that sentence can compose a convincing one around a different address.
snail.connectkey(k), called inside key(), runs the pairing flow one press at a time and returns "idle", "waiting", "done", "cancel", "denied", "expired" or "error". The app keeps the decisions: when to offer connecting, and what to do with a "done".
snail.agnt(path) is an authenticated fetch, and snail.agnt(path, "a,b,c", sink) is the parsed form. The app never sees a token; the engine attaches it.
snail.pair() and snail.paircheck() are the low-level flow underneath: pair starts the protocol and returns the code to show, and paircheck polls it. snail.connect superseded them for the shipped apps; they remain for card apps written against the older API and for a service whose flow the engine has no screen for.
local head, count = snail.agnt("/api/mail/inbox?limit=12",
"from,subject,when", function(from, subject, when)
rows[#rows + 1] = { from = from, subject = subject, when = when }
return #rows < 12 -- false stops the walk
end)
Fetching has the whole flow.
Keys§
| Button | Reaches key() as | Then |
|---|---|---|
| UP / DOWN | "up" / "down" | delivered to key(), then the screen repaints |
| LEFT / RIGHT | "left" / "right" | the same |
| OK | "ok" | the same |
| The side button, double-clicked | "top" | the same |
| BACK | never delivered | always leaves the app |
A held key repeats, so make a repeated press harmless. The panel takes about half a second to redraw, which means the device runs at roughly two frames a second and every press has to be worth a frame.
Art§
An art pack sits beside the script and carries the sprites snail.image names. tools/photo2art.py builds one from an ordinary photograph:
tools/photo2art.py --out billy.art --preview art/billy/preview \
"billy=source.jpg@w=220,crop=520:140:850:460,contrast=1.25,dark=6,sharp=1.6"Read that script's header before choosing settings. The short version is that Atkinson dithering and a hard-crushed dark end are what make a photograph survive one bit, and that cranking contrast is the wrong instinct.
What Lua the engine opens§
Four standard libraries: the base library, table, string and math. Everything in them works as it does anywhere else.
io, os, package and debug are not opened, and dofile, loadfile and collectgarbage are removed. A card app cannot open a file, load a C module or reach the debug interface. That is most of why the interpreter costs 114 KB of flash rather than 200.
The linter is built with the same four libraries and no others. A linter that offered io on the laptop and failed on the hardware would be worse than none.
Icons§
book drive vitals wifi bluetooth keys invert refresh info power qr mail chat warn cloud map game chart doc cards chip policy snail globe clock page settings sleep calendar github gdrive
Names are matched without regard to case, and an unknown one falls back to doc — a row with no glyph at all reads as a rendering fault. gdrive is Google Drive; drive is the storage glyph the built-in file server wears.
When it goes wrong§
An app that runs past its instruction budget gets "this app ran too long without returning". One that asks for more memory than the budget allows gets "not enough memory". Any other Lua error carries its own message and line number.
All of them unwind to the engine, which shows the error and keeps the OS running. A broken card app is a message on the screen rather than a dead device.