Skip to main content

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 fieldDefaultWhat it does
levelrequiredThe level from resolveLevel.
paramsrequiredThe player's CharacterParams. Each bot scales them by its profile.
gridbuilt from the levelA prebuilt NavGrid.
gridOptionsradius, height and step height from paramsOptions for buildNavGrid. See Nav grid.
noisea new NoiseBusShare one bus with a CombatWorld so bots hear shots.
seed3Seed for wandering and flee points.
MemberWhat 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, levelThe 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 fieldDefaultWhat it does
idrequiredUnique. Take it from the roster.
namethe idDisplay name.
color'#FF5C7A'Mesh colour.
role'guard''guard', 'hunter', 'skirmisher' or 'coward'.
difficulty'normal''easy', 'normal' or 'hard'.
overridesnoneAny profile field below, per bot.
spawnrequired{ 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 fieldeasy / normal / hardRangeWhat it does
reactionMs900 / 450 / 1500 to 3000How long a target must stay in view before the bot reacts.
sightRange10 / 16 / 243 to 60Metres.
fovDeg100 / 120 / 15030 to 360Full field of view.
hearingRange8 / 14 / 22 (hunter 20)0 to 60Metres a loudness-1 noise carries.
memorySeconds3 / 6 / 10 (hunter 14)0 to 30How long a lost target is remembered.
speed0.8 / 0.95 / 1 (coward 1)0.3 to 1.5Top speed, times the player's run speed.
patrolSpeed0.9 (hunter 1.1)0.3 to 1.5Patrol speed, times the player's walk speed.
preferredRange6 (skirmisher 7)2 to 20Metres a skirmisher keeps.
fleeDistance6 (coward 9)1 to 30Metres at which a coward runs.
pauseSeconds1.20 to 10Wait at a patrol point or an investigated noise.
repathMs400100 to 2000Time 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:

BehaviourWhen
'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:

FieldDefaultWhat it does
disabledfalseThe bot is out. The squad skips it, BotMeshes hides it.
scaredfalseAny role flees. The combat kit's gunner sets it below fleeBelow health.
objectivenullA planar point to go to and hold (a flag, a hill, a base).
objectiveRadius1.5Metres from the objective that count as there.
objectiveRushfalseIgnore 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.

MemberWhat 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(), countHousekeeping. 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.

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 fieldRangeDefaultWhat it does
cellSize0.5 to 41Metres per cell.
agentRadiusmetres0.4Obstacles grow by this.
agentHeightmetres1.7Boxes higher than this above the surface are walked under.
stepHeightmetres0.35Boxes lower than this are stepped onto, not around.
groundYmetresthe level's floor (0 when there is none)Ground height.
FunctionWhat 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.

FunctionWhat 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 fieldDefaultWhat it does
radius0.4Body radius of each agent.
horizon1.5Seconds ahead to look for collisions.
speed4Agent speed in m/s, to turn the direction into a velocity.
blockednone(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 fieldRangeDefault
targetPlayers1 to 164
maxBots0 to 126
namesstringsBOT_NAMES (Juno, Pax, Vega and 9 more)
colorsCSS coloursBOT_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.

PropTypeDefaultWhat it does
botsreadonly Bot[]requiredThe squad's bots. Pass a fresh array when the roster changes.
radiusnumberthe mesh defaultBody radius. Pass params.radius.
heightnumberthe mesh defaultBody height. Pass params.height.
alertColorCSS 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 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.objective for flags and hills, and fills seats with the roster.
  • Kingdom module: its sorties use their own enemy logic, not BotSquad.