Lua · Quickstart

Nothing to an app, in one sitting.

You need an X4 running Snail OS, its microSD card, and a text editor. No toolchain, no account, no cable to the device.

Prefer to delegate? Tell your agent — a coding agent with the skill and the linter writes the file for you. This page is the same journey by hand.

1 · Get a file onto the card§

Two ways. Put the card in a computer and create /apps/hello.lua — make the /apps folder if it is not there. Or leave the card in the device and use the Drive app, which serves the card over Wi-Fi.

A third way needs no editor: open the Store app on the device and install an app from this site, then modify it. The published apps are written to be copied — their sources are here.

--!name Hello
--!icon book

local n = 0

function start()
  snail.hint("OK counts   BACK menu")
end

function key(k)
  if k == "ok" then n = n + 1 end
end

function draw()
  snail.center(true)
  snail.title("Hello")
  snail.gap()
  snail.text("Pressed " .. n .. " times.")
end
hello.lua. The launcher reads only the two --! lines when it scans the card, so a menu costs one line of each file rather than a run of every app on it.

2 · Open it§

Put the card back and walk to the launcher. The scan runs on every launcher entry, so the app is already listed — Hello, with the book icon. Open it, press OK a few times, press BACK.

That loop is the whole development cycle: edit the file, re-enter the launcher, open the app. The file is loaded fresh on every open.

3 · Break it, on purpose§

Delete the end on the last line and open the app again. The error appears on the device, naming the line. A runtime error does the same and unwinds to the engine, which keeps the OS running: a broken card app is a message on the screen rather than a dead device.

4 · Keep your own cursor§

The engine draws no cursor for you. snail.row() takes the selection as an argument, so the app decides which row is highlighted — and an app that owns its cursor can also filter its list, reorder it, or put two lists on one screen.

--!name Tally
--!icon chart

local NAMES = {"coffee", "water", "walk"}
local counts, cursor = {0, 0, 0}, 1

function start()
  snail.hint("OK add   UP/DOWN pick   BACK menu")
end

function key(k)
  if k == "up" and cursor > 1 then cursor = cursor - 1
  elseif k == "down" and cursor < #NAMES then cursor = cursor + 1
  elseif k == "ok" then counts[cursor] = counts[cursor] + 1 end
end

function draw()
  snail.title("Today")
  snail.gap()
  for i, n in ipairs(NAMES) do
    snail.row(n .. "   " .. counts[i], i == cursor)
  end
  snail.rule()
  snail.small("OK adds one to " .. NAMES[cursor])
end
tally.lua, complete. Three rows, a cursor of its own, and one number per row.

Note what draw() does not do: it names no coordinate. An app appends to a display list and the engine lays it out, which is why a card app cannot paint over the header, escape the content band, or land a line half off the panel.

5 · Run it before the card does§

The linter plays the app against a stand-in for the device using the firmware's own copy of Lua. Three passes: 4,000 random-key frames with nothing loaded, the same 4,000 paired with a document staged, then 30,000 steps checking what the app saves. It counts every byte the interpreter allocates and fails an app whose peak crosses 70% of the 48 KB unpaired or 85% paired.

tools/lua-lint tally.lua

  source: 597 B of 24576 (2%)
  tally.lua: 4000 frames, 28001 draw calls, 0 ticks, no error
  one frame draws: gap=1  row=3  rule=1  small=1  title=1
  memory (unpaired): peak 15149 B of 49152 (31%), held 11495 B,
                     largest block 1024 B, 0 collections forced
  worst frame: 15149 B at step 1777 of 4000
  tally.lua: 4000 frames, 28001 draw calls, 0 ticks, no error
  one frame draws: gap=1  row=3  rule=1  small=1  title=1
  memory (paired): peak 15129 B of 49152 (31%), held 11495 B,
                   largest block 1024 B, 0 collections forced
  worst frame: 15129 B at step 2992 of 4000
  30000 steps: no error, and this app saves nothing
The display-list line is worth reading: an app that meant to draw three rows and drew none says so here. The paired pass stages a 12,288-byte document, which is the state a connected app is actually in when it runs out of memory.

Remove the two cursor guards in key() and the first pass stops with ERROR: attempt to concatenate a nil value (field '?'), because UP walks the cursor past the first row and NAMES[cursor] is then nil. That is a fault you would otherwise find while holding the device.

6 · Where next§

The reference has the rest of the API: snail.save and snail.load for state that survives a reboot, snail.image for sprites, snail.qr, and the status band. Fetching covers apps that read something off the network.

Next: Reference