~ / docs / language-guide # 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
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.
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 (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, 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.

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.container({…}), "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.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]
    })
}

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.

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

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.
  • SYNTAX-AND-USAGE.md — the exhaustive language spec. Every rule, every edge case.
  • examples/trivia/ in this repo — a complete working game. Read it once you’ve absorbed this guide; everything in it should be familiar.