Capítulo referencia de la guía Neblla — todos los métodos, eventos y propiedades del SDK, módulo a módulo, y la plantilla completa. El contrato base está en core.txt; el criterio de cuándo usar Neblla, en empezar.txt.

API de referencia

Nada cuelga de la raíz de neblla: todo va en un módulo (neblla.message, neblla.room, neblla.shop…), y aquí cada sección es un módulo, en el mismo orden que la referencia del panel. Cada método indica dónde se puede llamar: (CLIENT), (SERVER) o (BOTH). En los ficheros de server/, neblla trae los mismos módulos con lo que tiene un servidor: sin neblla.auth, sin neblla.room.me, sin DOM.

Eventos: cada módulo escucha lo suyo. neblla.<módulo>.on(nombre, callback) / neblla.<módulo>.off(nombre, callback) (BOTH): neblla.room.on('joined', cb), neblla.shop.on('purchase', cb). .off(nombre) sin callback quita todos los de ese nombre; .off(), todos los del módulo. Un nombre que el módulo no tiene lanza unknown_event. Mensajes y estado no llevan nombre: neblla.message.on(cb) y neblla.state.on(cb). En la raíz de neblla no hay .on ni .off.

neblla.message — mensajes

neblla.message.send(data) (BOTH) — a TODOS en la sala. Desde CLIENT, también te llega a ti mismo con fromMe: true (broadcast entre pares). Desde SERVER llega a todos los clientes con from: 'server' — es la voz autoritaria de la sala, no hay un "yo" al que reflejarlo.

neblla.message.to(target, data) (BOTH) — a una parte de la sala. target es el id de un miembro, una lista de ids, un papel ('presenter', 'master', 'board') o un grupo (sus miembros que están en la sala); desde CLIENT, también 'server'; desde SERVER, también el alias de un jugador en una partida anónima. Un id que no está en la sala se toma como nombre de grupo.

neblla.message.on(callback) / neblla.message.off(callback) (BOTH) — todos los mensajes, un solo evento: { from, to, data, user, time }. from es el id de quien lo manda, o 'server'; to es null (a toda la sala), un id, una lista, 'server', 'presenter', un papel o un grupo, tal como se mandó. Trae además fromMe y p2p. En el cliente llegan los de la sala, los de server/ (from === 'server') y, en el dispositivo del presentador, los que van a 'presenter'. En server/ llegan los de message.to('server', …) (to === 'server', solo a room.js, o a lobby.js / gathering.js según el estado de la sala) y los que un cliente manda a toda la sala.

neblla.state — el estado de la sala

neblla.state.set(state) (SERVER) — emite el nuevo estado autoritario. Todos los dispositivos de la sala lo reciben en neblla.state.on y neblla.state.get() queda actualizado en cada uno. Va P2P entre los dispositivos y así no gasta cuota; si Neblla tiene que reenviarlo a alguno sin P2P, sus bytes cuentan una vez para la cuota del día. Es público: lo secreto va en un sobre de No-code (neblla.tools.envelope, ver servidor.txt).

neblla.state.merge(obj, { mode }?) (SERVER) — funde obj con el estado actual y lo emite como set, con los modos de neblla.db.save: 'over' (por defecto), 'under', 'delete'.

neblla.state.get() (BOTH) — el último estado emitido por el server. Útil en peers de respaldo al promocionarse — room.js arranca con el último valor ya disponible.

neblla.state.on(callback) / neblla.state.off(callback) (BOTH) — el server emitió un nuevo estado con state.set(); el payload ES el estado. Todos los dispositivos lo reciben (incluidos los backups que tienen room.js cargado, listos para promocionarse). Se escucha al momento (neblla.state.on(cb), puede rebobinarse) o solo lo definitivo (neblla.state.on(cb, { final: true })).

neblla.room — la sala en la que estás

Ciclo de vida

Las salas nuevas empiezan en estado gathering (se entra con neblla.rooms.join, ver neblla.rooms) — neblla.room.start() las pasa a running y arranca room.js.

neblla.room.leave() (CLIENT) — sale de la sala actual y vuelve al lobby. Dispara room.on('left').

neblla.room.start(name?) (CLIENT o server/gathering.js) — desde el cliente solo puede llamarlo el presentador; además puede llamarlo server/gathering.js para arrancar sola la sala. Pasa la sala de gathering a running. El SDK elige al server peer y arranca room.js. Con argumento name, hace rooms.join + room.start en una sola operación.

neblla.room.persist({ name, type, whitelist?, ttlInactivity? }) (CLIENT) — sube una sala recién reservada de ephemeral a public o private. ttlInactivity (días enteros 0–3650, 0 = nunca): cuánto aguanta esta sala sin actividad antes de borrarse, en lugar del plazo de su tipo (salas.txt); fuera de rango, invalid_ttl. Una sala public no puede pasar del plazo de la app para las públicas (ni ser 0, salvo que ese plazo sea 0): si no, invalid_ttl con min: 1 y max = el plazo de la app. Llamar 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 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 neblla.social.inviteToRoom.

neblla.room.setData(data, { now? }) (CLIENT) — cualquier miembro, o solo sus moderadores si la sala los tiene (si no, room.on('dataError') con not_moderator). Fusiona data con neblla.room.data, clave a clave en el primer nivel (las claves que no mandas se quedan; para vaciar una, mándala con null) (persistente en salas public/private: Neblla la tiene en memoria y la escribe en los momentos clave; { now: true } la escribe ya, una vez cada 10 s por sala como mucho). Todos los miembros de la sala 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').

neblla.room.now() (BOTH) — la hora común de la sala, igual en todos los dispositivos. En server/, úsala en vez de Date.now().

Salas privadas, membresía

neblla.room.members(name?) (CLIENT) — solo private. Devuelve { members[], invites[] }.

neblla.room.kick(targetUserId) (CLIENT) — expulsa de la sala privada actual. Solo owner o moderador.

neblla.room.abandon(roomName?) (CLIENT) — renuncia a la membresía de una sala privada. Distinto de room.leave: leave = "me desconecto ahora", abandon = "quítame de la whitelist".

neblla.room.promote(targetUserId) / neblla.room.demote(targetUserId) (CLIENT) — gestión de owners. Solo owner. No puedes auto-demotearte si eres el último.

Las invitaciones se responden con neblla.rooms.acceptInvite / rejectInvite.

Roles de sala (master y board)

Dos roles opcionales de cara al usuario, no de servidor: nada de esto cambia quién ejecuta los ficheros de server/, y tomarlos no arranca la sala. Requieren la función multiplayer y no existen en el lobby (not_in_room). Toman un userId por app, así que un usuario anónimo también puede tenerlos.

