# Combat

> Health, armour, weapons as data, hitscan and projectile shots, explosions, aim assist, status effects, kill credit, respawns, bots that shoot back, and the effects and HUD that draw it all.

The combat kit (`src/engine/combat/`) is everything a game needs for characters that shoot or hit each other: health, weapons, hits, explosions, respawns, a score and a killfeed. Use it for any 3D fight built on the [character controller](https://docs.moonspell.app/engine/character-and-cameras.md): an arena shooter, paintball, laser tag, a sword duel, a wave survival game.

The core is `CombatWorld`, a plain TypeScript class with no React and no three.js. You call it from your frame loop and read its lists to draw. Bots from the [bots kit](https://docs.moonspell.app/engine/bots.md) get a trigger finger through `createGunner` and `driveBotWeapon`.

If you want a ready shooter with modes (free-for-all, team deathmatch, gun game, capture the flag, king of the hill), use the [shooter module](https://docs.moonspell.app/modules/shooter.md), which is built on this kit. Use the kit directly when the module's modes do not fit your rules.

## Smallest working example

A free-for-all against two armed bots. This is `examples/pulse-arena/` cut down to the parts that touch combat. The level, the player body and the camera follow the [character and cameras](https://docs.moonspell.app/engine/character-and-cameras.md) page; read it first if those lines are new to you.

```tsx
import React, { useMemo, useRef, useState } from 'react';
import { StyleSheet, View } from 'react-native';
import { useFrame } from '@react-three/fiber';
import {
  AmmoCounter,
  BotMeshes,
  BotSquad,
  CharacterCamera,
  CharacterControls,
  CharacterMesh,
  CombatEffects,
  CombatWorld,
  Crosshair,
  DamageNumbers,
  EngineCanvas,
  HealthBar,
  Killfeed,
  LevelMeshes,
  TopBar,
  WORLD_FRAME,
  applyEffectsToCommand,
  computeCameraPose,
  consumeInput,
  createCameraPose,
  createCharacterInput,
  createCharacterState,
  createCommand,
  createGunner,
  createRoster,
  driveBotWeapon,
  isReloading,
  moveFrameFor,
  resolveCameraRig,
  resolveCharacterParams,
  resolveLevel,
  spreadOf,
  stepCharacter,
  type Bot,
  type CharacterHandle,
  type CharacterInput,
  type ControlButton,
  type FeedEntry,
  type GameDefinition,
  type GameScreenProps,
  type Gunner,
  type ScreenNumber,
} from '@/engine';

const LEVEL = resolveLevel({ preset: 'arena', size: 24, seed: 7 });
const PARAMS = resolveCharacterParams({ runSpeed: 6.8, walkSpeed: 3.2 });
const BUTTONS: ControlButton[] = [
  { id: 'fire', label: 'FIRE', accessibilityLabel: 'Fire', size: 100, look: true },
  { id: 'jump', label: 'JUMP', accessibilityLabel: 'Jump', size: 72 },
];

interface Hud {
  hp: number;
  max: number;
  ammo: number;
  magazine: number;
  reloading: boolean;
  weapon: string;
  spread: number;
  locked: boolean;
  kills: number;
  feed: FeedEntry[];
}

const makeMatch = () => {
  const body = createCharacterState(LEVEL.spawns[0]);
  const squad = new BotSquad({ level: LEVEL, params: PARAMS });
  // The world shares the squad's noise bus, so bots hear every shot.
  const world = new CombatWorld({ level: LEVEL, noise: squad.noise, respawnSeconds: 2.5 });
  world.add({ id: 'player', name: 'You', color: '#7C5CFF', body, params: PARAMS, weapon: 'rifle', health: { regenPerSecond: 12 } });
  const roster = createRoster({ targetPlayers: 3, maxBots: 2 });
  roster.reconcile(1);
  const gunners: Record<string, Gunner> = {};
  roster.bots.forEach((b, i) => {
    const spawn = LEVEL.spawns[(i + 1) % LEVEL.spawns.length];
    const bot = squad.add({ id: b.id, name: b.name, color: b.color, role: 'skirmisher', spawn: { x: spawn.x, z: spawn.z, yaw: spawn.yaw } });
    world.add({ id: b.id, name: b.name, color: b.color, body: bot.state, params: bot.params, weapon: 'smg', bot: true });
    gunners[b.id] = createGunner('normal', { fleeBelow: 0.25 });
  });
  return { body, squad, world, gunners };
};

type Match = ReturnType<typeof makeMatch>;

const Simulation = ({ match, input, numbers, onHud }: { match: Match; input: CharacterInput; numbers: ScreenNumber[]; onHud: (h: Hud) => void }) => {
  const { squad, world, gunners } = match;
  const body = useRef(match.body);
  const rig = useMemo(() => resolveCameraRig('third'), []);
  const mesh = useRef<CharacterHandle>(null);
  const cmd = useRef(createCommand());
  const frame = useRef({ ...WORLD_FRAME });
  const pose = useRef(createCameraPose());
  const view = useRef({ origin: { x: 0, y: 0, z: 0 }, dir: { x: 0, y: 0, z: 1 }, shot: { x: 0, y: 0, z: 1 } });
  const clock = useRef(0);
  const lastHud = useRef(0);

  useFrame((_, rawDt) => {
    const dt = Math.min(rawDt, 1 / 30);
    clock.current += dt;
    const now = clock.current;
    const s = body.current;
    const me = world.get('player')!;

    // 1. Read the fire button before consumeInput clears this frame's presses.
    const fireHeld = !!input.held.fire;
    const firePressed = !!input.pressed.fire;
    consumeInput(input, cmd.current);

    // 2. Move the player, with slows and stuns applied first.
    applyEffectsToCommand(cmd.current, me);
    stepCharacter(s, cmd.current, dt, LEVEL, PARAMS, moveFrameFor(rig, s, frame.current));

    // 3. Move the bots. Dead bots sit out; each bot targets everyone else.
    for (const bot of squad.bots) bot.disabled = !!world.get(bot.id)?.health.dead;
    squad.step(dt, now, (bot: Bot) => world.targetsFor(bot.id));

    // 4. Aim along the camera ray (aim assist included) and pull the trigger.
    const p = computeCameraPose(rig, s, LEVEL, PARAMS.height, 0, pose.current);
    const v = view.current;
    v.origin.x = p.x;
    v.origin.y = p.y;
    v.origin.z = p.z;
    const len = Math.hypot(p.tx - p.x, p.ty - p.y, p.tz - p.z) || 1;
    v.dir.x = (p.tx - p.x) / len;
    v.dir.y = (p.ty - p.y) / len;
    v.dir.z = (p.tz - p.z) / len;
    let locked = false;
    if (!me.health.dead) {
      locked = world.aimFromView('player', v.origin, v.dir, v.shot).id !== null;
      world.pullTrigger('player', fireHeld, firePressed, v.shot, now, dt);
    }

    // 5. Bots shoot back, then the world advances.
    for (const bot of squad.bots) driveBotWeapon(world, bot, gunners[bot.id], now, dt);
    world.step(dt, now);
    world.drainEvents(); // play sounds and haptics here

    if (mesh.current?.group) {
      mesh.current.group.visible = !me.health.dead;
      mesh.current.group.position.set(s.x, s.y, s.z);
      mesh.current.group.rotation.y = s.lookYaw;
    }

    // The HUD is React Native: update it a few times a second, not every frame.
    if (now - lastHud.current > 0.1) {
      lastHud.current = now;
      onHud({
        hp: me.health.hp,
        max: me.health.max,
        ammo: me.weaponState.ammo,
        magazine: me.weapon.magazine,
        reloading: isReloading(me.weaponState, now),
        weapon: me.weapon.name,
        spread: spreadOf(me.weapon, me.weaponState, s.speed / PARAMS.runSpeed),
        locked,
        kills: me.kills,
        feed: [...world.feed],
      });
    }
  });

  return (
    <>
      <ambientLight intensity={0.5} />
      <directionalLight position={[-30, 60, -20]} intensity={1} />
      <LevelMeshes level={LEVEL} />
      <CharacterMesh ref={mesh} color="#7C5CFF" radius={PARAMS.radius} height={PARAMS.height} />
      <BotMeshes bots={squad.bots} radius={PARAMS.radius} height={PARAMS.height} />
      <CombatEffects world={world} now={() => clock.current} numbersOut={numbers} />
      <CharacterCamera rig={rig} target={body} level={LEVEL} bodyHeight={PARAMS.height} />
    </>
  );
};

const Arena = ({ onExit }: GameScreenProps) => {
  const input = useMemo(() => createCharacterInput(), []);
  const numbers = useMemo<ScreenNumber[]>(() => [], []);
  const [match] = useState(makeMatch);
  const [hud, setHud] = useState<Hud | null>(null);
  return (
    <View style={styles.root}>
      <EngineCanvas style={StyleSheet.absoluteFill} background={LEVEL.theme.sky}>
        <Simulation match={match} input={input} numbers={numbers} onHud={setHud} />
      </EngineCanvas>
      <CharacterControls input={input} buttons={BUTTONS} />
      <View style={StyleSheet.absoluteFill} pointerEvents="box-none">
        <TopBar title="Arena" status="Free-for-all" rightLabel="KILLS" rightValue={String(hud?.kills ?? 0)} onExit={onExit} />
        {hud && (
          <>
            <Crosshair spreadDeg={hud.spread} locked={hud.locked} />
            <DamageNumbers source={numbers} onlyBy="player" />
            <Killfeed entries={hud.feed} />
            <HealthBar hp={hud.hp} max={hud.max} />
            <AmmoCounter ammo={hud.ammo} magazine={hud.magazine} reloading={hud.reloading} weapon={hud.weapon} />
          </>
        )}
      </View>
    </View>
  );
};

const styles = StyleSheet.create({ root: { flex: 1, backgroundColor: '#000' } });

const game: GameDefinition = { id: 'arena', title: 'Arena', description: 'Free-for-all against two bots.', Component: Arena };
export default game;
```

To run it, put it in `src/game/index.tsx` and start the game as in the [quickstart](https://docs.moonspell.app/start/quickstart.md). The full version with a win condition, weapon swap, a scoreboard, a best time and a dev autopilot is `examples/pulse-arena/index.tsx`.

## How a frame runs

The order inside your frame loop matters:

1. Read `input.held.fire` and `input.pressed.fire`, then call `consumeInput`. `consumeInput` clears the presses.
2. `applyEffectsToCommand(cmd, world.get('player'))`, then `stepCharacter` for the player.
3. Set `bot.disabled` for dead bots, then `squad.step(...)`.
4. `world.aimFromView(...)` turns the camera ray into a shot direction; `world.pullTrigger(...)` fires.
5. `driveBotWeapon(...)` for each bot.
6. `world.step(dt, now)`: projectiles, burns, regeneration, falls, respawns, fading effects.
7. `world.drainEvents()` for sounds, haptics and scoring.

`now` is your game clock in seconds. Use the same clock for every call.

## Health

Each combatant has a `Health` made from a `HealthConfig`. Armour takes a share of every hit until it runs out. Regeneration starts after a delay without damage. A combatant cannot be hurt for a short time after it spawns.

| Field | Range | Default | What it does |
|---|---|---|---|
| `max` | 1 to 10000 | `100` | Hit points. |
| `armor` | 0 to 1000 | `0` | Armour points on spawn. |
| `armorAbsorb` | 0 to 1 | `0.6` | Fraction of each hit the armour takes while it lasts. |
| `regenPerSecond` | 0 to 200 | `0` (off) | Hit points regained per second. |
| `regenDelay` | 0 to 30 | `4` | Seconds without damage before regeneration starts. |
| `spawnProtection` | 0 to 10 | `1.5` | Seconds of invulnerability after a spawn or respawn. |

The pure functions work on a `Health` without a world, for games that only need hit points:

| Function | What it does |
|---|---|
| `resolveHealth(config?, now?)` | Clamps a config and returns a full `Health`. |
| `applyDamage(h, amount, now)` | Removes hit points (armour first). Returns a `DamageResult`: `dealt`, `absorbed`, `killed`, `blocked`. Blocked means dead already, spawn-protected or zero damage. |
| `heal(h, amount)` | Adds hit points up to `max`. Does nothing to the dead. Returns the amount healed. |
| `addArmor(h, amount, cap?)` | Adds armour up to `cap` (default: the spawn armour, or `amount` when there was none). |
| `stepHealth(h, dt, now)` | Regeneration. Call every frame. |
| `reviveHealth(h, now)` | Full health, fresh armour, fresh spawn protection. |
| `isHostile(teamA, teamB, friendlyFire?)` | `null` team means free-for-all, so everyone is hostile. |

## Weapons

A weapon is a row of data. Pass a preset name, a preset plus overrides, or a full `WeaponConfig`. `resolveWeapon` fills the rest from the rifle and clamps every field.

| Preset | Kind | Mode | Damage | Shots per second | Notes |
|---|---|---|---|---|---|
| `pistol` | hitscan | semi | 22 | 4 | 12 rounds, accurate. |
| `rifle` | hitscan | auto | 14 | 8 | 30 rounds. The default. |
| `smg` | hitscan | auto | 9 | 13 | 35 rounds, 32 m, more aim assist. |
| `shotgun` | hitscan | semi | 11 per pellet, 8 pellets | 1.3 | 6 shells, 22 m, knockback 6. |
| `sniper` | hitscan | semi | 85, headshot x2 | 0.8 | 140 m, wide spread on the move, half aim assist. |
| `blaster` | projectile | auto | 18 | 5 | 34 m/s bolts you can dodge. |
| `rocket` | projectile | semi | 30, 4 m splash | 0.9 | Knockback 12. Hurts you too. |
| `grenade` | projectile | semi | 20, 4.5 m splash | 0.8 | Arcs, bounces, 1.6 s fuse, no aim assist. |
| `sword` | melee | semi | 45 | 1.8 | 2.4 m reach, 100 degree arc. |

```ts
import { WEAPON_PRESETS } from '@/engine';

// A preset with overrides, swapped in at runtime (a pickup):
world.equip('player', 'rifle', { damage: 20, color: '#FF5CF0' });

// A new weapon built from a preset, given at add time:
world.add({
  id: 'player',
  body,
  params,
  weapon: { ...WEAPON_PRESETS.smg, name: 'Paint gun', status: { kind: 'slow', seconds: 1, power: 0.6 } },
});
```

Every `WeaponConfig` field. Defaults are the rifle's (the base every preset starts from):

| Field | Range | Default | What it does |
|---|---|---|---|
| `name` | string | `'Rifle'` | Shown in the killfeed and `AmmoCounter`. |
| `kind` | `'hitscan'`, `'projectile'`, `'melee'` | `'hitscan'` | How shots travel. |
| `mode` | `'semi'`, `'auto'`, `'burst'` | `'auto'` | One shot per press, fire while held, or bursts. |
| `damage` | 1 to 500 | `14` | Per pellet at close range. |
| `headshotMultiplier` | 1 to 4 | `1.6` | Hitscan and projectiles only. |
| `fireRate` | 0.2 to 30 | `8` | Shots per second. |
| `pellets` | 1 to 20 | `1` | Rays or projectiles per shot. |
| `spreadDeg` | 0 to 30 | `1.2` | Cone of inaccuracy, full angle. |
| `moveSpreadDeg` | 0 to 30 | `2.5` | Extra spread at full run speed. |
| `bloomDeg` | 0 to 5 | `0.4` | Extra spread per shot while the trigger stays held. Recovers on release. |
| `range` | hitscan 5 to 200, melee 1 to 5 | `60` | Metres. For projectiles, the maximum flight distance. |
| `falloffStart` | 0 to 1 | `0.5` | Damage starts dropping at this fraction of `range`. |
| `falloffMin` | 0 to 1 | `0.6` | Fraction of damage left at full range. |
| `magazine` | 0 to 200 | `30` | Rounds per magazine. `0` never reloads. |
| `reloadSeconds` | 0.2 to 6 | `1.8` | |
| `burstCount` | 2 to 6 | `3` | Shots per press in `burst` mode. |
| `projectileSpeed` | 5 to 200 | `40` | m/s. |
| `projectileGravity` | 0 to 40 | `0` | m/s² pulling projectiles down. |
| `projectileRadius` | 0.05 to 1 | `0.12` | Visual and hit radius. |
| `splashRadius` | 0 to 12 | `0` | Explosion radius. `0` is no explosion. |
| `splashMinFraction` | 0 to 1 | `0.3` | Damage fraction at the edge of the blast. |
| `fuseSeconds` | 0.2 to 10 | `3` | Life of a bouncing or falling projectile before it explodes. |
| `bounce` | boolean | `false` | Projectiles bounce off walls instead of stopping (grenades). |
| `knockback` | 0 to 30 | `1` | Push given to whoever is hit, m/s. |
| `arcDeg` | 20 to 360 | `90` | Melee swing arc. |
| `loudness` | 0 to 1 | `0.8` | Posted to the noise bus per shot, so bots hear it. |
| `aimAssist` | 0 to 1.5 | `1` | Multiplies the world's aim assist cone for this weapon. |
| `status` | `StatusSpec` or `null` | `null` | Effect each hit leaves. See [Status effects](https://docs.moonspell.app/engine/combat.md#status-effects). |
| `color` | CSS colour | `'#FFE08A'` | Tracer and projectile colour. |

Reloads start by themselves on an empty magazine. Call `world.reload(id, now)` for a reload button.

The weapon functions work without a world too:

| Function | What it does |
|---|---|
| `WEAPON_PRESETS`, `WEAPON_PRESET_NAMES` | The table above, and its keys. |
| `resolveWeapon(presetOrConfig?, overrides?)` | Returns a clamped `Weapon`. Default `'rifle'`. |
| `createWeaponState(weapon)` | A `WeaponState`: `ammo` (Infinity when `magazine` is 0), `reloadingUntil`, `nextShotAt`, `burstLeft`, `bloom`, `shots`. |
| `triggerWeapon(state, weapon, now, held, pressed, dt?)` | Returns how many shots to fire this frame (at most 4), spends ammo, starts and finishes reloads. |
| `startReload(state, weapon, now)` | Returns whether a reload started. |
| `isReloading(state, now)`, `reloadProgress(state, weapon, now)` | Reload state, progress from 0 to 1. |
| `damageAt(weapon, distance)` | Damage of one pellet after falloff. |
| `spreadOf(weapon, state, speedRatio)` | Current cone in degrees: base plus movement plus bloom. `speedRatio` is 0 to 1 of run speed. |

## Hitscan

Hitscan shots are rays. The world uses these, and you can call them yourself for a line-of-fire check.

| Export | What it does |
|---|---|
| `hitscan(level, origin, dir, range, bodies, ignoreId?, out?)` | The nearest body or level surface along a unit ray. Returns a `ShotHit`: `kind` (`'body'`, `'level'`, `'none'`), `id`, `distance`, `x`, `y`, `z`, the surface normal `nx`, `ny`, `nz` (level hits only), `headshot`. |
| `raycastBody(origin, dir, maxDist, body)` | Distance along a ray to one vertical capsule, or `null`. |
| `createShotHit()` | An empty `ShotHit` to reuse as `out`. |
| `HEAD_FRACTION` | `0.8`: hits above 80% of a body's height are headshots. |
| `HitBody` | `{ id, x, y, z, radius, height }`, feet position plus capsule size. |

## Projectiles

`ProjectileSystem` moves bolts, rockets and grenades with swept segments, so fast projectiles never pass through thin walls or bodies. At most 96 are alive; spawning another drops the oldest. A projectile ignores its shooter for its first 0.25 seconds. `CombatWorld` owns one as `world.projectiles`; its `list` holds every `Projectile` in flight.

| Member | What it does |
|---|---|
| `spawn(by, weapon, origin, dir, now)` | Fires one projectile. |
| `step(dt, now, level, bodies, onEnd)` | Moves every projectile and calls `onEnd` with a `ProjectileEnd` (`kind` `'body'`, `'level'` or `'expired'`) for each that stopped. |
| `clear()` | Removes all. |
| `splashFraction(level, center, body, radius, minFraction)` | Explosion damage fraction for one body: 1 at the centre down to `minFraction` at the edge, 0 outside the radius or behind a wall. |

## Aim assist

On a phone, a thumb cannot aim like a mouse. Aim assist bends a shot toward the visible enemy nearest the crosshair, inside a cone. Bodies behind walls never count.

`CombatWorld` applies it in `aimFromView`, scaled by each weapon's `aimAssist`. Set it with the world's `aimAssist` option, or pass `false` to turn it off.

| `AimAssistConfig` field | Range | Default | What it does |
|---|---|---|---|
| `coneDeg` | 0 to 30 | `9` | Full cone in which a target pulls the aim. |
| `range` | metres | `60` (in `aimFromView`: the weapon's range) | Farthest target considered. |
| `strength` | 0 to 1 | `0.6` | How far the shot bends toward the target. |
| `aimHeight` | fraction | `0.6` | Point on the body to aim at (chest). |

The aim helpers, for your own shooting or bots:

| Function | What it does |
|---|---|
| `aimAssist(level, origin, dir, bodies, ignoreId, config?, out?)` | Writes the corrected direction into `out`. Returns `{ id, angleDeg }`, the body it locked on. |
| `aimDirection(yaw, pitch, out?)` | Unit view direction. Yaw 0 looks along +Z; positive pitch looks down. |
| `spreadDirection(dir, spreadDeg, rng, out?)` | A random direction inside a cone. |
| `leadTarget(from, target, vel, speed, out?)` | Where a moving target will be when a projectile arrives. |
| `angleBetween(a, b)` | Degrees between two unit vectors. |
| `normalize(v)` | Normalises a `Vec3` in place. |

## Status effects

A weapon's `status` leaves an effect on every hit. A second hit of the same kind refreshes it: the longer end and the stronger power win. Effects do not stack.

| `kind` | `power` | Default power | Effect |
|---|---|---|---|
| `'burn'` | damage per second, 1 to 200 | `10` | Damage over time. The kill goes to the shooter. |
| `'slow'` | speed factor, 0.1 to 0.95 | `0.5` | Multiplies movement. Sprint is turned off. |
| `'stun'` | ignored | | No moving, no jumping, no shooting. |

`seconds` is 0.1 to 20. Each frame the world writes what the effects do into `combatant.effects` (a `StatusFrame`: `damage`, `damageBy`, `speedScale`, `stunned`). For the player, call `applyEffectsToCommand(cmd, combatant)` before `stepCharacter`. It also freezes the command of a dead combatant. Bots get it through the squad and `driveBotWeapon`. `addStatus(list, spec, now, by?)` and `stepStatuses(list, dt, now, out?)` are the pure functions underneath.

## CombatWorld

`new CombatWorld(options)` holds everyone who can shoot or be shot in one game.

| Option | Type | Default | What it does |
|---|---|---|---|
| `level` | `Level` | required | The level from `resolveLevel`. Shots stop at its boxes. |
| `friendlyFire` | boolean | `false` | Team-mates can hurt each other. |
| `selfDamage` | boolean | `true` | Your own explosions hurt you, at half damage. |
| `respawnSeconds` | number | `3` | Seconds before a dead combatant comes back. `0` means never (rounds, survival). |
| `spawnPoints` | `ResolvedPoint[]` | `level.spawns` | Respawn uses the point whose nearest living enemy is farthest away. |
| `noise` | `NoiseBus` | none | Pass `squad.noise` so bots hear shots and explosions. |
| `aimAssist` | `AimAssistConfig` or `false` | `{}` (on, defaults above) | Aim assist for `aimFromView`. |
| `seed` | number | `11` | Seed for spread and aim error. |

### Combatants

A combatant is a body you already have: the player's `CharacterState`, or a bot's `bot.state`. The world reads and pushes that body; you keep moving it with `stepCharacter` or the squad.

```ts
world.add({ id: 'player', name: 'You', color: '#7C5CFF', team: null, body, params, weapon: 'rifle', health: { max: 100, regenPerSecond: 12 } });
world.add({ id: bot.id, name: bot.name, color: bot.color, body: bot.state, params: bot.params, weapon: 'smg', bot: true });
```

| `CombatantSpec` field | Default | What it does |
|---|---|---|
| `id` | required | Unique. Adding the same id again replaces it. |
| `name` | the id | Shown in the killfeed. |
| `team` | `null` | Same string is the same side. `null` is free-for-all. |
| `color` | `'#FFFFFF'` | Killfeed colour. |
| `health` | defaults above | A `HealthConfig`. |
| `weapon` | `'rifle'` | A preset name or a `WeaponConfig`. |
| `body` | required | The `CharacterState` this combatant is. |
| `params` | required | Its `CharacterParams` (radius and height set the hit capsule). |
| `bot` | `false` | Marks a bot. |

A `Combatant` adds fields you read for the HUD and the score: `health` (`hp`, `max`, `armor`, `dead`), `weapon`, `weaponState` (`ammo`), `statuses`, `effects`, `kills`, `deaths`, `assists`, `streak`, `bestStreak`, `damageDealt`, `shotsFired`, `shotsHit`, `respawnAt`, `lastHitAt`, `lastKillAt`, `lastHurtAt`. An assist is damage dealt within 6 seconds before someone else's kill. A self-kill costs one kill.

### Methods

| Method | What it does |
|---|---|
| `add(spec)`, `remove(id)`, `get(id)` | Manage combatants. `get(null)` returns `null`. |
| `equip(id, weapon, overrides?)` | Swap a weapon (pickups, loadouts). The magazine is full. |
| `hostile(a, b)` | Whether two combatants are enemies. |
| `bodies()` | Hit capsules of the living, positions refreshed. |
| `targetsFor(id)` | Living enemies of `id`, shaped as `BotTarget[]` for `squad.step`. |
| `muzzleOf(combatant, dir, out?)` | Where shots leave: chest height, a little in front of the body. |
| `aimFromView(id, viewOrigin, viewDir, out)` | Turns a camera ray into a shot direction from the muzzle, with aim assist. What is under the crosshair is what gets hit, also in third person. Returns `{ id, angleDeg }` of the locked target. |
| `pullTrigger(id, held, pressed, dir, now, dt?)` | The fire button. Returns the shots fired. Stunned combatants cannot fire. |
| `reload(id, now)` | Starts a reload. |
| `damage(targetId, amount, by, now, options?)` | Hurts a combatant from game code: lava, traps, a punch you resolve yourself. `by` `null` is the world. Options: `headshot`, `point`, `push`, `knockback`, `weapon` (name in the killfeed), `status`, `quiet` (no damage number). Returns the damage dealt. |
| `step(dt, now)` | Projectiles, burns, regeneration, falls below `level.killY`, respawns, fading effects. Once per frame. |
| `respawn(combatant, now, at?)` | Brings a combatant back now, at the safest spawn or at `at`. |
| `safestSpawn(combatant)` | The spawn point farthest from living enemies. |
| `drainEvents()` | Events since the last call. |
| `scoreboard()` | Combatants sorted by kills, then fewest deaths, then assists. |
| `teamScores()` | Kills per team. Free-for-all players count under their own id. |
| `reset(now)` | Clears projectiles, effects, the feed and pending events for a new round. |

Lists to draw from, refreshed by `step`: `feed` (`FeedEntry[]`, the last 5 kills for 6 seconds), `tracers` (`Tracer[]`), `impacts` (`Impact[]`, sparks and explosions), `numbers` (`DamageNumber[]`), `projectiles`. Also `combatants`, `level`, `rng` and `now`.

### Events

`drainEvents()` returns `CombatEvent[]`. Use them for sounds, haptics and scoring.

| `type` | Fields |
|---|---|
| `'shot'` | `by`, `weapon`, `at` |
| `'hit'` | `by`, `target`, `damage`, `headshot`, `killed`, `x`, `y`, `z`, `at` |
| `'kill'` | `by`, `target`, `weapon`, `headshot`, `streak`, `at` |
| `'explode'` | `by`, `x`, `y`, `z`, `radius`, `at` |
| `'reload'` | `id`, `at` |
| `'respawn'` | `id`, `at` |

## Bots that shoot

The bot's brain (see [Bots](https://docs.moonspell.app/engine/bots.md)) decides where to go. The gunner decides when to shoot and how badly to aim. A gunner fires at the bot's current target while the bot sees it, is in `chase` or `strafe`, and the target is in range. It fires in bursts with pauses and aim error, so bots feel like players and not turrets. A bot that gets shot turns toward whoever shot it.

```ts
import { createGunner, driveBotWeapon } from '@/engine';

const gunner = createGunner('normal', { fleeBelow: 0.25 });
// every frame, after squad.step:
driveBotWeapon(world, bot, gunner, now, dt);
```

| `GunnerConfig` field | easy | normal | hard | Range |
|---|---|---|---|---|
| `aimErrorDeg` (full cone) | 7 | 4 | 2 | 0 to 20 |
| `burstSeconds` | 0.5 | 0.8 | 1.1 | 0.1 to 4 |
| `pauseSeconds` | 1.2 | 0.75 | 0.45 | 0 to 4 |
| `rangeFraction` (of weapon range) | 0.55 | 0.75 | 0.9 | 0.1 to 1 |
| `lead` | `true` | `true` | `true` | Projectile weapons aim ahead of moving targets. |
| `fleeBelow` | `0` | `0` | `0` | Health fraction under which the gunner sets `bot.scared`, so the bot runs. 0 to 1. |

Exports: `createGunner(difficulty?, config?)` returns a `Gunner` (its `firing` field is true on frames it held the trigger); `resolveGunner(difficulty?, config?)` returns the `GunnerProfile`; `driveBotWeapon(world, bot, gunner, now, dt)` returns the shots fired. The combat bot's id must match its squad bot's id.

Roles that fit: `skirmisher` with rifles or blasters (keeps its range and circles), `hunter` with shotguns or swords (closes in), `guard` with a sniper on patrol.

## CombatEffects

`<CombatEffects>` draws a `CombatWorld` inside the `EngineCanvas`: tracers for hitscan shots, glowing bolts for projectiles, sparks and explosion balls. It uses fixed pools of meshes moved every frame (24 tracers, 48 bolts, 16 impacts), so a firefight never allocates or re-renders React.

| Prop | Type | What it does |
|---|---|---|
| `world` | `CombatWorld` | Required. |
| `now` | `() => number` | Required. Returns the same game clock the world runs on. |
| `numbersOut` | `ScreenNumber[]` | Optional. Filled every frame with damage numbers projected to screen points, for `<DamageNumbers>`. |

## CombatHud

HUD pieces as React Native views, placed outside the canvas. Feed them from a snapshot you update a few times a second. `DamageNumbers` is the exception: it polls the array `CombatEffects` fills.

| Component | Props | What it shows |
|---|---|---|
| `Crosshair` | `spreadDeg` (1), `locked` (false), `color`, `lockColor`, `hidden` (false) | Centre crosshair that grows with spread and turns `lockColor` when aim assist has a target. |
| `HitMarker` | `hitAt`, `killAt`, `now` (all required) | An X for 0.15 s after a hit, larger and red for 0.35 s after a kill. Pass the combatant's `lastHitAt` and `lastKillAt`. |
| `HealthBar` | `hp`, `max` (required), `armor` (0), `maxArmor` (0), `accent` | Health bar and number. Shows an armour bar when `maxArmor` is above 0. Turns red under 30%. |
| `AmmoCounter` | `ammo`, `magazine`, `reloading` (required), `weapon` | `ammo / magazine`, `RELOADING`, or an infinity sign when the magazine is 0. |
| `Killfeed` | `entries` (`FeedEntry[]`) | The last eliminations. Pass a copy of `world.feed`. |
| `DamageNumbers` | `source` (`ScreenNumber[]`), `onlyBy` | Floating numbers. `onlyBy="player"` shows only the player's hits. |

Colours default to the template's theme (`@/theme`). Every piece has a `testID` (`hud-crosshair`, `hud-hit-marker`, `hud-kill-marker`, `hud-health`, `hud-ammo`, `hud-killfeed`) for device tests.

Pulse Arena marks its FIRE button `look: true`, so the finger that holds it also turns the camera: hold FIRE and drag to aim with the same thumb. FIRE stays held until that finger lifts, wherever it slides (see [Input](https://docs.moonspell.app/engine/input.md)).

## Gotchas

- Read `input.held.fire` and `input.pressed.fire` before `consumeInput`. It clears this frame's presses, and semi-automatic weapons only fire on a press.
- Pass one game clock to everything: `pullTrigger`, `step`, `driveBotWeapon`, and the `now` prop of `CombatEffects`. Mixing clocks breaks fire rates, regeneration and fading effects.
- `CombatWorld` never draws or moves bodies on its own beyond knockback and respawn. Hide the player's mesh while `health.dead`; `BotMeshes` hides bots with `disabled` set, so set `bot.disabled` from the world each frame.
- Bots and combatants are linked only by id. Use the same id in `squad.add` and `world.add`.
- Falling below `level.killY` kills a combatant in `step`. The kill shows as `'fall'` in the feed.
- A dead player still has a body. Stop moving it: `applyEffectsToCommand` zeroes the command of a dead combatant.
- Projectiles are capped at 96 alive and `CombatEffects` draws 48 bolts. Keep about 6 combatants with hitscan weapons for a steady frame on a phone.
- Tuning that plays well on a phone: a time to kill of 0.8 to 1.5 seconds with the rifle on 100 hp, aim assist cone 6 to 10 degrees with strength 0.5 to 0.6, bots at `normal`. Lower damage beats lower aim assist.
- `CombatWorld` runs on one device. Online, one phone (the host) runs it and the others send their aim and trigger; the [shooter module](https://docs.moonspell.app/modules/shooter.md) does this for you. Leaderboards for kills or times need the game's backend: see [Backend overview](https://docs.moonspell.app/backend/overview.md) and [Networking](https://docs.moonspell.app/engine/net.md).

## See also

- [Examples](https://docs.moonspell.app/examples.md): **Pulse Arena** (`examples/pulse-arena/`) composes the whole kit: rifle, shotgun and rockets, aim assist, health with regeneration, armed bots that strafe and flee, killfeed, respawns, first to 10. **Flag Rush** (`examples/flag-rush/`) is capture the flag on the shooter module.
- [Character and cameras](https://docs.moonspell.app/engine/character-and-cameras.md): the level, the controller and the camera rig the kit runs on.
- [Bots](https://docs.moonspell.app/engine/bots.md): the brains that move the bots this kit arms.
- [Shooter module](https://docs.moonspell.app/modules/shooter.md): ready match modes built on this kit.
- [Kingdom module](https://docs.moonspell.app/modules/kingdom.md): its sorties use their own simpler fight rules, not `CombatWorld`.
- [Input](https://docs.moonspell.app/engine/input.md): `CharacterControls` and its buttons.
