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

Billy's Blackjack

billy.lua · 10,971 bytes + billy.art · 5,990 bytes

One deck and a dealer who talks. Double down on the first two cards; blackjack pays three to two.

Read 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 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 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 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.