0.3 Series
The 0.3 line closes out a long-running cleanup: authored .cade behavior
that parsed cleanly and then did nothing at runtime, or behaved differently
between cade run, cade console and the browser/wasm host. The headline
changes are a single event-dispatch order on every host, progressive score
accumulators, servo named positions, mixer bus routing, a shipped tilt
module, and mode suppression now cascading to composed child modules — each
of those previously parsed and silently had no effect, or diverged by host.
A long tail of related configuration is now either wired up or refused with
a load error instead of being silently dropped.
This is the first release since 0.2 that asks a table to change. Read Upgrading from 0.2 before you move a table across.
For the entry-by-entry record, see the Changelog.
v0.3.0 “Ballyhoo” — 2026-10-16
Upgrading from 0.2
Work through these in order — most fail loudly (a parse error or a load error naming the construct), but two change behavior silently and need checking by hand.
Multipliers are now plain numbers
playfield_multiplier, and every shipped example’s *_multiplier
variable, is now a plain multiplier: 2 means 2x, not 200% (the old
base-100 fixed-point convention). Change playfield_multiplier = 200 to
playfield_multiplier = 2. If you want a percentage, divide explicitly
(/ 100).
Event dispatch order — check this by hand
Every host now dispatches an event in the same fixed order: handlers run
first, then scoring, then flow notifications. A handler bound to the same
event as a score rule now runs before that rule, so a set_variable in
the handler is visible to the rule’s own condition/points on the same
event. Inside one handler, action verbs run in a fixed executor order —
end_mode before points, for example — regardless of the order they are
authored in; only the handler’s own entry condition
(mode.<name>.active) decides whether it runs at all. A score event
rule’s own emit = <event> attribute now also dispatches, as a depth+1
follow-up through the same order.
If a table depended on scoring running before a handler on the same event, or on handler action order matching authored order, check it by hand — nothing reports this one.
Suppressed modes now suppress their composed children
module "parent" { compose { includes = [...] } } children with the
default lifecycle = "managed" now stop their event handlers, scoring
rules and device_control whenever an ancestor is suppressed. An
independent composed child is unaffected. This reverses the previous
ruling that suppression never cascaded — if a table relied on a composed
child continuing to run while its parent was suppressed, mark that child
independent.
The pragma {} block is gone
pragma { ... } is now a parse error naming the removed block, including
its already-inert disable_checks, events.dedupe_window and
errors_as_warnings attributes. pragma stays a reserved word in case a
future, deliberately-wired replacement arrives. cade pragma analyze and
cade pragma migrate are removed with it. The separate reserved-keyword
severity system behind cade validate --pragma-mode / --ignore-pragma is
unaffected — remove that distinction and that’s a different system.
Stricter load-time and type checking
Several previously-silent mistakes are now load or compile errors:
- an undeclared variable read, or an undeclared target on
set_variable/increment/decrement/toggle_variable, used to resolve to a zero value — it is now a compile error; - comparing a
boolto a number, or astringto a non-string, is a type error on every host; - an out-of-range integer literal in a score rule is refused at compile
instead of wrapping, and a runtime
int64overflow now refuses the whole expression (yields 0, warns once) instead of silently wrapping.
stacking lists take module references
stacking { requires, conflicts_with, replaces, promotes } now takes
module traversals, not quoted strings: requires = ["multiball"] becomes
requires = [module.multiball]. An unresolvable element is an error in a
complete table (one with a top-level game {} block) and a warning in a
fragment. A module’s own contradictory stacking declarations are now
always a hard error.
More configuration that parsed and did nothing
These now either work or refuse to load — fix or remove rather than expect the old (no-op) behavior:
- an unrecognized score accumulator
typeis a load error; - a
move_servo { position = "..." }name not declared in that device’spositions {}map is a load error; - a channel/bus
outputnaming no declared bus, itself, or closing a routing cycle is a load error (native hosts only — see Audio); - an
aggregationrule’son_trigger/on_tier_changeblock now refusesemit/data, which were always ignored; devices {}’s legacy attribute form no longer silently drops a bad category, and one mislabelled category block no longer aborts every sibling device in the samedevices {};game { max_players, credits_per_player }— these belong incade.confand were never read from.cade— are now refused, namingcade.confas where they belong;pulse_coil { pulse_ms }, deprecated with a warning since 0.2.0, is now a hard refusal.
Scoring and accumulators
Progressive accumulators (score "accumulator" { type = "progressive" })
bank a percentage of each scoring event: contribute_percent% of the
event’s rule points accumulates, gated by contribute_when, and collect
pays out value * collect_multiplier (a plain multiplier) then resets.
contribute_when reads a new score_event.value namespace — the points
this event’s rules just awarded — distinct from a device event’s own
event.* payload.
stacking { promotes = [...] } spells “require, then replace” as one
attribute, for the require-then-replace idiom already common in authored
tables.
A numeric variable’s declared min/max is now a clamp on every write
path — a score rule’s update {}, a modifier, an accumulator hook, a timer
action, increment/decrement — not just the one path that enforced it
before. A write past the bound now stores the bound instead of an
out-of-range or silently-reset value.
Modes and flow
A tilt module template ships at
docs/configuration/templates/devices/tilt.cade, emitting
game.tilt_warning, game.tilt and game.slam_tilt, with a
variable-driven warning trip and a slam_tilt_behavior variable
(end_game by default, or tilt / end_ball). The new end_ball action
verb forfeits the current ball — ball save does not return it.
flow "timed" and flow "state_machine" are implemented (previously
refused at load). A timed flow arms a duration window and fires
on_complete / on_timeout; a state machine moves between named
state {} blocks on on / to, with timeout / timeout_to /
auto_advance and an entry {} action, plus phases {} with
lazy-selected points and flow.<name>.elapsed_ms.
signal.<name> is now accepted in scoring points/conditions,
computed-variable formulas, and condition gates, backed by a real signal
source on every host.
Audio
Channel/bus output routing is now wired into the native mixer: a bus’s
own volume, pan and effects apply to everything routed into it, instead of
every channel going straight to the direct output. This is native-host
(cade run / cade console) only — the browser/wasm host’s separate mixer
still has the same gap.
Hardware
move_servo { position = "..." } now resolves the name through the
device’s declared positions {} map, on every host, at load and after a
hot-reload. One caveat: cade console driving a platform over its gRPC
transport does not yet send servo commands across that wire.
A paired device.<flipper>.hold_ended verb, and a flipper heat/cooling
model (flipper_cooling), are available; the shipped flipper template uses
it to warn on and derate overheating.
Hot-reload
A hot-reload now carries the running game forward by name: score, balls
left/extra balls, and every variable that still exists with the same type.
Previously, editing a .cade file mid-game reset every player.
Fixed
A long list of behavior that previously diverged between hosts, or
silently did nothing, is now consistent everywhere cade runs:
- a suppressed module’s event handlers, scoring rules, modifiers,
accumulators and
device_controlinhibitions now actually stop, on every host (some previously kept running or scoring while the mode manager reported the module as suppressed); - mode and module lifecycle events (start, end, suppression, timeout)
reach mode-scoped
on {}handlers and sibling modules’events {}handlers on every host; a mode’s own handlers and accumulators now run only while that mode is active, and its own variables resolve correctly from inside its handlers; - interaction-context
device_controland displays, and modetrigger/play_show/stop_showactions, now work oncade runandcade console, not only the browser host; - computed (formula) variables of every type — int, float, string, list,
map — evaluate and announce
variable.<name>.changedcorrectly, not just numeric ones; - decimal (fixed-point) arithmetic, integer division, modulo, and whole
numbers above float64’s precision limit now behave identically across
cade run,cade consoleand wasm; - hot-reload no longer races, deadlocks, drops live player state, or loses routed audio/signal/device_control bindings — a reload now re-registers mode/module definitions, re-seeds typed variable storage, and refreshes lights, flippers, servos, autofire grants, switches and the drop-bank tracker;
score "event"rules bound to the same trigger keep their authored update order, including when resolved from a fragment;- mode timers are scoped to their own mode instance, so two instances of the same mode (or a mode started again after ending) no longer share or clash on timer state;
- several event-routing and console/wasm shutdown races: duplicate or dropped device-closing events, a reload racing a watcher-triggered reload, and gRPC/platform goroutines not stopping cleanly on shutdown;
award_pointsand numeric expressions resolve end to end through mode actions and assembly-rewritten signal awards;- FAST controller coil settings and enable/disable state survive a hot-reload without dropping a held coil or losing a revoked autofire grant.
Performance
set_variable now wakes reactive conditions with an O(1) check instead of
scanning, and routed-audio selection conditions are precompiled once
instead of per dispatch.