// WebSocket hub: live game lobbies (real multiplayer) + studio co-editing. const { WebSocketServer } = require('ws'); const { get, save, uid, publicUser } = require('./db'); const { userFromReq, sanitizePart, applyConfig, MAX_PARTS } = require('./api'); // instanceId -> lobby const lobbies = new Map(); // gameId -> studio room const studios = new Map(); // userId -> { gameId, instanceId } | { studio: gameId } const presence = {}; const EDITOR_COLORS = ['#e54b4b', '#2a7de1', '#3aa14f', '#f0a020', '#8a4fff', '#19c2ff', '#ff5e9c', '#9ccc2e']; 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 m of room.members.values()) { if (m.user.id !== exceptId && m.ws.readyState === 1) m.ws.send(raw); } } // ---------------- play lobbies ---------------- function findOrCreateLobby(game) { for (const lobby of lobbies.values()) { if (lobby.gameId === game.id && lobby.members.size < game.config.maxPlayers) return lobby; } const lobby = { id: game.id + ':' + uid(6), gameId: game.id, members: new Map(), // userId -> {ws, user, pos, stage, score, lastTouch} mode: game.config.mode, modeState: { itId: null, lastTag: 0, coinsTaken: {} }, // coinsTaken: partId -> respawnAt created: Date.now(), }; lobbies.set(lobby.id, lobby); return lobby; } function lobbyPlayers(lobby) { return [...lobby.members.values()].map((m) => ({ id: m.user.id, username: m.user.username, avatar: m.user.avatar, pos: m.pos, stage: m.stage, score: m.score, })); } function leaderboard(lobby) { const entries = [...lobby.members.values()].map((m) => ({ id: m.user.id, username: m.user.username, stage: m.stage, score: m.score, it: lobby.mode === 'tag' && lobby.modeState.itId === m.user.id, })); if (lobby.mode === 'obby') entries.sort((a, b) => b.stage - a.stage); else entries.sort((a, b) => b.score - a.score); return entries; } function pickIt(lobby) { const ids = [...lobby.members.keys()]; if (ids.length < 2) { lobby.modeState.itId = null; return; } lobby.modeState.itId = ids[Math.floor(Math.random() * ids.length)]; lobby.modeState.lastTag = Date.now(); } function joinLobby(ws, user, game) { const db = get(); const lobby = findOrCreateLobby(game); // one connection per user per lobby; drop stale one const old = lobby.members.get(user.id); if (old && old.ws !== ws) try { old.ws.close(); } catch (e) {} const member = { ws, user, pos: [0, 5, 0], vel: [0, 0, 0], yaw: 0, anim: 'idle', stage: 0, score: 0 }; lobby.members.set(user.id, member); ws._room = { kind: 'play', lobby }; presence[user.id] = { gameId: game.id, instanceId: lobby.id }; // visit accounting + creator payout game.visits++; user.stats.joins++; const owner = db.users[game.ownerId]; if (owner && owner.id !== user.id) { owner.stats.visitsReceived++; if (!owner.system) owner.glimmers += 1; } save(); // active coin removals (not yet respawned) const now = Date.now(); const takenCoins = Object.entries(lobby.modeState.coinsTaken) .filter(([, t]) => t > now) .map(([pid, t]) => ({ partId: pid, respawnAt: t })); send(ws, { t: 'joined', selfId: user.id, instanceId: lobby.id, world: game.world, config: game.config, gameName: game.name, mode: lobby.mode, players: lobbyPlayers(lobby).filter((p) => p.id !== user.id), takenCoins, serverTime: Date.now(), }); broadcast(lobby, { t: 'pjoin', player: { id: user.id, username: user.username, avatar: user.avatar, pos: member.pos } }, user.id); broadcast(lobby, { t: 'chat', system: true, text: `${user.username} joined the game` }); if (lobby.mode === 'tag' && !lobby.modeState.itId && lobby.members.size >= 2) pickIt(lobby); broadcast(lobby, { t: 'lb', entries: leaderboard(lobby), itId: lobby.modeState.itId }); } function leaveRoom(ws) { const room = ws._room; if (!room) return; ws._room = null; if (room.kind === 'play') { const lobby = room.lobby; const member = [...lobby.members.values()].find((m) => m.ws === ws); if (!member) return; lobby.members.delete(member.user.id); delete presence[member.user.id]; broadcast(lobby, { t: 'pleave', id: member.user.id }); broadcast(lobby, { t: 'chat', system: true, text: `${member.user.username} left the game` }); if (lobby.mode === 'tag' && lobby.modeState.itId === member.user.id) pickIt(lobby); // refresh scoreboard for everyone so the leaver drops off immediately broadcast(lobby, { t: 'lb', entries: leaderboard(lobby), itId: lobby.modeState.itId }); if (lobby.members.size === 0) lobbies.delete(lobby.id); } else if (room.kind === 'studio') { const st = room.studio; const member = [...st.members.values()].find((m) => m.ws === ws); if (!member) return; st.members.delete(member.user.id); delete presence[member.user.id]; broadcast(st, { t: 'sleave', id: member.user.id }); if (st.members.size === 0) studios.delete(st.gameId); } } function dist2(a, b) { const dx = a[0] - b[0], dy = a[1] - b[1], dz = a[2] - b[2]; return dx * dx + dy * dy + dz * dz; } function partCenterAt(part, timeMs) { // movers oscillate on a triangle wave keyed to server epoch time if (part.type !== 'mover') return [part.x, part.y, part.z]; const t = (timeMs / 1000) / part.period % 2; const tri = t < 1 ? t : 2 - t; return [part.x + part.dx * tri, part.y + part.dy * tri, part.z + part.dz * tri]; } function handlePlayMsg(ws, member, lobby, msg) { const db = get(); const game = db.games[lobby.gameId]; if (!game) return; switch (msg.t) { case 'move': { if (Array.isArray(msg.p) && msg.p.length === 3 && msg.p.every(Number.isFinite)) { member.pos = msg.p; member.yaw = Number(msg.yaw) || 0; member.anim = typeof msg.anim === 'string' ? msg.anim.slice(0, 12) : 'idle'; broadcast(lobby, { t: 'p', id: member.user.id, p: member.pos, yaw: member.yaw, anim: member.anim }, member.user.id); } break; } case 'chat': { const text = String(msg.text || '').slice(0, 200).trim(); if (!text) break; broadcast(lobby, { t: 'chat', id: member.user.id, username: member.user.username, text }); send(ws, { t: 'chat', id: member.user.id, username: member.user.username, text }); break; } case 'touch': { // server-validated interactions: coins and goals const part = game.world.parts.find((p) => p.id === msg.partId); if (!part) break; const c = partCenterAt(part, Date.now()); const reach = Math.max(part.sx, part.sy, part.sz) / 2 + 4; if (dist2(member.pos, c) > reach * reach) break; // too far — reject if (part.type === 'coin') { const taken = lobby.modeState.coinsTaken[part.id]; if (taken && taken > Date.now()) break; lobby.modeState.coinsTaken[part.id] = Date.now() + 10000; member.score++; broadcast(lobby, { t: 'coin', partId: part.id, by: member.user.id, respawnAt: lobby.modeState.coinsTaken[part.id] }); send(ws, { t: 'coin', partId: part.id, by: member.user.id, respawnAt: lobby.modeState.coinsTaken[part.id] }); broadcast(lobby, { t: 'lb', entries: leaderboard(lobby), itId: lobby.modeState.itId }); } else if (part.type === 'goal') { if (member.finished) break; member.finished = true; member.score += 10; // first-completion glimmer prize for obby mode if (!member.user.system) { member.user.glimmers += 5; save(); } broadcast(lobby, { t: 'goalhit', by: member.user.id, username: member.user.username }); send(ws, { t: 'goalhit', by: member.user.id, username: member.user.username, reward: 5 }); broadcast(lobby, { t: 'lb', entries: leaderboard(lobby), itId: lobby.modeState.itId }); } break; } case 'stage': { const n = Math.round(Number(msg.n) || 0); if (n > member.stage && n < 1000) { member.stage = n; broadcast(lobby, { t: 'lb', entries: leaderboard(lobby), itId: lobby.modeState.itId }); send(ws, { t: 'lb', entries: leaderboard(lobby), itId: lobby.modeState.itId }); } break; } case 'died': { broadcast(lobby, { t: 'pdied', id: member.user.id }, member.user.id); break; } } } // tag proximity check loop — server decides who is "it" setInterval(() => { for (const lobby of lobbies.values()) { if (lobby.mode !== 'tag' || !lobby.modeState.itId) continue; if (Date.now() - lobby.modeState.lastTag < 2000) continue; // tagback cooldown const it = lobby.members.get(lobby.modeState.itId); if (!it) { pickIt(lobby); continue; } for (const m of lobby.members.values()) { if (m.user.id === it.user.id) continue; if (dist2(it.pos, m.pos) < 2.2 * 2.2) { lobby.modeState.itId = m.user.id; lobby.modeState.lastTag = Date.now(); it.score++; // points for passing the zap broadcast(lobby, { t: 'tag', itId: m.user.id, by: it.user.id }); broadcast(lobby, { t: 'lb', entries: leaderboard(lobby), itId: lobby.modeState.itId }); break; } } } }, 150); // ---------------- studio collab ---------------- function joinStudio(ws, user, game) { if (!(game.ownerId === user.id || game.collaborators.includes(user.id))) { send(ws, { t: 'error', error: 'No edit access to this place' }); ws.close(); return; } let st = studios.get(game.id); if (!st) { st = { gameId: game.id, members: new Map(), nextColor: 0 }; studios.set(game.id, st); } const old = st.members.get(user.id); if (old && old.ws !== ws) try { old.ws.close(); } catch (e) {} const color = EDITOR_COLORS[st.nextColor++ % EDITOR_COLORS.length]; const member = { ws, user, color, sel: null }; st.members.set(user.id, member); ws._room = { kind: 'studio', studio: st }; presence[user.id] = { studio: game.id }; send(ws, { t: 'sjoined', selfId: user.id, world: game.world, config: game.config, meta: { name: game.name, desc: game.desc, genre: game.genre, public: game.public }, isOwner: game.ownerId === user.id, editors: [...st.members.values()].map((m) => ({ id: m.user.id, username: m.user.username, color: m.color, sel: m.sel })), }); broadcast(st, { t: 'sjoin', editor: { id: user.id, username: user.username, color, sel: null } }, user.id); } function handleStudioMsg(ws, member, st, msg) { const db = get(); const game = db.games[st.gameId]; if (!game) return; switch (msg.t) { case 'op': { const op = msg.op || {}; let applied = null; if (op.kind === 'add') { if (game.world.parts.length >= MAX_PARTS) { send(ws, { t: 'error', error: `Part limit (${MAX_PARTS}) reached` }); return; } const part = sanitizePart(op.part); if (!part) return; // ensure unique id if (game.world.parts.some((p) => p.id === part.id)) part.id = uid(8); game.world.parts.push(part); applied = { kind: 'add', part }; } else if (op.kind === 'update') { const part = sanitizePart(op.part); if (!part) return; const i = game.world.parts.findIndex((p) => p.id === part.id); if (i < 0) return; game.world.parts[i] = part; applied = { kind: 'update', part }; } else if (op.kind === 'del') { const i = game.world.parts.findIndex((p) => p.id === op.partId); if (i < 0) return; game.world.parts.splice(i, 1); applied = { kind: 'del', partId: op.partId }; } else if (op.kind === 'meta') { applyConfig(game, op.data || {}); applied = { kind: 'meta', data: { name: game.name, desc: game.desc, genre: game.genre, public: game.public, config: game.config }, }; } if (applied) { game.updated = Date.now(); save(); broadcast(st, { t: 'op', from: member.user.id, op: applied }, member.user.id); send(ws, { t: 'opok', op: applied }); } break; } case 'sel': { member.sel = typeof msg.partId === 'string' ? msg.partId : null; broadcast(st, { t: 'sel', id: member.user.id, partId: member.sel }, member.user.id); break; } case 'chat': { const text = String(msg.text || '').slice(0, 200).trim(); if (!text) break; broadcast(st, { t: 'chat', id: member.user.id, username: member.user.username, text }); send(ws, { t: 'chat', id: member.user.id, username: member.user.username, text }); break; } } } // ---------------- wiring ---------------- function attach(server, apiRouter) { const wss = new WebSocketServer({ server, path: '/ws', perMessageDeflate: false }); wss.on('error', () => {}); // http server owns error handling (port retry) apiRouter.presence = () => presence; wss.on('connection', (ws, req) => { console.log('[RBLX-WS] Connection request, cookie:', req.headers.cookie); const user = userFromReq(req); console.log('[RBLX-WS] User resolved:', user ? user.username : 'null'); if (!user) { send(ws, { t: 'error', error: 'Not logged in' }); ws.close(); return; } ws.isAlive = true; ws.on('pong', () => { ws.isAlive = true; }); ws.on('message', (raw) => { let msg; try { msg = JSON.parse(raw); } catch (e) { return; } if (typeof msg !== 'object' || !msg) return; const db = get(); if (msg.t === 'join' || msg.t === 'studio') { leaveRoom(ws); const game = db.games[msg.gameId]; if (!game) { send(ws, { t: 'error', error: 'Game not found' }); return; } if (msg.t === 'join') { if (!game.public && !(game.ownerId === user.id || game.collaborators.includes(user.id))) { send(ws, { t: 'error', error: 'This place is private' }); return; } joinLobby(ws, db.users[user.id], game); } else { joinStudio(ws, db.users[user.id], game); } return; } const room = ws._room; if (!room) return; if (room.kind === 'play') { const member = room.lobby.members.get(user.id); if (member && member.ws === ws) handlePlayMsg(ws, member, room.lobby, msg); } else if (room.kind === 'studio') { const member = room.studio.members.get(user.id); if (member && member.ws === ws) handleStudioMsg(ws, member, room.studio, msg); } }); ws.on('close', () => leaveRoom(ws)); ws.on('error', () => leaveRoom(ws)); }); // heartbeat: drop dead sockets so lobbies 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) {} } }, 15000); return wss; } module.exports = { attach };