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:

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:

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' } });

// Desde el lobby, listar las partidas abiertas:
neblla.rooms.gathering.forEach(room => {
  console.log(room.name, room.users.length, room.config);
});

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:

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.


// 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.


// 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.


// 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 }]

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`));

Eventos de salas

Ciclo de vida general:

Salas persistentes:

Salas que sigues (ver «Seguir una sala»):

Chat persistido:

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):

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.