neblla.room.transferMaster(targetUserId) (CLIENT) — pasa el rol de *master* a otro miembro de la sala. Lo puede llamar el master actual; si todavía no hay master, también el presentador (fallback para que una sala no se quede sin). En cuanto hay master, el presentador deja de poder: si no, el rol sería recuperable a voluntad. En una sala con contraseña de master, el presentador nunca puede (master_protected). Errores: not_authorized, master_protected, target_not_found, not_in_room.

neblla.room.claimMaster(password) (CLIENT) — toma el rol de *master* con la contraseña con la que se reservó la sala (neblla.rooms.book({ masterPassword }), 1–128 caracteres). Errores: wrong_password, too_many_attempts (con retryInMs), no_master_password, not_in_room.

neblla.room.setBoard(targetUserId | null) (CLIENT) — marca a un miembro como *board*: el dispositivo de exhibición (la pantalla compartida, el tablero de la mesa). Misma autoridad que transferMaster. Es exclusivo: dárselo a uno se lo quita al anterior. Con null se limpia.

Eventos: room.on('master') / room.on('board') (CLIENT) — { user, isMe, room }, con user: null cuando el rol queda vacante.

Propiedades: neblla.room.master y neblla.room.board (el user, o null); y en cada user, isMaster / isBoard. neblla.room.me.isMaster es el atajo habitual para ramificar la UI.


// El presentador nombra la pantalla grande y cede la dirección
neblla.room.setBoard(tabletUserId);
neblla.room.transferMaster(otroJugadorId);

// Repinta en cada cambio de rol y ramifica por los roles ACTUALES, nunca por `isMe`:
// en una vacante (user: null) ningún dispositivo tiene isMe true, así que un isMe
// pelado deja a nadie pintando la mesa. Ver la regla de rama en empezar.txt.
let enLobby = true;   // ningún evento lo lleva salvo room.on('joined'), así que llévalo tú
const esMesa = () => !enLobby && (neblla.room.me.isBoard || (!neblla.room.board && neblla.room.me.isPresenter));
const pintar = () => esMesa() ? renderVistaDeMesa() : renderVistaDeMano();
neblla.room.on('joined', (e) => { enLobby = e.isLobby; pintar(); });   // el valor cambia AQUI tambien
neblla.room.on('board', () => esMesa() ? renderVistaDeMesa() : renderVistaDeMano());
neblla.room.on('presenter', () => esMesa() ? renderVistaDeMesa() : renderVistaDeMano());

Historial de chat (persistentes)

neblla.room.history({ name?, before?, limit? }) (CLIENT) — paginación de chat persistido. Newest-first. Default limit 50, max 200. Los mensajes borrados vienen con body: null y deleted: true.

neblla.room.deleteMessage(messageId, name?) (CLIENT) — soft-delete. Autor, owner o moderador. Emite room.on('messageDeleted') a todos.

Ficheros P2P

neblla.room.sendFile(file, opts?) (CLIENT) — transferencia P2P (WebRTC) de un fichero a los demás peers de la sala con los que ya hay canal P2P abierto — no abre uno nuevo. Sin to, a los que no lo tienen se los salta (resuelve con { fileId, sent }, y sent dice a quién llegó; si no hay ninguno, rechaza con no_p2p_peers). Con to, si alguno de los nombrados no tiene canal, rechaza entero con peer_not_p2p y la lista en peers. Nunca cae al relay del servidor. Límites: 100 MB por fichero (más grande rechaza con file_too_large y el máximo en max) y hasta 4 transferencias a la vez de un mismo emisor hacia cada receptor — varios ficheros, mejor de uno en uno. Emite room.on('fileStart') / ('fileProgress') / ('file') para que los receptores muestren progreso y lean el blob final. No pasa por Neblla — directo entre dispositivos. El emisor manda el SHA-256 del fichero; el receptor lo verifica sobre el blob reensamblado antes de emitir 'file'. Si no cuadra (transferencia truncada/corrupta) emite 'fileError' en vez de 'file' — nunca entrega un blob roto en silencio.

Propiedades

neblla.room.name (BOTH) El nombre de la sala actual.
neblla.room.type (BOTH) ephemeral, public o private.
neblla.room.phase (BOTH) gathering o running.
neblla.room.users (BOTH) Array con los users de la sala.
neblla.room.config (BOTH) El JSON libre que pasaste a rooms.book (así llegan esos ajustes a gathering.js / room.js).
neblla.room.data (BOTH) El room.data persistente de una sala public/private.
neblla.room.me (CLIENT) Tú en la sala: el mismo objeto que sale en neblla.room.users. Tú en la app (también como invitado) es neblla.auth.me.
neblla.room.presenter (BOTH) El user presentador actual.
neblla.room.master / neblla.room.board (BOTH) El user con ese rol, o null.
neblla.room.masterProtected (CLIENT) true si la sala se reservó con masterPassword; la contraseña nunca viaja.

Tu alias en una partida anónima (neblla.tools.anonymize(), ver servidor.txt) te llega solo a ti en neblla.room.on('alias', ({ alias }) => …) y queda en neblla.room.me.alias.

No existe user.isServer — el servidor es opaco a propósito.

El objeto user expone { id, publicInfo, isPresenter, isMaster, isBoard, isMe, purchases }. publicInfo es siempre un objeto — {} hasta que el usuario llama a neblla.db.setPublicInfo(), nunca undefined, así que u.publicInfo.name es seguro.

Eventos: neblla.room.on(nombre, cb)

'joined' (CLIENT) — entraste a una sala (también al recargar, con isReconnect: true). { room, isLobby }

'started' (BOTH) — la sala pasó a running. NO significa que el presentador haya pulsado nada: pudo llamarlo el presentador o server/gathering.js, que es el camino normal con matchmaking.mode: "nocode". No uses este evento para atribuir el arranque a una persona. room.js arranca en el server peer. isMe true si tú eres el peer. { room, isMe }

'server' (BOTH) — cambió el Fog que manda (el anterior se fue). Uno de los que le seguían ya tenía el estado y los timers, y sigue sin cortes. { room, isMe }

'flagged' (CLIENT) — sala marcada como sospechosa. Una vez por sala (anti-troll). { room, reason, flaggerId, userCount }

'left' (CLIENT) — { room, user }

'userJoined' (BOTH) — { user, room, isLobby, alreadyPresent }. alreadyPresent es true para los que ya estaban cuando tú entraste (snapshot inicial).

'userLeft' (BOTH) — { user, room }

'presenter' (CLIENT) — presentador cambió. { user, isMe, room, isLobby }.

'publicInfo' (CLIENT) — { user, publicInfo }.

'data' (BOTH) — alguien llamó room.setData(...). { room, data }.
'dataError' (CLIENT) — tu room.setData(...) fue rechazado (size cap u otro).

