# jgengine domain API — Combat

Reference module for the [`jgengine-combat` API](SKILL.md) skill. Load this when you need the combat surface — effects, projectiles, death, feel, and abilities.

## Portable named resources

`StatPool` (`@jgengine/core/stats/statPool`) is the plain
`{ current, max, min }` model for any caller-named bounded resource. Existing
projects implement the two-method `StatPoolAccess` over their own ECS, reducer,
or save model, then use `applyStatPoolDelta`; reducer-style integrations use
the pure `createStatPool` / `patchStatPool` / `changeStatPool` transitions.
Native scene entity stats are a compatibility bridge over the same model. See
the [portable stat-pool recipe](recipes/portable-stat-pools.md) for complete
imports, adapter code, ownership, and JSON save/restore.

## Portable runtime snapshots

`createMagazine` (`combat/magazine`) and `createStats`
(`stats/statModifiers`) retain their convenience APIs while exposing detached
plain state through `snapshot()` / `restore(...)`. Magazine snapshots include
loaded rounds, in-progress reload time, and finite/infinite reserve state;
stat snapshots include base values plus ordered modifier sources and absolute
expiry timestamps. Re-supply immutable config and the caller's injected clock
when rebuilding. The [portable runtime-state recipe](recipes/portable-runtime-state.md)
shows a complete existing-save round trip, including a caller-owned reserve.
`createEffectSystem` (`combat/effects`) commits direct or area effects through
the same `StatPoolAccess` contract. Receive rules still define deterministic
shield/resource spill order, modifiers, and the final lethal pool; results
carry exact applied deltas and lethal context. The legacy `resolveStats` input
remains as a compatibility bridge, while native `GameContext` now supplies its
`EntityStatsApi` adapter. Compose this committer with pure `resolveDamageHit`
(matchup/received/status provenance) and `resolveDamage` (ordered bounded
interceptors). See the [portable damage/effects recipe](recipes/portable-damage-effects.md).

## Effects and projectiles

Cold projectile restore requires the receiving owner's pellet limit and radius-aware sweep to support the captured launch. Incompatible or inconsistent pellet, cone-sample and flight counts reject atomically; never truncate saved flights or discard pending settlements.

Effect ids are **game-defined strings**. Magnitudes **drain** stats: positive subtracts down `receive.<effect>.order` (spilling to the next stat in the order), negative restores. Heals pass a negative amount (`via: { amount: -flashHeal }`, typically read from a `weapon.heal` stat).

```ts
ctx.scene.entity.canReceive(instanceId, effect, magnitude?)  // null | reason — reads catalog receive
ctx.scene.entity.preview({ from, to, effect, via })      // magnitude, no state change
ctx.scene.entity.effect({ from, to, effect, via })                          // single target
ctx.scene.entity.effect({ from, effect, via, at, radius, falloff?, los? })  // AoE at a point
```

AoE: `inRadius(at, radius)` → LoS filter (default on) → `canReceive` per target → absorption; `falloff: "linear" | "none"`. `via` = `{ item }` (magnitude from weapon stats) or `{ amount }`. `canReceive`'s `pools-depleted` reason checks headroom in the effect's direction: a positive (draining) magnitude needs a stat above its min, a negative (restorative) magnitude needs a stat below its max — so a heal can still raise a stat sitting at its minimum. Omitting `magnitude` assumes the draining direction.

Each per-target `EffectResult` carries `{ instanceId, effect, applied, lethal }`. On a **lethal** hit it additionally carries `slain: { catalogId, name?, userId? }` — the victim's identity captured **before** the death system despawns it, so kill credit / XP reads the slain kind straight off the call result without a spawn-time id→kind registry (a non-lethal hit omits `slain`). This is the synchronous complement to the `entity.died` event: use `slain` when the code awarding the kill is the same code that fired the effect; use `entity.died` for decoupled listeners (quests, feeds, leaderboards).

Every draining application also raises one `combat.damage` (`DamageAppliedEvent`) on `ctx.game.events` per target it lands on: `source` is the effect's `from`, `type` is the effect id, `amount` is the total drained, `absorbed` is the incoming magnitude the receive modifiers and depleted pools stopped, and `remaining`/`lethal` read the **last** stat in the receive order — the pool whose exhaustion counts as death. A restorative application (`via: { amount: -n }`) raises no damage event.

`canReceive`'s optional signed `magnitude` makes the check direction-aware: positive (or omitted) keeps the standard `"pools-depleted"` check; negative checks the opposite direction and returns `"pools-full"` only when every stat in the receive order is already at max — so a heal (`via: { amount: -n }`) correctly reaches a fully-depleted target and is rejected only when there's nothing left to restore.

**Projectiles** (aim-based — no target ids):

```ts
willHitProjectile({ from, via, aim, effect })   // prediction only, for crosshair UI
fireProjectile({ from, via, aim, effect })      // → shotId (pending)
settleProjectile(shotId)                        // authoritative → { at, hits } | rejection
```

