Recipes
Copy-paste patterns for things you’ll actually build. Each recipe is
a short, working snippet plus a sentence on why the pieces are where
they are. New to the language? Read the
language guide first; this doc assumes you
know what var, cstate, host, plyr, lstn, and cpnt mean.
Contents
- Name your game’s types (aliases & enums)
- A start screen with a Start button
- Move to a new state when the host hits Start
- List players with a kick button each
- Text input with a
cstatedraft - Pre-fill a
cstatefrom server data withinit - Instant UI with the client prelude & epilogue
- Score the round and move on
- Show different UI per player (“you’ve answered”)
- Build a list of buttons from a list of options
- Factor a reusable component that reads state & forwards actions
- Conditional CSS classes
- Pop a notification toast
- Play a UI sound on tap
- Show a custom image or play custom audio
- Time-out a state with
func.currentTime - Track who has answered with a player-keyed record
Name your game’s types (aliases & enums)
Repeating a shape like { [key: string]: { name: string, score: number } }
across every var, func, and state.params is error-prone. Declare it
once in globals.banto with a type alias and reference the name:
// globals.banto
type Player = { name: string, score: number }
type Phase = "lobby" | "playing" | "results" // a named enum
var players { type: { [key: string]: Player }, default: {} }
var phase { type: Phase, default: "lobby" }
// question.banto — reference aliases in params and func signatures too
state question { params: { question: Question } }
func topScore {
params: { ps: Player[] },
returnType: number,
action: {
let best = 0;
params.ps.forEach((p) => { if (p.score > best) { best = p.score; } });
return best;
}
}
Why: aliases are compile-time only (they expand to their real shape
before anything runs), so there’s zero runtime cost — they exist purely to
keep your types readable and consistent. A named string-union like Phase
is also the only way to declare an enum; the compiler rejects an inline
"a" | "b" in a block annotation. Aliases live in globals.banto only,
can reference each other, and can’t be recursive.
A start screen with a Start button
The host sees a Start button; clicking it flips a var.
// start.banto
state start {}
var started {
type: boolean,
default: false
}
host default {
condition: true,
child: cpnt.button({
child: "Start the game",
class: [],
onClick: () => { var.started = true; }
})
}
plyr default {
condition: true,
child: "Waiting for the host to start…"
}
Why this works: var.started starts at false. The button’s
onClick flips it. A listener (next recipe) watches var.started
and acts when it becomes true.
Move to a new state when the host hits Start
Pair the previous recipe with a lstn:
// start.banto (continued)
lstn onStart {
condition: var.started,
next: { state: "question", inputs: { questionId: 0 } }
}
The next state must exist (question.banto) and declare matching
params:
// question.banto
state question {
params: { questionId: number }
}
Why: a listener fires the moment its condition becomes true. next
hands off to the named state, passing values that match its params.
You can include actions: { … } too, to mutate vars before the
transition.
List players with a kick button each
host default {
condition: true,
child: cpnt.HostLobby({
roomCode: var.roomCode,
onStart: () => { var.started = true; },
playerButtons: Object.entries(var.players).map(([sessionId, p]) => ({
name: p.name,
sessionId: sessionId,
onClick: (sId) => { func.kickPlayer(sId); }
}))
})
}
Two things to notice:
- The
onClickisaction(string)— it receives the kicked player’ssessionIdas the arrow argument. (HostLobbyhappens to pass the sessionId through; for components where the action isaction, you’d write() => …and capturesessionIdfrom the.map.) func.kickPlayerreturnsaction, so it can stand alone as a statement. The runtime disconnects the player and removes them fromvar.players.
Text input with a cstate draft
The classic pattern: keep typing-in-progress on the client, only
flush to a var on submit.
cstate draft {
type: string,
default: ""
}
plyr default {
condition: true,
child: cpnt.textInput({
value: cstate.draft,
hint: "Your funniest answer…",
onChange: (next) => { cstate.draft = next; },
onSubmit: (text) => {
var.answers[player.sessionId] = text;
cstate.draft = "";
}
})
}
Why cstate and not var: every keystroke would otherwise round-trip
to the server. cstate.draft is browser-local, so typing is
instant; the server only learns the final string when the player
submits.
The compiler is strict about where you can write to
cstate. Top-level statements only — not insideifbranches or iterator bodies. The error message tells you exactly where to lift the assignment to.
Pre-fill a cstate from server data with init
A cstate default is a static literal — it can’t read player or
var. When each viewer’s draft should start from their own
server-side data (an editable profile, a previously-submitted answer, a
per-player board), seed it with an init block on the view.
cstate board {
type: { [key: string]: string },
default: {}
}
plyr default {
condition: true,
init: {
cstate.board = var.playerBoards[player.sessionId];
},
child: cpnt.PlayerScreen({
name: player.name,
score: player.score,
roomCode: var.roomCode,
child: cpnt.Board({ cells: cstate.board }) // edits stay local
})
}
init runs once on the server when the viewer enters this state (or
first connects), reads whatever server state it needs, and ships only
the resulting cstate to that client. Edits from there on are local
until you flush them to a var on submit — same as the plain draft
recipe above. init is skipped on incremental re-renders, so a later
var change won’t clobber the viewer’s in-progress edits.
initwritescstateonly — it can’t mutatevar(that’s a listener’s job). Its right-hand sides may readvar,player,state.params,data, andfunc.X(…).
Instant UI with the client prelude & epilogue
You write one action body, but the compiler splits any handler that
touches cstate into up to three phases that run in this order:
client prelude → server body → client epilogue
(cstate writes (var writes, (cstate writes
before the first func.X side- after the last
server write) effects) server write)
The split is purely by source order — you don’t annotate anything.
cstate.* writes (and the lets that only feed them) sitting before
your first var/server statement become the prelude; ones sitting
after your last server statement become the epilogue.
Why this matters: no round-trip. Both client phases run in the
browser, immediately, on the same frame as the tap/keystroke. The
server body is fired off in parallel and its fresh view streams back
whenever the server is done. So anything you express as a cstate write
paints instantly, while authoritative var state catches up
asynchronously. The epilogue does not wait for the server response —
it runs right after the trigger is emitted.
Two things this buys you:
- Zero-latency screen updates. Move a cursor, fill a grid cell, flip
a local toggle — drive it through
cstateand it updates without waiting a round-trip for the server to echo a new view. - Sequencing
cstatearoundvarwrites. Snapshot or adjust client state before the server sees it (prelude), hand work to the server (body), then reset or advance client state after (epilogue) — all in one handler.
Crossword grid, typing a letter. The player’s grid must update the instant they press a key; the server grades asynchronously. One handler:
onChange: (t) => {
// ── prelude (client, instant) ──
let curr = cstate.activeGrid;
cstate.board[curr.r][curr.c] = t; // fill the cell now
cstate.activeGrid.c = curr.c + 1; // advance the cursor now
// ── server body (async) ──
var.pendingLetters.push({ // let the server grade it
sessionId: player.sessionId,
r: curr.r, c: curr.c, letter: t
});
}
The cell fills and the cursor advances on the same frame the key is
pressed. Meanwhile var.pendingLetters reaches the server, a
lstn grades the move, and the corrected board streams back — without
ever blocking the typing.
Two rules keep the split clean. (1) The client work must stay contiguous — a prelude prefix and/or an epilogue suffix — with all your server writes grouped in the middle. A server write that lands after an epilogue
cstatewrite is rejected as interleaved (cstate-write-interleaved); move it up. (2)cstatewrites may live insideif/forEachbranches (the whole block lifts to the client, as the real crossword handler does), but such a branch can’t also contain avar/server write — one branch is either all-client or all-server (client-phase-mixed-branch). Split a mixed branch into a client one and a server one. See the gotchas.
Score the round and move on
When everyone has answered (or time runs out), grade and transition.
This snippet assumes a var.players map and a var.playerData map
keyed by sessionId.
lstn onDone {
condition: var.skipped
|| (Object.keys(var.players).length > 0
&& Object.keys(var.players).length == Object.keys(var.playerData).length)
|| (func.currentTime() - state.params.initTime) >= var.questionTime,
actions: {
Object.entries(var.players).forEach(([sessionId, p]) => {
if (!Object.keys(var.playerData).includes(sessionId)) { return; }
if (state.params.question.correctOptions.includes(
var.playerData[sessionId].selection
)) {
var.players[sessionId].score = p.score + 100;
}
});
},
next: { state: "results" }
}
return; inside .forEach skips to the next iteration — that’s how
you bail out early per-iteration without an extra if wrapping the
whole body.
Show different UI per player (“you’ve answered”)
Multiple plyr blocks, evaluated top-to-bottom; the first true one
wins.
plyr hasAnswered {
condition: Object.keys(var.playerData).includes(player.sessionId),
child: cpnt.PlayerScreen({
name: player.name,
score: player.score,
roomCode: var.roomCode,
child: "Answer locked in. Waiting for everyone else…"
})
}
plyr default {
condition: true,
child: cpnt.QuestionScreen({ /* ... */ })
}
The default block must be last and must be condition: true.
Build a list of buttons from a list of options
plyr default {
condition: true,
child: cpnt.container({
class: [css.answer-column],
child: state.params.question.options.map((option, i) =>
cpnt.button({
child: option,
class: [],
onClick: () => {
var.playerData[player.sessionId] = {
selection: i,
selectionTime: func.currentTime()
};
}
})
)
})
}
cpnt.container’s child accepts a list, so mapping options straight
into it is all you need — lay them out with a flex class in
styles.banto.css.
Each button’s onClick captures its own i and option from
the .map. The compiler snapshots those at render time, so when the
player taps option 2, the action knows it’s option 2 — even though
all four buttons share the same arrow.
Factor a reusable component that reads state & forwards actions
A custom cpnt can read global state (global vars, the inherent
roomCode/players/currentTime, and data.*) and can forward an
action — either by invoking an action param with a value, or by
letting an action ride through a .map into a nested cpnt. That’s enough
to factor a grid into Cell / Row pieces instead of inlining it.
// globals.banto
var roundScore { type: number, default: 0 }
// Reads a global var directly — no need to thread it through params.
cpnt ScoreHud {
params: { label: string },
child: cpnt.container({ class: [css.hud], child: params.label + ": " + var.roundScore })
}
// Invokes its `onPick` action param with the tapped value.
cpnt Choice {
params: { label: string, value: string, onPick: action(string) },
child: cpnt.button({
child: params.label,
class: [css.choice],
onClick: () => { params.onPick(params.value); }
})
}
// Forwards a per-cell action through a `.map` into `Choice`.
cpnt ChoiceRow {
params: { choices: { label: string, value: string, onPick: action(string) }[] },
child: cpnt.container({ class: [css.row], child: params.choices.map((c) =>
cpnt.Choice({ label: c.label, value: c.value, onPick: c.onPick })) })
}
Call it and handle the forwarded value at the call site:
plyr default {
condition: true,
child: cpnt.ChoiceRow({ choices: state.params.options.map((o) => ({
label: o.text,
value: o.id,
onPick: (id) => { var.answers[player.sessionId] = id; }
})) })
}
Two rules to remember: a cpnt body is read-only and can’t see
state-scoped vars, state.params, or cstate (pass those in via
params); and an invoked action (params.onPick(v)) must do server
work only — put any cstate/client.* work in the call-site handler.
Conditional CSS classes
&& and || follow JavaScript semantics — they return one of their
operands. The runtime drops non-string entries from style arrays
before joining, which lets you write:
class: [
css.option-button,
var.isCorrect && css.correct,
var.isWrong && css.wrong,
player.sessionId == state.params.activePlayer && css.is-you
]
When a guard is false, that entry becomes false, gets dropped, and
the rendered class attribute only contains the survivors.
Pop a notification toast
lstn onTimeUp {
condition: !var.allAnswered
&& (func.currentTime() - state.params.initTime) > var.questionTime,
actions: {
func.notify({ to: "all", message: "Time's up!", type: "standard" });
},
next: { state: "results" }
}
to accepts "host", "all", or a player sessionId. type is
optional ("error" | "success" | "standard", defaulting to
"standard"). Notifications are fire-and-forget — unknown
sessionIds silently drop, no error.
Play a UI sound on tap
cpnt.button({
child: "Buzz in!",
class: [],
onClick: () => {
client.playSound("ding");
var.buzzedPlayers.push(player.sessionId);
}
})
client.playSound(sound) runs on the player’s browser. The compiler
optimizes statically-known calls so the sound fires instantly on
click without waiting for the server round-trip.
Allowed built-in sounds: "tick", "ding", "whoosh", "chching",
"applause", "countdown", "anticipation", "waiting". To play one
of your own clips, pass an audio asset instead — see the next recipe.
Show a custom image or play custom audio
Upload your media once, then reference it as asst.<name>. Drop files
in an assets/ directory, title them in banto.config.json, and run
banto assets push:
my-game/assets/logo.png
my-game/assets/victory-fanfare.mp3
{ "assets": { "logo": { "title": "Logo" }, "victory-fanfare": { "title": "Fanfare" } } }
Then use them from any view or action:
// an image on the host screen
child: cpnt.image({ src: asst.logo, alt: "Logo", fit: "contain", class: [css.logo] })
// ambient background music
child: cpnt.audio({ src: asst.victory-fanfare, autoplay: true, loop: true })
// a one-shot cue on a tap (client.playSound accepts an audio asset)
onClick: () => { client.playSound(asst.victory-fanfare); }
asst.<name> uses the file’s base name (no extension) and resolves at
build time. The compiler enforces kind: an image asset won’t fit an
audio slot, or vice versa. See the language guide
for the full flow.
Time-out a state with func.currentTime
Two pieces — store the entry time as a state param, then check the elapsed time in a listener.
// previous state's lstn.next:
next: {
state: "question",
inputs: {
question: data.questionSet[var.questionIndex],
initTime: func.currentTime()
}
}
// question.banto
state question {
params: { question: ..., initTime: number }
}
var questionTime {
type: number,
default: 60000 // 60 seconds in milliseconds
}
lstn onTimeout {
condition: (func.currentTime() - state.params.initTime) >= var.questionTime,
next: { state: "results" }
}
func.currentTime() returns milliseconds since the Unix epoch.
Track who has answered with a player-keyed record
A { [sessionId: string]: <something> } map is the workhorse for
“per-player state during a round”. Declare it locally to the state so
it resets when the state ends:
var playerData {
type: { [key: string]: { selection: number, selectionTime: number } },
default: {}
}
Write to it from a player’s action:
onClick: () => {
var.playerData[player.sessionId] = {
selection: i,
selectionTime: func.currentTime()
};
}
Read out with Object.keys, Object.entries, Object.values:
condition: Object.keys(var.playerData).length == Object.keys(var.players).length
To remove a key cleanly (from a non-mutating perspective), use
func.removeKey:
var.playerData = func.removeKey({ obj: var.playerData, key: sessionId });