---
name: lua
description: Write and validate Lua apps (.lua files) for Snail OS on the XTEINK X4 e-ink handheld. Use whenever the user wants an app or a game for their Snail OS device, mentions a .lua file, or asks to put something "on the card". Covers the API, the limits, the drawing model, and the write → lint → play loop.
---

# Writing a Snail OS app

An app is one `.lua` file in `/apps` on the SD card. Read `docs/LUA.md` in this
repo first — it is short and it is the reference. This file is the working
procedure.

## Before you write anything

**Check the idea against the panel.** It redraws in ~500 ms. An app may step
itself with `snail.tick(ms)`, but the interval is clamped to 600 ms, so the
fastest anything moves is about two steps a second and every step costs a full
waveform. Animation in the ordinary sense cannot be built here. Turn-based is
what this device is good at.

**Check the data source.** If it fetches, the response must be small and flat.
A one-argument `snail.fetch()` returns the body as a string and Lua string
patterns are all you have — there is no JSON library in Lua on this device. A
400 KB document with nested objects is not parseable in a 48 KB budget. Pass a
field list and a sink instead and the engine parses it outside your budget. If
the API is big or nested, the answer is an endpoint on a-gnt that flattens it
(see `docs/CONNECTED.md`), not a cleverer app.

## The shape

```lua
--!name The Name People See
--!icon game

function start() end          -- once, on open
function key(k) end           -- "up" "down" "left" "right" "ok" "top"
function tick() end           -- every ms while snail.tick(ms) is in force
function draw() end           -- may run at any time, more than once per press
```

`BACK` never reaches you. `draw()` must be a pure function of your state —
decide in `key()`, draw in `draw()`.

## Drawing

Append to a display list; never name a coordinate:

`snail.title` `snail.text` `snail.small` `snail.row(label, selected, icon)`
`snail.rule` `snail.gap` `snail.image(name)` `snail.qr(text)`
`snail.center(on)`

Keep your own cursor and pass it to `snail.row`. Draw only the rows that fit —
about seven at the default size — and scroll by moving your own window.

Three calls draw a whole object, so the app names the state and the firmware
knows what it looks like:

`snail.cards(spec)` draws a row of playing-card faces. One string, one row: 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`, with `?`
for a card lying face down and `-` for an empty place. Spades and clubs are
solid, hearts and diamonds are hollow, because the panel is one bit.

`snail.board(spec, size, banner)` draws a tile grid. Rows are separated by `/`;
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. Up to 32
cells a side. Pass `"small"` as the second argument for an inset grid such as a
next-piece tray, and a string as the third for a banner across the middle. The
engine picks the tile size and centres the grid, so the same spec draws a 4x4
and a 17x17 board without the app knowing the width of the panel. A board takes
every pixel below it, so draw it last, and build the spec with `table.concat`.

`snail.card{sprite=, name=, num=, types=, stats=, info=}` composes one
full-bleed trading card across the whole content band: frame, inverted name
plate, art window, one pill per type, and one bar per stat against a fixed
ceiling of 255. `sprite` and `name` are required. The card is the screen —
anything appended after it is not drawn.

## Everything else

`snail.status` `snail.hint` `snail.save(s)` `snail.load()` `snail.cache(key[, s])` `snail.fetch(url)`

`snail.tick(ms)` asks for a `tick()` every `ms` and returns the interval
granted; `snail.tick(0)` stops it and so does leaving the app. **Turn it off
when there is nothing to step** — ticking repaints, and the kernel will not
light-sleep the chip while the panel's rails are up, so a game still ticking
after it ends is a device at ~20 mA in a pocket.

`snail.after(fn)` runs `fn` once, after your first frame is on the glass.
**Never fetch from `start()`**: it runs before the kernel has painted anything,
so the previous app stays on the panel through the Wi-Fi join, the handshake
and the request. Load the cache in `start()` and hand the network to
`snail.after`.

`snail.ink("fast")` draws with the short waveform while your app is open. What
it trades away is settling time, which is invisible on solid tiles and not on a
paragraph — call it from `start()` if your app is `snail.board` or
`snail.cards` and little else.

For an app that reads a personal account: `snail.paired()`, then
`snail.connect(name)` inside `draw()` and `snail.connectkey(k)` inside `key()`.
The engine draws the whole login screen and runs the pairing protocol; the app
acts only on the `"done"` it gets back. It never sees a credential.

Read the account with `snail.agnt(path, "a,b,c", sink)`. The engine parses the
reply outside your budget and calls `sink` once per row with those fields as
positional strings, so the body never becomes a Lua value you are charged for.
It answers `head, count`, where `head` is the reply's own top-level fields, or
`nil, why`. **A sink that returns `false` stops the walk**, which is how you cap
what the app keeps. `snail.fetch(url, "a,b,c", sink)` does the same for the open
web. 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. `docs/CONNECTED.md` has the flow.

## Limits

24 KB source, 48 KB live memory, 200k VM instructions per handler, 96 display
entries. `io`, `os`, `package` and `debug` are not
opened, and `dofile`, `loadfile` and `collectgarbage` are removed.

## The loop

1. Write the file into `apps/`.
2. `tools/lua-lint apps/yours.lua` — three passes: 4,000 random-key frames
   unpaired, the same 4,000 paired with a 12,288-byte document staged, then
   30,000 steps checking what it saves. Fix anything it reports.
3. Watch the memory lines. The linter fails a peak over 70% of the budget
   unpaired or 85% paired, because an app measured with nothing loaded is an
   app measured in the one state its owner will never use it in.
4. If it has money, a score or any state worth keeping, make sure the invariant
   pass would actually catch it going wrong. A game whose bankroll can go
   negative should fail the lint, not ship.
5. Art: `tools/photo2art.py`, and read its header before choosing settings.

## What good looks like

`apps/billy.lua` is the reference app: a full game with persistent state,
photographic art, and dealer dialogue chosen from what happened rather than at
random. Read it before writing your first one.

## Do not

- Do not put a credential in a card app. Anyone holding the card can read it,
  and any other app can fetch any URL. Accounts go through a-gnt.
- Do not animate. Do not poll in `draw()`. Do not fetch from `start()`.
