Lua · One app, walked

billy.lua, end to end.

One deck, a dealer who talks, a bankroll that survives a reboot, and a portrait built from a photograph. It is 266 lines and it is the reference app — read it before writing your first one.

The whole source is on the apps page. This page is the parts worth explaining.

All the state is at the top§

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

Locals, not globals. The engine loads the file once per open, so a local at file scope lives exactly as long as the app is on screen. Three phases carry the whole app: bet, play, over. Both key() and draw() branch on the same variable, which is what keeps them agreeing about what is on the screen.

The deck carries its own face text§

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

A card holds the string it will be drawn as, so nothing has to map an index back to a name at draw time. Suits are letters because the mono faces on this device are ASCII only, and a real suit glyph paints as noise at one bit.

The shuffle is a plain Fisher-Yates over 52 tables. That is 52 allocations of about 40 bytes each, which is the sort of thing the memory budget is comfortable with — the budget cares about the size of the largest single block, and none of these is large.

Aces are eleven until that busts§

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

Counting aces down one at a time is what makes a soft hand soft. The function is called from draw() as well as from the round logic, and it reads nothing but its argument, which is what lets draw() stay a pure function of the app's state.

A press resolves the whole turn§

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 dealer plays out his entire hand inside one key press rather than a card per frame. On a panel that takes half a second to redraw, watching him think for four seconds is a wait rather than suspense. Nothing in this app ticks: you press, the table resolves, it draws once.

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()
      snail.hint("OK hit   DOWN stand   LEFT double   BACK menu")
    end

  elseif phase == "play" then
    if k == "ok" then
      draw_card(you)
      if total(you) > 21 then dealer_plays()
      else message = billy_says(total(you) >= 17 and "close" or "hit") end
    elseif k == "down" then
      dealer_plays()
    elseif k == "left" and #you == 2 and bank >= bet * 2 then
      doubled = true
      draw_card(you)
      dealer_plays()
    end

  else -- over
    if k == "ok" then
      phase = "bet"
      message = "Again?"
      snail.hint("UP/DOWN bet   OK deal   BACK menu")
    end
  end
end
key() is where every decision is made. Note that a key with nothing to do in the current phase falls through and changes nothing.

Four numbers on one line§

local function save()
  snail.save(string.format("%d %d %d %d", bank, wins, losses, pushes))
end

The blob is keyed on a hash of the filename, so it follows the app rather than the slot it landed in. The matching load() reads it with a pattern and only assigns if the whole pattern matched, so a truncated or garbled blob resets to the defaults rather than half-restoring a bankroll.

The bankroll can reach zero, and the app handles that as a rule of the game instead of a dead end:

  if bank < MIN_BET then
    bank = BANK_START
    message = billy_says("broke")
  end

Drawing names no coordinate§

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.rule()
    snail.title("BET  $" .. bet)
    snail.gap()
    snail.text(message)
    return
  end

  snail.center(true)
  snail.small(phase == "over" and outcome or "BILLY")
  snail.cards(hand_spec(billy, phase == "play"))
  snail.small(phase == "play"
              and ("showing " .. total({billy[1]}))
              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()
  snail.text(message)
  if phase == "over" then
    snail.gap()
    snail.small(string.format("$%d   OK to play on", bank))
  end
end

Two screens, chosen by phase, each one a list of appends. The betting screen shows the portrait; the table does not, because at the large text size a 213-pixel face would push your own hand off the bottom of the panel. That is a layout decision made in the app, with no layout code in it.

snail.cards(spec) takes a string — "AS 10H ?" — and the firmware draws the faces, so the deck looks the same in every game on the device and this app never names a size. Nothing on the table draws a hairline: the cards are the structure, and air separates the two hands instead of a rule struck between them.

function start()
  snail.ink("fast")   -- solid tiles, so the short waveform is honest here
  load()
  phase = "bet"
  message = "Sit down."
  snail.hint("UP/DOWN bet   OK deal   BACK menu")
end
start() asks for the short waveform. Card faces are solid black on white, so the softer edges it trades away are invisible here — and it would be the wrong call in an app made of paragraphs.

Billy's line is picked from what actually happened rather than at random, so when he calls you unlucky it is because you were.

What the linter says about it§

tools/lua-lint apps/billy.lua

  source: 9226 B of 24576 (37%)
  apps/billy.lua: 4000 frames, 40894 draw calls, 0 ticks, no error
  one frame draws: cards=2  center=1  gap=2  small=4  text=1
  memory (unpaired): peak 33915 B of 49152 (69%), held 28417 B,
                     largest block 1320 B, 0 collections forced
  worst frame: 33915 B at step 47 of 4000
  memory (paired): peak 33915 B of 49152 (69%), held 28381 B,
                   largest block 1320 B, 0 collections forced
  30000 steps: saved state stayed in [5, 5469], never negative
  save blob: 1005 701 1548 99

The invariant pass is the one worth having here. It replays 30,000 steps and reads the saved blob every time, checking that it parses and that the bankroll never goes negative. A game whose money can go below zero should fail the lint rather than ship.

The memory lines are the other half. Billy peaks at 69% of the 48 KB, under the linter's 70% ceiling by a single point — the shuffle allocates 52 tables and the display list carries two rows of card faces. An app at 93% passes nothing, whatever it does on the bench.

Next: The Store