Gotchas & common compile errors
The fastest way to stop fighting the compiler. Each row is a mistake that costs a
build round-trip, the diagnostic code you’ll see (banto.<code>), and the fix.
Read this once before writing a game; skim it again the first time a build fails.
Source of truth for the rules is SYNTAX-AND-USAGE.md; this page is the
“things people actually trip on” subset. Diagnostic codes come from
packages/core/src/diagnostic-codes.ts.
Project & state structure
| Symptom | Code | Fix |
|---|
| Build says no start state | missing-start-banto | The project must have a start.banto whose state block is named start. |
| A state renders nothing / errors on entry | default-host-missing, default-plyr-missing | Every state needs one host default { condition: true, … } and one plyr default { condition: true, … }. These are the fallback screens. |
| ”default must be last” / “default condition must be true” | default-not-last, default-condition-not-true | The default block goes last among its host/plyr siblings, and its condition must be literally true. Put conditional variants above it. |
lstn ... next can’t find a state | state-not-found | The next.state string must match a real state name. States are files: foo.banto → state foo. |
next complains about inputs | lstn-next-missing-inputs, lstn-next-inputs-mismatch, lstn-next-extraneous-inputs | If the target state declares params, pass exactly those (names + types) in next.inputs. |
Where blocks are allowed
| Symptom | Code | Fix |
|---|
cpnt / data / func / global var rejected in a state file | cpnt-only-in-globals, data-only-in-globals, func-only-in-globals, block-not-in-globals | Declare custom components, datasets, custom funcs, and global vars in globals.banto. State files hold state, host, plyr, lstn, and state-local var/cstate. |
cstate rejected | cstate-only-in-states | cstate (client-local draft state) lives inside a state block, not globals. |
Actions, client vs server
| Symptom | Code | Fix |
|---|
| Action arrow rejected for too many params | action-arrow-too-many-params | Action arrows take at most one parameter: arg => { … }. Components pass a single value (onChange: (v) => …). |
client.X rejected | client-func-not-in-action, client-func-in-loop | client.* (e.g. client.playSound) may appear only inside an action arrow body (onClick/onChange/onSubmit), and never inside a .map/.forEach/.filter body. |
cstate write rejected | cstate-write-interleaved, client-phase-mixed-branch | The compiler splits a handler into client prelude → server body → client epilogue (see the recipe). So: keep cstate writes as a contiguous prefix and/or suffix with your server writes (var.*, func.notify, …) grouped in the middle — a server write after an epilogue cstate write is cstate-write-interleaved. cstate writes may sit inside if/forEach branches, but such a branch can’t also hold a var/server write (client-phase-mixed-branch) — split it. |
| Nothing happens when a button is clicked | (no error) | The action must sit directly in a component’s handler slot (onClick: () => …). An action nested inside a plain-object data param is silently dropped — hoist it to the handler field. |
| Text/number input loses characters or won’t submit | (no error) | Bind value to a cstate.<name>, write cstate.<name> = arg in onChange, and commit to var.* (and clear the cstate) in onSubmit. Binding value straight to a var fights the server round-trip. |
Iterators & registry functions
| Symptom | Code | Fix |
|---|
.map(...) callback rejected | iterator-callback-statement-body | The value-returning iterators — .map, .filter, .find, .findIndex, .some, .every — need a single expression callback (x => x.score), not a { … } statement block. .forEach is the one that takes a statement body. |
An unlisted method rejected (.reduce, .flatMap, .at, …) | instance-method-unknown, instance-method-wrong-type | Banto recognizes a fixed method set — see the full list. An unknown name, or a real method on the wrong type (.push on a string), is a compile error; rebuild it from the supported methods (e.g. .reduce → a .forEach accumulating into a let/var). |
Passing an arrow to func.mapValues / func.groupBy fails | call-arg-type-mismatch | Registry funcs don’t accept arrow callbacks. mapValues/groupBy take a key string (a property name), not a transform function. |
break / continue rejected | break-continue-outside-loop | Only valid inside a .forEach body. |
Object.assign / Object.freeze / etc. rejected | unknown-func | Banto recognizes only Object.keys, Object.values, Object.entries, Object.fromEntries. Everything else is a compile error. |
| A list of components renders empty for a non-named chain root | (fixed in compiler) | [1,2,3].map(...) etc. now compile to a chain IR node and iterate correctly — you don’t need a named intermediate. |
Custom func.X
Custom funcs are pure helpers over their params — think of them as
stateless utilities.
| Symptom | Code | Fix |
|---|
Func body references var/cstate/state/data/player | func-scope-forbidden | Pass game state in as an argument (func.score({players: var.players})), don’t read it from scope. |
Writing to params.x inside a func | func-param-readonly | Params are read-only. Build a new value and return it. |
| Func calls itself | func-recursion | Recursion is disallowed. Flatten to a loop / func.range(...).forEach(...). |
| Return-type errors | func-missing-return, func-return-in-void, func-return-type-mismatch | If returnType is set, every path must return an assignable value; if it’s unset, don’t return a value. |
Custom cpnt.X
Custom components can read global state and forward actions, but they’re
still read-only and state-scoped data isn’t in scope.
| Symptom | Code | Fix |
|---|
A cpnt body reads a var that’s declared inside a state file | unknown-var | A cpnt lives in globals and can only read global vars (declared in globals.banto) + the inherent roomCode/players/currentTime. Pass a state-scoped value in through params at the call site. |
A cpnt body reads state.params | state-params-wrong-block | state.params isn’t visible to a component. Pass the field(s) you need in via params. |
A cpnt body reads cstate.X | cstate-wrong-block | cstate is per-state client draft state; a component can’t reach it. Bind it at the call site and pass the value in. |
Writing to var/cstate inside a cpnt body | (type/ref error) | Components are read-only. Do the write in the call-site handler, or forward an action param (below) and write there. |
An invoked action param writes cstate / calls client.* | cpnt-action-invoke-client-phase | params.onSubmit(v) inlines the handler into a server-side slot, so it can only do server work (var/func.*). Move any cstate/client.* work to the call site. |
Invoking (params.onPick(v)) an action that was forwarded into the cpnt through a .map | cpnt-action-invoke-not-inlinable | The handler isn’t known at compile time, so it can’t be inlined with an argument. Forward it to a slot instead (onClick: params.onPick, no call), or invoke it at the call site and pass a plain action down. |
Reading a global var or data.*, invoking an action param with an argument
(params.onSubmit(params.draft)), and forwarding an action through a nested
.map into another custom cpnt all work — see the
language guide.
Type aliases (type X = …)
Declare reusable named types and enums in globals.banto; reference them
anywhere a type is expected. Aliases are compile-time only.
| Symptom | Code | Fix |
|---|
type X = … rejected in a state file | type-only-in-globals | Type aliases live in globals.banto only. Move it there; reference it from any file. |
| Two aliases with the same name | duplicate-type | Rename one. Alias names must be unique (they don’t collide with var/cpnt names — different namespace). |
| A type name doesn’t resolve | unknown-type | type: Widget needs a matching type Widget = … in globals. Check the spelling, or use a built-in type. |
| An alias refers to itself | type-alias-cycle | Recursive types aren’t supported (type Tree = { kids: Tree[] } and mutual A/B pairs are both rejected). Flatten the shape, or use any for the recursive part. |
Inline "a" | "b" in a var/params annotation rejected | string-enum-not-user-declarable | Enums must be named: declare type Mode = "a" | "b" in globals and reference Mode. |
CSS
styles.banto.css is a restricted subset. css-forbidden-property,
css-forbidden-function, css-forbidden-atrule, and
css-forbidden-element-selector mean you used something outside the allowlist — styling has to be done via a css block reference (declared in styles.banto.css)
Custom assets (asst.X)
| Symptom | Code | Fix |
|---|
asst.<name> rejected as unknown | unknown-asset | The base name must match a file you’ve pushed. Add it under assets/, title it in banto.config.json, and run banto assets push. |
| Image asset in an audio slot (or vice versa) | (type error) | asst.<name> carries a kind. cpnt.image wants an image, cpnt.audio/client.playSound want audio. Push the right file and reference it. |
Runtime (no compile error, but wrong on screen)
- Host overflow is a bug. The host screen shows on a TV and must never scroll
in any direction. Size fonts/padding so the busiest phase (max players, longest
text) still fits.
- An empty dataset means you didn’t seed one. When you host a published
game,
data.questionSet/promptSet/dynamic come from the data source the
host picks at game start — which is your banto.config.json default.*
binding. If default.* is missing (and there’s no new.* seed to bind
"this" to), the dataset is empty. Ship a new.* seed or point default.*
at a real data-source id.
- A custom asset renders nothing. If a
cpnt.image/cpnt.audio shows
nothing, the asst.<name> either wasn’t pushed (banto assets push) or you
passed a raw string id/URL that failed to load. asst.<name> refs are
build-checked; raw strings are not.
func.pickRandom / func.first / func.last return undefined on an empty
list. Branch before use. Number aggregates (sum/avg/minOf/maxOf)
return 0 on empty instead, so they render cleanly.
- Subtraction glued to a name mis-parses. Identifiers may contain hyphens
(
css.welcome-screen), so var.a-var.b lexes as one name and either errors or
reads wrong. Put spaces around the minus: var.a - var.b. (Only subtraction —
+ * / % aren’t affected.)