~ / docs / lang

The Banto language

This is the friendly tour of the Banto DSL — the language you write in .banto files. It assumes you’ve followed Getting started and have a project to play with.

1. Mental model

Banto is a small language for building multiplayer party games. One person is the host (the screen everyone watches) and several are players (their phones). Your job is to describe what each viewer should see, and what should happen when they tap things.

Three ideas to keep in mind:

  1. The server is in charge. Game state lives on the server, not in anyone’s browser. When a player taps a button, their browser sends a token back to the server; the server runs the code; everyone affected gets a fresh view.
  2. You write what each viewer sees, not how to update it. When the data changes, the runtime works out which views need to refresh and ships them only the data they actually use.
  3. No if you, no for you in views. Views are declarative. Use multiple ordered blocks for “if/else” (first true wins) and .map over an array to build a list.

The rest of this guide is the vocabulary for doing that.


2. Project layout

A Banto project is a flat directory of .banto files plus a single CSS file:

my-game/
├── globals.banto         # required: shared declarations
├── start.banto           # required: the entry state
├── question.banto        # optional: more states, named however you want
├── results.banto
├── styles.banto.css      # optional: CSS classes for your views
├── banto.config.json     # publish settings
└── banto.preview.json    # preview seed values

Two rules to remember:

  • Every project has exactly one globals.banto and exactly one start.banto. The latter is where every game starts.
  • Every other .banto file is a state. The file name (without .banto) is the state’s name and must match the state block declared inside it: question.banto contains state question { … }.

State files are how you carve a game into “phases” — lobby, ask a question, show results, final scores. Each state file describes what the host and players see during that phase, and how the game moves on.


3. Types

Banto uses TypeScript-style type expressions. If you’ve written a TypeScript interface, this will be familiar:

string                        // primitive
number
boolean
object
any
string[]                      // array of strings
{ name: string, score: number }   // object — note the commas, not semicolons
{ [key: string]: number }     // dictionary / record
{ name: string, nickname?: string }   // optional field

The only syntactic surprise is commas between fields, where TypeScript would let you use semicolons.

Banto-specific primitives

Three extra types appear throughout the language; you’ll get to know them as you read about the blocks that use them:

TypeWhat it represents
elementSomething renderable on screen — a primitive, a component, or a list of those.
styleA list of CSS class references. Used by component params that take styling.
actionA block of side-effects (assignments, registry calls). The thing that runs when a button is clicked.

String-enum types (registry-only)

Some component or function params restrict a string to a fixed set of values, written as a TypeScript-style union:

"tick" | "ding" | "whoosh"

You’ll only see these in registry signatures (you can’t declare your own). They behave like normal strings but the compiler will reject any literal that isn’t in the list.


4. Blocks: the building units

Everything you write at the top level of a .banto file is a block. A block has the shape:

blockKind blockName {
    attribute1: value,
    attribute2: value
}

Single-line is fine too:

var hasStarted { type: boolean, default: false }

There are nine block kinds. They split into three groups:

Where they goBlock kinds
State files onlystate, host, plyr, lstn, cstate
globals.banto onlydata, cpnt
Bothvar

Two more namespaces you’ll see written like blocks but never declare yourself: func (the function registry) and client (client-only registry). They’re explained at the end of the guide.

The next sections walk through every block kind.


5. State files: state, var, cstate

state

Every state file has exactly one state block. Its name must match the file:

// start.banto
state start {}

If a state needs incoming data — usually because another state transitioned into it with lstn.next — declare a params type:

// question.banto
state question {
    params: { questionId: number }
}

Inside this state file, anywhere you can use a value, you can read those params: state.params.questionId.

var

A var is server-side mutable state. It has a type and a default value:

var hasStarted {
    type: boolean,
    default: false
}

var players {
    type: { [key: string]: { name: string, score: number } },
    default: {}
}

vars declared in globals.banto live for the whole game. vars declared in a state file live as long as that state is active — when the game transitions away and back, they reset to default.

