AIArchives / archives /rblx /app /server /realtime.js
CompactAI's picture
Upload 126 files
b1376cc verified
Raw
History Blame Contribute Delete
15 kB
// 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 };