// NEONSTRIKE — multiplayer server. Static files + WebSocket rooms. // Zero config: `node server/index.js` and go. const http = require('http'); const fs = require('fs'); const path = require('path'); const crypto = require('crypto'); const { WebSocketServer } = require('ws'); const PUBLIC_DIR = path.join(__dirname, '..', 'public'); const PORT = Number(process.env.PORT) || 3002; const MAPS = ['vector', 'foundry', 'canopy']; const MAX_PLAYERS = 8; const KILL_TARGET = 15; // unambiguous alphabet: no 0/O/1/I const CODE_ALPHABET = 'ABCDEFGHJKLMNPQRSTUVWXYZ23456789'; const MIME = { '.html': 'text/html; charset=utf-8', '.js': 'text/javascript; charset=utf-8', '.css': 'text/css; charset=utf-8', '.json': 'application/json; charset=utf-8', '.png': 'image/png', }; // code -> room { code, map, players: Map, resetTimer } const rooms = new Map(); function uid(n) { return crypto.randomBytes(n).toString('base64url').slice(0, n); } function roomCode() { let code; do { code = ''; for (let i = 0; i < 4; i++) { code += CODE_ALPHABET[crypto.randomInt(CODE_ALPHABET.length)]; } } while (rooms.has(code)); return code; } function send(ws, msg) { if (ws.readyState === 1) ws.send(JSON.stringify(msg)); } function broadcast(room, msg, exceptId) { const raw = JSON.stringify(msg); for (const p of room.players.values()) { if (p.id !== exceptId && p.ws.readyState === 1) p.ws.send(raw); } } function scoreList(room) { return [...room.players.values()].map((p) => ({ id: p.id, name: p.name, kills: p.kills, deaths: p.deaths, })); } function createRoom(ws, msg) { const name = String(msg.name || 'player').slice(0, 24); const map = MAPS.includes(msg.map) ? msg.map : MAPS[0]; const room = { code: roomCode(), map, players: new Map(), resetTimer: null }; rooms.set(room.code, room); const player = makePlayer(ws, name); room.players.set(player.id, player); ws._room = room; ws._playerId = player.id; send(ws, { t: 'created', code: room.code, id: player.id }); } function makePlayer(ws, name) { return { id: uid(8), ws, name, hp: 100, kills: 0, deaths: 0, lastState: null }; } function joinRoom(ws, msg) { const code = String(msg.code || '').toUpperCase(); const room = rooms.get(code); if (!room) { send(ws, { t: 'error', msg: 'Room not found' }); return; } if (room.players.size >= MAX_PLAYERS) { send(ws, { t: 'error', msg: 'Room is full' }); return; } const name = String(msg.name || 'player').slice(0, 24); const player = makePlayer(ws, name); room.players.set(player.id, player); ws._room = room; ws._playerId = player.id; send(ws, { t: 'joined', id: player.id, code: room.code, map: room.map, players: scoreList(room).filter((p) => p.id !== player.id), }); // snapshot of where everyone currently is for (const p of room.players.values()) { if (p.id !== player.id && p.lastState) send(ws, { t: 'state', id: p.id, ...p.lastState }); } broadcast(room, { t: 'player_join', id: player.id, name: player.name }, player.id); } function leaveRoom(ws) { const room = ws._room; if (!room) return; const id = ws._playerId; ws._room = null; ws._playerId = null; if (!room.players.delete(id)) return; broadcast(room, { t: 'player_leave', id }); if (room.players.size === 0) { if (room.resetTimer) clearTimeout(room.resetTimer); rooms.delete(room.code); } } function handleHit(room, shooter, msg) { const victim = room.players.get(String(msg.target || '')); if (!victim || victim.id === shooter.id || victim.hp <= 0) return; const dmg = Math.max(0, Math.min(200, Number(msg.dmg) || 0)); const head = !!msg.head; victim.hp = Math.max(0, victim.hp - dmg); broadcast(room, { t: 'damage', id: victim.id, hp: victim.hp, from: shooter.id }); if (victim.hp <= 0) { shooter.kills++; victim.deaths++; broadcast(room, { t: 'death', id: victim.id, by: shooter.id, head }); broadcast(room, { t: 'scores', players: scoreList(room) }); if (shooter.kills >= KILL_TARGET && !room.resetTimer) { broadcast(room, { t: 'match_over', winner: { id: shooter.id, name: shooter.name } }); room.resetTimer = setTimeout(() => { room.resetTimer = null; for (const p of room.players.values()) { p.kills = 0; p.deaths = 0; } broadcast(room, { t: 'scores', players: scoreList(room) }); }, 5000); } } } function handleMessage(ws, msg) { if (msg.t === 'create') { leaveRoom(ws); createRoom(ws, msg); return; } if (msg.t === 'join') { leaveRoom(ws); joinRoom(ws, msg); return; } const room = ws._room; if (!room) return; const player = room.players.get(ws._playerId); if (!player || player.ws !== ws) return; switch (msg.t) { case 'state': { const { t, ...rest } = msg; player.lastState = rest; broadcast(room, { t: 'state', id: player.id, ...rest }, player.id); break; } case 'shoot': { broadcast(room, { t: 'shoot', id: player.id, weapon: msg.weapon }, player.id); break; } case 'hit': { handleHit(room, player, msg); break; } case 'respawn': { if (player.hp > 0) break; player.hp = 100; broadcast(room, { t: 'respawned', id: player.id, hp: 100 }); break; } case 'leave': { leaveRoom(ws); break; } } } // ---------------- http static server ---------------- const server = http.createServer((req, res) => { let urlPath; try { urlPath = decodeURIComponent(req.url.split('?')[0]); } catch (e) { res.writeHead(400); res.end('bad request'); return; } if (urlPath === '/') urlPath = '/index.html'; const filePath = path.join(PUBLIC_DIR, urlPath); if (!filePath.startsWith(PUBLIC_DIR)) { res.writeHead(403); res.end('forbidden'); return; } const mime = MIME[path.extname(filePath).toLowerCase()]; fs.readFile(filePath, (err, data) => { if (err || !mime) { res.writeHead(404); res.end('not found'); return; } res.writeHead(200, { 'Content-Type': mime }); res.end(data); }); }); // ---------------- websocket wiring ---------------- const wss = new WebSocketServer({ server, path: '/ws', perMessageDeflate: false }); wss.on('connection', (ws) => { ws.isAlive = true; ws.on('pong', () => { ws.isAlive = true; }); ws.on('message', (raw) => { try { const msg = JSON.parse(raw); if (!msg || typeof msg !== 'object' || typeof msg.t !== 'string') return; handleMessage(ws, msg); } catch (e) { // never crash on malformed input } }); ws.on('close', () => leaveRoom(ws)); ws.on('error', () => leaveRoom(ws)); }); // heartbeat: drop dead sockets so rooms don't fill with ghosts setInterval(() => { for (const ws of wss.clients) { if (!ws.isAlive) { try { ws.terminate(); } catch (e) {} continue; } ws.isAlive = false; try { ws.ping(); } catch (e) {} } }, 20000); server.listen(PORT, () => { console.log(`NEONSTRIKE server running at http://localhost:${PORT}`); });