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)

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:

  1. The onClick is action(string) — it receives the kicked player’s sessionId as the arrow argument. (HostLobby happens to pass the sessionId through; for components where the action is action, you’d write () => … and capture sessionId from the .map.)
  2. func.kickPlayer returns action, so it can stand alone as a statement. The runtime disconnects the player and removes them from var.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 inside if branches 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.

init writes cstate only — it can’t mutate var (that’s a listener’s job). Its right-hand sides may read var, player, state.params, data, and func.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 cstate and it updates without waiting a round-trip for the server to echo a new view.
  • Sequencing cstate around var writes. 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 cstate write is rejected as interleaved (cstate-write-interleaved); move it up. (2) cstate writes may live inside if/forEach branches (the whole block lifts to the client, as the real crossword handler does), but such a branch can’t also contain a var/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 });