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:

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:

MechanismTargetEffectReversible
conflicts_withMode modulesPrevents activationN/A
suppressed_byInfrastructure modulesPauses handlers/scoring/device_controlYes — resumes when suppressors leave
device_controlDevices / scoringInhibits outputYes — 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:

  1. Assembly definitiontags on the assembly block
  2. Instancetags on the use block
  3. Devicetags on individual device blocks 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:

Stateattracttiltstop_and_goinsert_initialsbase
Pre-gameActive — inhibits devicesActive, var.tilted=falseInactiveInactiveInactive
GameplaySuppressed by baseActive, var.tilted=falseMaybe activeMaybe activeActive
TiltSuppressed by basevar.tilted=true → inhibits devices+scoringEndsEndsActive
Stop-and-goSuppressed by baseActiveActive → inhibits flipper coilsInactive (conflicts)Active
Insert-initialsSuppressed by baseActiveInactive (conflicts)Active → inhibits flipper coilsActive
Game overResumesvar.tilted resets on new ballInactiveInactiveInactive

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