# Bots

> Computer opponents that patrol, hear, see, chase, keep their distance or flee on the same character controller as the player, with a nav grid, steering, senses and a roster that fills empty seats.

The bots kit (`src/engine/bots/`) gives a 3D character game opponents: guards that patrol and investigate noises, hunters that chase you down, skirmishers that keep their distance and circle, cowards that run. Use it for tag, stealth, horror, arena fights, or to fill empty seats in an online room.

Bots move with the same character controller as the player (`stepCharacter`, see [Character and cameras](https://docs.moonspell.app/engine/character-and-cameras.md)), with the player's params scaled by their profile. They obey walls, gravity and collisions exactly like the player, so they are fair. Everything here is plain TypeScript except `<BotMeshes>`. To make bots shoot, add the [combat kit](https://docs.moonspell.app/engine/combat.md)'s gunners.

## Smallest working example

Two guards patrol the arena. They hear you sprint and land, see you when you cross their view, chase you, and back off after a tag. This is `examples/lantern-tag/` cut down to the bot parts.

```tsx
import React, { useMemo, useRef } from 'react';
import { StyleSheet, View } from 'react-native';
import { useFrame } from '@react-three/fiber';
import {
  BotMeshes,
  BotSquad,
  CharacterCamera,
  CharacterControls,
  CharacterMesh,
  EngineCanvas,
  LevelMeshes,
  TopBar,
  WORLD_FRAME,
  backOff,
  consumeInput,
  createCharacterInput,
  createCharacterState,
  createCommand,
  createRoster,
  moveFrameFor,
  resolveCameraRig,
  resolveCharacterParams,
  resolveLevel,
  stepCharacter,
  type CharacterHandle,
  type CharacterInput,
  type CharacterState,
  type GameDefinition,
  type GameScreenProps,
  type Vec2,
} from '@/engine';

const LEVEL = resolveLevel({ preset: 'arena', seed: 4 });
const PARAMS = resolveCharacterParams({ runSpeed: 6.5, walkSpeed: 3 });
const PATROLS: Vec2[][] = [
  [{ x: -16, z: -14 }, { x: -16, z: 14 }, { x: -5, z: 14 }],
  [{ x: 16, z: 14 }, { x: 16, z: -14 }, { x: 5, z: -14 }],
];

const makeSquad = () => {
  const squad = new BotSquad({ level: LEVEL, params: PARAMS, seed: 9 });
  // One human seat (you); the roster names and colours the bots that fill the rest.
  const roster = createRoster({ targetPlayers: 3, maxBots: 2 });
  roster.reconcile(1);
  roster.bots.forEach((b, i) => {
    const route = PATROLS[i % PATROLS.length];
    squad.add({ id: b.id, name: b.name, color: b.color, role: 'guard', difficulty: 'normal', spawn: { x: route[0].x, z: route[0].z }, patrol: route });
  });
  return squad;
};

const Simulation = ({ input, body, squad }: { input: CharacterInput; body: { current: CharacterState }; squad: BotSquad }) => {
  const rig = useMemo(() => resolveCameraRig('third'), []);
  const mesh = useRef<CharacterHandle>(null);
  const cmd = useRef(createCommand());
  const frame = useRef({ ...WORLD_FRAME });
  const clock = useRef(0);
  const footstepAt = useRef(0);
  const target = useRef({ id: 'player', x: 0, y: 0, z: 0 });

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

    consumeInput(input, cmd.current);
    stepCharacter(s, cmd.current, dt, LEVEL, PARAMS, moveFrameFor(rig, s, frame.current));

    // Noises the guards can hear. `by: 'player'` lets bots ignore their own.
    if (s.landed) squad.noise.post({ x: s.x, z: s.z, loudness: 0.6, at: now, by: 'player' });
    if (s.grounded && s.speed > PARAMS.walkSpeed + 0.5 && now - footstepAt.current > 0.35) {
      footstepAt.current = now;
      squad.noise.post({ x: s.x, z: s.z, loudness: 0.35, at: now, by: 'player' });
    }

    // Bots think and move after the player.
    const t = target.current;
    t.x = s.x;
    t.y = s.y;
    t.z = s.z;
    squad.step(dt, now, [t]);

    // A tag: the bot backs off for 3 seconds.
    for (const bot of squad.bots) {
      const b = bot.state;
      if (Math.hypot(b.x - s.x, b.z - s.z) < PARAMS.radius * 2 + 0.15 && now > bot.backoffUntil) backOff(bot, now, 3);
    }

    if (mesh.current?.group) {
      mesh.current.group.position.set(s.x, s.y, s.z);
      mesh.current.group.rotation.y = s.yaw;
    }
  });

  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} />
      <CharacterCamera rig={rig} target={body} level={LEVEL} bodyHeight={PARAMS.height} />
    </>
  );
};

const Tag = ({ onExit }: GameScreenProps) => {
  const input = useMemo(() => createCharacterInput(), []);
  const body = useRef<CharacterState>(createCharacterState({ x: 0, y: 0, z: -19, yaw: 0 }));
  const squad = useMemo(() => makeSquad(), []);
  return (
    <View style={styles.root}>
      <EngineCanvas style={StyleSheet.absoluteFill} background={LEVEL.theme.sky}>
        <Simulation input={input} body={body} squad={squad} />
      </EngineCanvas>
      <CharacterControls input={input} />
      <View style={StyleSheet.absoluteFill} pointerEvents="box-none">
        <TopBar title="Tag" status="Do not get caught" onExit={onExit} />
      </View>
    </View>
  );
};

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

const game: GameDefinition = { id: 'tag', title: 'Tag', description: 'Two guards patrol the arena. They hear you run.', Component: Tag };
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 lanterns to collect, lives, a "SPOTTED" banner, a best time and a dev autopilot is `examples/lantern-tag/index.tsx`.

## BotSquad

`BotSquad` owns the bots of one game. It builds the shared nav grid from the level, keeps the shared noise bus, and each frame runs every bot's brain and then `stepCharacter` for its body.

```ts
const squad = new BotSquad({ level, params });
squad.add({ id: 'g1', role: 'guard', spawn: { x: 0, z: 10 } });
// each frame, after the player moved:
const summary = squad.step(dt, now, [{ id: 'player', x, y, z }], running);
```

| `BotSquadOptions` field | Default | What it does |
|---|---|---|
| `level` | required | The level from `resolveLevel`. |
| `params` | required | The player's `CharacterParams`. Each bot scales them by its profile. |
| `grid` | built from the level | A prebuilt `NavGrid`. |
| `gridOptions` | radius, height and step height from `params` | Options for `buildNavGrid`. See [Nav grid](https://docs.moonspell.app/engine/bots.md#nav-grid). |
| `noise` | a new `NoiseBus` | Share one bus with a `CombatWorld` so bots hear shots. |
| `seed` | `3` | Seed for wandering and flee points. |

| Member | What it does |
|---|---|
| `add(spec)` | Creates a bot from a `BotSpec` and returns it. |
| `remove(id)`, `get(id)`, `clear()` | Manage bots. |
| `step(dt, now, targets, running?)` | Advances every bot. Returns the `SquadSummary`. |
| `bots`, `grid`, `noise`, `level` | The bots, the nav grid, the noise bus, the level. |
| `summary` | `{ bots, seeing, chasing, path }`: active bots, how many see a target, how many chase or strafe, the longest current path in points. Use it for HUD hints ("SPOTTED" when `chasing` is above 0). |

`targets` is a `BotTarget[]` (`{ id, x, y, z }`), usually the player. Pass a function `(bot) => BotTarget[]` to give each bot its own list; for free-for-all with combat, `(bot) => world.targetsFor(bot.id)`. A bot never targets itself, and a target that leaves the list is forgotten. `running` `false` keeps bots still (a countdown); they still fall and settle.

Set `bot.disabled = true` for a bot that is out (dead, benched). The squad skips it and `BotMeshes` hides it.

## Bot specs, roles and difficulty

| `BotSpec` field | Default | What it does |
|---|---|---|
| `id` | required | Unique. Take it from the roster. |
| `name` | the id | Display name. |
| `color` | `'#FF5C7A'` | Mesh colour. |
| `role` | `'guard'` | `'guard'`, `'hunter'`, `'skirmisher'` or `'coward'`. |
| `difficulty` | `'normal'` | `'easy'`, `'normal'` or `'hard'`. |
| `overrides` | none | Any profile field below, per bot. |
| `spawn` | required | `{ x, y?, z, yaw? }`. |
| `patrol` | `[]` | A loop of `{ x, z }` points. Empty means wander between random reachable points. |

Roles:

- `guard` patrols, investigates noises, chases what it sees, and gives up when it loses you for `memorySeconds`.
- `hunter` does the same but remembers longer (14 s), hears farther (20 m) and patrols faster.
- `skirmisher` keeps `preferredRange` (7 m) from its target and circles it. Pair it with a gun.
- `coward` runs when a target comes within `fleeDistance` (9 m). Prey, panicked civilians, hide and seek.

The profile comes from the difficulty, then the role, then `overrides`. Every field is clamped.

| `BotProfileConfig` field | easy / normal / hard | Range | What it does |
|---|---|---|---|
| `reactionMs` | 900 / 450 / 150 | 0 to 3000 | How long a target must stay in view before the bot reacts. |
| `sightRange` | 10 / 16 / 24 | 3 to 60 | Metres. |
| `fovDeg` | 100 / 120 / 150 | 30 to 360 | Full field of view. |
| `hearingRange` | 8 / 14 / 22 (hunter 20) | 0 to 60 | Metres a loudness-1 noise carries. |
| `memorySeconds` | 3 / 6 / 10 (hunter 14) | 0 to 30 | How long a lost target is remembered. |
| `speed` | 0.8 / 0.95 / 1 (coward 1) | 0.3 to 1.5 | Top speed, times the player's run speed. |
| `patrolSpeed` | 0.9 (hunter 1.1) | 0.3 to 1.5 | Patrol speed, times the player's walk speed. |
| `preferredRange` | 6 (skirmisher 7) | 2 to 20 | Metres a skirmisher keeps. |
| `fleeDistance` | 6 (coward 9) | 1 to 30 | Metres at which a coward runs. |
| `pauseSeconds` | 1.2 | 0 to 10 | Wait at a patrol point or an investigated noise. |
| `repathMs` | 400 | 100 to 2000 | Time between path recomputations. |

`BOT_ROLES` and `BOT_DIFFICULTIES` list the allowed values. `resolveBotProfile(role?, difficulty?, overrides?)` returns a full `BotProfile`; `botParamsFrom(playerParams, profile)` returns the `CharacterParams` a bot runs with.

## Brain and behaviours

`stepBot(bot, world, dt, cmd)` is one frame of a bot's mind: senses, then a decision, then steering. It writes a world-frame stick and sprint into `cmd`; the caller runs `stepCharacter` with `bot.params` and `WORLD_FRAME`. `BotSquad.step` does this for you. Use `stepBot` and `createBot(spec, playerParams?)` directly only when you manage bots yourself; you then build the `BotWorld` (`level`, `grid`, `noise`, `now`, `targets`, `others`, `rng`).

`bot.behaviour` is one of:

| Behaviour | When |
|---|---|
| `'idle'` | Before the first step. |
| `'patrol'` | Walking the patrol loop or wandering. |
| `'investigate'` | Heard a noise or lost a target: goes to the spot, waits, returns to patrol. |
| `'chase'` | Saw a target for `reactionMs`. Sprints along a path, re-pathing every `repathMs`. |
| `'strafe'` | A skirmisher that sees its target inside 1.3 times `preferredRange`: circles it, facing it. |
| `'flee'` | A coward near a target, a bot with `scared` set, or a bot backing off. Runs toward the reachable point farthest from the threat. |
| `'objective'` | `bot.objective` is set and no enemy is in mind (or `objectiveRush` is on). |
| `'return'` | Walking back to patrol. |

Fields the game sets on a `Bot`:

| Field | Default | What it does |
|---|---|---|
| `disabled` | `false` | The bot is out. The squad skips it, `BotMeshes` hides it. |
| `scared` | `false` | Any role flees. The combat kit's gunner sets it below `fleeBelow` health. |
| `objective` | `null` | A planar point to go to and hold (a flag, a hill, a base). |
| `objectiveRadius` | `1.5` | Metres from the objective that count as there. |
| `objectiveRush` | `false` | Ignore enemies and keep running to the objective (a flag carrier). |

`backOff(bot, now, seconds?)` makes a bot retreat from its target for a while, default 2.5 seconds. Call it after a tag or a hit.

Fields you read: `behaviour`, `seesTarget` (true on frames the bot sees a target), `targetId`, `lastKnown`, `path`, `state` (its `CharacterState`), `params`, `profile`.

## Senses

```ts
import { canSee, NoiseBus } from '@/engine';
```

`canSee(observer, target, level, sight, targetHeight?)` is true when the target is within `sightRange`, inside `fovDeg` of the observer's `yaw`, and nothing in the level blocks the line from the eyes (`eyeHeight`) to the target's chest. `targetHeight` defaults to 1.7. It also works as a line-of-sight check outside bots.

`NoiseBus` holds things that happened loudly. The game posts; each bot asks what it heard since it last listened. A noise carries `hearingRange` times `loudness` metres. The bus keeps 4 seconds of history and at most 48 noises.

| Member | What it does |
|---|---|
| `post({ x, z, loudness, at, by? })` | Records a noise. `loudness` 0 to 1. Set `by` so bots ignore their own. |
| `heardBy(x, z, hearingRange, since, ignoreBy?)` | The newest noise in range made after `since`, or `null`. |
| `prune(now)`, `clear()`, `count` | Housekeeping. `BotSquad.step` prunes for you. |

Loudness that plays well: running footsteps 0.35 every 0.35 s, a landing 0.6, a thrown object 0.8, a shot 1. `CombatWorld` posts shots (the weapon's `loudness`) and explosions (1) when you give it the squad's bus.

## Nav grid

The nav grid is a walkable grid over the level, built from its boxes. Cells are walkable where a body of the agent's radius can stand: the floor, platform tops, ramps and stairs. Neighbouring cells connect when their surfaces are within a step. Paths are A* over 8 neighbours, with no corner cutting, then string-pulled.

```ts
import { buildNavGrid, findPath, nearestWalkable } from '@/engine';

const grid = buildNavGrid(level, { cellSize: 1, agentRadius: params.radius });
const path = findPath(grid, { x: 0, z: -10 }, { x: 8, z: 12 }); // Vec2[] or null
```

| `NavGridOptions` field | Range | Default | What it does |
|---|---|---|---|
| `cellSize` | 0.5 to 4 | `1` | Metres per cell. |
| `agentRadius` | metres | `0.4` | Obstacles grow by this. |
| `agentHeight` | metres | `1.7` | Boxes higher than this above the surface are walked under. |
| `stepHeight` | metres | `0.35` | Boxes lower than this are stepped onto, not around. |
| `groundY` | metres | the level's floor (0 when there is none) | Ground height. |

| Function | What it does |
|---|---|
| `buildNavGrid(level, options?)` | Builds the `NavGrid`. |
| `findPath(grid, from, to, { smooth? })` | World points from `from` to `to`, or `null` when unreachable. Ends inside an obstacle snap to the nearest walkable cell. `smooth` defaults to true. |
| `smoothPath(grid, path)` | Drops points a straight line can skip. |
| `nearestWalkable(grid, x, z, maxRings?)` | Nearest walkable cell centre, or `null`. `maxRings` defaults to 12. |
| `randomReachable(grid, from, rng, minDist?)` | A random reachable cell centre, at least `minDist` (default 4) away when possible. |
| `farthestFrom(grid, from, threat, rng, samples?)` | A reachable point far from `threat` (for fleeing). |
| `gridLineClear(grid, a, b)` | True when every cell under the segment is walkable and steppable. |
| `walkableAt(grid, x, z)`, `cellOf(grid, x, z)`, `centerOf(grid, c, r, out?)`, `canStep(grid, i, j)` | Low-level cell queries. |

The grid works for your own code too: the Lantern Tag and Pulse Arena autopilots walk the player along `findPath(squad.grid, ...)`.

## Steering

Steering functions write a planar direction (inside the unit disc) into `out`. Combine them with `addWeighted` and `clampUnit`, then turn the result into a stick with `worldDirToStick` from the [character kit](https://docs.moonspell.app/engine/character-and-cameras.md). The brain uses all of them; you can use them for your own movers.

| Function | What it does |
|---|---|
| `seek(from, to, out)` | Straight toward a point. |
| `arrive(from, to, slowRadius, out, stopRadius?)` | Seek that eases off inside `slowRadius` and stops within `stopRadius` (default 0.25). |
| `flee(from, threat, out)` | Straight away. |
| `orbit(from, target, range, band, clockwise, out)` | Holds `range` from a target and circles it. |
| `separation(from, others, radius, out)` | Pushes away from neighbours closer than `radius`. |
| `wander(state, rng, jitter, out)` | A slowly drifting heading. `state` is a `WanderState` (`{ angle }`). |
| `addWeighted(acc, v, weight)`, `clampUnit(v)`, `zero(out)` | Vector helpers. |

## Avoidance

`avoid(self, desired, others, out, options?)` steers around moving neighbours. It tries directions around the one the bot wants, at three paces, and scores each by how far it turns, how soon it would hit a neighbour (each agent is assumed to take half the dodge), and how much it turns from the current heading, so a dodge sticks instead of jittering. Ties go to the right, so two bots meeting head-on pass each other. The brain calls it every frame, then adds a gentle separation push.

| `AvoidOptions` field | Default | What it does |
|---|---|---|
| `radius` | `0.4` | Body radius of each agent. |
| `horizon` | `1.5` | Seconds ahead to look for collisions. |
| `speed` | `4` | Agent speed in m/s, to turn the direction into a velocity. |
| `blocked` | none | `(x, z) => boolean`, true where the agent cannot stand. Directions that hit one within half a second are skipped. |

`timeToCollision(px, pz, wx, wz, r)` returns the seconds until two discs touch: `Infinity` if never, `0` if touching.

## Roster

The roster decides how many bots a room needs so it never feels empty, and who they are. Humans always get the seat: when a human joins, the newest bot leaves.

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

const roster = createRoster({ targetPlayers: 4, maxBots: 6 });
for (const change of roster.reconcile(humanCount)) {
  if (change.type === 'bot-joined') squad.add({ id: change.bot.id, name: change.bot.name, color: change.bot.color, spawn });
  else squad.remove(change.bot.id);
}
```

| `RosterOptions` field | Range | Default |
|---|---|---|
| `targetPlayers` | 1 to 16 | `4` |
| `maxBots` | 0 to 12 | `6` |
| `names` | strings | `BOT_NAMES` (Juno, Pax, Vega and 9 more) |
| `colors` | CSS colours | `BOT_COLORS` (8 colours) |

`reconcile(humanCount)` returns `SeatChange[]` (`bot-joined` or `bot-left`, each with a `RosterBot`: `id` like `bot-0`, `name` like `Bot Juno`, `color`, `seat`). `bots` lists the current bots, `reset()` forgets everyone. With an online room, call `reconcile` with the number of non-spectator players whenever the room's player list changes. Without a backend the room has one player, you, so bots fill the rest. See [Networking](https://docs.moonspell.app/engine/net.md).

## BotMeshes

`<BotMeshes>` draws every bot inside the `EngineCanvas` with the engine's `CharacterMesh`, moved every frame from `bot.state`. A bot glows in `alertColor` while it chases or strafes and turns its head toward what it sees. Disabled bots are hidden.

| Prop | Type | Default | What it does |
|---|---|---|---|
| `bots` | `readonly Bot[]` | required | The squad's bots. Pass a fresh array when the roster changes. |
| `radius` | number | the mesh default | Body radius. Pass `params.radius`. |
| `height` | number | the mesh default | Body height. Pass `params.height`. |
| `alertColor` | CSS colour | `'#FF3B30'` | Glow while hunting. |

For your own bot models, read `bot.state` (`x`, `y`, `z`, `yaw`) and `bot.behaviour` in a `useFrame` the same way.

## Gotchas

- Step the squad after the player moved, every frame, with the same game clock you use for noises.
- Keep `speed` at or below 1 unless the game is meant to be unfair. A bot faster than the player can never be escaped.
- The nav grid is planar per surface. Bots walk ramps and stairs but do not jump between platforms.
- Corridors bots must pass need room for the grown body: leave at least 1.8 m, or build the grid with a smaller `cellSize`.
- Post noises for running and landing. Without them, hearing does nothing and guards only react to sight.
- `BotMeshes` reads the `bots` array you pass. After `squad.add` or `squad.remove`, pass a new array (or re-create the squad) so React sees the change.
- Cost: six bots on the arena preset stay well under a millisecond a frame on a recent iPhone. Keep 8 bots or fewer and `cellSize` at 1 m or more on big levels.
- Bots run on one device. In an online room, run them on one phone (the host) and send their poses with the room state; the [shooter module](https://docs.moonspell.app/modules/shooter.md) does this. Rooms need the game's backend: see [Backend overview](https://docs.moonspell.app/backend/overview.md).

## See also

- [Examples](https://docs.moonspell.app/examples.md): **Lantern Tag** (`examples/lantern-tag/`) is guards with patrols, hearing, sight, chases and back-offs, plus a roster and a nav-grid autopilot. **Pulse Arena** (`examples/pulse-arena/`) arms skirmisher and hunter bots with the combat kit. **Flag Rush** (`examples/flag-rush/`) shows bot objectives in capture the flag through the shooter module.
- [Character and cameras](https://docs.moonspell.app/engine/character-and-cameras.md): the controller and the level bots move through.
- [Combat](https://docs.moonspell.app/engine/combat.md): gunners that make bots shoot.
- [Shooter module](https://docs.moonspell.app/modules/shooter.md): sets `bot.objective` for flags and hills, and fills seats with the roster.
- [Kingdom module](https://docs.moonspell.app/modules/kingdom.md): its sorties use their own enemy logic, not `BotSquad`.
