Files
2026-07-22 00:34:09 -05:00

104 lines
3.6 KiB
TypeScript

// ---------------------------------------------------------------------------
// 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}`);
});