Variable-Driven State Synchronization
Variable-Driven State Synchronization
When lights or other outputs are driven by variable state, the physical output must stay in sync with the variable across all lifecycle transitions — game end, player switch, and player restore. Without explicit synchronization, outputs set by event handlers go stale whenever the underlying variable changes for a non-gameplay reason.
The Desync Problem
Consider a rollover lane that toggles a boolean variable and sets a light:
event_handler "turn_on" {
event = "device.sw6.rollover"
condition = "l6_lit == false"
actions {
toggle_variable = "l6_lit"
set_light "light" { device = "l6"; state = "on" }
}
}
This works during normal play. But three scenarios cause the light to diverge from the variable:
| Scenario | Variable state | Light state | Problem |
|---|---|---|---|
| Game end | l6_lit resets to false | Stays on | No event fires when a variable resets |
| Player switch (P1→P2) | P2’s l6_lit is false | Stays on (P1’s state) | Player context change is invisible to handlers |
| Player restore (P2→P1) | P1’s l6_lit is true | Stays off (P2’s state) | Same — no handler fires on context switch |
The root cause: event_handler blocks fire on gameplay events. Lifecycle transitions (resets, player switches) are not gameplay events — they happen in the engine and change variable values silently.
The Solution: variable.<name>.changed Events
Cade emits a variable.<name>.changed event whenever a variable’s value changes, regardless of the source. This includes:
set_variableandtoggle_variableactions (reason:set/toggle)- Lifecycle resets at game end and ball start (reason:
reset) - Active player switches that change the visible value (reason:
player_switch)
Event handlers can listen to these events and re-sync dependent outputs:
event_handler "sync_on" {
event = "variable.l6_lit.changed"
condition = "l6_lit == true"
actions {
set_light "light" { device = "l6"; state = "on" }
}
}
event_handler "sync_off" {
event = "variable.l6_lit.changed"
condition = "l6_lit == false"
actions {
set_light "light" { device = "l6"; state = "off" }
}
}
The sync handlers fire on every value change. During normal gameplay, the toggle handler already sets the light, and the sync handler re-confirms it (harmless). During lifecycle transitions, the sync handler is the only thing that fires — and it sets the light to match the new variable state.
Assembly Pattern
When the same sync logic applies to multiple devices, wrap it in an assembly with the gameplay handlers:
assembly "rollover_logic" {
parameter "string" "device_name" { required = true }
parameter "string" "light_name" { required = true }
parameter "string" "light_device" { required = true }
parameter "int" "points" { default = 5000 }
score "event" "score" {
trigger = "device.${param.device_name}.rollover"
points = param.points
}
# Gameplay handlers: toggle on switch hit
event_handler "turn_on" {
event = "device.${param.device_name}.rollover"
condition = "${param.light_name} == false"
actions {
toggle_variable = param.light_name
set_light "light" { device = param.light_device; state = "on" }
}
}
event_handler "turn_off" {
event = "device.${param.device_name}.rollover"
condition = "${param.light_name} == true"
actions {
toggle_variable = param.light_name
set_light "light" { device = param.light_device; state = "off" }
}
}
# Sync handlers: re-drive light on any variable change
event_handler "sync_on" {
event = "variable.${param.light_name}.changed"
condition = "${param.light_name} == true"
actions {
set_light "light" { device = param.light_device; state = "on" }
}
}
event_handler "sync_off" {
event = "variable.${param.light_name}.changed"
condition = "${param.light_name} == false"
actions {
set_light "light" { device = param.light_device; state = "off" }
}
}
}
Instantiate once per rollover lane — the assembly handles both gameplay toggling and lifecycle sync:
variable "bool" "l6_lit" { initial = false; scope = "player" }
variable "bool" "l7_lit" { initial = false; scope = "player" }
use "rollover_logic" "sw6" {
device_name = "sw6"
light_name = "l6_lit"
light_device = "l6"
}
use "rollover_logic" "sw7" {
device_name = "sw7"
light_name = "l7_lit"
light_device = "l7"
}
Scope Choice Matters
The variable scope determines which lifecycle events trigger resets:
| Scope | Game end reset | Player switch re-sync | Ball start reset |
|---|---|---|---|
global | No | No | No |
session | Yes | No (shared across players) | No |
player | Yes | Yes | No |
ball | Yes | Yes | Yes |
For rollover lights that track per-player progress, scope = "player" is the right choice:
- Lights clear at game end (reset fires
variable.*.changedwith reasonreset) - Each player sees their own light state (player switch fires with reason
player_switch) - Lights survive across a player’s balls (no ball-start reset)
Physical Resets Without a Variable
Some outputs have no backing variable to reset through the change-event chain. A drop-target bank is the clearest example — the physical state lives in the mechanism, not in a Cade variable, and a reset coil has to be pulsed to put the targets back up.
Cade does not reset drop banks automatically at game end. The engine reset path is deliberately reserved for variables; physical-device reset is an authoring concern so that tables without a bank reset coil, or tables with per-bank conditions, are not forced into a one-size-fits-all behavior.
The pattern is an ordinary event handler listening on system.game.end:
event_handler "reset_drops_on_game_end" {
event = "system.game.end"
actions {
pulse_coils = [
{ device = "c_drop_bank_reset", duration = "30ms" },
]
}
}
Extend the pulse_coils array to cover additional banks, or declare a second handler when per-bank conditions matter:
event_handler "reset_center_bank_on_game_end" {
event = "system.game.end"
condition = "center_bank_down_count > 0"
actions {
pulse_coils = [
{ device = "c_center_drop_reset", duration = "30ms" },
]
}
}
If the drop target is declared with the driven-device switch "drop" subtype, its own reset { coil, pulse_ms } sub-block already handles per-target recovery during play — see Driven Devices. The system.game.end pattern above is specifically for bulk bank resets at end of game, and for legacy declarations that do not use the switch "drop" subtype.
When to Use Change Events
Use variable.<name>.changed when a physical output must reflect variable state across lifecycle boundaries. This is the right tool when:
- A light, display element, or coil state is driven by a variable
- The variable’s scope means it can change outside of gameplay (resets, player switches)
- Multiple event handlers set the variable and you want a single sync point
You do not need change events for:
- Variables used only in expressions or conditions (these re-evaluate automatically)
global-scoped variables that never reset- Computed variables (these already recalculate on dependency changes)