* notes / socket.io
Socket.IO: salas y sincronización de movimiento para un videojuego
— Cómo orquesté un multijugador en tiempo real con Socket.IO: salas, frecuencia de tick, interpolación cliente y la trampa del broadcast por evento.
El día que conecté a tres amigos al prototipo y el servidor empezó a vomitar treinta mil paquetes por segundo, comprendí que emitir por cada evento de ratón era una mala idea. Lo que comenzó como un experimento de lobby evolucionó en una lección sobre tasas de actualización, autoridad del servidor e interpolación en el cliente. Aquí cuento cómo monté un multijugador en Socket.IO sin que la red se viniera abajo.
Contexto: por qué Socket.IO y no WebSocket «a pelo»
WebSocket es un transporte; Socket.IO es un protocolo construido encima que añade reconexión automática, salas, namespaces, acknowledgements, binary support y fallback a HTTP long-polling para entornos hostiles. Para un videojuego web, no un MMO competitivo, ese conjunto cubre el 90 % de las necesidades sin tener que reinventar pingüinos. No obstante, conviene tener en cuenta que Socket.IO añade su propio handshake y un pequeño overhead por mensaje; para juegos de muy alta frecuencia (60 Hz o más con latencia mínima) WebTransport o uWebSockets pueden ser mejor opción. Para mi caso, un party game a 20 Hz, Socket.IO sobra y bastante.
Modelo de salas
La diferencia entre namespace y room suele confundirse:
- Namespace (
/game,/admin): un canal de comunicación independiente. Cambia el endpoint y el handshake. - Room: un grupo lógico dentro de un namespace; un cliente puede pertenecer a varias.
Para un juego con partidas separadas, lo natural es un único namespace y una room por partida.
El servidor: autoridad y tick
La regla cardinal que adopté es servidor-autoritario: el cliente envía intenciones (input), no posiciones. El servidor las aplica, fija el estado canónico y reparte el resultado a 20 Hz.
import { createServer } from "node:http";
import { Server } from "socket.io";
const http = createServer();
const io = new Server(http, {
cors: { origin: "*" },
pingInterval: 10_000,
pingTimeout: 5_000,
});
type Vec = { x: number; y: number };
type PlayerState = { pos: Vec; vel: Vec; input: Vec };
type Room = { players: Map<string, PlayerState> };
const rooms = new Map<string, Room>();
const TICK_HZ = 20;
const DT = 1 / TICK_HZ;
const SPEED = 220;
io.on("connection", (socket) => {
socket.on("room:join", (roomId: string) => {
socket.join(roomId);
const room = rooms.get(roomId) ?? { players: new Map() };
room.players.set(socket.id, {
pos: { x: 100, y: 100 },
vel: { x: 0, y: 0 },
input: { x: 0, y: 0 },
});
rooms.set(roomId, room);
socket.data.roomId = roomId;
});
socket.on("player:input", (i: Vec) => {
const roomId = socket.data.roomId as string | undefined;
if (!roomId) return;
const p = rooms.get(roomId)?.players.get(socket.id);
if (!p) return;
// Normalizo para evitar trampa de diagonal más rápida.
const m = Math.hypot(i.x, i.y) || 1;
p.input = { x: i.x / m, y: i.y / m };
});
socket.on("disconnect", () => {
const roomId = socket.data.roomId as string | undefined;
if (!roomId) return;
rooms.get(roomId)?.players.delete(socket.id);
});
});
setInterval(() => {
for (const [roomId, room] of rooms) {
const snapshot: Record<string, Vec> = {};
for (const [id, p] of room.players) {
p.pos.x += p.input.x * SPEED * DT;
p.pos.y += p.input.y * SPEED * DT;
snapshot[id] = { x: p.pos.x, y: p.pos.y };
}
io.to(roomId).emit("world:snapshot", { t: Date.now(), players: snapshot });
}
}, 1000 / TICK_HZ);
http.listen(3001);Cabe destacar tres decisiones:
- Vector de entrada en lugar de posición: el cliente nunca dicta dónde está, solo hacia dónde quiere ir. Esto elimina una clase entera de exploits.
- Marca temporal del servidor en el snapshot (
t): la usaré en el cliente para interpolar. io.to(roomId).emit(...): el broadcast se circunscribe a los miembros de la sala. Sin esta limitación, cualquier partida de cuatro jugadores arrojaría tráfico a todo el servidor.
El cliente: throttling de input e interpolación
El problema que abría el artículo, emitir por cada mousemove, se soluciona enviando el vector de input a la cadencia del servidor, no a la del navegador. Mantengo el último input pendiente y lo despacho cada 50 ms.
import { io } from "socket.io-client";
const socket = io("ws://localhost:3001");
socket.emit("room:join", "lobby-7");
let pendingInput = { x: 0, y: 0 };
window.addEventListener("keydown", (e) => {
if (e.key === "ArrowRight") pendingInput.x = 1;
if (e.key === "ArrowLeft") pendingInput.x = -1;
if (e.key === "ArrowDown") pendingInput.y = 1;
if (e.key === "ArrowUp") pendingInput.y = -1;
});
window.addEventListener("keyup", (e) => {
if (["ArrowRight", "ArrowLeft"].includes(e.key)) pendingInput.x = 0;
if (["ArrowUp", "ArrowDown"].includes(e.key)) pendingInput.y = 0;
});
setInterval(() => socket.emit("player:input", pendingInput), 50);Para que el movimiento se vea suave (no a saltos de 50 ms) implemento un buffer de interpolación con desfase de un tick: el cliente se renderiza siempre 50 ms «en el pasado», entre los dos snapshots más recientes.
type Snap = { t: number; players: Record<string, { x: number; y: number }> };
const buffer: Snap[] = [];
const INTERP_DELAY = 50; // ms
socket.on("world:snapshot", (s: Snap) => {
buffer.push(s);
if (buffer.length > 30) buffer.shift();
});
function sample(now: number) {
const target = now - INTERP_DELAY;
for (let i = buffer.length - 1; i > 0; i--) {
const a = buffer[i - 1];
const b = buffer[i];
if (a.t <= target && target <= b.t) {
const k = (target - a.t) / (b.t - a.t);
const out: Record<string, { x: number; y: number }> = {};
for (const id of Object.keys(b.players)) {
const pa = a.players[id] ?? b.players[id];
const pb = b.players[id];
out[id] = {
x: pa.x + (pb.x - pa.x) * k,
y: pa.y + (pb.y - pa.y) * k,
};
}
return out;
}
}
return buffer.at(-1)?.players ?? {};
}
function frame() {
const positions = sample(Date.now());
render(positions);
requestAnimationFrame(frame);
}
requestAnimationFrame(frame);Asimismo, la sincronización temporal entre cliente y servidor (NTP ligero) sería el siguiente paso para evitar derivas si los relojes divergen; en pruebas locales no fue necesario.
Lecciones de carga
Tras instrumentar todo (Prometheus + un panel modesto en Grafana) lo que observé:
| Frecuencia de envío de input | Mensajes/s con 4 jugadores | Latencia percibida |
|---|---|---|
| Por evento de teclado (~80 Hz) | 320 emitidos + 80 broadcast | indistinguible |
| Throttled a 20 Hz | 80 emitidos + 80 broadcast | indistinguible |
| Throttled a 10 Hz | 40 emitidos + 40 broadcast | sensación de «pegajoso» |
Por consiguiente, 20 Hz es el punto dulce para input y tick en juegos casuales. Por encima de 30 Hz solo se justifica si la mecánica exige reacción muy fina; por debajo de 15 Hz, la interpolación deja de disimular.
Otro hallazgo: comprimir manualmente los snapshots (mapas de id → posición con Float32) redujo el tamaño del payload casi a la mitad respecto al JSON por defecto. Socket.IO admite envíos binarios con Buffer/ArrayBuffer sin ceremonia.
Trampas que pagué
- No limitar
room:join: cualquier cliente podía meterse en cualquier sala. Añadí un token de sala y validación servidor lado. - Olvidar limpiar las salas vacías: el
Mapcrecía sin control. Trasdisconnect, siroom.players.size === 0, elimino la sala. - Backpressure: cuando un cliente pierde conectividad, los buffers crecen.
socket.volatile.emitpara snapshots no críticos descarta paquetes en lugar de acumularlos. pingTimeoutdemasiado generoso: dejaba zombies durante un minuto. Bajé a 5 s y resolví la mayoría de los fantasmas.
Closure
Socket.IO me dejó claro que el reto de un multijugador no está en el transporte sino en la disciplina del bucle: tick fijo, autoridad servidor, interpolación cliente, throttling de input. Cuando estos cuatro pilares están en su sitio, el juego se vuelve indistinguible de uno mucho más complejo. Mi siguiente paso será introducir reconciliación con predicción local (client-side prediction + rollback) para que el jugador local no tenga que esperar al snapshot servidor. Lo dejaré para otra entrada, porque ahí empieza, no acaba, la parte interesante.