Changelog
All notable changes to this project will be documented in this file.
The format is based on Keep a Changelog, and this project adheres to Semantic Versioning.
Unreleased
Added
Owner-Addressed Audio Routing and Selection
- Two new top-level blocks replace every per-owner
audio { }sub-block. Aroute { }lists the candidate sounds an owner can make — one or moresource "<kind>" "<name>" { }sub-blocks, where kind is"audio_clip"or"synth"— and aselection { }decides which candidate plays when the owner fires - Both blocks are addressed with
owners =, written bare. An owner is adevice.<name>,score.<name>,modifier.<name>,accumulator.<name>, orevent_handler.<name>, so a jackpot fanfare and a bumper pop are now authored exactly the same way.ownersalso takes a bank —owners = [device.pop_1, device.pop_2, device.pop_3]— and the whole bank shares one candidate pool and one decision - Source attributes:
weight(relative routing weight) on any source;pitch,velocity,duration,sustain, and a nestedposition { x y z }on"synth"sources only.durationgives a timed note-off,sustain = trueholds the voice until the owning switch opens, and the two are mutually exclusive. An optionalmix { channel = "<name>" }sub-block sends the route’s output to a named mixer channel - Selection attributes:
selection_method(random,weighted,rotate,shuffle,balanced,probability,coordinated; defaultrandom),no_repeat,shuffle_memory,clip_probability, and a bareconditionexpression that keeps the owner silent while false routeandselectioncan be authored inside anassemblydefinition, usingowners = device.self.<name>to address a block the template itself declares — so each expanded instance gets its own correctly-prefixed route. An instance that wants different sound overrides just the block it cares about
route {
owners = device.drop_target_1
source "audio_clip" "target_1" { weight = 3 }
source "synth" "zap" {
weight = 1
pitch = 880
velocity = 0.4 + score / 200000
}
mix { channel = "fx" }
}
selection {
owners = device.drop_target_1
selection_method = "random"
no_repeat = 1
}See Routing & Selection for the full guide.
Explicit Synth Signal Graph
- A
synth { }patch now wires its signal path explicitly instead of having it inferred from node names. Exactly oneoutput { }block names what the voice sounds like:in = node.<name>.outis the node that produces the sound, and the optionalgain = node.<name>.outnames the envelope acting as the amplitude VCA - Port-wires reference a node’s current sample as
node.<name>.outand its previous sample asnode.<name>.z1, which is how feedback paths are authored - New node kinds inside a patch: a
mixernode sums several sources into one control or signal port (multi-carrier FM, multi-envelope filter cutoff), with per-inputgains, and adelaynode provides a feedback comb. Cycles through non-z1ports are rejected at load with an actionable message - The
ampenvelope is now optional. A minimal patch — one oscillator wired tooutput.in— makes sound; cade supplies a default amplitude gate (near-instant attack, full sustain, short release) so the voice still releases cleanly instead of droning. Author anampenvelope when you want to shape amplitude yourself - A patch that is structurally incomplete (an
outputwith noinwire, an unwired filter, an unconsumed mixer) now parses and is stored, and is rejected only when it has to make sound. A single bad patch no longer fails audio startup for the whole table — it is skipped with a diagnostic and its voices stay silent, while every other patch plays
Sustained and Timed Synth Notes
- A synth source with
duration = "0.5s"holds the note for that long and then releases, so the envelope’s release tail plays instead of being cut short by the default gate - A synth source with
sustain = trueholds the voice for as long as the owning switch stays closed and releases it on the open edge — a held flipper button or a captive-ball switch can now sound for exactly as long as it is held - Both are dispatched identically from a device, a scoring owner, or an event handler. Where a host cannot honor a capability, it now says so with a warning rather than failing silently
Generative Light Shows (Node-Graph Lighting)
- New top-level
light_layout "<name>" { }block maps your physical light devices onto a normalized 0–1 playfield space, so shows can address lights by position instead of by hardware id. Place lights individually withplace "<device>" { at = [x, y] }, lay out a regular grid withmatrix { rows cols origin size }, and group placements into namedregion "<name>" { }sub-blocks - New top-level
lightshow "<name>" { }block declares an animated show as a small graph ofnode "<type>" "<name>" { }generators wired to one or moreoutput { }layers. Each output names what it draws (in = node.<name>.out), where it draws (target = layout.<name>), and how it combines with the layers beneath it (blend = "over") - Show-level attributes:
mode(e.g."ambient"),duration,loop, andseed - Multiple shows composite through a priority stack with blend modes, so a high-priority mode show layers cleanly over an always-on ambient show. Direct
set_light/flash_lightactions route through the same compositor as an override band, so imperative light commands always win over whatever generative shows are running underneath - Shows evaluate per frame at the 30 Hz light tick and are fully deterministic for a given
seed— the same show produces the same frames every run, with bit-for-bit parity between native and in-browser (wasm) playback, so a recorded light sequence replays identically everywhere - An output layer’s
incan take several wires in list form —in = [node.a.out, node.b.out]— so multiple generators feed one layer without an intermediate node - An
editor { }sub-block carries canvas metadata (node positions) that round-trips through the file untouched and has no effect at runtime
light_layout "strip" {
place "self.l0" { at = [0.0, 0.0] }
place "self.l1" { at = [0.5, 0.0] }
place "self.l2" { at = [1.0, 0.0] }
}
lightshow "ambient_glow" {
mode = "ambient"
node "solid" "base" {
color = "#ff0000"
}
output {
in = node.base.out
target = layout.strip
blend = "over"
}
}Authorable Audio Mix Buses
- New
channel "<name>" { }andbus "<name>" { }blocks make the mixer’s routing authorable in HCL — previously the mixer’s channels and their per-channel effects were fixed. A channel/bus is a named mixer strip with an optionalvolume(0.0–1.0), apan/ balance (-1.0 full-left … 1.0 full-right), and an ordered chain ofeffect "<type>" { }insert effects applied in source order channelandbusare structurally identical — use whichever keyword reads better for the role. Omittingvolume/panleaves the mixer default (full volume, centered) rather than forcing a zero- Insert effect types:
reverb(feedback,wet_level),compressor(threshold,ratio,makeup_gain), andequalizer(low_gain,mid_gain,high_gain,low_freq,high_freq). Effect parameters are numeric literals
bus "music" {
volume = 0.8
pan = 0.0
effect "reverb" {
feedback = 0.4
wet_level = 0.25
}
}Weighted Clip Selection with Reproducible Seeding
- A device’s
audio { }block accepts adistributionattribute (aliasclip_probability) that names a weighted probability distribution controlling which clip is drawn from theclip_pool— a bumper can favor its “meaty” hits over its light taps, for example. It reuses the same weighted sampler the scoring engine already compiles - Clip selection is now fully reproducible from a seed: with the audio sequencing seed set, a table produces identical clip sequences across runs, and native and in-browser (wasm) playback pick the same clips. An explicitly authored
random_seed = 0is now honored as a real seed rather than being mistaken for “unseeded,” so0behaves like any other fixed seed
Bare Expressions in .cade Config
- Expressions are now written bare (unquoted) wherever an attribute expects one —
condition = var.tilted,points = var.bumper_value * 10,update { score = var.score + 100 }. What you write is what the engine evaluates, with no field-dependent rewriting - String literals inside an expression accept either double or single quotes interchangeably —
condition = var.mode == "wizard"andcondition = var.mode == 'wizard'parse to the same expression. Single quotes are handy for avoiding escapes when the expression itself sits inside a double-quoted HCL value (e.g. a list:clip_pool = "['a', 'b']") - The fully quoted form (
condition = "var.x > 5") still parses and is treated as equivalent, so existing configs keep loading — but bare is now canonical across the docs - Breaking: the
${...}interpolation form is now rejected in expression fields. Rewrite"${var.score} + 100"asvar.score + 100 - The bare form now also covers more attributes beyond scoring
condition/points/update: deviceaudio(condition,clip_pool),signalenabled,set_variablevalue,device_controlcondition, fragment definition fields (condition,points,updates), and a route synth source’spitch/velocity— quotes are optional on every expression-bearing attribute, not just the original three - Expression fields inside an
assemblydefinition block now accept the bare form too. Previously a bare runtime expression such ascondition = var.self.targets_down >= param.target_countwas silently dropped inside a definition and only the quoted form took effect
See Writing Expressions for the full rules.
TUI Logs Tab Enhancements
- Text filter (
/) in the Logs tab — hides non-matching lines case-insensitively across message, source, and structured fields.Entercommits,Escclears and exits - Search (
Ctrl+F) highlights matches in place without hiding lines;n/Nnavigate to the next / previous match and wrap at the ends. All occurrences on a line are now highlighted (previously only the first) - Pause (
p) freezes the visible feed while continuing to buffer new entries in the background (FIFO-capped at 10,000). The status bar showsPAUSED (N buffered)while paused; pressingpagain drains the backlog in order. Pause is independent of autoscroll - Source column populated from
sloggroups —logger.WithGroup("audio").WithGroup("mixer")now renders the source asaudio.mixerin the TUI and in copied output. Explicitslog.With("source", ...)attrs still take precedence over the group path - Cleaner clipboard output — the
Alt+Ccopy formatter no longer emits empty[]brackets when a log record has no source; the bracket segment is included only when a source is present, matching the on-screen rendering
See Logs Panel for the full keyboard reference.
Event Tree Viewer — Scrollback and Overflow Expansion
- Archived-game scrollback in the Events panel — finished games are retained as compact summaries above the live game so you can scroll back and review them without leaving the TUI. Up to three archived games are kept; starting a fourth game evicts the oldest
- Archived games are collapsed by default and render as siblings above the live
Gameroot; expand withlorSpaceto inspect the per-player leaderboard, per-ball summaries, completed-mode history, and lifecycle entries - Raw per-event leaves are dropped on archive — each archived ball shows its total event count (
Events: N) but no event children. This keeps memory bounded across long sessions (~95% reduction per archived game) - Overflow placeholders (
... N earlier ...) are now reachable with right-arrow (l) in addition toEnter, and the hint bar showsenter: show morewhen the cursor is on an overflow row - Overflow expansion applies to history and lifecycle sections on both live and archived games
- The live game is never archived while in progress — it only enters the ring when the next game starts or when the current game ends
See Events Panel for the full navigation reference.
Driven-Device Abstraction Layer
- Shared identity surface across every
device { }block —id,tags,description,location,part_number,notes, and a newenabledfield that declaratively disables a device without removing its declaration - New switch subtypes
dropandstandup, distinct from the existing generictargetsubtype:switch "drop"— falling targets with areset { coil, pulse_ms, debounce_ms }sub-block and an implicitup → hit → resetting → upstate machineswitch "standup"— upright hit-only targets (no reset semantics), commonly banked under a shared tag
- New
positions {}block onmotor "servo"devices — assign symbolic names (home,up,down, etc.) to abstract scalar positions; driver-agnostic, each platform driver maps the scalar to its own physical representation - New event aliases on mechanical devices:
rested(drop target has settled upright after reset + debounce),state_changed(any declared-state transition),moved/moving/reached(servo and stepper motion lifecycle) - New
behavior {}sub-block declaring hardware-level reflexes alongside the owning device — config-compile-time lowering to driver-local autofire rules, so reactions like pop-bumper auto-fire never enter the scoring hot path. Supportswhen,action,guard, andcooldown_ms - New action verbs usable in
behavior {}blocks and event handlers:pulse_coil,move_servo,set_state,trigger_behavior. All verbs are driver-agnostic — the platform driver chooses the physical implementation - Performance budgets preserved (50–100 Hz event tick, 30 Hz light tick, <200 ns binding resolve, <1 ms scoring) because
behavior {}lowering happens at config compile time, not per tick - Runtime wire-up:
move_servo/set_state/trigger_behaviordispatch through the platform driver, drop-target state machines emithit/state_changed/restedas reset pulses complete and debounce elapses, and symbolic servo positions are resolved from the device’spositions {}block at dispatch time - Software-fallback path for
behavior {}blocks the driver cannot lower to hardware — ineligible behaviors run through the standard event-handler pipeline instead of becoming a load-time error, so abehavior {}block is never silently dropped. Lowered behaviors remain on the hardware fast path
See Driven Devices for the architecture overview and Device API reference for the full schema.
Synthesis Subsystem
- New
synthblock for declaring oscillator-based sound effects in.cadeconfigs — generates PCM in real time without audio files - Four wave shapes: sine, saw, square, and triangle
- ADSR amplitude envelope (attack, decay, sustain, release) with per-sample state machine and zero-allocation steady-state processing
- FM modulation: route one oscillator’s output into another’s frequency for metallic, bell, and complex timbres (
modulate { source = "mod" target = "carrier.freq" }) - 64-voice polyphony per patch (configurable 1–128) with pre-allocated pool, free-list allocation, and LRU voice stealing when saturated
- Event dispatch: a patch is fired by a top-level
route { }whosesource "synth" "<patch>" { }names it — voices are automatically registered with the mixer and returned to the pool when the envelope completes (see Routing & Selection) - Dynamic pitch and velocity via
from:event.<field>bindings that resolve values from event data payloads at trigger time - HCL parser and compiler with validation: wave names, oscillator references, modulation routing, cycle detection, duration parsing, and polyphony range checks — all errors include source locations
- Synth voices implement the same
AudioSourceinterface as clip-based audio, feeding directly into the existing mixer pipeline with no backend changes - Named envelopes:
envelopeblocks now accept an optional label (e.g.,envelope "pitch" { ... }) — multiple envelopes per synth, each with an independent ADSR shape. Every synth must have exactly one"amp"envelope; additional envelopes modulate pitch or filter cutoff viamodulateblocks. Newdepthattribute specifies modulation range in Hz. Unlabeledenvelope { }defaults to"amp"for backwards compatibility with v1 configs - Biquad filter: new optional
filter { type cutoff resonance }block — lowpass and highpass biquad filters applied post-oscillator, pre-amplitude-envelope in the voice pipeline. Resonance (0.0–1.0) maps to Q factor internally - Filter cutoff modulation:
modulate { source = "<envelope>" target = "filter.cutoff" }drives the filter cutoff frequency from a named envelope. Coefficients are recalculated once per audio buffer for efficient per-block sweeps. Modulated cutoff is clamped to [20 Hz, Nyquist] - Envelope → pitch modulation:
modulate { source = "<envelope>" target = "<osc>.freq" }drives an oscillator’s frequency from a named envelope, producing pitch sweeps controlled by ADSR shape and depth - Editing a
synth { }patch now hot-swaps it live — previously synth definitions were applied only once at startup, so editing a patch mid-session had no effect until restart. In-flight voices finish on the old patch; new triggers immediately use the new one
Runtime Expression Bindings
- A route synth source’s parameter bindings (
pitch,velocity) accept literal numbers,from:event.<field>lookups, and full expressions evaluated against live game state - Expression namespace available in bindings:
event.<field>(event payload),score(current player score),var.<name>(variable registry),signal.<name>(1.0if signal active,0.0otherwise) - Example — score-reactive pitch:
pitch = "440 + (score / 1000000) * 220" - Example — multiball-reactive velocity:
velocity = "signal.multiball_active ? 1.0 : 0.6" - Literal numbers and
from:event.*lookups stay on a fast path — no expression evaluation is performed for them - Expressions are compiled once at config load; missing variables, signals, and event fields resolve to the zero value at runtime rather than failing the trigger
See Runtime Bindings for the full namespace reference and Synthesis for synth examples.
Exhaustive Automated Testing of Cade Config Language
- Scenario corpus parser now accepts all
.cade.testfiles without skipping — theknownParseFailuresskip list has been eliminated entirely initial_statevariables support nested objects and mixed-value-type maps (e.g.,variables = { player = { combo_count = 0, last_shot = "none" } })- Event
dataattributes support arbitrary key-value payloads (e.g.,data = { target = "left_orbit", multiplier = 2 }) - Mode
stateattributes ininitial_stateaccept nested object values for pre-setting mode-specific state - Chain transition
conditionblocks now parse correctly, enabling conditional transition tests withsuccess,min_events, andmax_eventsfields - Self-enforcing corpus test: adding a new
.cade.testfile todocs/examples/that fails to parse will break CI immediately — there is no mechanism to skip it
Scenario Testing Infrastructure
- New
.cade.testfile format for declarative scenario tests — declare initial game state, fire events, and assert scoring outcomes, variable state, and event processing results against the real engine cade scenario runnow accepts directories with--recursiveto batch-run all.cade.testfiles; supports--format jsonfor CI integration- Four-layer CI test coverage for the config language:
- Corpus validation — every
.cadefile indocs/examples/is automatically parsed and validated on every PR, under both default andstrictpragma modes - Scenario execution — every
.cade.testfile indocs/examples/runs through the scoring engine with assertion checking - Canary ground truth — a set of trivially-verifiable scenarios (single hit → exact score) plus their negative counterparts, validating that the test infrastructure itself is reliable
- Fuzz testing — random
.cadegeneration viatask fuzzstresses the parse-validate-execute pipeline for panics and unbounded memory; runs nightly in CI
- Corpus validation — every
- Advanced feature matrix under
docs/examples/advanced/with working scenario pairs for: nested fragments, cross-fragment variables, assembly expansion, mode stacks, cascade events, pragma modes, and expression edge cases - New task runner targets:
task fuzz,task test:fuzz:config,task test:fuzz:engine,task test:fuzz:resilience,task test:fuzz:platform,task test:determinism,task test:mutation
See Scenario Testing for the full file format reference and usage guide.
Session Variable Scope
- New
sessionscope for variables that reset at game boundaries but are shared across all players within a game — fills the gap betweenglobal(persists forever) andplayer(per-player turn) - Session-scoped variables are not player-keyed: all players read and write the same value during a game
- Default lifecycle flags:
ResetOnGame = true,Persist = false
variable "int" "table_bonus_level" {
initial = 0
scope = "session"
max = 5
}Game-End Variable Reset
- Variables now reset at game end in addition to game start —
player,ball, andsessionscoped variables return to their initial values during the game-over phase - Lights and other outputs driven by variables clear immediately when the game ends, rather than persisting until the next game start
Variable Change Events
- Cade now emits
variable.<name>.changedevents whenever a variable’s value changes, regardless of the mutation source - Event payload includes
name,old,new,scope, andreasonfields - Reason values:
set(explicit set),toggle(toggle action),reset(lifecycle reset),player_switch(active player changed) - Event handlers can listen to change events to keep physical outputs synchronized with variable state across lifecycle transitions
event_handler "sync_on" {
event = variable.l6_lit.changed
condition = var.l6_lit == true
actions {
set_light "light" {
device = l6
state = "on"
}
}
}Player-Switch Variable Re-Sync
- When the active player changes,
variable.<name>.changedevents fire for every player-scoped variable whose value differs between the outgoing and incoming player - Enables lights and outputs to automatically reflect the new player’s state without manual wiring
Nightly Fuzz CI Pipeline
- Fuzz testing suites now run on a nightly schedule in CI with bounded fuzz time, separate from per-PR gates
- New
task fuzztarget aggregates all four fuzz suites (platform, engine, resilience, config) for local runs
Async Log Sink With Drop-Oldest Backpressure
- Log records are now forwarded to the console log file and the TUI Logs panel through bounded background queues, so a slow sink (a paused Logs tab, a stalled terminal, a flushing file) no longer blocks the code path that emitted the log
- If either queue fills, the oldest record is dropped rather than stalling the caller — the ring-buffer view in the TUI always gets the record regardless, so nothing is lost from the live Logs panel
- A dropped-log counter is exposed for observability: when non-zero, it indicates the external sink (file or remote) could not keep up with the emission rate. The TUI feed itself is unaffected
- Matters most during event floods (rapid bumper hits, multiball chaos) where the audio mixer and scoring loops previously serialized behind log I/O
Value-Select switch Expression
- New
switch { case <cond> = <value> ... default = <value> }expression form — a guard-based, first-match-wins value selector usable anywhere an expression is expected - Cases are evaluated top-to-bottom; the first
casewhose condition is true supplies the result.defaultsupplies the value when no case matches - Every case condition must be boolean; branch values are unified to a common type (numbers, strings, etc.), mirroring the existing ternary operator
points = switch {
case var.combo >= 10 = 5000
case var.combo >= 5 = 2000
default = 500
}Hexadecimal Integer Literals
.cadeand.cade.testfiles now accept hex integer literals (0x30,0XFF) anywhere a number is expected — natural notation for hardware device addresses (id = 0x40)- Hex is rewritten to decimal before HCL parsing; hex text inside string literals, comments, and heredocs is left untouched
- Covers both the main game-config parser and the scenario (
.cade.test) parser, including chain files
Structured condition Blocks (all_of / any_of) Wired Into Scoring
score "event"rules andscore "modifier"blocks now accept a structuredcondition { all_of = [...] }/condition { any_of = [...] }sub-block as an alternative to the scalarcondition = <expr>form — previously this block was silently ignored by the game parser (no error, no effect) even though the expression engine already understood it- List elements may be bare expressions or quoted strings, matching the rest of the bare-expression convention
score "event" "guarded_award" {
when = device.target.hit
points = 1000
condition {
all_of = [
var.multiball_active,
var.balls_in_play >= 3
]
}
}User-Defined Functions in Modules
- Functions declared in a mode’s
functions { }block are now callable from that mode’s ownon_start/on_end/ event action bodies — previously calling a declared function always failed with “undefined function” - Functions support
return <expr>to produce a value usable in the caller’s expression; a function with noreturnevaluates to0 - Parameters are function-local: they no longer leak into surrounding variable state, and a parameter that shadows an existing variable is restored after the call
- Recursive calls are bounded (max depth 64) and fail with a clear error instead of overflowing the stack
- Functions are scoped to the mode that declares them — not visible from other modes’ actions
module "bonus_mode" {
mode {
functions {
award(points) {
set var.last_award = points
return points * 2
}
}
on_start {
set var.bonus = award(250)
}
}
}Variable constraints{} / operations{} Blocks
variabledeclarations with a nestedconstraints { }oroperations { }block now parse correctly in real.cadefiles — previously any nested block inside avariable { }body caused the entire variable declaration to fail to parse- List-typed variables now enforce
min_len(viaconstraints { min_len = N }),unique_elements, andallow_emptyfromoperations { }at assignment time, in addition to the existingmax_lencheck. Empty lists are permitted by default unlessallow_empty = falseis set
variable "list" "tags" {
initial = []
constraints {
min_len = 1
}
operations {
unique_elements = true
allow_empty = false
}
}Fragment Definitions Wired Into Scoring
- Top-level
fragment "static" "..."/fragment "dynamic" "..."blocks and ascore "event"rule’sfragments = [...]reference attribute are now recognized by the game config parser and resolved at load time — previously the parser had no schema entry forfragmentblocks or thefragmentsattribute, so both were silently dropped and referencing rules scored using unresolved (zero) values - Dynamic fragments compute fields (e.g.
points) from bound parameters viaexpr(...)or HCL-escaped${...}(as$${...}); a bare${...}collides with HCL’s own interpolation and is now rejected with a warning pointing at the correct form fragment.<name>.<key>dotted-path references inside a rule’spoints,condition, orupdatesexpressions now resolve to the fragment’s value at compile time — previously these paths evaluated to0
fragment "static" "bonus_config" {
base_points = 500
multiplier = 2
}
score "event" "bonus_ramp" {
when = device.ramp.hit
points = fragment.bonus_config.base_points
updates = {
score = score + fragment.bonus_config.multiplier
}
}See Fragment reference for the full attribute schema.
Classic Replay Awards (Score-Threshold Credits / Extra Balls)
- New top-level
replay { }block: declare score thresholds that award a credit or extra ball the moment a player’s score crosses them during a live game max_per_gamecaps how many replay awards a single player can earn per game (omit or0for unlimited)- Each
threshold { score = ... reward = "credit" | "extra_ball" }fires once per game per player when crossed; crossing emitssystem.replay.award - Reward type is validated at load time — any value other than
"credit"or"extra_ball"is a config error
replay {
max_per_game = 3
threshold {
score = 5000000
reward = "credit"
}
threshold {
score = 20000000
reward = "extra_ball"
}
}Event Flow Orchestration (flow Blocks)
- New top-level
flow "<type>" "<name>" { }block correlates multiple events into a single higher-level outcome, wired to the live runtime event bus — hits on the listedeventsactually drive the flow during a real game, andon_complete/on_timeoutre-emit an event you can react to from anevent_handler - Working today:
sequence(events must occur in the declared order withinwindow; a wrong-order event fails the flow),parallel(same event set, any order), andconditional(completes on the first listed event — see caveat below) conditionalcaveat: there is currently no HCL syntax to attach an actual condition to aconditionalflow — it behaves identically to a one-eventsequence, completing unconditionally on its first listed event- Not yet functional:
timedandstate_machineare accepted by the parser but do not advance or complete from events at all — only a configuredwindowtimeout (if present) will fireon_timeoutfor them. Do not rely on these two types;accumulatorparses and tracks progress but never auto-fireson_complete pointsinsideon_complete/on_timeoutis parsed but has no runtime effect yet — award points from a separateevent_handlerreacting to the emitted completion event instead
flow "sequence" "skill_shot" {
events = ["device.ball_plunged.triggered", "device.upper_loop.cleared", "device.skill_target.hit"]
window = "5s"
on_complete {
emit = flow.skill_shot.made
}
on_timeout {
emit = flow.skill_shot.missed
}
}
event_handler "skill_shot_reward" {
when = flow.skill_shot.made
actions {
points = 25000
}
}Module-Scoped Scoring Now Reaches the Engine
- Scoring content declared inside a
module { }block —variable,score "event",score "modifier", and accumulators — now actually scores during gameplay. Previously this content was parsed but silently dropped before reaching the scoring engine - A mode module’s scoring content is automatically gated on
module.<name>.active, so it only scores while that mode is running — no need to write a manualcondition module.self.activenow also resolves correctly for a standalone module declared directly (not just modules produced bycompose)
module "multiball" {
mode {
priority = 500
}
score "event" "multiball_bumper_bonus" {
when = "scoring.bumper.bonus"
points = var.bumper_value
}
}Implicit Playfield Multiplier (playfield_multiplier, Base-100)
- The engine now recognizes one reserved variable,
playfield_multiplier, and scales every rule-awarded points value by it before the award is applied — scoring rules need no explicit multiplier reference - Uses base-100 fixed point so fractional multipliers stay integers:
100= 1× (no change),300= 3×,250= 2.5×,50= 0.5×. The award ispoints × playfield_multiplier / 100(integer division) - A table that never declares
playfield_multiplierscores at 1× — fully opt-in and backward compatible - A non-positive stored value is treated as the 1× base, so an uninitialized or zeroed variable can’t silently zero out all scoring
variable "int" "playfield_multiplier" {
initial = 100
min = 100
max = 1000
scope = "ball"
}Infrastructure Module Content Now Activates at Game Start
- Variables, scoring rules/modifiers/accumulators, and event handlers declared inside an infrastructure module (a
module { }block with nomode { }block — always-on, e.g. a trough or ball-save module) now actually activate at game start. Previously this content parsed cleanly but was silently dropped, so infrastructure-module scoring and handlers never ran - This content is always-on and is not gated behind a
module.<name>.activecondition (infrastructure modules have no mode lifecycle to gate against) - An infrastructure module’s unconditional
device_control { }block (noconditionattribute) now applies at registration, before the first game starts, instead of never applying at all. Condition-bearingdevice_control { }blocks continue to react tovariable.changedevents as before
module.<name>.<var> Expression Namespace
- New expression namespace:
module.<name>.<var>resolves a module-scoped variable inside conditions, scoring rules, and event handlers, e.g.condition = module.bumper_frenzy.jackpot > 0 - Currently resolves to the variable’s declared
initialvalue, seeded once when the module registers at game load — it does not yet track live updates made during gameplay (e.g. viaset_variable). Treat it as a per-module constant, not a live binding, until live tracking ships - Referencing an unknown module or variable name is a clear evaluation error rather than a silent zero
New Action Verbs
- New
decrement = "<variable>"scalar action, the symmetric peer ofincrement— subtracts 1 in one step instead ofset_variable { name = "x" value = var.x - 1 } - New
music_control { action = "play"|"switch"|"stop" clip fade }action for one-shot imperative music transitions (a victory sting, an immediate cut on tilt);play/switchrequireclip,fadeis a duration hint. Distinct from declarativebackground_music(below), which drives ongoing state/mode-based music - New
display_text "<display>" { text duration priority }action pushes text to a named display for a duration — fully device-agnostic; text may be a bare expression. Commands now reach a real consumer: incade consolethey flow into the TUI event stream, in headlesscade runthey’re logged. No physical DMD/segment-display renderer exists yet — this makes the command observable, not yet rendered on hardware - New
launch_ball { count = N }action requests N balls served into play without naming a device — the ball/trough subsystem picks the trough/launcher. Distinct fromkick_ball(fires an existing ball already held by a named kicker) andeject_ball(creates + kicks a new ball from a named device)
event_handler "track_ball_drain" {
when = device.trough.active
actions {
decrement = "balls_in_play"
emit = ball_drained
}
}
event_handler "wizard_victory" {
when = signal.wizard_complete.complete
actions {
music_control { action = "switch" clip = "wizard_victory_theme" fade = "1s" }
display_text "main_display" { text = "WIZARD MODE" duration = "3s" priority = 1000 }
}
}
event_handler "multiball_lock" {
when = device.lock.activated
actions {
launch_ball { count = 2 }
}
}Background Music: Reactive Playback, Envelopes, and Conditions
background_music { clip auto_start }now actually plays — previously the block parsed cleanly but the music player was never instantiated at runtime, so no music played in a real session- Per-mode
audio { music = "<clip>" }blocks now reactively switch tracks: an active mode’s music overrides the basebackground_musictrack and the base resumes automatically when the mode ends - New
envelope = "<name>"attribute onbackground_music, referencing a config-scopeenvelope "<name>" { attack decay sustain release }block (same ADSR grammar as synth envelopes) — attack shapes the fade-in, release the fade-out condition = <expr>onbackground_musicnow gates playback against live game state — see the condition-evaluator fix below
envelope "mode_swell" {
attack = "2s"
decay = "0s"
sustain = 1.0
release = "1s"
}
audio {
background_music {
clip = "wizard_battle_theme"
auto_start = true
envelope = "mode_swell"
condition = mode.wizard.active
}
}Expression-Driven Device Audio Clip Pools
clip_pool(aliasclips) on a device’saudio { }block now accepts a cade expression in addition to a static list — the clip list is re-evaluated against live variable/score state at playback, not fixed at load time- Falls back to the static list when no runtime context is bound yet
device "switch" "bumper" "context_bumper" {
id = 0x40
audio {
clip_pool = <<-EXPR
var.wizard_mode_active ? ["wizard_bumper_1", "wizard_bumper_2"] :
var.multiball_active ? ["mb_bumper_1", "mb_bumper_2"] :
["bumper_normal_1", "bumper_normal_2"]
EXPR
}
}.cade-replay File Format and CLI
- New portable single-file binary
.cade-replayformat for archiving/sharing recorded sessions independent of the SQLite history database - New
cade replay export <game-id> [output.cade-replay] [--db <path>]— exports a recorded session to a.cade-replayfile - New
cade replay play <file.cade-replay> [--speed N]— replays events in recorded order (--speed 0= as fast as possible, default1.0= real time) - New
cade replay convert <file.cade-replay> [-o output.hcl] [--with-expectations]— converts a.cade-replayfile to an HCL scenario test file (lossy relative to the JSON export used bycade replay to-scenario)
History Storage: Pure-Go SQLite
- The cascade history store now uses a pure-Go SQLite engine instead of an embedded DuckDB (cgo + Arrow C++) — no native/cgo dependency
- Default history database filename changed from
history.duckdbtohistory.db - Measured on a stripped
--no-audiobuild: binary size ~68.7 MB → ~36.4 MB (-47%), idle RSS ~63 MB → ~37 MB (-42%), thread count 26 → 11
Console Config Persistence
- New
config save [path]console command persists your live REPL settings (prompt, history, display, colors, completion, startup, session, debug) to an HCL file. With no path it writes to~/.cade_console_config.hcl; an explicit path must end in.hcl - The saved file round-trips: what
config savewrites loads back through the console’s normal config-load path unchanged
cade:debug> config save
cade:debug> config save my-console.hcl
Event Tree Column Alignment
- Event tree rows now align into fixed
time score namecolumns — e.g.+6.961s +50 device.spinner.hit— instead of a single free-form line, so the event-name column stays put as scores and timestamps change width - Time and score columns pad to the widest value currently visible in the ball; scoreless rows still reserve a blank score column so names never drift
- Collapsed runs adopt the same
time score name ×Nlayout (previouslyname ×N +pts (last +X))
See Events Panel.
Active Modes Fade-Out and Column Alignment
- A mode that just ended now lingers in the Active Modes section for 8 seconds (previously 2.5s) before rolling off — greyed out, with its elapsed time and score frozen at the moment it ended, instead of disappearing immediately into History
- Fading (greyed) rows render above the live active-mode rows, so the live stack’s position stays settled instead of shifting every time a ghost rolls off
- Active-mode rows now read in time, score, name, priority order — matching the column order Event rows already use — with a
—placeholder when a mode has no score yet instead of dropping the field - Time and score columns are now padded and aligned across all active-mode rows, so mode names no longer drift as those values change width
- The Lifecycle section adopts the same time → score → name column order and alignment; a mode’s lifecycle entry now collapses into a single “ended” line (with duration and score) instead of separate started/ended entries
See Events Panel for the full reference.
Cascade Viewer Compact Mode and Row Truncation
- New compact mode (
c) in the cascade viewer drops the timing badge, running-total tail, and per-node score-delta suffix for a leaner view; off by default - Long tree rows are now clipped to the panel width with a trailing
…instead of running off the edge - Opening the cascade overlay (
F7/Ctrl+E/cfrom the Events panel) now freezes on the cascade you’re inspecting — it no longer gets overwritten as newer cascades arrive while the overlay is open F7/Ctrl+Efrom the Events panel now opens the cascade for the currently selected event, rather than always jumping to the latest live one- The
[Alt+E] Toggle emojisfooter hint now only appears once emojis are turned on (they’re off by default), reducing footer clutter
VPX Flipper Autofire Inhibit
- New
set_autofire { device = "..." enabled = true|false }action for event handlers, enabling or disabling a driver autofire rule at runtime by device name — e.g. to inhibit flippers during tilt or between balls - VPX flipper button input is now mapped by VPX action rather than raw device key, matching real hardware behavior more closely
event_handler "tilt_flippers_off" {
when = variable.tilted.changed
actions {
set_autofire { device = "RightFlipper001" enabled = false }
}
}Platform Debug Log File
- New
debug_logattribute onplatform "grpc"blocks writes gRPC platform-client stream diagnostics (connects, reconnects, manifests, errors) to a dedicated file, independent of the TUI’s own log pipeline — useful when the TUI Logs feed itself is under suspicion - Equivalent
--grpc-platform-debug-log <path>flag oncade consolesets the same option without editing the config file
platform "grpc" "vpx" {
platform_address = "127.0.0.1:55000"
debug_log = "/tmp/cade-platform.log"
}File-Organization Lint Warnings in cade validate
- New
--include-file-orgflag oncade validateopts in to advisory file-organization warnings: a device declared in what looks like a module-oriented file (and vice versa), mixed device/module concerns in one file, a top-level variable declared alongside module blocks, and files over 500 lines - Warnings-only — they never flip
cade validate’s pass/fail result — and surface intext,json, andyamloutput alike - Individual rules can be silenced with a
lint.<rule> = "ignore"pragma, or all of them at once withlint.file_organization = "ignore"
cade validate --include-file-org
Validate-Time Checking of Synth Parameter Bindings
cade validatenow compiles every synthpitch/velocitybinding expression at validate time — including the bindings on arouteblock’ssource "synth"— and reports a malformed one as a validation error, naming the synth and field- Previously a bad binding passed validation silently and only surfaced at runtime as a logged warning with the trigger skipped
Changed
Breaking: synth_trigger { } Is Retired
- The
synth_trigger { }action verb is no longer authorable. A.cadefile that contains one — nested inactions { }or written bare under anevent_handler— no longer loads, and the error names your synth and points at the replacement form - A synth voice is now authored only as a top-level
route { }with a synth source. Every sound in a table therefore travels one path, so a synth voice honorsselection { }decisions,conditiongating, weights, and mixer routing exactly like a clip does
# Before — no longer loads
event_handler "bumper_hit_sound" {
when = device.pop_bumper_1.hit
synth_trigger {
synth = "bumper_hit"
pitch = 880
velocity = 1.0
}
}
# After
event_handler "bumper_hit_sound" {
when = device.pop_bumper_1.hit
}
route {
owners = [event_handler.bumper_hit_sound]
source "synth" "bumper_hit" {
weight = 1
pitch = 880
velocity = 1.0
}
}Migration is mechanical: keep the handler (it may now carry no actions at all — that is valid, and the route still fires), and move synth, pitch, velocity, plus any duration / sustain / position, onto a source "synth" in a route owned by the handler. Expression-valued pitch / velocity carry over verbatim.
Breaking: Nested audio { } Blocks No Longer Parse
- The per-owner
audio { }sub-block underdevice,score,modifier,accumulator, andevent_handlerhas been removed. Authoring one is now a load error naming the owner and the replacement form - Move the clip list into a top-level
route { }and the decision attributes into a top-levelselection { }, both addressed withowners = <owner>. Thechannelslist under the audio system block is likewise replaced by top-levelchannel/busblocks
Breaking: Synth Patches Require an output { } Block
- Signal-path inference from node names (an oscillator called
carrier, an envelope calledamp) is gone. Every patch must wire its path explicitly with anoutput { }block, and a patch without one is rejected at compile with an actionable message - Existing patches migrate by adding
output { in = node.<carrier>.out gain = node.amp.out }— the same chain that used to be assumed
Breaking: Envelope Times Are Plain Milliseconds
attack,decay, andreleaseonenvelopeblocks (both synth envelopes and config-scope music envelopes) are now numbers in milliseconds. Writeattack = 5, notattack = "5ms"— the duration-string form no longer parses- Values are equivalent one-for-one:
"440ms"becomes440, and audio output is unchanged
Breaking: Node Names Must Be Unique Within a Patch
- A synth patch’s oscillators, envelopes, and filter share one namespace, so a duplicate label is now rejected at load. In particular the previously documented
envelope "filter"convention for a cutoff-modulating envelope collided with the filter node itself — the shipped examples now name itenvelope "cutoff_env". Rename any envelope calledfilterin your own patches
Deleting a Device No Longer Breaks the Whole Config
- A
route,selection, orlight_layoutplacethat still points at a device or scoring rule you deleted (or renamed) no longer fails the reload. The dangling reference is dropped with a warning — a route or selection that loses every owner is disabled, a layout just drives fewer lights — so the rest of the table keeps working while you finish the edit - Genuine authoring conflicts stay hard errors: two routes claiming the same owner, or a spatial/matrix violation in a layout, still fail the load
Rollover Light Examples Updated to Player Scope
- All three full-example-table variants now declare rollover light variables with
scope = "player"instead ofscope = "global", so each player maintains independent rollover progress during multiplayer games - Added sync handlers listening to
variable.<name>.changedevents to keep physical light state synchronized across player switches and game-end resets - Uses paired conditioned handlers (on/off) since light state blocks only support literal values, not expressions
Drop- and Standup-Target Examples Migrated to New Subtypes
- All four full-example-table variants now declare drop targets as
device "switch" "drop" "..."and stand-ups asdevice "switch" "standup" "...", matching the driven-device subtype convention - Existing VPX-style event-handler reset logic is preserved; inline comments note the declarative
reset { coil = ... }sub-block as the alternative when a physical reset coil is wired - No behavior change for existing examples — the migration is naming-only and opt-in for your own configs
New Example: Modes + Synth Bumper Table
- Added
docs/examples/full-example-table-with-modes-synth/— extends the modes example with two FM synth patches (bumper_pop,bumper_thud) wired across five bumpers at pitches drawn from a C major triad - Demonstrates synth sources and clip sources coexisting as candidates in the same
routeblock - No audio files required — all bumper sounds are generated from oscillator patches at runtime
Example Bumper Synth Patches Retuned to Sci-Fi Textures
- The
bumper_poppatch in the synth-enabled example tables is now a resonant laser-zap voice — inharmonic FM, a downward pitch “pew,” and a long amp/filter tail — replacing the original clangorous chirp - The
bumper_thudpatch is now a warp-core sub — slow detuned modulation and a deep filter sweep with extended decays so the voice rings out instead of cutting off short - Fixes a latent issue in the warp-core filter envelope — the previous patch used
sustain > 0on a one-shot voice (bumpers never issue a note-off), so the filter envelope would have held indefinitely. The retuned patch usessustain = 0so the envelope completes naturally - Device wiring, scoring, bumper pitch bindings, and modes are unchanged — this is a presentation-only refresh of the example content
Quieter Logs During Event Floods
- The per-tick
Mixing audioDEBUG line is now rate-limited — at most one entry every 250 ms (or every 50th mix tick, whichever fires first) — instead of once per tick at 50–100 Hz. The Logs panel is no longer swamped by mixer output while the audio engine is running - Cascade capture start/stop no longer emits narrational INFO lines. The toggle is still observable via events; the redundant log output has been removed
- Each
TriggerEventnow emits one consolidated DEBUG line at finalize time instead of four separate lines (2× INFO, 1× DEBUG, 1× INFO). Overall log volume under gRPC event floods drops by roughly 3×
Fixed
wave = "triangle" Emitted a Sawtooth
- The triangle oscillator produced a rising ramp identical to
saw, so a patch asking for a triangle got a sawtooth’s harmonic content. It is now a real folded triangle — softer odd harmonics, warmer than sine, gentler than saw, as documented. Patches usingwave = "triangle"will sound different (correct) after this change
Device-Owned Routes Made No Sound
- A
route { }owned by adevicevalidated and rendered but never played anything, and a synth source under any owner was dispatched as if it were a clip — resolving to a clip id that does not exist, and failing silently. Device-owned routes now dispatch on both the native and in-browser runtimes, with synth sources going down the voice path and unsupported capabilities warning loudly instead of going quiet
Multi-Source Routes Picked Differently Every Run
- Routed audio selection was seeded from the wall clock, so a route with more than one candidate chose a different source on every run and a replayed table produced a different sound sequence each time. Selection is now seeded deterministically, so the same table and seed replay identically
Playlist track_probability Was Ignored
- A music playlist naming a distribution in
track_probabilityparsed the attribute but never used it — track order fell back to the playlist’smode. The distribution is now sampled at selection time and takes precedence over the mode, deterministically for a given seed. An unknown distribution or out-of-range draw still falls back to mode-based selection rather than failing
Variable-Driven Outputs Not Clearing at Game End or Engine Stop
- Rollover lights and other outputs driven by
variable.<name>.changedsync handlers would stay lit after the last ball of a game, and again after the engine was stopped — the reset chain described in Syncing Outputs to Variable State was not wired into the running game-session manager, sovariable.*.changedevents withreason = "reset"never reached handlers in a real game - Game end now drives the same reset path that scenario tests already exercised:
system.game.endfires first, thenplayer,ball, andsessionscoped variables reset and emitvariable.*.changedwithreason = "reset", and pairedsync_offhandlers turn lights off immediately - Engine stop (leaving the live engine from the console) now triggers the same reset chain at machine-reset granularity, so outputs return to defaults instead of holding their last state across a restart
- An integration test drives a real game to natural end-of-game and asserts the
set_lightcalls reach the device commander — a regression in the wiring will fail the build rather than silently re-introducing the stale-light behavior
Scoring Compiler Constants Collision in Multi-Update Rules
- Scoring rules with multiple variable updates in a single
updateblock could produce incorrect values when the compiled sub-expressions used fused arithmetic operations — only one set of constants was retained, causing subsequent operations to read the wrong constant value - Example:
update = { counter = var.counter + 1, multiplier = var.multiplier + 10 }could incorrectly setcounterto10instead of1 - The fix properly concatenates constants from all sub-expressions and adjusts operation indices so each sub-expression’s constants occupy distinct slots
Synth Silence on Percussive Patches
- Short percussive synth voices (sustain = 0) accumulated as zombies in the voice pool without ever reaching idle, eventually starving new triggers of free voices — percussive ADSR envelopes now transition directly from decay to idle when sustain is 0
- The audio pipeline was being initialized twice in some startup paths, causing the first subsystem to stop producing output — initialization is now gated to a single path
- The ring buffer is simplified to always read mono and duplicate to stereo, removing a stereo-path mismatch that silenced certain patches
Crash When mode.<name>.active Referenced With No module {} Blocks
- Configs that referenced
mode.<name>.activein handler or scoring conditions but declared nomodule {}blocks would crash on the first event dispatch with a nil pointer dereference inside the mode manager - The config is now treated as “no modes configured” and
mode.<name>.activeevaluates tofalse— no crash, no dispatched mode lifecycle
Audio Stalls During Event Floods
- Rapid bumper hits and other high-rate event bursts could produce audible audio stalls of up to ~2.8 seconds before voices started playing — new triggers appeared to queue up and then flood out once the mixer caught up
- The mixer and synth dispatcher no longer serialize sound-effect triggers behind log I/O or per-tick debug output; the
AddSource/Dispatchpaths take only brief, map-scoped locks and emit logs outside the critical section - Net effect on a tight bumper loop: sound-effect latency stays bounded and consistent, with no perceptible catch-up burst when the scene quiets down
Parser Rejected Canonical Two-Label variable Blocks
- The scoring-only parser accepted only single-label
variable "name" { }declarations and rejected the canonicalvariable "type" "name" { }form documented in the Variable reference - The parser now accepts the two-label form and ignores unrelated top-level blocks (
assembly,device,mode, etc.) when parsing scoring from a full.cadefile
Assembly- and Instance-Level Tags Not Captured by the Parser
- The Assembly Tag Propagation — Three-Source Union Merge behavior documented for v0.1.0 only ever worked for device-level tags: the parser’s
assembly { }anduse { }block schemas had no entry fortags(ordescriptiononassembly), so assembly-definition tags and instance (useblock) tags were silently dropped before reaching the expander’s union-merge logic — only device-leveltagsactually propagated assembly { tags = [...] description = "..." }anduse "..." "..." { tags = [...] }are now captured by the parser, so all three tag sources genuinely union onto expanded device instances as documented
Legacy Top-Level event / global_event / settings Blocks No Longer Silently Dropped
- A top-level
settings { }block’s contents now reach the running engine (e.g.settings { audio { master_volume = 0.8 } }correctly overrides the audio master volume) — previously the block parsed without error but every value inside it was discarded - Legacy singular
event { }andglobal_event { }blocks (older aliases ofglobal_events { }) are now parsed instead of silently dropped
settings {
audio {
master_volume = 0.8
}
players {
max = 4
min = 1
}
}Bare Action Verbs in event_handler Blocks Now Fire
- Action verbs (
pulse_coil,set_light,set_variable,eject_ball, etc.) written directly underevent_handler { }, without an enclosingactions { }block, now execute. Previously they parsed cleanly but were silently dropped — the handler fired but nothing happened - The explicit
actions { }wrapper is still supported and can be mixed with bare verbs in the same handler
event_handler "kickback_fire" {
when = device.Kickback.activated
pulse_coil "c_kickback" {
duration = "35ms"
}
}Scoring Config Loading Converged With the Table Parser
- Loading a scoring configuration from a table directory or a standalone scoring file (used by scenario testing and other scoring-only tooling) now goes through the same parser and expansion pipeline as
cade run/cade console— module composition, assembly expansion, hex literals, and the full score-rule grammar all apply uniformly - Previously a narrower standalone decoder silently failed on constructs the main parser accepts:
update = { var = expr, ... }attribute-form updates on score rules, and mixed number/bool value coercion. Configs using either construct would fail to load through the scoring-only path even though they ran fine undercade run
Reactive device_control and background_music Conditions Never Evaluated in Real Sessions
- The
conditionexpression on reactivedevice_controlrules and onbackground_musictracks was previously only evaluated inside scenario tests — a livecade runorcade consolesession had no evaluator wired, so these conditions silently had no effect - A shared evaluator (the same live score/variable/signal state used by synth parameter bindings) is now installed on every session, so
.cadeconditions now behave identically in a real game and in a scenario test
Span State Snapshots Were Always Empty
- Spans recorded in the history database (ball turns, modes, multiball, etc.) now populate their
state_snapshotJSON column with live game state — players, scores, and ball number captured at span boundaries (game start, ball start, ball drain, game end) - Previously the column existed in the schema but was always
NULLeven withrecording.state_snapshotsenabled (the default)
Active-Mode Scoring Misattribution
- Points from a scoring rule were credited to every currently active mode, so an always-on infrastructure mode (or a lower-priority mode) would double- or triple-count the same points already attributed to a higher-priority mode
- Scoring is now attributed to the mode that actually owns the fired rule, resolved from the rule’s
module.<name>.activecondition; falls back to crediting the highest-priority active mode only for global/ungated rules or an ambiguous condition spanning multiple modules
Cascade History Commands Unreachable in the Console
- The console’s
historycommand always opened the built-in command-history viewer, so thehistory games/history scores/history switches/history spans/history cascade/history sqlsubcommands were implemented but silently unreachable history <subcommand>now routes to the cascade-history command family; barehistory(or a session with recording disabled) still falls back to the built-in viewer
Event Tree Could Get Stuck Showing “No Events Yet”
- On a busy machine, the event tree could miss the first game’s start signal if scoring activity arrived before the tree’s subscriber attached — the tree stayed on “No events yet” for the entire game, only unlocking on the next game’s start
- The tree now self-heals: if ball or scoring activity arrives with no game-start observed yet, it synthesizes the game span from that activity so the rest of the game displays normally
Event Tree Player Node Showed the Wrong Ball Count
- The player node’s ball counter (e.g.
Ball 1/3) was derived from how many balls had been played so far rather than the table’s configured balls-per-game, so it always readBall 1/1, 2/2, 3/3 - It now reads the configured ball count from game-start data, so a 3-ball game correctly shows
Ball 1/3,Ball 2/3,Ball 3/3
Command History Recall via Arrow Keys
Up/Downon the Console tab previously scrolled the REPL output transcript instead of recalling previous input — command history could not be recalled with the arrow keysUp/Downnow recall command history as expected;j/k/PgUp/PgDn/Home/Endremain available for scrolling REPL output
VPX Start Button Not Registering
- Pressing Start did nothing on some VPX tables — VPX emits
start_gameas its Start input action’s device key, which wasn’t in the engine’s recognized set of start-button keys start_gameis now recognized alongsidestart,start_button, andpause
VPX Bridge Connection Drops and Reconnect Storms
- The gRPC connection to VPX could drop with a TCP reset shortly after a table loaded — a device-manifest request made on the same already-active stream could starve VPX’s connection handling. The manifest is now pushed through the stream itself instead of fetched with a separate request, eliminating the conflict
- A connection that reconnects and drops again immediately (a flapping link, or a table that has already exited) no longer forces a fresh config update and engine restart on every attempt — the client now waits for the session to prove stable first, and backs off exponentially across repeated short-lived sessions
cade validate / cade migrate Crashed on -d
- Both commands crashed on startup with a flag-registration panic, because their
-dshorthand for--dircollided with the root command’s persistent-d(--table-directory) shorthand - The
-dshorthand has been removed from both commands’--dirflags — use--dir(long form only)
v0.1.0 — 2026-04-14
Initial versioned release of the Cade Runtime. Ships the declarative HCL configuration surface (.cade files, fragments, assemblies, modules), the compile-time-optimised scoring and expression engine, the event processing pipeline with aggregation primitives, the TUI console with Logs/Events/Console tabs, the VPX and FAST drivers, hot reload, scenario and replay tooling, and cross-platform builds for Linux, Windows, and macOS (arm64/amd64). Versioning is now tracked via the cade version command and the --version flag.
Added
Assembly Tag Propagation — Three-Source Union Merge
- Assembly expansion now unions tags from three sources onto each expanded device instance: assembly definition
tags, instance (useblock)tags, and device-leveltagswithin the assembly body - Merge order is assembly → instance → device; duplicates are deduplicated preserving first-occurrence order
- Instance tags are strictly additive — they cannot remove tags inherited from the assembly definition
- Enables
device_controltag selectors to work uniformly across all assembly instances while allowing per-instance differentiation (e.g.,"left_side"vs"right_side"tags on flipper instances)
assembly "bumper" {
tags = ["autofire", "scoring_device"]
parameter "int" "switch_id" { required = true }
parameter "int" "coil_id" { required = true }
device "switch" "bumper" "sensor" {
id = param.switch_id
tags = ["active_switch"]
}
device "coil" "bumper" "ring" {
id = param.coil_id
}
}
use "bumper" "pop_1" {
switch_id = 0x40
coil_id = 0x30
tags = ["upper_playfield"]
# After expansion:
# switch "bumper" "sensor" tags: ["autofire", "scoring_device", "upper_playfield", "active_switch"]
# coil "bumper" "ring" tags: ["autofire", "scoring_device", "upper_playfield"]
}Event Aggregation Example Configurations
- Comprehensive example file demonstrating all three aggregation primitives with real pinball use cases
- Burst examples: bumper cluster detection (5 hits in 2s), spinner frenzy (10 spins/s), slingshot chain (4 alternating hits in 3s), tag-based bumper flurry, and orbit combo with diminishing-returns cooldown
- Coincidence examples: hot playfield (bumpers + ramps + targets within 5s), multiball chaos (4 device groups within 3s), and cross-device skill shot (plunger lane + skill targets within 2s)
- Rate examples: spinner speed tiers (idle → active → fast → frenzy with per-spin multiplier), playfield activity intensity (calm → active → intense), and bumper intensity with progressive value adjustment
- Combined patterns: three-layer bumper scoring stack (per-hit + burst bonus + rate-adjusted value), multiball scoring stack with coincidence bonuses, and a
score "modifier"block that amplifies aggregation-triggered bonuses during multiball mode - Cooldown patterns: short (slingshot chains), medium (bumper clusters), long (spinner frenzy), no cooldown (coincidence), and dynamic cooldown via diminishing-returns scoring
Examples Conversion — game_mode to module Syntax
- All example files in
docs/examples/converted fromgame_mode/modeblocks to the canonicalmodule "name" { mode { } }form - Explicit
condition = "module.self.active"/game_activeconditions removed from module-scoped scoring modifiers in design examples — the loader auto-injectsmodule.<name>.activeguards, making manual conditions redundant - Example files now demonstrate
active_on_startup = trueandstop_eventsmode attributes within the module wrapper
Event Aggregation System
- New
aggregationHCL block type with three primitive variants for composite event detection:- Burst detection (
aggregation "burst"): per-device rapid-fire counting within a tumbling window, emitscount+rate_hzon threshold - Cross-device coincidence (
aggregation "coincidence"): multi-group activation within a shared window, emitsdevice_count - Rate-aware scoring (
aggregation "rate"): EWMA-smoothed event density withrate_tiersblock containingtiersub-blocks (min_rate,label,multiplier); 10% hysteresis on downgrades;tier.labelandtier.multiplieravailable in trigger data
- Burst detection (
- Each rule accepts
devicesordevice_groups,window,cooldown, and a trigger/tier-change emit target; aggregation signals feed existingscore "signal"blocks so scoring logic requires no changes - Cooldown tracks state silently and fires immediately at expiry if the condition is still met; per-primitive semantics (burst accumulates, coincidence resets, rate continues EWMA)
- Aggregation signals inherit cascade context from the first triggering event, so individual and aggregated scores are both attributed to the same cascade
- Additive by default — individual events continue scoring normally; aggregation adds composite signals on top. Under high load an aggressive mode may coalesce events to protect tick-rate
Event Tree Viewer Improvements
- Bug fix: runs of the same event now report accurate total count and score regardless of the display window size (previously capped at 16)
- Bug fix: collapsed-run nodes support scroll-back overflow within their children; runs with more than 16 events show
... N earlier ...markers - Bug fix: collapsed-run nodes default to collapsed — the summary label (e.g.
device.Bumper1.activated ×50 +50,000 pts (last +56.895s)) is enough at a glance; expand withSpaceto drill in - Event panel now renders with fidelity matching the design prototype: player labels show live score and event count; total-balls-per-player derives from recorded play; device-type coloring, mode routing tags (
→ [multiball, base]), held duration, score, and cascade depth indicators appear on event leaves - Active-mode lines show elapsed time via
since +12.494s; completed modes append accumulated score (e.g.+23,100 pts) - Mode history and lifecycle display split into separate sections — history summarises completed modes (
super_jackpot pri:600 +0.805s–+1.611s (806ms) 23,100 pts [20 events, 7 cascades]); lifecycle lists individual start/end entries chronologically - Sub-second time deltas now render as
+0.805sinstead of+805msfor visual consistency - Cascade timing durations below 0.05 ms are suppressed from the display — these would render as “0.0ms” and add visual noise without conveying useful latency information
- Collapse state resets automatically when a new game starts — tree keys like player and ball nodes are reused across games, so stale collapsed/expanded state from a previous game no longer carries over
- Zero overhead when running headless — tree state is only accumulated when the TUI is active
Active-on-Startup Mode Auto-Launch
- New
active_on_startup = trueattribute onmode {}blocks; when set, the mode is automatically started after the first ball launches — no explicitstart_modeaction required - Mode lifecycle events flow through the cascade pipeline into the TUI event viewer
module "base" {
mode {
priority = 100
active_on_startup = true
events {
on game.ball_end { end_mode = "base" }
}
}
}Engine-Level Multiplayer and Mid-Game Joins
- Unified start-button handling across headless, console, and hardware targets with a single 200ms-debounced entry point — auto-launches the game immediately after the first press from idle or game-over state (mirrors real pinball behavior: Start = queue + go)
- Mid-game joins: pressing Start while a game is in progress inserts a new player at the current ball without disturbing existing players’ scoring state; new player plays only remaining balls and emits
system.player.joinedwith"mid_game": true - Player IDs generated sequentially as plain integers (
"1","2","3") - Removed
--playersCLI flag and pre-seeded player configuration; the engine is now the sole authority over game start and player management
Config Primitive Cleanup — Mode Removal, Passive Mode Primitives, Signal Guidance
- Top-level
game_mode "x" { }, baremode "x" { }, andgame_modes { }container blocks are no longer valid; any of these now returns a parse error directing authors tomodule "x" { mode { } } - New
device_control { }block inside module and mode contexts for declarative enable/suppress rules; uses a unifiedtarget "<type>" { select { tags = [...] } apply { enabled/suppress } }pattern with an optionalconditionexpression - New
suppressed_by = [...]attribute on module blocks to name mode modules that suspend the infrastructure module’s event handlers, scoring, and device_control on activation - Assembly-level
tagspropagate additively to all devices expanded from auseblock, sodevice_controltag selectors work uniformly across all instances - Module loader auto-injects
module.<name>.activeas a condition guard on every modifier, event handler, and accumulator inside a mode module — entries with no condition get the guard directly; entries with an existing condition get"module.<name>.active && (<existing>)". Infrastructure modules (nomodeblock) are not affected - Documentation examples updated:
game_modeblocks converted tomodule { mode { } }; explicitcondition = "module.self.active"removed (now auto-injected)
# Before
game_mode "stop_and_go" {
priority = 800
events {
on device.left_flipper__button.activated { emit "menu_select_left" }
}
}
# After
module "stop_and_go" {
mode {
priority = 800
device_control {
target "device" {
select { tags = ["flipper"] }
apply { enabled = false }
}
}
events {
on device.left_flipper__button.activated { emit "menu_select_left" }
}
}
}Game-Passive Modes & Device Control Design
- New design documentation for game-passive mode infrastructure: attract, tilt, stop-and-go, and insert-initials modes with their inhibition contracts
- Unified
target/select/applypattern for addressing devices, modules, or scoring rules by tag glob rather than by name — usable both imperatively inside event handler actions and declaratively insidedevice_controlblocks device_controlblock: declarative device/scoring inhibition that auto-applies on mode activation (mode-scoped) or reacts to variable changes (module-level withcondition); owner-tracked so multiple concurrent inhibitors don’t accidentally re-enable each other on partial releasesuppressed_byfield on infrastructure modules: names mode modules whose activation suspends the infrastructure module’s event handlers, scoring, and device_control — enables attract to re-enable devices automatically when base gameplay begins, with no explicit wiring- Assembly-level
tagsfield: tags declared on an assembly definition propagate additively to all device blocks expanded from that assembly; ensures consistent tag coverage fordevice_controltargeting across all instances of hardware assemblies (e.g., every flipper fromuse "flipper"carries["player_controlled", "flipper"]automatically) - Updated
infrastructure-module.cadetilt example to use declarativedevice_control(condition-based onvar.tilted) replacing bareemit "flippers_disable"/emit "scoring_disable"events that had no backing receiver spec
Kick Ball Action — Driver-Agnostic Kicker-Kick Primitive
- New
kick_ball { device = "X" }action that fires an existing ball held by a kicker device without creating a new ball or incrementing the ball-in-play count - Distinct from
eject_ball(which doesCreateBall() + Kick()for trough/launcher flows):kick_ballwrapsKicker::Kick()only, with no ball creation — resolving the gate-to-kicker phantom-ball bug whereeject_ballspawned a new ball on every gate hit - The
kick_ballaction carries only adeviceattribute; physics parameters (angle, strength) live on the device declaration, keeping the action driver-agnostic - Kicker devices accept a
settings { kick_angle, kick_strength }block, mirroring the flippersettings { strength, hold_time }pattern — values that any driver can interpret - The VPX bridge wraps
Kicker::Kick()withoutCreateBall(); returns an error when the kicker has no ball held
device "switch" "kicker" "Kicker1" {
id = 41
settings {
kick_angle = 190
kick_strength = 10
}
}
event_handler "gate_hit" {
event = device.Gate1.cleared
actions {
kick_ball { device = Kicker1 }
increment = "balls_in_play"
start_mode = "multiball"
}
}Noise Context Configuration
- New
noise_contexttop-level HCL block for declaring named noise generation contexts in.cadefiles - Each context selects an algorithm (
"hash","white","simplex","perlin"), a seeding strategy ("time","random","deterministic", or an integer literal), an advisory quality hint, and an optional LRU cache size seed_base = "deterministic"produces identical sequences across runs for use in testing and replays;"time"/"random"seed from wall-clock time for non-deterministic production use- Multiple named contexts can coexist in the same configuration file, each maintaining independent state
- Invalid algorithm values are rejected at parse time with a descriptive error
noise_context "scoring" {
algorithm = "hash"
seed_base = "time"
quality = "balanced"
cache_size = 256
}
noise_context "testing" {
algorithm = "hash"
seed_base = "deterministic"
}Variable Auto-Decay and Auto-Grow
- Variables now support two decay models configurable directly in HCL:
- Continuous decay:
decay_rate(amount subtracted per second) withdecay_toas the floor value - Interval decay:
auto_decay = truewithdecay_interval(tick period),decay_amount(per-tick subtraction), anddecay_condition(optional expression guard that pauses decay when false)
- Continuous decay:
- Variables now support two growth models:
- Continuous growth:
growth_rate(amount added per second) withgrowth_toas the ceiling value - Interval growth:
auto_grow = truewithgrow_interval(tick period) andgrow_amount(per-tick addition)
- Continuous growth:
- Decay and grow fields can coexist on the same variable for competing-force mechanics (e.g., passive cooling + event-driven heating)
Show Dispatch from Event Handlers
play_showsandstop_showsfields inevent_handleraction blocks are now dispatched at runtime- Previously these fields were parsed but silently dropped; they now correctly activate and deactivate named shows
ActionBlock Naming Fixes and Trigger Wiring
fire_signalreplacestriggerin action blocks:fire_signal = "mystery_award"imperatively activates a named signal; the oldtrigger =keyword is no longer acceptedwhenreplacestriggerin score blocks:when = "shot.orbit.complete"declares which event activates a scoring rule, matching theevent_handlerconvention; the oldtrigger =keyword is no longer accepted in score blocksfire_signalroutes through the signal processing layer (not raw event emit): the signal’s own completion handlers, cascades, and dependent scoring rules all apply
event_handler "combo_complete" {
when = shot.left_orbit.complete
actions {
fire_signal = "combo_jackpot" # activates signal through signal processing layer
emit = combo_scored # injects event into event handler layer
points = 25000
}
}
score "jackpot_rule" {
when = combo_jackpot.complete
points = 100000
}ActionBlock Execution Pipeline — Scoring and Emit Data
points = Nin action blocks now awards points to the active player at runtime (previously silently dropped in both full-session and debug console paths)emit = "event.name"now forwards thedata = {}block contents through to subscribers (previously the payload was dropped)- No configuration schema changes —
points,emit, anddatafields already existed; they now produce runtime effects where previously they were silent no-ops
Mode Control from Event Handler Action Blocks
- New
start_modeandend_modescalar fields on action blocks inevent_handlerconfiguration let event handlers directly start or stop game modes without requiring a separate scripted mode body start_mode = "multiball"activates the named mode on the mode stack when the event handler firesend_mode = "multiball"deactivates the named mode from the mode stack when the event handler fires- Both fields are optional and composable with other action block fields (
emit,toggle_variable,pulse_coil, etc.) - Gracefully no-ops when no mode controller is configured (e.g., console-only sessions)
event_handler "multiball_lock" {
when = device.lock.activated
actions {
start_mode = "multiball"
toggle_variable = "lock_active"
emit = multiball_started
}
}
event_handler "drain_handler" {
when = game.ball_drain
actions {
end_mode = "multiball"
}
}Event Router Deduplication
- Event router now deduplicates identical events within a configurable time window (default 100 ms) to prevent the same physical event from being processed twice when it arrives via both the hardware path and a derived logical signal
- New
dedupe_windowfield onsignalblocks (e.g.dedupe_window = "75ms") overrides the default per-signal; empty means “use the router default” - Optional per-processor rate limiting via a token bucket — events that exceed a processor’s configured rate are dropped immediately
- Deduplicated events surface in routing metrics under reason
"deduplicated"; rate-limited events under reason"rate_limited"
Expression String & List Built-in Function Libraries
- Complete
string.*function library:string.contains,string.substring,string.to_upper,string.to_lower,string.split,string.join,string.trim,string.replace,string.matches,string.to_int,string.to_bool— added alongside the existingstring.length,string.concat,string.from_int, andstring.from_bool - Complete
list.*function library:list.empty,list.contains,list.index_of,list.first,list.last,list.get,list.slice,list.prepend,list.insert,list.remove,list.remove_at,list.reverse,list.sort,list.shuffle,list.unique,list.join,list.from_element,list.concat,list.difference,list.intersection— added alongside the existinglist.length,list.append, andlist.random string.splitreturns aListValueofStringValueelements;string.joinandlist.joinaccept a list of strings and a separatorstring.matchesuses Go’sregexppackage for full regular expression supportlist.shuffleuses the evaluator’s seeded RNG for deterministic results in tests and replays- All list modification functions return new lists; existing lists are never mutated
Reserved Keyword Validation
- New
--check-reserved-wordsflag oncade validateto opt in to identifier conflict detection (enabled automatically when--strictis set) - Reserved keyword validation stage wired into the integration validation pipeline as a post-
validateVariablesstep; issues appear intext,json, andyamloutput with category and suggested alternatives - Tiered severity controlled by
--pragma-mode:stricttreats all reserved names as errors;normal(default) allows contextual and future keywords as warnings;relaxedonly warns on strict keywords - Suggestion engine generates context-aware alternative names (e.g., block names prefixed with
my_,game_, orcustom_; variable names suffixed with_val,_count, or_total) - New
cade migrate --reserved-wordstop-level command for scanning.cadefiles and reporting reserved word conflicts with file, line, column, category, and suggestions cade migrate --reserved-words --auto-fixrewrites conflicting identifiers using the first suggestion, creating a.cade.bakbackup of each modified filecade migrate --reserved-wordssupports-d <dir>,-r(recursive),--pragma-mode, and--format text|json- Exit code 0 when no conflicts found; exit code 1 when conflicts are present (dry run) or an error occurs during fix
Expression Compile-Time Optimization
- Expressions and conditions are now simplified at compile time before emitting runtime operations:
- Constant folding: binary and unary expressions with all-literal operands are evaluated at compile time (e.g.,
1000 * 2→2000,(2 + 3) * 4→20); division by zero defers to runtime - Dead code elimination: ternary branches with compile-time-known conditions are pruned (e.g.,
true ? 1000 : 500→1000) - Operation fusion: recognizes
variable ± constantandvariable × variablepatterns and emits specialized fast-path operations
- Constant folding: binary and unary expressions with all-literal operands are evaluated at compile time (e.g.,
- Fully constant expressions emit a zero-work inline constant; partial expressions still benefit from folded sub-trees
Hot Reload and Session Unification
- Automatic scoring recompilation when
.cadeconfiguration files change at runtime, with full player state preservation (scores, balls, variables, accumulators) - Rollback to the previous configuration if the new one fails to compile; gameplay continues uninterrupted
- Consistent session behavior across
cade,cade console, andcade scenariocommands - Clean shutdown sequencing with optional context deadline
- Bug fix: the root
cadecommand now correctly recompiles scoring on configuration file change (previously detected changes but did not recompile)
Assembly System
- New
assemblyblock for defining reusable, parameterized groups of configuration blocks (devices, variables, scoring rules, event handlers, modes, audio) - New
useblock for instantiating assemblies with parameter arguments - Typed parameters (
string,number,bool,list,map) with required/optional support and default values - Name prefixing: expanded blocks are automatically prefixed with the instance name (e.g.,
left_flipper__button) - Self-references via
self.<block_name>for intra-assembly block references - Generator support:
for_eachandcountmeta-arguments onuseblocks for creating multiple instances - Nested assemblies with circular nesting detection and max depth enforcement
- Comprehensive validation: parameter types, required parameters, duplicate names, and cycle detection
Module System
- New
moduleblock for bundling related game logic (mode, variables, scoring, stacking, audio, shows) into a single organizational unit - Mode modules with priority stack participation and infrastructure modules (always-on, no mode)
- Stacking contracts via
stackingsub-block to declare module coexistence rules (allow_multiple,conflicts_with) - Config-time composition via
composesub-block withincludeand override support - Module namespace in expression evaluator:
module.<name>.active,module.<name>.<variable> - Composition expansion with dependency graph construction, cycle detection, and topological sorting
- Backward compatible with existing bare
modeblocks
Replay Analyze Command
- New
cade replay analyzesubcommand for comprehensive session analysis of exported replay JSON files - Session summary: config name, total duration, player count, total score, balls played
- Per-ball breakdown: score delta, event count, duration, drain type, events/sec
- Device scoring aggregation with top-10 device ranking
- Timing gap detection for gaps >2 seconds between events
- Anomaly detection with severity levels (info/warning/error): zero-score balls, instant drains, missing events, total inconsistencies
- Supports
--format text|json,--ball Nfiltering, and stdin input
Replay Tree Command
- New
cade replay tree <file.json>subcommand prints the event hierarchy from a replay export to stdout, mirroring the TUI event viewer’s ball/player tree - Supports
--ball Nto show only events from a specific ball - Accepts
-as the file argument to read from stdin - Intended for headless regression testing: ball labeling and event grouping bugs are visible in CI without running the full TUI
Activity Watchdog
- Detects physically stuck balls by monitoring hardware event inactivity
- Configurable inactivity timeout
- Active only during ball-in-play phase; notifies when no hardware events arrive within the timeout
- Purely observational — does not take corrective action automatically
Config Hash Validation for Scenarios
- Replay-generated scenarios now embed
config_hashandconfig_namefields from session exports - Scenario runner validates that the correct table configuration is loaded before execution
- Backward-compatible: legacy scenarios without a hash are skipped gracefully
- Clear mismatch errors with
-dflag hint for diagnostics
Platform Conformance Tests
- Conformance test suites for all six device types (switch, coil, light, flipper, autofire, servo)
- Conformance tests for optional platform capabilities (readiness, manifest, config update, web integration)
- Suites run automatically for any driver that supports the capability; skipped otherwise
Light System
- Color support for hex (
#FF0000), shorthand hex (#F00), and named CSS colors with linear interpolation - Light set and flash commands controlling state, intensity (0.0–1.0), color, and fade duration
- Tick-based light controller running at 30 Hz with batched platform emission
- Fade transitions with linear and incandescent (thermal-model exponential) curves
- Pattern-based blink/flash system with configurable on/off sequences and duration
- Named, ordered light groups — supports explicit group definitions and implicit tag-based group creation from light metadata
- Batch operations for setting and flashing named groups
- Timeline-based shows for declarative light animations
- Priority-based concurrent show playback — higher-priority shows override lower ones
- Built-in patterns: wave, chase, all-on
- Show compilation to FAST EXP firmware script strings (LED selection, RGB colors, fade, wait, loop — max 128 chars) for firmware-assisted autonomous show execution
- Light condition bindings in boolean (on/off) and intensity (float 0.0–1.0 for GI dimming) modes, with
on_color,on_intensity, andfade_msoptions set_lightandflash_lightaction blocks in event handlers
Stacking Contract Validation
- Static validation at config load time: no self-conflict, no self-requirement, require-replace overlap, conflict-replace overlap, unknown reference detection
- Symmetric conflict graph: if A conflicts with B, B automatically conflicts with A
- Circular dependency detection via DFS on the
requiresgraph OnMaxpolicy validation (reject,queue,replace_oldest)
Event Tree Viewer
- Overflow nodes (
... N earlier events) are now expandable — pressEnteron an overflow node to reveal the next 16 hidden events within that span; press repeatedly to page through the full history - New sibling-lock navigation mode (
sto activate): constrainsj/kto siblings at the same tree depth, withg/Gjumping to first/last sibling andEscto unlock; lock is automatically released when the locked parent is evicted or becomes invisible - Entry cap (default 10,000) with automatic eviction of the oldest completed game span when the cap is reached — at least two game spans must exist before eviction occurs, so the active game is never removed
- Collapsed-span rebuild fast path: incoming events for collapsed, non-structural spans skip the full tree rebuild and only increment the span event counter, reducing CPU usage at high event rates (50–100 Hz)
TUI Layout
- Dynamic registration of UI extension points (tabs, sidebar sections, overlays)
- Tabbed sidebar layout splitting the screen into a primary tabbed area (~75%) with a collapsible persistent sidebar (~25%)
- Sidebar auto-collapses below 100 terminal columns; sections stack vertically with proportional height allocation respecting minimum height constraints
- Tab bar with active tab styling and F-key switching (F1–F12)
- Context-sensitive help bar with key hints that update per active tab
- Score and Watch panels adapted for borderless sidebar rendering — bold labels and spacing replace box borders for narrow (~20–25 column) layouts
- Header simplified to single-line borderless format; breadcrumb deprecated in favor of the tab bar
TUI Header Redesign
- Two-mode header toggled with
F4: compact (2-line, default) shows table name, engine status, platform health, and version; regular (4-line) adds config path, platform address, and connection status on separate lines - Sidebar and Metrics/Profiler overlays removed from the layout for simplification
- Logs panel is now the default tab shown on startup (F1=Logs, F2=Events, F3=Console)
- Fixed key handling so
qandEnterpass through correctly on the Console tab - Replaced legacy “OPF” branding with “Cade” throughout the TUI
Game Status Bar
- New collapsible status bar widget between the header and tab bar, toggled with
F5 - Game state section: displays game phase (● Ball In Play, ○ Starting, ■ Game Over), current player, comma-formatted score, and ball count (e.g.,
Ball 1/3) - Event sparklines: ring buffer tracking 60 seconds of event frequency, rendered as an inline activity chart
- Extensible
StatusSectioninterface — new rows can be registered without modifying the container - Horizontal separator line beneath the status bar when expanded; disappears alongside the bar when toggled off
- Responsively adapts displayed fields to available terminal width
Event Compression and Viewer Improvements
- Hit/unhit switch event pairs in the same span are merged into a single tree entry showing held duration (e.g.,
left_inlane held 234ms); cross-span pairs are not merged - Flipper press/release sequences (up to 4 events across
left_flipper/staged_left_flipperorright_flipper/staged_right_flipper) compress to a single entry, eliminating per-flip noise - Game start and end events are merged into a single
Gamespan in the event tree, replacing the previousGame Started/Game Endedsibling spans; post-game events (e.g., credit switches) now correctly appear at root level - Background modes (always-active infrastructure modes) are filtered from the active modes section in ball spans; the active modes section only appears when a feature mode activates mid-ball
- Mode tracking sections (Mode, Scoring, Multiball) are now correctly visible under ball spans regardless of synthetic Player spans at depth 1
- New
ptoggle in both event views expands or collapses squashed event pairs inline without modifying the underlying event data
Event Handler Mode Actions
start_modeandend_modeaction block attributes now activate and deactivate modes directly from event handlers- Previously,
start_mode = "multiball"in an action block was silently ignored (fell into the unrecognised-field map); it is now wired through the executor to the mode manager end_modeworks symmetrically, stopping a running mode by name
Validate Command Path Scoping
cade validatenow respects a path argument or-d / --dirflag when determining which.cadefiles to validate, preventing unrelated files from being scanned- New
-r / --recursiveflag (defaultfalse) opts in to subdirectory traversal when a directory target is given - Scoping rules:
cade validate <file>validates that single file;cade validate <dir>validates files in that directory non-recursively (add-rto recurse);cade validatewith no argument preserves existing behaviour (recursive scan from the current working directory)
Other Recent Additions
- HCL parser support for
assemblyanduseblock schemas - HCL parser support for
moduleblocks and all sub-block types (mode, stacking, compose, variable, constant, score, event_handler, audio, shows) - Fuzz testing tasks:
fuzz:resilienceandfuzz:allmeta-task
Debug Console - Pipe Mode Support
- Automatic detection of piped input for non-interactive operation
- Enables scripted debugging and automation workflows
- Commands can be piped from files, scripts, or other programs
- Supports command chaining with Unix pipes
- Clean exit codes for script integration (0=success, non-zero=error)
--no-tuiflag to force pipe mode even with terminal input- Examples:
echo "eval 2+2" | cade console- Simple evaluationcat commands.txt | cade console- Run commands from filecade console < script.debug- File redirectionecho "inspect score" | cade console | grep "Value:"- Pipeline integration
Multiball Lifecycle Management
- Ejecting a ball now correctly increments balls-in-play so multiball drains take the intermediate branch instead of prematurely ending the ball
system.ball.endis now emitted on all terminal drain branches (extra-ball, last-ball/game-over, last-ball/rotation) with{player, ball, final_score, drain_device, timestamp}payloadsystem.ball.endis not emitted on intermediate multiball drains (whenballs_in_play > 1), so modes withstop_events = ["system.ball.end"]stay active through the multiball sequence- Strict event ordering within each drain:
system.ball.drain→system.play.end(if present) →system.ball.end→ rotation/endGame - End-to-end multiball lifecycle scenario at
docs/examples/scenarios/multiball_lifecycle.hclverifies mode start/stop, phase preservation, and ball count correctness
Module Active Condition in Scoring Engine
module.self.activeandmodule.<name>.activeconditions now evaluate correctly inside scoring rules — previously these silently returnedfalse- Correct evaluation is preserved across hot-reload recompiles
Mode Event Bridge in Console
- Modes with
stop_eventsnow automatically end when those events fire during acade consolesession - Previously, modes started via
start_modeaction blocks in the console never stopped on their declared stop events
Other Recent Additions
- Autoscroll toggle to events view with
akeybind - Autoscroll toggle to logs view with
akeybind - Support for
signal,condition, andaudioinevent_handlerblocks - Support for nested action block types in game parser
- Autofire rule support to gRPC bridge
- Autofire rule definitions to example table
- Standup target toggle light handlers for example table
- Drain auto-serve and game start handlers for example table
Changed
TUI Mode Lifecycle Display
- Mode started/ended lifecycle entries in the event tree are squashed into single-line pairs — a completed mode shows as one line with duration, score delta, and event count rather than separate “started” and “ended” nodes
- Stats map keyed by
name + instanceinstead of name alone, preventing duplicate stats entries when the same mode completes multiple times in a session - Separate
historyandlifecyclesections in ball spans unified into a singlemode lifecyclesection; each entry is enriched with duration, score delta, and event/cascade counts fromCompletedModeData
TUI Console Cleanup
- Removed unused panel factory, middleware, and plugin-loading code
- Console, Events, and Logs are now exclusively top-level tabs rendered by the chrome tab bar
- Simplified navigation targets — enable/disable machinery removed; target definitions are kept for breadcrumbs
- Deprecated breadcrumb widget in favor of the tab bar
VPX Driver
- Reduced manifest warning noise: 60+ per-device
WARNlog lines for unconfigured devices replaced with a single summary line (e.g.,49 unconfigured VPX devices: 12 lights, 8 flippers, 20 inputs, 9 other); per-device detail remains atDEBUGlevel; genuine warnings (category mismatches, configured devices absent from manifest) are preserved
Example Tables
- All three example tables (
full-example-table,full-example-table-with-modes,full-example-table-with-assemblies) updated to usekick_ball { device = "Kicker1" }in thegate_hithandler, replacing the prioreject_ballworkaround that caused phantom ball creation - Kicker1 device declarations in all three examples now include
settings { kick_angle = 190 kick_strength = 10 }for driver-agnostic kick physics
Other Changes
- Debug console now automatically switches between TUI and pipe modes
- Help text updated to document both interactive and pipe modes
- Exit behavior improved for clean script termination
Fixed
- Mode event handler conditions never evaluated: event handlers with a
conditionfield now correctly gate execution, supportingmode.<name>.activepatterns - Emitted events never reaching mode bus: events emitted from handler action blocks now reach mode-bridge subscribers, so
stop_events = ["multiball.ended"]subscriptions fire as expected - TUI events panel state correctness: game, ball, player, and mode state in the tree now derive exclusively from session-manager events (
system.game.*,system.ball.*,system.player.*) rather than mixing driver and VPX signals. This fixes four related bugs:- Mode lifecycle events (
mode.*.started/mode.*.ended) were not reaching the cascade pipeline — they now publish as cascade entries - Active-modes sections in ball spans tracked the current ball rather than the ball during which the mode started — mode sections are now pinned to their origin ball
- Premature ball-close on multiball drains — intermediate multiball drains no longer close the ball span
- Stray VPX
ball_starthardware events were incrementing ball counters — hardware/driver events no longer drive phase transitions
- Mode lifecycle events (
- Console flipper integration stability:
- Autofire rules sent FAST hardware addresses instead of the configured switch/coil identifier; rules now carry their logical name, and a redundant gRPC round-trip was removed since VPX handles flipper coils locally
- Event flood amplification under rapid flipper input: cascade buffer expanded from 100 to 1024 entries, evicted IDs return nil instead of stale data, cascade counting de-duplicated, high-frequency gRPC event log lines downgraded from
InfotoDebug - Logs panel O(N²) re-render eliminated with an insertion-time formatted cache; a retention cap prevents unbounded memory growth
- Multiball never stopping:
system.ball.endwas never emitted by the session manager, makingstop_events = ["system.ball.end"]a dead reference — multiball modes ran indefinitely across ball boundaries - Multiball ball count:
eject_ballsent the gRPC command but never calledAddBallToPlay()on the session manager, soballsInPlaystayed at 1 and the first drain rotated players instead of staying in multiball - TUI multiball rendering: multiple “Multiball Started” entries rendered across forced ball spans because the mode never ended within its own ball — transitive fix from the above two corrections
- Drain detection false positive:
isDrainEventpreviously matched"unhit"events because of astrings.Containscheck for"hit"; changed to exact/suffix matching so only genuine drain hit events trigger ball turn advancement - History/lifecycle node position: History and lifecycle nodes now render visually inside their
Ball N Startedspan rather than at the bottom of the entire tree after all game-ended events - Console properly handles EOF in piped input
- Exit codes now correctly propagate to calling scripts
- Store per-entry ball start time to prevent timestamp collapse on rebuildRows
- Exclude
.git/fromrelease:localrsync - Use
build:crossfor Windows inrelease:localtask - Hot-reload: fixed four bugs — session not enabling hot-reload on the engine, file watcher path comparison mismatch, assembly registry stale state on second reload, and a lock-ordering deadlock during reload
- Startup race: engine setup in
cade runnow runs synchronously before the shutdown loop, eliminating a data race on shared variables during startup - gRPC drain handler: removed stale hardcoded lowercase
"drain"key leftover from before device rename; drain device name now comes from config, andDefaultDrainDeviceKeyscontains only the uppercase fallback - Scenario fallback: only the absence of
.cadefiles now triggers the bare-engine fallback; parse errors, validation failures, and other build errors propagate to the caller instead of being silently swallowed - Full-example-table: device identifiers aligned with exact VPX gRPC manifest element names; removed devices, scoring rules, and assemblies that have no representation in the manifest
- Gate→Kicker flow in example tables: all three example tables originally used
pulse_coil "Kicker1"(no-op) then brieflyeject_ball { device="Kicker1" }(phantom ball creation); now corrected tokick_ball { device = "Kicker1" }with kicker physics on the device declaration’ssettings { kick_angle = 190 kick_strength = 10 }block — properly fires the existing held ball without creating a new one or incrementing ball-in-play count