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
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
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 99The 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.