'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: null si 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')).
'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.

'fileStart' / 'fileProgress' / 'file' / 'fileError' (CLIENT) — transferencia P2P de room.sendFile. 'file' trae el blob completo, ya verificado por SHA-256. 'fileError' ({ reason: 'checksum_mismatch' | 'size_mismatch', ... }) salta cuando el blob reensamblado no cuadra con lo declarado por el emisor.

fileProgress es asimétrico: al receptor le llega { received, total, fromMe: false } y al emisor { sent, total, fromMe: true }. Es el mismo concepto con campos distintos, así que una barra de progreso tiene que leer los dos — const done = event.received ?? event.sent; — o se quedará a cero en una de las dos puntas.

Salas persistentes (CLIENT): 'userInvited' — owner añadió a invitados pendientes. 'inviteAccepted' / 'inviteRejected'. 'userKicked' — quitado de la whitelist por owner/moderador. 'userAbandoned' — se auto-quitó. 'ownerAdded' / 'ownerRemoved'.

Chat persistido (CLIENT): 'messageSaved' — eco con el id necesario para borrar, { room, id, from, time, pending? } (pending: true: la línea espera para escribirse, base de datos llena o sin respuesta; se escribe sola). 'messageDeleted' — render tombstone. 'messageDropped' — solo al autor, mensaje no persistido (too_large o rate_limited).

Partidas anónimas y avisos: 'alias' (CLIENT) — { alias, room }, tu alias para la partida. 'announce' — lo que un jugador dice en público con neblla.tools.announce (ver servidor.txt). 'hook' — un webhook entrante o una Automation entregó datos a la sala (ver core.txt).

Los ficheros de server/

neblla.server.on('start' | 'stop', callback) (SERVER) — { file }. 'start' salta cuando el fichero (lobby.js, gathering.js o room.js) ya ejecutó su código de arriba; 'stop', antes de pararlo, para limpiar timers o mandar lo último. En el cliente no existe neblla.server.

neblla.rooms — todas las salas

neblla.rooms.book({ config?, length? }) (BOTH) — reserva un nombre de sala único. Devuelve Promise<name>. length (3–12, por defecto 5) fija cuántos caracteres tiene el código, p. ej. { length: 3 } si tu UI tiene 3 casillas. No entra en la sala — llama a neblla.rooms.join(name) después. El config opcional se persiste en la sala futura y se expone como neblla.room.config dentro de gathering.js y room.js.

neblla.rooms.join(roomName) (CLIENT) — entra en una sala. Dispara room.on('joined'). Las salas nuevas empiezan en estado gathering — el presentador llama a neblla.room.start() para pasar a running y arrancar room.js.

neblla.rooms.list({ type? }) (CLIENT) — lista salas persistentes accesibles al usuario. Cada entrada: { name, type, creator, memberCount, activeUsers, lastActivityAt }.

neblla.rooms.get(name?) (CLIENT) — inspecciona una sala por nombre (por defecto, la actual).

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.

neblla.rooms.acceptInvite(roomName) / neblla.rooms.rejectInvite(roomName) (CLIENT) — responde a una invitación (room.on('userInvited')).

neblla.rooms.gathering (CLIENT, lobby) — array de salas en formación visibles desde el lobby, cada una con { name, config, users }. Se mantiene sincronizado con rooms.on('gathering').

neblla.rooms.follow(name?) / neblla.rooms.unfollow(name?) (CLIENT) — seguir o dejar de seguir una sala persistente de la que eres miembro (por defecto, la actual); follow devuelve su entrada. Errores: login_required, not_whitelisted, room_not_found, not_followable, follow_limit_reached (200). Detalle en salas.txt, «Seguir una sala».

neblla.rooms.followed() (CLIENT) — pide al servidor las salas que sigues y actualiza neblla.rooms.following. neblla.rooms.markRead(name?) — deja a 0 sus nuevos sin entrar (not_following).

neblla.rooms.direct(friend) (CLIENT) — la sala privada de los dos con un amigo (friend o friend.id, de neblla.social.friends): la crea la primera vez, después devuelve la misma; no se borra por inactividad y los dos la siguen. Devuelve { name, created }; entra con rooms.join(name). Errores: login_required, scope_missing, not_friends, self, invalid_friend, type_not_allowed_by_policy, los topes de salas privadas. Receta en salas.txt.

neblla.rooms.following (CLIENT) — las salas que sigues, [{ name, type, unread, lastMessageAt, lastReadAt, followedAt }], la más reciente primero. Llega solo al hacer login y se mantiene con rooms.on('following'); [] sin login.

Eventos: neblla.rooms.on(nombre, cb)

'gathering' (CLIENT, lobby) — refresh del listado de salas en formación. { rooms: [{ name, config, users }] }. Reflejado en neblla.rooms.gathering.

'joinError' (CLIENT) — fallo entrando a una sala (code: 'room_full' si ya tiene matchmaking.maxPlayers usuarios, 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, whitelist, kick, etc).

'invite' (CLIENT) — { from, roomName, appId, type }.
'kicked' (CLIENT) — push directo al expulsado.
'deleted' (CLIENT) — { room, reason, by? }.
'typeChanged' (CLIENT).
'followedMessage' (CLIENT) — alguien escribió en una sala que sigues mientras no estabas dentro. { room, unread, from: { id, publicInfo }, time }.
'following' (CLIENT) — cambió neblla.rooms.following. { rooms }.

neblla.auth — quién es el usuario

neblla.auth.login(opts?) (CLIENT) — SSO. opts.scopes lista los permisos pedidos: email, friends, discoverable. Llámalo dentro del clic del usuario: la ventana de login se abre en ese instante (así el navegador no la bloquea) y, si el usuario ya tenía login, se cierra sola. Con { token }, hace login con tu propio login: el token de un solo uso que tu servidor pidió a Neblla (ver identidad.txt).

neblla.auth.logout() (CLIENT) — hace logout de esta app. No hace logout del SSO de Neblla.

neblla.auth.idToken() (CLIENT) → { token, expiresIn }, JWT firmado por Neblla para que tu servidor sepa quién es el usuario (ver identidad.txt).

neblla.auth.requestErasure() (CLIENT) — abre la ventana de Neblla donde el usuario pide que borres lo que dio a tu app (ver identidad.txt).

neblla.auth.me (CLIENT) — tú en la app, también como invitado: { id, publicInfo, grantedScopes, loggedIn }. Tu papel en la sala está en neblla.room.me.

Eventos: neblla.auth.on(nombre, cb)

