CompactAI's picture
Upload 126 files
b1376cc verified
Raw
History Blame Contribute Delete
7.03 kB
// 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}`);
});