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:
- 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.
- 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.
- No
ifyou, noforyou in views. Views are declarative. Use multiple ordered blocks for “if/else” (first true wins) and.mapover 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.bantoand exactly onestart.banto. The latter is where every game starts. - Every other
.bantofile is a state. The file name (without.banto) is the state’s name and must match thestateblock declared inside it:question.bantocontainsstate 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:
| Type | What it represents |
|---|---|
element | Something renderable on screen — a primitive, a component, or a list of those. |
style | A list of CSS class references. Used by component params that take styling. |
action | A 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 go | Block kinds |
|---|---|
| State files only | state, host, plyr, lstn, cstate |
globals.banto only | data, cpnt |
| Both | var |
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.
var | cstate | |
|---|---|---|
| Lives where? | Server | Each viewer’s browser |
| Lives how long? | Across state transitions | Wiped on every transition |
| Who can write it? | host, plyr, lstn (via actions) | host, plyr only |
| Who can read it? | Anyone server-side | Only 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. Anelementvalue: 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
actionsblock (mutate vars, callfunc.Xside effects). - Optionally, transition to another state via
next.
lstn hasStarted {
condition: var.started,
actions: {},
next: {
state: "question",
inputs: { questionId: 0 }
}
}
| Attribute | Required? | What it is |
|---|---|---|
condition | yes | A boolean. The listener fires when this becomes true. |
actions | no | An action block — server-side side effects (assignments, registry calls). |
next | no | { 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:
- The component registry — components shipped with Banto.
Reference them with
cpnt.<Name>(args). - Your own globals — declare a custom component with a
cpntblock inglobals.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 acstate.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 asparams(orparams.foofor an object).child(required) — anelementvalue. Same rules as inhost/plyrblocks.
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
paramsand use other components, but it cannot readvar,state,data, orplayer. Components are pure with respect to game state — pass values in viaparamsinstead.
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:
| Block | Type |
|---|---|
data questionSet | { prompt: string, options: string[], correctOptions: number[] }[] |
data promptSet | string[] |
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.forEachbody — works likecontinue, 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
fororwhileloops. 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
actionslot 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:
| What | When is it read? |
|---|---|
Iteration locals (item, i, destructured names) and player | Captured by value at render time. The action remembers which iteration produced it. |
var.*, state.params, data.*, registry calls | Read 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.