Spaces:
Running
Running
| // 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<id, player>, 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}`); | |
| }); | |