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.
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.
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.column({
items: state.params.question.options.map((option, i) =>
cpnt.button({
child: option,
class: [],
onClick: () => {
var.playerData[player.sessionId] = {
selection: i,
selectionTime: func.currentTime()
};
}
})
)
})
}
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.
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 sounds: "tick", "ding", "whoosh", "chching",
"applause", "countdown", "anticipation", "waiting".
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 });
Test a non-start state in the preview
banto preview always drops you into the state in
banto.preview.json. To work on question.banto without playing
through the lobby, edit banto.preview.json to seed that state
directly:
{
"role": "host",
"state": {
"name": "question",
"inputs": {
"question": {
"prompt": "What year did WW2 end?",
"options": ["1943", "1945", "1947", "1950"],
"correctOptions": [1]
},
"initTime": 1700000000000
}
},
"var": {
"players": {
"p1": { "name": "Alice", "score": 0 },
"p2": { "name": "Bob", "score": 100 }
},
"roomCode": "ABCD"
},
"data": {
"questionSet": [{ "prompt": "...", "options": ["..."], "correctOptions": [0] }]
}
}
Switch role to "player" (and add a player field) when you want
to test the player view of that state instead.