/** * The match: one round loop, server-authoritative, and the only place a * number changes. * * `tick(state, dt)` is the whole game — call it with a fixed step and it * runs without a clock, a socket or a browser, which is how the tests play * whole rounds in a millisecond. Inputs arrive as intent (a direction, an * angle, a held key); nothing a client says is taken as a position. */ import { cast, castable, agentById, buyableSlots, flashDuration, sightBlocked, SLOTS } from "./abilities.ts"; import { award, clampCredits, roundReward, spend, START_CREDITS } from "./economy.ts"; import { blockedCircle, clearPaths, moveCircle, orbSpots, rayHitWall, segmentPoint, siteAt, spawnCells, type Point, } from "./map.ts"; import { emptyInput, type Input, type MatchState, type Player, type Side, type Slot, type Team } from "./state.ts"; import { applyDamage, damage, shotSpread, SHIELD, WEAPONS, zoneFromMiss, type WeaponId } from "./weapons.ts"; export const PLAYER_R = 16; export const RUN_SPEED = 250; export const BUY_TIME = 14; export const ROUND_TIME = 100; export const PLANT_TIME = 4; export const FUSE_TIME = 45; export const DEFUSE_TIME = 7; export const DEFUSE_CHECKPOINT = 3.5; export const END_TIME = 5; export const OVER_TIME = 12; export const SWAP_AFTER = 12; export const WIN_ROUNDS = 13; export const ROSTER = 5; export const SPIKE_REACH = 46; const TEAMS: Team[] = ["red", "blue"]; /** Deterministic randomness: the same seed plays the same round. */ export function random(state: MatchState): number { state.seed = (state.seed + 0x6d2b79f5) | 0; let t = state.seed; t = Math.imul(t ^ (t >>> 15), t | 1); t ^= t + Math.imul(t ^ (t >>> 7), t | 61); return ((t ^ (t >>> 14)) >>> 0) / 4294967296; } const say = (state: MatchState, text: string): void => { state.events.unshift({ text, age: 0 }); state.events.length = Math.min(state.events.length, 12); }; export const sideOf = (state: MatchState, team: Team): Side => team === state.attackers ? "atk" : "def"; export const teammates = (state: MatchState, side: Side): Player[] => state.players.filter((p) => p.side === side); export const aliveOn = (state: MatchState, side: Side): Player[] => state.players.filter((p) => p.side === side && p.alive); const BOT_NAMES = [ "Ash", "Bex", "Cade", "Dune", "Enzo", "Fay", "Gus", "Hale", "Ivo", "Juno", "Kit", "Lux", "Mox", "Nell", "Orla", "Pike", "Quill", "Rio", "Sage", "Tove", ]; /** * Twenty names, shuffled, dealt to the ten seats — drawn without replacement. * * A name is how a person is addressed: in the kill feed, in the round log, on * the scoreboard. Two bots called Kit make "Kit killed Kit", which reads as a * team kill that this game cannot even have. Picking each name independently * collided in most matches — a uniform pick over twenty names for ten seats * is a birthday problem, and no arithmetic spacing survives adding a uniform * term to it. Shuffling once and dealing cannot collide at all. */ export function dealNames(state: MatchState, count = TEAMS.length * ROSTER): string[] { const bag = BOT_NAMES.slice(); for (let i = bag.length - 1; i > 0; i--) { const j = Math.floor(random(state) * (i + 1)); const swap = bag[i]!; bag[i] = bag[j]!; bag[j] = swap; } return bag.slice(0, Math.min(count, bag.length)); } /** * One seat. The name is dealt by the caller so a whole roster is drawn * without replacement; on its own it falls back to the seat's own slot, * which is distinct for every seat in a match too. */ export function makePlayer( state: MatchState, team: Team, index: number, name = BOT_NAMES[(TEAMS.indexOf(team) * ROSTER + index) % BOT_NAMES.length]!, ): Player { const agent = ["ember", "veil", "sable", "mender", "rook"][index % 5]!; return { id: `bot-${team}-${index}`, name: `${name}`, bot: true, agent, team, side: sideOf(state, team), x: 0, y: 0, angle: 0, hp: 100, armour: 0, alive: true, credits: START_CREDITS, weapon: "classic", ammo: WEAPONS.classic.magazine, reloadIn: 0, fireIn: 0, charges: { c: 0, q: 0, e: 1, x: 0 }, cooldown: { c: 0, q: 0, e: 0, x: 0 }, ult: 0, blind: 0, slow: 0, haste: 0, revealed: 0, spike: false, planting: 0, defusing: 0, kills: 0, deaths: 0, assists: 0, damageDone: 0, input: emptyInput(), goal: null, think: 0, spray: 0, joinedAt: 0, }; } export function newMatch(seed = Date.now() % 100000): MatchState { const state: MatchState = { id: `m${seed.toString(36)}${Math.floor(seed / 7) % 997}`, phase: "buy", clock: BUY_TIME, round: 1, score: { red: 0, blue: 0 }, attackers: "red", lossStreak: { red: 0, blue: 0 }, players: [], zones: [], nextZone: 1, spike: { carried: null, x: 0, y: 0, planted: false, fuse: 0, site: null, defuse: 0, checkpoint: 0 }, orbs: orbSpots().map((p) => ({ ...p, taken: false })), shots: [], events: [], result: null, seed, now: 0, }; const names = dealNames(state); for (const team of TEAMS) for (let i = 0; i < ROSTER; i++) state.players.push(makePlayer(state, team, i, names[state.players.length])); startRound(state, true); say(state, "Round 1 — buy phase"); return state; } /** A fresh match on the same seats: an ended match never parks. */ export function resetMatch(state: MatchState): void { const fresh = newMatch((state.seed + 7919) % 100000); fresh.players = state.players; fresh.attackers = state.attackers === "red" ? "blue" : "red"; for (const player of fresh.players) { player.kills = 0; player.deaths = 0; player.assists = 0; player.damageDone = 0; player.ult = 0; player.credits = START_CREDITS; player.weapon = "classic"; } Object.assign(state, fresh); startRound(state, true); say(state, "New match — round 1"); } function placeAtSpawn(state: MatchState, player: Player, index: number): void { const cells = spawnCells(player.side); const spot = cells[index % cells.length]!; player.x = spot.x + (random(state) - 0.5) * 20; player.y = spot.y + (random(state) - 0.5) * 20; const centre = player.side === "atk" ? { x: 1200, y: 400 } : { x: 1200, y: 1200 }; player.angle = Math.atan2(centre.y - player.y, centre.x - player.x); } export function startRound(state: MatchState, first = false): void { clearPaths(); state.phase = "buy"; state.clock = BUY_TIME; state.zones = []; state.shots = []; state.spike = { carried: null, x: 0, y: 0, planted: false, fuse: 0, site: null, defuse: 0, checkpoint: 0 }; state.orbs = orbSpots().map((p) => ({ ...p, taken: false })); const byTeam: Record = { red: 0, blue: 0 }; for (const player of state.players) { player.side = sideOf(state, player.team); // A gun survives the round its owner does; armour never does. if (!player.alive || first) player.weapon = "classic"; player.armour = 0; player.hp = 100; player.alive = true; player.ammo = WEAPONS[player.weapon].magazine; player.reloadIn = 0; player.fireIn = 0; player.blind = 0; player.slow = 0; player.haste = 0; player.revealed = 0; player.spike = false; player.planting = 0; player.defusing = 0; player.spray = 0; player.goal = null; player.think = 0; player.input = emptyInput(); // Basics are bought fresh every round; the signature comes back free. player.charges = { c: 0, q: 0, e: 1, x: 0 }; player.cooldown = { c: 0, q: 0, e: 0, x: 0 }; if (first) player.credits = START_CREDITS; placeAtSpawn(state, player, byTeam[player.team]++); } giveSpike(state); } function giveSpike(state: MatchState): void { const attackers = aliveOn(state, "atk"); if (attackers.length === 0) return; const carrier = attackers[Math.floor(random(state) * attackers.length)]!; carrier.spike = true; state.spike.carried = carrier.id; state.spike.x = carrier.x; state.spike.y = carrier.y; } /** Buying is refused server-side: no credits, no gun, no argument. */ export function buyWeapon(state: MatchState, player: Player, id: WeaponId): boolean { if (state.phase !== "buy") return false; const cost = WEAPONS[id].cost; const paid = spend(player.credits, cost); if (!paid.ok) return false; player.credits = paid.credits; player.weapon = id; player.ammo = WEAPONS[id].magazine; return true; } export function buyShield(state: MatchState, player: Player, kind: "light" | "heavy"): boolean { if (state.phase !== "buy") return false; const shield = SHIELD[kind]; if (player.armour >= shield.armour) return false; const paid = spend(player.credits, shield.cost); if (!paid.ok) return false; player.credits = paid.credits; player.armour = shield.armour; return true; } export function buyAbility(state: MatchState, player: Player, slot: Slot): boolean { if (state.phase !== "buy" || !buyableSlots.includes(slot)) return false; const ability = agentById(player.agent).abilities[slot]; if (player.charges[slot] >= ability.charges) return false; const paid = spend(player.credits, ability.cost); if (!paid.ok) return false; player.credits = paid.credits; player.charges[slot] += 1; return true; } export function pickAgent(state: MatchState, player: Player, agent: string): boolean { if (state.phase !== "buy" || player.charges.c > 0 || player.charges.q > 0) return false; player.agent = agent; return true; } // ---------------------------------------------------------------- the tick export function tick(state: MatchState, dt: number): void { state.now += dt; for (const shot of state.shots) shot.age += dt; state.shots = state.shots.filter((s) => s.age < 0.12); for (const event of state.events) event.age += dt; if (state.phase === "over") { state.clock -= dt; if (state.clock <= 0) resetMatch(state); return; } if (state.phase === "end") { state.clock -= dt; if (state.clock <= 0) nextRound(state); return; } state.clock -= dt; if (state.phase === "buy") { for (const player of state.players) movePlayer(state, player, dt, true); if (state.clock <= 0) { state.phase = "live"; state.clock = ROUND_TIME; say(state, "Round live"); } return; } for (const player of state.players) { if (player.blind > 0) player.blind = Math.max(0, player.blind - dt); if (player.slow > 0) player.slow = Math.max(0, player.slow - dt); if (player.haste > 0) player.haste = Math.max(0, player.haste - dt); if (player.revealed > 0) player.revealed = Math.max(0, player.revealed - dt); for (const slot of SLOTS) if (player.cooldown[slot] > 0) { player.cooldown[slot] = Math.max(0, player.cooldown[slot] - dt); // The signature charge comes back on its own when the wait is over. if (player.cooldown[slot] === 0) player.charges[slot] = Math.max(player.charges[slot], 1); } player.spray = Math.max(0, player.spray - dt * 6); if (!player.alive) continue; movePlayer(state, player, dt, false); useAbility(state, player); shoot(state, player, dt); spikeWork(state, player, dt); orbs(state, player); } if (state.spike.planted) state.spike.fuse = Math.max(0, state.spike.fuse - dt); zones(state, dt); endConditions(state); } function speedOf(player: Player): number { let speed = RUN_SPEED; if (player.haste > 0) speed *= 1.35; if (player.slow > 0) speed *= 0.55; if (player.planting > 0 || player.defusing > 0) speed = 0; if (player.weapon === "operator") speed *= 0.86; return speed; } function movePlayer(state: MatchState, player: Player, dt: number, penned: boolean): void { if (!player.alive) return; const input = player.input; if (Number.isFinite(input.angle)) player.angle = input.angle; let mx = input.mx; let my = input.my; const len = Math.hypot(mx, my); if (len > 1) { mx /= len; my /= len; } const speed = speedOf(player) * dt; if (speed === 0 || (mx === 0 && my === 0)) return; let next = moveCircle(player.x, player.y, mx * speed, my * speed, PLAYER_R); // In the buy phase nobody leaves their own spawn. if (penned) { const cells = spawnCells(player.side); const near = cells.reduce((best, c) => (Math.hypot(c.x - next.x, c.y - next.y) < Math.hypot(best.x - next.x, best.y - next.y) ? c : best)); if (Math.hypot(near.x - next.x, near.y - next.y) > 120) return; } // Walls put down by abilities are as solid as the map's. for (const zone of state.zones) { if (!zone.solid || zone.fuse > 0) continue; const gap = Math.hypot(next.x - zone.x, next.y - zone.y); if (gap < zone.r + PLAYER_R) { const push = zone.r + PLAYER_R - gap; const angle = gap === 0 ? 0 : Math.atan2(next.y - zone.y, next.x - zone.x); const shoved = { x: next.x + Math.cos(angle) * push, y: next.y + Math.sin(angle) * push }; if (!blockedCircle(shoved.x, shoved.y, PLAYER_R)) next = shoved; else next = { x: player.x, y: player.y }; } } player.x = next.x; player.y = next.y; } function useAbility(state: MatchState, player: Player): void { const slot = player.input.cast; if (slot === null) return; player.input.cast = null; if (state.phase !== "live" || player.blind > 0.8) return; if (cast(state, player, slot)) { const ability = agentById(player.agent).abilities[slot]; if (slot === "x") say(state, `${player.name} used ${ability.name}`); } } function shoot(state: MatchState, player: Player, dt: number): void { player.fireIn = Math.max(0, player.fireIn - dt); if (player.reloadIn > 0) { player.reloadIn -= dt; if (player.reloadIn <= 0) { player.ammo = WEAPONS[player.weapon].magazine; player.reloadIn = 0; } return; } const weapon = WEAPONS[player.weapon]; if (player.input.reload && player.ammo < weapon.magazine) { player.input.reload = false; player.reloadIn = weapon.reload; return; } if (!player.input.fire || player.fireIn > 0) return; if (!weapon.auto) player.input.fire = false; if (player.ammo <= 0) { player.reloadIn = weapon.reload; return; } player.ammo -= 1; player.fireIn = 1 / weapon.rate; player.spray = Math.min(6, player.spray + 1); const moving = Math.hypot(player.input.mx, player.input.my) > 0.1 ? 1 : 0; // Blind aim is a guess: the shot goes somewhere near where you were looking. const wobble = player.blind > 0 ? 0.5 : 0; const spread = shotSpread(player.weapon, moving) + player.spray * 0.006 + wobble; const angle = player.angle + (random(state) - 0.5) * 2 * spread; const from: Point = { x: player.x, y: player.y }; const wall = rayHitWall(from, angle, weapon.range); let stop = wall; // A wall an ability put up stops bullets too; smoke does not. for (const zone of state.zones) { if (!zone.solid || zone.fuse > 0) continue; const near = segmentPoint(from, wall, zone); if (near.dist <= zone.r && near.travel < Math.hypot(stop.x - from.x, stop.y - from.y)) stop = { x: from.x + Math.cos(angle) * near.travel, y: from.y + Math.sin(angle) * near.travel }; } let best: { target: Player; travel: number; miss: number } | null = null; for (const other of state.players) { if (!other.alive || other.side === player.side || other.id === player.id) continue; const near = segmentPoint(from, stop, other); if (near.dist > PLAYER_R || near.travel < 1) continue; if (best === null || near.travel < best.travel) best = { target: other, travel: near.travel, miss: near.dist }; } const end = best ? { x: from.x + Math.cos(angle) * best.travel, y: from.y + Math.sin(angle) * best.travel } : stop; state.shots.push({ x1: from.x, y1: from.y, x2: end.x, y2: end.y, hit: best !== null, age: 0 }); if (!best) return; const zone = zoneFromMiss(best.miss, PLAYER_R); const dealt = damage(player.weapon, zone, best.travel); hurt(state, best.target, dealt, player, zone === "head" ? "headshot" : ""); } function hurt(state: MatchState, target: Player, amount: number, from: Player | null, note: string): void { if (!target.alive) return; const before = target.hp + target.armour; const after = applyDamage(target.hp, target.armour, amount); target.hp = after.hp; target.armour = after.armour; if (from) from.damageDone += Math.max(0, before - (target.hp + target.armour)); if (target.hp > 0) return; target.alive = false; target.deaths += 1; target.planting = 0; // A defuse dies with the defender, back to the half-way checkpoint. if (target.defusing > 0) { target.defusing = 0; state.spike.defuse = state.spike.checkpoint; } if (target.spike) { target.spike = false; state.spike.carried = null; state.spike.x = target.x; state.spike.y = target.y; } if (from && from.side !== target.side) { from.kills += 1; from.credits = award(from.credits, 200); from.ult = Math.min(10, from.ult + 1); say(state, `${from.name} ${note === "headshot" ? "headshot" : "killed"} ${target.name}`); } else { say(state, `${target.name} died`); } } function spikeWork(state: MatchState, player: Player, dt: number): void { const spike = state.spike; if (player.side === "atk" && !spike.planted) { if (!spike.carried && !player.spike && Math.hypot(player.x - spike.x, player.y - spike.y) < SPIKE_REACH) { player.spike = true; spike.carried = player.id; say(state, `${player.name} picked up the spike`); } if (player.spike) { spike.x = player.x; spike.y = player.y; const site = siteAt(player.x, player.y); if (player.input.use && site) { player.planting += dt; if (player.planting >= PLANT_TIME) { spike.planted = true; spike.site = site; spike.fuse = FUSE_TIME; spike.carried = null; player.spike = false; player.planting = 0; player.ult = Math.min(10, player.ult + 1); for (const mate of teammates(state, "atk")) mate.credits = award(mate.credits, 300); say(state, `${player.name} planted on ${site}`); } } else player.planting = 0; } return; } if (player.side === "def" && spike.planted) { const close = Math.hypot(player.x - spike.x, player.y - spike.y) < SPIKE_REACH; if (player.input.use && close) { spike.defuse += dt; player.defusing = spike.defuse; // Half way is a checkpoint: the next defender starts from there. if (spike.defuse >= DEFUSE_CHECKPOINT && spike.checkpoint === 0) { spike.checkpoint = DEFUSE_CHECKPOINT; say(state, `${player.name} is half way through the defuse`); } } else if (player.defusing > 0) { player.defusing = 0; spike.defuse = spike.checkpoint; } } } function orbs(state: MatchState, player: Player): void { for (const orb of state.orbs) if (!orb.taken && Math.hypot(player.x - orb.x, player.y - orb.y) < 40) { orb.taken = true; player.ult = Math.min(10, player.ult + 1); say(state, `${player.name} took an ult orb`); } } function zones(state: MatchState, dt: number): void { for (const zone of state.zones) { if (zone.fuse > 0) { zone.fuse -= dt; if (zone.fuse > 0) continue; if (zone.kind === "flash") { // Pop: everyone with a sight line to it, blinded by how far their // back was turned. Smoke between them and it saves them. for (const player of state.players) { if (!player.alive) continue; const blocked = sightBlocked(zone, player, state.zones); const within = Math.hypot(player.x - zone.x, player.y - zone.y) <= zone.r; const blind = within ? flashDuration(zone.damage, player, zone, player.angle, blocked) : 0; if (blind > 0) player.blind = Math.max(player.blind, blind); } } continue; } zone.life -= dt; zone.beat -= dt; if (zone.kind === "molly" && zone.beat <= 0) { zone.beat = 0.5; const owner = state.players.find((p) => p.id === zone.owner) ?? null; for (const player of state.players) if (player.alive && player.side !== zone.side && Math.hypot(player.x - zone.x, player.y - zone.y) <= zone.r) hurt(state, player, Math.round(zone.damage / 2), owner, ""); } if (zone.kind === "dart" && zone.beat <= 0 && zone.pulses > 0) { zone.beat = 1.6; zone.pulses -= 1; for (const player of state.players) if (player.alive && player.side !== zone.side && Math.hypot(player.x - zone.x, player.y - zone.y) <= zone.r && !sightBlocked(zone, player, state.zones)) player.revealed = Math.max(player.revealed, 2); } if (zone.kind === "heal" && zone.beat <= 0) { zone.beat = 0.5; const target = state.players.find((p) => p.id === zone.owner); if (target && target.alive) { target.hp = Math.min(100, target.hp + zone.damage / 6); zone.x = target.x; zone.y = target.y; } } if (zone.kind === "trip" && zone.life > 0) { const caught = state.players.find( (p) => p.alive && p.side !== zone.side && Math.hypot(p.x - zone.x, p.y - zone.y) <= zone.r, ); if (caught) { caught.revealed = Math.max(caught.revealed, 4); caught.slow = Math.max(caught.slow, 3); zone.life = 0; say(state, `${caught.name} tripped a wire`); } } // Blackout: a smoke that carries a slow (marked with negative damage). if (zone.kind === "smoke" && zone.damage < 0) for (const player of state.players) if (player.alive && player.side !== zone.side && Math.hypot(player.x - zone.x, player.y - zone.y) <= zone.r) player.slow = Math.max(player.slow, 0.5); } state.zones = state.zones.filter((z) => z.life > 0 || z.fuse > 0); } function endConditions(state: MatchState): void { const spike = state.spike; if (spike.planted && spike.defuse >= DEFUSE_TIME) return finishRound(state, "def", "Spike defused"); if (aliveOn(state, "def").length === 0) return finishRound(state, "atk", "Defenders eliminated"); if (aliveOn(state, "atk").length === 0 && !spike.planted) return finishRound(state, "def", "Attackers eliminated"); if (spike.planted) { if (spike.fuse <= 0) return finishRound(state, "atk", "Spike detonated"); } else if (state.clock <= 0) return finishRound(state, "def", "Time out"); } function finishRound(state: MatchState, winners: Side, why: string): void { const winningTeam: Team = winners === "atk" ? state.attackers : state.attackers === "red" ? "blue" : "red"; const losingTeam: Team = winningTeam === "red" ? "blue" : "red"; state.score[winningTeam] += 1; state.lossStreak[losingTeam] += 1; state.lossStreak[winningTeam] = 0; for (const player of state.players) { const won = player.team === winningTeam; player.credits = clampCredits( player.credits + roundReward(won, state.lossStreak[player.team], state.spike.planted && player.side === "atk"), ); } state.phase = "end"; state.clock = END_TIME; say(state, `${why} — ${winningTeam} take round ${state.round} (${state.score.red}–${state.score.blue})`); if (state.score[winningTeam] >= WIN_ROUNDS) { state.phase = "over"; state.clock = OVER_TIME; state.result = `${winningTeam} win ${state.score.red}–${state.score.blue}`; say(state, `Match over — ${state.result}. A new one starts in ${OVER_TIME} seconds.`); } } function nextRound(state: MatchState): void { state.round += 1; // Sides swap once twelve rounds are behind us. if (state.round === SWAP_AFTER + 1) { state.attackers = state.attackers === "red" ? "blue" : "red"; say(state, "Sides swap"); } startRound(state); say(state, `Round ${state.round} — buy phase`); } /** What one player is allowed to see: their own side, and what light reaches. */ export function visible(state: MatchState, viewer: Player, other: Player): boolean { if (other.id === viewer.id || other.side === viewer.side) return true; if (!other.alive) return false; if (other.revealed > 0) return true; if (viewer.blind > 0) return false; // A dead player watches the round out through their team's eyes, not // through the walls: what a living team mate can see, and nothing more. // Otherwise dying and staying connected would be a wallhack to call out. if (!viewer.alive) return state.players.some( (mate) => mate.side === viewer.side && mate.alive && mate.blind <= 0 && !sightBlocked(mate, other, state.zones), ); return !sightBlocked(viewer, other, state.zones); } /** Wraps to (-pi, pi]: a client's own claim of where it looks, kept in range * rather than left to grow without bound and lose precision against it. */ const normalizeAngle = (angle: number): number => ((angle + Math.PI * 3) % (Math.PI * 2)) - Math.PI; export function setInput(player: Player, input: Partial): void { const target = player.input; if (typeof input.mx === "number" && Number.isFinite(input.mx)) target.mx = Math.max(-1, Math.min(1, input.mx)); if (typeof input.my === "number" && Number.isFinite(input.my)) target.my = Math.max(-1, Math.min(1, input.my)); if (typeof input.angle === "number" && Number.isFinite(input.angle)) target.angle = normalizeAngle(input.angle); if (typeof input.fire === "boolean") target.fire = input.fire; if (typeof input.use === "boolean") target.use = input.use; if (typeof input.reload === "boolean") target.reload = input.reload; if (input.cast === "c" || input.cast === "q" || input.cast === "e" || input.cast === "x") target.cast = input.cast; } export { castable, agentById };