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:

ScenarioVariable stateLight stateProblem
Game endl6_lit resets to falseStays onNo event fires when a variable resets
Player switch (P1→P2)P2’s l6_lit is falseStays on (P1’s state)Player context change is invisible to handlers
Player restore (P2→P1)P1’s l6_lit is trueStays 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:

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:

ScopeGame end resetPlayer switch re-syncBall start reset
globalNoNoNo
sessionYesNo (shared across players)No
playerYesYesNo
ballYesYesYes

For rollover lights that track per-player progress, scope = "player" is the right choice:

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:

You do not need change events for: