engine/player/states

PURPOSE

Player status-effect taxonomy. Owns the single exclusive-state slot on ShipState (currently used only by starpower) and the small apply/query/tick API that other systems hit to grant a state, gate behavior on it, and age the timer one sim frame at a time. The module is a pure mutator over fields on the ship value passed in — no globals, no signals, no side effects on other systems.

OWNS

  • The two-category model: a single exclusive-state slot per ship that times out and clears, alongside the convention that additive states live as independent boolean fields on ShipState with no central registry.
  • The PlayerExclusiveState string-union — the canonical list of named exclusive states. Today it has one member, 'starpower'.
  • The stacking rule for same-state regrants: timer is incremented by the new duration and the peak high-water mark is bumped if the new total exceeds the previous peak.
  • The replace rule for different-state grants: state name, timer, and peak are overwritten in one shot. Unreachable today because the union has one member; left as the contract for the second exclusive state.
  • The tick semantics: decrement timer by dt clamped at zero, then on expiry null the slot, zero the timer, and zero the peak.
  • The peak-timer field used as the UI fill denominator so a depleting HUD bar stays smooth across regrants without consumers having to remember the original grant duration.

READS FROM

  • engine/core/typesShipState type, specifically the exclusiveState, exclusiveStateTimer, and _exclusiveStateMaxTimer fields.

PUSHES TO

  • Nothing. The module does not call into other engine modules, does not fire signals, does not write to any global, and does not touch any field on ShipState other than the three exclusive-state fields. All observable consequences of being in an exclusive state are applied by consuming systems reading the state through hasExclusiveState.

DOES NOT

  • Define what an exclusive state actually does. Damage multiplier, invulnerability sync, thrust and max-speed bumps, heat lockout, rainbow-chrome shader, and rainbow particle trail are applied by the consuming systems — combat damage, physics movement, rendering draw, weapons fire, HUD bar — reading the state via the predicate.
  • Decide when to grant a state. Event-system sub-events and shooting-star pickup rewards live in their own systems and call in through applyExclusiveState; nothing in this file triggers a grant.
  • Own additive (boolean) status effects. Fields like ship.stalled are plain booleans written and read directly by the systems that care; this module neither registers them nor ticks them.
  • Sync ship.invulnerable, the rainbow shader, or any other observable side-effect of being in Star Power. Consumers read the state and apply their own effects.
  • Drive UI directly. The HUD reads the timer and peak fields to fill its bar; this module only maintains those fields.
  • Validate or clamp the duration argument. Repeated grants extend without bound — a cap, if needed, is the caller’s job.

Signals

None fired, none watched. The module is a synchronous helper API — no engine signal traffic in either direction.

Entry points

  • applyExclusiveState(ship, name, addDurationSec) — grant or extend an exclusive state. When the ship already has the same state active, the timer is incremented by addDurationSec and the peak high-water mark is raised to match if the new total exceeds it. When the ship is in a different exclusive state (or none), the slot is overwritten with the new name, the timer is set to addDurationSec, and the peak is reset to the same value. Called by the event system to grant the 15-second Star Power sub-event and by the leveling reward system to grant the 60-second shooting-star reward.
  • hasExclusiveState(ship, name) — predicate. Returns true when the named state is active and the timer is positive. Used by physics, combat, weapons, rendering, and HUD to gate behavior on Star Power being active.
  • tickPlayerStates(ship, dt) — once-per-sim-frame timer decrement. Early-returns when no exclusive state is set. Otherwise decrements the timer by dt clamped at zero, then on expiry nulls the slot, zeroes the timer, and zeroes the peak. Called from physics movement’s invulnerability update.

Pattern notes

  • Asymmetric design across the two categories is deliberate. Exclusive states get a single shared slot plus a string-union name because they compete for the slot and need to replace each other cleanly. Additive states are intentionally just booleans on ShipState with no registry because they don’t compete — adding a new additive state is just adding a new field.
  • The replace branch in applyExclusiveState is unreachable today since the union has one member. It is left in place as the documented contract for when a second exclusive state lands, so consumer call sites don’t have to be revisited.
  • The peak-timer field is a UI affordance baked into the state model. Without it, the HUD bar would jump on every regrant because the denominator would shift; with it, consumers render timer / peak and the bar refills smoothly when a same-state grant stacks duration. The module clears the peak on expiry so a fresh grant starts the bar at full.
  • Functions take the ship by parameter rather than reading a singleton. The same helpers would work for any future ship-shaped entity without a refactor.
  • The timer-then-clear order in tickPlayerStates matters. The decrement runs to zero in the same frame the state expires, then the next frame’s early-return short-circuits — so consumers see one frame of “timer at 0 with state still set” only between the decrement and the cleanup branch within the same call.
  • Direct field reads and writes on ShipState rather than an opaque accessor — the module is small enough that consumers can also read ship.exclusiveStateTimer directly for the HUD bar fill, and that is the chosen pattern in rendering/hud.