'login' (CLIENT) — { type, data, grantedScopes, appUserId, email? } (appUserId: el id del usuario en tu app, el mismo en todos sus dispositivos).
'logout' (CLIENT) — {}.
'expired' (CLIENT) — la sesión caducó. {}.
'error' (CLIENT) — { code: 'popup_blocked', url }: el navegador bloqueó la ventana de login. Pinta un botón que abra url (un clic es un gesto nuevo y el navegador lo deja). { code: 'store_unavailable' }: la base de datos propia de la app (BYODB) no responde y el login no llega a hacerse, sea cual sea el camino de entrada; llega también por neblla.app.on('error') (ver abajo), y neblla.auth.login({ token }) / ({ jwt }) lo rechazan con ese código. El usuario sigue como invitado.
'switch' / 'tokenExpired' (CLIENT) — cambio de cuenta y token de tu propio login caducado (ver identidad.txt).

neblla.db — lo que guarda el usuario

neblla.db.save(json, opts?) (CLIENT) — persiste datos. Local o servidor según login. Por defecto solo toca lo que mandas (mode: 'over') y se cobra solo eso. opts.mode = 'over' | 'under' | 'full' | 'delete'; opts.empty = qué cuenta como vacío ('null', 'emptyArray', 'emptyString'); opts.arrays = 'replace' | 'push' | 'pull' | 'complete' (o por campo, con unique y sort); opts.crdt = gana el más nuevo por campo entre dispositivos; opts.at = la hora del cambio (Date o ms), para mandar tarde lo que se hizo sin conexión: activa crdt, solo con 'over'; opts.table = una fila en una tabla tuya (ver identidad.txt); opts.now = mandarlo ya. Con login, el guardado espera en el navegador y sale en los momentos clave (cerrar o salir de la página, cambiar de pestaña, salir de una sala, entrar desde otro sitio, now, cada 10 minutos como mucho); los seguidos en over se juntan. Devuelve una promesa: al momento { data, mode, pending, onDevice: true }; con now, cuando el servidor lo guardó (onDevice: false), o falla con code (save_too_large, invalid_save, invalid_at, login_required, quota_exceeded, save_loop; con onDevice: true si sigue esperando). Cuando un lote llega, neblla.db.data se actualiza y se dispara db.on('merged').

neblla.db.data (CLIENT) — tus saves.

neblla.db.waiting (CLIENT) — cuántos guardados de este usuario esperan en este dispositivo a salir (ver identidad.txt).

neblla.db.pending (CLIENT) — true mientras tus guardados esperan en la cola porque la base de datos de la app está llena (ver publicar.txt); tus datos ya los llevan encima y se escriben cuando haya sitio. false en cuanto uno llega a la base de datos. Con la base de datos llena, una fila guardada con { table } vuelve con pending: true y su id, y db.delete() con { removed: true, pending: true }.

neblla.db.setPublicInfo(info) (CLIENT) — tu perfil público (visible a toda la sala). Nombres, avatares, colores.

neblla.db.load({ table, group?, where?, orderBy?, limit? }) / neblla.db.delete({ table, group?, id }) (CLIENT, login) — tus filas, o las de tu grupo según tu papel; se escriben con neblla.db.save(data, { table, group?, id? }) (ver identidad.txt, "Tablas"). neblla.db.watch(…), shareLink, openLink, links, revokeLink, pendingLink (CLIENT): avisos en directo y enlaces de solo lectura (openLink sin login). En server/, neblla.db.load, save y delete con { table } (SERVER); en save, { user } dice de qué usuario de la sala es la fila.

neblla.db.dataOf(user) / neblla.db.loadOf(user, { table, where?, orderBy?, limit?, offset? }) (CLIENT) — lo de otro usuario que tu app deja ver (neblla_update_app({ visible })): la parte de su db.data marcada para quien lee (un objeto, {} si no le toca nada) y sus filas de una tabla marcada, sin dueño. user: su id en la app, su id en la sala, el id de un amigo de neblla.social.friends, o el objeto (author de la comunidad, un usuario de la sala, un amigo). Errores: not_visible (la app no marca nada, o esa tabla), not_friends (tabla solo para amigos), user_not_found, unknown_table. Ver identidad.txt, «Lo que ven otros usuarios».

neblla.db.offer({ to, give, get, expires? }) / neblla.db.offers({ status?, limit? }) / neblla.db.acceptOffer(id) / neblla.db.declineOffer(id) / neblla.db.cancelOffer(id) (CLIENT, login) — intercambios entre usuarios (neblla_update_app({ trade })): ofrecer algo suyo (give) por algo del otro (get); cada cosa es { table, id } (una fila suya) o { product, amount } (unidades de lo comprado). offer devuelve la oferta ({ id, from, to, give, get, status: 'pending', expiresAt, mine: 'sent' }); offers las suyas, pendientes por defecto (status: 'all' o uno: done, declined, cancelled, expired, failed); acceptOffer lo mueve todo de una vez o nada, y devuelve la oferta done. neblla.db.on('offer', ({ action, offer }) => …): new, done, declined, cancelled, failed, en los dispositivos de los dos. Ver identidad.txt, «Intercambios entre usuarios».

Eventos: neblla.db.on(nombre, cb)

'merged' (CLIENT) — el servidor resolvió un db.save(). { data, mode, pending } — data es el doc resultante (ya copiado a neblla.db.data); pending: true si la base de datos está llena y el guardado espera en la cola (no es un error: el juego sigue con él).
'error' (CLIENT) — un db.save() no se guardó. { reason }, por ejemplo invalid_save si data no es un objeto, invalid_at si at no es una hora o el modo no es 'over', save_too_large (con bytes y max), save_loop (más de 1000 esperando en el dispositivo), o el error de una fila (too_many_rows…); los que el servidor no aceptará nunca traen save ({ k: 'doc' } o { k: 'row' | 'del', table, id }) y se descartan.

neblla.scores — puntuaciones de un juego para uno

neblla.scores.start(ranking) (CLIENT, login) — empieza una partida en Neblla: { run, seed, startedAt, until }. Cierra la que el usuario tuviera abierta en ese ranking. Rechaza con runs_per_day (lleva max), unknown_board, login_required.

neblla.scores.submit(partida, valor, { data? }) (CLIENT, login) — manda la puntuación de esa partida (lo que dio start, o su run), una vez. Neblla la comprueba con los límites del ranking y, si lo declaraste, con tu propia comprobación (check, que recibe data). { ok: true, value, best, newBest, place } si entra; { ok: false, reason } si no: below_min, over_max, too_soon, too_fast, check_refused (marcan al usuario), invalid_value, run_expired, run_used, run_closed, run_not_found, check_unavailable (con retry: true: se puede repetir).

