Event Aggregation

events aggregation scoring spinner

Event Aggregation

Event aggregation lets you score on patterns of events rather than individual hits — bumper bursts, cross-device “hot playfield” moments, and spinner rate tiers. Aggregation signals flow through the same pipeline as any other signal and can be consumed in score "signal" blocks, event_handler blocks, and conditions.

Three aggregation primitives are available: burst, coincidence, and rate. Each is declared at the top level of a .cade file.

Burst — Rapid Hits on a Device or Tag

Fires when a device (or tagged group of devices) produces a threshold number of events within a tumbling time window.

aggregation "burst" "rapid_bumper" {
  devices   = [device.bumper_top, device.bumper_left, device.bumper_right]
  window    = "2s"
  threshold = 5
  cooldown  = "3s"

  on_trigger {
    emit = aggregation.rapid_bumper.burst
    data = {
      count   = aggregation.count
      rate_hz = aggregation.rate_hz
    }
  }
}

score "signal" "rapid_bumper_bonus" {
  when   = aggregation.rapid_bumper.burst
  points = var.bumper_cluster_bonus
}

Known issue in v0.2.0: a scoring rule cannot read the fields a signal carries in its data — signal.count and event.count are not available to points or condition, and a rule that references either awards nothing. Award a flat bonus per burst, as above; the burst fires once each time threshold is reached, so size the bonus with the threshold in mind.

AttributeDescription
devicesList of device references (device.name) or tag expressions (tag.name), written bare.
windowTumbling window anchored to the first event (e.g., "2s", "200ms").
thresholdEvent count that triggers the signal.
cooldownSuppresses re-firing for this duration after a trigger. Optional.
on_triggerBlock specifying what signal to emit when the threshold is met.

Coincidence — Multiple Device Groups Active Together

Fires when every named device group registers at least one activation inside the shared window.

aggregation "coincidence" "hot_playfield" {
  device_groups = [
    { name = "bumpers",  devices = [device.bumper_top, device.bumper_left, device.bumper_right], min_count = 1 },
    { name = "ramps",    devices = [device.ramp_left, device.ramp_right],   min_count = 1 },
    { name = "targets",  devices = [device.target_1, device.target_2, device.target_3], min_count = 1 }
  ]

  window = "5s"

  on_trigger {
    emit = aggregation.hot_playfield.coincidence
    data = {
      device_count = aggregation.device_count
    }
  }
}

score "signal" "hot_playfield_bonus" {
  when   = aggregation.hot_playfield.coincidence
  points = 25000
}

Each device group object supports these fields:

FieldDescription
nameLabel for the group (appears in trigger data).
devicesDevice references or tag expressions for this group.
min_countMinimum activations required from this group (default 1).

Use coincidence for chaos detection: “bumpers AND ramps AND targets all saw action within 5 seconds.”

Rate — Tiered Event Density

Tracks a smoothed events-per-second rate and emits a signal each time the current tier changes. Use this for escalating multipliers on sustained activity such as spinner frenzies.

aggregation "rate" "spinner_speed" {
  devices = [device.spinner]
  window  = "1s"

  rate_tiers {
    tier {
      min_rate   = 0
      label      = "idle"
      multiplier = 1.0
    }
    tier {
      min_rate   = 5
      label      = "active"
      multiplier = 1.5
    }
    tier {
      min_rate   = 15
      label      = "fast"
      multiplier = 2.5
    }
    tier {
      min_rate   = 25
      label      = "frenzy"
      multiplier = 5.0
    }
  }

}

event_handler "spinner_fast" {
  when = aggregation.spinner_speed.fast
  actions {
    set_variable {
      name  = "spinner_frenzy_multiplier"
      value = 250
    }
  }
}

event_handler "spinner_frenzy" {
  when = aggregation.spinner_speed.frenzy
  actions {
    set_variable {
      name  = "spinner_frenzy_multiplier"
      value = 500
    }
  }
}

The rate_tiers block contains one or more tier blocks, each with:

FieldDescription
min_rateMinimum events-per-window to enter this tier. Tiers are sorted ascending automatically.
labelName for the tier. Entering the tier emits aggregation.<name>.<label> — aggregation.spinner_speed.frenzy above.
multiplierMultiplier associated with this tier, carried on the tier’s signal.

Each tier has its own signal, so react to a tier by naming it in when, one handler per tier you care about. There is no tier namespace in expressions, and an event_handler’s set_variable value can’t read the signal’s fields, so write each tier’s value as a literal, as above. An on_tier_change block is accepted for compatibility, but its emit and data are ignored — the signal name always comes from the tier’s label.

Rate signals don’t fire yet in v0.2.0. cade run emits a rate tier’s signal without its name, so no handler or scoring rule can match it. Burst and coincidence signals are unaffected.

Tier transitions include hysteresis — downgrades require the rate to drop about 10% below the tier’s min_rate before the tier changes. This keeps rules stable when rates hover at a boundary.

Cooldown Behavior

cooldown inhibits re-firing for a fixed duration. During cooldown, the rule still tracks state but does not emit.

PrimitiveWhat happens during cooldown
BurstEvents continue accumulating. If the count still exceeds the threshold at cooldown expiry, it fires again with the accumulated count.
CoincidenceBitmask resets on trigger. A new coincidence pattern must form after cooldown expires.
RateRate keeps updating. If the tier differs from the pre-cooldown tier at expiry, a tier change fires.

Combining Aggregations with Other Scoring

Aggregation signals do not replace per-event scoring — they layer on top of it. A typical bumper scoring stack uses three layers:

  1. Per-hit scoring — a normal score "event" rule awards base points for each bumper activation.
  2. Burst bonus — aggregation "burst" awards a cluster bonus when rapid hits cluster.
  3. Rate tracking — aggregation "rate" tiers the sustained bumper rate and adjusts var.bumper_cluster_bonus via an event_handler.

Aggregation signals can also be used as conditions on unrelated scoring rules. For example, only award an orbit bonus when the playfield is “hot”:

score "signal" "hot_orbit_bonus" {
  signal    = shot.left_orbit.complete
  condition = signal.aggregation.hot_playfield.active
  points    = 5000
}

Known issue in v0.2.0: a scoring condition cannot read signal.*, so the rule above never awards. Until a patch release fixes this, have an event_handler on aggregation.hot_playfield.coincidence set_variable a flag, and test that variable (var.<name>) in the condition instead.

See Signal vs Event Handler for the broader rules on consuming signals.