commit 0b24de2767dbe6cf47f627cdd18691cbe35eead4 Author: Owen Qwen Date: Wed Jul 22 00:34:09 2026 -0500 Inital commit diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..7a71b8d --- /dev/null +++ b/.gitignore @@ -0,0 +1,4 @@ +node_modules/ +dist/ +public/js/ +*.log diff --git a/README.md b/README.md new file mode 100644 index 0000000..7b0638a --- /dev/null +++ b/README.md @@ -0,0 +1,76 @@ +# slither.ts ๐Ÿ + +A full **slither.io clone** written in TypeScript โ€” authoritative Node.js + WebSocket +server, Canvas 2D client, zero asset files (every texture is generated at runtime, +every sound is synthesized with the Web Audio API). + +## Run it + +```bash +npm install +npm run build +npm start +``` + +Then open **http://localhost:8080** in your browser. Open more tabs (or other +machines on your LAN) for more human players โ€” the arena is automatically +filled to 12 snakes with AI bots. + +For development: `npm run dev` builds and starts the server. + +## Features + +- **Smooth movement** โ€” 30 Hz authoritative simulation, 15 Hz snapshots, + client-side dead reckoning (the client runs the same movement model every + frame) with decaying snapshot-correction offsets, plus dt-based exponential + camera smoothing +- **Grow by eating glowing pellets** scattered across the arena +- **Boost** with `SPACE` (or hold left mouse / second touch finger) โ€” moves + ~2ร— faster while burning your length and shedding pellets from your tail +- **Eliminations** โ€” make others crash into your body; you cannot collide with + yourself +- **Death explosions** โ€” dead snakes burst into glowing pellets along their + whole body for everyone else to harvest +- **Live leaderboard** (top 10, updates in real time) + score / rank / kills HUD +- **Instant respawning** โ€” click or hit `Enter`, no countdown +- **Large circular arena** with a glowing red border that kills on contact +- **Minimap** showing the entire map, all players and your position +- **Slither.io-style background** โ€” dark grey gradient, repeating hexagon grid, + faint glowing hexagon outlines, soft ambient light + vignette +- **AI bots** with pellet-seeking, obstacle avoidance and hunting behaviour +- **Main menu** with nickname entry, **settings** (volume, quality, minimap), + and **fullscreen** support โ€” all persisted to localStorage +- **Sound effects** (eat, boost whoosh, death explosion, UI clicks) and + **particle effects** (eat sparkles, boost bubbles, death bursts) + +## Controls + +| Input | Action | +| ---------------- | ------------------------------- | +| Mouse / touch | Steer (snake follows cursor) | +| `SPACE` / click | Boost (drains length) | +| `Enter` | Instant respawn when dead | + +## Project layout + +``` +shared/ constants + protocol shared by client & server +server/ world simulation (movement, collisions, bots) + HTTP/WS server +client/src/ renderer, background, minimap, particles, audio, UI, input +public/ index.html, style.css and the compiled client JS +scripts/ smoke.mjs โ€” end-to-end WebSocket smoke test +``` + +## How it works + +The server simulates everything at 30 ticks/s and broadcasts compact JSON +snapshots at 15 Hz (snake bodies as flat integer arrays; pellets as incremental +add/remove deltas after a one-time full list). The client does **not** render +the snapshots directly โ€” it runs the same movement model locally every frame +(dead reckoning): your own snake is steered straight from your input angle +with the server's turn-rate limit (zero perceived input latency), remote +snakes use headings estimated from snapshot deltas. Each snapshot captures +the prediction error as per-point correction offsets that decay smoothly over +the following frames, so server authority is kept without any visible steps. +Collision and eating use a uniform-grid spatial hash, so the sim stays fast +with a full arena of long snakes. diff --git a/client/src/audio.ts b/client/src/audio.ts new file mode 100644 index 0000000..1e88c25 --- /dev/null +++ b/client/src/audio.ts @@ -0,0 +1,156 @@ +// --------------------------------------------------------------------------- +// All sound effects are synthesized with the Web Audio API โ€” no asset files. +// --------------------------------------------------------------------------- + +export class AudioSys { + private ctx: AudioContext | null = null; + private master: GainNode | null = null; + private noiseBuf: AudioBuffer | null = null; + private boostNodes: { src: AudioBufferSourceNode; gain: GainNode } | null = null; + private volume = 0.6; + private lastEat = 0; + + /** Must be called from a user gesture at least once. */ + ensure(): void { + if (!this.ctx) { + const AC = window.AudioContext || (window as unknown as { webkitAudioContext: typeof AudioContext }).webkitAudioContext; + if (!AC) return; + this.ctx = new AC(); + this.master = this.ctx.createGain(); + this.master.gain.value = this.volume; + this.master.connect(this.ctx.destination); + + // 1 second of white noise, reused by several effects + const len = this.ctx.sampleRate; + this.noiseBuf = this.ctx.createBuffer(1, len, this.ctx.sampleRate); + const d = this.noiseBuf.getChannelData(0); + for (let i = 0; i < len; i++) d[i] = Math.random() * 2 - 1; + } + if (this.ctx.state === 'suspended') void this.ctx.resume(); + } + + setVolume(v: number): void { + this.volume = v; + if (this.master && this.ctx) this.master.gain.setTargetAtTime(v, this.ctx.currentTime, 0.02); + } + + private env(gain: GainNode, peak: number, attack: number, decay: number): void { + const t = this.ctx!.currentTime; + gain.gain.setValueAtTime(0.0001, t); + gain.gain.exponentialRampToValueAtTime(Math.max(0.0002, peak), t + attack); + gain.gain.exponentialRampToValueAtTime(0.0001, t + attack + decay); + } + + click(): void { + if (!this.ctx || !this.master) return; + const o = this.ctx.createOscillator(); + const g = this.ctx.createGain(); + o.type = 'triangle'; + o.frequency.value = 340; + o.frequency.exponentialRampToValueAtTime(520, this.ctx.currentTime + 0.06); + this.env(g, 0.25, 0.005, 0.09); + o.connect(g).connect(this.master); + o.start(); + o.stop(this.ctx.currentTime + 0.12); + } + + eat(): void { + if (!this.ctx || !this.master) return; + // avoid machine-gunning when hoovering many pellets at once + const now = performance.now(); + if (now - this.lastEat < 45) return; + this.lastEat = now; + + const f = 480 + Math.random() * 320; + const o = this.ctx.createOscillator(); + const g = this.ctx.createGain(); + o.type = 'sine'; + o.frequency.setValueAtTime(f, this.ctx.currentTime); + o.frequency.exponentialRampToValueAtTime(f * 1.9, this.ctx.currentTime + 0.07); + this.env(g, 0.16, 0.004, 0.09); + o.connect(g).connect(this.master); + o.start(); + o.stop(this.ctx.currentTime + 0.12); + } + + death(): void { + if (!this.ctx || !this.master || !this.noiseBuf) return; + const t = this.ctx.currentTime; + + // noise burst through a sweeping lowpass + const src = this.ctx.createBufferSource(); + src.buffer = this.noiseBuf; + const lp = this.ctx.createBiquadFilter(); + lp.type = 'lowpass'; + lp.frequency.setValueAtTime(1400, t); + lp.frequency.exponentialRampToValueAtTime(90, t + 0.55); + const g = this.ctx.createGain(); + this.env(g, 0.5, 0.008, 0.6); + src.connect(lp).connect(g).connect(this.master); + src.start(); + src.stop(t + 0.7); + + // low "whump" + const o = this.ctx.createOscillator(); + const og = this.ctx.createGain(); + o.type = 'sine'; + o.frequency.setValueAtTime(180, t); + o.frequency.exponentialRampToValueAtTime(38, t + 0.45); + this.env(og, 0.4, 0.005, 0.5); + o.connect(og).connect(this.master); + o.start(); + o.stop(t + 0.55); + } + + /** soft pop when a nearby enemy dies */ + pop(): void { + if (!this.ctx || !this.master) return; + const o = this.ctx.createOscillator(); + const g = this.ctx.createGain(); + o.type = 'square'; + o.frequency.setValueAtTime(220, this.ctx.currentTime); + o.frequency.exponentialRampToValueAtTime(70, this.ctx.currentTime + 0.12); + this.env(g, 0.1, 0.004, 0.14); + o.connect(g).connect(this.master); + o.start(); + o.stop(this.ctx.currentTime + 0.18); + } + + respawn(): void { + if (!this.ctx || !this.master) return; + const o = this.ctx.createOscillator(); + const g = this.ctx.createGain(); + o.type = 'triangle'; + o.frequency.setValueAtTime(240, this.ctx.currentTime); + o.frequency.exponentialRampToValueAtTime(760, this.ctx.currentTime + 0.18); + this.env(g, 0.2, 0.01, 0.24); + o.connect(g).connect(this.master); + o.start(); + o.stop(this.ctx.currentTime + 0.3); + } + + /** looping whoosh while boosting */ + boost(on: boolean): void { + if (!this.ctx || !this.master || !this.noiseBuf) return; + if (on && !this.boostNodes) { + const src = this.ctx.createBufferSource(); + src.buffer = this.noiseBuf; + src.loop = true; + const bp = this.ctx.createBiquadFilter(); + bp.type = 'bandpass'; + bp.frequency.value = 520; + bp.Q.value = 0.8; + const g = this.ctx.createGain(); + g.gain.setValueAtTime(0.0001, this.ctx.currentTime); + g.gain.exponentialRampToValueAtTime(0.09, this.ctx.currentTime + 0.15); + src.connect(bp).connect(g).connect(this.master); + src.start(); + this.boostNodes = { src, gain: g }; + } else if (!on && this.boostNodes) { + const { src, gain } = this.boostNodes; + this.boostNodes = null; + gain.gain.setTargetAtTime(0.0001, this.ctx.currentTime, 0.08); + setTimeout(() => { try { src.stop(); } catch { /* already stopped */ } }, 400); + } + } +} diff --git a/client/src/background.ts b/client/src/background.ts new file mode 100644 index 0000000..4811a94 --- /dev/null +++ b/client/src/background.ts @@ -0,0 +1,169 @@ +// --------------------------------------------------------------------------- +// Slither.io-style arena background: +// - dark grey radial gradient +// - subtle repeating hexagon grid pattern spanning the whole map +// - a few faint glowing hexagon outlines +// - soft ambient center light + vignette for depth +// All textures are generated at runtime (no asset files). +// --------------------------------------------------------------------------- + +const HEX_SIZE = 46; // hexagon "radius" in world px + +export class Background { + private hexPattern: CanvasPattern | null = null; + private glowHex: HTMLCanvasElement; + private gradient: CanvasGradient | null = null; + private vignette: CanvasGradient | null = null; + private ambient: CanvasGradient | null = null; + private gw = 0; + private gh = 0; + + constructor() { + this.glowHex = this.makeGlowHex(); + } + + /** pointy-top hexagon path */ + private static hexPath(ctx: CanvasRenderingContext2D, cx: number, cy: number, s: number): void { + ctx.beginPath(); + for (let i = 0; i < 6; i++) { + const a = (Math.PI / 3) * i - Math.PI / 6; + const x = cx + Math.cos(a) * s; + const y = cy + Math.sin(a) * s; + if (i === 0) ctx.moveTo(x, y); else ctx.lineTo(x, y); + } + ctx.closePath(); + } + + /** tile containing two staggered hexagon outlines -> seamless hex grid */ + private makeHexPattern(ctx: CanvasRenderingContext2D): CanvasPattern | null { + const s = HEX_SIZE; + const w = Math.sqrt(3) * s; // horizontal spacing + const h = 1.5 * s; // vertical spacing + const tile = document.createElement('canvas'); + tile.width = Math.ceil(w * 2); + tile.height = Math.ceil(h * 2); + const t = tile.getContext('2d')!; + t.strokeStyle = 'rgba(255,255,255,0.045)'; + t.lineWidth = 1.5; + // draw the hexagons that overlap the tile (with wrap-around copies) + const centers: [number, number][] = [ + [0, 0], [w, 0], [2 * w, 0], + [w / 2, h], [1.5 * w, h], + [0, 2 * h], [w, 2 * h], [2 * w, 2 * h], + [w / 2, -h], [1.5 * w, -h], + ]; + for (const [cx, cy] of centers) { + Background.hexPath(t, cx, cy, s); + t.stroke(); + } + return ctx.createPattern(tile, 'repeat'); + } + + private makeGlowHex(): HTMLCanvasElement { + const s = HEX_SIZE; + const pad = 30; + const c = document.createElement('canvas'); + c.width = c.height = Math.ceil(s * 2 + pad * 2); + const t = c.getContext('2d')!; + t.translate(c.width / 2, c.height / 2); + t.strokeStyle = 'rgba(120,190,255,0.85)'; + t.lineWidth = 2.5; + t.shadowColor = 'rgba(90,160,255,0.9)'; + t.shadowBlur = 16; + Background.hexPath(t, 0, 0, s); + t.stroke(); + t.stroke(); // second pass to intensify the glow + return c; + } + + /** deterministic pseudo-random in [0,1) for a hex cell */ + private static hash(col: number, row: number): number { + let h = (col * 374761393 + row * 668265263) | 0; + h = (h ^ (h >> 13)) * 1274126177; + h = h ^ (h >> 16); + return (h >>> 0) / 4294967296; + } + + /** screen-space gradient layers; rebuilt on resize */ + resize(ctx: CanvasRenderingContext2D, w: number, h: number): void { + this.gw = w; this.gh = h; + const cx = w / 2, cy = h / 2; + const R = Math.hypot(w, h) / 2; + + const g = ctx.createRadialGradient(cx, cy, 0, cx, cy, R); + g.addColorStop(0, '#22252c'); + g.addColorStop(0.55, '#171a1f'); + g.addColorStop(1, '#0d0f13'); + this.gradient = g; + + const v = ctx.createRadialGradient(cx, cy, R * 0.35, cx, cy, R); + v.addColorStop(0, 'rgba(0,0,0,0)'); + v.addColorStop(1, 'rgba(0,0,0,0.42)'); + this.vignette = v; + + const a = ctx.createRadialGradient(cx, cy, 0, cx, cy, Math.min(w, h) * 0.55); + a.addColorStop(0, 'rgba(130,170,255,0.05)'); + a.addColorStop(1, 'rgba(130,170,255,0)'); + this.ambient = a; + } + + /** + * Draw the background. Called in screen space; this method temporarily + * switches to the world transform for the hex grid so it scrolls and zooms + * exactly with the world. + */ + draw( + ctx: CanvasRenderingContext2D, + w: number, h: number, + cam: { x: number; y: number; zoom: number }, + highQuality: boolean, + ): void { + if (!this.gradient || w !== this.gw || h !== this.gh) this.resize(ctx, w, h); + if (!this.hexPattern) this.hexPattern = this.makeHexPattern(ctx); + + // base gradient + ctx.fillStyle = this.gradient!; + ctx.fillRect(0, 0, w, h); + + // hex grid in world space + ctx.save(); + ctx.translate(w / 2, h / 2); + ctx.scale(cam.zoom, cam.zoom); + ctx.translate(-cam.x, -cam.y); + const vw = w / cam.zoom, vh = h / cam.zoom; + const vx = cam.x - vw / 2, vy = cam.y - vh / 2; + ctx.fillStyle = this.hexPattern!; + ctx.fillRect(vx - 4, vy - 4, vw + 8, vh + 8); + + // faint glowing hexagon outlines sprinkled deterministically over the map + if (highQuality) { + const s = HEX_SIZE; + const stepX = Math.sqrt(3) * s; + const stepY = 1.5 * s; + const row0 = Math.floor(vy / stepY) - 1; + const row1 = Math.ceil((vy + vh) / stepY) + 1; + const sprite = this.glowHex.width / 2; + ctx.globalAlpha = 0.10; + for (let row = row0; row <= row1; row++) { + const offset = (row & 1) ? stepX / 2 : 0; + const col0 = Math.floor((vx - offset) / stepX) - 1; + const col1 = Math.ceil((vx + vw - offset) / stepX) + 1; + for (let col = col0; col <= col1; col++) { + if (Background.hash(col, row) < 0.055) { + const cx = col * stepX + offset; + const cy = row * stepY; + ctx.drawImage(this.glowHex, cx - sprite, cy - sprite); + } + } + } + ctx.globalAlpha = 1; + } + ctx.restore(); + + // soft ambient light + vignette (screen space) + ctx.fillStyle = this.ambient!; + ctx.fillRect(0, 0, w, h); + ctx.fillStyle = this.vignette!; + ctx.fillRect(0, 0, w, h); + } +} diff --git a/client/src/input.ts b/client/src/input.ts new file mode 100644 index 0000000..3181521 --- /dev/null +++ b/client/src/input.ts @@ -0,0 +1,71 @@ +// --------------------------------------------------------------------------- +// Mouse / keyboard / touch input. The snake steers towards the cursor +// direction relative to the screen center, slither.io style. +// --------------------------------------------------------------------------- + +export class Input { + mouseX = 0; + mouseY = 0; + spaceDown = false; + mouseDown = false; + onRespawnKey: () => void = () => {}; + /** set while the death overlay is up so Enter triggers respawn */ + dead = false; + + constructor() { + this.mouseX = window.innerWidth / 2; + this.mouseY = window.innerHeight / 2 - 100; // start heading "up" + + window.addEventListener('mousemove', (e) => { + this.mouseX = e.clientX; + this.mouseY = e.clientY; + }); + window.addEventListener('mousedown', (e) => { + if (e.button === 0 && !(e.target instanceof HTMLInputElement) && !(e.target instanceof HTMLButtonElement)) { + this.mouseDown = true; + } + }); + window.addEventListener('mouseup', () => { this.mouseDown = false; }); + window.addEventListener('contextmenu', (e) => { + if (!(e.target instanceof HTMLInputElement)) e.preventDefault(); + }); + + window.addEventListener('keydown', (e) => { + if (e.code === 'Space') { + this.spaceDown = true; + if (!(e.target instanceof HTMLInputElement)) e.preventDefault(); + } + if (e.code === 'Enter' && this.dead) { + e.preventDefault(); + this.onRespawnKey(); + } + }); + window.addEventListener('keyup', (e) => { + if (e.code === 'Space') this.spaceDown = false; + }); + + // basic touch support: drag to steer, second finger to boost + window.addEventListener('touchstart', (e) => { + const t = e.touches[0]; + this.mouseX = t.clientX; this.mouseY = t.clientY; + if (e.touches.length > 1) this.mouseDown = true; + }, { passive: true }); + window.addEventListener('touchmove', (e) => { + const t = e.touches[0]; + this.mouseX = t.clientX; this.mouseY = t.clientY; + if (e.cancelable && !(e.target instanceof HTMLInputElement)) e.preventDefault(); + }, { passive: false }); + window.addEventListener('touchend', (e) => { + if (e.touches.length <= 1) this.mouseDown = false; + }); + } + + /** Desired heading angle in world space (screen center = snake head). */ + angle(): number { + return Math.atan2(this.mouseY - window.innerHeight / 2, this.mouseX - window.innerWidth / 2); + } + + boosting(): boolean { + return this.spaceDown || this.mouseDown; + } +} diff --git a/client/src/main.ts b/client/src/main.ts new file mode 100644 index 0000000..6a5b6c3 --- /dev/null +++ b/client/src/main.ts @@ -0,0 +1,233 @@ +// --------------------------------------------------------------------------- +// Client entry point: networking, camera, HUD, effects, and the render loop. +// --------------------------------------------------------------------------- + +import { Net } from './net.js'; +import { State, applySnakeSnaps, simulateSnakes } from './state.js'; +import { Renderer } from './renderer.js'; +import { Particles } from './particles.js'; +import { AudioSys } from './audio.js'; +import { UI } from './ui.js'; +import { Input } from './input.js'; +import { Minimap } from './minimap.js'; +import { + SNAKE_COLORS, PELLET_COLORS, snakeRadius, clamp, +} from '../../shared/constants.js'; +import type { GameEvent, InitMsg, SnapMsg } from '../../shared/protocol.js'; + +const canvas = document.getElementById('game') as HTMLCanvasElement; +const minimapCanvas = document.getElementById('minimap') as HTMLCanvasElement; + +const state = new State(); +const renderer = new Renderer(canvas); +const particles = new Particles(); +const audio = new AudioSys(); +const ui = new UI(); +const input = new Input(); +const minimap = new Minimap(minimapCanvas); +const net = new Net(); + +let joined = false; // clicked Play and joined the arena +let wasAlive = false; +let pendingDeathAt = 0; // show the death overlay shortly after exploding +let deathHandled = false; + +// ------------------------------------------------------------ networking -- + +net.onInit = (m: InitMsg) => { + state.myId = m.id; + state.arena = m.arena; + state.pellets.clear(); + for (const p of m.pellets) state.pellets.set(p.id, p); + joined = true; + ui.showGame(); +}; + +net.onSnap = (m: SnapMsg) => { + applySnakeSnaps(state, m.snakes); + + for (const p of m.add) state.pellets.set(p.id, p); + for (const id of m.rem) handlePelletRemoved(id); + + state.lb = m.lb; + state.total = m.total; + if (m.you) { + const prevAlive = state.you.alive; + state.you = m.you; + if (m.you.alive && !prevAlive && joined) { + // respawned + ui.hideDeath(); + input.dead = false; + deathHandled = false; + audio.respawn(); + } + } + + for (const ev of m.ev) handleEvent(ev); +}; + +net.onClose = () => { + if (joined) ui.showError('Connection lost. Please refresh the page.'); + else if (playRequested) ui.showError('Could not reach the server. Is it running?'); +}; + +function handlePelletRemoved(id: number): void { + const p = state.pellets.get(id); + if (!p) return; + state.pellets.delete(id); + const color = PELLET_COLORS[p.c % PELLET_COLORS.length]; + const head = scratchHead(); + if (head) { + const r = snakeRadius(state.you.score); + const d = Math.hypot(p.x - head.x, p.y - head.y); + if (d < r + 26) { + // we ate it + audio.eat(); + particles.eat(p.x, p.y, color); + return; + } + } + // someone else ate it near the camera: tiny sparkle for ambience + if (Math.hypot(p.x - state.cam.x, p.y - state.cam.y) < 700) { + particles.eat(p.x, p.y, color); + } +} + +function handleEvent(ev: GameEvent): void { + if (ev.t === 'die') { + const color = SNAKE_COLORS[ev.c % SNAKE_COLORS.length]; + // explosion along the whole body + for (let i = 0; i < ev.p.length; i += 2) { + particles.burst(ev.p[i], ev.p[i + 1], color, 0.45); + } + particles.burst(ev.x, ev.y, color, 1.4); + + if (ev.id === state.myId) { + audio.death(); + audio.boost(false); + state.lastDeathPos = { x: ev.x, y: ev.y }; + pendingDeathAt = performance.now() + 750; + } else if (Math.hypot(ev.x - state.cam.x, ev.y - state.cam.y) < 900) { + audio.pop(); + } + } +} + +// ------------------------------------------------------------ game flow -- + +let playRequested = false; + +ui.onPlay = (name) => { + playRequested = true; + audio.ensure(); + audio.click(); + particles.clear(); + state.snakes.clear(); + net.send({ t: 'join', name }); +}; + +ui.onRespawn = () => { + audio.ensure(); + audio.click(); + net.send({ t: 'respawn' }); +}; + +input.onRespawnKey = () => ui.onRespawn(); + +ui.onSettingsChanged = () => { + audio.setVolume(ui.settings.volume); + particles.qualityScale = ui.settings.quality === 'low' ? 0.5 : 1; + particles.maxParticles = ui.settings.quality === 'low' ? 350 : 700; +}; + +// connect right away so Play is instant +net.connect(); + +// send inputs at ~33 Hz +setInterval(() => { + if (!joined || !state.you.alive) return; + net.send({ t: 'in', a: input.angle(), b: input.boosting() }); +}, 30); + +function scratchHead(): { x: number; y: number } | null { + const me = state.snakes.get(state.myId); + if (!me || me.pts.length < 2) return null; + return { x: me.pts[0], y: me.pts[1] }; +} + +// ------------------------------------------------------------- main loop -- + +let last = performance.now(); +let hudTimer = 0; + +function frame(now: number): void { + const dt = Math.min(0.05, (now - last) / 1000); + last = now; + + // --- local movement simulation (dead reckoning + server reconciliation) --- + simulateSnakes(state, dt, input.angle(), state.you.alive && input.boosting()); + + // --- camera --- + const head = scratchHead(); + let tx = state.cam.x, ty = state.cam.y, tz = state.cam.zoom; + if (head && state.you.alive) { + tx = head.x; ty = head.y; + tz = clamp(1.55 - Math.sqrt(Math.max(1, state.you.score)) * 0.011, 0.55, 1.55); + if (!state.camInit) { + state.cam.x = tx; state.cam.y = ty; state.cam.zoom = tz; + state.camInit = true; + } + } else if (state.lastDeathPos) { + tx = state.lastDeathPos.x; ty = state.lastDeathPos.y; + tz = 0.55; // zoom out a little while dead + } + const k = 1 - Math.exp(-6 * dt); + state.cam.x += (tx - state.cam.x) * k; + state.cam.y += (ty - state.cam.y) * k; + state.cam.zoom += (tz - state.cam.zoom) * (1 - Math.exp(-3 * dt)); + + // --- effects --- + particles.update(dt); + + // boost bubbles at tails of boosting snakes + boost sound for ourselves + if (joined) { + const meBoost = state.you.alive && input.boosting(); + for (const s of state.snakes.values()) { + const b = s.id === state.myId ? meBoost : s.boosting; + if (!b) continue; + const n = s.pts.length / 2; + if (n < 2) continue; + const tx2 = s.pts[(n - 1) * 2], ty2 = s.pts[(n - 1) * 2 + 1]; + const ang = Math.atan2(ty2 - s.pts[(n - 2) * 2 + 1], tx2 - s.pts[(n - 2) * 2]); + particles.boost(tx2, ty2, SNAKE_COLORS[s.colorIdx % SNAKE_COLORS.length], ang); + } + audio.boost(meBoost && !document.hidden); + } + + // --- death overlay (slight delay so the explosion is visible) --- + if (pendingDeathAt && now >= pendingDeathAt && !state.you.alive && !deathHandled) { + pendingDeathAt = 0; + deathHandled = true; + input.dead = true; + ui.showDeath(state.you); + } + + // --- HUD --- + hudTimer -= dt; + if (joined && hudTimer <= 0) { + hudTimer = 0.3; + ui.updateHud(state.lb, state.myId, state.you, state.total); + } + + // --- render --- + renderer.draw(state, now, particles, { quality: ui.settings.quality }); + if (joined) minimap.draw(state, now, ui.settings.minimap); + + requestAnimationFrame(frame); +} + +requestAnimationFrame(frame); + +// unlock audio on first interaction (browser autoplay policy) +window.addEventListener('pointerdown', () => audio.ensure(), { once: true }); +window.addEventListener('keydown', () => audio.ensure(), { once: true }); diff --git a/client/src/minimap.ts b/client/src/minimap.ts new file mode 100644 index 0000000..46c985d --- /dev/null +++ b/client/src/minimap.ts @@ -0,0 +1,69 @@ +// --------------------------------------------------------------------------- +// Circular minimap: shows the whole arena, the red border, every snake and +// your own highlighted position. +// --------------------------------------------------------------------------- + +import { State } from './state.js'; +import { SNAKE_COLORS } from '../../shared/constants.js'; + +export class Minimap { + private ctx: CanvasRenderingContext2D; + + constructor(private canvas: HTMLCanvasElement) { + this.ctx = canvas.getContext('2d')!; + } + + draw(state: State, now: number, visible: boolean): void { + this.canvas.style.display = visible ? 'block' : 'none'; + if (!visible) return; + + const ctx = this.ctx; + const W = this.canvas.width, H = this.canvas.height; + const cx = W / 2, cy = H / 2; + const scale = (W / 2 - 8) / state.arena; + + ctx.clearRect(0, 0, W, H); + + // arena disc + red rim + ctx.beginPath(); + ctx.arc(cx, cy, state.arena * scale + 3, 0, Math.PI * 2); + ctx.fillStyle = 'rgba(14,16,20,0.72)'; + ctx.fill(); + ctx.lineWidth = 2.5; + ctx.strokeStyle = 'rgba(255,45,60,0.85)'; + ctx.stroke(); + + for (const s of state.snakes.values()) { + const pts = s.pts; + const n = pts.length / 2; + if (n < 2) continue; + const isMe = s.id === state.myId; + const color = SNAKE_COLORS[s.colorIdx % SNAKE_COLORS.length]; + + // body polyline (downsampled) + const step = Math.max(1, Math.floor(n / 18)); + ctx.beginPath(); + ctx.moveTo(cx + pts[0] * scale, cy + pts[1] * scale); + for (let i = step; i < n; i += step) { + ctx.lineTo(cx + pts[i * 2] * scale, cy + pts[i * 2 + 1] * scale); + } + ctx.lineWidth = isMe ? 2.6 : 1.6; + ctx.strokeStyle = color; + ctx.globalAlpha = isMe ? 1 : 0.75; + ctx.stroke(); + + // head dot + ctx.globalAlpha = 1; + ctx.fillStyle = color; + ctx.beginPath(); + ctx.arc(cx + pts[0] * scale, cy + pts[1] * scale, isMe ? 3.4 : 2.4, 0, Math.PI * 2); + ctx.fill(); + if (isMe) { + ctx.lineWidth = 1.4; + ctx.strokeStyle = '#ffffff'; + ctx.stroke(); + } + } + ctx.globalAlpha = 1; + } +} diff --git a/client/src/net.ts b/client/src/net.ts new file mode 100644 index 0000000..f71e114 --- /dev/null +++ b/client/src/net.ts @@ -0,0 +1,45 @@ +// --------------------------------------------------------------------------- +// Thin WebSocket client wrapper. +// --------------------------------------------------------------------------- + +import type { ClientMsg, InitMsg, SnapMsg } from '../../shared/protocol.js'; + +export class Net { + onInit: (m: InitMsg) => void = () => {}; + onSnap: (m: SnapMsg) => void = () => {}; + onOpen: () => void = () => {}; + onClose: () => void = () => {}; + + private ws: WebSocket | null = null; + private queue: ClientMsg[] = []; + connected = false; + + connect(): void { + const proto = location.protocol === 'https:' ? 'wss' : 'ws'; + const ws = new WebSocket(`${proto}://${location.host}/ws`); + this.ws = ws; + + ws.onopen = () => { + this.connected = true; + for (const m of this.queue) ws.send(JSON.stringify(m)); + this.queue.length = 0; + this.onOpen(); + }; + ws.onclose = () => { this.connected = false; this.onClose(); }; + ws.onerror = () => { /* onclose follows */ }; + ws.onmessage = (e) => { + let m: InitMsg | SnapMsg; + try { m = JSON.parse(e.data as string); } catch { return; } + if (m.t === 'init') this.onInit(m); + else if (m.t === 'snap') this.onSnap(m); + }; + } + + send(m: ClientMsg): void { + if (this.ws && this.ws.readyState === WebSocket.OPEN) { + this.ws.send(JSON.stringify(m)); + } else { + this.queue.push(m); + } + } +} diff --git a/client/src/particles.ts b/client/src/particles.ts new file mode 100644 index 0000000..e95338a --- /dev/null +++ b/client/src/particles.ts @@ -0,0 +1,100 @@ +// --------------------------------------------------------------------------- +// Lightweight additive-blended particle system (eat sparkles, boost bubbles, +// death explosions). +// --------------------------------------------------------------------------- + +interface Particle { + x: number; y: number; + vx: number; vy: number; + life: number; maxLife: number; + size: number; + color: string; + drag: number; + shrink: boolean; +} + +export class Particles { + private list: Particle[] = []; + maxParticles = 700; + qualityScale = 1; // 0.5 on low quality + + private push(p: Particle): void { + if (this.list.length >= this.maxParticles) this.list.shift(); + this.list.push(p); + } + + /** small sparkle when a pellet is eaten */ + eat(x: number, y: number, color: string): void { + const n = Math.max(2, Math.round(5 * this.qualityScale)); + for (let i = 0; i < n; i++) { + const a = Math.random() * Math.PI * 2; + const sp = 30 + Math.random() * 90; + this.push({ + x, y, vx: Math.cos(a) * sp, vy: Math.sin(a) * sp, + life: 0.3 + Math.random() * 0.2, maxLife: 0.5, + size: 1.5 + Math.random() * 2, color, drag: 4, shrink: true, + }); + } + } + + /** bubble shed behind a boosting snake */ + boost(x: number, y: number, color: string, angle: number): void { + const spread = 0.7; + const a = angle + Math.PI + (Math.random() - 0.5) * spread; + const sp = 40 + Math.random() * 60; + this.push({ + x: x + (Math.random() - 0.5) * 8, y: y + (Math.random() - 0.5) * 8, + vx: Math.cos(a) * sp, vy: Math.sin(a) * sp, + life: 0.35 + Math.random() * 0.25, maxLife: 0.6, + size: 2 + Math.random() * 2.5, color, drag: 2.5, shrink: true, + }); + } + + /** big explosion when a snake dies */ + burst(x: number, y: number, color: string, power = 1): void { + const n = Math.max(6, Math.round(26 * power * this.qualityScale)); + for (let i = 0; i < n; i++) { + const a = Math.random() * Math.PI * 2; + const sp = 60 + Math.random() * 240 * power; + this.push({ + x, y, vx: Math.cos(a) * sp, vy: Math.sin(a) * sp, + life: 0.5 + Math.random() * 0.6, maxLife: 1.1, + size: 2 + Math.random() * 4 * power, color, drag: 2.2, shrink: true, + }); + } + } + + update(dt: number): void { + const l = this.list; + for (let i = l.length - 1; i >= 0; i--) { + const p = l[i]; + p.life -= dt; + if (p.life <= 0) { l.splice(i, 1); continue; } + const d = Math.max(0, 1 - p.drag * dt); + p.vx *= d; p.vy *= d; + p.x += p.vx * dt; + p.y += p.vy * dt; + } + } + + /** called with the world transform already applied */ + draw(ctx: CanvasRenderingContext2D): void { + if (this.list.length === 0) return; + ctx.save(); + ctx.globalCompositeOperation = 'lighter'; + for (const p of this.list) { + const t = p.life / p.maxLife; + const size = p.shrink ? p.size * (0.3 + 0.7 * t) : p.size; + ctx.globalAlpha = Math.min(1, t * 1.6) * 0.9; + ctx.fillStyle = p.color; + ctx.beginPath(); + ctx.arc(p.x, p.y, size, 0, Math.PI * 2); + ctx.fill(); + } + ctx.restore(); + } + + clear(): void { + this.list.length = 0; + } +} diff --git a/client/src/renderer.ts b/client/src/renderer.ts new file mode 100644 index 0000000..bce09af --- /dev/null +++ b/client/src/renderer.ts @@ -0,0 +1,237 @@ +// --------------------------------------------------------------------------- +// World renderer: red border ring, glowing pellet sprites, snakes with +// tapered striped bodies + eyes + name tags. All sprites generated at runtime. +// --------------------------------------------------------------------------- + +import { State, ClientSnake } from './state.js'; +import { Background } from './background.js'; +import { Particles } from './particles.js'; +import { SNAKE_COLORS, PELLET_COLORS, snakeRadius } from '../../shared/constants.js'; + +export interface RenderSettings { + quality: 'high' | 'low'; +} + +function shade(hex: string, amt: number): string { + // amt in [-1,1]: negative darkens, positive lightens + const n = parseInt(hex.slice(1), 16); + let r = (n >> 16) & 255, g = (n >> 8) & 255, b = n & 255; + if (amt < 0) { r *= 1 + amt; g *= 1 + amt; b *= 1 + amt; } + else { r += (255 - r) * amt; g += (255 - g) * amt; b += (255 - b) * amt; } + return `rgb(${r | 0},${g | 0},${b | 0})`; +} + +export class Renderer { + private ctx: CanvasRenderingContext2D; + private bg = new Background(); + private dpr = 1; + private w = 0; + private h = 0; + private pelletSprites: HTMLCanvasElement[] = []; + + constructor(private canvas: HTMLCanvasElement) { + this.ctx = canvas.getContext('2d')!; + for (const color of PELLET_COLORS) this.pelletSprites.push(this.makePelletSprite(color)); + this.resize(); + window.addEventListener('resize', () => this.resize()); + } + + resize(): void { + this.dpr = Math.min(window.devicePixelRatio || 1, 2); + this.w = window.innerWidth; + this.h = window.innerHeight; + this.canvas.width = Math.round(this.w * this.dpr); + this.canvas.height = Math.round(this.h * this.dpr); + this.canvas.style.width = `${this.w}px`; + this.canvas.style.height = `${this.h}px`; + } + + private makePelletSprite(color: string): HTMLCanvasElement { + const S = 64; + const c = document.createElement('canvas'); + c.width = c.height = S; + const t = c.getContext('2d')!; + const g = t.createRadialGradient(S / 2, S / 2, 0, S / 2, S / 2, S / 2); + g.addColorStop(0, 'rgba(255,255,255,0.95)'); + g.addColorStop(0.18, color); + g.addColorStop(0.42, color + 'aa'); + g.addColorStop(1, color + '00'); + t.fillStyle = g; + t.fillRect(0, 0, S, S); + return c; + } + + private beginWorld(cam: { x: number; y: number; zoom: number }): void { + const ctx = this.ctx; + ctx.save(); + ctx.translate(this.w / 2, this.h / 2); + ctx.scale(cam.zoom, cam.zoom); + ctx.translate(-cam.x, -cam.y); + } + + draw(state: State, now: number, particles: Particles, settings: RenderSettings): void { + const ctx = this.ctx; + const high = settings.quality === 'high'; + const cam = state.cam; + ctx.setTransform(this.dpr, 0, 0, this.dpr, 0, 0); + + this.bg.draw(ctx, this.w, this.h, cam, high); + + this.beginWorld(cam); + const vw = this.w / cam.zoom, vh = this.h / cam.zoom; + const vx = cam.x - vw / 2, vy = cam.y - vh / 2; + + this.drawArenaEdge(state, vx, vy, vw, vh, high); + this.drawPellets(state, now, vx, vy, vw, vh); + this.drawSnakes(state, now, vx, vy, vw, vh, high); + particles.draw(ctx); + + ctx.restore(); + } + + // ------------------------------------------------------------- border -- + + private drawArenaEdge(state: State, vx: number, vy: number, vw: number, vh: number, high: boolean): void { + const ctx = this.ctx; + const R = state.arena; + + // darken everything outside the arena + ctx.save(); + ctx.beginPath(); + ctx.rect(vx - 8, vy - 8, vw + 16, vh + 16); + ctx.arc(0, 0, R, 0, Math.PI * 2, true); + ctx.fillStyle = 'rgba(30, 6, 10, 0.62)'; + ctx.fill('evenodd'); + ctx.restore(); + + // the red kill-ring + ctx.save(); + if (high) { ctx.shadowColor = 'rgba(255,45,60,0.9)'; ctx.shadowBlur = 26; } + ctx.strokeStyle = 'rgba(255,45,60,0.95)'; + ctx.lineWidth = 7; + ctx.beginPath(); + ctx.arc(0, 0, R, 0, Math.PI * 2); + ctx.stroke(); + ctx.shadowBlur = 0; + ctx.strokeStyle = 'rgba(255,80,90,0.22)'; + ctx.lineWidth = 42; + ctx.beginPath(); + ctx.arc(0, 0, R + 14, 0, Math.PI * 2); + ctx.stroke(); + ctx.restore(); + } + + // ------------------------------------------------------------ pellets -- + + private drawPellets(state: State, now: number, vx: number, vy: number, vw: number, vh: number): void { + const ctx = this.ctx; + const pulse = now * 0.004; + for (const p of state.pellets.values()) { + if (p.x < vx - 30 || p.x > vx + vw + 30 || p.y < vy - 30 || p.y > vy + vh + 30) continue; + const r = 2.5 + p.v * 0.9; + const scale = (1 + 0.13 * Math.sin(pulse + p.id * 1.7)) * (r / 14); + const half = 32 * scale; + ctx.drawImage(this.pelletSprites[p.c % this.pelletSprites.length], p.x - half, p.y - half, half * 2, half * 2); + } + } + + // ------------------------------------------------------------- snakes -- + + private drawSnakes(state: State, now: number, vx: number, vy: number, vw: number, vh: number, high: boolean): void { + // draw smaller snakes first so bigger ones overlap them + const list = [...state.snakes.values()].sort((a, b) => a.score - b.score); + for (const s of list) { + const pts = s.pts; + const n = pts.length / 2; + if (n < 2) continue; + + // view culling via bounding box + let minX = Infinity, maxX = -Infinity, minY = Infinity, maxY = -Infinity; + for (let i = 0; i < n; i++) { + const x = pts[i * 2], y = pts[i * 2 + 1]; + if (x < minX) minX = x; if (x > maxX) maxX = x; + if (y < minY) minY = y; if (y > maxY) maxY = y; + } + const r = snakeRadius(s.score); + if (maxX < vx - r - 60 || minX > vx + vw + r + 60 || maxY < vy - r - 60 || minY > vy + vh + r + 60) continue; + + this.drawSnake(s, pts, n, r, state, now, high); + } + } + + private drawSnake(s: ClientSnake, pts: number[], n: number, r: number, state: State, now: number, high: boolean): void { + const ctx = this.ctx; + const base = SNAKE_COLORS[s.colorIdx % SNAKE_COLORS.length]; + const dark = shade(base, -0.38); + const ghost = s.sp; + + ctx.save(); + if (ghost) ctx.globalAlpha = 0.45 + 0.15 * Math.sin(now * 0.02); + ctx.lineCap = 'round'; + ctx.lineJoin = 'round'; + + // soft glow underlay + if (high) { + ctx.strokeStyle = base; + ctx.globalAlpha = (ghost ? 0.25 : 0.16); + ctx.lineWidth = r * 2 + 7; + ctx.beginPath(); + ctx.moveTo(pts[0], pts[1]); + for (let i = 1; i < n; i++) ctx.lineTo(pts[i * 2], pts[i * 2 + 1]); + ctx.stroke(); + ctx.globalAlpha = ghost ? 0.45 : 1; + } + + // striped tapered body, tail -> head + const stripe = 3; + for (let i = n - 1; i > 0; i--) { + const fromTail = n - 1 - i; + const taper = Math.min(1, 0.4 + fromTail / (Math.max(6, n * 0.28))); + ctx.lineWidth = Math.max(1, r * 2 * taper); + ctx.strokeStyle = ((i / stripe) | 0) % 2 === 0 ? base : dark; + ctx.beginPath(); + ctx.moveTo(pts[i * 2], pts[i * 2 + 1]); + ctx.lineTo(pts[(i - 1) * 2], pts[(i - 1) * 2 + 1]); + ctx.stroke(); + } + + // head + const hx = pts[0], hy = pts[1]; + const angle = Math.atan2(hy - pts[3], hx - pts[2]); + ctx.fillStyle = base; + ctx.beginPath(); + ctx.arc(hx, hy, r * 1.12, 0, Math.PI * 2); + ctx.fill(); + + // eyes (perpendicular pair, pupils look along the heading) + const ex = Math.cos(angle), ey = Math.sin(angle); + const pxv = -ey, pyv = ex; + for (const side of [-1, 1]) { + const exx = hx + pxv * side * r * 0.55 + ex * r * 0.28; + const eyy = hy + pyv * side * r * 0.55 + ey * r * 0.28; + ctx.fillStyle = '#ffffff'; + ctx.beginPath(); + ctx.arc(exx, eyy, r * 0.42, 0, Math.PI * 2); + ctx.fill(); + ctx.fillStyle = '#14161c'; + ctx.beginPath(); + ctx.arc(exx + ex * r * 0.18, eyy + ey * r * 0.18, r * 0.22, 0, Math.PI * 2); + ctx.fill(); + } + + // name tag + score + if (r * state.cam.zoom > 4.5) { + ctx.font = `600 ${Math.max(9, r * 0.95)}px 'Segoe UI', sans-serif`; + ctx.textAlign = 'center'; + ctx.textBaseline = 'bottom'; + ctx.fillStyle = 'rgba(255,255,255,0.75)'; + ctx.shadowColor = 'rgba(0,0,0,0.8)'; + ctx.shadowBlur = 4; + const label = s.id === state.myId ? `${s.name} (you)` : s.name; + ctx.fillText(label, hx, hy - r * 1.7); + ctx.shadowBlur = 0; + } + + ctx.restore(); + } +} diff --git a/client/src/state.ts b/client/src/state.ts new file mode 100644 index 0000000..58ab958 --- /dev/null +++ b/client/src/state.ts @@ -0,0 +1,188 @@ +// --------------------------------------------------------------------------- +// Client-side world state + local movement simulation. +// +// Rendering raw snapshots (even interpolated) looks jittery at 15 Hz, so the +// client runs the SAME movement model as the server every render frame +// (dead reckoning) and only softly reconciles toward the latest snapshot. +// Your own snake is steered with your local input angle = zero input latency. +// --------------------------------------------------------------------------- + +import { + BASE_SPEED, BOOST_MULT, + snakeSpacing, snakePointCount, normAngle, +} from '../../shared/constants.js'; +import type { PelletData, SnakeSnap, LeaderboardRow, YouState } from '../../shared/protocol.js'; + +/** If the local sim drifts further than this from the server, hard-reset. */ +const RESET_DIST = 140; +/** Per-second decay rate of snapshot correction offsets (smaller = smoother). */ +const CORRECT_DECAY = 8; + +export interface ClientSnake { + id: number; + name: string; + colorIdx: number; + score: number; + kills: number; + boosting: boolean; + sp: boolean; + pts: number[]; // flat float points [x0,y0,...], simulated every frame + heading: number; // current simulated heading + estHeading: number; // heading estimated from snapshots (remote snakes) + corr: number[]; // decaying per-point correction offsets from snapshots + snapHeadX: number; // previous snapshot head (for heading estimation) + snapHeadY: number; + targetLen: number; // target point count +} + +export class State { + myId = -1; + arena = 4200; + snakes = new Map(); + pellets = new Map(); + lb: LeaderboardRow[] = []; + you: YouState = { score: 0, rank: 0, alive: false, kills: 0, best: 0 }; + total = 0; + cam = { x: 0, y: 0, zoom: 1.35 }; + camInit = false; + lastDeathPos: { x: number; y: number } | null = null; +} + +/** Approximate direction of travel from the first few body points. */ +function bodyHeading(p: number[]): number { + const k = Math.max(1, Math.min(3, p.length / 2 - 1)); + return Math.atan2(p[1] - p[k * 2 + 1], p[0] - p[k * 2]); +} + +/** Merge a snapshot's snake list into the local simulated state. */ +export function applySnakeSnaps(state: State, snaps: SnakeSnap[]): void { + const seen = new Set(); + for (const snap of snaps) { + seen.add(snap.id); + const n = snap.p.length / 2; + const s = state.snakes.get(snap.id); + + if (!s) { + const h = bodyHeading(snap.p); + state.snakes.set(snap.id, { + id: snap.id, name: snap.n, colorIdx: snap.c, score: snap.s, kills: snap.k, + boosting: snap.b, sp: !!snap.sp, + pts: snap.p.slice(), heading: h, estHeading: h, + corr: new Array(snap.p.length).fill(0), + snapHeadX: snap.p[0], snapHeadY: snap.p[1], + targetLen: n, + }); + continue; + } + + // heading estimate from server head movement since the last snapshot + const dx = snap.p[0] - s.snapHeadX; + const dy = snap.p[1] - s.snapHeadY; + const moved = Math.hypot(dx, dy); + s.snapHeadX = snap.p[0]; + s.snapHeadY = snap.p[1]; + + const headErr = Math.hypot(s.pts[0] - snap.p[0], s.pts[1] - snap.p[1]); + if (headErr > RESET_DIST) { + // respawn / heavy desync: hard reset to the server state + s.pts = snap.p.slice(); + s.corr = new Array(snap.p.length).fill(0); + s.heading = bodyHeading(snap.p); + s.estHeading = s.heading; + if (snap.id === state.myId) state.camInit = false; // snap camera too + } else { + if (moved > 4 && snap.id !== state.myId) { + const est = Math.atan2(dy, dx); + s.estHeading += normAngle(est - s.estHeading) * 0.5; + } + // capture the current prediction error as decaying correction offsets; + // they are spread smoothly over the next few frames by simulateSnakes + const m = Math.min(s.pts.length, snap.p.length) / 2; + if (s.corr.length !== s.pts.length) s.corr = new Array(s.pts.length).fill(0); + for (let i = 0; i < m; i++) { + s.corr[i * 2] = snap.p[i * 2] - s.pts[i * 2]; + s.corr[i * 2 + 1] = snap.p[i * 2 + 1] - s.pts[i * 2 + 1]; + } + for (let i = m * 2; i < s.corr.length; i++) s.corr[i] = 0; + } + + s.targetLen = n; + s.name = snap.n; + s.colorIdx = snap.c; + s.score = snap.s; + s.kills = snap.k; + s.boosting = snap.b; + s.sp = !!snap.sp; + } + for (const id of [...state.snakes.keys()]) { + if (!seen.has(id)) state.snakes.delete(id); + } +} + +/** + * Advance every snake by one render frame using the same movement model as + * the server, then apply decaying snapshot-correction offsets. + */ +export function simulateSnakes(state: State, dt: number, myAngle: number, myBoost: boolean): void { + const ck = 1 - Math.exp(-CORRECT_DECAY * dt); + + for (const s of state.snakes.values()) { + const n = s.pts.length / 2; + if (n < 2) continue; + const isMe = s.id === state.myId; + const boost = isMe ? myBoost : s.boosting; + + // own snake faces the mouse instantly โ€” no turn-rate lag + if (isMe) { + s.heading = myAngle; + } else { + s.heading += normAngle(s.estHeading - s.heading) * (1 - Math.exp(-8 * dt)); + } + + // advance the head + const speed = BASE_SPEED * (boost ? BOOST_MULT : 1); + s.pts[0] += Math.cos(s.heading) * speed * dt; + s.pts[1] += Math.sin(s.heading) * speed * dt; + + // pull the body chain along at fixed spacing (same as server) + const sp = snakeSpacing(s.score); + for (let i = 1; i < n; i++) { + const ax = s.pts[(i - 1) * 2], ay = s.pts[(i - 1) * 2 + 1]; + const bx = s.pts[i * 2], by = s.pts[i * 2 + 1]; + const dx = bx - ax, dy = by - ay; + const d = Math.hypot(dx, dy); + if (d > sp) { + const f = (d - sp) / d; + s.pts[i * 2] = bx - dx * f; + s.pts[i * 2 + 1] = by - dy * f; + } + } + + // grow / shrink toward the server-reported length + if (n > s.targetLen) { + s.pts.length -= Math.min(n - s.targetLen, 3) * 2; + } else if (n < s.targetLen) { + s.pts.push(s.pts[(n - 1) * 2], s.pts[(n - 1) * 2 + 1]); + } + if (s.corr.length !== s.pts.length) { + const old = s.corr; + s.corr = new Array(s.pts.length).fill(0); + for (let i = 0; i < Math.min(old.length, s.corr.length); i++) s.corr[i] = old[i]; + } + + // apply a slice of the decaying correction offsets (smooth, no steps) + let anyCorr = false; + for (let i = 0; i < s.corr.length; i++) if (s.corr[i] !== 0) { anyCorr = true; break; } + if (anyCorr) { + for (let i = 0; i < s.pts.length; i++) { + const c = s.corr[i]; + if (c !== 0) { + const d = c * ck; + s.pts[i] += d; + s.corr[i] = c - d; + if (s.corr[i] > -0.001 && s.corr[i] < 0.001) s.corr[i] = 0; + } + } + } + } +} diff --git a/client/src/ui.ts b/client/src/ui.ts new file mode 100644 index 0000000..cd0b2c2 --- /dev/null +++ b/client/src/ui.ts @@ -0,0 +1,162 @@ +// --------------------------------------------------------------------------- +// DOM UI: main menu, settings, HUD (leaderboard + score), death overlay, +// fullscreen support, localStorage persistence. +// --------------------------------------------------------------------------- + +import type { LeaderboardRow, YouState } from '../../shared/protocol.js'; + +export interface Settings { + volume: number; // 0..1 + quality: 'high' | 'low'; + minimap: boolean; +} + +const SETTINGS_KEY = 'slither.settings'; +const NAME_KEY = 'slither.name'; +const BEST_KEY = 'slither.bestEver'; + +function el(id: string): T { + const e = document.getElementById(id); + if (!e) throw new Error(`missing element #${id}`); + return e as T; +} + +export class UI { + settings: Settings = { volume: 0.6, quality: 'high', minimap: true }; + bestEver = 0; + + onPlay: (name: string) => void = () => {}; + onRespawn: () => void = () => {}; + onSettingsChanged: () => void = () => {}; + + private menu = el('menu'); + private settingsEl = el('settings'); + private hud = el('hud'); + private death = el('death'); + private nick = el('nick'); + private lbRows = el('lb-rows'); + private scoreEl = el('score'); + private scoreSub = el('score-sub'); + private deathVisible = false; + + constructor() { + // persisted settings / nickname / best score + try { + const raw = localStorage.getItem(SETTINGS_KEY); + if (raw) Object.assign(this.settings, JSON.parse(raw)); + } catch { /* ignore */ } + this.nick.value = localStorage.getItem(NAME_KEY) || ''; + this.bestEver = Number(localStorage.getItem(BEST_KEY) || 0) || 0; + + el('play').addEventListener('click', () => { + const name = this.nick.value.trim() || 'Player'; + localStorage.setItem(NAME_KEY, name); + this.onPlay(name); + }); + this.nick.addEventListener('keydown', (e) => { + if (e.key === 'Enter') el('play').click(); + e.stopPropagation(); + }); + + // settings panel + const vol = el('opt-volume'); + const qual = el('opt-quality'); + const mm = el('opt-minimap'); + const fs = el('opt-fullscreen'); + vol.value = String(Math.round(this.settings.volume * 100)); + qual.value = this.settings.quality; + mm.checked = this.settings.minimap; + fs.checked = !!document.fullscreenElement; + + el('settings-btn').addEventListener('click', () => { + this.menu.classList.add('hidden'); + this.settingsEl.classList.remove('hidden'); + this.onSettingsChanged(); + }); + el('settings-back').addEventListener('click', () => { + this.settingsEl.classList.add('hidden'); + this.menu.classList.remove('hidden'); + this.onSettingsChanged(); + }); + vol.addEventListener('input', () => { this.settings.volume = Number(vol.value) / 100; this.save(); }); + qual.addEventListener('change', () => { this.settings.quality = qual.value === 'low' ? 'low' : 'high'; this.save(); }); + mm.addEventListener('change', () => { this.settings.minimap = mm.checked; this.save(); }); + fs.addEventListener('change', () => { UI.toggleFullscreen(); }); + + el('fullscreen-btn').addEventListener('click', () => UI.toggleFullscreen()); + document.addEventListener('fullscreenchange', () => { fs.checked = !!document.fullscreenElement; }); + + el('respawn').addEventListener('click', () => this.onRespawn()); + } + + private save(): void { + localStorage.setItem(SETTINGS_KEY, JSON.stringify(this.settings)); + this.onSettingsChanged(); + } + + static toggleFullscreen(): void { + if (document.fullscreenElement) { + void document.exitFullscreen(); + } else { + void document.documentElement.requestFullscreen().catch(() => {}); + } + } + + showError(msg: string): void { + const e = el('menu-error'); + e.textContent = msg; + e.classList.remove('hidden'); + // if we were in game, fall back to the menu + if (!this.hud.classList.contains('hidden')) this.showMenu(); + } + + showMenu(): void { + this.menu.classList.remove('hidden'); + this.hud.classList.add('hidden'); + this.death.classList.add('hidden'); + this.deathVisible = false; + } + + showGame(): void { + this.menu.classList.add('hidden'); + this.settingsEl.classList.add('hidden'); + this.hud.classList.remove('hidden'); + this.death.classList.add('hidden'); + this.deathVisible = false; + } + + showDeath(you: YouState): void { + if (this.deathVisible) return; + this.deathVisible = true; + if (you.best > this.bestEver) { + this.bestEver = you.best; + localStorage.setItem(BEST_KEY, String(you.best)); + } + el('d-score').textContent = String(you.score); + el('d-best').textContent = String(Math.max(you.best, this.bestEver)); + el('d-kills').textContent = String(you.kills); + this.death.classList.remove('hidden'); + el('respawn').focus(); + } + + hideDeath(): void { + this.death.classList.add('hidden'); + this.deathVisible = false; + } + + updateHud(lb: LeaderboardRow[], myId: number, you: YouState, total: number): void { + this.scoreEl.textContent = String(you.score); + this.scoreSub.textContent = + `rank #${you.alive ? you.rank : 'โ€“'} of ${total} ยท best ${Math.max(you.best, this.bestEver)} ยท kills ${you.kills}`; + + let html = ''; + for (let i = 0; i < lb.length; i++) { + const row = lb[i]; + const me = row.id === myId ? ' me' : ''; + const name = row.n.replace(/&/g, '&').replace(/${i + 1}` + + `${name}${row.s}`; + } + this.lbRows.innerHTML = html; + } +} diff --git a/package-lock.json b/package-lock.json new file mode 100644 index 0000000..4e35cb1 --- /dev/null +++ b/package-lock.json @@ -0,0 +1,82 @@ +{ + "name": "slither-clone", + "version": "1.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "slither-clone", + "version": "1.0.0", + "dependencies": { + "ws": "^8.18.0" + }, + "devDependencies": { + "@types/node": "^20.14.0", + "@types/ws": "^8.5.10", + "typescript": "^5.5.4" + } + }, + "node_modules/@types/node": { + "version": "20.19.43", + "resolved": "https://registry.npmjs.org/@types/node/-/node-20.19.43.tgz", + "integrity": "sha512-6oYBAi5ikg4Pl+kGsoYtawUMBT2zZMCvPNF7pVLnHZfd1zf38DRiWn/gT01RYCdUqkv7Fhr+C9ot4/tb+2sVvA==", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": "~6.21.0" + } + }, + "node_modules/@types/ws": { + "version": "8.18.1", + "resolved": "https://registry.npmjs.org/@types/ws/-/ws-8.18.1.tgz", + "integrity": "sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, + "node_modules/typescript": { + "version": "5.9.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/undici-types": { + "version": "6.21.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", + "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/ws": { + "version": "8.21.1", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.21.1.tgz", + "integrity": "sha512-+0NTnW77fFN/DjQi6k/Sq/Yvk4Sgajw7urW8V+asjXnRgDs9gyGkdb7EzgfhA4goXsRIZKE28fzIXBHEzhuiWw==", + "license": "MIT", + "engines": { + "node": ">=10.0.0" + }, + "peerDependencies": { + "bufferutil": "^4.0.1", + "utf-8-validate": ">=5.0.2" + }, + "peerDependenciesMeta": { + "bufferutil": { + "optional": true + }, + "utf-8-validate": { + "optional": true + } + } + } + } +} diff --git a/package.json b/package.json new file mode 100644 index 0000000..1efbe7a --- /dev/null +++ b/package.json @@ -0,0 +1,19 @@ +{ + "name": "slither-clone", + "version": "1.0.0", + "description": "A slither.io clone in TypeScript: multiplayer over WebSocket, AI bots, minimap, particles, synthesized sound.", + "type": "module", + "scripts": { + "build": "tsc -p tsconfig.server.json && tsc -p tsconfig.client.json", + "start": "node dist/server/server.js", + "dev": "npm run build && npm start" + }, + "dependencies": { + "ws": "^8.18.0" + }, + "devDependencies": { + "@types/node": "^20.14.0", + "@types/ws": "^8.5.10", + "typescript": "^5.5.4" + } +} diff --git a/public/index.html b/public/index.html new file mode 100644 index 0000000..81072a5 --- /dev/null +++ b/public/index.html @@ -0,0 +1,84 @@ + + + + + +slither.ts + + + + + + + + + + + + + + + + + + + + + diff --git a/public/style.css b/public/style.css new file mode 100644 index 0000000..9e43fa5 --- /dev/null +++ b/public/style.css @@ -0,0 +1,155 @@ +* { margin: 0; padding: 0; box-sizing: border-box; } + +html, body { + width: 100%; height: 100%; + overflow: hidden; + background: #101216; + font-family: 'Segoe UI', 'Trebuchet MS', Verdana, sans-serif; + color: #e8ecf1; + user-select: none; + -webkit-user-select: none; +} + +#game { position: fixed; inset: 0; display: block; cursor: crosshair; } + +.hidden { display: none !important; } + +/* ---------------- overlay screens ---------------- */ +#menu, #settings, #death { + position: fixed; inset: 0; + display: flex; flex-direction: column; + align-items: center; justify-content: center; + background: radial-gradient(ellipse at center, rgba(16,18,22,0.55) 0%, rgba(10,11,14,0.88) 100%); + z-index: 20; +} + +.menu-box { + display: flex; flex-direction: column; align-items: center; gap: 14px; + padding: 44px 56px; + background: rgba(20, 23, 29, 0.82); + border: 1px solid rgba(255,255,255,0.08); + border-radius: 18px; + box-shadow: 0 24px 80px rgba(0,0,0,0.6), inset 0 1px 0 rgba(255,255,255,0.06); + backdrop-filter: blur(6px); +} + +.title { + font-size: 64px; font-weight: 800; letter-spacing: 1px; + background: linear-gradient(90deg, #7dff5b, #4dffd2, #4dc9ff, #c95bff); + -webkit-background-clip: text; background-clip: text; + -webkit-text-fill-color: transparent; color: transparent; + filter: drop-shadow(0 0 18px rgba(77, 255, 210, 0.25)); +} +.title-dot { -webkit-text-fill-color: #ff5b5b; color: #ff5b5b; } +.title-small { font-size: 34px; font-weight: 700; color: #e8ecf1; } +.subtitle { margin-top: -10px; color: #8b93a1; font-size: 14px; letter-spacing: 2px; text-transform: uppercase; } + +#nick { + width: 260px; padding: 12px 16px; + font-size: 17px; text-align: center; color: #e8ecf1; + background: rgba(255,255,255,0.06); + border: 1px solid rgba(255,255,255,0.14); + border-radius: 10px; outline: none; + transition: border-color 0.15s, box-shadow 0.15s; +} +#nick:focus { border-color: #4dffd2; box-shadow: 0 0 14px rgba(77,255,210,0.25); } + +.btn { + width: 260px; padding: 13px 0; + font-size: 19px; font-weight: 700; letter-spacing: 0.5px; + border: none; border-radius: 10px; cursor: pointer; + transition: transform 0.08s, box-shadow 0.15s, filter 0.15s; +} +.btn:active { transform: scale(0.97); } +.btn-play { + color: #06281c; + background: linear-gradient(180deg, #6dff9e, #2fd977); + box-shadow: 0 4px 24px rgba(61, 230, 137, 0.35), inset 0 1px 0 rgba(255,255,255,0.5); +} +.btn-play:hover { filter: brightness(1.08); box-shadow: 0 4px 32px rgba(61, 230, 137, 0.55); } +.btn-ghost { + color: #c6cdd8; + background: rgba(255,255,255,0.07); + border: 1px solid rgba(255,255,255,0.12); +} +.btn-ghost:hover { background: rgba(255,255,255,0.12); } + +#fullscreen-btn { + position: fixed; top: 18px; right: 18px; + width: 46px; height: 46px; font-size: 24px; line-height: 1; + color: #c6cdd8; background: rgba(255,255,255,0.07); + border: 1px solid rgba(255,255,255,0.12); border-radius: 10px; + cursor: pointer; z-index: 30; +} +#fullscreen-btn:hover { background: rgba(255,255,255,0.14); } + +.footer { position: fixed; bottom: 22px; color: #6b7280; font-size: 13px; letter-spacing: 1px; } +.error { color: #ff7070; font-size: 14px; } + +/* settings rows */ +.row { + width: 300px; display: flex; align-items: center; justify-content: space-between; + font-size: 15px; color: #c6cdd8; gap: 16px; +} +.row input[type=range] { width: 150px; accent-color: #2fd977; } +.row input[type=checkbox] { width: 20px; height: 20px; accent-color: #2fd977; } +.row select { + padding: 6px 10px; border-radius: 8px; border: 1px solid rgba(255,255,255,0.14); + background: #1a1e25; color: #e8ecf1; outline: none; +} + +/* ---------------- HUD ---------------- */ +#hud { position: fixed; inset: 0; pointer-events: none; z-index: 10; } + +.panel { + background: rgba(12, 14, 18, 0.55); + border: 1px solid rgba(255,255,255,0.07); + border-radius: 12px; + backdrop-filter: blur(3px); +} + +#leaderboard { + position: fixed; top: 16px; right: 16px; + width: 210px; padding: 10px 14px 12px; + font-size: 13px; +} +.lb-title { + font-weight: 700; font-size: 14px; letter-spacing: 1px; text-transform: uppercase; + color: #9aa3b2; margin-bottom: 6px; text-align: center; +} +.lb-row { display: flex; gap: 8px; padding: 2px 4px; border-radius: 6px; color: #cfd6e0; white-space: nowrap; overflow: hidden; } +.lb-row .lb-rank { width: 20px; color: #7c8494; text-align: right; } +.lb-row .lb-name { flex: 1; overflow: hidden; text-overflow: ellipsis; } +.lb-row .lb-score { color: #9aa3b2; } +.lb-row.me { background: rgba(77, 255, 210, 0.14); color: #7dffd8; font-weight: 700; } +.lb-row.me .lb-score, .lb-row.me .lb-rank { color: #7dffd8; } + +#scorebar { + position: fixed; left: 18px; bottom: 16px; + display: flex; flex-direction: column; + text-shadow: 0 2px 8px rgba(0,0,0,0.8); +} +#score { font-size: 34px; font-weight: 800; color: #fff; line-height: 1; } +#score-sub { font-size: 13px; color: #9aa3b2; margin-top: 4px; } + +#hint { + position: fixed; bottom: 16px; left: 50%; transform: translateX(-50%); + font-size: 12px; color: #6b7280; letter-spacing: 1px; + text-shadow: 0 2px 6px rgba(0,0,0,0.8); +} + +#minimap { + position: fixed; right: 16px; bottom: 16px; + border-radius: 50%; + border: 2px solid rgba(255, 60, 70, 0.5); + box-shadow: 0 0 24px rgba(0,0,0,0.6), 0 0 12px rgba(255,60,70,0.25); + background: rgba(10, 12, 16, 0.6); +} + +/* death screen */ +.death-box { min-width: 340px; } +.death-stats { display: flex; gap: 34px; margin: 6px 0 10px; } +.death-stats > div { display: flex; flex-direction: column; align-items: center; } +.stat-label { font-size: 12px; color: #8b93a1; text-transform: uppercase; letter-spacing: 1px; } +.stat-value { font-size: 30px; font-weight: 800; color: #fff; } +.hint-small { font-size: 12px; color: #6b7280; } diff --git a/scripts/sim-test.mjs b/scripts/sim-test.mjs new file mode 100644 index 0000000..fcc6f3a --- /dev/null +++ b/scripts/sim-test.mjs @@ -0,0 +1,54 @@ +// Validates the client-side dead-reckoning: applies real server snapshots, +// simulates at 60 fps like the browser, and measures prediction error +// between the locally simulated head and the server's authoritative head. +import WebSocket from 'ws'; +import { applySnakeSnaps, simulateSnakes, State } from '../public/js/client/src/state.js'; + +const state = new State(); +const ws = new WebSocket('ws://localhost:8080/ws'); +let myAngle = 0.7; +let myBoost = false; +let samples = 0, sumErr = 0, maxErr = 0, resets = 0; + +ws.on('open', () => ws.send(JSON.stringify({ t: 'join', name: 'SimTest' }))); +ws.on('message', (d) => { + const m = JSON.parse(d); + if (m.t === 'init') { state.myId = m.id; state.arena = m.arena; return; } + if (m.t !== 'snap') return; + const before = state.snakes.get(state.myId); + const prevHead = before ? [before.pts[0], before.pts[1]] : null; + applySnakeSnaps(state, m.snakes); + if (m.you) state.you = m.you; + for (const ev of m.ev) if (ev.t === 'die' && ev.id === state.myId) ws.send(JSON.stringify({ t: 'respawn' })); + const me = state.snakes.get(state.myId); + const snap = m.snakes.find((s) => s.id === state.myId); + if (me && snap) { + if (prevHead && Math.hypot(prevHead[0] - snap.p[0], prevHead[1] - snap.p[1]) > 140) resets++; + const err = Math.hypot(me.pts[0] - snap.p[0], me.pts[1] - snap.p[1]); + sumErr += err; samples++; + if (err > maxErr) maxErr = err; + } +}); + +// mimic the browser: input at ~33 Hz, simulation at 60 fps +setInterval(() => { + myAngle += (Math.random() - 0.5) * 0.3; // wander like a mouse would + myBoost = Math.random() < 0.1; // sometimes boost + const me = state.snakes.get(state.myId); + if (me && Math.hypot(me.pts[0], me.pts[1]) > 3000) { + myAngle = Math.atan2(-me.pts[1], -me.pts[0]); // steer back to safety + myBoost = false; + } + ws.send(JSON.stringify({ t: 'in', a: myAngle, b: myBoost })); +}, 30); +setInterval(() => simulateSnakes(state, 1 / 60, myAngle, myBoost && state.you.score > 20), 1000 / 60); + +setTimeout(() => { + console.log(`samples=${samples} avgErr=${(sumErr / Math.max(1, samples)).toFixed(2)}px maxErr=${maxErr.toFixed(2)}px hardResets=${resets}`); + if (samples > 100 && sumErr / samples < 25 && resets === 0) { + console.log('SIM TEST PASSED (prediction tracks server closely)'); + process.exit(0); + } + console.log('SIM TEST FAILED'); + process.exit(1); +}, 20000); diff --git a/scripts/smoke.mjs b/scripts/smoke.mjs new file mode 100644 index 0000000..39b89f1 --- /dev/null +++ b/scripts/smoke.mjs @@ -0,0 +1,67 @@ +// Smoke test: join the game, verify snapshots flow, movement works, +// boosting drains score, and steering toward the border kills us. +import WebSocket from 'ws'; + +const ws = new WebSocket('ws://localhost:8080/ws'); +let init = null; +let snaps = 0; +let firstHead = null; +let lastHead = null; +let maxScore = 0; +let died = false; +let respawned = false; +let phase = 0; + +ws.on('open', () => ws.send(JSON.stringify({ t: 'join', name: 'SmokeTest' }))); + +ws.on('message', (d) => { + const m = JSON.parse(d); + if (m.t === 'init') { + init = m; + console.log(`[init] id=${m.id} arena=${m.arena} pellets=${m.pellets.length}`); + ws.send(JSON.stringify({ t: 'in', a: 0, b: false })); + return; + } + if (m.t !== 'snap') return; + snaps++; + + const me = m.snakes.find((s) => s.id === init.id); + if (me) { + lastHead = [me.p[0], me.p[1]]; + if (!firstHead) firstHead = [...lastHead]; + maxScore = Math.max(maxScore, m.you.score); + } + + if (snaps === 30) { + const moved = Math.hypot(lastHead[0] - firstHead[0], lastHead[1] - firstHead[1]); + console.log(`[move] head moved ${moved.toFixed(1)}px over ~2s (${m.snakes.length} snakes, lb=${m.lb.length}, total=${m.total})`); + if (moved < 50) fail('snake did not move'); + // start boosting toward the border to test boost drain + border death + phase = 1; + ws.send(JSON.stringify({ t: 'in', a: Math.atan2(lastHead[1], lastHead[0]), b: true })); + } + + for (const ev of m.ev) { + if (ev.t === 'die' && ev.id === init.id && !died) { + died = true; + console.log(`[death] died as expected (score=${ev.s}, pellets sample pts=${ev.p.length / 2})`); + ws.send(JSON.stringify({ t: 'respawn' })); + } + } + if (died && me && m.you.alive && !respawned) { + respawned = true; + console.log(`[respawn] instant respawn OK, alive with score=${m.you.score}`); + console.log('SMOKE TEST PASSED'); + process.exit(0); + } + if (snaps === 150 && phase === 1) { + console.log(`[boost] max score observed while boosting: ${maxScore}`); + } +}); + +function fail(msg) { + console.error('SMOKE TEST FAILED: ' + msg); + process.exit(1); +} + +setTimeout(() => fail(`timeout (snaps=${snaps}, died=${died}, respawned=${respawned})`), 90000); diff --git a/server/server.ts b/server/server.ts new file mode 100644 index 0000000..c3967ca --- /dev/null +++ b/server/server.ts @@ -0,0 +1,103 @@ +// --------------------------------------------------------------------------- +// HTTP static file server + WebSocket game server. +// Run with: npm start (after npm run build) +// --------------------------------------------------------------------------- + +import { createServer } from 'node:http'; +import { readFile } from 'node:fs/promises'; +import { extname, join, normalize, resolve } from 'node:path'; +import { WebSocketServer, WebSocket } from 'ws'; +import { World, Snake } from './world.js'; +import { TICK_RATE, SNAP_EVERY, ARENA_RADIUS } from '../shared/constants.js'; +import type { ClientMsg, InitMsg } from '../shared/protocol.js'; + +const PORT = process.env.PORT ? Number(process.env.PORT) : 8080; +const PUBLIC_ROOT = resolve(process.cwd(), 'public'); + +const MIME: Record = { + '.html': 'text/html; charset=utf-8', + '.js': 'text/javascript; charset=utf-8', + '.css': 'text/css; charset=utf-8', + '.json': 'application/json', + '.png': 'image/png', + '.svg': 'image/svg+xml', + '.ico': 'image/x-icon', + '.map': 'application/json', +}; + +const httpServer = createServer(async (req, res) => { + try { + let url = (req.url || '/').split('?')[0]; + if (url === '/') url = '/index.html'; + const filePath = normalize(join(PUBLIC_ROOT, url)); + if (!filePath.startsWith(PUBLIC_ROOT)) { res.writeHead(403); res.end(); return; } + const data = await readFile(filePath); + res.writeHead(200, { + 'Content-Type': MIME[extname(filePath).toLowerCase()] || 'application/octet-stream', + 'Cache-Control': 'no-cache', + }); + res.end(data); + } catch { + res.writeHead(404); + res.end('not found'); + } +}); + +// ------------------------------------------------------------ game setup -- + +const world = new World(); +const wss = new WebSocketServer({ server: httpServer, path: '/ws' }); + +interface Client { ws: WebSocket; snake: Snake | null } +const clients = new Set(); + +wss.on('connection', (ws) => { + const client: Client = { ws, snake: null }; + clients.add(client); + + ws.on('message', (raw) => { + let m: ClientMsg; + try { m = JSON.parse(raw.toString()) as ClientMsg; } catch { return; } + + if (m.t === 'join' && !client.snake) { + const name = String(m.name || 'Player').replace(/[<>&"']/g, '').trim().slice(0, 16) || 'Player'; + client.snake = world.addPlayer(name); + const init: InitMsg = { t: 'init', id: client.snake.id, arena: ARENA_RADIUS, pellets: world.pelletList() }; + ws.send(JSON.stringify(init)); + } else if (m.t === 'in' && client.snake && client.snake.alive) { + if (typeof m.a === 'number' && Number.isFinite(m.a)) client.snake.targetAngle = m.a; + client.snake.boosting = !!m.b; + } else if (m.t === 'respawn' && client.snake && !client.snake.alive) { + world.respawn(client.snake); // instant respawn, no countdown + } + }); + + ws.on('close', () => { + clients.delete(client); + if (client.snake) world.removePlayer(client.snake.id); + }); + ws.on('error', () => { /* handled by close */ }); +}); + +// -------------------------------------------------------------- game loop -- + +let last = process.hrtime.bigint(); +setInterval(() => { + const now = process.hrtime.bigint(); + const dt = Math.min(0.1, Number(now - last) / 1e9); + last = now; + + world.update(dt); + + if (world.tick % SNAP_EVERY === 0) { + for (const c of clients) { + if (!c.snake || c.ws.readyState !== WebSocket.OPEN) continue; + c.ws.send(world.snapshotFor(c.snake)); + } + world.postSnapshot(); // deltas were just broadcast; start a fresh batch + } +}, 1000 / TICK_RATE); + +httpServer.listen(PORT, () => { + console.log(`slither-clone server running at http://localhost:${PORT}`); +}); diff --git a/server/world.ts b/server/world.ts new file mode 100644 index 0000000..ba3a283 --- /dev/null +++ b/server/world.ts @@ -0,0 +1,447 @@ +// --------------------------------------------------------------------------- +// 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); + } +} diff --git a/shared/constants.ts b/shared/constants.ts new file mode 100644 index 0000000..b82b625 --- /dev/null +++ b/shared/constants.ts @@ -0,0 +1,71 @@ +// --------------------------------------------------------------------------- +// Shared game constants & helpers (used by both the server sim and the client) +// --------------------------------------------------------------------------- + +export const TICK_RATE = 30; // server simulation ticks per second +export const SNAP_EVERY = 2; // send a snapshot every N ticks (15 Hz) + +export const ARENA_RADIUS = 4200; // playable circle radius in world px + +export const BASE_SPEED = 178; // px per second +export const BOOST_MULT = 1.95; // speed multiplier while boosting +export const TURN_RATE = 4.4; // radians per second +export const BOOST_TURN_RATE = 3.8; // slightly stiffer steering while boosting + +export const START_SCORE = 25; +export const MIN_BOOST_SCORE = 18; // cannot boost below this score +export const BOOST_DRAIN = 16; // score per second burned while boosting +export const BOOST_DROP_INTERVAL = 0.26; // seconds between tail pellets while boosting + +export const PELLET_COUNT = 950; // ambient pellets kept alive in the arena +export const BOT_TARGET_TOTAL = 12; // arena is kept filled to this many snakes +export const RESPAWN_PROTECT_TICKS = TICK_RATE * 2; // ~2s of spawn protection + +// --- derived snake geometry ------------------------------------------------- + +export function snakeRadius(score: number): number { + return 5 + Math.sqrt(Math.max(0, score)) * 0.32; +} + +export function snakePointCount(score: number): number { + return Math.min(420, Math.round(14 + Math.sqrt(Math.max(0, score)) * 2.1)); +} + +export function snakeSpacing(score: number): number { + return Math.max(5.5, snakeRadius(score) * 0.5); +} + +// --- math helpers ------------------------------------------------------------ + +export function clamp(v: number, lo: number, hi: number): number { + return v < lo ? lo : v > hi ? hi : v; +} + +/** Normalize an angle to (-PI, PI]. */ +export function normAngle(a: number): number { + while (a > Math.PI) a -= Math.PI * 2; + while (a <= -Math.PI) a += Math.PI * 2; + return a; +} + +export function dist(x1: number, y1: number, x2: number, y2: number): number { + return Math.hypot(x2 - x1, y2 - y1); +} + +// --- palettes --------------------------------------------------------------- + +export const SNAKE_COLORS = [ + '#ff5b5b', '#ffb84d', '#ffe14d', '#7dff5b', '#4dffd2', '#4dc9ff', + '#8a7dff', '#c95bff', '#ff5bd0', '#9dff4d', '#5bff9d', '#ffd24d', +]; + +export const PELLET_COLORS = [ + '#ff6b6b', '#ffd93d', '#6bff8f', '#4dd2ff', + '#c586ff', '#ff8fe1', '#8ffcff', '#ffe28f', +]; + +export const BOT_NAMES = [ + 'Noodle', 'Viper', 'Slinky', 'Zigzag', 'Cobra', 'Python', 'Nagini', + 'Basilisk', 'Wiggles', 'Turbo', 'Boa', 'Rattles', 'Medusa', + 'Sidewinder', 'Anaconda', 'DangerNoodle', 'Hiss', 'Monty', +]; diff --git a/shared/protocol.ts b/shared/protocol.ts new file mode 100644 index 0000000..c42b227 --- /dev/null +++ b/shared/protocol.ts @@ -0,0 +1,72 @@ +// --------------------------------------------------------------------------- +// Network protocol shared between server and client (JSON over WebSocket) +// --------------------------------------------------------------------------- + +export interface PelletData { + id: number; + x: number; + y: number; + v: number; // food value (also drives render size) + c: number; // color index into PELLET_COLORS +} + +export interface SnakeSnap { + id: number; + n: string; // nickname + c: number; // color index into SNAKE_COLORS + s: number; // score + p: number[]; // flat body points [x0,y0,x1,y1,...], head first + b: boolean; // boosting + k: number; // kills + sp?: boolean; // spawn protection active (ghost) +} + +export type GameEvent = + | { t: 'die'; id: number; x: number; y: number; c: number; s: number; p: number[] } + | { t: 'join'; id: number; n: string } + | { t: 'leave'; id: number }; + +export interface LeaderboardRow { + id: number; + n: string; + s: number; +} + +export interface YouState { + score: number; + rank: number; + alive: boolean; + kills: number; + best: number; +} + +// --- server -> client ------------------------------------------------------- + +export interface InitMsg { + t: 'init'; + id: number; // your snake id + arena: number; // arena radius + pellets: PelletData[]; // full pellet list (deltas arrive afterwards) +} + +export interface SnapMsg { + t: 'snap'; + tick: number; + snakes: SnakeSnap[]; + add: PelletData[]; // pellets added since last snapshot + rem: number[]; // pellet ids removed since last snapshot + ev: GameEvent[]; // events since last snapshot + lb: LeaderboardRow[]; + you?: YouState; + total: number; // total snakes alive +} + +export type ServerMsg = InitMsg | SnapMsg; + +// --- client -> server ------------------------------------------------------- + +export interface JoinMsg { t: 'join'; name: string } +export interface InputMsg { t: 'in'; a: number; b: boolean } // angle, boosting +export interface RespawnMsg { t: 'respawn' } + +export type ClientMsg = JoinMsg | InputMsg | RespawnMsg; diff --git a/tsconfig.client.json b/tsconfig.client.json new file mode 100644 index 0000000..b97471a --- /dev/null +++ b/tsconfig.client.json @@ -0,0 +1,13 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "ESNext", + "moduleResolution": "Bundler", + "strict": true, + "skipLibCheck": true, + "forceConsistentCasingInFileNames": true, + "outDir": "public/js", + "lib": ["ES2022", "DOM", "DOM.Iterable"] + }, + "include": ["client/**/*.ts", "shared/**/*.ts"] +} diff --git a/tsconfig.server.json b/tsconfig.server.json new file mode 100644 index 0000000..260c50d --- /dev/null +++ b/tsconfig.server.json @@ -0,0 +1,14 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "NodeNext", + "moduleResolution": "NodeNext", + "strict": true, + "skipLibCheck": true, + "esModuleInterop": true, + "forceConsistentCasingInFileNames": true, + "outDir": "dist", + "types": ["node"] + }, + "include": ["server/**/*.ts", "shared/**/*.ts"] +}