Driven Devices

Driven Devices

Coils, motors, servos, drop targets, and stand-up targets share more than they differ. They all have the same identity needs (a stable id, grouping tags, a description, replacement part number, service notes), the same runtime surface (namespaced events, state bindings, an optional audio block), and they often want to react to inputs without consulting the scoring engine. Cade’s driven-device layer formalizes this shared ground so these devices can be declared, referenced, and scripted consistently.

This page walks through the four things the layer gives you:

Shared Identity

Every device block — regardless of category — accepts the same identity fields:

FieldPurpose
idHardware address used by the platform driver
tagsGrouping labels for bulk references (tag.drop_bank.hit, etc.)
descriptionHuman-readable summary — appears in the TUI and in error messages
locationOptional playfield coordinates and named area
part_numberReplacement part for service
notesFree-form service notes surfaced in the TUI device panel
enabledWhen false, the declaration stays but inputs and actions are ignored

Because every driven device carries the same identity, you can bulk-address them with tags, filter them in the TUI by location, or temporarily disable a broken assembly with enabled = false without having to delete or comment out config.

Mechanical Target Subtypes

Drop targets and stand-up targets are both “the ball hit a small piece of metal” from the electrical perspective — they’re both switches — but they behave very differently in a game. Drop targets fall when hit and need a coil to stand back up; stand-ups stay upright indefinitely. The driven-device layer splits them into explicit subtypes so the config expresses the intent directly.

switch "drop" — Falling Targets

A drop target declares its own reset behavior as a first-class sub-block:

device "switch" "drop" "drop_center" {
  id = 0x51

  reset {
    coil        = device.drop_reset_coil
    pulse_ms    = 30
    debounce_ms = 50
  }
}

Behind that declaration is an implicit state machine with four states: up → hit → resetting → up. The runtime emits events at every transition:

The rested event is the one most scoring rules care about, because firing a reset coil and actually having the target back upright are two different things — a debounce window is needed before awarding bank-complete bonuses or starting new shot sequences.

switch "standup" — Upright Targets

Stand-ups are hit-only. No reset coil, no state machine:

device "switch" "standup" "standup_3" {
  id   = 0x63
  tags = ["standup_bank"]
}

They emit device.<name>.hit. Arrays of stand-ups typically share a tag so a single handler can score the whole bank:

score "event" "standup_hit" {
  when   = tag.standup_bank.hit
  points = 500
}

Servo Positions

Cade’s core principle is that .cade files stay driver-agnostic — nothing in a table config should have to know whether the hardware is a FAST board, a Visual Pinball bridge, or a gRPC test harness. Servos challenge that principle because “move to 90 degrees” is a pulse-width setting on one driver and a step count on another.

The positions {} block solves this by letting the config assign symbolic names to abstract scalar positions:

device "motor" "servo" "left_diverter" {
  id = 0x80

  positions {
    home = 0
    up   = 90
    down = -45
  }
}

Game logic references positions by name:

move_servo {
  device   = device.left_diverter
  position = "up"
}

Each driver is responsible for mapping the scalar value to its own physical representation. If you later swap a FAST servo for a Visual Pinball stub, the positions stay put — only the driver’s mapping changes.

Servos emit a motion-aware event family: moved (motion started), moving (continuous while in motion), reached (arrived at target), and state_changed (any position transition). The event’s state field carries the position name.

Behaviors: Config-Compile-Time Reflexes

Some reactions need to happen immediately: a pop bumper auto-firing its coil, a drop target resetting after a bank is cleared, a servo parking when a mode ends. You could write scoring rules for all of these, but two concerns show up:

The behavior {} sub-block declares these reactions alongside the device that owns them:

device "switch" "bumper" "pop_bumper_1" {
  id = 0x40

  behavior "auto_fire_coil" {
    when        = device.pop_bumper_1.active
    action      = pulse_coil { device = device.pop_bumper_1_coil, pulse_ms = 20 }
    cooldown_ms = 100
  }
}

At config-load time the compiler recognizes this shape and lowers it to an autofire-style rule the platform driver can run locally. Once lowered, the pop bumper fires its coil without involving the scoring engine at all — the reaction runs at hardware speed, and the scoring engine is free to work through the rest of the frame.

This lowering happens once, at config compile time, not per-tick. The runtime cost of adding more behaviors is the cost of adding more driver-local rules — the scoring hot path is untouched. The performance budgets Cade guarantees elsewhere (50–100 Hz event tick, 30 Hz light tick, <200 ns runtime binding resolve, <1 ms scoring) are preserved because lowered behaviors never enter those loops.

Not every behavior is eligible for hardware lowering. Some reference runtime variables or scoring state that the platform driver cannot see at compile time. In that case, the compiler falls back to running the behavior through the same handler pipeline that powers scoring actions — the behavior still fires, just at event-tick latency instead of hardware speed. Hardware-lowered behaviors are the fast path; software-fallback behaviors are the safety net, so a behavior {} block is never silently ignored.

Action Verbs

behavior {} blocks and event handlers both produce side effects through the same driver-agnostic action verbs:

VerbWhat it does
pulse_coilFires a coil for a bounded duration
move_servoCommands a servo to a named positions {} entry
set_stateTransitions a stateful device (e.g., drop-target FSM)
trigger_behaviorFires a named behavior block on demand

See the Device API reference for the full verb syntax.

Relationship to Lights

Lights share the identity layer — every device "light" "..." gets the same id/tags/description/location/part_number/notes/enabled surface. They do not share the behavior {} sub-block; light behavior is already handled by the dedicated light-show compiler, which has its own declarative surface optimized for the 30 Hz light tick. Think of the driven-device layer and the light-show compiler as siblings: both lower declarative game-config into driver-local reflexes, each tuned for its own hot path.

When to Reach for What

You want to…Reach for…
Award points when the ball hits somethingA score "event" { … } rule
React to a hit with a coil pulse that must be immediateA behavior { action = pulse_coil { … } }
Move a servo to a known position from game logicmove_servo { device = …, position = "…" }
Bank-complete bonuses that wait for the drop to re-restA scoring rule on device.<name>.rested
Bulk-score a whole target bankA shared tag plus when = tag.<tag>.hit
Temporarily remove a broken device from playSet enabled = false on the device