Bots
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), 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'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.
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. 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.
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. |
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:
guardpatrols, investigates noises, chases what it sees, and gives up when it loses you formemorySeconds.hunterdoes the same but remembers longer (14 s), hears farther (20 m) and patrols faster.skirmisherkeepspreferredRange(7 m) from its target and circles it. Pair it with a gun.cowardruns when a target comes withinfleeDistance(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
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.
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. 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.
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.
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
speedat 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.
BotMeshesreads thebotsarray you pass. Aftersquad.addorsquad.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
cellSizeat 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 does this. Rooms need the game's backend: see Backend overview.
See also
- Examples: 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: the controller and the level bots move through.
- Combat: gunners that make bots shoot.
- Shooter module: sets
bot.objectivefor flags and hills, and fills seats with the roster. - Kingdom module: its sorties use their own enemy logic, not
BotSquad.