Casos: chat, colaboración, presencia, juegos y notificaciones
Cinco aplicaciones que parecen muy distintas y que por debajo son el mismo objeto. El chat difunde y guarda historial. La edicion colaborativa convierte al objeto en arbitro del orden, con una version monotona que permite a los clientes rebasar sus cambios. La presencia vive en las etiquetas y en el attachment, y se apoya en las alarmas para expulsar zombis. El juego multijugador anade un bucle autoritativo por alarma donde el cliente manda intenciones y no estado. Las notificaciones invierten la figura: un objeto por usuario que es buzon cuando esta desconectado y canal cuando esta presente.
Ya tienes la mecánica completa: abrir el canal, guardar las conexiones, difundir y dormir sin colgar a nadie. Toca ahora ver qué se construye con eso, y la sorpresa de esta lección es cuánto se repite. Un chat, un documento con varios cursores, una lista de quién está en línea, una partida de cuatro jugadores y un sistema de avisos parecen cinco productos distintos, con cinco arquitecturas distintas en cinco equipos distintos. Vistos desde el objeto son cinco disfraces del mismo esqueleto: una entidad con nombre, un conjunto de conexiones, un orden y un estado. Cambia lo que se difunde y quién es el dueño; no cambia la forma.
- Diseñar un chat con historial durable y una lista de presencia que no mienta.
- Convertir el objeto en árbitro del orden para edición colaborativa, con una versión monótona por operación.
- Implementar un bucle de juego autoritativo por alarma donde el cliente envía intenciones y no estado.
- Modelar notificaciones con un objeto por usuario que sea buzón cuando está ausente y canal cuando está presente.
Chat y presencia: la sala que sabe quién está
El chat es el caso fundacional y el que mejor enseña la división del trabajo. Las conexiones las guarda la plataforma; los datos de cada conexión viven en el attachment; el historial reciente vive en el almacén durable del objeto, que además permite enviarle a quien acaba de entrar los últimos mensajes sin consultar ninguna base externa. La sala es autosuficiente: computa, recuerda y difunde en el mismo lugar.
La presencia es el mismo problema con otro ritmo. Quién está en línea no es un dato que se consulte, es un dato que se emite: cada entrada y cada salida difunden un aviso, y el estado agregado se deriva de recorrer los sockets vivos. La trampa clásica son los zombis, conexiones que la red dio por buenas y que ya no responden. La cura es una alarma periódica que revise la marca del último latido de cada socket y cierre las que llevan demasiado tiempo mudas.
async webSocketMessage(ws: WebSocket, mensaje: string): Promise<void> {
const datos = ws.deserializeAttachment() as { usuario: string };
const evento = { tipo: "chat", de: datos.usuario, texto: mensaje, ts: Date.now() };
this.ctx.storage.sql.exec(
"INSERT INTO mensajes (autor, texto, ts) VALUES (?, ?, ?)",
datos.usuario, mensaje, evento.ts,
);
this.difundir(evento);
}
| Caso | Quién es el objeto | Qué difunde | Qué guarda |
|---|---|---|---|
| Chat | La sala | tipo: "chat" | "entra" | "sale" |
Historial reciente en SQLite |
| Presencia | La sala o el equipo | Altas, bajas y latidos | Nada; se deriva de los sockets vivos |
| Colaboración | El documento | Operaciones con versión | El documento y su registro de cambios |
| Juego | La partida | Instantánea por tick | Posiciones, turno y marcador |
| Avisos | El usuario | Cada notificación | Las no entregadas |
Colaboración: el objeto como árbitro del orden
Editar un texto entre varios es difícil por una razón concreta: dos personas escriben sobre la misma posición y sus intenciones llegan cruzadas. La solución clásica se apoya en un secuenciador, alguien que decida el orden canónico de las operaciones, y ese papel encaja con el objeto sin esfuerzo. Cada cliente envía su operación con la versión sobre la que la pensó; el objeto la aplica en su turno, le asigna una versión nueva y superior, y difunde el resultado. Los clientes que iban por detrás transforman su cambio local contra las operaciones que se les adelantaron y convergen.
async webSocketMessage(ws: WebSocket, crudo: string): Promise<void> {
const op = JSON.parse(crudo) as { base: number; cambio: Cambio };
let version = (await this.ctx.storage.get<number>("version")) ?? 0;
// el hilo unico decide el orden canonico: no hay carrera posible
const aplicada = this.aplicarSobre(op.cambio, op.base, version);
version += 1;
await this.ctx.storage.put("version", version);
this.difundir({ tipo: "op", version, cambio: aplicada });
}
Los cursores merecen una nota aparte, porque son el tráfico dominante de cualquier editor y el que peor escala si se trata como todo lo demás. La posición del puntero de otra persona es un dato efímero: si llegan veinte por segundo y el navegador solo va a pintar el último, difundir los veinte es desperdicio puro. El patrón correcto es quedarse con el valor más reciente de cada emisor y emitirlos agrupados a un ritmo fijo, y no persistir ninguno, porque la posición de un cursor no forma parte del documento.
Lo notable es lo que no aparece: ni un bloqueo, ni un algoritmo de consenso, ni una ventana de reconciliación entre servidores. La versión monótona es fiable porque solo un hilo la incrementa, y la difusión llega a todos en el mismo orden porque solo hay un emisor. Si prefieres estructuras que convergen por sí solas, el mismo objeto sirve de punto de reunión y de almacén del documento, y la ventaja se traslada al historial: puedes guardar instantáneas periódicas con una alarma y podar el registro de operaciones sin coordinarte con nadie.
Juegos: un bucle autoritativo por alarma
Una partida añade una dimensión que el chat no tiene: el tiempo avanza aunque nadie hable. El patrón es el bucle autoritativo. Los clientes no envían estado —dónde están— sino intenciones: quiero moverme a la izquierda, quiero disparar. El objeto acumula esas intenciones, y una alarma que se reprograma sola marca el compás: cada tick resuelve el mundo, aplica las reglas y difunde una instantánea a todos. Como el estado autoritativo vive en un solo sitio, hacer trampas se vuelve un problema de validación local y no de auditoría distribuida.
async alarm(): Promise<void> {
this.resolverTick(); // aplica las intenciones acumuladas
this.difundir({ tipo: "estado", mundo: this.mundo });
if (this.ctx.getWebSockets().length > 0) {
await this.ctx.storage.setAlarm(Date.now() + 100); // diez ticks por segundo
}
// sin jugadores, la partida deja de latir y no cuesta nada
}
La última condición es la que hace elegante el patrón. Mientras haya jugadores conectados, la partida late; cuando el último se va, la alarma no se reprograma y el objeto se apaga sin dejar nada corriendo. Un millón de partidas terminadas cuestan exactamente lo que cuesta el silencio.
Dos consecuencias de este diseño valen para cualquier simulación, no solo para juegos. La primera es que el tick desacopla la frecuencia de entrada de la de salida: da igual que cuatro jugadores manden treinta intenciones por segundo cada uno, porque solo salen diez instantáneas por segundo hacia cada uno. La segunda es que el estado autoritativo, al vivir en el mismo hilo que lo modifica, no necesita ninguna sincronización: las reglas del mundo se aplican una vez, en orden, sobre datos que nadie más toca.
No confíes en que un socket abierto signifique un usuario presente: una conexión puede quedar en pie durante minutos después de que el dispositivo desaparezca. Combina el evento de cierre, que cubre las salidas ordenadas, con un latido periódico y una alarma que expulse a quien lleva demasiado tiempo mudo. Y expón la presencia como una lista derivada de los sockets vivos, nunca como un contador que incrementas y decrementas, porque ese contador se desincroniza al primer cierre que se pierde.
flowchart TB J1[jugador uno envia intencion] --> DO[objeto partida] J2[jugador dos envia intencion] --> DO AL[alarma cada cien ms] --> DO DO --> S[instantanea difundida a todos] DO -.reprograma la alarma.-> AL style DO fill:#a6e3a1,color:#11111b
Notificaciones: un buzón por identidad
Los cuatro casos anteriores comparten una forma: muchos usuarios dentro de un objeto compartido. Las notificaciones invierten la figura y por eso conviene verlas al final. Aquí la entidad es la persona: un objeto por usuario, al que se conectan sus dispositivos. Cualquier parte del sistema que quiera avisarle obtiene su objeto por nombre y le llama por RPC, sin saber ni preocuparse de si hay alguien escuchando.
async notificar(aviso: Aviso): Promise<void> {
const sockets = this.ctx.getWebSockets();
if (sockets.length === 0) {
// ausente: el objeto hace de buzon durable hasta que vuelva
await this.ctx.storage.put(`pendiente:${aviso.id}`, aviso);
return;
}
const carga = JSON.stringify(aviso);
for (const ws of sockets) ws.send(carga);
}
Ese doble papel —canal cuando estás, buzón cuando no— resuelve de un golpe el problema que suele repartirse entre una cola, una tabla de pendientes y un servicio de entrega. Y la difusión masiva se vuelve un abanico de llamadas independientes: para avisar a diez mil personas no recorres una lista de sockets, sino diez mil objetos que cada uno decide si entrega o guarda.
La entrega fiable se completa con dos gestos pequeños. Al reconectarse, el dispositivo pide lo pendiente y el objeto le vuelca lo que guardó, borrándolo solo cuando el cliente acusa recibo; y una alarma caduca lo que lleva demasiado tiempo sin entregarse, para que un usuario que no vuelve nunca no acumule un buzón infinito. Con eso tienes semántica de al menos una vez sobre un canal que no la garantizaba, y sin ninguna infraestructura añadida.
Chat: difundir y recordar
La sala emite lo que llega y guarda el historial a su lado. Quien entra recibe el pasado reciente sin salir del objeto.
Presencia: emitir, no consultar
Entradas y salidas se difunden; el estado se deriva de los sockets vivos. Una alarma barre a los zombis que la red dio por buenos.
Colaboración: orden con versión
Cada operación recibe una versión monótona del único hilo que la asigna. Los clientes rebasan sus cambios y convergen.
Juego: intenciones y ticks
El cliente envía lo que quiere hacer; el objeto resuelve el mundo por alarma y difunde la instantánea autoritativa.
Detente a mirar lo que acaba de pasar, porque es el corazón de este nivel. Un chat, un editor colaborativo, un indicador de presencia, un juego y un sistema de notificaciones se venden como categorías distintas de software; cada una tiene su industria, sus proveedores especializados y sus artículos sobre la arquitectura correcta. Y sin embargo los cinco se han escrito aquí con las mismas cuatro piezas: una entidad con nombre, un conjunto de conexiones, un hilo que impone el orden y un almacén coubicado. Lo único que cambia entre ellos es la respuesta a dos preguntas: quién es la entidad y qué se difunde. En el chat la entidad es la sala y se difunden mensajes; en el editor es el documento y se difunden operaciones con versión; en el juego es la partida y se difunden instantáneas que dispara un reloj propio; en las notificaciones es la persona y se difunde lo que llega, o se guarda si no hay nadie. Esa uniformidad no es una casualidad estética, es la señal de que hemos encontrado el primitivo correcto. Cuando cinco problemas que parecían pedir cinco arquitecturas se resuelven con una sola forma, lo que tienes delante no es una biblioteca ni un truco de plataforma: es una abstracción que estaba esperando ser nombrada. El corolario práctico es el que quiero que te lleves al próximo diseño. Ante cualquier requisito de tiempo real, no empieces preguntando qué tecnología de sockets uso, ni cuántos servidores necesito, ni dónde pongo la cola. Empieza preguntando cuál es la entidad cuyo nombre reúne a quienes deben verse lo mismo. Nombra esa entidad y el resto —el canal, el orden, la persistencia, el reloj— viene dado. La arquitectura del tiempo real deja de ser un problema de infraestructura y se convierte en un ejercicio de modelado del dominio, que es exactamente donde debería haber estado siempre.
- Añade historial al chat: guarda cada mensaje en SQLite y envía los veinte últimos al usuario que acaba de entrar, solo a él.
- Implementa presencia honesta con una alarma que revise el último latido de cada socket y expulse a los que llevan un minuto mudos.
- Escribe el árbitro de orden de un editor: versión monótona por operación, difusión con esa versión y una instantánea periódica que pode el registro.
- Monta un bucle de partida a diez ticks por segundo que se detenga solo cuando el último jugador se desconecte, y razona por qué el cliente debe enviar intenciones y no posiciones.
- Construye el objeto de usuario con doble papel, canal y buzón, y comprueba que un aviso emitido mientras estaba ausente le llega al reconectarse.