Combat
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: 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 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, 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 page; read it first if those lines are new to you.
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. 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:
- Read
input.held.fireandinput.pressed.fire, then callconsumeInput.consumeInputclears the presses. applyEffectsToCommand(cmd, world.get('player')), thenstepCharacterfor the player.- Set
bot.disabledfor dead bots, thensquad.step(...). world.aimFromView(...)turns the camera ray into a shot direction;world.pullTrigger(...)fires.driveBotWeapon(...)for each bot.world.step(dt, now): projectiles, burns, regeneration, falls, respawns, fading effects.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. |
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. |
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.
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) 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.
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).
Gotchas
- Read
input.held.fireandinput.pressed.firebeforeconsumeInput. 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 thenowprop ofCombatEffects. Mixing clocks breaks fire rates, regeneration and fading effects. CombatWorldnever draws or moves bodies on its own beyond knockback and respawn. Hide the player's mesh whilehealth.dead;BotMesheshides bots withdisabledset, so setbot.disabledfrom the world each frame.- Bots and combatants are linked only by id. Use the same id in
squad.addandworld.add. - Falling below
level.killYkills a combatant instep. The kill shows as'fall'in the feed. - A dead player still has a body. Stop moving it:
applyEffectsToCommandzeroes the command of a dead combatant. - Projectiles are capped at 96 alive and
CombatEffectsdraws 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. CombatWorldruns on one device. Online, one phone (the host) runs it and the others send their aim and trigger; the shooter module does this for you. Leaderboards for kills or times need the game's backend: see Backend overview and Networking.
See also
- Examples: 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: the level, the controller and the camera rig the kit runs on.
- Bots: the brains that move the bots this kit arms.
- Shooter module: ready match modes built on this kit.
- Kingdom module: its sorties use their own simpler fight rules, not
CombatWorld. - Input:
CharacterControlsand its buttons.