Skip to main content

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:

  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.

FieldRangeDefaultWhat it does
max1 to 10000100Hit points.
armor0 to 10000Armour points on spawn.
armorAbsorb0 to 10.6Fraction of each hit the armour takes while it lasts.
regenPerSecond0 to 2000 (off)Hit points regained per second.
regenDelay0 to 304Seconds without damage before regeneration starts.
spawnProtection0 to 101.5Seconds of invulnerability after a spawn or respawn.

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

FunctionWhat 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.

PresetKindModeDamageShots per secondNotes
pistolhitscansemi22412 rounds, accurate.
riflehitscanauto14830 rounds. The default.
smghitscanauto91335 rounds, 32 m, more aim assist.
shotgunhitscansemi11 per pellet, 8 pellets1.36 shells, 22 m, knockback 6.
sniperhitscansemi85, headshot x20.8140 m, wide spread on the move, half aim assist.
blasterprojectileauto18534 m/s bolts you can dodge.
rocketprojectilesemi30, 4 m splash0.9Knockback 12. Hurts you too.
grenadeprojectilesemi20, 4.5 m splash0.8Arcs, bounces, 1.6 s fuse, no aim assist.
swordmeleesemi451.82.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):

FieldRangeDefaultWhat it does
namestring'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.
damage1 to 50014Per pellet at close range.
headshotMultiplier1 to 41.6Hitscan and projectiles only.
fireRate0.2 to 308Shots per second.
pellets1 to 201Rays or projectiles per shot.
spreadDeg0 to 301.2Cone of inaccuracy, full angle.
moveSpreadDeg0 to 302.5Extra spread at full run speed.
bloomDeg0 to 50.4Extra spread per shot while the trigger stays held. Recovers on release.
rangehitscan 5 to 200, melee 1 to 560Metres. For projectiles, the maximum flight distance.
falloffStart0 to 10.5Damage starts dropping at this fraction of range.
falloffMin0 to 10.6Fraction of damage left at full range.
magazine0 to 20030Rounds per magazine. 0 never reloads.
reloadSeconds0.2 to 61.8
burstCount2 to 63Shots per press in burst mode.
projectileSpeed5 to 20040m/s.
projectileGravity0 to 400m/s² pulling projectiles down.
projectileRadius0.05 to 10.12Visual and hit radius.
splashRadius0 to 120Explosion radius. 0 is no explosion.
splashMinFraction0 to 10.3Damage fraction at the edge of the blast.
fuseSeconds0.2 to 103Life of a bouncing or falling projectile before it explodes.
bouncebooleanfalseProjectiles bounce off walls instead of stopping (grenades).
knockback0 to 301Push given to whoever is hit, m/s.
arcDeg20 to 36090Melee swing arc.
loudness0 to 10.8Posted to the noise bus per shot, so bots hear it.
aimAssist0 to 1.51Multiplies the world's aim assist cone for this weapon.
statusStatusSpec or nullnullEffect each hit leaves. See Status effects.
colorCSS 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:

FunctionWhat it does
WEAPON_PRESETS, WEAPON_PRESET_NAMESThe 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.

ExportWhat 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_FRACTION0.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.

MemberWhat 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 fieldRangeDefaultWhat it does
coneDeg0 to 309Full cone in which a target pulls the aim.
rangemetres60 (in aimFromView: the weapon's range)Farthest target considered.
strength0 to 10.6How far the shot bends toward the target.
aimHeightfraction0.6Point on the body to aim at (chest).

The aim helpers, for your own shooting or bots:

FunctionWhat 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.

kindpowerDefault powerEffect
'burn'damage per second, 1 to 20010Damage over time. The kill goes to the shooter.
'slow'speed factor, 0.1 to 0.950.5Multiplies movement. Sprint is turned off.
'stun'ignoredNo 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.

OptionTypeDefaultWhat it does
levelLevelrequiredThe level from resolveLevel. Shots stop at its boxes.
friendlyFirebooleanfalseTeam-mates can hurt each other.
selfDamagebooleantrueYour own explosions hurt you, at half damage.
respawnSecondsnumber3Seconds before a dead combatant comes back. 0 means never (rounds, survival).
spawnPointsResolvedPoint[]level.spawnsRespawn uses the point whose nearest living enemy is farthest away.
noiseNoiseBusnonePass squad.noise so bots hear shots and explosions.
aimAssistAimAssistConfig or false{} (on, defaults above)Aim assist for aimFromView.
seednumber11Seed 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 fieldDefaultWhat it does
idrequiredUnique. Adding the same id again replaces it.
namethe idShown in the killfeed.
teamnullSame string is the same side. null is free-for-all.
color'#FFFFFF'Killfeed colour.
healthdefaults aboveA HealthConfig.
weapon'rifle'A preset name or a WeaponConfig.
bodyrequiredThe CharacterState this combatant is.
paramsrequiredIts CharacterParams (radius and height set the hit capsule).
botfalseMarks 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​

MethodWhat 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.

typeFields
'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 fieldeasynormalhardRange
aimErrorDeg (full cone)7420 to 20
burstSeconds0.50.81.10.1 to 4
pauseSeconds1.20.750.450 to 4
rangeFraction (of weapon range)0.550.750.90.1 to 1
leadtruetruetrueProjectile weapons aim ahead of moving targets.
fleeBelow000Health 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.

PropTypeWhat it does
worldCombatWorldRequired.
now() => numberRequired. Returns the same game clock the world runs on.
numbersOutScreenNumber[]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.

ComponentPropsWhat it shows
CrosshairspreadDeg (1), locked (false), color, lockColor, hidden (false)Centre crosshair that grows with spread and turns lockColor when aim assist has a target.
HitMarkerhitAt, 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.
HealthBarhp, max (required), armor (0), maxArmor (0), accentHealth bar and number. Shows an armour bar when maxArmor is above 0. Turns red under 30%.
AmmoCounterammo, magazine, reloading (required), weaponammo / magazine, RELOADING, or an infinity sign when the magazine is 0.
Killfeedentries (FeedEntry[])The last eliminations. Pass a copy of world.feed.
DamageNumberssource (ScreenNumber[]), onlyByFloating 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.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 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: CharacterControls and its buttons.