Capítulo salas de la guía Neblla — salas, lobby, gathering y emparejamiento. Las salas sirven igual a una partida que a una app colaborativa (una pizarra compartida, un documento entre varios, un chat). El contrato base está en core.txt; el criterio de cuándo usar Neblla, en empezar.txt.
Salas y emparejamiento
El lobby
Todo usuario conectado aterriza en la sala lobby. Quién hay y quién soy:
neblla.room.users— array de usuarios de la sala actual.neblla.room.me— tú en esta sala (el mismo objeto que sale enneblla.room.users). Tu id en la app esneblla.auth.me.id.
room.on('userJoined') se dispara una vez por cada usuario que ya estaba al llegar y otra vez por cada entrada nueva; el flag alreadyPresent los distingue:
neblla.room.on('userJoined', event => {
if (event.alreadyPresent) {
// ya estaba — añadirlo a la lista en silencio
} else {
// acaba de entrar — sonido, animación
}
});
neblla.room.on('userLeft', event => {
// event.user se ha ido — quitarlo de la lista
});
El lobby es buen momento para publicar el perfil visible del usuario:
neblla.db.setPublicInfo({ name: 'John', color: '#ff8800' });
Queda en user.publicInfo para todos los de la misma sala y se restaura en reconexiones. Cuando alguien lo actualiza, todos reciben el evento room.on('publicInfo'):
neblla.room.on('publicInfo', event => {
// event.user.publicInfo tiene los nuevos datos
});
Render de lista de usuarios:
const renderPlayers = () => {
neblla.room.users.forEach(user => {
console.log(user.publicInfo?.name || 'Guest',
user.isPresenter ? '(host)' : '',
user.isMe ? '(you)' : '');
});
};
neblla.room.on('userJoined', renderPlayers);
neblla.room.on('userLeft', renderPlayers);
Salas con nombre (estilo chat)
Mismo nombre de sala = mismo grupo. Un usuario solo puede estar en una sala a la vez.
neblla.rooms.join('eurovision');
neblla.message.send({ type: 'msg', text: '¡me encanta esta canción!' });
Los mensajes solo llegan a quien esté dentro. Cambiar de sala: rooms.join('otra'). Volver al lobby: room.leave().
Por defecto cualquier usuario crea salas con solo hacer rooms.join('nombre-nuevo'). Para limitar la app a una lista fija de salas válidas: developers.neblla.com → Mis apps → tu app → Funciones › Salas.
Tipos de sala
La política se elige por app desde el dashboard:
ephemeral(default) — existe solo mientras hay alguien conectado; desaparece cuando se va el último usuario. Sin DB, sin historial: quien entra después no ve lo escrito antes.public— persistida. Cualquier usuario de la app puede entrar. Historial de chat guardado en la base de datos de la app (ver «Historial de chat»). Sobrevive a reinicios. Foros, lounges, lobbies públicos.private— persistida con whitelist de personas. Solo miembros entran. Los *owners* (rol intra-sala) invitan, expulsan, promueven a otros owners y borran mensajes. Si el owner abandona, el rol pasa al miembro más antiguo. Si la whitelist queda vacía, la sala se borra. Guilds, salas privadas, DMs grupales.
Una misma app puede mezclar tipos: las salas nacen ephemeral y se promueven con room.persist.
Cuándo se borra una sala persistida. Una vez por hora Neblla borra las salas public y private que llevan demasiado tiempo sin actividad (nadie escribe en el chat, cambia sus data ni sus miembros), con su chat y sus datos; quien estuviera dentro vuelve al lobby con rooms.on('deleted') y reason: 'inactive'. El plazo es de la app, por tipo: 30 días las públicas y 90 las privadas, 0 = nunca (neblla_update_app con rooms: { public: { ttlInactivity }, private: { ttlInactivity } }). Cada sala puede llevar su propio plazo, que manda sobre el de su tipo: room.persist({ name, type, ttlInactivity }) al crearla, en días enteros de 0 a 3650, y 0 = no se borra nunca. Así, en un ajedrez, las partidas privadas se van a los 90 días y la conversación privada entre Ana y Luis se queda:
const name = await neblla.rooms.book();
await neblla.room.persist({ name, type: 'private', ttlInactivity: 0 }); // no se borra nunca
Un valor que no es un entero de 0 a 3650 se rechaza con invalid_ttl. Desde el cliente, una sala public dura como mucho el plazo de la app para las públicas: con 30 días, vale de 1 a 30, y 0 o 31 se rechazan con invalid_ttl, min: 1 y max: 30 (si el plazo de la app es 0, vale cualquiera). Que una sala pública no se borre nunca lo decide el dev (neblla_create_room, neblla_update_room o el panel); las privadas, como la conversación de dos de arriba, sí pueden pedir 0. rooms.get y rooms.list lo devuelven en ttlInactivity (null: la sala sigue el plazo de su tipo). El dev lo cambia o lo quita después con neblla_update_room (ttlInactivity: null la devuelve al plazo de su tipo), y lo pone en las salas predefinidas con neblla_create_room.
Salas solo para usuarios con login. neblla.rooms.book({ config, requireLogin: true }) —o requireLogin en la política de salas de la app, para todas— deja fuera a los invitados: al intentar entrar reciben login_required, y tu app puede ofrecerles neblla.auth.login(). Las salas private y la persistencia lo exigen siempre.
Partidas: rooms.book → gathering → room.start
Una partida necesita reservar espacio, fijar reglas, esperar jugadores y empezar:
const roomId = await neblla.rooms.book({ config: { tiempoMax: 180, modo: 'blitz' } });
configes un JSON abierto del desarrollador. Neblla no lo interpreta. Se consulta conneblla.room.config(también dentro deserver/gathering.jsyserver/room.js).- Una sala con comportamiento propio (un modo boss, un torneo, una sala de práctica) no necesita otro fichero: reserva la sala con su modo en
configy haz queroom.jsse comporte segúnneblla.room.config. - La sala nace en estado
gatheringy es visible desde el lobby víaneblla.rooms.gathering: array de salas en formación, cada una con{ name, config, users }, refrescado por el eventorooms.on('gathering').
// Desde el lobby, listar las partidas abiertas:
neblla.rooms.gathering.forEach(room => {
console.log(room.name, room.users.length, room.config);
});
- Los jugadores entran con
rooms.join(roomId). Cuando están todos se llama aneblla.room.start()— desde un cliente solo puede hacerlo el presentador (y ni él si las reglas sin código tienenallowPresenterStart: false, «El presentador puede empezar antes» apagado en el panel: responde{ error: 'not_authorized', reason: 'presenter_start_off' }), yserver/gathering.jstambién puede: la sala pasa arunning, el server arranca y funcionanmessage.to('server', …)y lo que manda el server. - Desde Funciones › Salas (en el panel) se limita el máximo de jugadores por sala y si se permite entrar como espectador a la partida ya empezada. Vía MCP ese tope es
neblla_update_appconmatchmaking: { maxPlayers }(0 = sin límite, hasta 1000) — norooms, ni elconfigderooms.book.
Flujo correcto:
// El nombre de la sala lo genera el servidor — viene en el ack de rooms.book.
// `config` es JSON tuyo: Neblla NO lo interpreta. Este maxPlayers no limita
// nada por sí solo — lo lees en room.js y lo haces cumplir tú. El tope real
// que aplica Neblla se configura aparte (matchmaking.maxPlayers).
const roomName = await neblla.rooms.book({ config: { maxPlayers: 4 } });
await neblla.rooms.join(roomName);
// usuarios entran, esperan, se llena
neblla.room.start();
rooms.book no acepta un nombre de cliente — el id de sala siempre lo elige el servidor para evitar colisiones. Sí puedes pedir su longitud: rooms.book({ length: 3 }) da un código de 3 caracteres (3–12, por defecto 5; alfabeto sin 0/O/1/I/L).
Salas por invitación. neblla.rooms.book({ config, guests: [id, …] }) reserva una partida a la que solo entran los de la lista (y quien la reservó) mientras exista, también ya empezada; cualquier otro recibe rooms.on('joinError') con code: 'not_invited', y la sala no sale en neblla.rooms.gathering. Cada entrada de guests es el id en la app de un usuario de esta app (24 hex; uno que no lo es da invalid_guests con la lista unknown) o el id de sesión de alguien conectado ahora (neblla.room.users[i].id). Los conectados ahora reciben rooms.on('invite') con type: 'guests' y roomName; a los demás pásales el código o el enlace. Hasta 100; pide login (login_required) y la sala exige login para entrar. Errores del ack: { error: 'login_required' }, { error: 'invalid_guests' }.
Jugar ya. const name = await neblla.rooms.play({ config? }) mete al usuario en la sala en formación más llena que aún tenga plaza bajo matchmaking.maxPlayers y el mismo config, o en una nueva; así los siguientes N llenan la siguiente sala. Las salas por invitación quedan fuera. Si el usuario ya está en una sala (que no sea el lobby), devuelve esa: play() nunca le saca de una partida. El config se compara sin importar el orden de sus claves. Rechaza con el motivo de la última sala que no le dejó entrar (login_required si la app pide login para sus salas, quota_exceeded…) o room_unavailable.
Compartir una sala. neblla.rooms.link(name?) da la URL de esta página con ?room=<código> (por defecto, la sala actual; en el lobby, null); neblla.rooms.qr(name?, { size, dark }) da la URL de un SVG con su QR que sirve Neblla, para un <img src>; neblla.rooms.linked() da el código con el que se abrió la página (o null), para entrar directamente:
const code = neblla.rooms.linked();
if (code) await neblla.rooms.join(code);
img.src = neblla.rooms.qr(neblla.room.name, { size: 160 });
Gathering configurado vs custom
server/gathering.js decide *cuándo* arranca la sala (la partida, la sesión). Para los casos comunes no hace falta código: desde Funciones › Salas (en el panel) se configuran reglas declarativas — empezar con un mínimo de usuarios, cuando pasen X segundos desde la última entrada, cuando llegue un mensaje específico (botón "Empezar" del anfitrión), o combinaciones. Escribe gathering.js solo si la lógica de arranque es más compleja.
Vía MCP esas reglas son neblla_update_app con matchmaking: { mode, rule, minPlayers, maxPlayers, waitSeconds, allowPresenterStart } — rule es "count" (se alcanza minPlayers), "timer" (waitSeconds desde la última entrada), "count_then_timer" o "button" (un mensaje del anfitrión). Los valores por defecto son mode: "nocode", rule: "count_then_timer", minPlayers: 2, maxPlayers: 0 (sin límite), waitSeconds: 60, allowPresenterStart: true; una app nueva empieza con esos valores, y su gathering.js es el que sale de esas reglas (cada receta de «Recetas: tres formas de sala», abajo, dice con qué regla arranca). Con "button", el neblla.message.to('server', { type: 'start' }) de cualquier jugador arranca la sala; con las demás reglas y allowPresenterStart: true, el gathering.js generado solo acepta ese mensaje del presentador y lo ignora si viene de otro. El mode importa: "nocode" (el de por defecto) REGENERA server/gathering.js a partir de estas reglas cada vez que se guardan; "custom" significa que el fichero es tuyo y Neblla no lo toca nunca. Así que, si vas a escribir gathering.js a mano, pon antes mode: "custom": con "nocode", el siguiente guardado de las reglas lo sustituye.
server/lobby.js igual: solo si hay decisiones reales en el lobby (matchmaking inteligente, cola de espera, agrupación por nivel). Patrón de matchmaking:
// lobby.js (corre en Fog: lo elige Neblla, y puede coincidir con el presentador)
const queue = [];
// Un fichero de server oye en message.on lo que los clientes mandan con
// message.to('server', …) (m.to === 'server'). Nunca oye lo de
// message.to('presenter', …): eso es privado del cliente cuyo isPresenter es
// true (el rol de presentador es distinto del de servidor).
neblla.message.on(m => {
if (m.to !== 'server') return;
if (m.data.type === 'ready') {
queue.push(m.from);
if (queue.length >= 4) {
neblla.rooms.book().then(roomId => {
const players = queue.splice(0, 4);
neblla.message.send({ type: 'match-found', roomId, players });
});
}
}
});
// client.js
neblla.message.to('server', { type: 'ready' }); // esto es lo que te mete en la cola
// Lo que manda el server llega a message.on con m.from === 'server'
// (un broadcast entre pares, de cliente a cliente, trae el id de quien lo mandó).
neblla.message.on(m => {
if (m.from === 'server' && m.data.type === 'match-found' &&
m.data.players.includes(neblla.auth.me.id)) {
neblla.rooms.join(m.data.roomId);
}
});
API de salas persistentes
neblla.room.persist({ name, type, whitelist?, ttlInactivity? }) (CLIENT) — sube una sala recién reservada de ephemeral a public o private. ttlInactivity: los días sin actividad tras los que se borra, solo esta sala (0 = nunca; ver «Cuándo se borra una sala persistida»). Hay que llamarlo entre rooms.book y el primer rooms.join — una vez alguien entra, el tipo queda fijado. La sala guarda lo que le dio rooms.book (config, la contraseña de máster y requireLogin) y sus data desde el primer momento: cuando vuelve después de quedarse vacía, vuelve igual. La whitelist (para private) bootstrappea miembros iniciales; el llamador se añade automáticamente como primer owner. Los ids de la whitelist son per-app userIds (los id de neblla.room.users[i]), no personIds — solo se resuelven contra usuarios online en esta app en ese momento. Los offline se añaden después vía social.inviteToRoom.
neblla.rooms.list({ type? }) (CLIENT) — lista salas persistentes accesibles. Cada entrada: { name, type, creator, memberCount, activeUsers, lastActivityAt }.
neblla.rooms.get(name?) (CLIENT) — inspecciona una sala por nombre (por defecto, la actual).
neblla.room.members(name?) (CLIENT) — solo private. Devuelve { members[], invites[] }.
neblla.rooms.delete(name?) (CLIENT) — borra una sala persistente. Moderadores (cualquier sala) y owners de privadas (la suya). Echa a todos al lobby y emite rooms.on('deleted').
neblla.rooms.setType(name, type) (CLIENT) — promociona/baja entre public y private. Solo moderadores.
Membresía de salas privadas:
neblla.rooms.acceptInvite(roomName)/neblla.rooms.rejectInvite(roomName)— responde a una invitación (rooms.on('invite')).neblla.room.kick(targetUserId)— expulsa de la sala privada actual. Solo owner o moderador.neblla.room.abandon(roomName?)— renuncia a la membresía. Distinto deroom.leave: leave = "me desconecto ahora", abandon = "quítame de la whitelist".neblla.room.promote(targetUserId)/neblla.room.demote(targetUserId)— gestión de owners. Solo owner. No puedes auto-demotearte si eres el último.neblla.social.inviteToRoom(target)— añade a la whitelist de tu sala privada.
Roles de sala: master y board
Los dos roles requieren el módulo room encendido (neblla_update_app con features: { room: true }); sin él las llamadas responden feature_disabled.
Dos roles opcionales de cara a los usuarios. El master dirige la sesión; el board es el dispositivo de exhibición (la pantalla compartida, el tablero de la mesa). Ninguno tiene que ver con quién ejecuta los ficheros de server/, y tomarlos no arranca la sala.
neblla.room.setBoard(tabletUserId); // null lo limpia; es exclusivo
neblla.room.transferMaster(otroJugadorId);
neblla.room.on('board', ({ user, isMe }) => { /* … */ });
Los reparte el master; mientras no haya master, también el presentador. Firmas completas y errores en referencia.txt.
Master con contraseña (opcional). neblla.rooms.book({ config, masterPassword: 'profe-1234' }) protege el rol: el presentador ya no puede repartirlo, y quien sepa la contraseña lo toma con neblla.room.claimMaster('profe-1234') — la profesora cuyo portátil se cayó, desde otro dispositivo. El master actual puede seguir pasándolo. La sala lo anuncia con neblla.room.masterProtected; la contraseña nunca viaja a nadie. Errores de room.claimMaster: wrong_password, too_many_attempts (cinco fallos seguidos bloquean un minuto), no_master_password.
Recetas: tres formas de sala
Una app con salas nace con un solo ejemplo: server/room.js pone el estado (neblla.state.set), cada cliente lo pinta (neblla.state.on) y le habla por message (neblla.message.to('server', …)). En el ejemplo, cada jugador suma puntos a un marcador. Casi todo se construye sobre esa forma; estas tres recetas son las que más se repiten. Cada una dice qué pone el servidor, qué hace cada cliente y cómo arranca la sala. El tablero (board) y el director (master) que se eligen al crear la app traen su propia pieza de ejemplo y sirven con cualquiera de las tres.
Con árbitro (ajedrez, cartas, un quiz competitivo)
El servidor tiene las reglas: valida cada jugada, lleva el turno y publica el estado bueno. Los clientes no cambian nada por su cuenta: mandan la jugada y pintan lo que vuelve. Es la forma del ejemplo, con reglas de verdad.
- El servidor (
server/room.js): el tablero, de quién es el turno y quién ha ganado, en el estado. Lo que no vale lo rechaza con un aviso solo a quien lo mandó. - Cada cliente (
client.js): pinta el estado, deja jugar solo en su turno y manda la jugada al servidor. - Arranque: con los dos jugadores dentro:
neblla_update_app({ matchmaking: { rule: 'count', minPlayers: 2, maxPlayers: 2 } }).
// server/room.js — el árbitro de un tres en raya
const LINES = [[0,1,2],[3,4,5],[6,7,8],[0,3,6],[1,4,7],[2,5,8],[0,4,8],[2,4,6]];
let s = neblla.state.get() || {
board: Array(9).fill(null),
players: neblla.room.users.slice(0, 2).map(u => u.id),
turn: 0, // índice en players
winner: null, // id del ganador, o 'draw'
};
neblla.message.on((m) => {
if (m.to !== 'server' || m.data?.type !== 'move') return;
const reject = (reason) => neblla.message.to(m.from, { type: 'rejected', reason });
const cell = m.data.cell;
if (s.winner) return reject('game_over');
if (s.players[s.turn] !== m.from) return reject('not_your_turn');
if (!Number.isInteger(cell) || cell < 0 || cell > 8 || s.board[cell]) return reject('bad_move');
s.board[cell] = s.turn === 0 ? 'X' : 'O';
if (LINES.some(l => l.every(i => s.board[i] === s.board[cell]))) s.winner = m.from;
else if (s.board.every(Boolean)) s.winner = 'draw';
else s.turn = 1 - s.turn;
neblla.state.set(s);
});
// Si un jugador se va, gana el otro.
neblla.room.on('userLeft', ({ user }) => {
if (s.winner || !s.players.includes(user.id)) return;
s.winner = s.players.find(id => id !== user.id);
neblla.state.set(s);
});
neblla.state.set(s);
// client.js — dentro de window.onNeblla (con $ = id => document.getElementById(id), como en el
// ejemplo); nueve botones con data-cell="0" … "8" y un #status
const status = $('status');
const cells = [...document.querySelectorAll('[data-cell]')];
cells.forEach(b => b.onclick = () => neblla.message.to('server', { type: 'move', cell: Number(b.dataset.cell) }));
neblla.state.on(paint);
neblla.room.on('joined', ({ isLobby }) => { if (!isLobby && neblla.state.get()) paint(neblla.state.get()); });
neblla.message.on(({ from, data }) => {
if (from === 'server' && data.type === 'rejected') status.textContent = 'No vale: ' + data.reason;
});
function paint(s) {
const myTurn = !s.winner && s.players[s.turn] === neblla.room.me?.id;
cells.forEach((b, i) => { b.textContent = s.board[i] || ''; b.disabled = !myTurn || !!s.board[i]; });
status.textContent = s.winner === 'draw' ? 'Empate'
: s.winner ? (s.winner === neblla.room.me?.id ? 'Has ganado' : 'Has perdido')
: (myTurn ? 'Te toca' : 'Turno del otro');
}
Cartas que no ve nadie más. El estado lo ven todos. La mano de cada uno va aparte: neblla.message.to(id, { type: 'hand', cards }) desde room.js llega solo a ese jugador. Si room.js corre en Fog, el dispositivo que hace de servidor podría leerlas; cuando eso importa, reparte con neblla.tools.deck («Baraja: quién tiene qué», en servidor.txt) o lleva la sala a Server.
Pantalla y mandos (al estilo Jackbox)
Una pantalla grande que todos miran (la tele, un portátil, un proyector) y cada jugador con su móvil como mando. La pantalla es el board (neblla.room.setBoard) o, mientras no haya, el presentador: el primer dispositivo que entra.
- El servidor (
server/room.js): la fase, la pregunta, quién ha respondido y el marcador, en el estado. Solo la pantalla puede pasar de ronda; las respuestas de un mando se aceptan una vez por ronda. - La pantalla: enseña el código de la sala y su QR para entrar desde el móvil («Compartir una sala», arriba); después, la pregunta, quién falta por responder y el marcador. Su botón pasa a la ronda siguiente.
- Cada mando: cuatro botones; manda su respuesta al servidor y espera la siguiente ronda.
- Arranque: la pantalla pulsa «Empezar» cuando están todos:
matchmaking: { rule: 'button', minPlayers: 1 }. Ese botón mandaneblla.message.to('server', { type: 'start' })y elgathering.jsque sale de la regla arranca la sala.
// server/room.js — un quiz de cuatro opciones
const QUESTIONS = [
{ q: '¿Capital de Australia?', options: ['Sídney', 'Canberra', 'Melbourne', 'Perth'], right: 1 },
{ q: '¿Cuántas patas tiene una araña?', options: ['6', '8', '10', '12'], right: 1 },
];
let s = neblla.state.get() || { phase: 'waiting', round: -1, question: null, answered: [], scores: {} };
let answers = {}; // id → opción; fuera del estado, que lo ven todos
const isScreen = (id) => neblla.room.board ? neblla.room.board.id === id
: !!neblla.room.users.find(u => u.id === id)?.isPresenter;
function next() {
const round = s.round + 1;
if (round >= QUESTIONS.length) { s = { ...s, phase: 'end', question: null }; return neblla.state.set(s); }
const { q, options } = QUESTIONS[round]; // la correcta no viaja
answers = {};
s = { ...s, phase: 'question', round, question: { q, options }, answered: [] };
neblla.state.set(s);
}
function reveal() {
const right = QUESTIONS[s.round].right;
for (const [id, a] of Object.entries(answers)) if (a === right) s.scores[id] = (s.scores[id] || 0) + 1;
s = { ...s, phase: 'reveal', right };
neblla.state.set(s);
}
neblla.message.on((m) => {
if (m.to !== 'server') return;
if (m.data?.type === 'next' && isScreen(m.from)) return s.phase === 'question' ? reveal() : next();
if (m.data?.type === 'answer' && s.phase === 'question' && !(m.from in answers) && !isScreen(m.from)) {
answers[m.from] = m.data.option;
s = { ...s, answered: [...s.answered, m.from] };
neblla.state.set(s);
}
});
neblla.state.set(s);
// client.js — dentro de window.onNeblla
// #screen (con #screenCode, un <img> para el QR, #screenQuestion, #screenScores y el botón
// #screenNext) y #pad (cuatro botones
// con data-option="0" … "3"): cada dispositivo enseña uno de los dos.
let isLobby = true;
const me = () => neblla.room.me;
const isScreenUser = (u) => !!u && (u.isBoard || (!neblla.room.board && u.isPresenter));
const amScreen = () => !isLobby && isScreenUser(me());
neblla.room.on('joined', (e) => {
isLobby = e.isLobby;
if (!isLobby) $('screenCode').src = neblla.rooms.qr(neblla.room.name, { size: 200 });
show();
});
neblla.room.on('board', show);
neblla.room.on('presenter', show);
neblla.state.on(show);
// La pantalla: «Empezar» antes de arrancar; después, pasar de ronda.
$('screenNext').onclick = () => neblla.message.to('server',
{ type: neblla.room.phase === 'running' ? 'next' : 'start' });
// Los mandos: un botón por opción.
document.querySelectorAll('[data-option]').forEach(b => b.onclick = () =>
neblla.message.to('server', { type: 'answer', option: Number(b.dataset.option) }));
function show() {
const s = neblla.state.get();
$('screen').hidden = !amScreen();
$('pad').hidden = isLobby || amScreen();
if (!s) return;
if (amScreen()) {
$('screenQuestion').textContent = s.question?.q || (s.phase === 'end' ? 'Fin' : 'Preparados…');
$('screenScores').textContent = neblla.room.users.filter(u => !isScreenUser(u))
.map(u => (u.publicInfo.name || u.id.slice(0, 6)) + ' ' + (s.scores[u.id] || 0)
+ (s.answered.includes(u.id) ? ' ✓' : '')).join(' · ');
} else {
const done = s.phase !== 'question' || s.answered.includes(me()?.id);
document.querySelectorAll('[data-option]').forEach((b, i) => {
b.textContent = s.question?.options[i] || '';
b.disabled = done;
});
}
}
neblla.room.phase dice si la sala está en gathering o running. Si la respuesta buena no debe estar ni en el dispositivo que hace de servidor (en Fog es el de un jugador), mira «Ejemplo: un quiz en clase» en servidor.txt: las correctas viven solo en la pantalla y las respuestas van en sobres.
Todos a la vez (una pizarra, un documento)
Nadie valida: cada uno dibuja y los demás lo ven al momento. Los trazos viajan entre los clientes por P2P (neblla.message.send, sin coste de cuota mientras va directo); el servidor solo guarda lo dibujado para quien llega tarde.
- El servidor (
server/room.js): la lista de trazos terminados, en el estado. Nada más. - Cada cliente: al mover el dedo pinta y manda el tramo a los demás; al soltar, manda el trazo entero al servidor. Pinta lo que llega de los otros, y al entrar, lo que ya estaba en el estado.
- Arranque: con el primero que entra:
matchmaking: { rule: 'count', minPlayers: 1 }.
// server/room.js — guarda la pizarra para los que llegan tarde
let s = neblla.state.get() || { strokes: [] };
neblla.message.on((m) => {
if (m.to !== 'server' || m.data?.type !== 'stroke') return;
s.strokes.push(m.data.stroke);
if (s.strokes.length > 300) s.strokes.shift(); // que no crezca sin fin
neblla.state.set(s);
});
neblla.state.set(s);
// client.js — dentro de window.onNeblla; un <canvas id="board" width="800" height="500">
const ctx = $('board').getContext('2d');
const color = '#' + Math.floor(Math.random() * 0xffffff).toString(16).padStart(6, '0');
let stroke = null;
function line(c, a, b) { ctx.strokeStyle = c; ctx.lineWidth = 3; ctx.beginPath(); ctx.moveTo(...a); ctx.lineTo(...b); ctx.stroke(); }
function paintStroke(st) { for (let i = 1; i < st.points.length; i++) line(st.color, st.points[i - 1], st.points[i]); }
$('board').onpointerdown = (e) => { stroke = { color, points: [[e.offsetX, e.offsetY]] }; };
$('board').onpointermove = (e) => {
if (!stroke) return;
const a = stroke.points[stroke.points.length - 1], b = [e.offsetX, e.offsetY];
stroke.points.push(b);
line(color, a, b);
neblla.message.send({ type: 'seg', color, a, b }); // a toda la sala, P2P
};
$('board').onpointerup = () => {
if (stroke) neblla.message.to('server', { type: 'stroke', stroke });
stroke = null;
};
// Lo que dibujan los demás, al momento.
neblla.message.on(({ data, fromMe, from }) => {
if (fromMe || from === 'server' || data?.type !== 'seg') return;
line(data.color, data.a, data.b);
});
// Al entrar: lo que ya había.
neblla.room.on('joined', ({ isLobby }) => {
ctx.clearRect(0, 0, 800, 500);
if (!isLobby) (neblla.state.get()?.strokes || []).forEach(paintStroke);
});
Si dos cambian lo mismo a la vez gana el último; para un texto editado entre varios, usa un CRDT (por ejemplo, Yjs) sobre los mismos mensajes. Y si lo que se guarda crece más allá de una sala, mira «Un espacio compartido más grande que una sala» en comunidad.txt. Sin salas separadas (un solo espacio para todos), el patrón es lobby-only (core.txt).
room.data
neblla.room.setData(data) (CLIENT) — cualquier miembro, o solo los moderadores si la sala los tiene. Fusiona data con neblla.room.data (persistente en salas public/private), clave a clave en el primer nivel: las claves que no mandas se quedan, y para vaciar una la mandas con null. El fichero de servidor no la tiene: lo que decide él va en el estado (neblla.state.set). Todos los miembros reciben room.on('data'). Sujeto al límite antiabuso maxDataSize (10 KB por defecto; lo ajustas o lo quitas en la configuración de salas); un exceso emite room.on('dataError'). En una sala persistente, Neblla la tiene en memoria y la escribe en la base de datos en los momentos clave (la sala se vacía o se cierra, y como mucho cada 3 minutos; ver «Lo que no es del usuario» en identidad.txt); room.setData(data, { now: true }) la escribe ya, para lo que no puede esperar (el final de una partida); una sala escribe así una vez cada 10 segundos como mucho, y si llegan más, la última se escribe al acabar ese plazo. Si tu documento compartido crece, mira "Un espacio compartido más grande que una sala" en comunidad.txt: una entry por persona, sin conflictos de escritura, y la sala se queda solo con el aviso de que algo cambió.
Historial de chat (salas persistentes)
neblla.room.history({ name?, before?, limit? }) (CLIENT) — paginación del chat persistido. Newest-first. Default limit 50, max 200. Los mensajes borrados vienen con body: null y deleted: true.
Dónde se guarda. El chat es un dato más de la app: va a su base de datos y cuenta en su tope (50 MB por cuenta gratis, compartidos por sus apps; 100 MB por proyecto Pro), junto a los guardados y las tablas. El panel (Config → Capacidad) muestra cuánto de esa base de datos es chat. Tú decides cuánto guarda cada sala con msgLimit.count en la política de salas: al pasarse, se borran los más antiguos (los borrados se quedan como marca para la moderación). Con la base de datos llena, una línea nueva espera en la cola con las demás escrituras de los usuarios que ya estaban dentro (ver publicar.txt) y el historial ya la muestra; si la base de datos (o tu base) no responde, la línea espera a que vuelva y se escribe sola, y el historial también la muestra. En los dos casos messageSaved lleva pending: true. Borrar mensajes, salas o usuarios devuelve su sitio a la base de datos. Con base de datos propia (neblla_byodb) el chat va a tu base, tabla nb_room_messages (neblla_nb_room_messages), sin el id de Neblla de nadie, y no cuenta en la base de datos de Neblla; al pasar a ella, el chat se copia y Neblla no se queda con nada.
Neblla no cifra el chat. Se guarda en claro, en la base de datos de la app o en tu base, para que lo puedas leer, buscar y moderar. Si tu app lo necesita, cifra los mensajes en ella antes de send (y descífralos al recibirlos y al leer el historial): Neblla los guarda y reenvía tal cual.
neblla.room.deleteMessage(messageId, name?) (CLIENT) — soft-delete. Autor, owner o moderador. Emite room.on('messageDeleted') a todos.
Seguir una sala
Un usuario sigue una sala persistente y, cuando alguien escribe en ella mientras él no está dentro, se le apunta como nuevo; si tiene la app abierta (en el lobby o en otra sala) le llega un aviso al momento, y cuando vuelve ve cuántos mensajes nuevos tiene en cada sala que sigue. Sirve igual para el canal de una clase, el hilo de un foro o una partida por turnos («te toca»): cuenta cualquier mensaje que se queda en el historial de la sala (neblla.message.send en una sala public o private). Lo que cambia room.data o el estado del servidor no cuenta: en una partida por turnos, manda también la jugada con message.send.
await neblla.rooms.follow(name); // sin nombre, la sala en la que estás
neblla.rooms.on('followedMessage', ({ room, unread, from }) => {
aviso(`${from?.publicInfo?.name || 'Alguien'} escribió en ${room} (${unread} nuevos)`);
});
neblla.rooms.on('following', ({ rooms }) => pintarInsignias(rooms));
// Al volver: neblla.rooms.following ya trae [{ name, type, unread, lastMessageAt, lastReadAt, followedAt }]
- Quién puede seguir: un usuario con login que es miembro de la sala: en una
public, cualquiera; en unaprivate, los de su lista. Errores:login_required,not_whitelisted,room_not_found,not_followable(el lobby, o una salaephemeral),follow_limit_reached(hasta 200 salas por usuario y app). Seguir dos veces no hace nada. - Qué cuenta: cada mensaje guardado de otra persona mientras el seguidor no está dentro de la sala. El que escribe y quien está dentro no suman.
- Leído: entrar en la sala la deja a 0 (y las demás pestañas del usuario lo oyen);
neblla.rooms.markRead(name?)la deja a 0 sin entrar (not_followingsi no la sigue).lastReadAtdice desde cuándo pintar «nuevos» en el historial. - Al volver: al hacer login (o al recargar la página con login),
neblla.rooms.followingllega solo con las salas que sigue, la más reciente primero, y saltarooms.on('following').neblla.rooms.followed()lo vuelve a pedir. - Se deja de seguir con
neblla.rooms.unfollow(name?), al abandonar o ser expulsado de una sala privada, al dejar de ser amigos (la sala de los dos) y cuando la sala se borra. - El aviso no sale de la app: llega solo a quien la tiene abierta; quien no, ve el número al volver. Neblla no manda email ni notificación push por esto.
- Cuota: cada
follow,unfollow,followedymarkReadgasta como un mensaje (módulorooms), y cada aviso y lista que llega, como cualquier respuesta. El dev ve cuántos siguen cada sala enneblla_list_rooms(followers).
Receta: mensajes privados entre amigos
Desde el perfil de un amigo (neblla.social.friends), un botón «Escribir» abre la sala de los dos: neblla.rooms.direct(amigo) la crea la primera vez y devuelve la misma todas las demás, la pida quien la pida. Es una sala private que no se borra nunca por inactividad (ttlInactivity: 0) y que los dos siguen («Seguir una sala»): lo que uno escribe mientras el otro no está le llega como aviso o como nuevos al volver.
// En el perfil de un amigo: amigo = una entrada de neblla.social.friends
botonEscribir.onclick = async () => {
const { name } = await neblla.rooms.direct(amigo); // o amigo.id
await neblla.rooms.join(name);
pintarConversacion(await neblla.room.history());
};
neblla.message.on(({ data, fromMe }) => { if (data?.type === 'dm') pintarLinea(data.text, fromMe); });
botonEnviar.onclick = () => neblla.message.send({ type: 'dm', text: caja.value });
// La lista de conversaciones, con sus nuevos: las salas directas que sigues.
neblla.rooms.on('following', ({ rooms }) => pintarConversaciones(rooms));
neblla.rooms.on('followedMessage', ({ room, from }) => aviso(`${from?.publicInfo?.name || 'Un amigo'} te ha escrito`));
- Solo entre amigos: hace falta el scope
friendsy una amistad aceptada; a quien no es amigo,not_friends, así que no sirve para escribir a desconocidos. A uno mismo,self; un id que no es de persona,invalid_friend; sin login,login_required. - Solo los dos: la sala es
direct(rooms.get/rooms.listlo dicen), no tiene owners y nadie más entra;social.inviteToRoom,room.kick,room.promote/demoteyrooms.setTyperespondendirect_room. Su nombre (DM_…) es aleatorio. Los moderadores de la app siguen pudiendo moderarla. - Salir:
room.abandon(name)saca a quien la deja de la lista y deja de seguirla. Si el otro vuelve a abrirla conrooms.directy siguen siendo amigos, vuelve a la lista y a seguirla; para que no pueda, hay que dejar también la amistad (social.remove). Si los dos la dejan, se borra. - Al dejar de ser amigos (cualquiera de los dos, desde la app o desde su cuenta de Neblla), los dos salen de la sala a la vez: dejan de seguirla, quien estaba dentro vuelve al lobby (
room.on('userAbandoned')conreason: 'unfriended') y nadie puede entrar ni escribir en ella. La sala y su historial no se borran: si vuelven a ser amigos,rooms.directlos vuelve a meter a los dos y a seguirla. - Es una sala privada más: la política de salas de la app tiene que permitir las privadas (
type_not_allowed_by_policysi no) y al crearla cuentan los topes por usuario (per_user_create_limit_reached,per_user_participation_limit_reached,friend_participation_limit_reached). Cuota: cadarooms.directgasta como un mensaje (módulorooms).
Eventos de salas
Ciclo de vida general:
room.on('joined')(CLIENT) — entraste a una sala.{ room, isLobby }room.on('left')(CLIENT) —{ room, user }room.on('userJoined')(BOTH) —{ user, room, isLobby, alreadyPresent }.room.on('userLeft')(BOTH) —{ user, room }rooms.on('gathering')(CLIENT, lobby) — refresh del listado de salas en formación.{ rooms: [{ name, config, users }] }. Reflejado enneblla.rooms.gathering.room.on('started')(BOTH) — la sala pasó arunning, la haya arrancado el presentador conroom.start()oserver/gathering.js.{ room, isMe }room.on('data')(BOTH) —{ room, data }.room.on('dataError')(CLIENT) — turoom.setData(...)fue rechazado (size cap u otro).room.on('closed')(CLIENT) — la app pasó su cuota del día y el margen del 10 %, y el servidor cerró la sala y te sacó.{ room, reason: 'quota_exceeded', resetsAt }(room: nullsi la pestaña no estaba cuando se cerró). Lo que guardes aquí espera en el navegador hasta que vuelva la cuota; al resetearse vuelves al lobby (room.on('joined')).room.on('leftOut')(CLIENT) — este dispositivo no llega a nadie de la sala: ni P2P directo, ni por el relé TURN si la app lo tiene, y el reenvío por el servidor está apagado (connections: 'p2p', con el que nace una app nueva). No le llegan los mensajes de los demás. Neblla ya se lo ha dicho al usuario con un aviso; tu app puede añadir algo propio.{ room, turn }. Una vez por sala.rooms.on('joinError')(CLIENT) — fallo entrando a una sala (code: 'room_full'si ya tienematchmaking.maxPlayersusuarios,code: 'quota_exceeded'si la app gastó su cuota del día,code: 'invalid_room_name'si el nombre no es texto de 1-64 caracteres,code: 'not_invited'si la sala es por invitación y no estás en la lista, whitelist, kick, etc).
Salas persistentes:
room.on('userInvited')(CLIENT) — owner añadió a invitados pendientes.rooms.on('invite')(CLIENT) — al invitado: te invitan a una sala.type: 'guests'cuando es la lista de invitados derooms.book.room.on('inviteAccepted')/room.on('inviteRejected')(CLIENT)room.on('userKicked')(CLIENT) — quitado de la whitelist por owner/moderador.rooms.on('kicked')(CLIENT) — push directo al expulsado.room.on('userAbandoned')(CLIENT) — se auto-quitó.room.on('ownerAdded')/room.on('ownerRemoved')(CLIENT)rooms.on('deleted')(CLIENT) —{ room, reason, by? }.rooms.on('typeChanged')(CLIENT)
Salas que sigues (ver «Seguir una sala»):
rooms.on('followedMessage')(CLIENT) — alguien escribió en una sala que sigues mientras no estabas dentro.{ room, unread, from: { id, publicInfo }, time }.rooms.on('following')(CLIENT) — cambió la lista de salas que sigues (al hacer login, al seguir o dejar de seguir, con un mensaje nuevo, al leer).{ rooms }, reflejado enneblla.rooms.following.
Chat persistido:
room.on('messageSaved')(CLIENT) — eco con el id necesario para borrar.{ room, id, from, time, pending? }:pending: truesi la línea espera para escribirse (base de datos llena o sin respuesta); el historial ya la muestra y se escribe sola.room.on('messageDeleted')(CLIENT) — render tombstone.room.on('messageDropped')(CLIENT) — solo al autor, mensaje no persistido (too_large o rate_limited).
Seguir conectados: la hora y el «¿sigues ahí?»
Mientras la app está abierta, el SDK se mantiene en contacto con Neblla de dos maneras, y cada app elige cuánto (neblla_update_app({ connection }), "Pasar a pro" lo lleva a los usuarios):
- La hora común (
neblla.room.now()): al entrar se mide bien, con varias muestras, y después cada vez menos: a 1 s, 1,4 s, 2 s, 2,8 s… (×√2) hasta un techo, 1 hora por defecto. Un ajedrez de partidas de 3 minutos lo baja:connection: { clock: { max: 120 } }. Un chat puede fiarse de la hora del dispositivo:clock: falsela mide solo al entrar. - El «¿sigues ahí?»: el SDK manda un latido cada
everysegundos, y si Neblla pasatimeoutsin oírlo, corta su conexión (si era el servidor de la sala, otro toma el relevo en ese momento) pero le guarda el sitio 3 minutos (12 s si era el servidor), por si vuelve: un túnel o un cambio de wifi no lo saca de la partida. Si vuelve antes, sigue como si nada; si no, sale de la sala y los demás reciben su salida. Por defecto, una app con salas (el móduloroom) lo nota en 45 s ({ every: 15, timeout: 45 }), para que en un juego se vea pronto quién se ha ido; cualquier otra, en 10 minutos ({ every: 120, timeout: 600 }), porque en un chat un fantasma de más no importa.connection: { presence: { every: 60 } }(eltimeoutes 4 ×everysi no lo das, y nunca menos de 2 ×). Con la página oculta (pantalla bloqueada, otra pestaña) el navegador frena sus temporizadores, así que Neblla le da siempre al menos 3 minutos.
El latido va del dispositivo a Neblla y no tiene respuesta: ajustarlo no gasta ancho de banda de salida. En la cuota, el latido y la hora cuentan como mucho una vez cada 30 s cada uno, vayan como vayan. null vuelve a los valores por defecto.