Capítulo servidor de la guía Neblla — Server (tu room.js en un servidor de verdad), Fog (el servidor que vive en los dispositivos de los usuarios), No-code (herramientas de servidor que corren en Neblla), estado, hora común, anonimato y anti-cheat. El contrato base está en core.txt; el criterio de cuándo usar Neblla, en empezar.txt.
El servidor
Tu servidor son los ficheros de server/ (lobby.js, gathering.js, room.js). Neblla te da tres piezas que trabajan juntas, siempre las tres a la vez:
- Server: tu
room.jsen un servidor de verdad: en la nube de Neblla o en el tuyo (abajo). Para lo que no debe correr en el dispositivo de ningún usuario. - Fog: tu
room.jscorre en los dispositivos de los usuarios. Neblla elige dónde, coordina y juzga. No hay servidor que montar ni pagar. - No-code: herramientas de servidor que corren en Neblla y no necesitan código: una baraja, un dado, sobres cerrados, zonas, contadores, límites de puntuación. Tu
room.jslas llama para lo que tiene que ser verdad aunque el dispositivo que ejecutaroom.jssea de un usuario.
El reparto es este: Neblla guarda lo poco que tiene que ser verdad (el azar, los secretos, la hora, el veredicto) y los dispositivos hacen el trabajo. Fog es lo que usa una app por defecto, gratis. Cuando una sala necesita un servidor que no sea de nadie —un aforo que no se puede pasar, un ranking de un juego de un jugador que no se puede falsear, una arena de muchos jugadores—, ese mismo room.js pasa a Server sin cambiar de código.
Qué necesitas según tu juego
Neblla trae mucho, pero cada juego usa poco. Empieza por tu caso y deja el resto para cuando lo necesites:
- Un juego casual entre amigos (dibujar, adivinar, preguntas):
room.jsconstate.setymessage.to('server', …). Nada más. - Con información oculta (roles, cartas, respuestas): añade la baraja y los sobres de No-code.
- Competitivo, con algo en juego (rankings, premios): añade testigos, el azar y la hora del servicio (abajo) y, si hay roles secretos, el anonimato.
- De acción (movimiento en tiempo real): pon la ventana a unos 200 ms y, si el mapa es grande, zonas.
- Un juego para uno con ranking (un Wordle, reflejos, un test): sin sala,
neblla.scores(ver identidad.txt). La partida empieza en Neblla y lo imposible no entra en el ranking. - Lo que no debe correr en ningún jugador (un aforo, una arena grande): Server.
Lo demás de este capítulo existe para cuando lo necesites; no hace falta leerlo todo para empezar.
Fog: el servidor que vive en los usuarios
Fog es un servicio con sus propias reglas. Lo que hace por ti:
- Elige dónde corre tu
room.js: en tres dispositivos de la sala a la vez. Uno manda y dos le siguen. Los elige por latencia y CPU, y los reordena durante la sesión. - Relevo sin cortes: si el que manda se va (pestaña cerrada, túnel, batería), uno de los que le siguen ya tiene el estado al día y sigue en el acto, con sus timers.
- Compara a los tres: los Fog se pasan entre ellos una huella de cada estado. Si no coinciden, Neblla compara los estados: gana la mayoría, el que no coincide se resincroniza en silencio y se marca a su usuario. El caso queda en tu panel, Personas › Moderación (y en
neblla_list_fog_cases) con lo que difería y quiénes eran los Fog. Si para ti fue un bug y no una trampa, lo anulas y la marca se va; si fue un ataque de los otros dos, les pasas la marca a ellos (neblla_decide_fog_case). - Hora común y orden: todos los dispositivos comparten una hora, y los mensajes se aplican en el mismo orden en todos (abajo).
- Quórum de dos: todo lo que destapa un secreto en No-code necesita que lo pidan dos Fog con la misma llamada. Un Fog trucado, solo, no consigue nada y queda marcado.
- Juzga Neblla, nunca un usuario: los testigos, las huellas y los flags los resuelve el servidor de Neblla.
Con menos de tres dispositivos en la sala, corre en los que haya. Lo que viaja entre dispositivos va P2P y no gasta cuota.
Su identidad es opaca: no hay user.isServer ni nada que diga qué dispositivo es. Nunca escribas código que inspeccione quién es el servidor: rutea con message.to('server', …).
En el móvil: pantalla bloqueada, app en segundo plano, red que se corta
- Pantalla bloqueada o app en segundo plano. El navegador frena los timers de una página oculta. El SDK avisa a Neblla en cuanto la página se oculta: si ese dispositivo llevaba la sala, uno de los Fog visibles sigue en el acto, con el estado al día; si era uno de los que seguían, le cede el sitio a un usuario con la pantalla encendida. Mientras haya dispositivos visibles, uno oculto no se elige como Fog. Si todos están ocultos, el que manda sigue, más lento, y al volver a primer plano todo continúa.
- El móvil pierde la conexión (túnel, ascensor, batería). Sin red no puede avisar: Neblla lo nota cuando el socket deja de responder, como mucho en unos 45 s, y entonces el relevo es inmediato. Mientras tanto la sala espera al que manda. El usuario sigue siendo de la sala 3 minutos (12 s si llevaba la sala); si vuelve dentro de ese plazo, sigue en ella.
- Redes que cortan el P2P (algunas wifi de empresa o de operadoras). Lo que no llega P2P a un dispositivo se lo reenvía Neblla, y ese reenvío gasta cuota (ver publicar.txt). Con el relé TURN encendido (
neblla_update_app({ p2p: { turn: true } })), el P2P pasa por él y también cuesta cuota. Un dispositivo así funciona igual, pero rara vez es el mejor Fog: Neblla elige a los Fog por latencia.
Las reglas de room.js
Tres reglas, siempre:
1. Sin DOM. window, document, globalThis y self están sombreados en server/. La UI vive en client.js. 2. Sin "yo". No existen neblla.auth ni neblla.room.me en server/. 3. Solo eventos. Cliente y servidor se hablan con message.to('server', …), message.on y state. El azar y la hora: tú decides. Con Math.random() y Date.now() tu room.js funciona, gratis; pero cada Fog saca su propio número y su propia hora, así que los que siguen al que manda pueden ir algo distintos, y en un relevo el siguiente sigue desde el último estado publicado. Para un juego entre amigos, suficiente. Si quieres que los tres Fog coincidan exactamente (comparar huellas, anti-trampas, relevo sin un solo salto), usa neblla.tools.random (o la baraja), que cuesta cuota por llamada, y neblla.room.now(), la hora común de la sala, que es gratis.
Los timers funcionan. setTimeout y setInterval sirven dentro de room.js, y un timer es lo único que hace avanzar la sala cuando nadie actúa. Cuando salta en el Fog que manda, el SDK lo reproduce en los otros dos en el mismo punto.
Si tus reglas usan trigonometría (Math.sin, Math.atan2…), un iPhone y un Android pueden diferir en el último decimal. Para decisiones que dependen de eso, redondea antes de comparar, o lleva la sala a Server, donde corre en una sola máquina.
Arrancar con la partida en marcha. El código de arriba de room.js no corre solo al empezar: corre otra vez en cada dispositivo que pasa a ser Fog con la sala ya en marcha (un relevo, otra réplica, uno que Neblla resincroniza), con neblla.state.get() tal como está en ese momento. Arriba, lee el estado y apunta tus handlers; llama a herramientas solo para empezar la partida (sin estado) o para terminar un paso que quedó a medias (por ejemplo, «si el estado está en dealing, crea el mazo»). Neblla hace que eso sea seguro: un Fog que arranca mientras otros ya corren el fichero no sabe en qué punto van, así que sus llamadas No-code (y callApi) esperan a que uno de los que ya corren haga la del mismo número. Si es la misma, recibe la misma respuesta y desde ahí va al paso; si es otra (los demás ya habían pasado ese punto), la sala sigue como si nada y Neblla arranca otra vez ese Fog desde el estado y el número de llamada actuales de la sala. Si no queda nadie corriendo el fichero, sigue solo: es él quien termina el paso. Una llamada que no casa no vuelve nunca, ni con error, porque el código que venía detrás es de un arranque que se descarta; si los demás no la hacen en unos segundos (no están en ese paso), tampoco vuelve, y la consola del dispositivo lo avisa (fog_unpaired).
// ❌ Mal — room.js toca el DOM:
document.getElementById('counter').textContent = state.turno;
// ✅ Bien — room.js solo cambia estado, el cliente reacciona:
// room.js
neblla.state.set({ ...state, turno: state.turno + 1 });
// client.js
neblla.state.on(state => {
document.getElementById('counter').textContent = state.turno;
});
La hora común y el orden de los mensajes
Cada mensaje que un usuario manda al servidor lleva un número (el 1, el 2, el 3… de ese usuario) y la hora común en que lo mandó. Los tres Fog los aplican en orden de hora, así que todos llegan al mismo estado aunque los mensajes les lleguen en distinto orden.
- Provisional y definitivo. Un mensaje es provisional hasta que llega el siguiente del mismo usuario o pasa la ventana: 1 segundo por defecto. Pasada la ventana, es definitivo.
- Si llega tarde uno anterior, Fog rebobina al último estado bueno y vuelve a aplicar todo en orden (rollback). Tu
room.jsno tiene que hacer nada: el SDK vuelve a ejecutar tus handlers. - Si un mensaje se pierde, Fog lo pide a los otros Fog. Si nadie lo tiene, es lag de ese usuario: su mensaje se aplica cuando llega, no en su hora.
- Una hora imposible (más antigua de lo que permite la latencia real de ese usuario) se corrige a la más cercana posible. Si se repite, flag.
La ventana se cambia por app con neblla_update_app({ fog: { window: 200 } }), y por sala en el config de rooms.book. Un juego de acción quiere unos 200 ms; uno por turnos puede dejar el segundo.
Dos flujos de eventos en el cliente. El estado y los mensajes del servidor llegan de dos formas, y tú eliges cuál escuchas:
// Al momento. Puede rebobinarse: tu front tiene que reflejar el estado nuevo cuando llegue.
neblla.state.on(state => render(state));
// Solo lo definitivo. Nunca se rebobina: para lo que no se puede deshacer ("has ganado").
neblla.state.on(state => anunciar(state), { final: true });
Lo mismo con lo que manda el servidor en neblla.message.on. Lo visual suele ir al momento; lo importante, definitivo.
Estado — state.set
neblla.state.set(state) (SERVER) emite el estado a todos los dispositivos de la sala. Viaja P2P y no gasta cuota; solo hacia un dispositivo que no conecta P2P lo reenvía Neblla, y ese reenvío cuenta (ver publicar.txt). Cada dispositivo comprueba que viene de un Fog legítimo.
// room.js
let state = neblla.state.get() || { tablero: initialBoard(), turno: 0 };
neblla.message.on(m => {
if (m.to !== 'server') return;
if (esLegal(state, m.data)) {
state.tablero = aplicar(state.tablero, m.data);
state.turno++;
neblla.state.set(state);
}
});
// client.js
neblla.state.on(state => render(state));
console.log(neblla.state.get()); // siempre el último valor
Usa state.set, no message.send, para el estado:
// ❌ Mal — mensaje genérico:
neblla.message.send({ type: 'state', state: newState });
// ✅ Bien — primitivo de estado:
neblla.state.set({ tablero, turno });
El estado es público: llega a todos. Lo que no deben ver los usuarios (las respuestas correctas, el mazo, lo que ha contestado cada uno) no va en el estado: va en un sobre de No-code (abajo).
Un usuario que pierde la conexión conserva su sesión unos tres minutos: si vuelve dentro de esa ventana reentra en la misma sala, con sus datos, como miembro normal.
Lo que manda el servidor: a toda la sala o a uno
Un message.send(data) del servidor llega a todos los clientes por message.on, con from: 'server'. Para un solo usuario, message.to(userId, data): el rol de cada jugador o su mano de cartas le llegan por privado.
message.to('server'), no broadcast filtrado
// ❌ Mal — broadcast con filtro manual:
neblla.message.send({ type: 'move', square: 'e4', forServer: true });
// ✅ Bien — destino dedicado:
// cliente
neblla.message.to('server', { move: 'e4' });
// room.js
neblla.message.on(m => {
if (m.to !== 'server') return; // los mensajes para el servidor traen to: 'server'
});
message.to('server', …) no se difunde al resto de clientes: llega solo al servidor de la sala.
Anonimato: el servidor que no sabe quién es quién
En juegos con rol oculto (quién es el lobo, quién tiene qué carta) el problema es que el servidor corre en el dispositivo de un jugador. Con await neblla.tools.anonymize() (SERVER), nadie lo sabe, ni el Fog:
- Cada jugador recibe un alias que solo conoce él (en su cliente le llega con
neblla.room.on('alias', ({ alias }) => …)). Es un código largo al azar, nuevo en cada partida. room.jssolo ve alias.m.from(ym.user.id) es el alias. Tus reglas funcionan igual: "el alias X es el lobo" basta para aplicarlas.- Lo que manda el servidor va por alias.
message.to(alias, data)llega solo a ese jugador, sin que el servidor sepa quién es ni los demás lo vean. Va por Neblla, que lo reenvía tal cual: no está cifrado de extremo a extremo. - Lo que un jugador tiene que decir en público ("he muerto"):
await neblla.tools.announce(alias, data, { within: 5000 })(SERVER). Neblla se lo pide solo a ese jugador, y su móvil lo dice en público con su nombre, tras una pausa al azar de hasta 1,5 s para que nadie lo empareje con lo que pasa dentro del servidor. Si no lo dice a tiempo, lo dice Neblla por él y marca la sala. Todos lo reciben enneblla.room.on('announce', ({ user, data, by }) => …), conby: 'player' | 'neblla', y la llamada devuelve{ by }.withinva de 1 a 30 s (5 por defecto) ydatahasta 4 KB. Sin anonimato respondenot_anonymized; con un alias que no está en la sala,not_in_room. - Cuando un jugador nombra a otro en secreto, usa
neblla.tools.seal(userId)(CLIENT):neblla.message.to('server', { mata: neblla.tools.seal('bob') }). El nombre viaja cerrado, y el propio Bob lo cambia por su alias por el camino. El servidor recibe el alias, nunca el nombre. Si Bob no lo hace, Neblla lo traduce (con quórum de dos Fog) y lo marca. - El origen también se oculta: los mensajes a un servidor anónimo rebotan por otros jugadores antes de llegar, así que nadie sabe quién escribió cada uno. Lo hace el SDK.
- Desenmascarar, cuando el juego lo hace público (una votación elimina a Ana y hay que saber si era la loba):
await neblla.tools.unmask(userId)(SERVER) devuelve su alias. Necesita quórum de dos Fog.
Con menos de tres jugadores no se anonimiza: si el mensaje no es tuyo, es del otro.
No-code: herramientas que corren en Neblla
Viven en Neblla, por sala, y se llaman por nombre con await, desde room.js o desde el cliente según sus permisos. Reglas comunes:
- Con nombre: puedes tener varias (una baraja de roles y otra de avatares). Sobreviven al relevo: el siguiente Fog las encuentra por su nombre.
- Los tres Fog hacen la misma llamada y reciben la misma respuesta: Neblla las empareja por su número y el efecto ocurre una vez. Un Fog que arranca con la partida en marcha casa las suyas con las de los que ya corren (arriba, «Arrancar con la partida en marcha»).
- Permisos por papel:
fog(el servidor),player(cualquier usuario de la sala) yowner(el usuario al que se le asignó). Cada herramienta dice qué puede hacer cada uno. - Destapar un secreto necesita dos Fog (quórum).
- Coste: cada llamada cuenta como un mensaje que entra a Neblla (ver publicar.txt). Son pocos bytes y solo en los momentos clave.
Baraja: quién tiene qué
Sacar sin repetir: cartas, roles, el orden de las preguntas.
// room.js — repartir roles sin que el servidor sepa quién tiene cuál
await neblla.tools.deck.create('roles', ['lobo', 'lobo', 'vidente', 'medico', 'aldeano', 'aldeano'],
{ perUser: 1, fromUser: true, secret: true });
await neblla.tools.deck.shuffle('roles'); // no devuelve el orden
neblla.message.send({ fase: 'roba' });
// client.js
const rol = await neblla.tools.deck.draw('roles'); // robar otra vez devuelve la misma carta
neblla.message.send({ visto: true }); // público: "esperando a…"
// room.js, cuando todos han dicho que lo vieron
const roles = await neblla.tools.deck.holders('roles'); // { alias: carta }, de golpe
perUser: cuántas cartas por jugador; repetirdrawdevuelve las mismas (los reintentos por lag no hacen nada).fromUser: true: los jugadores roban directamente, sin pasar por el servidor.secret: true: ni el servidor puede descargar quién tiene qué; soloholders, de golpe, cuando lo pides.type: 'cards'rellena una baraja estándar (jokers,decks).- Pilas con permisos, para juegos de cartas:
stacks: { mano: { fog: 'add|assign', owner: 'draw|muck' }, mesa: { fog: 'add|draw' } }.add(pila, n).assign(userId)da cartas a un jugador;muck(pila)las tira (ya no las enseña nadie, nunca);show(pila, ...userIds)las enseña al final, con quórum de dos Fog, solo de quien las enseñó en público.
Una baraja de cada jugador: duelos con sus cartas
En un juego de cartas coleccionables cada jugador trae su mazo, hecho de sus filas de una tabla que solo escribe Neblla (identidad.txt). Neblla comprueba que son suyas y las baraja en una pila secreta de cada uno:
// room.js
await neblla.tools.deck.create('duelo', [], { fromRows: { table: 'coleccion', min: 10, max: 20 } });
await neblla.tools.deck.deal('duelo', userId, 5); // la mano inicial: solo la ve ese jugador
// client.js
await neblla.tools.deck.bring('duelo', idsElegidos); // tus filas; los demás oyen cuántas, no cuáles
const robadas = await neblla.tools.deck.draw('duelo', 1); // a tu mano; los demás oyen «robó 1»
const { hand, pile, played } = await neblla.tools.deck.hand('duelo');
await neblla.tools.deck.play('duelo', robadas[0].id); // ahora sí la ve toda la sala
const cuantas = await neblla.tools.deck.counts('duelo'); // { usuario: { pile, hand, played } }, para todos
- Traer pide login y solo filas propias de una tabla con
write: 'neblla', una vez, sin repetir y entreminymax(hasta 200). Errores:not_owned(conid),table_not_read_only,repeated_card,invalid_deck_size(conminymax),already_brought,invalid_from_rows. - Nadie más ve una pila: ni el rival ni el servidor.
drawanuncia el número;playenseña la carta (not_in_handsi no la tiene);deal, solo desde el servidor, manda las cartas a ese jugador y a nadie más (eventodealt). Pila vacía:deck_empty. Sin traer:not_brought. - Los eventos llegan por
neblla.tools.on('event', fn)({ op, user, n }, ocardal jugar), también al fichero de servidor: así decide la ronda con la carta que Neblla anuncia, sin ver nunca una pila. Las filas no cambian: la partida no gasta las cartas.
Azar: el dado
Sacar con repetición. Cada tirada la anuncia Neblla a toda la sala: si el servidor tira más veces de las que tocaba, todos lo ven y se marca.
await neblla.tools.random.create('dado', { min: 1, max: 6, decimals: 0, dices: 2 });
const [a, b] = await neblla.tools.random.roll('dado');
Permisos: fog, player, owner (roll, read, assign para dar el dado a un jugador).
El reto del día. Con same: 'daily', la tirada es la misma para todos los jugadores de la app durante el día, y mañana otra. Nadie puede adivinar la de mañana, así que sirve de semilla para un nivel diario igual para todos; con neblla.tools.counter y reset: 'daily' limitas los intentos.
await neblla.tools.random.create('reto', { min: 0, max: 1e9, same: 'daily' });
const semilla = await neblla.tools.random.roll('reto'); // la misma para todos hoy
Sobres: quién sabe qué, y cuándo
Cualquier JSON, cerrado hasta que se cumple una condición. Dos modos:
mode: 'p2p'(gratis): cada usuario cierra su sobre en su dispositivo (la llave no sale de él) y lo manda cerrado a través de Neblla, que apunta la hora a la que llega; Neblla no puede leerlo. Al abrirlo, los usuarios entregan su llave. Un sobre sin llave, o con una llave que no sirve, no cuenta. Para lo que mandan los usuarios: respuestas, apuestas, votos secretos.mode: 'nocode': la llave la tiene Neblla y abre cuando se cumple la condición. Para lo que no puede depender de ningún usuario. Un sobre que solo abrefoges la memoria privada de tu servidor.
await neblla.tools.envelope.create('r1', { mode: 'p2p', perUser: true, owner: 'seal', fog: 'open' });
await neblla.tools.envelope.seal('r1', 'C'); // client.js
const respuestas = await neblla.tools.envelope.open('r1'); // room.js → { userId: { value, time } }
Zonas: quién está con quién
Cada jugador solo recibe a los que tiene cerca, y el servidor no sabe dónde está nadie. Las posiciones exactas viajan P2P solo entre los de la misma zona.
await neblla.tools.zones.create('nave', { type: 'graph', graph: {
cocina: ['cafeteria', 'baño'], cafeteria: ['cocina', 'oficina'],
motores: [{ to: 'reactor', requires: 'impostor' }], // paso solo para quien tiene esa carta
} });
await neblla.tools.zones.create('mundo', { type: '2d', vision: 24, release: 'onEmpty' });
// client.js
const z = await neblla.tools.zones.move('mundo', { x, y }); // → { alias, members, chunks? }
neblla.message.send({ x, y }, { zone: 'mundo' }); // P2P solo a los de tu zona y las vecinas
- Tipos:
graph(salas con un mapa de vecinos),1d(una carrera),2d,3d. - Saltar a una zona que no es vecina se bloquea, en los cuatro tipos.
- El servidor ve el alias de la zona, no cuál es: las coordenadas solo las sabe Neblla. Cuando una zona se vacía, cambia de alias, así que nadie reconstruye el mapa observando.
- En
1d,2dy3d,visiones hasta dónde ve cada jugador: ves tu celda y las vecinas, y tu juego enseña solo lo que entra en el radio. Para mundos grandes, Neblla agrupa las celdas en niveles de zoom (como los chunks de Minecraft; cada nivel junta 16×16 del de abajo) a medida que alguien entra. Cuántos: conplayers(los jugadores que esperas), Neblla los calcula: hasta 16 jugadores, 1 nivel; con 500, 3; sinplayers, 2.levels(de 1 a 6) los fija a mano. Con más de un nivel,movedevuelve tambiénchunks: el alias de cada chunk en el que estás, de arriba abajo, que comparten los que están dentro y cambia cuando se vacía.precision: trueusa celdas de media visión: ves las dos de alrededor (5, 25 o 125 celdas) y el margen del tramposo baja; puedes moverte hasta dos celdas de golpe. Conrelease: 'onEmpty', lo que se vacía se libera: solo existe lo que alguien está usando.
Contador y puntuación
await neblla.tools.counter.create('intentos', { max: 3, per: 'user', reset: 'daily' });
const r = await neblla.tools.counter.add('intentos'); // → { value: 2 }, o { ok: false } si pasa del máximo
await neblla.tools.score.create('nivel3', { max: 15000, maxPerSecond: 500 });
await neblla.tools.score.submit('nivel3', 12000); // por encima: rechazado y flag
counter: intentos, vidas, partidas al día, existencias.reset:'match','daily'o nunca. Por defecto solo el Fog suma y los usuarios leen; para que el cliente sume, créalo conplayer: 'add|read'.score: valida lo que se envía a un ranking desde una sala.maxPerSecondpilla al que hace en diez segundos lo que cuesta un minuto. Un juego para uno, sin sala, tieneneblla.scores(ver identidad.txt): la partida empieza en Neblla y su puntuación se comprueba antes de entrar en el ranking.
Ejemplo: un quiz en clase
La profe proyecta las preguntas; los alumnos contestan desde el móvil; acertar más rápido da más puntos. Las respuestas correctas solo las tiene el dispositivo de la profe, y las de los alumnos van en sobres, así que nadie ve nada antes de tiempo, tampoco el alumno cuyo móvil hace de servidor.
// server/room.js
const PREGUNTA_MS = 20000;
let state = neblla.state.get() || { fase: 'idle', q: null, inicio: 0, puntos: {} };
async function preguntar(q) { // q llega de la profe, sin la correcta
await neblla.tools.envelope.create('r' + q.n, { mode: 'p2p', perUser: true, owner: 'seal', fog: 'open' });
state = { ...state, fase: 'preguntando', q, inicio: neblla.room.now() };
neblla.state.set(state);
setTimeout(cerrar, PREGUNTA_MS);
}
async function cerrar() {
const respuestas = await neblla.tools.envelope.open('r' + state.q.n); // sin llave, no cuenta
state = { ...state, fase: 'corrigiendo', respuestas };
neblla.state.set(state);
}
function puntuar(correcta) {
for (const [id, r] of Object.entries(state.respuestas)) {
if (r.value !== correcta) continue;
const rapidez = 1 - (r.time - state.inicio) / PREGUNTA_MS / 2; // hora común del sobre
state.puntos[id] = (state.puntos[id] || 0) + Math.round(1000 * rapidez);
}
state = { ...state, fase: 'revelando', correcta };
neblla.state.set(state);
}
neblla.message.on(m => {
if (m.to !== 'server' || !m.user?.isPresenter) return;
if (m.data.pregunta) preguntar(m.data.pregunta);
if (m.data.correcta !== undefined && state.fase === 'corrigiendo') puntuar(m.data.correcta);
});
// Si los tres Fog se fueran a la vez, el siguiente arranca con neblla.state.get():
if (state.fase === 'preguntando') setTimeout(cerrar, Math.max(0, state.inicio + PREGUNTA_MS - neblla.room.now()));
// client.js — alumno
const contestar = opcion => neblla.tools.envelope.seal('r' + neblla.state.get().q.n, opcion);
// client.js — pantalla de la profe: las correctas viven solo aquí
neblla.state.on(s => {
if (s.fase === 'corrigiendo') neblla.message.to('server', { correcta: correctas[s.q.n] });
}, { final: true });
La profe manda la correcta solo cuando ya están todas las llaves, así que nadie la ve antes de entregar la suya. Si su pantalla se cae, la partida espera y sigue al volver: los puntos están a salvo en los Fog.
Testigos y flags
Es el módulo anticheat: se enciende con neblla_update_app y features: { anticheat: true }, y juzga Neblla, nunca un jugador. Cada cliente dice con anticheat.confirm lo que ha visto de un evento crítico, y Neblla comprueba que todos han visto lo mismo.
neblla.anticheat.confirm(eventName, value) (CLIENT) — Neblla recoge los reportes durante unos 3 segundos y comprueba:
- ¿todos los valores son estrictamente iguales (
===)? - ¿reportó al menos el 75% de la sala?
Si las dos respuestas son sí, silencio. Si alguna falla, Neblla marca la sala, igual que un neblla.anticheat.flag().
El value tiene que ser primitivo (string, number finito, boolean). No vale objeto, array, null, undefined ni NaN — cada afirmación debe ser un dato inequívoco que la sala pueda votar.
// client.js
neblla.state.on(state => {
if (state.ganador) neblla.anticheat.confirm('ganador-final', state.ganador);
}, { final: true });
// ❌ Mal — pasa un objeto:
neblla.anticheat.confirm('estado', { tablero, turno }); // el SDK lo rechaza
// ✅ Bien — afirmaciones inequívocas:
neblla.anticheat.confirm('ganador', userId);
neblla.anticheat.confirm('score-final', 1250);
neblla.anticheat.flag(reason?) (SERVER) — marca la sala como sospechosa. Lo llaman solos los testigos que fallan, las horas imposibles, las tiradas de más y los quórums incompletos; también puedes llamarlo tú desde el archivo de servidor. Un cliente no puede: un jugador marcaría a todos los de su sala. Una vez marcada, los siguientes anticheat.flag() en la misma sala son no-ops (anti-troll). El contador flags sube en todos los usuarios de la sala (logueados lo persisten, anónimos en sesión) y se ve en el backoffice, donde se banea sin redeploy. Evento room.on('flagged') { room, reason, flaggerId, userCount }, una vez por sala.
Protecciones siempre activas
- Datos privados aislados.
neblla.db.dataes por usuario y vive en su sesión. Nunca llega a otros clientes ni a los Fog. Lo público va porpublicInfo. - Pagos mediados.
neblla.shop.buy()abre un checkout de Stripe alojado por Neblla.room.jssolo ve el resultado, nunca datos de tarjeta ni la capacidad de falsificar compras. - Backoffice con bandera. El dashboard muestra datos, mensajes, actividad y
flags. Desde ahí se banea.
Módulos: pagas por lo que usas
La base va siempre y es gratis: elegir los tres Fog, el relevo, el estado P2P y la hora común. El resto se enciende solo si lo usas:
Pieza Se enciende con Coste
────────────────────────────────────── ────────────────────────────────── ─────────────────────────────
Orden y rollback fog: { window } (por defecto, 1 s) en los dispositivos; sin cuota
Vigilancia de horas imposibles fog: { latencyWatch: true } poca cuota
Testigos features: { anticheat: true } por cada confirm
Anonimato neblla.tools.anonymize() por partida, y las traducciones que hace Neblla
Herramientas No-code usarlas por llamada (ver publicar.txt)
Un quiz usa sobres y testigos; un juego de acción, una ventana de 200 ms; un juego de dibujo entre amigos, solo la base.
Server: tu room.js en un servidor de verdad
El mismo room.js, con el mismo SDK de servidor (neblla.message, neblla.state, neblla.room, neblla.tools…), corriendo en un servidor que no es de ningún usuario. Los usuarios siguen conectados a Neblla como siempre: tu código de cliente no cambia. Dos sitios:
1. En la nube de Neblla (serverMode: 'cloud'). Cada app tiene una caja de 128 MB en Cloudflare (Workers y Durable Objects), cerca de los usuarios, con todas sus salas dentro. Para texto y lógica (de juego o de app) sobra. Dormida no cuesta nada; se despierta sola cuando una sala necesita su servidor y vuelve a dormirse cuando no le queda ninguna sala abierta. Despierta cuesta unos 100 créditos por hora (unas diez horas al día con la cuota gratis de toda la cuenta, unas veinte con un proyecto Pro), y cada mensaje que procesa, 5 unidades (ver publicar.txt). Una sala abierta la mantiene despierta aunque nadie mande nada, así que cierra las salas que ya no se usan. Las reglas de la caja:
- solo el SDK de servidor y JavaScript; sin ficheros, sin npm, sin
process; - para llamar fuera,
neblla.api.callcon tus secretos; - todas las salas de la app comparten la caja y sus 128 MB;
- una sala pesada puede ir en su propia caja, al mismo precio:
rooms.book({ config, dedicated: true }); - guardar un fichero de
server/redespliega la caja (la respuesta traecloud: { status: 'deployed', script, hash, files }): las salas abiertas esperan un instante y siguen con la versión nueva.
El día compartido de la nube. Hoy la nube de Neblla corre en el plan gratis de Cloudflare, y ese plan da un tiempo de caja despierta al día (unas 28 horas) para todas las apps juntas. Para que un bucle o una sala que no se cierra en una app no gaste el día de las demás, cada app tiene como mucho 4 horas de caja despierta al día (su pro y su pre juntos; Neblla cuenta el tiempo que la caja está conectada, y la caja se desconecta unos segundos después de quedarse sin salas). La cuota gratis de la cuenta (1.000 créditos ≈ 10 horas al día, para todas sus apps) puede llegar si una sola app la gasta en su caja. Al pasar de su parte, las salas de esa app dejan de ir a la nube hasta las 00:00 UTC; las demás apps siguen. Y si un día se gasta el tiempo de toda la nube, pasa lo mismo con todas hasta las 00:00 UTC. En los dos casos la sala sigue en Fog con el mismo room.js, como cuando tu servidor se cae (la red de seguridad, abajo); con server: { fallback: null } espera a las 00:00 UTC y los usuarios no ejecutan tu room.js. Te llega un aviso de la app: cloud_app_limit (tu parte) o cloud_day_full (la de todos), que dice hasta cuándo; terminan solos a las 00:00 UTC, y entonces las salas que esperaban vuelven a su caja.
La nube llena. Si al pasar a cloud (o al pasar a pro una app cuya caja aún no existe) la nube no tiene sitio para una caja más, la respuesta trae cloud: { status: 'error', error: 'cloud_full', message } y el aviso cloud_full. Neblla está avisado y hace sitio; mientras, las salas siguen en Fog (o esperan, con fallback: null). Vuelve a intentarlo más tarde (guardar un fichero de server/ o volver a elegir cloud) o usa tu propio servidor.
2. En tu propio servidor (BYOS, serverMode: 'own'). Tu room.js corre donde quieras —un VPS, AWS, tu ordenador, tu cuenta de Cloudflare— con el paquete @neblla/server, que llama él a Neblla con la clave de servidor de la app (neblla_server_key, solo en tu servidor). No abres puertos ni necesitas una URL pública:
npx @neblla/server --app <appId> --key $NEBLLA_SERVER_KEY ./server
El paquete trae los tipos del neblla de servidor: con /// <reference types="@neblla/server/room" /> en la primera línea de room.js, el editor autocompleta y avisa de lo que un servidor no tiene. Y si prefieres arrancarlo desde tu propio código: import { startServer } from '@neblla/server'.
Tu room.js corre en una caja aislada de Node con el mismo SDK de servidor que en la nube: sin require, fs ni process, y fetch solo llega a Neblla; para llamar fuera, neblla.api.call con tus secretos. Lo que ganas es el sitio: tu máquina o tu cuenta, sin las horas de la caja de Neblla. Solo cuesta el reenvío de los mensajes entre los usuarios y tu servidor, como cualquier mensaje que pasa por Neblla. Con tu cuenta de Cloudflare, el dev pega el token en el panel (Conexiones, tema «Tu servidor»: neblla_projects con cloudflare_setup da el enlace, panelUrl; nunca en el chat), que lo comprueba y lo guarda cifrado como conexión del proyecto (la de la cuenta si la app está en Explorer); después neblla_byos({ appId, action: 'set', provider: 'cloudflare' }) pone la app en own y despliega la caja en tu cuenta (en tu workers.dev); guardar un fichero de server/ la redespliega, y Cloudflare te lo factura a ti. El token de Cloudflare es uno para todas las cajas de la cuenta, o del proyecto que ponga el suyo, y es el mismo de los demás temas (ficheros, dominio, correo). Si la app se mueve a un proyecto con otro Cloudflare, su caja se redespliega allí. status nunca devuelve el token y remove quita el Worker (el token sigue conectado para las demás apps). Errores: cloudflare_not_connected (no hay token en el proyecto ni en la cuenta: viene con panelUrl) y workers_subdomain_missing (activa workers.dev una vez en tu cuenta).
Mientras tu servidor no está, tu proceso recibe needServer para conectarse, y la sala sigue en Fog (la red de seguridad, abajo); si la apagas, la sala espera y los usuarios no ejecutan tu room.js. Tu servidor no ocupa plaza en la sala, no aparece en su lista y nunca es presentador.
Red de seguridad incluida, encendida por defecto. Si tu servidor se cae la sala pasa sola a Fog con el mismo room.js y sigue, sin que los usuarios lo noten; cuando tu servidor vuelve, la sala vuelve a él. Si prefieres que la sala espere a tu servidor, apágala con neblla_update_app({ server: { fallback: null } }).
Escribe room.js con las reglas de Fog (azar de neblla.tools, hora de neblla.room.now()) aunque lo pienses correr en Server: así puede pasar de un sitio a otro, y el relevo a Fog funciona.
La hora común, el orden de los mensajes y el rollback funcionan igual en los tres sitios: van en el SDK de servidor, no en el transporte.
Dos cosas que conviene saber
- Con dos jugadores no hay quórum de dos Fog, y el servidor corre en el dispositivo del rival. Lo que el rival no debe ver va en sobres y en barajas con
secret: true, nunca en el estado; y si mirar importa de verdad, Server. - Lo que ningún sistema evita: dos amigos que se cuentan las cartas por teléfono, o un bot que juega demasiado bien. Eso no rompe ninguna regla del servidor.