Changelog

changelog releases reference

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_multiplier is a plain multiplier, as is every shipped example’s *_multiplier variable. 2 now 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 score rule now runs before that rule, so a handler’s set_variable is visible to the rule’s own condition/points on the same event. Within one handler, action verbs run in the executor’s own fixed order — end_mode before points, 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 score event rule’s own emit = <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 default lifecycle = "managed" stop their event handlers, scoring rules and device_control whenever an ancestor is suppressed; an independent composed child is unaffected. This reverses the prior ruling that suppression never cascaded.
  • The pragma {} block is removed, including its already-inert disable_checks, events.dedupe_window and errors_as_warnings attributes. pragma { ... } is now a parse error naming the removed block; pragma stays a reserved word for a future, deliberately-wired revival. cade pragma analyze/migrate are removed with it. The separate reserved-keyword severity system behind cade validate --pragma-mode / --ignore-pragma is 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 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.
  • More configuration that parsed and did nothing now either works or is a load error. A stacking list (requires, conflicts_with, replaces, the new promotes) 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-level game {} block) and a warning in a fragment; checks for a module’s own contradictory declarations are now always hard errors. 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 Added). An aggregation rule’s on_trigger/on_tier_change block 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 in the same devices {}. game { max_players, credits_per_player } — cade.conf settings, never read from .cade — are refused, naming cade.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 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 awarded — distinct from a device event’s own event.* payload.
  • A tilt module template (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 default, tilt, or end_ball); new end_game/end_ball action verbs. end_ball forfeits 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" 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.
  • Channel/bus output routing 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 declared positions {} map on every host, at load and after a hot-reload. cade console driving a platform over its gRPC transport does not yet send servo commands across that wire.
  • stacking { promotes = [...] } means requires + replaces as one attribute, for the require-then-replace idiom already used by both corpora.
  • A paired device.<flipper>.hold_ended verb, 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/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 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 .cade edit mid-game reset every player.

Fixed

  • A suppressed module’s event handlers, scoring rules, modifiers, accumulators and device_control inhibitions 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_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, 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 console and 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_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 wakes 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 … EOT script 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 } }. Inside actions {}, each verb’s value is its argument list: emit "x" → emit = x, start_mode "m" → start_mode = module.m, bare end_mode → end_mode = [], play_show "intro", 10 → play_show = ["intro", 10], and set var.x = … → set_variable { name = "x" value = … }. The same applies to on_start, on_end and timer on_expire / on_complete bodies, and an unknown block inside events {} is now an error.
  • Variable scope. The scopes are global, machine, session, player and ball. An omitted scope now means player, not global: such variables reset with each game instead of persisting across games, so add scope = "global" where you relied on the old behavior. scope = "game" is removed (it used to mean global); replace it with global, machine or session, whichever you meant. An unknown scope is an error.
  • Variable types. The type labels are int, float, bool, string, list, map and probability. 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 {} and properties { pulse_ms } on a coil are no longer read. Use settings { pulse_ms, strength, hold_power, recycle_ms, max_pulse_ms }. strength and hold_power are 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 new modes.cade unit now holds module, mode and interaction_context blocks. 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 device event {} and properties {} sub-blocks, and audio { ball_rolling / ball_drop / ball_collision }), a singular action {} in an event_handler (use actions {}), a labelled set_variable "x" {}, unknown attributes in a flow, a scoring rule or handler with no trigger, a score expression whose operand types don’t match, an unsupported selection_method, and a lightshow output.target that names a device rather than a layout. audio_system {} engine tuning moves out of .cade into the audio {} block of cade.conf.
  • cade.conf is validated at startup. An invalid block now stops startup instead of falling back to defaults, a --config path that does not exist is an error, and analytics { backend_type = "grpc" } is rejected. It never sent events anywhere.
  • Release downloads are archives. Releases now ship .tar.gz/.zip archives plus .deb, .rpm and .apk packages, 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 accept tags = [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]) and list.* / map.* functions; all nine documented decay/growth attributes on variables (auto_decay, decay_interval, grow_amount, …).
  • # and // comments inside action and function bodies; mode functions {} bodies are checked at load time instead of on first call.

Events, scoring & modes

  • Interaction contexts: a reusable interaction_context block that remaps player input (input_map, including held_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_window on event_handler for tap-pass detection.
  • Shot and sequence signals now run in production, with combo chaining, all_down, per-step points, an enabled gate, and working fire_signal.
  • Cade now owns drop-bank reset and publishes bank_complete; launch_ball auto-plunges a served ball through a shooter-lane auto-launch coil.
  • High scores: a built-in board, a high_score.qualified event, and initials entry both in the console and on machine hardware, plus a correction command.
  • A module { mode {} } can be composed inside an assembly.

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_show blocks with authored trigger criteria and priority/blend/opacity arbitration, started and stopped by display_show / stop_display_show actions; per-element visible.
  • display_text resolves ${…} 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_method for the four probability mapping methods, and a reset_selection action.

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-config checks a console configuration file.
  • --log-level and --log-format work 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 {} and analytics {} blocks now take effect: enabled = false really disables recording. CADE_* environment variables now override the file, and logging {} 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>.active always evaluated false.
  • With cade run, mode timers, emit in mode actions, firmware light shows and device commands had no effect; on the console, routed device audio was silent.
  • set_variable evaluates expressions (var.credits - 1), not just literals.
  • Labelled background_music blocks are read instead of dropped.
  • A module’s shows { start / end } now play.
  • play_show and stop_show now work.
  • cade fmt and 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 (.cade files) 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 reservedwords command to scan and fix conflicting identifiers
  • Hot-reload of machine configuration — edit a .cade file mid-session and the table recompiles with player state preserved
  • Cross-platform builds for Linux (amd64/arm64) and Windows (amd64)
  • cade version command and --version flag 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.active injection, 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 z1 cross-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_clip sounds 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_layout placement 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