`Aim = { origin, direction } | { yaw, pitch, spread? }`. A free `{ yaw, pitch }` aim defaults to `converge`: the bullet leaves the gun `muzzle` (so a tracer visibly comes from the barrel, not the camera) yet bends toward whatever the eye ray's crosshair covers, so the reticle stays truth. An explicit `{ origin, direction }` aim (enemy AI, turrets) passes through unchanged. Shot origin otherwise resolves via `ShotOriginPolicy` (`eye`: shooter position raised to eye height — 90% of the shooter's own hitbox top when colliders are known, ~1.6m otherwise — so shots trace the sightline and the crosshair is truth; `muzzle`/`camera`/`world` for custom rigs, `legacy` = raw entity position). Hitboxes auto-fit the rendered model: an entity kind mapped in `entityModels` (or an object catalog id in `objectModels`) gets a grounded AABB matching its model's measured bounds — footprint x height composed with `scale`/`targetHeight` exactly like the renderer — so the damage box equals the visual without hand-tuning. Custom-rendered content auto-fits too: the shell measures what `renderEntity`/`renderObject` actually mount (and any model with no index `dims`) and reports the entity-local bounds through `ctx.scene.entity.reportBounds` / `ctx.scene.object.reportBounds`, so a procedural mesh's hitbox wraps the render instead of the humanoid default — flag debug gizmos with `userData.jgMeasureExclude` to keep them out of the measurement, and note the measurement happens on the rendering client, so a never-rendering authoritative host should author `colliders` or model `dims` instead. Entities with nothing measurable at all keep the body-covering humanoid AABB default (feet to ~1.8m); unmodeled objects with a `visual.scale` get a matching grounded physical body. Catalog assets opted into `COLLISION_MESH_ASSET_IDS` (`@jgengine/assets`) go one step further: the fit raycasts the asset's actual triangles (BVH over a quantized mesh shipped in the index), so a shot through a torus hole or archway passes clean instead of hitting the fitted box — same shape on client and headless host. Explicit catalog `colliders`/`halfExtents` and per-instance `setColliders` always override the fit. Hitscan settles into per-hit effects; ballistic shots (`weapon.projectile` with `fuseTime`/`settleOn`) settle to a landing point — the handler then calls `effect({ at: settle.at, radius })`. Settling twice rejects. Prediction is never authority.

Raycasts are **object-aware**: the default raycast checks placed scene objects as well as entities, discriminated by `RaycastHit.kind` (`"entity" | "object"` — an `ObjectRaycastHit` also carries `catalogId`). A crate or wall between shooter and target blocks or absorbs the shot instead of every projectile passing through scenery; supply `ProjectileSystemDeps.objects` (`{ list(), halfExtents?(catalogId) }`, matching `ObjectStore.list()` structurally) to opt in, with a `[0.5, 0.5, 0.5]` half-extent default per object.

`defineGame({ projectileTravel })` accepts `GameProjectileTravelOptions` from
`@jgengine/core/game/defineGame`: the existing `maxActive`, `maxRetained` and
`maxTargets` budgets plus `sweep(ctx, from, to, step)`,
`acceleration(ctx, position, velocity, time, input)` and `targets(ctx)`.
Each callback receives the live context of that world, including when one
definition hosts multiple worlds; the authoritative clock remains engine-owned.
Supply a radius-aware `sweep` for finite-radius travel through authored cover.
The default cover query supports radius zero and rejects larger radii.
`targets` selects bounded entity IDs for the default moving-target query;
omission selects that world's entities, and exceeding `maxTargets` (default
2048) throws instead of dropping blockers. A custom sweep owns its complete
collision policy, including cover and receivers.
Supplied `acceleration` fully replaces the authored sampler; call
`ctx.environment.accelerationAt(position, time, windResponse, maxAcceleration,
forceMask)` explicitly when composing authored forces. Omitting it keeps the
authored sampler. Providers are unsaved world policy; projectile snapshot and
restore retain launch and motion state and use the receiving world's providers.

## Death

Resolved **once** by the engine when the last stat in the receive order hits min. No HP polling in `onTick`, ever.