Read a var with var.<name>. You can read vars from host, plyr, and lstn blocks. Mutate them inside an action (covered in §11).

cstate — per-client state

A cstate is per-client, browser-only state: each viewer has their own copy, the server never holds it, and it resets to default on every state transition.

cstate draft {
    type: string,
    default: ""
}

Read it with cstate.<name> from host or plyr blocks.

The textbook use case is a text input in progress: the typing-in-progress text lives in cstate.draft so it stays smooth and local; only when the player presses Enter does it travel to the server. See Recipes → text input.

varcstate
Lives where?ServerEach viewer’s browser
Lives how long?Across state transitionsWiped on every transition
Who can write it?host, plyr, lstn (via actions)host, plyr only
Who can read it?Anyone server-sideOnly the client that owns it

6. Views: host and plyr

host and plyr blocks describe what’s on screen during a state. They look identical:

host default {
    condition: true,
    child: cpnt.HostScreen({ … })
}

plyr default {
    condition: true,
    child: cpnt.PlayerScreen({ … })
}

Two attributes:

  • condition — a boolean expression. If true, this block “wins”.
  • child — what to render. An element value: a string, a component call (cpnt.X(…)), or a list of those.

You can declare multiple host blocks and multiple plyr blocks per state. They’re evaluated top to bottom; the first one whose condition is true is the one rendered. Like a chain of else ifs.

The last block of each kind must be named default with condition: true, so something always renders:

plyr hasAnswered {
    condition: var.playerData.includes(player.sessionId),
    child: cpnt.WaitingScreen({ … })
}

plyr default {
    condition: true,
    child: cpnt.QuestionScreen({ … })
}

The player value

Inside a plyr block you have access to a special value, player, representing the specific viewer this render is for:

player.sessionId   // string, unique per viewer
player.name        // string, the player's display name
player.score       // number, their current score

Each player gets their own evaluation of the plyr blocks, with player bound to their record. The host gets one evaluation of the host blocks, with no player value (don’t reference it there).

Building the child

child is whatever you want on screen. The easy cases:

child: "Hello!"                                    // a string
child: var.message                                 // a var that holds a string
child: cpnt.button({ child: "Tap me", onClick: () => {}, class: [] })
child: [cpnt.row({…}), "footer", cpnt.button({…})]   // a list of elements

For larger UIs, lean on the component registry — see §8 Components and the Registry reference.


7. Listeners: lstn

A lstn block watches the game state and fires when its condition becomes true. It does two things:

  • Run an actions block (mutate vars, call func.X side effects).
  • Optionally, transition to another state via next.
lstn hasStarted {
    condition: var.started,
    actions: {},
    next: {
        state: "question",
        inputs: { questionId: 0 }
    }
}
AttributeRequired?What it is
conditionyesA boolean. The listener fires when this becomes true.
actionsnoAn action block — server-side side effects (assignments, registry calls).
nextno{ state: "<otherState>", inputs?: <params> }. The state name must exist; inputs must match that state’s params type.

Like host and plyr, multiple lstn blocks are evaluated in declared order — the first one whose condition is true wins. They’re the workhorse for “when X happens, do Y and move on”.

A real example from the trivia starter:

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: {
        // ... grade the answers, update scores ...
    },
    next: { state: "results" }
}

8. Components: cpnt

A component is a renderable thing — a button, a container, a custom screen layout. Components come from two places:

  1. The component registry — components shipped with Banto. Reference them with cpnt.<Name>(args).
  2. Your own globals — declare a custom component with a cpnt block in globals.banto.

Calling a registry component

Pass an object whose fields match the component’s params:

cpnt.button({
    child: "Start the game",
    class: [css.big-button],
    onClick: () => { var.started = true; }
})

The full list of registry components is in the Registry reference. The most common ones to get started:

  • cpnt.HostLobby — default lobby screen with a kick list.
  • cpnt.PlayerScreen — wrapper for player views, shows score + room code.
  • cpnt.button — a button.
  • cpnt.container — a styleable wrapper.
  • cpnt.textInput — a text input bound to a cstate.
  • cpnt.row / cpnt.column — layout helpers.

