shellcade games are multiplayer terminal games on a fixed 80×24 canvas, reachable over SSH at shellcade.com. You write a game against this SDK, test it locally (no wasm, no network, no setup), compile it to WebAssembly, and submit the artifact — the arcade hosts it sandboxed. You never need shellcade's source; this module and ABI.md are the whole contract.
Grab shellcade-kit — the one author tool (scaffold, verify, play
artifacts) — from this repo's
Releases, then:
shellcade-kit new mygame
cd mygame && go mod tidy && go run .(macOS may quarantine the downloaded binary:
xattr -d com.apple.quarantine shellcade-kit clears it.)
You're playing your game. Edit main.go, go run . again — the inner loop is
a normal Go program (debugger, prints, sub-second builds). The wasm toolchain
only enters when you want the real artifact.
Prefer Rust? shellcade-kit new --rust mygame scaffolds the same game shape
on the Rust SDK — this guide's mental model carries over
one-for-one (the Rust README has the Go↔Rust dictionary).
A game is a function from events to frames. You implement six callbacks; the arcade calls them one at a time (never concurrently) and you respond by composing 80×24 frames:
type Handler interface {
OnStart(r Room) // the room exists
OnJoin(r Room, p Player) // someone sat down
OnLeave(r Room, p Player) // someone left
OnInput(r Room, p Player, in Input) // someone pressed a key
OnWake(r Room) // the heartbeat (see below)
OnClose(r Room) // the room is over
}Three rules shape everything:
-
Your code only runs inside callbacks. There are no goroutines-between- events, no
time.Sleep, nosetTimeout-equivalents — built-in timers simply never fire.OnWake, called ~20×/second while players are connected, is the only way your game advances without input. -
All state lives in your room struct. There's nowhere else. The arcade can freeze an idle room (snapshot) and revive it later — even across deploys — and your game won't notice. One consequence: key durable per-player state by
Player.AccountID, never byPlayer.Conn(connections change across a freeze; accounts don't). -
Render on change, frames by pointer, reuse the buffer. Compose a
*FrameandSendit whenever your state changed — from any callback. Never passFrameby value (~46KB; ABI.md §6), and prefer ONE long-lived frame overNewFrame()per render —Sendcopies immediately, so the allocation-free idiom is:type room struct{ frame *kit.Frame /* ... */ } // allocate once // per render, per player: rm.frame.Clear() rm.drawFor(p, rm.frame) r.Send(p, rm.frame)
The sandbox has no timers, no goroutines, no time.Sleep — your code runs
only inside callbacks, and the only one that fires without player input is
OnWake (the host heartbeat, ~20×/second while anyone is connected). So every
"later…" becomes a comparison against r.Now() inside OnWake.
r.Now() is the room clock the host owns: monotonic-enough for game logic, the
same value your language's time.Now() would return (the arcade virtualizes
the clock, so accidental use is harmless), and — under go run . -seed N — a
deterministic virtual clock that advances one heartbeat per wake. Build your
time logic against r.Now() and it behaves identically native, in wasm, and
under test.
The four idioms below are everything. They are drawn from the pokies game
in the public catalog,
a 1–5 player slot machine that exercises every one of them in production code —
read its room.go
for the full, working versions. (The snippets bind now := r.Now() once at the
top of OnWake; that's the only clock read you ever need.)
Want: do something once, T later. Don't: start a timer. Do: store
the deadline now; check it every wake; act when Now() passes it.
// set the deadline when the event happens:
rm.flashUntil = r.Now().Add(flashDur)
// expire it on the heartbeat, in OnWake:
if rm.flash != "" && now.After(rm.flashUntil) {
rm.flash = ""
}The state (flashUntil) lives in your room struct, so it survives a
hibernation freeze/thaw — a real AfterFunc would not. This is the whole
pattern; "flash a banner for 1.5s" is the same shape as "expire a power-up".
Want: several things to land at offset times (reel 0 at +150ms, reel 1 at +400ms, …). Do: derive each deadline from one start time and an index; land whatever is due this wake.
// in OnWake: land every reel whose deadline has passed.
for i := rm.spin.landed; i < 3; i++ {
due := rm.spin.startedAt.Add(reelStopBase + time.Duration(i)*reelStopStep)
if !now.After(due) {
break // not due yet, and later reels are even later — stop
}
rm.landReel(r, i)
}Because deadlines are derived from startedAt, a slow frame, a paused tab,
or a hibernation gap can never desync them: when wakes resume, every overdue
reel lands in order on the next heartbeat. Never advance an index "per wake" —
that couples your animation to the (variable) heartbeat rate.
Want: a smooth, repeating cadence (the reels scrolling while they spin).
Do: compute the current cycle from elapsed time, every render. Never
keep a counter you ++ each wake.
// which scroll frame are we on right now?
func (s *spinState) cycle(now time.Time) int {
return int(now.Sub(s.startedAt) / cycleRate)
}A derived clock is framerate-independent (looks right whether the heartbeat is
20Hz or 5Hz), reproducible under -seed, and hibernation-stable. The same
trick drives a repeating cadence: "fire every N" is now.Sub(start) / N
changing value — or, for a periodic config refresh, a rolling next-deadline:
// in OnWake: re-read admin config on a slow cadence.
if now.After(rm.nextCfg) {
rm.loadVariant(r)
rm.nextCfg = now.Add(configRefresh)
}A turn timer is just idiom #1 with a player-facing countdown. Store the
deadline when the turn begins; show deadline.Sub(now) in your frame; on the
wake where now.After(deadline), end the turn (auto-fold, skip, time-out).
// armed when the turn (or, in pokies, a big-win banner) begins:
rm.ticker = ticker{text: msg, until: r.Now().Add(tickerDur)}
// read while live, in your renderer:
func (rm *room) tickerActive(now time.Time) bool {
return rm.ticker.text != "" && now.Before(rm.ticker.until)
}For a real turn timeout, swap "clear the banner" for "advance the turn" in the
now.After(deadline) branch of OnWake.
| You had | You write |
|---|---|
time.AfterFunc(d, fn) |
store deadline = Now().Add(d); in OnWake, if Now().After(deadline) { fn() } (idiom 1) |
time.NewTimer you Reset |
same, and reassign deadline when you'd Reset |
time.NewTicker(d) |
derive: cycle := Now().Sub(start) / d — read it, don't accumulate (idiom 3) |
time.Sleep(d) then act |
there is no sleep; it's a one-shot deadline (idiom 1) |
| N staggered callbacks | precompute each deadline = start + offset(i); land what's due (idiom 2) |
| a countdown a player sees | idiom 1 + render deadline.Sub(Now()) each frame (idiom 4) |
context.WithTimeout for a turn |
a turn deadline checked in OnWake (idiom 4) |
Three traps the idioms avoid:
- Don't accumulate.
cycle++each wake drifts with heartbeat rate and breaks across hibernation. Derive from elapsed time instead. - Keep all time-state in the room struct. It's the only thing that survives a freeze; a host-side timer wouldn't exist after a thaw.
OnWakedoes the work, then renders once. Advance every due deadline, thenSendthe frame: expire flashes, land reels, refresh config, and call yourrenderlast.
Native clock: pass
-seedandr.Now()becomes a virtual clock — it starts at a fixed epoch derived from the seed and advances by exactly one heartbeat perOnWake(and at no other moment), so a-seedrun is reproducible: same seed, same frames. Without-seed,r.Now()is the real wall clock (handy for feeling out timing live). The wasm path (shellcade-kit check) is still the determinism source of record, and thekittestpackage gives you a virtual clock in unit tests.
OnInput delivers raw keys; resolve them through the platform's canonical
control vocabulary so your game matches every other game's conventions
(↑/k, ↓/j, Enter/Space confirm, Esc/q back):
switch kit.Resolve(in, kit.CtxNav) {
case kit.ActUp: // ...
case kit.ActConfirm: // ...
}Declare your input context once (r.SetInputContext): CtxNav for menus and
move-around games, CtxCommand when letters are commands (blackjack's
h/s/d), CtxText for typing games.
Players on phones reach the arcade through a touch deck that produces exactly
the canonical vocabulary — arrows, Confirm, Back — plus the OS soft keyboard
when your game publishes CtxText. A game that resolves everything through
Resolve is fully playable on touch with zero work.
If your game reads inputs beyond the vocabulary — letter commands like
r to resign, or a named key like Backspace to undo — declare them in
GameMeta.Controls so touch front ends can surface each one as a tappable
button carrying your label:
Controls: []kit.ControlDecl{
kit.RuneControl('r', "RESIGN"),
kit.RuneControl('d', "DRAW"),
kit.KeyControl(kit.KeyBackspace, "UNDO"),
},A declaration is presentation metadata only — tapping the button delivers the
exact declared input to OnInput, indistinguishable from the key, and
declarations never change how any input resolves. Keep labels short (≤16
runes; they render as small chips), declare at most the handful of inputs
that matter (≤32), and don't declare what the vocabulary already covers.
Expect players whose only inputs are the canonical actions, your declared
controls, and (in CtxText) free text — if that set can't play your game,
neither can a phone.
Terminals (and SSH) deliver only discrete key events — there is no key-up,
and "holding" a key produces press → pause → auto-repeat at the user's
terminal rate. For hold-to-thrust/fire semantics, use the keyhold package,
which derives held state from repeat timing:
import "github.com/shellcade/kit/v2/keyhold"
rm.keys = keyhold.New(0) // in NewRoom
rm.keys.Observe(in, r.Now()) // in OnInput
if rm.keys.Held(kit.KeyUp, r.Now()) { ... } // in OnWakeIntegrate physics against elapsed time while held — never per input event
(event rate is the user's repeat rate). The trade-off is release latency (a key
reads held for up to the linger after letting go); tune keyhold.New(linger)
for feel.
More action-game realities:
Resolveis menu-shaped (Up/Down/Confirm/Back). For a shooter, read the rawInputdirectly and keepResolvefor your menus.- Cells are ~2:1 tall. Circular motion and round collisions need an aspect correction (multiply y-velocity/area by ~0.5) or everything looks ovoid.
- Double-width glyphs (CJK, many emoji).
Frame.Textis one-rune-one-cell, so a wide rune would visually overrun its neighbour. UseFrame.SetWide(row, col, r, st)instead: it writes the glyph plus a marked continuation cell so the rune owns both columns. It returns the next free col (col+2), and refuses (draws nothing) when the pair can't fit — out of bounds or at the right edge (col == Cols-1) — matchingSet's drop-on-overflow rule. - Reserved keys: the local runners reserve
Esc/Ctrl-C(leave) andCtrl-T(seat switch); in the arcade,Escis the lobby's Back. Don't bind gameplay to them.
Some emoji are a single visible glyph built from several Unicode code
points: a heart plus a VS16 variation selector (❤️ = ❤ + U+FE0F), a thumbs-up
plus a skin-tone modifier (👍🏽), a keycap (1️⃣ = 1 + U+FE0F + U+20E3). A cell
holds up to three code points for exactly this, and you write one with
SetGrapheme:
f.SetGrapheme(row, col, "❤️", st) // heart + VS16, one cell, width 1
f.SetGrapheme(row, col, "1️⃣", st) // keycap: '1' + VS16 + U+20E3
w := f.SetGraphemeWide(row, col, "👍🏽", st) // a width-2 grapheme; w == col+2SetGrapheme(row, col, cluster, st) intwrites the cluster into one cell (base → first code point, then the extras) and returns the next column (col+1).SetGraphemeWide(...)is the width-2 companion — it marks a continuation cell atcol+1and returnscol+2, mirroringSetWide, and refuses (draws nothing) at the right edge rather than drawing a half-glyph.- Three code points is the ceiling. A cluster that decodes to more than
three code points — a family ZWJ emoji like
👨👩👧(man + ZWJ + woman + ZWJ- girl, five code points) — is unsupported.
SetGraphemerefuses it: it draws nothing and returnscolunchanged. Refusal (not truncation) is deliberate — truncating to three code points would render a different, valid-looking glyph (a lone person instead of a family), a worse surprise than a blank.
- girl, five code points) — is unsupported.
- Width is your contract, and your risk. The SDK never measures display
width — it does not pull a Unicode width table into your wasm artifact.
Declaring width via
SetGraphemeWideis the same author's-contract / author's-risk deal asSetWidetoday: viewer terminals disagree on how wide an emoji sequence (ZWJ, VS16, skin-tone, keycap) actually renders, so test on the terminals you care about and lay out defensively. A wrong width guess shifts everything to its right on terminals that disagree. - Ordinary text is unchanged.
SetRune,Set,Text, andSetWideare exactly as before — single-code-point writers that leave the extra slots empty. You only reach forSetGraphemewhen you specifically want a multi-code-point emoji.
GameMeta.Leaderboard declares the board (label, direction, aggregation,
format); your game feeds it with Post or End — never via KV (KV is
durable state; the board doesn't read it):
End(result)settles the room once — for round-based games. Every player'sPlayerResult{Metric, Rank, Status}is recorded and the room ends.Post(result)records results without ending the room — for endless or social games. Post when a score is banked (a new personal best, a finished run, a kill streak cashed in), not every frame; the board keeps each account's best (or sum, per yourAggregation).
Worked example (endless arena, best-kills board):
Leaderboard: &kit.LeaderboardSpec{MetricLabel: "Kills", Direction: kit.HigherBetter}
// on death/respawn — bank the run:
r.Post(kit.Result{Rankings: []kit.PlayerResult{{
Player: p, Metric: run.kills, Status: kit.StatusFinished,
}}})See pokies
in the catalog: it posts a player's new peak after a winning spin — the KV
write keeps the wallet durable; the Post is what reaches the board.
Doing the above by hand is a footgun: you have to remember to Post on the
right events, re-post on disconnect so a mid-run leaver still scores, and — for
games that never end — keep posting an abandoned-but-ticking world. Miss one
and the board silently lies. Prefer kit.ScoreKeeper over hand-rolled
r.Post calls (and prefer its PersistBest/PersistWallet helpers over
hand-rolled KV writes); it standardises all three post points and gets the
merge rules right. Hold one keeper on the room:
type myRoom struct {
sk *kit.ScoreKeeper
}
func New() kit.Game { return &myRoom{sk: kit.NewScoreKeeper(kit.OnImprove)} }
// During play — record each player's current metric. The keeper posts only
// when the cadence says to, so this is safe to call freely:
rm.sk.Record(r, p, run.kills)
// On disconnect — bank their progress so a leaver still ranks:
func (rm *myRoom) OnLeave(r kit.Room, p kit.Player) {
rm.sk.FlushLeave(r, p, kit.StatusDNF)
}Cadence controls when Record auto-posts (it always posts the first time
it sees a player):
kit.OnImproveposts only when the new metric beats the last posted value — the right choice for monotonic high-water boards (peak credits, best survival time, kill count).kit.OnChangeposts whenever the metric changes from the last posted value (up or down) — for boards that track a current, fluctuating figure.
Live Record posts always carry StatusFinished. FlushLeave(r, p, status)
posts the player's last tracked metric with the status you pass (normally
kit.StatusDNF) and stops tracking them; calling it for an untracked player is
a no-op. The leaderboard reader ranks DNF rows the same as finished ones, so on
a lower-is-better board pass a fair full-run metric
(par-extrapolated), never a raw partial — a half-played run would otherwise top
the board.
Continuous / never-ending games (anything LifecycleResident, see
Choosing a lifecycle) also call
FlushAll(r, status) from OnWake on a throttled interval, so an abandoned
world that is still ticking keeps recording. FlushAll posts every tracked
player's current metric in deterministic AccountID order. The keeper holds no
goroutines or timers — periodic flushing is driven by your own OnWake
heartbeat — so behaviour stays deterministic under hibernation and replay, and
a single keeper is safe for the room actor.
The keeper also wraps the durable-KV side so a store blip cannot clobber a real value at merge time:
sk.PersistBest(r, p, key, value)writes a high-water value withMergeMax— for session resume, separate from the board.sk.PersistWallet(r, p, balanceKey, balance, peakKey, peak)writes a carryable balance withMergeSumand a peak withMergeMaxin one call — the casino-style wallet pattern, no per-gamepersistWallethelper needed.
These feed only the KV (for resume); the board is still fed solely by
Record/FlushLeave/FlushAll.
store := r.Services().Accounts.For(p).Store()
store.Set(ctx, "balance", []byte("990"), kit.MergeSum)
v, ok, _ := store.Get(ctx, "balance")Keys are namespaced to your game and that player by the host — you cannot
read another game's data, and nobody can read yours. The merge rule says how
a key reconciles if two accounts merge: sum for currencies, max for high
scores, keep-winner (default) for everything else. sum/max values MUST
be base-10 ASCII integers (e.g. strconv.Itoa — "990"), within int64;
anything unparsable falls back to keep-winner at merge time.
The store can degrade, and you won't see an error. The ABI gives your game
no error channel for KV: when the host's store has a transient failure, Get
reports the key as missing (nil, false, nil) and Set/Delete return
nil without persisting. That makes the natural
Get → missing → initialize starting balance → Set wallet pattern a trap — a
store blip reads a veteran's wallet as absent, and your "new player" write can
clobber the durable value. Treat a missing read conservatively (defer the
initializing write, or use sum/max rules so a blip-era write cannot win at
merge time), and test the scenario with kittest: set
r.KVUnavailable = true and your suite sees exactly those production
semantics (see ExampleRoom_kvUnavailable in the kittest package).
Per-game configuration (tunable by arcade admins without your involvement)
arrives through r.Services().Config.Get(ctx, "key") — read it on a slow
cadence in OnWake and fall back to compiled defaults when absent.
Declare the keys you read in GameMeta.Config so the arcade's admin tools can
render a real editor for them instead of a blind key/value prompt:
Config: []kit.ConfigKeySpec{{
Key: "odds-variant",
Title: "Odds variant",
Description: "PAR sheet: per-symbol reel weights plus the paytable.",
Type: kit.ConfigJSON, // or ConfigText / ConfigNumber / ConfigBool
Default: defaultVariantJSON, // what you use when the key is unset
Schema: variantSchemaJSON, // optional JSON Schema → rich form editing
}},Declarations are optional and validated at meta() time: keys must be unique,
non-empty, and never under the reserved host. prefix, and Schema (json keys
only) must parse as JSON. Admins can still set undeclared keys — specs improve
the editor, they don't gate the store.
Every game is one of two kinds for the platform economy. Game (the default): players earn platform credits from the results you already post — you declare nothing and change nothing. Casino: players gamble their account-wide credits through your game. Declare it in your meta:
func (g Game) Meta() kit.GameMeta {
return kit.GameMeta{
Slug: "blackjack", Name: "Blackjack",
Kind: kit.GameKindCasino,
MaxPayoutMultiplier: 10, // MUST cover your largest possible win
CtxFeatures: kit.CtxFeatCredits,
// ...
}
}The host owns every balance — your game never stores one. Take bets and pay out through the credits service:
credits := r.Services().Credits
// Show the stack.
bal, err := credits.Balance(p)
// Take the bet BEFORE the deal: the host atomically escrows it from the
// player's account-wide balance. A refused wager means the bet did not
// happen — render the error and stay in the betting phase.
if err := credits.Wager(p, 100); err != nil {
switch {
case errors.Is(err, kit.ErrInsufficientCredits):
// tell the player to earn credits in Games, or take the lobby buy-back
case errors.Is(err, kit.ErrEconomyDisabled):
// the host's economy is off: render an out-of-service table
}
return
}
// Settle ONCE per stake with the GROSS (stake-inclusive) return:
credits.Settle(p, 0) // loss
credits.Settle(p, 100) // push: the stake back
credits.Settle(p, 250) // win: stake + winningsRules the host enforces (so you don't have to): repeated wagers before a
settle accumulate into one open stake (double-down, side bets); every payout
is clamped to stake × your declared MaxPayoutMultiplier — declare a
ceiling that covers your top prize with every feature and gamble compounding
applied, because a clamped honest jackpot is an authoring bug; a win
sequence (free spins, a double-up ladder) keeps the triggering stake open
and settles once with the total; open stakes refund if your room crashes,
and are void after a restore — treat an in-flight hand as void when your
room comes back. A player leaving mid-hand gets your normal OnLeave: you
may settle their open stake as a loss per your table rules (do — otherwise
leaving is a free cancel on every losing bet).
Casino games never earn credits from posted results, and their leaderboard
is platform-computed (biggest single win) — post nothing for it. Unit-test
the flow with kittest: the Room double carries an in-memory wallet
(Credits, CreditsStakes, CreditsDisabled to script the economy-off
state). In the native dev runner (go run .) every player starts with 1000
credits.
Rooms hold 1–N players: your GameMeta declares the range, and the platform
bound is 1..1024 (wire.RosterCap — the same constant that sizes the
frame-delta roster on both sides of the ABI). Render per-player views by
composing a frame per member:
for _, p := range r.Members() {
r.Send(p, rm.composeFor(p)) // each player sees their own view
}Test it without leaving your terminal: go run . -seats 3 joins three
players; Ctrl-T switches which seat your keyboard controls, and the view
follows — so you can play every side of your own game.
The native runner also rides terminal resizes: shrink the window below 80×24 and it shows a "terminal too small" notice, then repaints your game the moment you grow it back — the same letterboxing the arcade does over SSH.
GameMeta.Lifecycle declares what happens to your room when everyone
leaves:
LifecycleResumable(the default): the room hibernates and players can resume it later from the lobby. Right for games where an interrupted match is worth returning to — chess, anything with long-arc state.LifecycleEphemeral: after the abandonment grace the room ends and disposes — no snapshot, no resume entry. Right for casual social rooms (slots, card tables, quick board games) where a match without its players is meaningless. The grace still protects against connection blips: a rejoin within it finds the room intact.LifecycleResident: one long-lived room per slug — the persistent- world shape. It keeps ticking with zero players, checkpoints periodically, and survives deploys without anyone resuming it. Declaring it is a REQUEST: the platform grants residency per slug (always-on compute is an operator decision), and an ungranted declaration simply behaves as resumable. Resident games must tolerater.Count() == 0in every callback (all games should —OnStartfires before the first join), should idle-throttle expensive work when nobody is online, and cannot declareMinPlayers > 1. Ending a resident room (r.End) is the world-reset primitive: the next join creates a fresh world.
The SDK supports rooms of up to 1024 players (wire.RosterCap, the platform's
hard ceiling), but a large room only stays inside the wake budget if the game
follows three disciplines:
Declare your heartbeat. A roguelike or board game does not need the 50ms default. Declare your real cadence and the platform honors it (an admin override always wins):
func (Game) Meta() kit.GameMeta {
return kit.GameMeta{
// ...
HeartbeatMS: 100, // gentle tick: 10 wakes/sec
}
}Declare the roster-epoch feature. By default every callback payload carries the full member list. In a 1000-player room that is ~100KB per input; with the feature, an unchanged roster costs 6 bytes:
CtxFeatures: kit.CtxFeatRosterEpoch,The SDK handles the rest. One contract to know: the slice from
r.Members() is valid for the duration of the callback — copy any Player
you keep, and key long-lived state by AccountID (you already should).
Render on change, not on wake. Composing and sending every player's frame on every wake is the single largest cost in a big room — and most frames are identical to the last. Track per-player dirtiness and skip clean viewports:
- re-compose a player's view only when THEY moved, something moved within their visible window, or their HUD line changed;
- throttle ambient HUD clocks to ~1Hz — a per-wake counter in the HUD forces a nonzero delta for every player on every wake, defeating the delta encoder;
- at typical input rates expect only ~10–30% of viewports dirty per tick — a 3–10× cut in wake cost.
One trap: mark a player dirty on REJOIN (same account re-seating, including after a hibernation resume) — their frame baselines were invalidated, and a render-on-change game that skips them leaves the resumed session staring at nothing until something happens to move.
The frame-delta layer already makes CLEAN-but-resent frames cheap on the
wire; dirty tracking makes them free in CPU too. Allocation discipline
matters as much as ever — under -gc=conservative steady-state allocations
are GC pressure (pauses inside the callback deadline): compose into a reused
kit.Frame, write cells directly, avoid per-player-per-wake string
building.
Every catalog game ships a smoke.yaml next to its source: a small
deterministic script that drives the game and dumps named 80×24 screens. The
games repo CI runs it on every PR and posts the screens as a visual preview —
and it's a handy inner-loop tool ("show me the reveal screen" without
replaying the whole game by hand):
go run . -smoke smoke.yaml -smoke-out shots/ # native: no TinyGo needed
shellcade-kit smoke . # the same script vs the real wasmA script declares the deterministic inputs and the steps:
seed: 42 # required — fixed RNG seed
seats: 2 # required, 1..8 — all seats join before the first step
heartbeat: 50ms # optional — wake cadence during `advance`
config: # optional — per-game config values
variant: classic
steps:
- shot: lobby # dump the current screen(s), named "lobby"
- rune: "5" # printable rune → current seat
- key: enter # enter/backspace/esc/tab/up/down/left/right/space
- text: "hello" # sugar: one rune per character (typing games)
- seat: 1 # switch the current input seat (sticky hot-seat)
- advance: 1.5s # sweep the virtual clock, one wake per heartbeat
- wake: # a single wake without moving the clock
- shot: reveal
seats: [0, 1] # optional filter; default = every seatThe room is fully virtual: the clock starts at a seed-derived epoch and moves
only through advance (which wakes your game once per heartbeat — so
advance: 1.5s at 50ms is exactly 30 wakes), and the RNG comes from seed.
Two runs produce byte-identical shots; the wasm and native paths agree the
same way shellcade-kit check's determinism gate guarantees.
Shots are per-seat (01-lobby.seat0.ansi + a plain-text .txt twin) and
collapse to a single file when every seat sees the same screen — broadcast
games never produce duplicates. cat an .ansi file to view it in your
terminal.
Authoring tips:
- Shoot quiescent or exact-tick moments. Mid-animation screens are
deterministic too, but pick ticks that look intentional: land
advanceon the frame you mean to show (reels stopped, card slid in, flash visible). - Capture the moments a reviewer cares about: the opening screen, a mid-game state with real play on the board, and the payoff (win/reveal/ results) — three to six shots is plenty.
- Per-player games: shoot after the moment views diverge (deal, bets, reveal) so the preview shows what each seat sees.
advancemust be a whole number of heartbeats — the parser rejects ambiguous durations rather than rounding.- Smoke scripts drive at most 8 seats — a screen-preview tool, not a
load harness. Large-room games (up to the platform's 1..1024 bound) still
pass smoke: the runner clamps your
MinPlayersto the scripted seat count, and large-room behavior is exercised byshellcade-kit check's budget gates, not by smoke screens.
The smoke package exposes the same machinery as Go API (smoke.Parse,
smoke.Run, smoke.RenderANSI) if you want shots inside your own tests.
The arcade's games screen shows a little looping animation beside each game's
blurb — a hand-authored preview loop, distinct from the automatic smoke shots.
You author it as a preview/ directory: a preview.yaml manifest plus one
plain-text file per frame, and shellcade-kit preview pack compiles the whole
thing into a single self-contained preview.scp bundle that ships as a release
asset alongside your .wasm.
preview/
preview.yaml
01-idle.frame
02-spin.frame
03-win.frame
The manifest lists frames in playback order, each with a file and a duration:
frames:
- file: 01-idle.frame
ms: 900 # hold this frame 900ms
- file: 02-spin.frame
ms: 120
- file: 03-win.frame
ms: 600Looping is implicit — playback runs the frames in order, then loops from the last back to the first. There is nothing to configure.
Each frame file is plain text: at most 7 rows of at most 46 display cells (the art block's interior — the arcade draws the border). Short rows and short frames are space-padded for you; the packer never truncates, so an oversize row is a hard error naming the offending file and line — trim the art yourself. Wide glyphs (CJK, most emoji) count as two cells.
The frame-text contract is printable text plus ANSI SGR color only:
ESC[…mstyling sequences are allowed (colors, bold, reverse). Any other escape — cursor movement, screen clears, anything that isn't SGR — is rejected, as are raw control characters and invalid UTF-8.- No variation selectors (VS16
U+FE0F), zero-width joiners, or keycap combiners. These render at terminal-dependent widths: a frame that measures 46 cells in the packer can overflow the art block on a real terminal and desync every column to its right. Compose your art from single, unambiguous glyphs. (Styling need not be closed at end of line — the packer resets it before the padding.)
Pack it and ship the bundle:
shellcade-kit preview pack preview/ -o preview.scpThe bundle is small (capped at 64 KiB) and versioned; the arcade re-runs every
one of these checks when it ingests your bundle, so a preview that
preview pack accepts is one the arcade will accept.
| Stage | Command | What it proves |
|---|---|---|
| iterate | go run . (~0.1s) |
gameplay, rendering, logic |
| artifact | tinygo build -opt=1 -no-debug -gc=conservative -target wasip1 -buildmode=c-shared . (~4s) |
the real wasm builds |
| verify | shellcade-kit check game.wasm |
ABI conformance, budgets, determinism — the same gate the arcade runs |
| play it | shellcade-kit play game.wasm --seats 2 |
the artifact, on the production engine |
shellcade-kit check passing locally is the arcade's acceptance bar — same
code, same verdict.
TinyGo is the artifact toolchain.
GOOS=wasip1 go build ./...with the standard toolchain type-checks your wasm-tagged files (useful!) but does NOT produce a valid artifact —//go:exportis TinyGo's directive; a stdlib build won't export the trampolines.
| Member | What | Notes |
|---|---|---|
Members/Has/Count |
roster reads | free (local) |
Config |
mode, capacity, seed | free (local) |
Rand |
seeded PRNG | deterministic under -seed |
Now |
room clock | wall-clock natively, virtual under -seed (see Native clock) |
Settled |
has the room ended? | |
Send(p, *Frame) |
one player's view | copies immediately — reuse the frame |
Identical(*Frame) |
same view to everyone | |
SetInputContext |
Nav / Command / Text | governs Back/q resolution |
End(Result) |
settle once and finish | round-based games |
Post(Result) |
record scores, keep playing | endless/social games |
Log(msg) |
host-side room log | visible in shellcade-kit play stderr |
Services() |
KV / config / accounts | see Durable state |
For unit tests, github.com/shellcade/kit/v2/kittest is an in-memory Room +
Services with a virtual clock, seeded RNG, and recorded
frames/posts/settles — drive your Handler directly and assert. Its
KVUnavailable knob replays the host's KV degradation (reads come back
missing, writes silently drop — see Durable state) so you can prove your
wallet code survives a store blip.
No filesystem, no network, no system clock, no threads: the sandbox gives
your game exactly the eleven host functions in ABI.md §3 and nothing else.
Resource budgets (memory cap, per-callback deadline) are enforced and
reported by shellcade-kit check — stay comfortably inside them and an arcade full
of other games stays healthy around yours.
The public games catalog is the example
gallery: every game there is published and conformance-green. The best
all-rounder to read is
pokies — a
1–5 player slot machine exercising every feature: wake-driven reel animation,
staggered one-shots, admin-tunable odds via config, a durable wallet with
merge rules, and per-player rendering. It's a faithful port of the arcade's own
native pokies, so it reads like production code, because it is. Scaffold your
own with shellcade-kit new mygame and grow it from there.