Game-Passive Modes & Device Control
Game-Passive Modes & Device Control
This document covers the design of game-passive modes (attract, tilt, stop-and-go, insert-initials) and the unified target/select/apply control primitives that power declarative device inhibition in Cade configurations.
The Inhibition Problem
Pinball machines regularly need to shut off devices or scoring during non-gameplay states — attract, tilt, initial entry. Without a declarative mechanism, each such state emits bare custom events (flippers_disable, scoring_disable) that require matching handlers elsewhere. This scatters the inhibition contract across multiple files and makes it invisible at the declaration site of the module that owns it.
The prior tilt example in infrastructure-module.cade illustrated this directly:
event_handler "tilt_penalty" {
when = "tilt"
actions {
emit "flippers_disable" # no backing spec — who handles this?
emit "scoring_disable" # same problem
}
}
The target/select/apply Pattern
The target block is the primitive for addressing game entities by category. It has three forms — device, module, scoring — each with a select block for tag-based matching and an apply block for the action:
target "device" {
select { tags = ["player_controlled", "autofire"] }
apply { disable }
}
target "scoring" {
select { tags = ["*"] }
apply { suppress_scoring }
}
Select semantics: An entity matches if it carries any of the listed tags. Globs are supported ("auto*" matches any tag beginning with auto). Matching is OR-logic across the list.
Apply semantics: Any action that normally takes a named device/module/scoring target can appear in apply. The runtime expands the tag match and applies the action to each matched entity.
A target block can appear:
- Imperatively — inside an
actions {}block in an event handler. Fires once when the event fires. - Declaratively — inside a
device_control {}block. Fires automatically on mode activation or condition change.
device_control Blocks
device_control groups target declarations into a lifecycle-bound unit. There are two placement contexts:
Mode-scoped
Placed inside a mode {} block. The runtime applies inhibition when the mode starts and releases it when the mode ends:
module "stop_and_go" {
mode {
priority = 800
device_control {
target "device" {
select { tags = ["flipper"] }
apply { disable }
}
}
events {
on device.left_flipper__button.activated { emit "menu_select_left" }
on device.right_flipper__button.activated { emit "menu_select_right" }
}
}
}
The flipper coils are disabled for the duration of the mode. The button switches — which carry "player_controlled" not "flipper" — remain active for menu navigation.
Module-level with condition
Placed at the module level (outside any mode block) with an optional condition expression. The runtime re-evaluates the condition whenever referenced variables change:
module "tilt" {
variable "bool" "tilted" {
initial = false
scope = "ball"
}
device_control {
condition = "var.tilted"
target "device" {
select { tags = ["player_controlled", "autofire"] }
apply { disable }
}
target "scoring" {
select { tags = ["*"] }
apply { suppress_scoring }
}
}
}
When var.tilted becomes true, inhibition applies automatically. When var.tilted resets to false (ball scope — resets between balls), inhibition releases. No emit "flippers_disable" needed.
Owner-Tracked Inhibition
Each controllable entity (device, scoring rule) maintains an inhibition owner set. Inhibition is active as long as any owner holds it; it releases only when the set is empty.
left_flipper__main_coil.inhibition_owners = {
"tilt.device_control",
"stop_and_go.device_control"
}
If both tilt and stop-and-go inhibit the same flipper coil simultaneously, ending stop-and-go does not re-enable the coil — tilt still owns it. Only when both release does the device become active. This prevents accidental re-enablement when multiple passive modes overlap.
Module Suppression (suppressed_by)
Infrastructure modules (those without a mode block) can declare suppressed_by to name mode modules that suspend them when active:
module "attract" {
suppressed_by = ["base"]
device_control {
target "device" {
select { tags = ["player_controlled", "autofire"] }
apply { disable }
}
}
event_handler "handle_start_button" {
when = "device.start_button.activated"
condition = "var.credits > 0 || var.free_play"
actions { emit "game_start" }
}
}
When base activates, attract’s event handlers stop firing and its device_control inhibition pauses — devices re-enable. When base ends, attract resumes and re-inhibits. The transition is automatic with no explicit enable/disable wiring.
Suppression is distinct from stacking conflicts:
| Mechanism | Target | Effect | Reversible |
|---|---|---|---|
conflicts_with | Mode modules | Prevents activation | N/A |
suppressed_by | Infrastructure modules | Pauses handlers/scoring/device_control | Yes — resumes when suppressors leave |
device_control | Devices / scoring | Inhibits output | Yes — per owner release |
Assembly-Level Tag Propagation
For device_control tag targeting to work consistently, every instance of the same hardware type must carry the same tags. Tags on expanded devices are the union of three sources:
- Assembly definition —
tagson theassemblyblock - Instance —
tagson theuseblock - Device —
tagson individualdeviceblocks inside the assembly
assembly "flipper" {
tags = ["player_controlled", "flipper"]
device "switch" "standard" "button" {
id = param.button_switch_id
tags = ["active_switch"]
# expanded tags: ["player_controlled", "flipper", "left_side", "active_switch"]
}
device "coil" "flipper" "main_coil" {
id = param.main_coil_id
# expanded tags: ["player_controlled", "flipper", "left_side"]
}
}
use "flipper" "left_flipper" {
button_switch_id = 0x11
main_coil_id = 0x01
tags = ["left_side"]
}
use "flipper" "right_flipper" {
button_switch_id = 0x12
main_coil_id = 0x02
tags = ["right_side"]
}
Both expanded flippers carry the assembly tags automatically. A device_control targeting ["flipper"] disables both main coils without any per-instance configuration. Instance-level tags like "left_side" let device_control rules target a specific instance when needed.
Duplicates across the three sources are deduplicated in first-occurrence order. Tags are strictly additive — instance and device tags cannot remove tags inherited from the assembly definition.
Passive Mode Lifecycle
The passive modes form a coordinated system:
| State | attract | tilt | stop_and_go | insert_initials | base |
|---|---|---|---|---|---|
| Pre-game | Active — inhibits devices | Active, var.tilted=false | Inactive | Inactive | Inactive |
| Gameplay | Suppressed by base | Active, var.tilted=false | Maybe active | Maybe active | Active |
| Tilt | Suppressed by base | var.tilted=true → inhibits devices+scoring | Ends | Ends | Active |
| Stop-and-go | Suppressed by base | Active | Active → inhibits flipper coils | Inactive (conflicts) | Active |
| Insert-initials | Suppressed by base | Active | Inactive (conflicts) | Active → inhibits flipper coils | Active |
| Game over | Resumes | var.tilted resets on new ball | Inactive | Inactive | Inactive |
Tilt during stop-and-go
If a tilt occurs during stop-and-go, both device_control blocks own the flipper coils simultaneously:
left_flipper__main_coil.inhibition_owners = {
"stop_and_go.device_control",
"tilt.device_control"
}
Stop-and-go ends (round timeout) → its ownership releases → coil remains disabled (tilt still owns). Ball drains → var.tilted resets → tilt releases → coil re-enables on next ball.
Design Reference
Full specification: DESIGN/modules/05-game-passive-modes.md
Long-form example with scenario walkthroughs: DESIGN/modules/examples/passive-mode-patterns.cade