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

Capabilitysignalevent_handlerdevice_control
Detect switch sequencesYesNoNo
Detect timing windowsYesNoNo
Detect combo chainsYesNoNo
React to named eventsNo (emits them)YesNo
Award pointsYes (inline)YesNo
Start / end a modeNoYesNo
Emit eventsYes (on_complete.emit)YesNo
Chain to another signalYes (fire_signal)NoNo
Enable / disable devicesNoNoYes
Suppress scoring rulesNoNoYes
Suspend a moduleNoNoYes (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:

FieldRoutes toUse when
emitLayer 3 — event handlers and score blocksNotifying game logic that something happened
fire_signalLayer 2 — signal processing layerProgrammatically 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

See also: Game-Passive Modes & Device Control for the full device_control / suppressed_by reference.