Files
syncer/packages/shared/src/shooter-game.ts
Syncer Deploy e93c1b026f
Some checks failed
build / image (push) Failing after 28s
deploy Syncer to a2acloud Kubernetes
2026-08-28 10:46:42 -03:00

1201 lines
36 KiB
TypeScript

import {
defineNetworkedGame,
deterministicHash,
withLagCompensation,
withReplayTransport,
withTimeTravel,
} from "@syncer/engine";
import {
ARENA_SPAWNS,
BOT_PATROL_POINTS,
PLAYER_EYE_HEIGHT,
collidesWithArena,
hasLineOfSight,
rayBlockDistance,
} from "./arena.js";
import {
shooterEventCodec,
shooterInputCodec,
shooterWorldCodec,
} from "./shooter-codecs.js";
import {
WEAPONS,
Weapon,
type AuthoritativePlayer,
type AuthoritativeShooterState,
type DistanceBand,
type ImpactKind,
type LoadoutState,
type PickupKind,
type PickupState,
type PlayerKind,
type PlayerState,
type ShooterAuthorityEvent,
type ShooterInput,
type ShooterPerception,
type ShooterWorldState,
type WeaponId,
} from "./shooter-types.js";
export const SHOOTER_TICK_RATE = 60;
export const SHOOTER_SNAPSHOT_RATE = 20;
export const SHOOTER_SCORE_LIMIT = 20;
export const BOT_COUNT = 6;
export const BOT_ID_BASE = 10_000;
export interface ShooterGameOptions {
/** Deterministic authority-controlled opponents created with the match. */
botCount?: number;
}
const deltaSeconds = 1 / SHOOTER_TICK_RATE;
const normalSpeed = 4.7;
const sprintSpeed = 6.45;
const movementAcceleration = 31;
const movementFriction = 11;
const visibilityRange = 31;
const fieldOfViewRadians = Math.PI * 0.62;
const footstepHearingRange = 14;
const gunshotHearingRange = 38;
const respawnDelayTicks = 180;
const spawnProtectionTicks = 75;
const pickupRespawnTicks = 720;
const presentationEventLifetime = 240;
interface TraceResult {
distance: number;
target: ShooterLagPlayer | null;
critical: boolean;
impact: ImpactKind;
}
interface ShooterLagPlayer {
id: number;
alive: boolean;
x: number;
z: number;
spawnProtectionTicks: number;
}
interface ShooterLagState {
players: Map<number, ShooterLagPlayer>;
}
type ShooterLagAction = { type: "hitscan" };
export function createShooterGame(options: ShooterGameOptions = {}) {
const botCount = Math.max(0, Math.floor(options.botCount ?? BOT_COUNT));
const shooterDefinition = defineNetworkedGame<
AuthoritativeShooterState,
ShooterWorldState,
ShooterInput,
ShooterAuthorityEvent,
ShooterPerception
>({
tickRateHz: SHOOTER_TICK_RATE,
snapshotRateHz: SHOOTER_SNAPSHOT_RATE,
server: {
createInitialState: () => createAuthoritativeWorld(botCount),
cloneState: cloneAuthoritativeWorld,
addPlayer(state, { playerId }) {
const spawn = safestSpawn(state, playerId);
state.players.set(playerId, createPlayer(playerId, "human", spawn));
},
removePlayer(state, { playerId }) {
state.players.delete(playerId);
},
applyInput(state, input, { playerId }) {
const player = state.players.get(playerId);
if (!player || player.kind !== "human" || !player.alive) return;
applyPlayerCommand(player, input, true);
},
step(state, { tick, emit }) {
state.elapsedTicks += 1;
updatePickups(state);
for (const player of state.players.values()) {
updatePlayerTimers(state, player, emit);
}
for (const player of state.players.values()) {
if (player.alive && player.bot) updateBot(state, player, tick);
}
for (const player of [...state.players.values()].sort((a, b) => a.id - b.id)) {
if (!player.alive) continue;
simulateMovement(player, deltaSeconds);
emitFootstep(state, player, tick, emit);
if (player.bot) tryFireWeapon(state, player, emit);
collectNearbyPickup(state, player, emit);
player.lastFirePressed = player.firing;
}
},
validateState: validateAuthoritativeState,
},
client: {
createInitialState: createClientWorld,
cloneState: cloneClientWorld,
applyInput(state, input, { playerId }) {
const player = state.players.get(playerId);
if (!player || !player.alive) return;
applyPlayerCommand(player, input, false);
},
step(state, { tick }) {
state.match.elapsedTicks += 1;
for (const player of state.players.values()) {
if (!player.alive) continue;
if (player.cooldownTicks > 0) player.cooldownTicks -= 1;
if (player.reloadTicks > 0) player.reloadTicks -= 1;
if (player.spawnProtectionTicks > 0) player.spawnProtectionTicks -= 1;
simulateMovement(player, deltaSeconds);
}
state.events = state.events.filter(
(entry) => entry.receivedTick >= tick - presentationEventLifetime,
);
},
mergeSnapshot(predicted, snapshot, { tick }) {
const events = predicted.events
.filter((entry) => entry.receivedTick >= tick - presentationEventLifetime)
.map((entry) => ({
receivedTick: entry.receivedTick,
event: { ...entry.event },
}));
const merged = cloneClientWorld(snapshot);
merged.events = events;
return merged;
},
applyEvent(state, event, { tick }) {
if (state.events.some((entry) => entry.event.id === event.id)) return;
state.events.push({ receivedTick: tick, event: { ...event } });
if (state.events.length > 48) state.events.shift();
},
validateState: validateClientWorld,
},
replication: {
createSnapshot(authoritative, { playerId }) {
const viewer = authoritative.players.get(playerId);
const players = new Map<number, PlayerState>();
if (viewer) {
players.set(viewer.id, clonePublicPlayer(viewer));
for (const target of authoritative.players.values()) {
if (target.id !== viewer.id && canViewerSee(viewer, target)) {
players.set(target.id, cloneVisibleOpponent(target));
}
}
}
const scoreboard = [...authoritative.players.values()]
.map((player) => ({
id: player.id,
kind: player.kind,
kills: player.kills,
deaths: player.deaths,
}))
.sort((a, b) => b.kills - a.kills || a.deaths - b.deaths || a.id - b.id);
return {
players,
pickups: clonePickups(authoritative.pickups),
scoreboard,
match: {
elapsedTicks: authoritative.elapsedTicks,
scoreLimit: SHOOTER_SCORE_LIMIT,
leaderId: scoreboard[0]?.id ?? 0,
},
events: [],
};
},
validateClientState(authoritative, candidate, { playerId }) {
const expected = authoritative.players.get(playerId);
const reported = candidate.players.get(playerId);
if (!expected || !reported) return false;
if (!finitePlayer(reported)) return false;
if (expected.alive !== reported.alive) return false;
return Math.hypot(expected.x - reported.x, expected.z - reported.z) <= 1.15;
},
perceive(authoritative, event, { playerId }) {
return perceiveEvent(authoritative, event, playerId);
},
},
validateInput(input) {
return (
Number.isFinite(input.strafe) &&
Number.isFinite(input.forward) &&
Number.isFinite(input.yaw) &&
Number.isFinite(input.pitch) &&
Math.abs(input.strafe) <= 1 &&
Math.abs(input.forward) <= 1 &&
input.pitch >= -1.35 &&
input.pitch <= 1.35 &&
(input.weapon === 0 || input.weapon === 1 || input.weapon === 2) &&
typeof input.fire === "boolean" &&
typeof input.sprint === "boolean" &&
typeof input.reload === "boolean"
);
},
codecs: {
input: shooterInputCodec,
state: shooterWorldCodec,
event: shooterEventCodec,
},
});
const lagCompensatedShooterDefinition = withLagCompensation(shooterDefinition, {
historySeconds: 0.5,
captureState(state) {
return {
players: new Map(
[...state.players].map(([id, player]) => [
id,
{
id,
alive: player.alive,
x: player.x,
z: player.z,
spawnProtectionTicks: player.spawnProtectionTicks,
},
]),
),
};
},
cloneHistoricalState: cloneShooterLagState,
classifyAction(input): ShooterLagAction | null {
return input.fire ? { type: "hitscan" } : null;
},
cloneAction: (action) => ({ ...action }),
actions: {
hitscan: {
mode: "rewind",
maximumRewindMs: 250,
maximumFutureMs: 25,
outOfWindow: "clamp",
validate({ currentState, playerId }) {
const player = currentState.players.get(playerId);
return Boolean(player?.alive && player.kind === "human");
},
resolve({
currentState,
historicalState,
playerId,
emit,
}) {
const player = currentState.players.get(playerId);
if (player) {
tryFireWeapon(currentState, player, emit, historicalState, true);
}
},
},
},
});
const timeTravelShooterGame = withTimeTravel(lagCompensatedShooterDefinition, {
createSeed: () => 0,
hashState: deterministicHash,
checkpointIntervalTicks: SHOOTER_TICK_RATE * 2,
verificationIntervalTicks: 1,
});
return withReplayTransport(timeTravelShooterGame, {
historySeconds: 30,
captureRateHz: SHOOTER_SNAPSHOT_RATE,
listPerspectives: (state) => state.players.keys(),
createTickets(event, { tick, connectedPlayerIds }) {
if (
event.type !== "elimination" ||
!connectedPlayerIds.includes(event.victimId)
) {
return null;
}
return {
requesterId: event.victimId,
perspectiveId: event.killerId,
fromTick: Math.max(0, tick - SHOOTER_TICK_RATE * 4),
toTick: tick,
playbackRate: 0.72,
};
},
authorizeReplay({ requesterId, perspectiveId, authorityState }) {
return (
authorityState.players.has(requesterId) &&
authorityState.players.has(perspectiveId)
);
},
});
}
export const shooterGame = createShooterGame();
function createAuthoritativeWorld(botCount: number): AuthoritativeShooterState {
const state: AuthoritativeShooterState = {
players: new Map(),
pickups: createPickups(),
elapsedTicks: 0,
nextEventId: 1,
};
for (let index = 0; index < botCount; index += 1) {
const id = BOT_ID_BASE + index;
const bot = createPlayer(id, "bot", ARENA_SPAWNS[index % ARENA_SPAWNS.length]!);
bot.bot = {
targetId: null,
patrolIndex: (index * 2) % BOT_PATROL_POINTS.length,
nextThinkTick: index,
strafeSign: index % 2 === 0 ? 1 : -1,
};
state.players.set(id, bot);
}
return state;
}
function createClientWorld(): ShooterWorldState {
return {
players: new Map(),
pickups: new Map(),
scoreboard: [],
match: { elapsedTicks: 0, scoreLimit: SHOOTER_SCORE_LIMIT, leaderId: 0 },
events: [],
};
}
function createPlayer(
id: number,
kind: PlayerKind,
spawn: { x: number; z: number; yaw: number },
): AuthoritativePlayer {
return {
id,
kind,
alive: true,
x: spawn.x,
z: spawn.z,
velocityX: 0,
velocityZ: 0,
yaw: spawn.yaw,
pitch: 0,
health: 100,
armor: 25,
weapon: Weapon.PulseRifle,
ammo: createLoadout(),
cooldownTicks: 0,
reloadTicks: 0,
respawnTicks: 0,
spawnProtectionTicks,
inputStrafe: 0,
inputForward: 0,
firing: false,
sprinting: false,
kills: 0,
deaths: 0,
lastFirePressed: false,
lastReloadPressed: false,
lastFootstepTick: -100,
bot: null,
};
}
function createLoadout(): LoadoutState {
return [
{ magazine: WEAPONS[0].magazineSize, reserve: WEAPONS[0].startingReserve },
{ magazine: WEAPONS[1].magazineSize, reserve: WEAPONS[1].startingReserve },
{ magazine: WEAPONS[2].magazineSize, reserve: WEAPONS[2].startingReserve },
];
}
function createPickups(): Map<number, PickupState> {
const definitions: Array<Omit<PickupState, "active" | "respawnTicks">> = [
{ id: 1, kind: "health", weapon: Weapon.PulseRifle, x: 0, z: -7.4 },
{ id: 2, kind: "health", weapon: Weapon.PulseRifle, x: 0, z: 7.4 },
{ id: 3, kind: "ammo", weapon: Weapon.PulseRifle, x: -12.8, z: 0 },
{ id: 4, kind: "ammo", weapon: Weapon.PulseRifle, x: 12.8, z: 0 },
{ id: 5, kind: "weapon", weapon: Weapon.Scattergun, x: -11.8, z: -11.8 },
{ id: 6, kind: "weapon", weapon: Weapon.RailRifle, x: 11.8, z: 11.8 },
];
return new Map(
definitions.map((pickup) => [pickup.id, { ...pickup, active: true, respawnTicks: 0 }]),
);
}
function cloneAuthoritativeWorld(state: AuthoritativeShooterState): AuthoritativeShooterState {
return {
players: new Map(
[...state.players].map(([id, player]) => [id, {
...player,
ammo: cloneLoadout(player.ammo),
bot: player.bot ? { ...player.bot } : null,
}]),
),
pickups: clonePickups(state.pickups),
elapsedTicks: state.elapsedTicks,
nextEventId: state.nextEventId,
};
}
function cloneClientWorld(state: ShooterWorldState): ShooterWorldState {
return {
players: new Map(
[...state.players].map(([id, player]) => [id, clonePublicPlayer(player)]),
),
pickups: clonePickups(state.pickups),
scoreboard: state.scoreboard.map((score) => ({ ...score })),
match: { ...state.match },
events: state.events.map((entry) => ({
receivedTick: entry.receivedTick,
event: { ...entry.event },
})),
};
}
function clonePublicPlayer(player: PlayerState): PlayerState {
return {
id: player.id,
kind: player.kind,
alive: player.alive,
x: player.x,
z: player.z,
velocityX: player.velocityX,
velocityZ: player.velocityZ,
yaw: player.yaw,
pitch: player.pitch,
health: player.health,
armor: player.armor,
weapon: player.weapon,
ammo: cloneLoadout(player.ammo),
cooldownTicks: player.cooldownTicks,
reloadTicks: player.reloadTicks,
respawnTicks: player.respawnTicks,
spawnProtectionTicks: player.spawnProtectionTicks,
inputStrafe: player.inputStrafe,
inputForward: player.inputForward,
firing: player.firing,
sprinting: player.sprinting,
};
}
function cloneVisibleOpponent(player: PlayerState): PlayerState {
return {
...clonePublicPlayer(player),
// Exact health is present only because this player projection already
// passed the viewer's FOV and line-of-sight gate. Armor, inventory, and
// server timers remain redacted.
armor: 0,
ammo: [
{ magazine: 0, reserve: 0 },
{ magazine: 0, reserve: 0 },
{ magazine: 0, reserve: 0 },
],
cooldownTicks: 0,
reloadTicks: 0,
respawnTicks: 0,
spawnProtectionTicks: 0,
};
}
function cloneLoadout(loadout: LoadoutState): LoadoutState {
return [
{ ...loadout[0] },
{ ...loadout[1] },
{ ...loadout[2] },
];
}
function cloneShooterLagState(state: Readonly<ShooterLagState>): ShooterLagState {
return {
players: new Map(
[...state.players].map(([id, player]) => [id, { ...player }]),
),
};
}
function clonePickups(pickups: ReadonlyMap<number, PickupState>): Map<number, PickupState> {
return new Map([...pickups].map(([id, pickup]) => [id, { ...pickup }]));
}
function applyPlayerCommand(
player: PlayerState | AuthoritativePlayer,
input: ShooterInput,
authoritative: boolean,
): void {
player.inputStrafe = clamp(input.strafe, -1, 1);
player.inputForward = clamp(input.forward, -1, 1);
player.yaw = normalizeAngle(input.yaw);
player.pitch = clamp(input.pitch, -1.25, 1.25);
player.firing = input.fire;
player.sprinting = input.sprint;
if (player.weapon !== input.weapon && player.reloadTicks === 0) {
player.weapon = input.weapon;
player.cooldownTicks = Math.max(player.cooldownTicks, 8);
}
if (authoritative) {
const authority = player as AuthoritativePlayer;
if (input.reload && !authority.lastReloadPressed) startReload(authority);
authority.lastReloadPressed = input.reload;
} else if (input.reload && player.reloadTicks === 0) {
const ammo = player.ammo[player.weapon];
if (ammo.magazine < WEAPONS[player.weapon].magazineSize && ammo.reserve > 0) {
player.reloadTicks = WEAPONS[player.weapon].reloadTicks;
}
}
}
function simulateMovement(player: PlayerState, dt: number): void {
const magnitude = Math.hypot(player.inputStrafe, player.inputForward);
const inputScale = magnitude > 1 ? 1 / magnitude : 1;
const strafe = player.inputStrafe * inputScale;
const forward = player.inputForward * inputScale;
const forwardX = Math.sin(player.yaw);
const forwardZ = -Math.cos(player.yaw);
const rightX = Math.cos(player.yaw);
const rightZ = Math.sin(player.yaw);
const speed = player.sprinting ? sprintSpeed : normalSpeed;
const targetX = (rightX * strafe + forwardX * forward) * speed;
const targetZ = (rightZ * strafe + forwardZ * forward) * speed;
const acceleration = magnitude > 0.01 ? movementAcceleration : movementFriction;
player.velocityX = approach(player.velocityX, targetX, acceleration * dt);
player.velocityZ = approach(player.velocityZ, targetZ, acceleration * dt);
const nextX = player.x + player.velocityX * dt;
if (!collidesWithArena(nextX, player.z)) {
player.x = nextX;
} else {
player.velocityX = 0;
}
const nextZ = player.z + player.velocityZ * dt;
if (!collidesWithArena(player.x, nextZ)) {
player.z = nextZ;
} else {
player.velocityZ = 0;
}
}
function updatePlayerTimers(
state: AuthoritativeShooterState,
player: AuthoritativePlayer,
emit: (event: ShooterAuthorityEvent) => void,
): void {
if (!player.alive) {
player.respawnTicks = Math.max(0, player.respawnTicks - 1);
if (player.respawnTicks === 0) {
respawnPlayer(state, player);
emit({ id: nextEventId(state), type: "respawn", playerId: player.id });
}
return;
}
player.cooldownTicks = Math.max(0, player.cooldownTicks - 1);
player.spawnProtectionTicks = Math.max(0, player.spawnProtectionTicks - 1);
if (player.reloadTicks > 0) {
player.reloadTicks -= 1;
if (player.reloadTicks === 0) finishReload(player);
}
}
function updatePickups(state: AuthoritativeShooterState): void {
for (const pickup of state.pickups.values()) {
if (pickup.active) continue;
pickup.respawnTicks = Math.max(0, pickup.respawnTicks - 1);
if (pickup.respawnTicks === 0) pickup.active = true;
}
}
function emitFootstep(
state: AuthoritativeShooterState,
player: AuthoritativePlayer,
tick: number,
emit: (event: ShooterAuthorityEvent) => void,
): void {
const speed = Math.hypot(player.velocityX, player.velocityZ);
const interval = player.sprinting ? 15 : 21;
if (speed > 1.2 && tick - player.lastFootstepTick >= interval) {
player.lastFootstepTick = tick;
emit({ id: nextEventId(state), type: "footstep", sourceId: player.id });
}
}
function tryFireWeapon(
state: AuthoritativeShooterState,
player: AuthoritativePlayer,
emit: (event: ShooterAuthorityEvent) => void,
historicalState?: Readonly<ShooterLagState>,
triggerAction = false,
): void {
const weapon = WEAPONS[player.weapon];
const canRepeat =
triggerAction || weapon.automatic || player.bot !== null || !player.lastFirePressed;
if (
!player.firing ||
!canRepeat ||
player.cooldownTicks > 0 ||
player.reloadTicks > 0
) {
return;
}
const ammo = player.ammo[player.weapon];
if (ammo.magazine <= 0) {
startReload(player);
return;
}
ammo.magazine -= 1;
player.cooldownTicks = weapon.cooldownTicks;
const originX = player.x;
const originY = PLAYER_EYE_HEIGHT;
const originZ = player.z;
const shotId = nextEventId(state);
const centralDirection = directionFromAngles(player.yaw, player.pitch);
const candidates = (historicalState?.players ?? state.players).values();
const centralTrace = traceShot(candidates, player, centralDirection, weapon.range);
const endX = originX + centralDirection.x * centralTrace.distance;
const endY = originY + centralDirection.y * centralTrace.distance;
const endZ = originZ + centralDirection.z * centralTrace.distance;
emit({
id: shotId,
type: "shot",
sourceId: player.id,
weapon: player.weapon,
originX,
originY,
originZ,
endX,
endY,
endZ,
impact: centralTrace.impact,
});
const damageByPlayer = new Map<number, { amount: number; critical: boolean }>();
for (let pellet = 0; pellet < weapon.pellets; pellet += 1) {
const yawOffset = signedNoise(shotId, pellet * 2 + 1) * weapon.spread;
const pitchOffset = signedNoise(shotId, pellet * 2 + 2) * weapon.spread;
const direction = directionFromAngles(player.yaw + yawOffset, player.pitch + pitchOffset);
const trace = traceShot(
(historicalState?.players ?? state.players).values(),
player,
direction,
weapon.range,
);
if (!trace.target) continue;
const damage = Math.round(weapon.damage * (trace.critical ? 1.55 : 1));
const previous = damageByPlayer.get(trace.target.id);
damageByPlayer.set(trace.target.id, {
amount: (previous?.amount ?? 0) + damage,
critical: Boolean(previous?.critical || trace.critical),
});
}
for (const [targetId, damage] of damageByPlayer) {
const target = state.players.get(targetId);
if (target?.alive) applyDamage(state, player, target, damage.amount, damage.critical, emit);
}
}
function traceShot(
candidates: Iterable<ShooterLagPlayer>,
shooter: AuthoritativePlayer,
direction: { x: number; y: number; z: number },
maximumDistance: number,
): TraceResult {
const wallDistance = rayBlockDistance(
shooter.x,
PLAYER_EYE_HEIGHT,
shooter.z,
direction.x,
direction.y,
direction.z,
maximumDistance,
);
let distance = wallDistance ?? maximumDistance;
let target: ShooterLagPlayer | null = null;
let critical = false;
for (const candidate of candidates) {
if (
candidate.id === shooter.id ||
!candidate.alive ||
candidate.spawnProtectionTicks > 0
) continue;
const bodyDistance = raySphereDistance(
shooter.x,
PLAYER_EYE_HEIGHT,
shooter.z,
direction,
candidate.x,
0.83,
candidate.z,
0.49,
);
const headDistance = raySphereDistance(
shooter.x,
PLAYER_EYE_HEIGHT,
shooter.z,
direction,
candidate.x,
1.55,
candidate.z,
0.24,
);
const candidateDistance = minimumNullable(bodyDistance, headDistance);
if (candidateDistance !== null && candidateDistance < distance) {
distance = candidateDistance;
target = candidate;
critical = headDistance !== null && headDistance <= candidateDistance + 0.0001;
}
}
return {
distance,
target,
critical,
impact: target ? "player" : wallDistance !== null ? "wall" : "miss",
};
}
function raySphereDistance(
originX: number,
originY: number,
originZ: number,
direction: { x: number; y: number; z: number },
centerX: number,
centerY: number,
centerZ: number,
radius: number,
): number | null {
const offsetX = originX - centerX;
const offsetY = originY - centerY;
const offsetZ = originZ - centerZ;
const projection =
offsetX * direction.x + offsetY * direction.y + offsetZ * direction.z;
const constant =
offsetX * offsetX + offsetY * offsetY + offsetZ * offsetZ - radius * radius;
const discriminant = projection * projection - constant;
if (discriminant < 0) return null;
const near = -projection - Math.sqrt(discriminant);
const far = -projection + Math.sqrt(discriminant);
if (near >= 0) return near;
return far >= 0 ? far : null;
}
function applyDamage(
state: AuthoritativeShooterState,
attacker: AuthoritativePlayer,
target: AuthoritativePlayer,
amount: number,
critical: boolean,
emit: (event: ShooterAuthorityEvent) => void,
): void {
const absorbed = Math.min(target.armor, Math.round(amount * 0.3));
target.armor -= absorbed;
const healthDamage = Math.max(1, amount - absorbed);
target.health = Math.max(0, target.health - healthDamage);
emit({
id: nextEventId(state),
type: "damage",
sourceId: attacker.id,
targetId: target.id,
amount,
health: target.health,
critical,
});
if (target.health > 0) return;
target.alive = false;
target.deaths += 1;
target.respawnTicks = respawnDelayTicks;
target.velocityX = 0;
target.velocityZ = 0;
target.inputForward = 0;
target.inputStrafe = 0;
target.firing = false;
attacker.kills += 1;
emit({
id: nextEventId(state),
type: "elimination",
killerId: attacker.id,
victimId: target.id,
weapon: attacker.weapon,
critical,
});
}
function startReload(player: PlayerState): void {
const definition = WEAPONS[player.weapon];
const ammo = player.ammo[player.weapon];
if (
player.reloadTicks === 0 &&
ammo.magazine < definition.magazineSize &&
ammo.reserve > 0
) {
player.reloadTicks = definition.reloadTicks;
}
}
function finishReload(player: PlayerState): void {
const definition = WEAPONS[player.weapon];
const ammo = player.ammo[player.weapon];
const needed = definition.magazineSize - ammo.magazine;
const moved = Math.min(needed, ammo.reserve);
ammo.magazine += moved;
ammo.reserve -= moved;
}
function respawnPlayer(state: AuthoritativeShooterState, player: AuthoritativePlayer): void {
const spawn = safestSpawn(state, player.id);
player.alive = true;
player.x = spawn.x;
player.z = spawn.z;
player.yaw = spawn.yaw;
player.pitch = 0;
player.health = 100;
player.armor = 25;
player.velocityX = 0;
player.velocityZ = 0;
player.inputForward = 0;
player.inputStrafe = 0;
player.firing = false;
player.sprinting = false;
player.cooldownTicks = 0;
player.reloadTicks = 0;
player.respawnTicks = 0;
player.spawnProtectionTicks = spawnProtectionTicks;
player.lastFirePressed = false;
player.lastReloadPressed = false;
for (const weapon of [Weapon.PulseRifle, Weapon.Scattergun, Weapon.RailRifle] as const) {
const ammo = player.ammo[weapon];
ammo.magazine = WEAPONS[weapon].magazineSize;
ammo.reserve = Math.max(ammo.reserve, Math.ceil(WEAPONS[weapon].startingReserve / 2));
}
}
function collectNearbyPickup(
state: AuthoritativeShooterState,
player: AuthoritativePlayer,
emit: (event: ShooterAuthorityEvent) => void,
): void {
for (const pickup of state.pickups.values()) {
if (!pickup.active || Math.hypot(player.x - pickup.x, player.z - pickup.z) > 0.9) {
continue;
}
const amount = applyPickup(player, pickup);
if (amount <= 0) continue;
pickup.active = false;
pickup.respawnTicks = pickupRespawnTicks;
emit({
id: nextEventId(state),
type: "pickup",
playerId: player.id,
pickup: pickup.kind,
weapon: pickup.weapon,
amount,
});
}
}
function applyPickup(player: AuthoritativePlayer, pickup: PickupState): number {
if (pickup.kind === "health") {
const gained = Math.min(45, 100 - player.health);
player.health += gained;
return gained;
}
if (pickup.kind === "ammo") {
let gained = 0;
for (const weapon of [Weapon.PulseRifle, Weapon.Scattergun, Weapon.RailRifle] as const) {
const amount = weapon === Weapon.PulseRifle ? 30 : weapon === Weapon.Scattergun ? 8 : 4;
player.ammo[weapon].reserve += amount;
gained += amount;
}
return gained;
}
const definition = WEAPONS[pickup.weapon];
player.weapon = pickup.weapon;
player.ammo[pickup.weapon].reserve += definition.magazineSize * 2;
return definition.magazineSize * 2;
}
function updateBot(
state: AuthoritativeShooterState,
bot: AuthoritativePlayer,
tick: number,
): void {
const mind = bot.bot!;
if (tick >= mind.nextThinkTick) {
mind.nextThinkTick = tick + 7 + (bot.id % 4);
let target: AuthoritativePlayer | null = null;
let bestDistance = Infinity;
for (const candidate of state.players.values()) {
if (candidate.id === bot.id || !candidate.alive) continue;
const distance = Math.hypot(candidate.x - bot.x, candidate.z - bot.z);
if (
distance < bestDistance &&
distance <= visibilityRange &&
hasLineOfSight(bot.x, bot.z, candidate.x, candidate.z)
) {
target = candidate;
bestDistance = distance;
}
}
mind.targetId = target?.id ?? null;
if (signedNoise(bot.id, Math.floor(tick / 90)) > 0.65) mind.strafeSign *= -1;
}
const target = mind.targetId === null ? null : state.players.get(mind.targetId);
if (target?.alive && hasLineOfSight(bot.x, bot.z, target.x, target.z)) {
const dx = target.x - bot.x;
const dz = target.z - bot.z;
const distance = Math.hypot(dx, dz);
const aimNoise = signedNoise(bot.id, Math.floor(tick / 16)) * 0.045;
const desiredYaw = Math.atan2(dx, -dz) + aimNoise;
bot.yaw = rotateToward(bot.yaw, desiredYaw, 0.095);
bot.pitch = rotateToward(bot.pitch, -0.035 + aimNoise * 0.25, 0.035);
bot.weapon = distance < 8.5 ? Weapon.Scattergun : distance > 19 ? Weapon.RailRifle : Weapon.PulseRifle;
if (bot.ammo[bot.weapon].magazine === 0) startReload(bot);
bot.firing = Math.abs(normalizeAngle(desiredYaw - bot.yaw)) < 0.19;
bot.inputStrafe = mind.strafeSign * (distance < 15 ? 0.72 : 0.25);
bot.inputForward = distance > 11 ? 0.72 : distance < 5 ? -0.55 : 0.12;
bot.sprinting = false;
return;
}
mind.targetId = null;
const patrol = BOT_PATROL_POINTS[mind.patrolIndex % BOT_PATROL_POINTS.length]!;
let dx = patrol.x - bot.x;
let dz = patrol.z - bot.z;
if (Math.hypot(dx, dz) < 1.15) {
mind.patrolIndex = (mind.patrolIndex + 1 + (bot.id % 3)) % BOT_PATROL_POINTS.length;
const next = BOT_PATROL_POINTS[mind.patrolIndex]!;
dx = next.x - bot.x;
dz = next.z - bot.z;
}
let desiredYaw = Math.atan2(dx, -dz);
const lookAheadX = bot.x + Math.sin(desiredYaw) * 1.1;
const lookAheadZ = bot.z - Math.cos(desiredYaw) * 1.1;
if (collidesWithArena(lookAheadX, lookAheadZ, 0.55)) {
desiredYaw += mind.strafeSign * Math.PI * 0.48;
}
bot.yaw = rotateToward(bot.yaw, desiredYaw, 0.08);
bot.pitch = rotateToward(bot.pitch, 0, 0.03);
bot.inputForward = 1;
bot.inputStrafe = 0;
bot.firing = false;
bot.sprinting = true;
}
function perceiveEvent(
state: AuthoritativeShooterState,
event: ShooterAuthorityEvent,
viewerId: number,
): ShooterPerception | null {
const viewer = state.players.get(viewerId);
if (!viewer) return null;
if (event.type === "elimination") {
return { ...event };
}
if (event.type === "damage") {
if (event.sourceId === viewerId) {
return {
id: event.id,
type: "hit",
amount: event.amount,
eliminated: event.health === 0,
critical: event.critical,
};
}
if (event.targetId !== viewerId) return null;
const source = state.players.get(event.sourceId);
const bearing = source
? normalizeAngle(Math.atan2(source.x - viewer.x, -(source.z - viewer.z)) - viewer.yaw)
: 0;
return {
id: event.id,
type: "damage",
amount: event.amount,
health: event.health,
bearingRadians: bearing,
critical: event.critical,
};
}
if (event.type === "pickup") {
if (event.playerId !== viewerId) return null;
return {
id: event.id,
type: "pickup",
pickup: event.pickup,
weapon: event.weapon,
amount: event.amount,
};
}
if (event.type === "respawn") {
return event.playerId === viewerId ? { id: event.id, type: "respawn" } : null;
}
const sourceId = event.type === "shot" ? event.sourceId : event.sourceId;
const source = state.players.get(sourceId);
if (!source) return null;
const distance = Math.hypot(source.x - viewer.x, source.z - viewer.z);
const bearing = normalizeAngle(
Math.atan2(source.x - viewer.x, -(source.z - viewer.z)) - viewer.yaw,
);
if (event.type === "shot") {
if (source.id === viewerId) {
return {
id: event.id,
type: "shot",
weapon: event.weapon,
sourceId: event.sourceId,
originX: event.originX,
originY: event.originY,
originZ: event.originZ,
endX: event.endX,
endY: event.endY,
endZ: event.endZ,
impact: event.impact,
};
}
if (distance <= gunshotHearingRange) {
const occlusion = hasLineOfSight(viewer.x, viewer.z, source.x, source.z)
? 1
: 0.48;
return {
id: event.id,
type: "sound",
sound: "gunshot",
weapon: event.weapon,
bearingRadians: bearing,
intensity:
Math.max(0.08, 1 - distance / gunshotHearingRange) * occlusion,
distanceBand: distanceBand(distance, gunshotHearingRange),
};
}
return null;
}
if (distance > footstepHearingRange || source.id === viewerId) return null;
const occlusion = hasLineOfSight(viewer.x, viewer.z, source.x, source.z)
? 1
: 0.48;
return {
id: event.id,
type: "sound",
sound: "footstep",
weapon: Weapon.PulseRifle,
bearingRadians: bearing,
intensity: Math.max(0.05, 1 - distance / footstepHearingRange) * occlusion,
distanceBand: distanceBand(distance, footstepHearingRange),
};
}
function canViewerSee(viewer: AuthoritativePlayer, target: AuthoritativePlayer): boolean {
if (!target.alive) return false;
const dx = target.x - viewer.x;
const dz = target.z - viewer.z;
const distance = Math.hypot(dx, dz);
if (distance > visibilityRange) return false;
const angle = normalizeAngle(Math.atan2(dx, -dz) - viewer.yaw);
if (distance > 3.2 && Math.abs(angle) > fieldOfViewRadians) return false;
return hasLineOfSight(viewer.x, viewer.z, target.x, target.z);
}
function safestSpawn(state: AuthoritativeShooterState, playerId: number) {
let best = ARENA_SPAWNS[playerId % ARENA_SPAWNS.length]!;
let bestScore = -Infinity;
for (let offset = 0; offset < ARENA_SPAWNS.length; offset += 1) {
const spawn = ARENA_SPAWNS[(playerId + offset) % ARENA_SPAWNS.length]!;
let closest = 100;
for (const player of state.players.values()) {
if (player.id === playerId || !player.alive) continue;
closest = Math.min(closest, Math.hypot(spawn.x - player.x, spawn.z - player.z));
}
if (closest > bestScore) {
best = spawn;
bestScore = closest;
}
}
return best;
}
function validateAuthoritativeState(state: AuthoritativeShooterState): boolean {
return (
state.players.size <= 128 &&
state.pickups.size <= 64 &&
[...state.players.values()].every(
(player) => finitePlayer(player) && !collidesWithArena(player.x, player.z, 0.39),
)
);
}
function validateClientWorld(state: ShooterWorldState): boolean {
return (
state.players.size <= 128 &&
state.pickups.size <= 64 &&
state.scoreboard.length <= 128 &&
[...state.players.values()].every(finitePlayer)
);
}
function finitePlayer(player: PlayerState): boolean {
return (
Number.isFinite(player.x) &&
Number.isFinite(player.z) &&
Number.isFinite(player.velocityX) &&
Number.isFinite(player.velocityZ) &&
Number.isFinite(player.yaw) &&
Number.isFinite(player.pitch) &&
player.health >= 0 &&
player.health <= 100 &&
player.armor >= 0 &&
player.armor <= 100
);
}
function nextEventId(state: AuthoritativeShooterState): number {
const id = state.nextEventId;
state.nextEventId = (state.nextEventId + 1) >>> 0;
return id;
}
function directionFromAngles(yaw: number, pitch: number) {
const horizontal = Math.cos(pitch);
return {
x: Math.sin(yaw) * horizontal,
y: Math.sin(pitch),
z: -Math.cos(yaw) * horizontal,
};
}
function signedNoise(a: number, b: number): number {
let value = (a * 374_761_393 + b * 668_265_263) >>> 0;
value = ((value ^ (value >>> 13)) * 1_274_126_177) >>> 0;
return ((value ^ (value >>> 16)) / 4_294_967_295) * 2 - 1;
}
function distanceBand(distance: number, range: number): DistanceBand {
return distance < range / 3 ? "near" : distance < (range * 2) / 3 ? "medium" : "far";
}
function minimumNullable(left: number | null, right: number | null): number | null {
if (left === null) return right;
if (right === null) return left;
return Math.min(left, right);
}
function approach(value: number, target: number, amount: number): number {
return value < target ? Math.min(target, value + amount) : Math.max(target, value - amount);
}
function rotateToward(value: number, target: number, amount: number): number {
return normalizeAngle(value + clamp(normalizeAngle(target - value), -amount, amount));
}
function normalizeAngle(value: number): number {
return Math.atan2(Math.sin(value), Math.cos(value));
}
function clamp(value: number, minimum: number, maximum: number): number {
return Math.max(minimum, Math.min(maximum, value));
}