// --------------------------------------------------------------------------- // Authoritative server-side world simulation for the slither clone. // Handles snake movement/growth, boosting, pellets, collisions, deaths, // death-explosions into pellets and simple AI bots. // --------------------------------------------------------------------------- import { ARENA_RADIUS, BASE_SPEED, BOOST_MULT, BOOST_DRAIN, BOOST_DROP_INTERVAL, MIN_BOOST_SCORE, PELLET_COUNT, START_SCORE, TICK_RATE, TURN_RATE, BOOST_TURN_RATE, BOT_TARGET_TOTAL, RESPAWN_PROTECT_TICKS, SNAKE_COLORS, PELLET_COLORS, BOT_NAMES, snakeRadius, snakePointCount, snakeSpacing, clamp, normAngle, } from '../shared/constants.js'; import type { GameEvent, PelletData, SnapMsg, SnakeSnap } from '../shared/protocol.js'; export interface Pellet { id: number; x: number; y: number; v: number; c: number } interface GridItem { x: number; y: number; r: number; sid: number } export interface Snake { id: number; name: string; isBot: boolean; colorIdx: number; angle: number; targetAngle: number; boosting: boolean; score: number; best: number; kills: number; points: { x: number; y: number }[]; // head first alive: boolean; protect: number; // remaining spawn-protection ticks dropTimer: number; // bot brain state think: number; botBoostT: number; } /** Uniform-grid spatial hash for cheap neighborhood queries. */ class Grid { private map = new Map(); constructor(private cell: number) {} private key(cx: number, cy: number): number { return (cx + 128) * 512 + (cy + 128); } clear(): void { this.map.clear(); } insert(item: T): void { const k = this.key(Math.floor(item.x / this.cell), Math.floor(item.y / this.cell)); let a = this.map.get(k); if (!a) { a = []; this.map.set(k, a); } a.push(item); } /** Return all items in cells overlapping the circle (caller distance-checks). */ query(x: number, y: number, r: number): T[] { const out: T[] = []; const c = this.cell; const x0 = Math.floor((x - r) / c), x1 = Math.floor((x + r) / c); const y0 = Math.floor((y - r) / c), y1 = Math.floor((y + r) / c); for (let cx = x0; cx <= x1; cx++) { for (let cy = y0; cy <= y1; cy++) { const a = this.map.get(this.key(cx, cy)); if (a) for (const it of a) out.push(it); } } return out; } } const MAX_PELLETS = 1800; // ambient ~950 + room for death/boost drops export class World { snakes = new Map(); pellets = new Map(); tick = 0; private nextSnakeId = 1; private nextPelletId = 1; private events: GameEvent[] = []; private added: PelletData[] = []; private removed: number[] = []; private grid = new Grid(72); // snake body points (rebuilt each tick) private pelletGrid = new Grid(72); // pellets (rebuilt each tick) constructor() { for (let i = 0; i < PELLET_COUNT; i++) this.spawnAmbientPellet(false); this.added = []; // initial pellets are delivered via the init message instead } // ------------------------------------------------------------- spawning -- private randPos(margin: number): { x: number; y: number } { const a = Math.random() * Math.PI * 2; const d = Math.sqrt(Math.random()) * (ARENA_RADIUS - margin); return { x: Math.cos(a) * d, y: Math.sin(a) * d }; } private spawnAmbientPellet(announce = true): void { const p = this.randPos(80); const v = 1 + Math.floor(Math.pow(Math.random(), 2.2) * 5); // 1..5, weighted low const pel: Pellet = { id: this.nextPelletId++, x: Math.round(p.x), y: Math.round(p.y), v, c: (Math.random() * PELLET_COLORS.length) | 0 }; this.pellets.set(pel.id, pel); if (announce) this.added.push({ ...pel }); } private dropPellet(x: number, y: number, v: number): void { // hard cap so long sessions don't accumulate pellets forever if (this.pellets.size >= MAX_PELLETS) return; // keep dropped pellets inside the arena const d = Math.hypot(x, y); if (d > ARENA_RADIUS - 40) { const f = (ARENA_RADIUS - 40) / d; x *= f; y *= f; } const pel: Pellet = { id: this.nextPelletId++, x: Math.round(x), y: Math.round(y), v: clamp(Math.round(v), 1, 12), c: (Math.random() * PELLET_COLORS.length) | 0, }; this.pellets.set(pel.id, pel); this.added.push({ ...pel }); } private makeSnake(name: string, isBot: boolean): Snake { const id = this.nextSnakeId++; const colorIdx = (Math.random() * SNAKE_COLORS.length) | 0; return { id, name, isBot, colorIdx, angle: Math.random() * Math.PI * 2, targetAngle: 0, boosting: false, score: START_SCORE, best: START_SCORE, kills: 0, points: [], alive: false, protect: 0, dropTimer: 0, think: (Math.random() * 6) | 0, botBoostT: 0, }; } addPlayer(name: string): Snake { const s = this.makeSnake(name, false); this.snakes.set(s.id, s); this.spawnSnake(s); this.events.push({ t: 'join', id: s.id, n: s.name }); return s; } private spawnBot(): void { const name = BOT_NAMES[(Math.random() * BOT_NAMES.length) | 0] + (Math.random() < 0.4 ? String((Math.random() * 99) | 0) : ''); const s = this.makeSnake(name, true); s.score = START_SCORE * (1 + ((Math.random() * 14) | 0)); // varied sizes this.snakes.set(s.id, s); this.spawnSnake(s); this.events.push({ t: 'join', id: s.id, n: s.name }); } spawnSnake(s: Snake): void { let x = 0, y = 0; for (let tries = 0; tries < 24; tries++) { const p = this.randPos(500); x = p.x; y = p.y; if (this.clearAround(x, y, 320)) break; } s.angle = Math.random() * Math.PI * 2; s.targetAngle = s.angle; if (!s.isBot) s.score = START_SCORE; s.boosting = false; s.alive = true; s.protect = RESPAWN_PROTECT_TICKS; s.dropTimer = 0; const n = snakePointCount(s.score); const sp = snakeSpacing(s.score); s.points = []; for (let i = 0; i < n; i++) { s.points.push({ x: x - Math.cos(s.angle) * sp * i, y: y - Math.sin(s.angle) * sp * i }); } } private clearAround(x: number, y: number, r: number): boolean { for (const s of this.snakes.values()) { if (!s.alive) continue; for (const p of s.points) { if ((p.x - x) * (p.x - x) + (p.y - y) * (p.y - y) < r * r) return false; } } return true; } respawn(s: Snake): void { if (!s.alive) this.spawnSnake(s); } removePlayer(id: number): void { const s = this.snakes.get(id); if (!s) return; if (s.alive) this.killSnake(s); // leave a pellet trail behind this.snakes.delete(id); this.events.push({ t: 'leave', id }); } // -------------------------------------------------------------- update -- update(dt: number): void { this.tick++; // NOTE: events/pellet deltas accumulate across ticks and are cleared by // postSnapshot() once a snapshot carrying them has been broadcast. // rebuild spatial grids this.grid.clear(); for (const s of this.snakes.values()) { if (!s.alive || s.protect > 0) continue; // protected snakes are ghosts const r = snakeRadius(s.score); for (const p of s.points) this.grid.insert({ x: p.x, y: p.y, r, sid: s.id }); } this.pelletGrid.clear(); for (const p of this.pellets.values()) this.pelletGrid.insert(p); // bot brains for (const s of this.snakes.values()) if (s.isBot && s.alive) this.botThink(s); // move, then resolve collisions const dead: { s: Snake; by?: Snake }[] = []; for (const s of this.snakes.values()) { if (!s.alive) continue; this.moveSnake(s, dt); const h = s.points[0]; const r = snakeRadius(s.score); // red border kills if (Math.hypot(h.x, h.y) > ARENA_RADIUS - r * 0.4) { dead.push({ s }); continue; } if (s.protect > 0) { s.protect--; } else { // crashed into another snake's body? let killer: Snake | undefined; let hit = false; for (const q of this.grid.query(h.x, h.y, r + 40)) { if (q.sid === s.id) continue; // you cannot collide with yourself if (Math.hypot(q.x - h.x, q.y - h.y) < r * 0.8 + q.r * 0.85) { hit = true; killer = this.snakes.get(q.sid); break; } } if (hit) { dead.push({ s, by: killer }); continue; } } // eat pellets for (const p of this.pelletGrid.query(h.x, h.y, r + 24)) { if (!this.pellets.has(p.id)) continue; const pr = 2.5 + p.v * 0.9; if (Math.hypot(p.x - h.x, p.y - h.y) < r + pr) { this.pellets.delete(p.id); this.removed.push(p.id); s.score += p.v; } } if (s.score > s.best) s.best = s.score; } for (const d of dead) this.killSnake(d.s, d.by); // keep ambient pellet population topped up (trickle in) let want = PELLET_COUNT - this.pellets.size; while (want-- > 0 && Math.random() < 0.7) this.spawnAmbientPellet(); // keep the arena filled with bots let humans = 0, bots = 0; for (const s of this.snakes.values()) (s.isBot ? bots++ : humans++); const targetBots = Math.max(0, BOT_TARGET_TOTAL - humans); if (bots < targetBots) this.spawnBot(); else if (bots > targetBots) { // make room for humans: retire the weakest bot let weakest: Snake | null = null; for (const s of this.snakes.values()) { if (s.isBot && (!weakest || s.score < weakest.score)) weakest = s; } if (weakest) { this.killSnake(weakest); this.snakes.delete(weakest.id); } } } private moveSnake(s: Snake, dt: number): void { if (s.boosting && s.score <= MIN_BOOST_SCORE) s.boosting = false; const speed = BASE_SPEED * (s.boosting ? BOOST_MULT : 1); // players face their cursor instantly; bots keep a capped turn rate if (s.isBot) { const maxTurn = (s.boosting ? BOOST_TURN_RATE : TURN_RATE) * dt; const da = normAngle(s.targetAngle - s.angle); s.angle += clamp(da, -maxTurn, maxTurn); } else { s.angle = s.targetAngle; } // advance head, then pull the body chain along at fixed spacing const h = s.points[0]; s.points.unshift({ x: h.x + Math.cos(s.angle) * speed * dt, y: h.y + Math.sin(s.angle) * speed * dt }); const sp = snakeSpacing(s.score); for (let i = 1; i < s.points.length; i++) { const a = s.points[i - 1], b = s.points[i]; const dx = b.x - a.x, dy = b.y - a.y; const d = Math.hypot(dx, dy); if (d > sp) { const f = (d - sp) / d; b.x -= dx * f; b.y -= dy * f; } } const want = snakePointCount(s.score); while (s.points.length > want) s.points.pop(); // boosting burns length and sheds pellets from the tail if (s.boosting) { s.score = Math.max(1, s.score - BOOST_DRAIN * dt); s.dropTimer -= dt; if (s.dropTimer <= 0) { s.dropTimer = BOOST_DROP_INTERVAL; const tail = s.points[s.points.length - 1]; this.dropPellet(tail.x + (Math.random() - 0.5) * 14, tail.y + (Math.random() - 0.5) * 14, 2); } } } killSnake(s: Snake, by?: Snake): void { if (!s.alive) return; s.alive = false; if (by && by.id !== s.id) by.kills++; // explode into glowing pellets along the whole body const pts = s.points; const n = pts.length; const r = snakeRadius(s.score); const baseVal = clamp((s.score / Math.max(1, n / 2)) * 0.8, 1, 12); const sample: number[] = []; for (let i = 0; i < n; i += 2) { const p = pts[i]; this.dropPellet(p.x + (Math.random() - 0.5) * r * 1.6, p.y + (Math.random() - 0.5) * r * 1.6, baseVal * (0.7 + Math.random() * 0.6)); if (sample.length < 40) sample.push(Math.round(p.x), Math.round(p.y)); } this.events.push({ t: 'die', id: s.id, x: Math.round(pts[0].x), y: Math.round(pts[0].y), c: s.colorIdx, s: Math.round(s.score), p: sample }); if (s.isBot) { this.snakes.delete(s.id); this.events.push({ t: 'leave', id: s.id }); } } // ------------------------------------------------------------- bot AI -- private botThink(s: Snake): void { if (--s.think > 0) return; s.think = 5 + ((Math.random() * 4) | 0); const h = s.points[0]; const r = snakeRadius(s.score); // attraction: nearest pellet let px = 0, py = 0, pd = Infinity; for (const p of this.pelletGrid.query(h.x, h.y, 460)) { const d = Math.hypot(p.x - h.x, p.y - h.y); if (d < pd) { pd = d; px = p.x; py = p.y; } } let want = pd < 800 ? Math.atan2(py - h.y, px - h.x) : s.angle + (Math.random() - 0.5); // hunting: chase a clearly smaller snake if one is close let huntX = 0, huntY = 0, huntD = Infinity, huntBoost = false; for (const o of this.snakes.values()) { if (!o.alive || o.id === s.id || o.score > s.score * 0.8) continue; const oh = o.points[0]; const d = Math.hypot(oh.x - h.x, oh.y - h.y); if (d < 430 && d < huntD) { huntD = d; huntX = oh.x; huntY = oh.y; huntBoost = true; } } if (huntD < 430) want = Math.atan2(huntY - h.y, huntX - h.x); // score a fan of candidate headings, pick the safest one const look = 190 + BASE_SPEED * 0.55; let bestOff = 0, bestScore = -Infinity; for (let off = -100; off <= 100; off += 20) { const dir = s.angle + (off * Math.PI) / 180; const qx = h.x + Math.cos(dir) * look; const qy = h.y + Math.sin(dir) * look; let sc = -off * off * 0.02; const dc = Math.hypot(qx, qy); const over = dc - (ARENA_RADIUS - 280); if (over > 0) sc -= over * 0.6 + 80; for (const q of this.grid.query(qx, qy, 130)) { if (q.sid === s.id) continue; const d = Math.hypot(q.x - qx, q.y - qy); const danger = r * 0.8 + q.r * 0.85 + 40; if (d < danger) sc -= (danger - d) * 5; } sc += Math.cos(normAngle(want - dir)) * 34; if (sc > bestScore) { bestScore = sc; bestOff = off; } } s.targetAngle = normAngle(s.angle + (bestOff * Math.PI) / 180); // boost decisions if (--s.botBoostT <= 0) { s.boosting = false; if (s.score > 90 && Math.abs(bestOff) <= 20) { if (huntBoost && Math.random() < 0.6) { s.boosting = true; s.botBoostT = TICK_RATE; } else if (Math.random() < 0.05) { s.boosting = true; s.botBoostT = TICK_RATE / 2; } } } } // ------------------------------------------------------- serialization -- /** Called by the server after a snapshot has been broadcast to all clients. */ postSnapshot(): void { this.events = []; this.added = []; this.removed = []; } pelletList(): PelletData[] { const out: PelletData[] = []; for (const p of this.pellets.values()) out.push({ ...p }); return out; } snapshotFor(me: Snake | null): string { const snakes: SnakeSnap[] = []; const ranked: Snake[] = []; for (const o of this.snakes.values()) { if (!o.alive) continue; ranked.push(o); const p: number[] = new Array(o.points.length * 2); for (let i = 0; i < o.points.length; i++) { p[i * 2] = Math.round(o.points[i].x); p[i * 2 + 1] = Math.round(o.points[i].y); } snakes.push({ id: o.id, n: o.name, c: o.colorIdx, s: Math.round(o.score), p, b: o.boosting, k: o.kills, sp: o.protect > 0 || undefined, }); } ranked.sort((a, b) => b.score - a.score); const lb = ranked.slice(0, 10).map(o => ({ id: o.id, n: o.name, s: Math.round(o.score) })); let you: SnapMsg['you']; if (me) { const rank = me.alive ? ranked.findIndex(o => o.id === me.id) + 1 : 0; you = { score: Math.round(me.score), rank, alive: me.alive, kills: me.kills, best: Math.round(me.best) }; } const msg: SnapMsg = { t: 'snap', tick: this.tick, snakes, add: this.added, rem: this.removed, ev: this.events, lb, you, total: ranked.length, }; return JSON.stringify(msg); } }