0.3 Series

releases changelog upgrading

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 bool to a number, or a string to 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 int64 overflow 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 type is a load error;
  • a move_servo { position = "..." } name not declared in that device’s positions {} map is a load error;
  • a channel/bus output naming no declared bus, itself, or closing a routing cycle is a load error (native hosts only — see Audio);
  • an aggregation rule’s on_trigger / on_tier_change block now refuses emit / 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 same devices {};
  • game { max_players, credits_per_player } — these belong in cade.conf and were never read from .cade — are now refused, naming cade.conf as 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_control inhibitions 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_control and displays, and mode trigger / play_show / stop_show actions, now work on cade run and cade console, not only the browser host;
  • computed (formula) variables of every type — int, float, string, list, map — evaluate and announce variable.<name>.changed correctly, 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 console and 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_points and 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.