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:

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:

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:

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

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.

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:

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:

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

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

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:


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

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

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:

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

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:

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