neblla.scores.top(ranking, { period?, limit?, friends? }) (CLIENT) — los mejores: period 'all' (por defecto) o 'day' (hoy, UTC); limit hasta 100; friends: true, solo el usuario y sus amigos que dieron a la app el permiso de amigos, con el puesto entre ellos (pide login y el permiso de amigos: login_required, scope_missing). [{ place, player, value, at, info?, me? }] (player, el id del usuario en la app; info, su publicInfo; me: true en la fila propia). También para invitados.

neblla.scores.mine(ranking) (CLIENT, login) — la marca propia: { best, bestAt, dayBest, runs, place } o null.

Los rankings se declaran con neblla_update_app({ scores }) o en el panel, Funciones › Puntuaciones y clasificaciones (ver identidad.txt, "Puntuaciones de un juego para uno").

neblla.media — fotos, texturas y sonidos de la app

Los medios de la app (fotos de una galería, texturas de un juego, sonidos, vídeos, modelos glb), muchos y aparte de sus ficheros: los sube el dev con neblla_media o en el panel (Construir › Medios), sin versiones ni neblla_promote, y la app los lee siempre al momento, en pre y en pro. Sin login.

neblla.media.collection(nombre) (CLIENT) — una colección, por su nombre («Bodas») o su clave («bodas»), en su orden: { collection, key, changedAt, items: [{ id, kind, mime, title, text, alt, fields, width?, height?, color?, url, srcset?, variants?, original }] }. fields son los campos propios del dev ({ year: 2023, size: "70 x 45 cm", price: 520, available: true }); url, la variante WebP de hasta 1280 px; srcset, todas (320, 640, 1280, 2048, nunca más anchas que el original); original, el fichero tal como se subió; color, el color dominante, para el fondo mientras carga. Se lee de Neblla cada vez (con ETag: si no cambió, casi no cuesta), así que un cambio del dev se ve en la siguiente lectura sin tocar el código. Rechaza con collection_not_found.

neblla.media.collections() (CLIENT) — [{ collection, key, count, changedAt, cover }], cover el primer elemento o null.

neblla.media.img(elemento, { sizes?, loading? }) (CLIENT) — un <img> listo: src, srcset con sizes ('100vw' por defecto), ancho y alto (sin saltos al cargar), alt (o el title), loading: 'lazy' y el color dominante de fondo.


const { items } = await neblla.media.collection('Obra');
for (const it of items) grid.append(neblla.media.img(it, { sizes: '(min-width: 800px) 33vw, 100vw' }));

Ficheros a la venta (C230): un medio privado trae private: true, bytes, unlockedBy: { products, credits } y original: null; quien tiene derecho lo descarga con neblla.media.download(id) (CLIENT, login), un enlace de 5 minutos directo del bucket ({ url, expiresAt, name, mime, bytes, via }; { open: false } solo lo devuelve), gasta un bono en uno con neblla.media.unlock(id, kind?) y lee lo suyo con neblla.media.mine(), también el contenido privado de lo que compró (content: un enlace o un texto, C330). La historia entera, en pagos.txt, "Ficheros a la venta".

neblla.app — avisos de la app

neblla.app.on('error', cb) (CLIENT) — el canal de error general del SDK: el servidor ha bloqueado algo y, sin escucharlo, tus eventos se quedarían muertos en silencio. { code }, donde code es 'quota_exceeded' (la app gastó su presupuesto del día; se resetea a las 00:00 UTC; trae resetsAt y stage: 'grace' mientras las salas en marcha siguen y solo se rechaza lo nuevo, 'closed' cuando ya se cerraron), 'player_quota_exceeded' (este usuario gastó su parte del día de la app, playerQuota; también hasta las 00:00 UTC), 'storage_full' (la base de datos de la app está llena y hay escrituras en la cola: no entra nadie nuevo hasta que el dev haga sitio), 'store_unavailable' (la base de datos propia de la app, BYODB, no contesta: entrar, guardar y comprar se paran hasta que vuelve; el usuario no puede hacer login por ningún camino y sigue como invitado, y la ventana de Neblla le dice por qué; quien ya está dentro sigue; también deja un aviso en consola), 'app_not_found' (el dev borró la app; también deja un aviso en consola) o 'app_maintenance' (la base de la app se está mudando; una llamada que espera respuesta la recibe también, como { error: 'app_maintenance' }, y los guardados que esperan en el navegador se reintentan luego). El caso de cuota además deja un aviso en consola. Escúchalo si quieres ensenar una pantalla de "hoy se nos acabó" en vez de una app congelada.

neblla.app.on('maintenance', cb) (CLIENT) — el dev está migrando la BBDD de la app a otro backend (ver identidad.txt). Tu socket se desconectará en ~2 segundos; el SDK vuelve a conectar solo pasados retryInSeconds (mínimo 5 s). Si vuelve antes de que acabe, oye otro maintenance y se le suelta otra vez; no hay aviso de fin: cuando vuelve a entrar, ya terminó. Aprovéchalo para mostrar un mensaje tipo "estamos en mantenimiento, vuelve en un momento". { reason: 'byodb_migration', retryInSeconds: 30 }.

neblla.app.permission(name, opts?) (CLIENT) — pide un permiso del navegador: 'microphone', 'camera', 'geolocation' o 'midi' (con { sysex: true } pide también SysEx). Resuelve 'granted' o 'denied' (un nombre desconocido, 'denied'). Tu app corre dentro de un iframe en la galería, así que el permiso lo pide la página de Neblla y el aviso del navegador dice «neblla»; si llamas directamente a getUserMedia o a la geolocalización, el SDK hace lo mismo por ti la primera vez.

neblla.app.devPreview (CLIENT) — true solo en el pre de la app (su enlace privado, o tu página en localhost); nunca para los usuarios de pro. Úsalo para enseñar atajos de dev, como «configurar productos».

neblla.app.dashboardUrl({ tab?, section? } | { account }) (CLIENT) — enlace al panel de esta app ({ tab: 'products' }) o a una pestaña de tu cuenta ({ account: 'claude' }). Va bien junto a devPreview.

neblla.app.debugging (CLIENT) — encendido por defecto: el SDK emite neblla.app.on('debug', cb) con { label, data } en sus pasos internos. Ponlo a false para callarlo.

neblla.social — amigos e invitaciones

Requiere el scope friends, salvo inviteToApp.

neblla.social.request(target) — petición a un usuario online en esta app, o neblla.social.request({ code }) con su código de amistad aunque no esté conectado (espera en su buzón). neblla.social.code() → el código del usuario, o null.
neblla.social.accept(requestId) / neblla.social.reject(requestId).
neblla.social.remove(friendId) — funciona offline.
neblla.social.inviteToRoom(target) — añade a la whitelist de tu sala privada.
neblla.social.inviteFriend(target) — invita casual a un amigo a tu sala (cualquier tipo).
neblla.social.inviteToApp() — abre un popup de Neblla para invitar a un amigo de Neblla A TU APP (la app no ve la lista de amigos). Requiere login. Fire-and-forget: la invitación va a la bandeja del amigo en play.neblla.com (caduca en 5 min) y lo trae a tu app. No requiere el scope friends.
neblla.social.list() — refresh manual de friends y requests; devuelve los amigos.

