Signals vs Event Handlers — Usage Guide
Signals vs Event Handlers — Usage Guide
The Cade event system has three distinct layers, each with a specific responsibility. Config authors sometimes use event_handler blocks for problems better solved by signal blocks, or vice versa. This page clarifies which layer to use for each class of problem.
The Three-Layer Model
Layer 1 — Hardware
Raw state changes: switch open/close, coil on/off
Always fires; no configuration needed
Layer 2 — Signals
Pattern detection: sequences, timing windows, combos
Config: signal blocks
Output: named completion events (e.g. shot.left_orbit.complete)
Layer 3 — Game Events
Reaction logic: scoring, mode control, side effects
Config: event_handler blocks, score blocks
Input: any named event from Layer 2 (or bare emits)
Signals cannot listen for named events. Event handlers cannot detect patterns. Each layer only operates on its own inputs. Device inhibition belongs in neither — it goes in device_control blocks.
Decision Tree
Is the problem about detecting a hardware pattern?
(sequence, timing window, combo, all-targets-down, etc.)
YES → signal block
NO ↓
Is the problem about reacting to an event with side effects?
(score points, start/end a mode, emit another event, play a show)
YES → event_handler or score block
NO ↓
Is the problem about enabling or disabling devices/scoring?
(flipper disable on tilt, scoring off during attract)
YES → device_control block (see Game-Passive Modes doc)
Capability Matrix
| Capability | signal | event_handler | device_control |
|---|---|---|---|
| Detect switch sequences | Yes | No | No |
| Detect timing windows | Yes | No | No |
| Detect combo chains | Yes | No | No |
| React to named events | No (emits them) | Yes | No |
| Award points | Yes (inline) | Yes | No |
| Start / end a mode | No | Yes | No |
| Emit events | Yes (on_complete.emit) | Yes | No |
| Chain to another signal | Yes (fire_signal) | No | No |
| Enable / disable devices | No | No | Yes |
| Suppress scoring rules | No | No | Yes |
| Suspend a module | No | No | Yes (suppressed_by) |
Canonical Example: Orbit Shot
A left orbit shot requires detecting three switches in order within a time window, then awarding points and notifying game logic. The correct layering:
# Layer 2 — Signal detects the hardware pattern
signal "shot" "left_orbit" {
switches = [device.orbit_entry, device.orbit_middle, device.orbit_exit]
time_window = "2s"
# Emits "shot.left_orbit.complete" automatically on completion
}
# Layer 3 — Event handler reacts to the named event
event_handler "orbit_scored" {
when = "shot.left_orbit.complete"
actions {
points = 1000
increment = "orbit_count"
emit = "orbit_made"
}
}
# Layer 3 — Separate score rule can also react
score "orbit_bonus" {
when = "shot.left_orbit.complete"
points = "500 * var.orbit_multiplier"
condition = "mode.multiball.active"
}
The signal handles when the shot happened (pattern + timing). The event handler handles what to do about it (points, state, notifications). Neither block could substitute for the other.
Anti-Pattern: Simulating Sequences in Event Handlers
# BAD — event_handler cannot track partial sequence state
event_handler "fake_orbit" {
when = "device.orbit_entry.activated"
actions {
# No way to then wait for orbit_middle and orbit_exit
# within a time window. This approach requires external
# variables and timers and still can't express the
# sequence constraint declaratively.
emit = "orbit_maybe_started"
}
}
event_handler blocks fire once per event and have no memory of prior firings. Sequence detection — where event A must be followed by event B within N seconds — is exactly what signal blocks exist for.
Anti-Pattern: Device Inhibition in Event Handlers
# BAD — bare emits with no backed receiver
event_handler "tilt_happened" {
when = "tilt"
actions {
emit = "flippers_disable" # who handles this?
emit = "scoring_disable" # scattered contract
}
}
Bare emit-based inhibition scatters the contract across multiple files and provides no owner-tracking — if two modes both “disable” flippers, the second mode ending will inadvertently re-enable them even while the first mode is still active. Use device_control instead:
# GOOD — declarative, owner-tracked, automatic rollback
module "tilt" {
variable "bool" "tilted" { initial = false; scope = "ball" }
device_control {
condition = "var.tilted"
target "device" {
select { tags = ["player_controlled", "autofire"] }
apply { enabled = false }
}
target "scoring" {
select { tags = ["*"] }
apply { enabled = false }
}
}
}
When var.tilted becomes true, inhibition activates automatically. When it resets (ball scope), inhibition releases. Multiple concurrent inhibitors are tracked separately — no accidental re-enabling.
When to Use emit vs fire_signal
Inside a signal’s on_complete block (or an event handler’s actions), two fields can activate other parts of the system:
| Field | Routes to | Use when |
|---|---|---|
emit | Layer 3 — event handlers and score blocks | Notifying game logic that something happened |
fire_signal | Layer 2 — signal processing layer | Programmatically completing another signal and triggering all its handlers |
on_complete {
emit = "shot.scoop.made" # event_handlers react to this
fire_signal = "mystery_award" # mystery_award signal completes with full scoring
points = 500
}
Use fire_signal when you want a named signal to complete as though its hardware conditions were satisfied — including its own scoring rules and chained combos. Use emit when you only want event handlers to be notified.
Summary
- Detect hardware patterns (sequence, timing, combo) →
signal - React to named events with side effects →
event_handlerorscore - Enable or disable devices / scoring declaratively →
device_control - Notify other event handlers from a signal →
on_complete { emit = "..." } - Trigger another signal programmatically →
on_complete { fire_signal = "..." }
See also: Game-Passive Modes & Device Control for the full device_control / suppressed_by reference.