Snail OS · Store
Apps and books, ready for your Snail.
The Store app on the device installs these with one press. Or download a file and put it on the SD card. Apps belong in /apps; books belong in /books.
Each of these is one .lua file, read by an interpreter compiled into the firmware. It cannot write to the card and it never runs on the CPU. An app may carry one more file beside it — an .art sprite pack — and the Store downloads the pair or neither.
The device fetches the same index.json this page is built from, so the two cannot drift. To write your own, start with the developer documentation.
Connected
Four apps are built into Snail OS. One pairing with an eight-character code covers them all. Fresh pairings use connect.snailos.org; retained V1 pairings keep their saved a-gnt origin until you disconnect and pair again. Every screen opens from the SD card before the radio is touched. RIGHT asks the saved service again.
GitHub
Your repositories, their directories, and a file set as a page.
Analytics
Users, views, top pages and where the readers came from, for today, 7 days or 28.
Search Console
Clicks, impressions, CTR and average position, plus the top queries and pages.
OpenBubbles
Read and reply to iMessages through an awake Mac running Snail Connect.
Sync for free over Bluetooth with Snail Companion for Android while your phone is nearby. iOS is coming soon. For Wi-Fi sync while your phone is elsewhere and online, or iMessage through an always-on Mac, add Snail Connect. New subscriptions cost $4.99/mo. Snail Connect includes Snail Messages, Calendar, Reminders, Contacts, Sync, and TailTerm. Calendar opens from a month grid to a day, then an event. Apple services require a Mac that stays awake, online, signed into those services, and running Snail Connect for Mac.
OpenBubbles setup and the Mac companion are documented on the Snail Connect page.
Coming soon: Google Drive.
Games
One deck and a dealer who talks. Double down on the first two cards; blackjack pays three to two.
Read the sourceHide the source
--!name Billy's Blackjack
--!icon game
--
-- Billy's Blackjack, for Snail OS. One deck, dealer stands on all 17, blackjack
-- pays 3:2, double on the first two cards only.
--
-- WHY IT IS SHAPED LIKE THIS
-- The panel takes about half a second to redraw, so every press has to be worth
-- a frame. There is no animation and nothing ticks: you press, the table
-- resolves, it draws once. That is also why the dealer plays out his whole hand
-- in a single step instead of card by card — watching him think for four
-- seconds is not suspense on a screen this slow, it is a wait.
--
-- Billy talks because a dealer who says nothing is a table with no one at it.
-- His lines are picked from what actually happened rather than at random, so
-- when he calls you unlucky it is because you were.
local BANK_START = 500
local MIN_BET, MAX_BET, BET_STEP = 5, 500, 5
-- state: "bet" | "play" | "over"
local phase, deck, you, billy = "bet", {}, {}, {}
local bank, bet, message, outcome = BANK_START, 25, "", ""
local doubled, wins, losses, pushes = false, 0, 0, 0
local net = 0 -- what the last hand moved, for the settled screen
-- ------------------------------------------------------------------ the deck
-- Ranks carry their own face text so nothing has to map an index back to a
-- name at draw time. A card's face is also its card code: "10H" is what
-- snail.cards() reads, so the hand is passed to the engine without being
-- translated on the way.
local RANKS = {
{"A", 11}, {"2", 2}, {"3", 3}, {"4", 4}, {"5", 5}, {"6", 6}, {"7", 7},
{"8", 8}, {"9", 9}, {"10", 10}, {"J", 10}, {"Q", 10}, {"K", 10},
}
local SUITS = {"S", "H", "D", "C"}
local function shuffle()
deck = {}
for s = 1, 4 do
for r = 1, 13 do
deck[#deck + 1] = {face = RANKS[r][1] .. SUITS[s], v = RANKS[r][2], rank = r}
end
end
for i = #deck, 2, -1 do
local j = math.random(i)
deck[i], deck[j] = deck[j], deck[i]
end
end
local function draw_card(hand)
if #deck == 0 then shuffle() end
hand[#hand + 1] = table.remove(deck)
end
-- Aces are eleven until that busts, then one. Counting them down one at a time
-- is what makes a soft hand soft.
local function total(hand)
local t, aces = 0, 0
for _, c in ipairs(hand) do
t = t + c.v
if c.rank == 1 then aces = aces + 1 end
end
while t > 21 and aces > 0 do t, aces = t - 10, aces - 1 end
return t
end
local function soft(hand)
local t, aces = 0, 0
for _, c in ipairs(hand) do
t = t + c.v
if c.rank == 1 then aces = aces + 1 end
end
-- Count the aces down exactly as total() does: what is left at eleven is
-- what makes the hand soft. Summing at eleven alone called A,A,9 hard.
while t > 21 and aces > 0 do t, aces = t - 10, aces - 1 end
return aces > 0
end
local function is_blackjack(hand)
return #hand == 2 and total(hand) == 21
end
-- The spec snail.cards() reads: the codes for the hand, with "?" wherever a card
-- is still face down. A hole card is drawn as a back rather than described as
-- one, which is the whole reason the engine owns the faces.
local function hand_spec(hand, hide)
local out = {}
for i, c in ipairs(hand) do
out[i] = (hide and i == 2) and "?" or c.face
end
return table.concat(out, " ")
end
-- ---------------------------------------------------------------- persistence
-- One line, four numbers. The engine keys the blob on a hash of this file's
-- name, so it follows the game rather than the slot it happens to land in.
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
-- A blob from an older build could carry a bank below the table minimum.
if bank < MIN_BET then bank = BANK_START end
end
-- The bet may never exceed the bankroll: the default 25 against a loaded bank
-- of 10 was a wager the player did not have.
local function clamp_bet()
bet = math.max(MIN_BET, math.min(bet, MAX_BET, bank))
end
-- --------------------------------------------------------------------- Billy
-- Picked from what happened, never at random. A dealer who says "tough luck"
-- when you just won is a dealer nobody believes.
local function billy_says(kind)
local lines = {
deal = {"Place it and we'll see.", "Cards are cards.", "Let's have a look."},
hit = {"Bold.", "Another, then.", "You want it, you got it."},
close = {"That's a working hand.", "I'd sit on that.", "Now we're talking."},
bust = {"Over. Happens.", "Twenty-two is still twenty-two.",
"The house thanks you."},
bbust = {"Over. Yours.", "I've overdone it.", "The deck turned on me."},
dealbj = {"Blackjack. Don't look at me like that.",
"Twenty-one on the deal. That's the game."},
yourbj = {"Blackjack. Pays three to two.", "Well. Look at that."},
win = {"You take it.", "Fair and square.", "Good hand."},
lose = {"Mine.", "That's the house.", "Better luck next one."},
push = {"Nobody's hand.", "Push. Try again.", "A tie is a rest."},
broke = {"That's the bankroll. I'll spot you a fresh one.",
"Cleaned out. Sit, I'll deal you back in."},
}
local set = lines[kind] or lines.deal
return set[math.random(#set)]
end
-- ------------------------------------------------------------------ the round
local function settle()
local yt, bt = total(you), total(billy)
local ybj, bbj = is_blackjack(you), is_blackjack(billy)
local stake = doubled and bet * 2 or bet
if yt > 21 then
outcome, net, losses = "BUST", -stake, losses + 1
message = billy_says("bust")
elseif ybj and not bbj then
-- 3:2, rounded down to a whole chip. A half-chip payout on a $5 table is a
-- rule nobody can check by eye.
outcome, net, wins = "BLACKJACK", math.floor(stake * 3 / 2), wins + 1
message = billy_says("yourbj")
elseif bbj and not ybj then
outcome, net, losses = "BILLY HAS IT", -stake, losses + 1
message = billy_says("dealbj")
elseif bt > 21 then
outcome, net, wins = "BILLY BUSTS", stake, wins + 1
message = billy_says("bbust")
elseif yt > bt then
outcome, net, wins = "YOU WIN", stake, wins + 1
message = billy_says("win")
elseif bt > yt then
outcome, net, losses = "BILLY WINS", -stake, losses + 1
message = billy_says("lose")
else
outcome, net, pushes = "PUSH", 0, pushes + 1
message = billy_says("push")
end
bank = bank + net
if bank < MIN_BET then
bank = BANK_START
message = billy_says("broke")
end
clamp_bet()
phase = "over"
snail.hint("OK deal again BACK menu")
save()
end
local function dealer_plays()
-- One step, not one card a frame. See the note at the top.
while total(billy) < 17 do draw_card(billy) end
settle()
end
-- The footer names only the moves that are legal right now: double exists on
-- the first two cards, with the bank to cover it, and not after.
local function play_hint()
snail.hint(#you == 2 and bank >= bet * 2
and "OK hit DOWN stand LEFT double BACK menu"
or "OK hit DOWN stand BACK menu")
end
local function deal()
shuffle()
you, billy, doubled = {}, {}, false
draw_card(you); draw_card(billy); draw_card(you); draw_card(billy)
phase = "play"
message = billy_says("deal")
-- A blackjack either way ends it immediately; there is nothing to decide.
if is_blackjack(you) or is_blackjack(billy) then settle() end
end
-- ------------------------------------------------------------------ lifecycle
function start()
snail.ink("fast") -- solid tiles, so the short waveform is honest here
load()
clamp_bet()
phase = "bet"
message = "Sit down."
snail.hint("OK deal BACK menu")
end
function key(k)
if phase == "bet" then
if k == "up" then bet = math.min(math.min(MAX_BET, bank), bet + BET_STEP)
elseif k == "down" then bet = math.max(MIN_BET, bet - BET_STEP)
elseif k == "left" then bet = MIN_BET
elseif k == "right" then bet = math.min(MAX_BET, bank)
elseif k == "ok" then
deal()
-- A natural either way settles inside deal(), which also posts the
-- settled hint, so the play hint only goes up over a hand still to play.
if phase == "play" then play_hint() end
end
elseif phase == "play" then
if k == "ok" then
draw_card(you)
local t = total(you)
if t > 21 then
-- Busted: the house wins where it stands. Billy does not deal himself
-- more cards over a hand that is already his.
settle()
elseif t == 21 then
-- Twenty-one plays itself; another card could only ruin it.
dealer_plays()
else
message = billy_says(t >= 17 and "close" or "hit")
play_hint() -- double left the table with the third card
end
elseif k == "down" then
dealer_plays()
elseif k == "left" and #you == 2 and bank >= bet * 2 then
-- Double: one card, then it is out of your hands.
doubled = true
draw_card(you)
if total(you) > 21 then settle() else dealer_plays() end
end
else -- over
if k == "ok" then
phase = "bet"
clamp_bet()
message = "Again?"
snail.hint("OK deal BACK menu")
end
end
end
function draw()
if phase == "bet" then
snail.center(true)
snail.image("billy")
snail.title("$" .. bank)
snail.small(string.format("%d won %d lost %d pushed", wins, losses, pushes))
snail.gap()
snail.title("BET $" .. bet)
snail.gap()
snail.text(message)
return
end
-- The table. Billy's hand, yours, and what he thinks about it. No portrait:
-- the cards are what you are reading, and a 213px face would push your own
-- hand off the bottom at the large text size.
--
-- Nothing here draws a hairline. The cards are the structure now, and the
-- rules that used to divide the hands read as lines struck through the screen.
-- Air separates them instead.
snail.center(true)
snail.small("BILLY")
snail.cards(hand_spec(billy, phase == "play"))
snail.small(phase == "play"
and ("showing " .. billy[1].v)
or ("total " .. total(billy)))
snail.gap()
snail.small("YOU" .. (doubled and " (doubled)" or ""))
snail.cards(hand_spec(you, false))
snail.small(string.format("total %d%s", total(you),
soft(you) and " soft" or ""))
snail.gap()
if phase == "over" then
-- The verdict in the large face, with what it moved. The bet screen is the
-- ledger; this line is the hand's own arithmetic.
snail.title(outcome .. (net > 0 and (" +$" .. net)
or net < 0 and (" -$" .. -net) or ""))
snail.text(message)
snail.gap()
snail.small("bank $" .. bank)
else
snail.text("Press down to stay.")
end
end
Snake
snake.lua · 16,936 bytes
The snake crawls on its own and the arrows steer. Eat, grow, and keep off the walls and your tail.
Read the sourceHide the source
--!name Snake
--!icon game
--
-- Snake, for Snail OS. The snake crawls on its own; the arrows steer it.
--
-- WHY IT IS SHAPED LIKE THIS
-- The engine offers snail.tick(ms) with a floor of one panel waveform, so a
-- step and a painted frame are the same event and the snake cannot get ahead
-- of the glass. The game the native firmware shipped comes back whole: the
-- snake moves by itself and the arrows only turn it.
--
-- NOTHING CRAWLS UNTIL YOU PRESS SOMETHING, and it stops the moment the snake
-- dies. A ticking app repaints, a repaint holds the panel's rails up, and the
-- kernel will not light-sleep the chip while they are up -- so an app left
-- open on a table must not be an app that keeps stepping. The first arrow
-- starts it and OK pauses it.
--
-- WHY THE BOARD IS 17 x 17
-- snail.board() picks the tile size from the grid and the space under the
-- score line. 17 columns across the 444px content column gives a 26px tile,
-- within two pixels of the native app's 24px cell, the size that was legible
-- at arm's length. It stays 17 because the saved game encodes a coordinate as
-- one character of a 17-character alphabet.
local N = 17 -- board is N x N cells
local CELL_EMPTY = "." -- ground, drawn as a dot so an empty board
local CELL_BODY = "#" -- still reads as a field rather than a void
local CELL_HEAD = "@" -- a block with the centre punched out
local CELL_FOOD = "*" -- a disc: food has its own silhouette
local START_LEN = 3
local FOOD_SCORE = 10
-- THE PACE RAMPS WITH THE MEAL COUNT. The native app ran a fixed beat because
-- the panel, not its timer, was the governor; here the engine's tick floor is
-- XT_LUA_TICK_MIN = 450ms, so there is a hundred and fifty milliseconds of headroom
-- between a readable opening pace and the glass's own ceiling — and the game
-- spends it. Fifteen meals walk the crawl from 600 down to the floor, ten
-- milliseconds at a time, so a long snake presses exactly as hard as this
-- panel allows. The interval only changes when a meal lands, so a held arrow
-- re-asserting the same number never re-phases the engine's clock.
local PACE_MAX = 600
local PACE_MIN = 450
-- One character per coordinate, so a 289-segment snake still saves in 578
-- bytes of the 1024 the engine allows.
local ALPHA = "0123456789abcdefg"
-- Directions are indices so the whole snake state is numbers and saves as a
-- flat string. 1 up, 2 down, 3 left, 4 right.
local DX = {0, 0, -1, 1}
local DY = {-1, 1, 0, 0}
local OPPOSITE = {2, 1, 4, 3}
local DIR_OF_KEY = {up = 1, down = 2, left = 3, right = 4}
local DIR_NAME = {"NORTH", "SOUTH", "WEST", "EAST"}
-- THE SNAKE IS A LIST OF NUMBERS, NOT A LIST OF POINTS. A segment as its own
-- {x, y} table costs about eighty bytes once the header and hash slots are
-- counted; a full board of them was twenty-two kilobytes of the forty-eight an
-- app is given, and the game died of memory in the owner's hands after a good
-- long game. A cell packed as y * N + x is one eight-byte array slot.
local body, dir, food = {}, 4, 0
-- THE TURN QUEUE, two deep, exactly as the native app kept it. A step is most
-- of a second — long enough for a player to tap two turns inside one — and
-- with a single direction slot the first tap is silently eaten. The queue is
-- what made the native game feel right: a corner tapped during a refresh
-- still gets turned.
local q, qn = {}, 0
-- ONE BUFFER FOR EVERY LIST THIS FILE WOULD OTHERWISE BUILD AND THROW AWAY.
-- The board spec, the save routine and the food placer each need a working
-- array; none can run while another is running, because the engine calls
-- draw(), tick() and key() one at a time. Each writes the range it needs and
-- reads back only that range.
local scratch = {}
-- Where a cell lands in the board spec, which is the grid written row by row
-- with a "/" between rows: the row is (N + 1) entries wide once the separator
-- is counted, and the index is one-based.
local function spec_index(cell)
return (cell // N) * (N + 1) + (cell % N) + 1
end
local score, best, alive, note = 0, 0, true, ""
-- Whether THIS game beat the best it started against. `score >= best` cannot
-- answer that on the game-over screen: best has already been raised to the
-- score by then, so a game that merely tied the old best would read NEW BEST.
local hi = false
-- Whether the crawl is armed. Deliberately not saved: a game resumed from the
-- card starts stopped, so the board can be read before it moves.
local running = false
-- --------------------------------------------------------------------- board
-- Food goes on a cell chosen from among the free ones rather than by guessing
-- until a guess lands: rejection sampling gets slower exactly as the board
-- fills, which is when a long game would run out of fuel mid-press. The
-- occupancy map is the shared buffer used as one flag per cell, and the nth
-- free cell is found by counting rather than by collecting the free ones into
-- a fresh list at the moment memory is tightest.
local function place_food()
local occ = scratch
for i = 1, N * N do occ[i] = false end
for i = 1, #body do occ[body[i] + 1] = true end
local free = 0
for i = 1, N * N do if not occ[i] then free = free + 1 end end
if free == 0 then
alive, note = false, "PERFECT BOARD"
return
end
local pick, seen = math.random(free), 0
for c = 0, N * N - 1 do
if not occ[c + 1] then
seen = seen + 1
if seen == pick then food = c return end
end
end
end
local function new_game()
body = {}
local cy = N // 2
for i = 1, START_LEN do
body[i] = cy * N + (N // 2) - i + 1
end
dir, qn, score, alive, note = 4, 0, 0, true, "GO"
hi = false
running = false
place_food()
end
-- ---------------------------------------------------------------- persistence
-- Four counters, then the live game after a semicolon. The counters come
-- first and are all non-negative so the blob reads as a score line to
-- anything that only wants the numbers.
--
-- The two characters of a segment go into the buffer separately: a
-- one-character slice of ALPHA is one of seventeen short strings the
-- interpreter already has and hands back without allocating.
--
-- The body is encoded in two halves and the halves are joined, so the buffer
-- never holds more entries than the board spec already needs. On this chip a
-- large contiguous request fails before the total does, so two four-kilobyte
-- passes beat the one big allocation they replace.
local function encode(from, to)
local buf, n = scratch, 0
for i = from, to do
local x, y = body[i] % N, body[i] // N
n = n + 1 buf[n] = ALPHA:sub(x + 1, x + 1)
n = n + 1 buf[n] = ALPHA:sub(y + 1, y + 1)
end
return table.concat(buf, "", 1, n)
end
local function save()
local half = #body // 2
snail.save(string.format("%d %d %d %d ;%d %d %d %s",
best, score, #body, alive and 1 or 0,
dir, food % N, food // N, encode(1, half) .. encode(half + 1, #body)))
end
local function load()
local s = snail.load()
if not s then return end
local b, sc, ln, al, d, fx, fy, segs =
s:match("^(%d+) (%d+) (%d+) (%d+) ;(%d+) (%d+) (%d+) (%w*)$")
if not b then return end
best = tonumber(b)
-- A blob whose body does not match its own length was written by a
-- different version of this file, so the counters are kept and the game is
-- restarted rather than half-restored.
ln = tonumber(ln)
if #segs ~= ln * 2 or ln < 1 then return end
-- A BODY IS A CHAIN. Every segment sits beside the one before it and no cell
-- appears twice; a blob that fails either test was edited or half-written,
-- and resuming it would be a snake folded through itself. The occupancy map
-- is the shared buffer, and it is kept for the food check below.
local rebuilt, occ = {}, scratch
for i = 1, N * N do occ[i] = false end
local px, py
for i = 1, ln do
local x = ALPHA:find(segs:sub(i * 2 - 1, i * 2 - 1), 1, true)
local y = ALPHA:find(segs:sub(i * 2, i * 2), 1, true)
if not x or not y then return end
local c = (y - 1) * N + (x - 1)
if occ[c + 1] then return end
occ[c + 1] = true
if i > 1 and math.abs(x - px) + math.abs(y - py) ~= 1 then return end
px, py = x, y
rebuilt[i] = c
end
body, score, alive = rebuilt, tonumber(sc), tonumber(al) == 1
-- The counters keep their own invariant whatever the blob claims.
if score > best then best = score end
dir = tonumber(d)
if dir < 1 or dir > 4 then dir = 4 end
-- THE FOOD COMES BACK ONLY WHERE IT CAN BE EATEN: on the board and off the
-- snake. The save format cannot write an off-board cell, but the card can
-- hold anything -- an unreachable food starves the game forever, and one
-- under the body turns the meal that finds it into "ATE ITSELF".
local fx2, fy2 = tonumber(fx), tonumber(fy)
if fx2 >= N or fy2 >= N then
place_food()
else
food = fy2 * N + fx2
if occ[food + 1] then place_food() end
end
qn = 0
-- THE RESUMED HEADING COMES FROM THE BODY'S OWN GEOMETRY, NOT THE BLOB. An
-- earlier version of this file wrote `dir` to the card at press time, so a
-- blob can hold a freshly asked-for turn beside a body that never took it.
-- Resume such a game and the reversal guard is seeded from a heading the
-- snake does not hold, which is the length-3 "ATE ITSELF": save with UP
-- pending over an east-facing body, reopen, press LEFT — LEFT clears the
-- guard because UP is perpendicular to it, and the first step walks the
-- head straight back through the neck. The neck is ground truth: head
-- minus body[2] is the way the snake last actually moved, and the guard
-- must be seeded from that and nothing else. The pending turn itself is
-- deliberately dropped, by the same rule the pause key applies to its
-- queue: a turn asked for in an earlier session must not fire the moment
-- play resumes.
if #body > 1 then
local gx = body[1] % N - body[2] % N
local gy = body[1] // N - body[2] // N
dir = (gy < 0 and 1) or (gy > 0 and 2) or (gx < 0 and 3) or 4
end
note = alive and "RESUMED" or "GAME OVER"
end
-- ---------------------------------------------------------------- one step
local function die(why)
alive, note = false, why
if score > best then best, hi = score, true end
end
-- THE 180 TEST, AND WHY IT IS SHAPED EXACTLY LIKE THIS. The classic Snake bug
-- -- the "ATE ITSELF" death at length three -- is two presses inside one step
-- composing into a reversal: heading EAST, an UP is accepted, and a LEFT is
-- then tested against UP, found legal, and applied -- so the step that
-- finally lands runs LEFT, straight back through the neck. The turn must be
-- tested against the heading the snake will actually hold WHEN THIS TURN
-- FIRES. With a queue that drains one entry per step, that heading is the
-- last queued turn, or the live direction when nothing is queued -- and since
-- every entry was screened against its predecessor at press time, no chain of
-- accepted presses can ever contain a 180. Testing against the direction
-- last TRAVELLED would also block the reversal, but it rejects the honest
-- two-turn corner (UP then LEFT inside one step) that this queue exists to
-- keep. A duplicate of the reference heading is swallowed too, which is what
-- eats key auto-repeat.
local function queue_turn(turn)
local ref = qn > 0 and q[qn] or dir
if turn == ref or turn == OPPOSITE[ref] then return end
if qn >= 2 then return end
qn = qn + 1
q[qn] = turn
end
local function step()
if not alive then return end
if qn > 0 then -- pop one queued turn per step
dir = q[1]
q[1] = q[2]
qn = qn - 1
end
local head = body[1]
local nx, ny = head % N + DX[dir], head // N + DY[dir]
if nx < 0 or nx >= N or ny < 0 or ny >= N then
die("HIT THE WALL")
return
end
local cell = ny * N + nx
local grow = (cell == food)
-- The tail cell only counts if the snake is about to grow into it;
-- otherwise that segment vacates on this same step.
local last = grow and #body or #body - 1
for i = 1, last do
if body[i] == cell then
die("ATE ITSELF")
return
end
end
table.insert(body, 1, cell)
if grow then
score = score + FOOD_SCORE
if score > best then best, hi = score, true end
place_food()
else
body[#body] = nil
end
end
-- ------------------------------------------------------------------ lifecycle
local function set_hint()
if not alive then
snail.hint("OK plays again BACK menu")
elseif running then
snail.hint("OK pause BACK menu")
else
snail.hint("OK starts BACK menu")
end
end
-- The one place the crawl is armed or stopped. Asking on every press rather
-- than only on the presses that change it keeps the engine's clock and this
-- file's idea of the game from ever disagreeing.
local function set_pace()
if alive and running then
local pace = PACE_MAX - 10 * ((#body - START_LEN))
if pace < PACE_MIN then pace = PACE_MIN end
snail.tick(pace)
else
snail.tick(0)
end
end
function start()
snail.ink("fast") -- solid tiles, so the short waveform is honest here
new_game()
load()
set_hint()
set_pace()
end
-- THE CARD IS WRITTEN ONLY WHEN THE BLOB WOULD DIFFER. A steer changes the
-- queue and the pause key changes `running`, and neither is in the blob --
-- the save that used to sit at the bottom of this function rewrote an
-- identical blob on every press of a long game. Only a new game changes
-- what the card holds from in here; meals and deaths save in tick().
function key(k)
if k == "top" then
new_game()
save()
elseif not alive then
if k == "ok" then
new_game()
save()
end
elseif k == "ok" then
-- Pausing drops anything queued: a turn asked for a minute ago must not
-- fire the instant play resumes.
running = not running
qn = 0
else
local turn = DIR_OF_KEY[k]
if turn then queue_turn(turn) end
-- An arrow is also the start signal, so the first press both aims the
-- snake and sets it going.
running = true
end
set_hint()
set_pace()
end
-- One crawl step, called by the engine on the current pace.
function tick()
local before = score
step()
if not alive then running = false end
set_hint()
set_pace()
-- THE CARD IS WRITTEN WHEN SOMETHING HAPPENED, NOT ON EVERY STEP. An
-- unconditional save here would rewrite the blob five thousand times an
-- hour to record a position already on the screen. A death and a meal are
-- the two things worth surviving a power cut.
if not alive or score ~= before then save() end
end
-- The board as one spec string: rows separated by "/", one character per
-- cell. The empty board is laid down first and the exceptions are written
-- over the top, which costs one pass over the buffer and one over the snake
-- and allocates nothing once the shared buffer has reached its size --
-- against an occupancy hash rebuilt fresh each frame, eight kilobytes of
-- garbage per press. table.concat still builds the final string, which is
-- unavoidable: the engine takes a string. One allocation, not three hundred.
local function board_spec()
local buf, n = scratch, 0
for y = 0, N - 1 do
if y > 0 then n = n + 1 buf[n] = "/" end
for x = 0, N - 1 do n = n + 1 buf[n] = CELL_EMPTY end
end
buf[spec_index(food)] = CELL_FOOD
-- The body is written before the head so that a head sharing a cell with
-- the tail of a snake about to grow still reads as the head.
for i = 1, #body do buf[spec_index(body[i])] = CELL_BODY end
buf[spec_index(body[1])] = CELL_HEAD
return table.concat(buf, "", 1, n)
end
function draw()
snail.center(true)
if not alive then
-- The score is the headline of a finished game, so it gets the big face.
-- The final position stays on the board below with the cause written
-- across it, because the cell that killed you is the thing to look at.
snail.title("GAME OVER")
snail.title(string.format("%d", score))
if hi then
snail.small("NEW BEST")
else
snail.small(string.format("BEST %d", best))
end
snail.gap()
snail.board(board_spec(), nil, note)
return
end
snail.title("SNAKE")
snail.small(string.format("SCORE %d BEST %d LEN %d", score, best, #body))
-- While the snake is alive the line above the board says which way it is
-- committed to going -- the last queued turn, or the live heading when
-- nothing is queued. A heading is always true where a message about the
-- last press goes stale after it.
if running then
snail.small("HEADING " .. DIR_NAME[qn > 0 and q[qn] or dir])
else
snail.small("STOPPED -- FACING " .. DIR_NAME[dir])
end
snail.gap()
-- The board is drawn last and takes every pixel left below the score.
snail.board(board_spec())
end
Cascade
cascade.lua · 17,258 bytes
Seven tetrominoes, line clears and a next-piece preview. Gravity takes a row every three seconds.
Read the sourceHide the source
--!name Cascade
--!icon game
--
-- Cascade, for Snail OS. The piece falls on its own, slowly, and the keys place
-- it: LEFT and RIGHT move, UP turns, DOWN steps it down a row itself, and OK
-- drops it the whole way and locks it in one frame.
--
-- WHY GRAVITY IS TWO SECONDS A ROW
-- This file used to have no gravity at all, on the reasoning that a panel taking
-- half a second per refresh would draw a falling piece three rows below where
-- the player last saw it. That is true of Cascade at its ORIGINAL speeds and it
-- is not true of gravity as such. A press costs one refresh, so placing a piece
-- takes four or five of them -- move, move, turn, look -- and a row of fall has
-- to be worth more than that or the player is fighting the panel instead of the
-- well. Three seconds is about six presses per row, which is enough to put a
-- piece exactly where it was meant to go and still not enough to sit and think
-- forever. The clock here is pressure, not pace; OK is still how a piece that
-- has been decided should reach the bottom.
--
-- NOTHING FALLS UNTIL THE FIRST PRESS, and it stops when the game ends. A
-- ticking app repaints, a repaint holds the panel's rails up, and the kernel
-- will not light-sleep the chip while they are up -- so a game left open on a
-- table must not be a game that keeps stepping.
--
-- WHY THE WELL IS 10 x 16
-- The well is drawn with snail.board(), so the engine picks the tile size from
-- the grid and the space left under the score. Ten columns is the width Cascade
-- has always been. Sixteen rows is what the content band pays for once the
-- title, the score, the state line and the next-piece tray have been taken out,
-- and it is short for Cascade -- the alternative was a well so small that an S
-- piece and a Z piece look the same.
--
-- The board is drawn LAST on the screen because the engine gives a board every
-- pixel that is left below it. Everything the player reads while thinking --
-- the score, what is in hand, what is coming -- sits above the well, and the
-- well takes the rest.
local COLS, ROWS = 10, 16
local CELL_EMPTY = "." -- a lattice, so an empty well still has depth
local CELL_LOCKED = "#"
local CELL_LIVE = "@" -- the piece you are still holding
local LINE_SCORE = {100, 300, 500, 800}
local GRAVITY = 2000 -- milliseconds per row of fall
-- Each piece is its cells in an N x N box, so one rotation rule covers all of
-- them: (x, y) becomes (N - 1 - y, x). The four rotations are unrolled ONCE at
-- load, below, from these base shapes -- one source of truth, and fits(), the
-- hottest call in the file, becomes a table read. The engine's fuel counter
-- runs across the life of the app rather than per press, so instructions spent
-- rotating the same piece the same way on every press are instructions that
-- eventually surface as "this app ran too long" mid-game.
local PIECES = {
{name = "I", n = 4, c = {{0,1},{1,1},{2,1},{3,1}}},
{name = "O", n = 2, c = {{0,0},{1,0},{0,1},{1,1}}},
{name = "T", n = 3, c = {{1,0},{0,1},{1,1},{2,1}}},
{name = "S", n = 3, c = {{1,0},{2,0},{0,1},{1,1}}},
{name = "Z", n = 3, c = {{0,0},{1,0},{1,1},{2,1}}},
{name = "J", n = 3, c = {{0,0},{0,1},{1,1},{2,1}}},
{name = "L", n = 3, c = {{2,0},{0,1},{1,1},{2,1}}},
}
local well = {} -- well[y][x], 0 empty, 1..7 a locked piece
local kind, rot, px, py = 1, 0, 3, 0
local nextkind = 1
local score, lines, best, dead, note = 0, 0, 0, false, ""
-- Whether gravity is running. Deliberately not saved: a game resumed from the
-- card sits still until the player presses something, so the well can be read
-- before the piece starts moving again.
local falling = false
-- ------------------------------------------------------------------- geometry
-- ROT[k][r] is a flat {x1,y1, x2,y2, x3,y3, x4,y4}: 28 small arrays built once,
-- read forever, and no table is allocated on any press after this loop runs.
local ROT = {}
for k = 1, #PIECES do
local p = PIECES[k]
ROT[k] = {}
for r = 0, 3 do
local out = {}
for i = 1, 4 do
local x, y = p.c[i][1], p.c[i][2]
for _ = 1, r do x, y = p.n - 1 - y, x end
out[i * 2 - 1], out[i * 2] = x, y
end
ROT[k][r] = out
end
end
local function cells_of(k, r) return ROT[k][r % 4] end
-- The landing row, memoised. The descent scan costs a fits() per row, draw()
-- runs it twice for one press, and OK walks the same descent again -- against
-- a fuel counter the engine runs over the app's whole life. The key packs the
-- piece's whole state; wellgen counts the events that change the well, which
-- is every lock, restart and restore, so a stale answer cannot survive one.
local wellgen, gkey, ggy = 0, -1, 0
local function fits(k, r, ox, oy)
local c = cells_of(k, r)
for i = 1, 8, 2 do
local x, y = ox + c[i], oy + c[i + 1]
if x < 0 or x >= COLS or y >= ROWS then return false end
-- Above the top of the well is legal, so a piece can rotate on the way in.
if y >= 0 and well[y][x] ~= 0 then return false end
end
return true
end
local function ghost_y()
local k = (((wellgen * 8 + kind) * 4 + rot % 4) * 16 + px + 2) * 32 + py
if k ~= gkey then
local gy = py
while fits(kind, rot, px, gy + 1) do gy = gy + 1 end
gkey, ggy = k, gy
end
return ggy
end
-- ---------------------------------------------------------------- the round
-- The locked rows, pre-rendered: rowspec[y] is the row as board cells and
-- rowdig[y] is its save-blob digits. The well only changes when a piece locks,
-- but draw() and save() run on every press, and 160 cells walked per frame is
-- most of what a press costs in VM instructions -- fuel the engine meters over
-- the app's whole life. Rendering a row once per lock instead leaves draw()
-- splicing eight characters over sixteen cached strings.
local rowspec, rowdig = {}, {}
local function refresh_row(y)
local rc, rd = {}, {}
for x = 0, COLS - 1 do
local v = well[y][x]
rc[x + 1] = v ~= 0 and CELL_LOCKED or CELL_EMPTY
rd[x + 1] = string.char(48 + v)
end
rowspec[y] = table.concat(rc)
rowdig[y] = table.concat(rd)
end
local function refresh_rows()
for y = 0, ROWS - 1 do refresh_row(y) end
end
local function clear_well()
for y = 0, ROWS - 1 do
well[y] = {}
for x = 0, COLS - 1 do well[y][x] = 0 end
end
end
-- spawn() does not touch `note`, because it runs after a line clear and the
-- clear is the thing the player wants told. lock() decides what the line says.
local function spawn()
kind, rot, px, py = nextkind, 0, 3, 0
nextkind = math.random(#PIECES)
if not fits(kind, rot, px, py) then
dead = true
if score > best then best = score end
end
end
local function clear_lines()
local kept, n = {}, 0
for y = ROWS - 1, 0, -1 do
local full = true
for x = 0, COLS - 1 do
if well[y][x] == 0 then full = false break end
end
if not full then
n = n + 1
kept[n] = well[y]
end
end
local cleared = ROWS - n
if cleared == 0 then return 0 end
clear_well()
-- Rows were collected bottom-up, so they go back bottom-up and the gap ends
-- at the top where a cleared line belongs.
for i = 1, n do well[ROWS - i] = kept[i] end
lines = lines + cleared
score = score + LINE_SCORE[cleared]
return cleared
end
-- The line under the well always says something that is still true. Naming the
-- piece in hand is worth more on a panel that redraws twice a second than a
-- message about the press before last, so that is the resting state and an
-- event only displaces it until the next piece arrives.
local function say_state()
if dead then
-- best has already been settled by the time this runs, so equalling it
-- means this game set it (a tie counts -- the player matched the record).
note = (score > 0 and score >= best) and "NEW BEST" or "GAME OVER"
else note = "IN HAND: " .. PIECES[kind].name end
end
local function lock()
wellgen = wellgen + 1
local c = cells_of(kind, rot)
for i = 1, 8, 2 do
local x, y = px + c[i], py + c[i + 1]
if y >= 0 then well[y][x] = kind end
end
local cleared = clear_lines()
-- A clear rearranges every row; a plain lock only dirties the ones the piece
-- reached. Refreshing a row twice is merely harmless, so the four cells are
-- not deduplicated.
if cleared > 0 then
refresh_rows()
else
for i = 1, 8, 2 do
local y = py + c[i + 1]
if y >= 0 then refresh_row(y) end
end
end
-- Settled here, not only in key(): a lock driven by the clock scores lines
-- too, and a best that lags until the next press is a best that lies.
if score > best then best = score end
spawn()
say_state()
if cleared > 0 and not dead then
-- Four at once is the game's own name. The word is the reward.
note = string.format("%s +%d", cleared == 4 and "CASCADE!"
or (cleared .. " LINE" .. (cleared > 1 and "S" or "")), LINE_SCORE[cleared])
end
end
local function new_game()
wellgen = wellgen + 1
clear_well()
refresh_rows()
score, lines, dead, falling = 0, 0, false, false
nextkind = math.random(#PIECES)
spawn()
say_state()
end
-- ---------------------------------------------------------------- persistence
-- Four counters first, then the live game after a semicolon. The well is one
-- digit per cell, which is 160 characters of the 1024 the engine allows.
local function save()
local rows = {}
for y = 0, ROWS - 1 do rows[y + 1] = rowdig[y] end
snail.save(string.format("%d %d %d %d ;%d %d %d %d %d %s",
best, score, lines, dead and 1 or 0,
kind, rot, px, py, nextkind, table.concat(rows)))
end
local function load()
local s = snail.load()
if not s then return end
local b, sc, ln, dd, k, r, x, y, nk, grid =
s:match("^(%d+) (%d+) (%d+) (%d+) ;(%d+) (%d+) (%-?%d+) (%-?%d+) (%d+) (%d*)$")
if not b then return end
best = tonumber(b)
-- A grid of the wrong length came from a different version of this file, so
-- only the best score is kept and the well is left as the fresh one.
if #grid ~= ROWS * COLS then return end
for gy = 0, ROWS - 1 do
for gx = 0, COLS - 1 do
local v = tonumber(grid:sub(gy * COLS + gx + 1, gy * COLS + gx + 1))
well[gy][gx] = (v and v >= 0 and v <= #PIECES) and v or 0
end
end
score, lines, dead = tonumber(sc), tonumber(ln), tonumber(dd) == 1
kind, rot, px, py = tonumber(k), tonumber(r), tonumber(x), tonumber(y)
nextkind = tonumber(nk)
if kind < 1 or kind > #PIECES then kind = 1 end
if nextkind < 1 or nextkind > #PIECES then nextkind = 1 end
-- A restored piece that does not fit would let the player push it through
-- the stack, so an impossible one is respawned rather than trusted. A stack
-- that rejects even the spawn point is a finished game, whatever the save
-- said, or the piece would sit embedded in the blocks that refused it.
if not fits(kind, rot, px, py) then
rot, px, py = 0, 3, 0
if not fits(kind, rot, px, py) then dead = true end
end
wellgen = wellgen + 1
refresh_rows()
say_state()
end
-- ------------------------------------------------------------------ lifecycle
local function set_hint()
if dead then
snail.hint("OK plays again BACK menu")
else
-- 2x SIDE throws the game away, so it must be named; BACK gives way, being
-- the one key that behaves the same in every app on the device.
snail.hint("L/R move OK drop BACK menu")
end
end
-- The one place gravity is armed or stopped, called after every press and every
-- fall, so the engine's clock and this file's idea of the game cannot disagree.
local function set_pace()
snail.tick((not dead and falling) and GRAVITY or 0)
end
function start()
snail.ink("fast") -- solid tiles, so the short waveform is honest here
new_game()
load()
set_hint()
set_pace()
end
function key(k)
-- A restart is not a move: the fresh well sits still until the first press
-- that actually plays, exactly as a game restored from the card does.
local restart = k == "top" or (dead and k == "ok")
if restart then
new_game()
elseif dead then
-- Every other key on a dead board is ignored; OK is the way back in.
elseif k == "left" then
if fits(kind, rot, px - 1, py) then px = px - 1 end
elseif k == "right" then
if fits(kind, rot, px + 1, py) then px = px + 1 end
elseif k == "up" then
-- A kick of one column each way, which is what makes a piece turnable
-- against a wall. Anything more elaborate would need a rotation system the
-- player cannot see on a board this small.
local r = (rot + 1) % 4
if fits(kind, r, px, py) then rot = r
elseif fits(kind, r, px - 1, py) then rot, px = r, px - 1
elseif fits(kind, r, px + 1, py) then rot, px = r, px + 1 end
elseif k == "down" then
if fits(kind, rot, px, py + 1) then
py, score = py + 1, score + 1
else
lock()
end
elseif k == "ok" then
-- The drop lands where the ghost stood, by the same call that drew it, so
-- the outline and the outcome cannot disagree.
local gy = ghost_y()
score = score + 2 * (gy - py)
py = gy
lock()
end
-- Any playing press is also the start signal, so the first key both places
-- the piece and sets the clock running. A restart is not one: it leaves the
-- fresh well still, so the board can be read before anything moves.
if not dead and not restart then falling = true end
if score > best then best = score end
set_hint()
set_pace()
save()
end
-- One row of fall, called by the engine every GRAVITY milliseconds. A piece
-- that cannot fall locks, which is the same path DOWN takes when it runs out of
-- room, so a piece resting on the stack is placed by the clock whether or not
-- the player presses anything.
function tick()
if not dead and fits(kind, rot, px, py + 1) then
py = py + 1
elseif not dead then
lock()
-- WRITTEN ON THE LOCK, NOT ON EVERY ROW. A lock is what changes the well; a
-- row of fall only moves a piece the player is looking at. A power cut mid
-- fall therefore costs the descent of one piece and nothing else, and the
-- card is not rewritten every three seconds for as long as the app is open.
save()
end
if dead then falling = false end
set_hint()
set_pace()
end
-- ---------------------------------------------------------------------- draw
-- A board spec is one string: rows separated by "/", one character per cell.
-- Everything here reads the caches kept above -- the tray per piece, the well
-- per lock -- and joins with one table.concat, because a string appended in a
-- loop is a throwaway allocation per cell against a 48 KB ceiling, and this
-- runs on every press.
-- The next piece, in its own 4 x 2 tray. Every piece at rotation 0 sits inside
-- those bounds, which is why the tray does not have to be as big as the piece
-- box the rotation rule uses. Seven pieces have seven trays, so each is built
-- on its first appearance and read from then on.
local TRAYS = {}
local function tray_spec()
local s = TRAYS[nextkind]
if s then return s end
local c = cells_of(nextkind, 0)
local hit = {}
for i = 1, 8, 2 do hit[c[i + 1] * 4 + c[i]] = true end
local out, n = {}, 0
for y = 0, 1 do
if y > 0 then n = n + 1 out[n] = "/" end
for x = 0, 3 do
n = n + 1
out[n] = hit[y * 4 + x] and CELL_LOCKED or CELL_EMPTY
end
end
s = table.concat(out)
TRAYS[nextkind] = s
return s
end
local function well_spec()
-- The live piece is spliced over the cached rows rather than written into
-- them, because draw() has to be able to run twice for one press. The ghost
-- — the outline where the piece would land if OK were pressed now — is
-- spliced the same way, first, so the live cell wins where they overlap and
-- a drop can be aimed without counting rows on a panel that repaints twice a
-- second. Both positions are fits()-valid, so neither ever covers a locked
-- cell.
local out = {}
for y = 1, ROWS do out[y] = rowspec[y - 1] end
if not dead then
local c = cells_of(kind, rot)
local gy = ghost_y()
local function put(x, y, ch)
local r = out[y + 1]
out[y + 1] = r:sub(1, x) .. ch .. r:sub(x + 2)
end
if gy > py then
for i = 1, 8, 2 do put(px + c[i], gy + c[i + 1], "o") end
end
for i = 1, 8, 2 do put(px + c[i], py + c[i + 1], CELL_LIVE) end
end
return table.concat(out, "/")
end
function draw()
snail.center(true)
-- The score is the headline. The header chrome already says Cascade, so the
-- title face goes to the number a player actually watches, under its own
-- small overline, the way a dashboard sets a figure.
snail.small("SCORE")
snail.title(tostring(score))
snail.small(string.format("LINES %d BEST %d", lines, best))
snail.small(note)
snail.small("NEXT")
snail.board(tray_spec(), "small")
-- The well is the last thing on the screen and takes every pixel that is left
-- below the tray. When the game is over the last position stays on the board
-- and the words are drawn across the middle of it, because which move killed
-- you is the thing worth looking at.
snail.board(well_spec(), nil, dead and ("GAME OVER " .. score) or nil)
end
Books
Through the Brazilian Wilderness
brazilian-wilderness.epub · 15,873,593 bytes
Roosevelt's illustrated account of his 1913-14 expedition through Brazil, prepared by Standard Ebooks.
Apps
Stocks
stocks.lua · 22,684 bytes
A watchlist you edit on the device, with Day, Month, and Quarter charts for each symbol.
Read the sourceHide the source
--!name Stocks
--!icon chart
--
-- A watchlist the owner edits on the device, and one symbol at a time with a
-- price chart, two moving averages and RSI. Nothing is fetched until asked for.
-- THE INTERFACE IS A HIERARCHY. LEFT goes up a level, RIGHT goes in, UP and DOWN
-- move within it, OK acts. This app needs its own way up because BACK leaves it
-- entirely and cannot be bound.
--
-- THE DATA
-- https://query1.finance.yahoo.com/v8/finance/chart/AAPL
-- ?range=3mo&interval=1d
--
-- No API key, no cookie, no session crumb, HTTPS. The v7 spark endpoint this
-- app shipped on now answers 429 to everything (verified 2026-08-10); v8 chart
-- still answers, carries the same meta names, and its extra open/high/low/
-- volume arrays cost only staging — the extractor pulls close[] by name and
-- steps past the rest. ~7.5 KB against a 24 KB staging buffer.
--
-- THE DOCUMENT NEVER BECOMES A LUA STRING. Cutting the series out of the body
-- with a pattern once put this app at 92% of its budget on a real BTC-USD
-- reply; the engine now hands the elements of a named array to a sink one at a
-- time, out of the staging buffer — see docs/LUA.md.
--
-- The endpoint is undocumented, so a failure on every symbol at once means Yahoo
-- changed something. Quotes run fifteen minutes late and there is no clock here,
-- so a price reads CACHED until it is refreshed in this session.
--
-- TWO CEILINGS: 49,152 bytes of memory and 24,576 bytes of source. A comment is
-- free against the first and paid for against the second, and this file is
-- within a hundred bytes of the second, so a new sentence has to displace one.
-- --- limits
local MAX_SYMS = 8 -- one round trip each, and 1 KB of saved state
local COLS = 30 -- the chart is a board, and a board is 32 cells wide
-- Board depth is affordable because the reply is not held while it is built.
local CHART_ROWS = 30
local SLOTS = 8 -- characters in a spelled symbol
-- MOVING AVERAGE PERIODS. Two trading weeks and one month, both defined across
-- all thirty drawn bars. Fifty and two hundred day averages cannot come out of
-- three months of data. RSI is fourteen and is computed Wilder's way with the
-- smoothed averages rather than as a plain mean of the last fourteen changes,
-- because the two disagree and the smoothed form is what every chart draws.
local MA_FAST, MA_SLOW = 10, 20
local period = 1
local HOST = "https://query1.finance.yahoo.com/v8/finance/chart/"
local function period_name()
return period == 1 and "Day" or (period == 2 and "Month" or "Quarter")
end
local function period_tail()
return period == 1 and "?range=1d&interval=5m"
or (period == 2 and "?range=1mo&interval=1d" or "?range=3mo&interval=1d")
end
local WHEEL = "ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789.-^ "
-- --- state
-- The list is the owner's; this is only what a card with no saved blob starts
-- with, and it holds no equity on purpose.
local list = {"^GSPC", "^IXIC", "^DJI", "BTC-USD"}
-- ONE QUOTE IS ONE STRING, parallel to `list` and in the same order. Its first
-- field is the watchlist row exactly as it is drawn, and the four after it are
-- the price, the change, the day's range and the volume for the symbol page.
-- Everything is formatted at the moment it arrives, so a frame of the watchlist
-- allocates one substring a row rather than a format, a match and an arrow.
-- `L` is parallel again and says whether that row was fetched in THIS session,
-- which is what keeps a number off the card from being drawn as though it were
-- live.
local Q, L = {}, {}
-- THE SERIES IS ONE INTERLEAVED RING of the last COLS columns, four numbers to a
-- column (close, fast average, slow average, RSI) at
-- ((Shead + c - 1) % COLS) * 4 + field for the c'th column left to right. A
-- column with no average holds false rather than nil, because a nil punches a
-- hole in the array and pushes the rest into the hash part. Only the open symbol
-- has a series: eight of them was six kilobytes to draw thirty numbers.
local S, Shead, Scols, Ssym = {}, 0, 0, nil
local function drop() S, Ssym, Scols, Shead = {}, nil, 0, 0 end
-- THE ACCUMULATORS LIVE HERE RATHER THAN INSIDE THE FETCH so that the sink is
-- one function made once when this file loads. A sink built per request is a
-- closure and a box for every upvalue it captures, allocated at the moment
-- the reply is arriving.
local Sn, Sfast, Sslow, Sprev, Sback = 0, 0, 0, 0, 0
local view = "home" -- "home" | "sym" | "edit" | "add"
local sel = 1
local esel, armed = 1, nil
local msg = ""
local buf, bpos = {}, 1 -- the spelling wheel, one WHEEL index per slot
-- --- numbers
-- Decimal places follow the PRICE's magnitude: two would print a seven cent
-- coin's every move as +0.00.
local function places(p)
p = math.abs(p or 0)
return p >= 1 and 2 or (p >= 0.01 and 4 or 6)
end
-- The one formatter. `sign` is the leading plus a change wants and a price does
-- not; a missing value arrives as false, the ring's way of saying no average.
local function num(v, d, sign)
if not v then return "--" end
return string.format("%" .. (sign and "+" or "") .. "." .. d .. "f", v)
end
-- Two thresholds rather than four: a billion and a million are the two a quote
-- reaches, and the rest is printed whole.
-- No calendar date and no company name: dates need the civil-from-days
-- arithmetic there is no os library for, and staleness reads CACHED instead.
local function sv(c, f)
return S[((Shead + c - 1) % COLS) * 3 + f]
end
-- --- persistence
-- One blob of at most 1024 bytes, so the series is not in it: thirty closes a
-- symbol would fill it on their own.
-- VERSION 3 IS THIS FORMAT, and 1 and 2 are the two before it. THE SYMBOL LIST
-- IS READ OUT OF ALL THREE: the owner spelt it in by hand on a wheel, and a
-- card that forgot it on an upgrade would cost him the only part he typed.
-- Cached quotes are not carried across — the older formats hold other fields
-- in other orders, and a price read out of the wrong slot is a wrong number
-- rather than an absent one — so a migrated row reads "--" until fetched.
-- (Version 2 spelled its three defaults the way stooq does; those arrive as
-- they were and are removed by hand, which the editor exists for. The table
-- that renamed them bought back the memory the chart's step line costs.)
local function save()
local out = {"3", table.concat(list, ",")}
for i = 1, #list do out[i + 2] = Q[i] or "-" end
local blob = table.concat(out, "|")
-- Cut at a field boundary: half a quote parses as a whole one.
if #blob > 1024 then blob = blob:sub(1, 1024):match("^(.*)|") or "3" end
snail.save(blob)
end
local function load()
local blob = snail.load()
if not blob then return end
local ver, n = nil, 0
for p in blob:gmatch("[^|]+") do
n = n + 1
if n == 1 then
ver = p
if ver ~= "1" and ver ~= "2" and ver ~= "3" then return end
elseif n == 2 then
local fresh = {}
for s in p:gmatch("[^,]+") do
if #fresh < MAX_SYMS then
fresh[#fresh + 1] = s
end
end
if #fresh > 0 then list = fresh end
elseif ver == "3" and n - 2 <= #list and p ~= "-" then
-- A row with no quote was written as a dash, because gmatch skips an
-- empty field and one skipped field shifts every quote after it.
Q[n - 2] = p
end
end
end
-- --- the chrome
-- The status band is a fixed reserve the kernel drops when there is no status,
-- which would give the chart a different tile size, so there is always one.
local HINT = {
home = "OK open BACK menu",
sym = "LEFT back OK Fetch RIGHT Page",
-- A fourth entry wrapped the footer; 2x SIDE still works, and home names it.
edit = "LEFT back OK add RIGHT remove BACK menu",
add = "L/R slot OK add BACK menu",
}
local function chrome()
snail.header(view == "sym" and (list[sel] or "Stocks") or "Stocks")
local st
if view == "sym" then
st = not Q[sel] and "no data yet"
or (L[sel] and "delayed ~15 min" or "CACHED - not refreshed yet")
elseif view == "edit" then
st = armed and "RIGHT again removes it" or "editing the watchlist"
elseif view == "add" then
st = "spelling"
else
st = #list .. " on the watchlist"
end
snail.status(msg ~= "" and msg or st)
snail.hint(HINT[view])
end
-- --- fetch
-- THE FOUR SCALARS AND THE SERIES, FROM ONE REQUEST. The names before `close[]`
-- are read out of the meta block wherever it sits in the reply, and the array
-- after them is delivered to the sink one close at a time. Nothing larger than
-- one number is ever a Lua value, so the worst instant here is the ring of
-- thirty columns rather than the ring plus a six-kilobyte document. A fetch is
-- started from key() and never from draw(), which runs twice a press; a refresh
-- goes through snail.after(), so the press is answered before the radio is.
local FIELDS = "regularMarketPrice,chartPreviousClose,close[]"
-- ONE CLOSE, AS IT ARRIVES. This is the sink itself, so a number goes from the
-- wire into the ring without a Lua value living longer than the call. Both
-- running sums evict the value that has just left their window by reading it
-- back out of the display ring rather than out of a second buffer, which works
-- because both periods are shorter than the ring. RSI's first value is the plain
-- average of the first RSI_N changes and every one after it smooths the previous
-- with the new change, which is the recursion that makes this Wilder's RSI. No
-- losses would divide by zero, so that case reads 100, the limit it approaches.
--
-- A bar that did not trade arrives as nil and the previous close is carried
-- forward: dropping one shortens the series, and a zero spikes the chart and
-- drags both averages through it. The engine hands over a bare JSON number as a
-- number; tonumber() is here for the day a vendor quotes them instead.
local function step(tok)
local v = tonumber(tok) or Sprev
if not v then return end
local n = Sn + 1
Sn = n
if n > MA_SLOW then Sslow = Sslow - S[((n - MA_SLOW - 1) % COLS) * 3 + 1] end
if n > MA_FAST then Sfast = Sfast - S[((n - MA_FAST - 1) % COLS) * 3 + 1] end
Sfast, Sslow = Sfast + v, Sslow + v
Sback, Sprev = Sprev, v
local o = ((n - 1) % COLS) * 3
S[o + 1] = v
S[o + 2] = n >= MA_FAST and Sfast / MA_FAST or false
S[o + 3] = n >= MA_SLOW and Sslow / MA_SLOW or false
end
local function series_key(sym)
return sym .. ":" .. period_name()
end
local function reset_series()
Sn, Sfast, Sslow, Sprev, Sback = 0, 0, 0, 0, 0
end
local function remember(sym)
local out = {Q[sel] or "-"}
for c = 1, Scols do out[c + 1] = tostring(sv(c, 1)) end
snail.cache(series_key(sym), table.concat(out, "|"))
end
local function recall()
local sym = list[sel]
if not sym then return false end
-- Release the previous period before asking Lua to allocate the saved copy.
-- The card API enforces the same memory ceiling on cache reads as fetches.
drop()
reset_series()
local saved = snail.cache(series_key(sym))
if not saved then return false end
local quote, closes = saved:match("^([^|]*)|(.*)$")
if not quote or quote == "-" then return false end
for value in closes:gmatch("[^|]+") do step(value) end
if Sn < 2 then drop() return false end
Ssym, Scols, Shead = series_key(sym), math.min(Sn, COLS), 0
Q[sel], L[sel] = quote, nil
return true
end
local function fetch()
local i = sel
local sym = list[i]
if not sym then return end
-- No "fetching" status: this runs inside snail.after, and nothing paints
-- again before it returns, when msg is already overwritten.
-- THE SERIES GOES BEFORE THE REQUEST DOES when the symbol is not the one the
-- ring holds, so that two symbols' closes are never in memory at once. A
-- refresh of the symbol on screen keeps its chart, so a refresh that never
-- ran the sink leaves the last one that worked.
local key = series_key(sym)
if Ssym ~= key then drop() end
reset_series()
-- The names in front of the array are read out of the reply's meta block and
-- come back as a small table of strings; the closes go straight to step().
local head, why = snail.fetch(HOST .. sym:gsub("%^", "%%5E") .. period_tail(),
FIELDS, step)
local price = head and tonumber(head.regularMarketPrice)
if not price or Sn < 2 then
-- A parsed reply ran the sink over a kept ring, so the kept chart no
-- longer matches Scols and Shead and goes with it.
if head and Ssym then drop() end
msg = head and "no price in the reply" or why or "no answer"
return chrome()
end
-- THE PREVIOUS CLOSE IS NOT A FIELD HERE. meta carries chartPreviousClose,
-- the close before the whole range rather than yesterday's, so it comes out of
-- the series, and which bar depends on whether the last is today's unfinished
-- one. Comparing them stops a stale change all weekend.
local eps = math.max(0.01, math.abs(price) * 1e-5)
local livebar = math.abs(Sprev - price) < eps
local series_pc = livebar and Sback or Sprev
local pc = (period == 1 and tonumber(head.chartPreviousClose)) or series_pc or price
-- The chart should end on the number printed above it, so the live price
-- becomes one more column when the series does not already carry it.
if not livebar then step(price) end
Ssym = key
Scols = math.min(Sn, COLS)
Shead = Sn > COLS and Sn % COLS or 0
-- The row and the page are both written here, once, so that no frame has to
-- work out what a quote looks like while it is being drawn.
local dp = places(price)
local d = price - pc
local pt = num(price, dp)
local pf = pc ~= 0 and string.format("%+.2f%%", d / pc * 100) or "--"
Q[i] = string.format("%-9s %s %s %s", sym, pt,
(d > 0 and "^") or (d < 0 and "v") or "=", pf)
L[i] = true
msg = ""
save()
remember(sym)
-- Without this, a quote fetched a moment ago still wears the CACHED band.
chrome()
end
local function blank()
for i = 1, SLOTS do buf[i] = #WHEEL end
end
local function spelled()
local out = {}
for i = 1, SLOTS do out[i] = WHEEL:sub(buf[i], buf[i]) end
-- Every space goes: "B TC" is a URL with a hole in it.
return (table.concat(out):gsub(" ", ""))
end
-- --- the plot
-- CAN snail.board() CARRY A CHART. A board is a grid of up to 32 by 32 tiles,
-- each taking one of five silhouettes: a quantised plot carrying three series
-- told apart by shape because the panel is one bit. The close is joined into a
-- step line — every column also fills to the row where the one before it ended
-- — so it reads as a line; the averages stay one mark per column.
--
-- A SERIES BECOMES ONE ROW NUMBER PER COLUMN, added into `out` at digit `mul`.
-- `k`, when given, is drawn across every column, which is how RSI's guide
-- lines share its scale. The first pass (mul of 1) is the close, and each of
-- its columns becomes a run reaching the row the one before it ended on.
local function rows_of(f, lo, hi, rows, out, k, mul)
local span = hi - lo
if span <= 0 then span = 1 end
local p
for c = 1, Scols do
local v, r = k or sv(c, f), 0
if v then
r = rows - math.floor((v - lo) / span * (rows - 1) + 0.5)
if r < 1 then r = 1 elseif r > rows then r = rows end
end
if mul == 1 then
if r > 0 then
local q = p or r
p = r
r = (q < r and q or r) * 100 + (q < r and r or q)
end
out[c] = r
else
out[c] = out[c] + r * mul
end
end
end
-- The close wins the cell, so it is never hidden. Its run sits in the low four
-- digits as top * 100 + bottom, the averages two digits each above it, 0 for
-- absent: three arrays of thirty crossed 70% of memory.
local function board(rows, r1, c2, c3)
local out, line = {}, {}
for r = 1, rows do
for c = 1, Scols do
local v = r1[c]
local a = v % 10000
line[c] = (a > 0 and r >= a // 100 and r <= a % 100 and "#")
or (v // 10000 % 100 == r and c2)
or (v // 1000000 == r and c3) or "."
end
out[r] = table.concat(line)
end
return table.concat(out, "/")
end
-- --- the symbol
local function draw_sym()
local s = list[sel]
if not s then
snail.title("Nothing selected")
snail.text("The watchlist is empty. LEFT goes back to it.")
return
end
local mine = Ssym == series_key(s) and Scols > 1
snail.small(period_name())
if not mine then
snail.title("No series")
snail.gap()
snail.text("Press OK to load the history for " .. s .. ".")
snail.small("Day, Month and Quarter are kept separately on the card.")
return
end
local r1 = {}
local rows, lo, hi = CHART_ROWS, sv(1, 1), sv(1, 1)
local dp = places(sv(Scols, 1))
for c = 1, Scols do
for f = 1, 3 do
local v = sv(c, f)
if v then
if v < lo then lo = v elseif v > hi then hi = v end
end
end
end
snail.small("high " .. num(hi, dp) .. " low " .. num(lo, dp))
-- The legend and graph go through the same board renderer, so each close,
-- MA10 and MA20 marker has identical pixels in both places.
snail.board("#.........*.........o.........")
snail.small("close MA" .. MA_FAST .. " MA" .. MA_SLOW)
snail.rule()
rows_of(1, lo, hi, rows, r1, nil, 1)
rows_of(2, lo, hi, rows, r1, nil, 10000)
rows_of(3, lo, hi, rows, r1, nil, 1000000)
snail.board(board(rows, r1, "*", "o"))
end
-- --- the app
function start()
load()
if #list == 0 then list = {"^GSPC"} end
sel, period, esel, view, armed, msg = 1, 1, 1, "home", nil, ""
blank()
chrome()
end
-- One handler for four views. The watchlist and its editor are the same list
-- with a different cursor and a different RIGHT, so they share a branch.
function key(k)
-- UP and DOWN mean the same thing in all four views, a step that wraps at both
-- ends, so the direction is worked out once and each view says only what it
-- steps through. The wheel turns the other way: UP raises the letter.
local d = (k == "down" and 1) or (k == "up" and -1) or 0
if view == "add" then
if d ~= 0 then
buf[bpos] = (buf[bpos] - 1 - d) % #WHEEL + 1
elseif k == "right" then
bpos = bpos < SLOTS and bpos + 1 or SLOTS
elseif k == "left" then
if bpos > 1 then bpos = bpos - 1 else view, msg = "edit", "" end
elseif k == "ok" then
local s = spelled()
local dup = false
for _, e in ipairs(list) do if e == s then dup = true end end
if s == "" then msg = "nothing spelled yet"
elseif dup then msg = s .. " is already on the list"
elseif #list >= MAX_SYMS then msg = "the watchlist holds " .. MAX_SYMS
else
list[#list + 1] = s
save()
msg, view, esel = "added " .. s, "edit", #list
end
end
elseif view == "sym" then
if k == "left" then
view, msg = "home", ""
elseif k == "right" then
period = period % 3 + 1
msg = ""
if not recall() then snail.after(fetch) end
elseif d ~= 0 then
if #list > 0 then sel = (sel - 1 + d) % #list + 1 end
msg = ""
if not recall() then snail.after(fetch) end
elseif k == "ok" or k == "top" then
snail.after(fetch)
end
else
local edit = view == "edit"
local rows = #list + 1 -- the last row edits, or spells
if d ~= 0 then
armed = nil
local cur = (((edit and esel or sel) - 1 + d) % rows) + 1
if edit then esel = cur else sel = cur end
elseif k == "left" then
if edit then
view, armed, msg = "home", nil, ""
if sel > #list then sel = math.max(1, #list) end
end
elseif k == "top" then
-- To the first row. The watchlist is the owner's own and lives on the
-- card, and each quote is its own request made when a symbol is opened,
-- so there is no list to ask for again and the footer says so.
armed = nil
if edit then esel = 1
else sel, period = 1, 1 end
elseif edit and k == "right" and esel <= #list then
if armed == esel then
-- The cached quote goes with the symbol rather than sitting in the blob
-- as a price for a row nobody can see. The parallel arrays are shifted
-- by hand, because a row never fetched leaves a hole and a table with a
-- hole in it has no length.
local n = #list
local gone = list[esel]
table.remove(list, esel)
for j = esel, n - 1 do Q[j], L[j] = Q[j + 1], L[j + 1] end
Q[n], L[n] = nil, nil
if Ssym and Ssym:sub(1, #gone + 1) == gone .. ":" then drop() end
armed, msg = nil, "removed"
if esel > #list then esel = math.max(1, #list) end
if sel > #list then sel = math.max(1, #list) end
save()
else
armed = esel
end
elseif k == "ok" or k == "right" then
armed = nil
if edit then
if esel > #list then
view, bpos, msg = "add", 1, ""
blank()
end
elseif sel > #list then
view, msg = "edit", ""
esel = math.min(esel, #list + 1)
else
period, view, msg = 1, "sym", ""
if not recall() then snail.after(fetch) end
end
end
end
chrome()
end
-- The watchlist and its editor are one screen drawn twice: prices on the rows
-- in one, what RIGHT would do in the other.
function draw()
if view == "sym" then return draw_sym() end
if view == "add" then
snail.center(true)
snail.title("Add a symbol")
snail.gap()
local out = {}
for i = 1, SLOTS do
local c = WHEEL:sub(buf[i], buf[i])
if c == " " then c = "_" end
out[i] = (i == bpos) and ("[" .. c .. "]") or (" " .. c .. " ")
end
snail.title(table.concat(out))
snail.gap()
local s = spelled()
snail.text(s == "" and "(nothing yet)" or s)
snail.gap()
snail.small("An index carries its caret: ^GSPC. A coin is BTC-USD.")
snail.small(msg)
return
end
-- The house list: rows and nothing else, with one small scope line where the
-- editor names itself. The action row wears the settings glyph.
local edit = view == "edit"
if #list == 0 then
snail.center(true)
snail.gap()
snail.text("The watchlist is empty.")
snail.small("Open the editor below and spell a symbol.")
snail.center(false)
end
local cur = edit and esel or sel
for i, s in ipairs(list) do
local q = not edit and Q[i]
snail.row(edit and ((armed == i and "remove " or "") .. s)
or (q and q:match("^[^\t]*") or (s .. " --")), i == cur, "chart")
end
snail.row(edit and "Add a symbol" or "Edit watchlist", cur > #list,
"settings")
end
Publishing
Wrote something worth sharing? Send the file — reply to your receipt or use the address on the main page. Everything published here is readable in full before anyone installs it. A card app cannot write to the card and does not run on the CPU, so reading the file is the whole audit.