Developers › Guide

Making eOctavo apps

An app is one Lua file in the apps folder of the X3's SD card, plus an optional art pack. You describe what's on the screen; eOctavo lays it out, draws it and puts the button labels along the bottom.

Your first app

--!name Hello eOctavo
--!icon code
--!category Examples

local n = 0

function start()
  n = tonumber(snail.load()) or 0
end

function key(k)
  if k == "up" or k == "ok" then n = n + 1
  elseif k == "down" then n = n - 1 end
  snail.save(tostring(n))
end

function draw()
  snail.title("Hello eOctavo")
  snail.text("Counter: " .. n)
  snail.hint("OK +1  UP/DOWN change")
end

Open it in the simulator, change something and press Run. To try it on your X3, save it as hello.lua in the SD card's apps folder; it shows up in the Examples folder on the home screen.

How an app runs

The X3 runs the file once, then calls four global functions. Define the ones you need.

start()
Once, when the app opens. Load saved data here.
key(k)
After every button press, with the button's name. Change your state here.
tick()
On a timer, while snail.tick(ms) has one running.
draw()
Builds the screen. eOctavo calls it after start, every key and every tick, and whenever else it needs to.

Keep draw() a pure function of your state: decide things in key() and tick(), and only describe the screen in draw(). It may run more than once per press. If the new screen is the same as the last one, the X3 doesn't refresh the panel.

If any of them raises an error, the app stops on an error screen that shows the message. BACK leaves it.

Buttons

key(k) gets "up", "down", "left", "right", "ok" or "top".

BACK is special. It first sends "top", so you can use it to close a menu or go back a level. If the screen your draw() returns afterwards is exactly the same as before, the app is at its top level and BACK leaves it, back to the home screen. So on your top screen, ignore "top"; everywhere else, use it to go up. The Store refuses apps that BACK can't get out of.

Drawing

In draw() you add entries to a list, top to bottom. You never give coordinates.