- `entity.died` is emitted (before despawn — handlers can still read the victim's stats), then reason-matching `onDeath` entries run.
- `DeathReason = { kind: "player_kill", killerUserId, via? } | { kind: "environment", source } | { kind: "self", source }`. Local players, hosted members and uniquely owned pawns attribute automatically. `ctx.player.possession.ownerOf(entityId)` resolves recorded ownership without inventing an owner; shared ownership is ambiguous and returns `undefined`.
- `onDeath.drops` tables are rolled and **granted to the killer** on player kills (emits `loot.granted`) when `onDeath.dropMode` is `"grant"` (default); `onDeath.command` runs through `ctx.game.commands.runAs` with the resolved killer. XP commands, quest kill events and loot therefore use the same actor identity on a remote host.
- `onDeath.dropMode: "world"` routes item drops through a scatter impulse into ground `worldItem`s instead of straight to inventory (currency drops still grant directly) — tune the impulse with `onDeath.scatter: { radius, minRadius?, height? }` (defaults from `game/worldItem`'s `DEFAULT_SCATTER`).
- Non-player kills suppress runtime loot by default. To opt an item table into environmental or unowned deaths, author `onDeath: { dropMode: "world", drops: [{ table: "salvage", when: { reason: "any" } }] }`. Only explicit `"any"` tables spawn in this case; neighboring ordinary tables stay suppressed. Bag grants and currency still require a resolved player killer, including when the table uses `"any"`.
- Respawning under the same instance id revives it (it can die again). Same-id respawn must not happen synchronously inside the `entity.died` handler — defer a tick.
- `ctx.hydrate` and `ctx.restore` also revive restored entities whose terminal receive pools are above their minima. Snapshots captured inside `entity.died` still contain the victim before despawn; their depleted terminal pools retain the duplicate-death guard.
- `quest.bind("entity.died")` credits kill objectives from the same event; leaderboards and kill feeds hang off it too.

## Damage interception and threshold transitions

Two genre-agnostic seams that sit **before** lethal resolution and drive **phase** logic, so games never hand-code "crossed threshold" branches or a boss-only invuln path. Both are pure/serializable `@jgengine/core/combat` factories.

- **Interception pipeline** (`combat/damageInterceptors`) — `resolveDamage(interceptors, pending, ctx)` runs a `PendingDamage` through an ordered list; each `DamageInterceptor.intercept` returns `pass | transform | clamp | redirect | split | defer | reject`, and every decision lands in `DamageResolution.provenance` (no hidden callbacks). Split parts continue at the *next* interceptor and total work is capped by `MAX_INTERCEPT_STEPS`, so redirect/split can't recurse. `createDamagePipeline()` is the mutable install/remove chain a transition toggles (`install`/`remove`/`has`). `ctx` carries `nowMs` (deterministic clock) and optional `healthOf(target)`.
- **Reusable policies** — `createDamageClamp({ maxPerHit })` caps a hit; `createImmunityWindow(id)` is a per-target invuln window (`grant`/`grantFor`/`active`/`clear`/`clearExpired`, `snapshot`/`restore`) installed and removed by transitions — i-frames and phase invuln are ordinary policies, not a boss branch; `createAntiOneShotPolicy({ guardAboveFraction, leaveFraction, recoverMs })` is the "cannot cross lethal while above X, leave Y, recover for Z" composition (clamps the killing blow to a health floor, then arms recovery immunity).
- **Threshold crossings** (`combat/thresholdCrossings`) — `createThresholdTracker({ thresholds, trigger?, hysteresis?, policy?, initial? })` watches any numeric source (boss HP %, stamina, armor, an objective counter). `update(value)` returns ordered `ThresholdCrossing[]` with `direction` (`falling`/`rising`), honoring `trigger` filtering, a `hysteresis` dead-band, `once`/`repeat` policy, and skipped-threshold handling (a single big jump emits every mark it passed, in travel order). `snapshot`/`restore` round-trip the sides + once-ledger for save/load. Wire crossings to whatever a phase owns — installing an immunity interceptor, swapping AI, music, objectives. Non-health consumers (resource warnings) use the same tracker.
## Reward allocation (multiplayer distribution)

Generation and allocation are different operations. A loot table (`@jgengine/core/game/lootTable`) or any roll produces `RewardResult`s; `@jgengine/core/combat`'s `allocateRewards(kind, request)` then decides **who gets what** among participants — the co-op question single-player kills never ask. It is deterministic and serializable: the same seeds, recipients, and results yield a byte-identical `AllocationOutcome` on host and every peer regardless of join order, so allocation replicates as data without re-rolling.

- Policies (`kind`): `instanced` (each recipient rolls its own results from a stable per-id stream — private by default), `copy` (full copy to everyone), `shared` (weight-split one count with a conserving largest-remainder distribution), `roundRobin` (one result each in deterministic rotation), `assigned` (caller `assignment` map), `claimed` (defer results into a `ClaimablePool`).
- Keep `allocationSeed` (ordering, splits, rotation, reservation) separate from `generationSeed` (per-recipient `instanced` rolls) so a late joiner never perturbs existing players' rolls. Recipient order is seed-shuffled, never input order.
- Claims resolve authoritatively via `resolveClaim(pool, claimantId, { nowMs })` — idempotent (re-claiming your own pool re-returns the same grants), with eligibility, `reservedFor`, and `expiresAtMs` enforced by the authority, not UI hiding. `first` pools are open/contested; `reserved` pools tie to one recipient (personal quest reward).
- `filterOutcomeFor(outcome, viewerId)` is the replication filter: private grants for other recipients and pools the viewer cannot claim are dropped, so private rewards never leak. Provenance (`grant.via`) survives projection.
- Works for any reward, not just items — split XP/currency by contribution weight the same way.

## Combat feel (melee, defense, telegraphs)

Layered on top of effects/projectiles/death — none of it replaces them, it adds **feel**. All models are renderer-free pure `@jgengine/core` factories a game composes per entity (like the `ctx`-vs-factory split above); the shell renders the telegraphs, styled damage numbers, hitstop shake.

- **Animation state machine** (`combat/animationState`) is the root the rest hangs on. A `AnimationClip` is catalog data — `{ frames, fps, ranges }` where each `FrameRange` tags a window `windup | active | recovery | cancel` (cancel may overlap recovery). `createAnimationState({ clips })` gives a per-entity SM: `play(clipId)`, `tick(dt)` → `{ entered, exited, completed }`, and queries combat/defense subscribe to — `inPhase("active")`, `isActive()`, `canCancel()`, `activeWindowMs()`. Frame ranges are the "commit frame" contract for delayed/feinted attacks.
- **Attack tags** (`combat/attackTags`) — `attackMeta(["unblockable" | "thrust" | "sweep" | "grab" | …], { effect, power })`. Defense logic reads them: `isBlockable`, `isParryable`, `isDodgeable`, `counters(meta, "mikiri")`. A grab beats all defenses; an unblockable is parry/dodge-only.
- **Defensive window** (`combat/defensiveWindow`) — a parry/block/dodge with `{ startupMs, activeMs, recoveryMs, iframes }`. `resolveDefense({ config, elapsedMs, attack })` is the pure overlap of the defender's window against the moment the attacker's `active` frames land → `parry | block | iframe | hit`. `createDefensiveWindow(config)` tracks the open time (`open(now)` / `evaluate(now, attack)` / `isInvulnerable(now)`).
- **Combo strings** (`combat/comboString`) — `ComboStep`s with `cancelInto` + `cancelPhases` + optional `stance`, over the anim SM. `advanceCombo(...)` (pure) accepts the next attack only inside the current step's cancel window and matching stance; `createComboRunner(combo, anim)` drives the SM.
- **Meters share one accumulator** (`stats/accumulatorMeter`) — `createAccumulatorMeter({ max, mode: "hold" | "reset", decayPerSecond, decayDelayMs, tiers })`: fill via `add(n)` → `MeterAddResult { fired, overflow, tier, tierChanged }`, `tick(dt)` decays after an idle grace, reading its current tier off the pure `tierAt(value, tiers)` lookup. `combat/breakMeters` builds two on it: `createStaggerMeter` (mode `hold` — fills from hits, `broke()` stays true until `recover()` after a riposte/deathblow) and `createBuildupMeter` (mode `reset` — `add(n)` returns a `BuildupProc { status, durationMs }` at threshold for bleed/frost/rot, then decays). The same base backs G6's ult/streak meters.
- **Delayed-regen shield** (`combat/regenShield`) — `createRegenShield(config)` is a shield pool that pauses regen for `regenDelayMs` after every hit, then refills at `regenPerSecond`; `damage`/`tick` replace snapshot-comparing a stat to detect "took damage."
- **DoT field** (`combat/dotField`) — `createDotField()` schedules recurring damage-over-time instances (bleed/poison/burn) keyed by id; `tick` drains due damage per interval. Pairs with `combat/breakMeters`' `BuildupProc` — a fired buildup applies a DoT instead of one-shot damage.
- **Magazine** (`combat/magazine`) — `createMagazine({ capacity, reloadMs, loaded?, reserve? })` is the per-weapon mag-size/reload/reserve-pool primitive (#536.2): `fire(rounds?)` spends loaded ammo, `startReload()`/`tick(dt)` run the timed refill, `canFire`/`canReload`/`isEmpty`/`isFull`/`reloadFraction` drive the HUD. `reserve` is a plain number for a self-contained pool, a `MagazineReserve` (`{ current, spend, gain? }`) to bridge reload draws into an externally-owned pool (a shared ammo-type stat, another `ResourcePool`), or omitted for an infinite reserve.
- **Dash / dodge** (`movement/dash`) — `createDashState({ distance, durationMs, iframes, staminaCost, staminaMax, staminaRegenPerSecond, cooldownMs })`: `tryDash(dir, now)` → burst or `{ reason: "no-stamina" | "cooldown" | "dashing" }`, `isInvulnerable(now)` for the i-frame window, `offset(now)` for the burst displacement, `tick(dt, now)` regens stamina.
- **Hit reaction** (`combat/hitReaction`) — `resolveHitReaction(config, { attackerPos, targetPos, power })` (pure) → `{ hitstopMs, impulse, shake, trauma, timescale }`. `config` is a raw `HitReactionConfig` **or** a named `impactPresets` event — `resolveHitReaction("explosion", input)` gives calibrated hitstop + trauma with zero hand-tuning (`pickup` 0.15 / `jumpLand` 0.2 / `enemyKilled` 0.3 @ 40ms hitstop / `playerHit` 0.4 @ 70ms / `explosion` 0.7 @ 90ms, heavy hits at `timescale: 0.05`; trauma clamps to 1.0). Wired on `ctx`: `ctx.scene.entity.hitReaction({ from, to, config, power? })` knocks the target back and emits `combat.hitReaction` with `trauma` set when the config resolved from `trauma` — the shell's `CombatCameraShake` feeds it into the trauma² camera-shake channel (`@jgengine/shell/camera`'s `traumaShake`; see `jgengine-ui`), or reads `shake` for the amplitude/decay channel when `config.shake` is set instead.
- **Render cues** (`combat/renderCues`, #542) - the pure math behind a custom `renderEntity`/viewmodel rig's motion feel: `EntityRenderCues { speed, bobPhase, firing, reloading, recoil, hit, dead }`, `advanceMotionCues(cues, speed, dt, tuning?)` advances `bobPhase`/decays `recoil` from a live `groundSpeed(entity)` sample, `applyRenderAnimationEvent(cues, event)` folds in `"fire"` / `"reload"` / `"reloadEnd"` from `ctx.game.playEntityAnimation`, `applyRenderHitEvent`/`applyRenderDeathEvent` fold in `combat.hitReaction`/`entity.died`. `RenderCueTuning` overrides `cyclesPerUnit`/`recoilDecayPerSecond`/`firePulseSeconds`/`hitPulseSeconds`. A game never calls these directly — `@jgengine/shell/render`'s `useEntityRenderCues(instanceId)` (see `jgengine-ui`) wires all four into one ref-returning hook for `useFrame` consumers.
- **Telegraph** (`combat/telegraph`) — a `TelegraphShape` (`circle | ring | cone | line`) + `windupMs`. `ctx.scene.entity.telegraph({ from, shape, at, dir?, windupMs, kind?, effect? })` emits `combat.telegraph` for the shell to draw a ground decal that fills over the windup, and — if `effect` is bound — applies that effect to everyone inside the shape (`pointInTelegraph`) at activation. Returns a cancel handle. **Turn-scoped variant**, for tactics/turn-based enemy intents instead of a real-time windup: set `TelegraphConfig.turns` (a count of turns instead of `windupMs`), then read `telegraphTurnProgress(config, startedTurn, currentTurn)` (0..1 fill fraction), `telegraphFiredAtTurn(config, startedTurn, currentTurn)` (has it resolved), and `telegraphTurnsRemaining(config, startedTurn, currentTurn)` — the same "enemy will hit this tile" decal, advanced by `turnLoop.round()`/`active()` instead of a clock.
- **Damage-number typing** — `ctx.scene.entity.floatText({ …, crit?, element?, hitType?, scale? })` (and the `entity.floatText` event) carry hit metadata; the shell's `resolveFloatTextStyle` (`@jgengine/shell/world/floatTextStyle`) maps crit/element to color + scale + glow.
- **VFX — make an attack visible in one line** — the easy, discoverable path is a named `preset`: `ctx.scene.entity.vfx({ preset: "arrow", from: caster, to: enemy })` renders a visible bolt with zero color/archetype tuning, and `"lightning"`, `"web"`, `"slash"`/`"sword"`, `"shield"`, `"heal"`, `"fireball"`, `"frostbolt"`, `"explosion"` and the rest of `vfxPresets` (`@jgengine/core/combat`) likewise just work — this is the answer to "I fired a projectile at the enemy but couldn't see anything." A preset is pure presentation vocabulary (a `kind` + color + lifetime, like named CSS colors); it never touches damage or hit resolution, which stay in your effect/projectile data. **The authoritative `fireProjectile`/`settleProjectile` and `applyEffect` do not emit any visual on their own** — emit the `vfx` burst yourself when you fire/settle (`fireProjectile(...)` → `vfx({ preset, from, to })`). Any field you also pass overrides the preset (`vfx({ preset: "fireball", color: 0x00ff00 })`); supply `kind` + `color` directly for a fully custom burst with no preset; an unknown flavor name degrades to a visible `spark` rather than nothing. `resolveVfxPreset(name, overrides?)` returns the raw `{ kind, color, durationMs?, radius? }` when you need the numbers (e.g. to seed a retained instance from the same vocabulary). Full walkthrough: [make an attack you can actually see](recipes/visible-attacks.md).
- **VFX — one-shot vs retained** — `ctx.scene.entity.vfx({ preset?, kind?, color?, from?, to?, radius?, durationMs? })` fires a **one-shot** burst (`combat.vfx`, `kind: projectile | beam | nova | glow | spark`) the shell renders once and expires. For a **long-lived** effect whose endpoints/params move over time (held beam, tether, zone, target line, looping emitter) use the retained registry `ctx.scene.entity.vfxInstance` (`createVfxInstanceStore`, `@jgengine/core/game/vfxInstance`): `upsert({ id?, kind, color, from?, to?, radius?, params?, ttlMs? })` creates or replaces by stable id, `update(id, patch)` nudges dynamic params (a missing id is a no-op, not a throw), `stop(id, { fadeMs? })` disposes with an optional fade, and `tick(now)` enforces the TTL heartbeat; `list()`/`count()` are for debug. Endpoints are `VfxRef`s — an entity **instance id** (the shell resolves its live pose each frame, so the effect follows a moving entity with no per-frame commands) or a fixed `[x, y, z]` world point. State is headless/serializable and the store is renderer-independent; each op emits `combat.vfxInstance` which the shell's `RetainedVfx` overlay binds to render resources (beam is the first shipped retained renderer). Call `vfxInstance.clear()` on scene/level reset. Duet Keys' prism beam is the first adopter (driven from its `onTick`).

## Abilities, resources, auto-target, resistance, run drafts

Genre systems layered over the same effects/projectiles/targeting/loot primitives — all renderer-free pure `@jgengine/core` factories a game holds per player and ticks on **game-time** `dt` (so pause/fast-forward carry through). The ability kit is deliberately **separate from inventory items**: an item is a stackable id, an ability slot is cooldown/charge/resource state the HUD's four slot-states bind to.

- **Ability kit** (`combat/abilityKit`) — `createAbilityKit([{ id, cooldownMs, chargesMax?, resourceCost?, castType?, flashMs? }])`. `state(id, resourceAvailable?)` → `AbilitySlotSnapshot { state: "ready" | "cooldown" | "no-resource" | "just-cast", charges, chargesMax, cooldownRemainingMs, cooldownFraction, justCast, ready }`; `cast(id, resourceAvailable?)` consumes a charge, starts the recharge, and flashes just-cast; `canCast` / `tick(dt)` / `reset` / `retuneSlot(id, { cooldownMs?, resourceCost? })` (rebalance a slot at runtime — patches the config in place without touching charges or an in-flight cooldown timer, returns `false` for an unknown slot). The kit is resource-**agnostic** — it reports `no-resource` by comparing `resourceCost` to a supplied `resourceAvailable` (a mana stat, or an ult meter), and never spends the resource itself; the game's handler spends it and calls `cast`. Charges recharge one at a time; the four states drive the hotbar slot art. Cooldowns tick on `dt`, so hang `kit.tick(dt)` in `onTick`.
- **Event-fed meters** (`stats/eventMeter`, built on `stats/accumulatorMeter`) — `createEventMeter({ max, mode, gains, resets?, tiers?, decayPerSecond? })`. `feed(tag, scale?)` maps a tagged combat event to a gain (or a reset when `tag ∈ resets`) → `EventMeterFeedResult { fired, ready, tier, tierChanged, reset }`. Mode `"hold"` is the **ult/adrenaline** economy — charges off `{ damageDealt, damageTaken, kill }` events, `ready()` at full, `consume()` spends it (Overwatch/Marvel Rivals). Mode `"reset"` is the **streak/combo** meter — builds on `kill`, resets on a break tag like `damageTaken`, climbs ascending `tiers` (Returnal adrenaline, DMC style rank). One primitive, two catalog configs.
- **Auto-target policy** (`scene/autoTarget`) — `selectAutoTarget(policy, fromId, deps)` / `createAutoTargeter(policy, deps)` evaluated each tick with zero input: `"nearest" | "farthest" | "random" | "strongest" | "weakest" | "first" | "last"`. `deps` supplies `candidates`/`distance` and optional `strength` (health/threat) + `progress` (path progress for first/last-on-path). Bullet-heaven auto-fire (Vampire Survivors nearest), Bloons tower priority (first/last on path). Feed the picked id to `abilityKit.cast` + `effect`.
- **Resistance matrix** (`combat/resistance`) — a damage-category × target-property table distinct from `combat/attackTags` (those are defense tags). `resolveResistance(matrix, category, targetProperties)` → `{ verdict: "immune" | "resist" | "normal" | "vulnerable", multiplier, immune }`; `resistanceScale` returns just the multiplier (default `immune:0, resist:0.5, normal:1, vulnerable:2`, overridable). Immune on any property wins; resists stack multiplicatively. Sits over the `receive` gate — the handler multiplies its effect `amount` by the scale (Bloons lead/camo immunities, elemental RPG weaknesses).
- **Run draft** (`game/runDraft`, built on `world/scatterItems` `pickWeighted` + `stats/statModifiers`) — `createRunModifierStack(offers)` accumulates stacking picks (`add`, `count`, `atMax`, `total(stat)` aggregating adds × exponentiated multiplies, `apply(stats)` onto a `createStats` source). `createRunDraft({ offers, rng? })` adds the pause-flow: `present(n)` draws N distinct weighted offers (excluding maxed-out ones), `choose(id)` applies to the stack. Pause/resume is game-side (`ctx.time.pause()` before `present`, `play()` after `choose`). Vampire Survivors level-up picks, Hades boons, Risk of Rain stacking items.

## Damage model, armor, crits, and statuses

`createDamageRuntime` (`combat/damageRuntime`) is the stateful in-process composition of the table, the zones, the armor curve, and carried statuses. A number reads as `base × zone multiplier × type multiplier × (1 − armor mitigation)`; `hit()` returns every stage, `tick()` ticks statuses through the same table and armor, `timeToKill()` quotes a fight, and `table()` reads the whole chart. `snapshot()`/`restore()` carry registered targets and carried statuses; `retune()` swaps table, zones, or definitions without dropping either. For multiplayer authority, keep the pure stages (`resolveMatchup`, `resolveReceivedDamage`, `resolveStatusApplication`) and replicate their results instead.

With `events: ctx.game.events` and `healthOf`, every hit and status tick raises one `combat.damage` carrying the table `type`, the final `amount`, what resistance stopped (`absorbed`), and `remaining`/`lethal`. The runtime and the effect path each announce their own application; neither calls the other, so a game that uses both gets one event per blow, not two. Prefer the runtime for damage that needs types, zones, armor or time-to-kill; the effect path stays for pools drained directly through the entity catalog's `receive` rules and now announces the same event with the effect id as `type`.

- **Type × resistance table** (`combat/damageTable`) — `{ types: [{ id, vs: { armor: 2, flesh: 0.5 }, bypassArmor? }], armor: { halfValue, maxMitigation }, defaultMultiplier? }`. `typeMultiplier` reads one pair; `resolveTableDamage` folds type, armor, and base into a row; `readDamageTable` returns every row at given armor values and `formatDamageTable` prints them. Armor uses `armor / (armor + halfValue)` — armor equal to `halfValue` halves the hit — so a designer reads the number rather than guessing a percentage per point. Unknown types throw; unknown resistance classes use the default.
- **Hit zones and crits** (`combat/hitZones`) — `{ zones: [{ id, multiplier?, critChance?, critMultiplier? }], defaultZone, critChance?, critMultiplier? }`. `resolveHitZone(table, zone, rng)` rolls once and returns **one** multiplier: a crit replaces the zone's normal multiplier (head 1.5× normal, 2.5× crit → 2.5×, never 3.75×). `hitZoneMultiplier(table, zone, crit)` quotes the same number without a roll, which is what `timeToKill({ crit: true })` uses.
- **Statuses** (`combat/statusRuntime`) — `createStatusRuntime({ statuses, rng?, applyDamage? })`; each `StatusDef` declares `{ id, stack, durationMs, intervalMs?, magnitude?, effect?, chance? }`. `stack` is the existing `StatusStackPolicy` (`refresh` / `stack` to `max` / `replace` / `ignore`), and it is the only thing that changes stacks: a tick deals `magnitude × stacks`, so two burns at 4 per tick deal 8, never 16. `effect` is `{ kind: "damage", channel }` (routed through `applyDamage`, so resistances and armor apply) or `{ kind: "stat", stat }` (read with `statDelta`, e.g. negative `moveSpeed` for slow or negative `armor` for corrode). Reapplication restarts the tick clock instead of banking a burst; ticks per call are bounded and the remainder carries. `dotField` stays the cheap no-stacking scheduler.

## Weapons as data

`createWeaponSystem` (`combat/weaponSystem`) runs one `WeaponDefinition` (`combat/weaponDefinition`): plain JSON with `roundsPerMinute`, `fireMode` (`single` / `auto` / `burst` + `burst.count`), `delivery` (`hitscan` + `maxRange`, or `projectile` + `speed` / `gravity` / `drag` / `lifetime`), optional `magazine`, `handling` (the same recoil bloom and ADS tuning `createWeaponHandling` takes), `damage` (`type`, `base`, optional `statuses`), optional `falloff`, `pellets`, `maxInFlight` and `autoReload`. Nothing in it is a function, so a generator rolls it and a save stores it; `assertWeaponDefinition` rejects a bad roll at construction with the field named.

- **Loop.** `setAim(yaw, pitch)` from look input, `setTrigger(down)`, `tick(dt, stance)` → `{ shots, impacts }`. A press survives to the next tick that can use it; a tap does not repeat. Recoil is applied to the aim before the shot leaves, so `record.directions` and the world query see the kicked vector, and `aim()` reports it with recovery folded in.
- **Spread and bounds.** Each pellet samples the cone uniformly over solid angle from the injected `rng`; zero spread draws nothing. One tick fires at most `maxShotsPerTick` (default 4, clamp 16) catch-up shots and keeps the remaining budget, so a stalled frame cannot turn into a long burst.
- **Delivery.** A hitscan calls your `raycast(from, direction, maxRange)` on the firing tick. A projectile is stepped with semi-implicit Euler (drag, then gravity), swept as one segment per tick so it cannot tunnel, and resolved when `sweep(from, to)` reports a contact. Both build the same `DamageRuntimeHitInput` for `createDamageRuntime().hit()`, so zone, type, armor, status rolls and provenance are identical; only the distance differs. Falloff scales `base` by the distance the pellet actually travelled.
- **Live pellets are capped** by `maxInFlight` (default 32, max 256); excess pellets of a shot are counted in `stats().pelletsDropped` rather than queued.
- **Reload states.** `reload()` starts or continues; `interruptReload()` suspends through `Magazine.pauseReload` keeping progress; `resumeReload()` continues from it; `cancelReload()` discards. `ammo()` returns loaded / capacity / reserve / state / reload fraction / burst remaining / cadence fraction, and `stats()` carries shots, rounds spent, pulls, reloads, drops and damage dealt — the whole ammo economy is readable without touching internals.
- **State.** `snapshot()` / `restore()` carry aim, handling, magazine, trigger, burst, pellets and counters as plain data; `retune(definition)` swaps rate, feel, damage and capacity without losing ammo or aim (a shrinking magazine discards rounds above the new capacity); a `devtoolsName` registers a one-line probe.
- **When to use which.** `createWeaponRuntime` (`combat/weaponFire`) is the minimal plumbing for a game that already owns its fire loop and raycaster; `createWeaponSystem` is the definition-driven version with fire modes, ballistics, reload states and ammo accounting. Both leave models, audio, muzzle flash, camera and input to the game; feel numbers come from `measureWeapon` and presentation from `weaponPresentation`.
- **Evidence** (`combat/weaponSystem.test.ts`): the test fixture at 600 rpm with a 30-round magazine empties in 2.9 s, climbs 17.0° pitch and 3.1° yaw over the magazine, is back to half climb in 0.37 s and within 5% in 0.6 s; bloom runs 2.0 → 8.0 → 12.0 mrad (cap) by the tenth shot and settles to base 0.63 s after the last shot. A hitscan and a projectile contact at the same distance produce the same 20 damage (30 on a 1.5x head zone), and one second of held fire cannot exceed four shots.

## Encounter sequencing

`combat/encounterSequence` is the genre-agnostic hierarchical sequencer behind arenas, raids, tutorials, races, defense phases, and scripted objectives — a serializable state machine over caller-defined phase `data` with **no built-in wave/boss concept**. State is plain JSON (the working tree, per-node runtime, active path, elapsed) so a mid-encounter save round-trips; the predicate/spawn registries live in the config you re-supply alongside it, the way `advanceSpawnDirector` pairs config with state.

- **Tree + lifecycle** — `createEncounterState(config)` deep-copies the authored phase tree (unique ids enforced) and leaves it pending; `startEncounter` enters the first leaf; `updateEncounter(config, state, dt, ctx)` advances one bounded step, emitting provenance-rich `enter`/`spawn`/`complete`/`done` events. A group (has `children`) runs them in order and completes when they do — plus its own predicate if set (survive-the-timer-while-adds-run); a leaf without a predicate is an immediate marker. `activePhaseId` / `phaseStatus` / `encounterProgress` drive the HUD. A phase that spawns is deferred one tick before it can complete, so freshly-spawned "cleared" gates never satisfy against stale counts.
- **Completion predicates** — `completion: { kind, params }` names a registered evaluator; `BUILTIN_COMPLETION_PREDICATES` ships `immediate`, `manual`/`flag`, `timer` (phase seconds), `cleared` (tagged count drained), `metric` (live threshold), `event` (cumulative count), and `quorum` (N of nested predicates). Signals (`counts`/`metrics`/`events`/`flags`) come in through `ctx.signals`; evaluation is deterministic. Register your own via `config.predicates`.
- **Spawn providers** — spawning is one optional node adapter: `spawn: { kind, params }` invoked on enter. `BUILTIN_SPAWN_PROVIDERS.points` reads a named authored scene point list from `ctx.spawnPoints` (coordinates stay in the scene, referenced by key — never embedded in the sequence); `list` echoes inline requests. Register catalog/director-backed providers via `config.spawnProviders`.
- **Injection & scripted control** — `injectPhase(state, phase, { after | before | childOf })` inserts a phase or branch at runtime (e.g. a boss after the final wave) into the serializable tree without disturbing active/completed nodes; `forceCompletePhase` completes the active phase regardless of its predicate for scripted skips and failure/retry, restoring the authored predicate afterward.

## Enemy squad AI

`createSquadCombat` (`combat/squadCombat`) is the combat half of an NPC squad: it reads the perception memory the game already keeps (`createPerception`, B1.4) and moves through the nav crowd it already owns (`createNavCrowd`, B1.2). It never sees a global player position — a member reacts to what its own memory or a squadmate's shared sighting says, so losing contact means losing the fight.

- **Cover comes from geometry.** `createCoverField({ solids })` turns authored solid boxes (a `WorldSolid` list drops in) into stand points on every vertical face, clear of the corners and filtered by `walkable: (p) => nav.findPolygon(p) !== null`; `rebuild(solids)` re-derives after an editor edit. A game with its own spatial index supplies the narrower `CoverSource` instead: `near(position, radius, limit)`, bounded per call. Never author a coordinate list for cover.
- **Selection is by threat.** `selectCover(points, query)` scores each point on `hidden` (confidence-weighted share of remembered threats whose line of sight it blocks, including a body-sized footprint), the role's `preferredRange`, its `flankAngle` around the threat, distance, a squadmate's claim, and whether the walk to it is a detour. A point that hides from one of two shooters is not cover; a memory below `minConfidence` stops steering.
- **Roles are content.** `assignSquadRoles(members, { roles })` maps names and counts to members deterministically; a role carries `preferredRange`, `flankAngle`, `retreatBelow`, `holdCover`, `discipline` (hold/peek/peek distance/fire interval) and `cover` weights. The engine has no role list, and `"assault"`, `"support"`, `"flanker"` exist only in the game's composition.
- **Behavior over a tick.** A member picks cover, walks to it, leans out for `peekSeconds` to shoot (`peekSpot` steps around the corner it hides behind), then drops back. `underFire(memberId, amount)` raises suppression, which pins the member to its cover and cancels its peeks until the meter decays. Below a role's `retreatBelow`, the member falls back to the point furthest from the threat. After `relocateAfterSeconds` of continuous exposure it stops trusting its point and moves, which is what breaks a long spell in the open.
- **Wiring.** `engage(memberId, targetId, distance)` is the paced shot: resolve damage in your own pipeline, call `underFire` on the victim, push a `damage` stimulus at the shooter's position so a member learns about a shooter it cannot see. `healthOf` reads authoritative health, `teamOf` decides who is hostile, `blocked` is your collision layer. `snapshot()`/`restore()` carry member decisions, the crowd and the tuning, so a save mid-fight resumes the same fight.
- **Evidence.** `combat/squadCombat.soak.test.ts` is the reference run: 2v4 in an authored arena, 90 s, exposure measured every tick against the walls from live positions. Worst exposure 2.33 s against a 3 s bar, 0 of 38 exposure spells over 3 s, p50 0.05 ms per tick. Copy its shape (authored boxes, nav grid with the same holes, perception observing live positions) rather than trusting a flag.
