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; } 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(); 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 { const definitions: Array> = [ { 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 { return { players: new Map( [...state.players].map(([id, player]) => [id, { ...player }]), ), }; } function clonePickups(pickups: ReadonlyMap): Map { 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, 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(); 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, 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)); }