snail.title(s)
The app's title, in the bar at the top. The first one counts.
snail.text(s)
A paragraph. Text wraps to at most two lines; \n starts a new line. Draw more entries for more text.
snail.small(s)
A secondary line. On the X3 it's drawn like text.
snail.row(label, selected)
A list item. A selected row is drawn with > in front. Keep the cursor in your own state and move it in key(). A third argument (an icon name) is accepted for Snail OS apps and ignored.
snail.rule()
A thin line across the screen.
snail.gap()
A little empty space.
snail.center(on)
Centres the text entries of this frame when on is true.
snail.status(s)
A line at the top of the body, above everything else (up to 47 characters).
snail.hint(s)
The button labels along the bottom; see Button labels.
snail.image(name)
A sprite from the app's art pack, at its own size, shrunk if it doesn't fit.
snail.qr(s)
Shows the text s (eOctavo doesn't draw QR codes yet).
local items, sel = {"New game", "Continue", "Rules"}, 1

function key(k)
  if k == "down" then sel = sel % #items + 1
  elseif k == "up" then sel = (sel - 2) % #items + 1 end
end

function draw()
  snail.title("Menu")
  for i, label in ipairs(items) do snail.row(label, i == sel) end
  snail.hint("OK choose  UP/DOWN move")
end

Text entries are cut at 95 characters. Use plain ASCII: other characters show as ? on the X3. A frame holds up to 96 entries; anything past the bottom of the screen isn't drawn, so page long lists yourself.

Button labels

snail.hint takes a line like "OK roll L/R pick BACK quit" and puts each part over its button. A button name starts a label and the words after it are the label:

  • OK, BACK (or TOP), LEFT / L, RIGHT / R
  • L/R labels both side buttons; L- and R+ style words (like L-1 R+1) label them separately
  • UP, DOWN, UP/DOWN and anything without a button go on a line above the buttons

Without a hint, the BACK button is labelled Leave app. Up to 79 characters.

Cards, boards and cards with art

Three calls draw a whole game object.

snail.cards(spec)
A row of playing cards from space-separated tokens: rank then suit (AS, 10H, QD, 7C), ? for a face-down card and - for an empty slot. Spades and clubs are solid, hearts and diamonds outlined. Up to 16 cards; several cards calls in a row overlap like a solitaire cascade.
snail.board(spec, size, banner)
A grid. Rows are separated by /, up to 32×32 cells. # solid, o outline, @ marked, * disc, . or space empty, {12} a numbered tile. Any other character draws the sprite t + that character from your art pack (tg for g), or the character itself. "small" as size makes it half width; a banner is written across the middle (for "You win!").
snail.card{sprite=, name=, num=, types=, stats=, info=}
A collectible-style card: a sprite from the art pack, a name plate with a number, a bold types line, then stats and up to three lines of info.
snail.board("#.o/.*./o.#", nil, won and "You win!" or nil)
snail.cards("AS 10H ? -")

Saving

snail.save(s) keeps one string for your app, and snail.load() returns it next time (or nil). It's stored on the SD card, up to 1023 bytes; more is cut off. Save whenever something worth keeping changes; there's no "on close" handler.

-- a few numbers, comma-separated
snail.save(table.concat({best, level, coins}, ","))
local b, l, c = (snail.load() or ""):match("(%d+),(%d+),(%d+)")

Timers

snail.tick(ms) calls tick() every ms milliseconds, between 450 and 60000; it returns the interval it granted. snail.tick(0) stops it. Every tick redraws, and each change on screen is an e-ink refresh of about half a second, so tick only as fast as the screen needs. os.time() and os.date() give the X3's clock.

snail.after(fn) runs fn once the current frame is on the screen. Use it for slow work, so people see "Loading..." first. A second call replaces the first.

snail.ink("fast") switches the app to the panel's fast refresh: quicker, with a little ghosting, cleaned up by a full refresh every 24 frames. Call it from start().

The internet

snail.fetch(url) downloads a URL and returns its body as a string, or nil and a short reason ("no Wi-Fi", "HTTP 404", "too big"...). It joins the saved Wi-Fi network if needed and waits for the answer, up to 10 seconds. http and https both work; bodies over 24576 bytes are refused and redirects aren't followed.

There's no JSON library: pick the values out with Lua string patterns, and prefer APIs that return small, flat answers. Keep what you fetched with snail.cache, so the app opens instantly and works offline:

snail.cache(key)
Returns the stored value and its age in seconds, or nil.
snail.cache(key, value)
Stores it on the SD card (keys 1–96 bytes, values up to 24576). Each app keeps eight; a ninth drops the oldest.
snail.cache(key, nil)
Deletes it.
local quote = "Loading..."

local function refresh()
  local body, why = snail.fetch("https://example.com/quote.txt")
  if body then quote = body; snail.cache("quote", body)
  else quote = "Couldn't fetch: " .. why end
end

function start()
  local saved, age = snail.cache("quote")
  if saved then quote = saved end
  if not saved or (age and age > 3600) then snail.after(refresh) end
end

function key(k) if k == "ok" then quote = "Loading..."; snail.after(refresh) end end
function draw() snail.title("Quote"); snail.text(quote); snail.hint("OK refresh") end

In the simulator, fetches go through this site (20 a minute). On the X3 there's no Wi-Fi while Bluetooth mode is on, so handle nil gracefully.

Art packs

Pictures come from an art pack: a file named like the script (myapp.lua gets myapp.art) next to it in apps. It holds named 1-bit sprites. Make one with the art pack maker.

  • snail.image("logo") draws sprite logo at its own size, shrunk to fit if needed.
  • snail.card{sprite = "dragon", ...} draws it as the card's picture, enlarged up to 2× when there's room.
  • snail.board draws sprite t + character for each cell with that character. Tiles are at most 32×32 pixels and are scaled up by whole numbers when the grid has room, so a 16×16 tile set looks crisp.

The format, XTA1: "XTA1", a one-byte sprite count, then 21 bytes per sprite (a 12-byte name padded with zeros, width and height as little-endian 16-bit numbers, a zero flags byte, and a little-endian 32-bit offset to its pixels), then the pixels: rows of ceil(width / 8) bytes, most significant bit first, 1 = black.

Name, icon and folder

The first lines of the script tell the X3 how to list it:

--!name Five Dice
--!icon dice
--!category Games
--!name
The name on the home screen and in the app's title bar.
--!icon
One of game, cards, dice, heart, snake, sword, code, quote, book, clock, timer, weather, calendar, notes, write, chat, mail, web, store, settings, contacts, bell, phone, image, folder. Anything else gets a generic app icon.
--!category
The home-screen folder it goes in: Games, Puzzles, Learning, Tools, Reading, or another name up to 15 characters (the X3 shows at most eight folders). Without it, apps with a game icon go in Games and the rest get their own home icon.

What Lua has

Lua 5.4 with string, table, math, utf8, coroutine, the basic functions (pairs, pcall, tostring, setmetatable...), and os.time, os.date, os.clock and os.difftime. Apps run in a sandbox: there's no io, require, load, debug or collectgarbage, and an app can't see your books, notes or other apps' data. print writes to the simulator's log.

Everything above is also reachable as octavo.*; snail.* is the same table.

Limits

Script24 KB (24576 bytes) of source
Data48 KB (49152 bytes) of live Lua data; a bit less for very large scripts
Time200,000 Lua steps per call to start, key, tick or draw
Screen96 entries per frame; 95 characters per entry; 2 lines per text entry
Save1023 bytes
Cache8 entries of up to 24576 bytes
Fetch24576 bytes, 10 seconds
Timer450 to 60000 ms
Apps64 on one X3

The simulator's Limits panel shows how close your app gets: orange past 75%, red past the limit.

Snail OS apps

eOctavo runs card apps written for Snail OS unchanged: the same functions under snail.*, the same handlers and limits. eOctavo adds --!category (Snail OS ignores it) and art tiles in snail.board (on Snail OS those cells fall back to its own drawing), and lists up to 64 apps rather than 16. snail.fetch takes one argument here.

Designing for e-ink

  • The screen takes about half a second to change. Turn-based games, tools, lists and readers work well; anything that needs smooth motion doesn't.
  • It's black and white: no grey. Big, simple shapes read best.
  • Only redraw what changed. Same screen, no refresh, no battery spent.
  • Label the buttons with snail.hint on every screen, and make BACK step back one level.
  • Save progress as you go: the X3 turns itself off after a while without presses.

Publishing

Send your app with the submission form: the .lua, its .art, an icon and screenshots (the simulator's Screenshot button makes 528×792 ones). It's tested straight away: the store runs it with about 1,500 random presses and ticks, and checks it never errors, stays inside the limits, starts with something on screen, and that BACK always gets out. Then a person tries it before it goes in the Store. Store apps are free to install.

To update your app, send it again with the same file name and a higher version.