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

SymptomCodeFix
Build says no start statemissing-start-bantoThe project must have a start.banto whose state block is named start.
A state renders nothing / errors on entrydefault-host-missing, default-plyr-missingEvery 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-trueThe 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 statestate-not-foundThe next.state string must match a real state name. States are files: foo.bantostate foo.
next complains about inputslstn-next-missing-inputs, lstn-next-inputs-mismatch, lstn-next-extraneous-inputsIf the target state declares params, pass exactly those (names + types) in next.inputs.

Where blocks are allowed

SymptomCodeFix
cpnt / data / func / global var rejected in a state filecpnt-only-in-globals, data-only-in-globals, func-only-in-globals, block-not-in-globalsDeclare custom components, datasets, custom funcs, and global vars in globals.banto. State files hold state, host, plyr, lstn, and state-local var/cstate.
cstate rejectedcstate-only-in-statescstate (client-local draft state) lives inside a state block, not globals.

Actions, client vs server

SymptomCodeFix
Action arrow rejected for too many paramsaction-arrow-too-many-paramsAction arrows take at most one parameter: arg => { … }. Components pass a single value (onChange: (v) => …).
client.X rejectedclient-func-not-in-action, client-func-in-loopclient.* (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 rejectedcstate-write-interleaved, client-phase-mixed-branchThe 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

SymptomCodeFix
.map(...) callback rejectediterator-callback-statement-bodyThe 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-typeBanto 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 failscall-arg-type-mismatchRegistry funcs don’t accept arrow callbacks. mapValues/groupBy take a key string (a property name), not a transform function.
break / continue rejectedbreak-continue-outside-loopOnly valid inside a .forEach body.
Object.assign / Object.freeze / etc. rejectedunknown-funcBanto 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.

SymptomCodeFix
Func body references var/cstate/state/data/playerfunc-scope-forbiddenPass game state in as an argument (func.score({players: var.players})), don’t read it from scope.
Writing to params.x inside a funcfunc-param-readonlyParams are read-only. Build a new value and return it.
Func calls itselffunc-recursionRecursion is disallowed. Flatten to a loop / func.range(...).forEach(...).
Return-type errorsfunc-missing-return, func-return-in-void, func-return-type-mismatchIf 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.

SymptomCodeFix
A cpnt body reads a var that’s declared inside a state fileunknown-varA 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.paramsstate-params-wrong-blockstate.params isn’t visible to a component. Pass the field(s) you need in via params.
A cpnt body reads cstate.Xcstate-wrong-blockcstate 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-phaseparams.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 .mapcpnt-action-invoke-not-inlinableThe 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.

SymptomCodeFix
type X = … rejected in a state filetype-only-in-globalsType aliases live in globals.banto only. Move it there; reference it from any file.
Two aliases with the same nameduplicate-typeRename one. Alias names must be unique (they don’t collide with var/cpnt names — different namespace).
A type name doesn’t resolveunknown-typetype: Widget needs a matching type Widget = … in globals. Check the spelling, or use a built-in type.
An alias refers to itselftype-alias-cycleRecursive 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 rejectedstring-enum-not-user-declarableEnums 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)

SymptomCodeFix
asst.<name> rejected as unknownunknown-assetThe 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.)