Changelog
All notable changes to the Cade Runtime are documented in this file.
The format is based on Keep a Changelog, and the project adheres to Semantic Versioning.
For a readable walk through what a release means rather than an entry-by-entry list, see the Cade Runtime release notes for its version series.
[v0.3.0] - 2026-10-16 “Ballyhoo”
This release closes out a long-running cleanup: authored .cade behavior
that parsed cleanly and then did nothing at runtime, or did something
different on cade run, cade console and the browser/wasm host. The
headline changes are a single event-dispatch order on every host,
progressive accumulators, servo named positions, mixer bus routing, a
shipped tilt module, and managed-child suppression — each previously parsed
and silently had no effect, or diverged by host. A long tail of related
constructs is now either wired up or refused with a positioned load error
instead of being silently dropped.
Breaking Changes
playfield_multiplieris a plain multiplier, as is every shipped example’s*_multipliervariable.2now means 2x; it no longer means 200% (base-100 fixed point).playfield_multiplier = 200→playfield_multiplier = 2. An author who wants a percentage now divides explicitly (/ 100).- Event dispatch order is fixed on every host: handlers, then scoring,
then flow notify. A handler bound to the same event as a
scorerule now runs before that rule, so a handler’sset_variableis visible to the rule’s owncondition/pointson the same event. Within one handler, action verbs run in the executor’s own fixed order —end_modebeforepoints, for example — not the order they are authored in; only the handler’s own entry condition (mode.<name>.active) decides whether it runs at all. A scoreeventrule’s ownemit = <event>attribute is now dispatched too, as a depth+1 follow-up through the same order. - Suppressing a mode now suppresses its managed composed children.
module "parent" { compose { includes = [...] } }children with the defaultlifecycle = "managed"stop their event handlers, scoring rules anddevice_controlwhenever an ancestor is suppressed; anindependentcomposed child is unaffected. This reverses the prior ruling that suppression never cascaded. - The
pragma {}block is removed, including its already-inertdisable_checks,events.dedupe_windowanderrors_as_warningsattributes.pragma { ... }is now a parse error naming the removed block;pragmastays a reserved word for a future, deliberately-wired revival.cade pragma analyze/migrateare removed with it. The separate reserved-keyword severity system behindcade validate --pragma-mode/--ignore-pragmais unaffected. - Stricter load-time and type checking turns several previously-silent
constructs into errors: an undeclared variable read, or an undeclared
target on
set_variable/increment/decrement/toggle_variable, is now a compile error instead of resolving to a zero value; comparing aboolto 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 int64 overflow now refuses the whole expression (yields 0, warns once) instead of silently wrapping. - More configuration that parsed and did nothing now either works or is
a load error. A
stackinglist (requires,conflicts_with,replaces, the newpromotes) now takes module traversals, not quoted strings —requires = ["multiball"]→requires = [module.multiball]— and an unresolvable element is an error in a complete table (one with a top-levelgame {}block) and a warning in a fragment; checks for a module’s own contradictory declarations are now always hard errors. An unrecognized score accumulatortypeis a load error. Amove_servo { position = "..." }name not declared in that device’spositions {}map is a load error. A channel/busoutputnaming no declared bus, itself, or closing a routing cycle is a load error (native hosts only — see Added). Anaggregationrule’son_trigger/on_tier_changeblock 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 in the samedevices {}.game { max_players, credits_per_player }—cade.confsettings, never read from.cade— are refused, namingcade.conf.pulse_coil { pulse_ms }, deprecated with a warning since v0.2.0, is now a hard refusal.
Added
- Progressive accumulators (
score "accumulator" { type = "progressive" }):contribute_percent% of each scoring event’s rule points accumulates, gated bycontribute_when, andcollectpays outvalue * collect_multiplier(a plain multiplier) then resets.contribute_whenreads a newscore_event.valuenamespace — the points this event’s rules awarded — distinct from a device event’s ownevent.*payload. - A tilt module template (
docs/configuration/templates/devices/tilt.cade) emittinggame.tilt_warning,game.tiltandgame.slam_tilt, with a variable-driven warning trip and aslam_tilt_behaviorvariable (end_gamedefault,tilt, orend_ball); newend_game/end_ballaction verbs.end_ballforfeits the current ball: ball save does not return it. signal.<name>is accepted in scoring points/conditions, computed-variable formulas and condition gates, backed by a real signal source on every host.flow "timed"andflow "state_machine"are implemented (previously refused at load): a timed flow arms a duration window and fireson_complete/on_timeout; a state machine moves between namedstate {}blocks onon/to, withtimeout/timeout_to/auto_advanceand anentry {}action, plusphases {}with lazy-selected points andflow.<name>.elapsed_ms.- Channel/bus
outputrouting is wired into the native mixer: a bus’s own volume, pan and effects now apply to everything routed into it, instead of every channel going straight to the direct output. Native host (cade run/cade console) only — the browser/wasm host’s separate mixer has the same gap. move_servo { position = "..." }resolves the name through the device’s declaredpositions {}map on every host, at load and after a hot-reload.cade consoledriving a platform over its gRPC transport does not yet send servo commands across that wire.stacking { promotes = [...] }meansrequires+replacesas one attribute, for the require-then-replace idiom already used by both corpora.- A paired
device.<flipper>.hold_endedverb, and a flipper heat/cooling model (flipper_cooling, used by the shipped flipper template to warn on and derate overheating).
Changed
- A numeric variable’s declared
min/maxis now a clamp on every write path — a score rule’supdate {}, a modifier, an accumulator hook, a timer action,increment/decrement— not just the one path that enforced it before; a write past the bound stores the bound instead of an out-of-range or silently-reset value. - 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 a
.cadeedit mid-game reset every player.
Fixed
- A suppressed module’s event handlers, scoring rules, modifiers,
accumulators and
device_controlinhibitions now actually stop on every host; they previously kept running or scoring while the mode manager reported the module as suppressed. - Mode and module lifecycle events (start, end, suppression, timeout) are
delivered to mode-scoped
on {}handlers and to sibling modules’ `events 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, instead of only 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, instead of diverging between the compiled engine and the expression evaluator fallback. - 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, and a fragment-resolved rule keeps its own.- 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.
- A variety of 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_variablewakes reactive conditions with an O(1) slot comparison instead of a fingerprint scan, and routed-audio selection conditions are precompiled once instead of per dispatch.
[v0.2.0] - 2026-10-04 “Whiffle”
The second Cade Runtime release makes .cade a native-HCL language end to end:
mode logic is written as structured actions {} blocks instead of embedded
script, any reference can be written fully qualified, and a table can be split
into per-domain source files. It adds a dot-matrix display pipeline, interaction
contexts, high-score initials entry, and list, map and string variables. Much of
the rest closes a long tail of configuration that used to parse cleanly and then
do nothing; most of those cases now produce an error or warning instead.
Breaking Changes
- Mode event handlers are native HCL. The quoted trigger label and the
do = <<-EOT … EOTscript body are gone; both now fail with a parse error that names the retired spelling. Before → after:on "device.frenzy_target.hit" { do = <<-EOT start_mode "frenzy" EOT }→on { when = device.frenzy_target.hit actions { start_mode = module.frenzy } }. Insideactions {}, each verb’s value is its argument list:emit "x"→emit = x,start_mode "m"→start_mode = module.m, bareend_mode→end_mode = [],play_show "intro", 10→play_show = ["intro", 10], andset var.x = …→set_variable { name = "x" value = … }. The same applies toon_start,on_endand timeron_expire/on_completebodies, and an unknown block insideevents {}is now an error. - Variable scope. The scopes are
global,machine,session,playerandball. An omittedscopenow meansplayer, notglobal: such variables reset with each game instead of persisting across games, so addscope = "global"where you relied on the old behavior.scope = "game"is removed (it used to meanglobal); replace it withglobal,machineorsession, whichever you meant. An unknown scope is an error. - Variable types. The type labels are
int,float,bool,string,list,mapandprobability. The alias spellings (integer,double,number,boolean,text,array) are now errors:variable "integer" "x"→variable "int" "x". - Every scoring rule bound to a trigger now fires, in priority order. Before, when two rules shared a trigger, only the higher-priority one scored (or, at equal priority, the scoring config failed to compile). If your table used priority to override a rule, remove the rule you don’t want.
- Coil tuning lives in
settings {}.default { power, duration },settings { trigger_time },main {},hold {},pulse {}andproperties { pulse_ms }on a coil are no longer read. Usesettings { pulse_ms, strength, hold_power, recycle_ms, max_pulse_ms }.strengthandhold_powerare whole percentages:default { power = 0.8 }→settings { strength = 80 }. - Device categories are checked. An unknown category (e.g. a subtype
written where the category goes) is now an error instead of a device that
no driver backs:
device "led" "matrix" "gi"→device "light" "led" "gi",device "ramp" …→device "switch" "ramp" …. - Source units. In a table stored as separate unit files, the scoring unit
is renamed
rules.cade→scoring.cade. A newmodes.cadeunit now holdsmodule,modeandinteraction_contextblocks. A single-file table loads as before. - Configuration that never took effect is now reported. A table that
loaded with these constructs will now get warnings or errors: unrecognized
top-level blocks and device sub-blocks (including
game_audio,audio_effect,audio_group, the deviceevent {}andproperties {}sub-blocks, andaudio { ball_rolling / ball_drop / ball_collision }), a singularaction {}in anevent_handler(useactions {}), a labelledset_variable "x" {}, unknown attributes in aflow, a scoring rule or handler with no trigger, a score expression whose operand types don’t match, an unsupportedselection_method, and a lightshowoutput.targetthat names a device rather than a layout.audio_system {}engine tuning moves out of.cadeinto theaudio {}block ofcade.conf. cade.confis validated at startup. An invalid block now stops startup instead of falling back to defaults, a--configpath that does not exist is an error, andanalytics { backend_type = "grpc" }is rejected. It never sent events anywhere.- Release downloads are archives. Releases now ship
.tar.gz/.ziparchives plus.deb,.rpmand.apkpackages, with an SBOM for each archive, instead of bare binaries. Scripts that download a bare binary need updating.
Added
Configuration & language
- Fully-qualified references everywhere:
device.<category>.<subtype>.<name>.<action>, with*in the name position as an explicit group (device.switch.rollover.*.hit). Device lists accepttags = [tag.bumper], and references are written as bare traversals rather than quoted strings. Short spellings remain accepted. - Device action verbs take the device as an attribute:
pulse_coil "reset" {}→pulse_coil { device = device.coil.standard.reset }. The label form still parses. - List, map and working string variables, with indexing (
var.scores[var.mode]) andlist.*/map.*functions; all nine documented decay/growth attributes on variables (auto_decay,decay_interval,grow_amount, …). #and//comments inside action and function bodies; modefunctions {}bodies are checked at load time instead of on first call.
Events, scoring & modes
- Interaction contexts: a reusable
interaction_contextblock that remaps player input (input_map, includingheld_for(...)holds and release edges), drives displays and inhibits devices while a mode is active. ball_watermark { target = … }on a mode holds a minimum ball count in play and tops it up, for multiball and add-a-ball.repeat_count/repeat_windowonevent_handlerfor tap-pass detection.- Shot and sequence signals now run in production, with combo chaining,
all_down, per-step points, anenabledgate, and workingfire_signal. - Cade now owns drop-bank reset and publishes
bank_complete;launch_ballauto-plunges a served ball through a shooter-lane auto-launch coil. - High scores: a built-in board, a
high_score.qualifiedevent, and initials entry both in the console and on machine hardware, plus a correction command. - A
module { mode {} }can be composed inside anassembly.
Displays & DMD
- A dot-matrix display pipeline: a 128×32 shaded frame buffer, a priority-ordered compositor, image and sprite sources, and a text rasterizer.
display_showblocks with authored trigger criteria and priority/blend/opacity arbitration, started and stopped bydisplay_show/stop_display_showactions; per-elementvisible.display_textresolves${…}when it fires, so a live score reaches the display.
Audio
- Amplitude modulation on oscillators (
target = "<osc>.amp") alongside FM. route { ordered = true }for ordered clip rotation,probability_methodfor the four probability mapping methods, and areset_selectionaction.
Authoring surface (editor hosts)
- Validate-only and format calls for source units, a block-to-unit splitter for the multi-file layout, and an export of the combined source.
- A published event vocabulary (including the
system.*lifecycle events), fully-qualified event names, and what each reference parameter refers to. - The rules graph covers modes, interaction contexts, variable writes and action verbs, and all of them can be edited.
- Display frames, display descriptors and show frames are readable from the browser build, as is a session export with its full event tree.
Platform & tooling
- Declared coils, switches, servos and flippers are configured on every driver.
- Device commands can be sent to a single connected session, and each event records which session it came from.
cade console validate-configchecks a console configuration file.--log-leveland--log-formatwork on every subcommand.
Changed
- Deferred engine work, mode timers, parse-error order and the configuration identity hash are now deterministic, so replays line up. The hash no longer depends on file names, source positions or how many files a table uses.
- A configuration file’s
history {}andanalytics {}blocks now take effect:enabled = falsereally disables recording.CADE_*environment variables now override the file, andlogging {}reaches every command. hardware {}addresses are plain hex bytes, as the drivers read them:coil = "C01"→coil = "01".
Fixed
- Event handlers inside a module that has a mode never fired, and
mode.<name>.activealways evaluated false. - With
cade run, mode timers,emitin mode actions, firmware light shows and device commands had no effect; on the console, routed device audio was silent. set_variableevaluates expressions (var.credits - 1), not just literals.- Labelled
background_musicblocks are read instead of dropped. - A module’s
shows { start / end }now play. play_showandstop_shownow work.cade fmtand editor writes no longer corrupt bare hex literals or drop comments.- A modulation loop is rejected as it is authored, not when the synth compiles.
- Shutdown and session recording no longer race, and a recorded session is now saved when the run is stopped, not only at game over.
- In the TUI, history rows are scoped to the current game and switch events are grouped by ball again.
Performance
- Score conditions are compiled once instead of being rebuilt for every event, and handling an event no longer allocates memory.
[v0.1.0] - 2026-07-31 “Bagatelle”
Initial release of the Cade Runtime, codenamed “Bagatelle” after pinball’s tabletop ancestor — a declarative, high-performance engine for authoring and running pinball machine behavior from HCL, with real-time event processing, scoring, audio synthesis, and lighting.
Added
Core runtime & configuration
- Declarative HCL-based machine configuration (
.cadefiles) interpreted at runtime - Fragment-based reusable configuration with variables, defaults, and validation
- Expression language with module namespaces and compile-time optimization (constant folding, dead-code elimination), so complex conditions stay cheap at runtime
- Reserved-word validation with a
cade migrate reservedwordscommand to scan and fix conflicting identifiers - Hot-reload of machine configuration — edit a
.cadefile mid-session and the table recompiles with player state preserved - Cross-platform builds for Linux (amd64/arm64) and Windows (amd64)
cade versioncommand and--versionflag with embedded build metadata
Event system
- High-performance typed event processing (50–100 Hz) with parallel execution
- Event aggregation primitives: burst, coincidence, and rate windows
- Live event tree with cascade depth, inline timing, and multi-player turn rotation
- Event deduplication with correlation IDs and per-processor rate limiting
- Cascade reconstruction from history exports for offline replay
Scoring & modes
- Declarative scoring engine with compile-time optimization (< 1 ms latency)
- Timer variables (auto-decay, auto-grow, elapsed) and probability variables with weighted, bucketed, and without-replacement sampling
- Three-tier fallback value system with safety-mode activation and panic recovery
tag.<name>.<action>group bindings for scoring targets- Module-based game modes with automatic
module.activeinjection, passive-mode primitives, owner-tracked inhibition, and mid-game player join
Audio system
- Real-time audio pipeline with < 5 ms device latency
- Modular synth engine: named envelopes, biquad low-pass/high-pass filters,
envelope-to-cutoff modulation, and an explicit signal graph — carriers,
mixers, a delay comb, and
z1cross-node feedback with cycle detection - Faithful in-browser synth playback — a patch sounds the same in the browser as on the native runtime
- Sustain-aware synth notes: duration-driven and switch-edge-driven note-off, with an optional amp envelope and a default amplitude gate when none is authored
- Audio routing and selection blocks: owner-weighted routes, device-owned routed audio, deterministic seeded selection, and assembly-provided defaults
- Audio-clip manifest so
play_clipsounds play faithfully in the browser
Lighting system
- Light show compiler that translates shows to FAST EXP scripts for firmware-assisted playback
- Time-parameterized show frames for deterministic, replayable light playback
- Sprite and video sources baked into real frame sequences with an authorable
fit light_layoutplacement with per-LED device names on the layout handshake
Node-editor authoring surface
- Structured edit operations for audio, lighting, and rules graphs, with per-operation error reporting and validate-only dry runs
- Unit-scoped source replacement with per-file snapshot undo
- Rules graph served with a full node-type catalog for graph-based editing
- Graceful parse-lenient / compile-strict handling, so incomplete graphs still boot instead of failing the whole table
- Browser authoring session with automatic asset loading and live engine probes
Platform & networking
- gRPC platform server with bidirectional event streaming for hardware and simulator integrations
- Visual Pinball (VPX) bridge with kicker/coil registration and connection hardening
- Web server for health monitoring and debugging
TUI & console
- Borderless themed TUI with a collapsible game-status bar and standalone Logs and Events panels
- Debug console with cascade visualization, event inspection, and pipe-mode support for scripted, non-interactive use
Tooling & testing
- Scenario testing framework for reproducible game-logic validation
- Replay tooling (
cade replay tree,cade replay analyze) for offline regression