neblla.social.friends (CLIENT) Amigos visibles en esta app.
neblla.social.requests (CLIENT) Peticiones recibidas.
neblla.social.invitedBy (CLIENT) Quién te invitó a esta app vía bandeja de Neblla, o null. { id, name, picture, slug, userId|null }.

Eventos: neblla.social.on(nombre, cb)

'updated' (CLIENT) — { friends }.
'request' (CLIENT) — { requestId, from }.
'accepted' / 'removed' (CLIENT).
'appInvite' (CLIENT) — un amigo de Neblla te invitó a esta app (llegaste por el enlace de la bandeja, o te invitó estando ya dentro). { from } con from = { id, name, picture, slug, userId|null }. También rellena neblla.social.invitedBy.

neblla.shop — compras

neblla.shop.buy(productId, amount?, opts?) (CLIENT) — abre checkout. Un invitado compra con la cuenta ligera; con emailCheckout: false, haz login antes en la misma ventana. opts.series: el horario elegido de una plaza fija con varios (series_required con choices si falta).
neblla.shop.consume(productId, amount) (CLIENT) — consume unidades.
neblla.shop.validate(userId, productId) (BOTH) — verifica posesión.
neblla.shop.products() (BOTH) — lista productos.
neblla.shop.manage(productId) (CLIENT, login) — abre el portal de Stripe del suscriptor (ver pagos.txt).
neblla.shop.restore(email) (CLIENT) — con la cuenta ligera (emailCheckout, encendida por defecto), manda el enlace que une este dispositivo a las compras de ese email.

neblla.shop.purchases (CLIENT) — tus compras, por productId. Las de los demás no viajan: compruébalas con shop.validate.

neblla.shop.on('purchase', cb) (CLIENT) — { userId, productId, type, purchases }, solo en los dispositivos del comprador.

neblla.inbox — comunicaciones con el dev

neblla.inbox.send({ message, data?, dateFrom?, dateTo?, thread? }) (CLIENT) — manda mensaje al backoffice.
neblla.inbox.update(commId, {...}) (CLIENT) — edita (mientras no esté read).
neblla.inbox.delete(id) (CLIENT) — borra propia.

neblla.inbox.messages (CLIENT) — array de comunicaciones del usuario.

neblla.inbox.on('messages', cb) (CLIENT) — sync inicial.
neblla.inbox.on('updated', cb) (CLIENT) — { communication, action }.

neblla.ai — IA

neblla.ai.prompt(text, { preset?, model?, provider?, userToken? }?) (CLIENT) — devuelve Promise<{ text, usage, quota }>. provider: 'claude' | 'openai' | 'deepseek', el de la app por defecto; otro solo cuando paga el usuario (ver ia.txt).

neblla.ai.on('reply', cb) (CLIENT) — respuesta de neblla.ai.prompt().

neblla.api — APIs de terceros y keys

neblla.api.call(name, path, body?, { method }?) (BOTH) — llama a una API externa con un secreto guardado con neblla_set_api_secret; Neblla pone el secreto y solo llama bajo su URL base. Devuelve { status, contentType, body }. Desde el cliente, solo si el secreto tiene fromClient: true (ver core.txt).

neblla.api.key(name) (BOTH) — el valor actual de una key que el dev puso en el backoffice o con neblla_update_app({ keys }). Público: nunca secretos (ver core.txt).

neblla.anticheat — anti-cheat

neblla.anticheat.confirm(eventName, value) (CLIENT) — necesita el módulo anticheat (features: { anticheat: true }) — observar y reportar.
neblla.anticheat.flag(reason?) (SERVER) — marcar sala como sospechosa. Los jugadores lo ven en room.on('flagged').

neblla.groups — grupos

neblla.groups.mine() (CLIENT, login) — tus grupos, [{ group, role }]; create, invite, join, leave, members y pendingInvite si la app lo permite. En Server, neblla.groups.create, delete, add, remove, list.

neblla.community — comunidad

Todos devuelven promesa; rechazan con err.code. Detalle en comunidad.txt.

neblla.community.publish({ title, summary?, category?, tags?, lang?, contentType, payload, parentId?, visibility? }) (CLIENT, login) → entry. Nace published o pending según el modo de moderación.
neblla.community.update(entryId, patch) (CLIENT, login) → entry. Solo el autor o un moderador.
neblla.community.unpublish(entryId) (CLIENT, login) — el autor retira su entry (borrado real).
neblla.community.mine(opts?) (CLIENT, login) → { entries, next }, todos los estados propios.
neblla.community.list({ q?, category?, tags?, lang?, contentType?, owner?, parent?, sort?, cursor?, limit? }) (BOTH) → { entries, next } solo published, sin payload.
neblla.community.facets(['category'|'tags'|'lang'|'contentType'], { parent? }) (BOTH) → conteos por valor.
neblla.community.get(entryId) (BOTH) → { entry, payload }.
neblla.community.relate(entryId, { favorite?, rating?, review?, data? }) (CLIENT, login) → { relation, stats }. Merge parcial; null borra una ranura. data es tuyo: un objeto JSON libre de hasta 4 KB en la relación de ese usuario con esa entrada (un marcador, una nota, su mejor tiempo); vuelve en relation.data y no cuenta en las estadísticas. El autor no valora ni reseña lo suyo.
neblla.community.vote(entryId, 1 | -1 | null) (CLIENT, login) → { vote, stats }. Un voto por persona y entrada: 1 a favor (lo que hace vote(entryId) sin más), -1 en contra solo si la app lo enciende (community.downvotes: true; si no, downvotes_off), null o 0 lo quita. Suma en stats.upvotes, stats.downvotes y stats.score (a favor menos en contra); list({ sort: 'votes' }) ordena por él. El autor no vota lo suyo (own_entry).
neblla.community.relation(entryId) (CLIENT, login) → { favorite, rating, review, reported, vote }.
neblla.community.favorites(opts?) (CLIENT, login) → { entries, next }, mis favoritas publicadas.
neblla.community.reviews(entryId, opts?) (BOTH) → { reviews, next }, reseñas públicas.
neblla.community.report(entryId, { reason, message? }) (CLIENT, login) → { ok, suspended }. Una por usuario y entry; al umbral la entry vuelve a pending.
neblla.community.shareLink(entryId, { uses?, expiresInDays?, deleteEntry? }) (CLIENT, login, autor) → { token, key, expiresAt, uses }. Enlace con clave a una entry; la clave va detrás de # en tu URL y nunca llega al servidor (una publish({ ..., visibility: 'private' }) va cifrada de extremo a extremo y no se lista).
neblla.community.openLink(token, key) (CLIENT, login) → { entry, payload }; gasta un uso y descifra en el navegador. Desconocido, gastado o caducado → link_not_found.
neblla.community.deviceLink() (CLIENT, login) → { url }. Enlace (o QR) con tu clave personal para leer tus entries privadas en otro dispositivo tuyo.

