Inital commit

This commit is contained in:
2026-07-22 00:34:09 -05:00
commit 0b24de2767
24 changed files with 2691 additions and 0 deletions
+4
View File
@@ -0,0 +1,4 @@
node_modules/
dist/
public/js/
*.log
+76
View File
@@ -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.
+156
View File
@@ -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);
}
}
}
+169
View File
@@ -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);
}
}
+71
View File
@@ -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;
}
}
+233
View File
@@ -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 });
+69
View File
@@ -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;
}
}
+45
View File
@@ -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);
}
}
}
+100
View File
@@ -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;
}
}
+237
View File
@@ -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();
}
}
+188
View File
@@ -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<number, ClientSnake>();
pellets = new Map<number, PelletData>();
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<number>();
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;
}
}
}
}
}
+162
View File
@@ -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<T extends HTMLElement>(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<HTMLDivElement>('menu');
private settingsEl = el<HTMLDivElement>('settings');
private hud = el<HTMLDivElement>('hud');
private death = el<HTMLDivElement>('death');
private nick = el<HTMLInputElement>('nick');
private lbRows = el<HTMLDivElement>('lb-rows');
private scoreEl = el<HTMLSpanElement>('score');
private scoreSub = el<HTMLSpanElement>('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<HTMLButtonElement>('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<HTMLButtonElement>('play').click();
e.stopPropagation();
});
// settings panel
const vol = el<HTMLInputElement>('opt-volume');
const qual = el<HTMLSelectElement>('opt-quality');
const mm = el<HTMLInputElement>('opt-minimap');
const fs = el<HTMLInputElement>('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<HTMLButtonElement>('settings-btn').addEventListener('click', () => {
this.menu.classList.add('hidden');
this.settingsEl.classList.remove('hidden');
this.onSettingsChanged();
});
el<HTMLButtonElement>('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<HTMLButtonElement>('fullscreen-btn').addEventListener('click', () => UI.toggleFullscreen());
document.addEventListener('fullscreenchange', () => { fs.checked = !!document.fullscreenElement; });
el<HTMLButtonElement>('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<HTMLDivElement>('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<HTMLSpanElement>('d-score').textContent = String(you.score);
el<HTMLSpanElement>('d-best').textContent = String(Math.max(you.best, this.bestEver));
el<HTMLSpanElement>('d-kills').textContent = String(you.kills);
this.death.classList.remove('hidden');
el<HTMLButtonElement>('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, '&amp;').replace(/</g, '&lt;');
html += `<div class="lb-row${me}"><span class="lb-rank">${i + 1}</span>` +
`<span class="lb-name">${name}</span><span class="lb-score">${row.s}</span></div>`;
}
this.lbRows.innerHTML = html;
}
}
+82
View File
@@ -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
}
}
}
}
}
+19
View File
@@ -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"
}
}
+84
View File
@@ -0,0 +1,84 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1, user-scalable=no">
<title>slither.ts</title>
<link rel="icon" href="data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 100 100'><text y='.9em' font-size='90'>🐍</text></svg>">
<link rel="stylesheet" href="./style.css">
</head>
<body>
<canvas id="game"></canvas>
<!-- ======================= HUD ======================= -->
<div id="hud" class="hidden">
<div id="leaderboard" class="panel">
<div class="lb-title">Leaderboard</div>
<div id="lb-rows"></div>
</div>
<div id="scorebar">
<span id="score">0</span>
<span id="score-sub">rank #– &nbsp;·&nbsp; best 0 &nbsp;·&nbsp; kills 0</span>
</div>
<div id="hint">move: mouse &nbsp;·&nbsp; boost: <b>SPACE</b> / click</div>
<canvas id="minimap" width="180" height="180"></canvas>
</div>
<!-- ======================= MAIN MENU ======================= -->
<div id="menu">
<div class="menu-box">
<h1 class="title">slither<span class="title-dot">.</span>ts</h1>
<div class="subtitle">a typescript slither.io clone</div>
<input id="nick" maxlength="16" placeholder="Nickname" autocomplete="off" spellcheck="false">
<button id="play" class="btn btn-play">Play</button>
<button id="settings-btn" class="btn btn-ghost">Settings</button>
<div id="menu-error" class="error hidden"></div>
</div>
<button id="fullscreen-btn" title="Fullscreen">⛶</button>
<div class="footer">eat pellets · grow huge · cut others off · don't touch the red border</div>
</div>
<!-- ======================= SETTINGS ======================= -->
<div id="settings" class="hidden">
<div class="menu-box">
<h2 class="title-small">Settings</h2>
<label class="row">
<span>Sound volume</span>
<input id="opt-volume" type="range" min="0" max="100" value="60">
</label>
<label class="row">
<span>Graphics quality</span>
<select id="opt-quality">
<option value="high">High</option>
<option value="low">Low</option>
</select>
</label>
<label class="row">
<span>Show minimap</span>
<input id="opt-minimap" type="checkbox" checked>
</label>
<label class="row">
<span>Fullscreen</span>
<input id="opt-fullscreen" type="checkbox">
</label>
<button id="settings-back" class="btn btn-play">Back</button>
</div>
</div>
<!-- ======================= DEATH SCREEN ======================= -->
<div id="death" class="hidden">
<div class="menu-box death-box">
<h2 class="title-small">You died!</h2>
<div class="death-stats">
<div><span class="stat-label">Score</span><span id="d-score" class="stat-value">0</span></div>
<div><span class="stat-label">Best</span><span id="d-best" class="stat-value">0</span></div>
<div><span class="stat-label">Kills</span><span id="d-kills" class="stat-value">0</span></div>
</div>
<button id="respawn" class="btn btn-play">Respawn</button>
<div class="hint-small">press <b>Enter</b> to respawn instantly</div>
</div>
</div>
<script type="module" src="./js/client/src/main.js"></script>
</body>
</html>
+155
View File
@@ -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; }
+54
View File
@@ -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);
+67
View File
@@ -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);
+103
View File
@@ -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<string, string> = {
'.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<Client>();
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}`);
});
+447
View File
@@ -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<T extends { x: number; y: number }> {
private map = new Map<number, T[]>();
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<number, Snake>();
pellets = new Map<number, Pellet>();
tick = 0;
private nextSnakeId = 1;
private nextPelletId = 1;
private events: GameEvent[] = [];
private added: PelletData[] = [];
private removed: number[] = [];
private grid = new Grid<GridItem>(72); // snake body points (rebuilt each tick)
private pelletGrid = new Grid<Pellet>(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);
}
}
+71
View File
@@ -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',
];
+72
View File
@@ -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;
+13
View File
@@ -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"]
}
+14
View File
@@ -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"]
}