~ / docs / recipes

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:

  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.


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.