neblla.community.links({ entryId? }) (CLIENT, login) → tus enlaces [{ id, entryId, usesLeft, expiresAt, createdAt, deleteEntry }], los más nuevos primero. id = hash del token (no abre nada).

neblla.community.revokeLink(id) (CLIENT, login) → { revoked: true }. Solo el autor (not_owner); desconocido → link_not_found. Con deleteEntry, revocar el último enlace borra la entry.

neblla.community.cardUrl(entryId) (CLIENT) → 'https://play.neblla.com/c/<appId>/<entryId>', la tarjeta pública (Open Graph) de una entry publicada; las demás dan 404. neblla.community.linkedEntry() (CLIENT) → el ?entry=<id> con el que se llegó desde una tarjeta, o null.

neblla.community.counterNotice(entryId, statement) (CLIENT, el autor de la entry) → { cases }: contesta todas las notificaciones formales abiertas sobre esa entry; no la restaura. neblla.community.noticeUrl(entryId) → el formulario público de notificación formal para esa entry.
neblla.community.queue({ status?, cursor? }) / neblla.community.moderate(entryId, { action, reason?, message? }) (CLIENT, moderadores de la app) — cola y acciones approve | reject | remove | restore | suspend.

Eventos: neblla.community.on(nombre, cb)

'updated' (CLIENT) — moderación tocó una entry mía de la comunidad. { entry, action }, action ∈ published | pending | rejected | removed | restored | modified | suspended; entry.moderation trae { reason, message }.

'reportClosed' (CLIENT) — se cerró el caso de una entry que denuncié. { entryId, outcome }, outcome ∈ upheld | dismissed.

neblla.business — negocio: reservas y bonos (detalle y errores en core.txt)

neblla.business.booking.* (CLIENT, login) — reservas: services() (los servicios, sedes y profesionales del negocio), slots(set, { from, to }) (con left y, si el hueco es de una serie que se repite, series; earlyUntil en una sesión nueva que aún es solo de quien tiene el producto del servicio, early), times(set, { from, to, site, staff, step }) (las horas libres de un servicio 'times' u 'open'), reserve(set, slotId, { for, party }) (en un conjunto que se paga al reservar, { booking, held, holdUntil, url } y abre el pago), reserveAt(set, { start, site, staff, for, party }) (a una hora libre: la sesión se hace para él, o es su partido abierto), cancel(set, slotId, { for }), mine(set) (con room, el chat de la sesión, y standing si vino de una plaza fija), standing(set?) (sus plazas fijas en curso), changeStanding(productId, series, { when: 'now' | 'next' }) (otro de los horarios de su plaza fija, sin pagar), move(set, slotId, toSlotId, { for }) (pasar una reserva a otra sesión del servicio, si tiene moveHours), closures({ from, to }) (los días cerrados del negocio, para pintarlos; sus sesiones vienen en slots como state: 'cancelled' con reason), on('update', fn) (la cita pagada, confirmada o cancelada; o { set, standing } cuando una plaza fija reserva sus casos).

neblla.business.credits.balance(kind) (CLIENT, login) — el saldo de bonos.

neblla.tools — No-code (detalle en servidor.txt)

neblla.tools.seal(userId) (CLIENT) — nombra en secreto a otro jugador en una partida anónima; el servidor recibe su alias, nunca el nombre.
neblla.tools.anonymize() (SERVER) — partida anónima: cada jugador recibe un alias y room.js solo ve alias. neblla.tools.unmask(userId) (SERVER) devuelve el alias de alguien cuando el juego lo hace público (quórum de dos Fog).
neblla.tools.deck.* (BOTH) — baraja: create, shuffle, draw, holders, add(...).assign/draw, muck, show. Sacar sin repetir.
neblla.tools.random.* (BOTH) — azar: create, roll. Sacar con repetición; cada tirada se anuncia a la sala.
neblla.tools.envelope.* (BOTH) — sobres: create, seal, open. Cualquier JSON, cerrado hasta que se cumple una condición (mode: 'p2p' o 'nocode').
neblla.tools.zones.* (BOTH) — zonas: create, move. Quién está con quién, sin que el servidor sepa dónde.
neblla.tools.counter.* / neblla.tools.score.* (BOTH) — contadores con tope (create, add, read) y límites de puntuación para rankings (create, submit). Solo el Fog los crea. Por defecto, en counter solo el Fog suma y los jugadores leen (para que sumen ellos, créalo con player: 'add|read'); en score los jugadores envían.

La hora común de la sala, para Fog, es neblla.room.now() (en neblla.room).

neblla.world — Mundo, el motor 3D (detalle en mundo.txt)

Con el módulo world. neblla.world.create(opts) (CLIENT) → Promise<world>: descarga el motor la primera vez. world.add(spec), world.load(src) → Promise<modelo>, world.scatter(modelo, transforms, opts), world.control(personaje), world.camera.use(preajuste, opts) (side, top, iso, third, first, chase), world.on(evento, fn) (touch, enter, leave, collect, fall, step), world.after(s, fn), world.every(s, fn), world.onFrame(fn), world.state(), world.hash(), world.start(), world.stop(), world.destroy(); el catálogo de piezas: world.catalog.search(texto, opts), world.catalog.get(id), world.catalog.list(opts) y world.load('catalog:<id>'); el muñeco estándar: world.doll(cuerpo) → Promise<muñeco> ({ height, width, legs, torso, arms, head }), world.add({ doll }), muñeco.play(movimiento), muñeco.stop(); el muñeco de cada persona (su cuenta Neblla): neblla.avatar.get(userId?) → Promise<aspecto>, neblla.avatar.doll(world, userId?) → Promise<muñeco> vestido, world.avatar(aspecto), muñeco.dress(aspecto); un obby desde su JSON (mundo.txt, «Obby con JSON»): world.obby(json, { doll }) → Promise<juego> (juego.start(), juego.on('finish', fn)) y world.checkObby(json) → { ok, errors, warnings }; world.three, world.rapier y world.sim para bajar de capa. neblla.world.engine() → el módulo del motor.

Plantilla completa — una sala con árbitro

Esqueleto completo y funcional de una app con salas en la que el servidor valida cada jugada (la receta «con árbitro» de salas.txt). Las demás formas parten de aquí, con las adaptaciones del final.

index.html