Declaring your own component

Declare it in globals.banto:

cpnt MyButton {
    params: string,
    child: cpnt.button({
        child: params,
        class: [css.my-button],
        onClick: () => {}
    })
}

Two attributes:

  • params (optional) — the input type. Inside the body, read it as params (or params.foo for an object).
  • child (required) — an element value. Same rules as in host/plyr blocks.

Use it like any registry component:

cpnt.MyButton("Hello")

A component with object params:

cpnt myCard {
    params: { title: string, body: string },
    child: cpnt.container({
        class: [css.card],
        child: [params.title, params.body]
    })
}

Limitation, on purpose. A custom component’s body can read its own params and use other components, but it cannot read var, state, data, or player. Components are pure with respect to game state — pass values in via params instead.

Lists of elements

The element type accepts a single element or a list. Both are valid:

child: cpnt.button(...)                        // single
child: [cpnt.a(...), cpnt.b(...), "footer"]    // list, rendered in order
child: []                                      // empty list

That makes […].map(item => cpnt.X(item)) the natural way to render a dynamic list of components.


9. Datasets: data

A data block declares a dataset that the host picks at game start. The most common case: a question list for a trivia game.

data blocks are declared in globals.banto only. Exactly three names are allowed: questionSet, promptSet, or dynamic.

data questionSet
data promptSet
data dynamic

You can have at most one data block in a project. The shape is fixed by Banto:

BlockType
data questionSet{ prompt: string, options: string[], correctOptions: number[] }[]
data promptSetstring[]
data dynamic(string | number | (string | number | (string | number)[])[])[]

Read it like a var: data.questionSet, data.promptSet, data.dynamic.

The dynamic block is the general-purpose escape hatch — each top-level entry is a string, a number, or a sub-list. A sub-list may itself mix strings, numbers, and a further (innermost) list of strings/numbers. Max nesting depth is 3 — no list-of-list-of-list-of-list.

When you publish, banto.config.json determines what real data source the host sees pre-selected — see the CLI reference’s banto.config.json section.


10. Styling: css and the style type

CSS classes live in styles.banto.css. Restrictions:

  • Class selectors only (.my-class) and pseudo-classes (.my-class:hover). No element selectors, no @import, no @font-face, no @keyframes.
  • The compiler will reject anything outside that subset.
.host-column {
    display: flex;
    flex-direction: column;
    gap: 12px;
}

.option-button:hover {
    transform: translateY(-2px);
}

Reference a class from a .banto file as css.<class-name>. A single css.X is a single class reference, not a style.

The style type is a list of class references:

class: [css.host-column]            // one class
class: [css.host-column, css.with-shadow]   // multiple
class: []                                   // none

Always wrap in [ ] — even for one class. A bare css.X in a style slot is a type error.

Conditional classes with &&

&& and || follow JavaScript semantics — they return one of their operands. The runtime drops non-string entries from style arrays before joining, so this just works:

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 array entry becomes false, gets dropped, and the final class string only contains the survivors.


11. Actions

An action is a block of server-side side effects: changing var values, calling registry functions that have side effects. Anywhere the type action is expected, you can supply one.

Action statements

Inside an action, statements end with a semicolon. The available statements:

  • Assignment: var.score = 0; and the compound forms += -= *= /= %=.
  • Mutating method calls: var.players.push(p);.
  • Function calls that return action: func.kickPlayer(sId);.
  • Iterators for side effects: var.players.forEach((p) => { … });.
  • if / else if / else — same syntax as JavaScript.
  • return; inside a .forEach body — works like continue, skipping to the next iteration.
actions: {
    var.players[player.sessionId].score += 100;
    if (var.players[player.sessionId].score >= 1000) {
        var.winners.push(player.sessionId);
    }
    Object.entries(var.players).forEach(([sessionId, p]) => {
        if (p.score < 0) { return; }
        var.scoreBoard[sessionId] = p.score;
    });
}

