Docs The Banto language View as Markdown

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.

The canonical, every-rule version is SYNTAX-AND-USAGE.md at the repo root. This guide is the readable version: same content, less reference-doc density, more examples.

Contents

  1. Mental model
  2. Project layout
  3. Types
  4. Blocks: the building units
  5. State files: state, var, cstate
  6. Views: host and plyr
  7. Listeners: lstn
  8. Components: cpnt
  9. Datasets: data
  10. Styling: css and the style type
  11. Actions
  12. The function registries: func and client
  13. Comments

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

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                           // opts out of checking; assignable both ways
unknown                       // accepts any value, but can't be used where a concrete type is required
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.
styleOne or more CSS class references — a single css.X or a list of them. Used by component params that take styling.
actionA block of side-effects (assignments, registry calls). The thing that runs when a button is clicked.
imageA reference to a custom image asset (asst.<name>). See Custom image & audio assets.
audioA reference to a custom audio asset (asst.<name>).

String-enum types

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

"tick" | "ding" | "whoosh"

They behave like normal strings, but the compiler rejects any literal that isn’t in the list. You’ll see these in registry signatures, and you can declare your own enums by naming a string-union with a type alias (type Phase = "lobby" | "playing"). Writing one inline in a block annotation isn’t allowed — name it first.

Type aliases and enums

Declare a named type once in globals.banto with type Name = …, then use that name anywhere a type is expected. Great for a shape you repeat (a player record, a board cell) and the only way to declare a reusable enum:

// 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" }

Reference an alias in a var/cstate type, a state/cpnt/func params, a func returnType, or nested inside another type. Aliases are compile-time only — the compiler expands each reference to its real structure before anything runs, so a type declaration never reaches the runtime.

A few rules: aliases live in globals.banto only, names must be unique, a referenced name must be declared, and an alias can’t refer to itself (no recursive types). Aliases may reference other aliases in any order. See gotchas for the error codes.

Operators

Values are combined with JavaScript-style operators. The full set:

CategoryOperators
Arithmetic+ - * / %+ also concatenates when either side is a string
Unary-x (negate), !x (logical not)
Comparison< > <= >=
Equality=== !== (strict) and == != (loose)
Logical&& || — return one of the operands, not a coerced boolean (see §10 Styling)
Ternarycond ? a : b — a value, usable anywhere a value is expected

Subtraction needs spaces. Names can contain hyphens (css.welcome-screen), so var.a-var.b reads as one name, not a subtraction. Write var.a - var.b with spaces around the -.

Built-in methods

Values carry JavaScript-style methods based on their type — "HI".toLowerCase(), var.scores.filter((s) => s > 0), var.names.join(", "). Banto recognizes a fixed set (there’s no general JS passthrough — an unlisted method is a compile error). The common ones:

  • String: .length, .toUpperCase(), .toLowerCase(), .trim(), .slice(), .substring(), .replace(), .charAt(), .split(), .includes(), .startsWith(), .endsWith(), .indexOf().
  • Array: .length, .map(), .filter(), .find(), .findIndex(), .some(), .every(), .forEach(), .includes(), .indexOf(), .slice(), .concat(), .sort(), .reverse(), .join(), .push(), .pop(), .shift().
  • Number: .toString().
  • Object: Object.keys(), Object.values(), Object.entries(), Object.fromEntries().

The value-returning array iterators (.map, .filter, .find, .findIndex, .some, .every) need an expression callback ((x) => x.foo), not a statement block { … }. Only .forEach takes a statement body. The full table with return types is in SYNTAX-AND-USAGE.md.


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, func
Bothvar

func is dual-purpose: func.X(...) calls a built-in registry function, and you can also declare your own func blocks in globals.banto (see §12). client (the client-only registry) is the one namespace you write like a block but never declare yourself.

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.

The default is a static literal. To seed a cstate from server state instead — e.g. pre-fill a per-player draft from that player’s var record — use an init block on the host/plyr view. See §6 → Seeding cstate with init.

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 required attributes, plus one optional:

  • 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.
  • init (optional) — a server-side initializer that seeds this viewer’s cstate from server state. Covered in §6 → Seeding cstate with init.

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.container({…}), "footer", cpnt.button({…})]   // a list of elements

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

Seeding cstate with init

A cstate gets its starting value from its own default, but that default is a static literal — it can’t look at game state. When the initial value of a viewer’s client state depends on server data (their var record, player, state.params, …), use the optional init block on the host/plyr block:

cstate board {
    type: { [key: string]: string },
    default: {}
}

plyr default {
    condition: true,
    init: {
        cstate.board = var.playerBoards[player.sessionId];
    },
    child: cpnt.PlayerScreen({ … })   // reads cstate.board
}

How it behaves:

  • Runs on the server, once per snapshot. init fires when a viewer first connects to this state or when the game transitions into this state — the same moment the view is first built. It is skipped on incremental view updates (when a var changes and the already-rendered view refreshes).
  • Only its cstate.X = … writes matter. init reads server state (var, player, state.params, data, func.X(…)) freely and writes the results into cstate. The viewer receives only the resulting cstate values — never the var/data you read along the way. It cannot write var (that’s what lstn.actions are for).
  • Overrides the cstate default. Whatever init writes replaces the declared default for that render. Leave a cstate out of init and it keeps its default.