<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="UTF-8" />
  <meta name="viewport" content="width=device-width, initial-scale=1.0" />
  <title>Mi App</title>
  <link rel="stylesheet" href="main.css" />
  <script src="https://neblla.com/connect/app.js"></script>
</head>
<body>
  <button id="loginBtn">Log in</button>
  <button id="logoutBtn" hidden>Log out</button>
  <div id="status">Connecting…</div>
  <div id="roomControls" hidden>
    <button id="createRoomBtn">Create room</button>
    <input id="roomCodeInput" placeholder="code" maxlength="12">
    <button id="joinRoomBtn">Join</button>
    <button id="startRoomBtn" hidden>Start</button>
    <button id="leaveRoomBtn" hidden>Leave room</button>
  </div>
  <div id="players"></div>
  <div id="game"></div>
  <script src="client.js"></script>
</body>
</html>

main.css


body { font-family: system-ui, sans-serif; max-width: 640px; margin: 2rem auto; }
#players .player { padding: .25rem .5rem; }
#players .player.presenter::before { content: "★ "; }
#players .player.me { font-weight: bold; }

client.js


let isLobby = true;

window.onNeblla = (neblla) => {
  const $ = (id) => document.getElementById(id);
  $('status').textContent = 'Connected. In: ' + neblla.room.name;
  $('roomControls').hidden = false;

  // login is a feature toggle: if it is off for this app the call is refused
  // (feature_disabled), which is a setting to change, not a bug to code around.
  $('loginBtn').onclick      = () => neblla.auth.login().catch((e) => {
    $('status').textContent = 'Sign-in is not available in this app.';
    console.warn('login refused', e);
  });
  $('logoutBtn').onclick     = () => neblla.auth.logout();
  $('createRoomBtn').onclick = async () => {
    // rooms.book reserves the code but does NOT enter; rooms.join puts you in.
    // The room starts in `gathering` — nothing in server/room.js runs until
    // room.start() is called (by the presenter here, or by server/gathering.js).
    const code = await neblla.rooms.book({ length: 5 });
    await neblla.rooms.join(code);
    $('status').textContent = 'Room ' + code + ' — waiting for players';
  };
  $('startRoomBtn').onclick = () => neblla.room.start();
  $('leaveRoomBtn').onclick  = () => neblla.room.leave();
  $('joinRoomBtn').onclick   = () => {
    const code = $('roomCodeInput').value.trim();
    if (code) neblla.rooms.join(code);
  };

  // A mistyped code is the most likely thing to happen. Handle it: without this
  // listener a failed join looks like nothing happening at all, and there is no
  // runtime log to debug it with.
  neblla.rooms.on('joinError', (event) => {
    $('status').textContent = 'Could not join that room. Check the code.';
    console.warn('rooms.join failed', event);
  });

  // The SDK's general error channel. Without it, a spent daily quota just makes
  // every event stop working with no signal.
  neblla.app.on('error', (event) => {
    if (event.code === 'quota_exceeded') {
      $('status').textContent = 'This app is out of budget for today.';
    }
  });

  neblla.room.on('joined', (event) => {
    isLobby = event.isLobby;
    $('status').textContent = 'In room: ' + event.room.name;
    $('leaveRoomBtn').hidden = isLobby;
    renderPlayers();

    if (isLobby) {
      $('game').innerHTML = '';
    } else {
      // render game board (neblla.state.get() has the state if the game is running)
    }
  });

  // Authoritative state travels ONLY through state.set → state.on.
  // Never publish state as a generic message (see servidor.txt).
  neblla.state.on((state) => {
    // render(state);
  });

  // Messages from server/room.js (from === 'server') are for one-off
  // announcements ("wrong answer", "your turn"), never for the state itself.
  neblla.message.on(({ from, data }) => {
    if (from !== 'server') return;
    // if (data.type === 'toast') showToast(data.text);
  });

  neblla.room.on('userJoined', renderPlayers);
  neblla.room.on('userLeft',   renderPlayers);
  neblla.room.on('publicInfo', renderPlayers);
  neblla.room.on('presenter',  renderPlayers);
  // Only the presenter may start the room.
  const syncStart = () => { $('startRoomBtn').hidden = !(neblla.room.me?.isPresenter && !isLobby); };
  neblla.room.on('joined', syncStart);
  neblla.room.on('presenter', syncStart);
  neblla.room.on('started', () => { $('startRoomBtn').hidden = true; });

  neblla.auth.on('login', () => {
    $('loginBtn').hidden  = true;
    $('logoutBtn').hidden = false;
  });

  neblla.auth.on('logout', () => {
    $('loginBtn').hidden  = false;
    $('logoutBtn').hidden = true;
  });

  function renderPlayers() {
    const el = $('players');
    el.innerHTML = '';
    neblla.room.users.forEach(u => {
      const div = document.createElement('div');
      div.className = 'player'
        + (u.isPresenter ? ' presenter' : '')
        + (u.isMe ? ' me' : '');
      div.textContent = (u.publicInfo.name || u.id.slice(0, 8))
        + (u.isMe ? ' (you)' : '');
      el.appendChild(div);
    });
  }
};

room.js


// Top-level. neblla en scope global. Sin DOM. Sin neblla.auth ni neblla.room.me.

let gameState = neblla.state.get() || initialState();

neblla.message.on((m) => {
  if (m.to !== 'server') return;   // solo lo que un cliente mandó con message.to('server', …)
  if (m.data.type === 'action') {
    gameState = applyAction(gameState, m.user.id, m.data);
    neblla.state.set(gameState);
  }
});

neblla.room.on('userJoined', () => {
  if (gameState) neblla.state.set(gameState);
});

Adaptaciones

Cada una tiene su receta completa, con código, en salas.txt («Recetas: tres formas de sala»).

Todos los códigos de error, en un sitio

Los errores te llegan por tres vías, y saber cuál es cuál ahorra mucho adivinar. Como rechazo de la promesa de la llamada que hiciste (casi toda la API de comunidad y de salas). Como evento con nombre propio (neblla.rooms.on('joinError'), neblla.room.on('dataError'), neblla.room.on('fileError'), neblla.auth.on('expired'), neblla.app.on('maintenance')). Y el evento general neblla.app.on('error'), que lleva { code } para los casos en que el servidor te bloquea y tus eventos se quedarían muertos sin más: quota_exceeded (el presupuesto del día se agotó; se resetea a las 00:00 UTC, así que no reintentes en bucle) y app_maintenance; y store_unavailable cuando un login no se puede hacer porque la base de datos propia de la app no responde.

Todo lo demás es un código de rechazo en la llamada que lo provocó:

Dos reglas que valen para cualquier código: no rodees nunca un feature_disabled, y no reintentes nunca un quota_exceeded.