No for or while loops. All looping goes through array methods — .map (when you want a new value back), .forEach (when you just want side effects).

Action values: () => { … }

Anywhere a slot is typed action, you can write an arrow function whose body is an action block. This is how event handlers are wired up:

cpnt.button({
    child: "Start",
    onClick: () => {
        var.hasStarted = true;
    }
})

The arrow runs on the server, when the user emits the event. From inside it, you have full access to var, state, data, registry functions — the same things lstn actions can touch.

Events that carry data: action(T)

Some events deliver a value (a text input sends what was typed; a slider sends its new position). Those slots are typed action(T) — declare an arrow with one parameter to receive the value:

cpnt.textInput({
    value: cstate.draft,
    onChange: (next) => { cstate.draft = next; },
    onSubmit: (text) => {
        var.messages.push(text);
        cstate.draft = "";
    }
})

Rules:

  • A plain action slot accepts only zero-arg arrows.
  • An action(T) slot accepts zero- or one-arg arrows.
  • Two or more parameters is always an error.

Closures: what’s captured vs. what’s live

When you write an arrow inside a .map (for example, building a button per player), the arrow’s body can refer to two kinds of names:

WhatWhen is it read?
Iteration locals (item, i, destructured names) and playerCaptured by value at render time. The action remembers which iteration produced it.
var.*, state.params, data.*, registry callsRead live at click time. The action sees the latest server state.

This means the player-mapping pattern below works correctly:

items: data.questionSet[state.params.questionIndex].options.map((option, i) =>
    cpnt.button({
        child: option,
        class: [],
        onClick: () => {
            // `i` is captured: this button's onClick always uses ITS i.
            // `player.sessionId` is captured: it's this player's id.
            // `var.playerData` is live: we read the latest snapshot.
            var.playerData[player.sessionId] = {
                selection: i,
                selectionTime: func.currentTime()
            };
        }
    })
)

There’s no “last-i” footgun here. Each button captures its own i.


12. The function registries: func and client

Banto ships two read-only registries of utility functions.

func.X — server-side

Anything under func. runs on the server, in the same place as your action bodies. Grouped:

  • Time: func.currentTime().
  • RNG: func.randomInt({min, max}), func.randomFloat({min, max}), func.shuffle(arr), func.pickRandom(arr), func.pickUnused({total, used}).
  • Math: func.floor, func.ceil, func.round, func.abs, func.clamp, func.sum, func.avg, func.minOf, func.maxOf.
  • Collections: func.unique, func.first, func.last, func.size, func.isEmpty, func.hasKey, func.merge, func.removeKey, func.mapValues, func.groupBy, func.range, func.equals.
  • Game control (returns: action): func.kickPlayer(sessionId).
  • UX side effects: func.notify({to, message, type?}), func.log({message, level?}).

A function whose return type is action can appear as a statement in an action block; the others are just expressions.

The full catalog is in the Registry reference.

client.X — browser-side

client.X(...) runs on the browser of the player who triggered the action — useful for UI sound effects and other purely-presentational things the server has no analogue for.

cpnt.button({
    child: "Buzz in!",
    class: [],
    onClick: () => {
        client.playSound("ding");
        var.buzzedPlayers.push(player.sessionId);
    }
})

Two restrictions:

  • client.X(...) may only appear inside an action arrow body.
  • client.X(...) may not appear inside an iterator body (.map, .forEach).

The current registry has one function — client.playSound(sound) — where sound is one of "tick", "ding", "whoosh", "chching", "applause", "countdown", "anticipation", "waiting".


13. Comments

Single-line // comments only — same as JavaScript. Multi-line /* … */ comments are not allowed.

// This var holds whether the host has hit Start yet.
var hasStarted { type: boolean, default: false }

Where to go next

  • Recipes — the patterns you’ll keep reaching for: text inputs, kicks, scoring, conditional UI, state transitions.
  • Registry reference — every component and function Banto ships with.