Reach for init whenever a text input, drawing surface, or other client-local widget should start pre-filled from server state rather than blank. Without it you’d have no way to seed per-client state from per-player data, since a cstate default can’t read player.


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.container — a styleable wrapper; lay out its children with CSS flex classes.
  • cpnt.image / cpnt.audio — render your own uploaded media (see below).

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]
    })
}

Reading game state, and forwarding actions

A custom component’s body can do more than wire up its own params:

  • Read global vars and datasets. A cpnt body may read any var declared in globals.banto (plus the inherent roomCode / players / currentTime) and data.*. It cannot read state-scoped vars, state.params, cstate, or player, and it never writes — pass those in via params.

    var roundScore { type: number, default: 0 }
    
    cpnt ScoreHud {
        params: { label: string },
        child: cpnt.container({ class: [css.hud], child: params.label + ": " + var.roundScore })
    }
  • Invoke an action param with a value. A params field typed action(T) can be called from the component’s own handler, forwarding a value to the call site:

    cpnt SubmitRow {
        params: { draft: string, onSubmit: action(string) },
        child: cpnt.button({
            child: "Submit",
            class: [css.submit],
            onClick: () => { params.onSubmit(params.draft); }
        })
    }

    The invoked handler is inlined at compile time and must do server work only (var writes, func.*); one that writes cstate or calls client.* is a compile error — keep that at the call site.

  • Forward actions through nested .maps. An action carried in a data field survives a .map into a nested custom cpnt, so grids/lists can be factored into reusable Cell / Row / Grid components.

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.

Custom image & audio assets

Games can ship their own images and audio. The flow has two halves — an upload step in the CLI and an asst.<name> reference in your .banto files.

1. Upload. Put media files in an assets/ directory at your project root, give them titles in banto.config.json, then push:

my-game/
├── assets/
│   ├── logo.png
│   └── victory-fanfare.mp3
└── banto.config.json
// banto.config.json
{
    "assets": {
        "logo": { "title": "Game logo" },
        "victory-fanfare": { "title": "Victory fanfare" }
    }
}
banto assets push

2. Reference. asst.<name> is a namespace (like css.<class>); <name> is the file’s base name (no extension). It resolves at build time to the asset’s id — there’s no runtime asst lookup.

// an image
cpnt.image({ src: asst.logo, alt: "Our logo", fit: "contain", class: [css.badge] })

// background audio
cpnt.audio({ src: asst.victory-fanfare, autoplay: true })

// a one-shot cue fired from an action
onClick: () => { client.playSound(asst.victory-fanfare); }

asst.<name> carries a kindimage or audio. The compiler checks it: an image asset in an audio slot (or vice versa) is a type error. A plain string is also accepted anywhere an image/audio is expected (a raw asset id or URL escape hatch), but it’s resolved at runtime and renders nothing if it can’t load.

Moderation. Freshly-pushed assets are pending — usable in your own games immediately, but only listable to others once an admin approves them. The editor lists your asset names for completion even before you push; the build is what enforces that a referenced asset actually exists. See the CLI reference for banto assets ls / rm / catalog.


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: at any position, of any list, at any depth, a value is a string, a number, or another list. Lists may nest to any depth (type Dynamic[] where Dynamic = string | number | Dynamic[]).

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>.

The style type is a list of class references — but a style slot also accepts a single reference as shorthand for a one-item list:

class: css.host-column              // one class (shorthand)
class: [css.host-column]            // one class (list form)
class: [css.host-column, css.with-shadow]   // multiple
class: []                                   // none

Use whichever reads best. The single-value form is handy for the common case of one class; reach for [ ] when you have several or want to mix in conditional entries (see below).

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. The same applies to the single-value form — class: var.isCorrect && css.correct applies the class only when the guard is truthy.


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.

Declaring your own functions

Beyond the built-in registry you can define reusable functions in globals.banto and call them the same way — func.<name>(...). They’re pure helpers: they take a single argument, compute, and return a value for an action body (or a view) to use.

func scoreFor {
    params: { correct: boolean, streak: number },
    returnType: number,
    action: {
        let base = params.correct ? 100 : 0;
        return base + params.streak * 10;
    }
}
// In a listener or action body:
var.players[player.sessionId].score += func.scoreFor({ correct: true, streak: var.streak });

Declaration shape:

  • params — the type of the single argument, referenced inside the body as params.<name> (or bare params when the type isn’t an object). Omit for a no-argument function.
  • returnType — the type returned. Optional: omit it for a void helper that just computes side-effect-free intermediate values. When set, every path through the body must return a value of this type.
  • action — the body. It behaves like an action block: let bindings are allowed, and it may call other registry functions (func.X).

The rules that keep them predictable:

  • Params are read-only. You can’t assign to params.x or call a mutating method on it — build a new value and return it.
  • Pure over params. A func body can’t read game state (var, cstate, state, data, player) or call client.X. Pass whatever it needs in as an argument. This is what lets the same function run on the server and the browser.
  • No recursion. A function may not call itself, directly or through another function.

Functions run wherever they’re needed: server-side in action bodies and listeners, and client-side when referenced from a view or a client-side cstate write. A function reached only from the client is shipped to the browser automatically.

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 the built-in cue names ("tick", "ding", "whoosh", "chching", "applause", "countdown", "anticipation", "waiting") or an audio asset (asst.<name>) to play one of your own uploaded clips.


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.