Command pattern: replay determinista, undo y networking lockstep
Convierte cada acción de tu IA en un objeto serializable. Encolas, grabas, deshaces y mandas por la red — todo cae en el mismo molde.
Publicado: · Por Juanjo "Banyo" López
0. Introducción
La mayoría del código de IA llama a métodos directamente: agent.MoveTo(target), enemy.Attack(), wizard.Cast(fireball). Funciona — hasta que tu juego necesita una de estas:
- Replay de una partida (ghost replays, demos, automated tests).
- Networking determinista entre dos clientes que tienen que ver lo mismo.
- Undo/redo en un strategy turn-based.
- Combos y scripted sequences que el level designer pueda armar sin tocar código.
- Debugging de IA: poder pausar, retroceder y ver qué decidió hacer un agente.
Las cinco se vuelven triviales con el patrón Command. La idea es vieja (GoF, 1994) y minúscula: en vez de llamar a agent.Move(target), construyes un objeto MoveCommand(agent, target) y le dices cuándo ejecutarse. Una vez que las acciones son objetos, puedes guardarlas, encolarlas, deshacerlas, mandarlas por la red.
¿Qué problema resuelve?
| Sin Command | Con Command |
|---|---|
| Cada acción es una llamada que se pierde después de ejecutarse. | Cada acción es un objeto serializable que persiste. |
| No puedes grabar una partida sin escribir un sistema paralelo. | Replay = guardar la lista de comandos y re-ejecutarla. |
| Networking sincroniza estados gigantes. | Networking sincroniza comandos chiquitos (lockstep). |
| Undo es un proyecto en sí mismo. | Undo es cmd.Undo() — “gratis” si el comando lo implementó. |
Debugging de IA es tirar Debug.Log en mil sitios. | Inspeccionas la cola de comandos, retrocedes, paso por paso. |
¿Cuándo aplicarlo?
- Tienes que grabar y reproducir acciones (ghost replays de Trackmania, demos de FPS competitivo).
- Tu juego es multiplayer determinista (RTS estilo Age of Empires II o StarCraft, fighting frame-perfect, deck-builders online).
- Tienes turn-based con undo (estrategia, puzzles, editor de niveles).
- Quieres un macro/combo system donde el jugador o el level designer encadenen acciones sin tocar código.
¿Cuándo NO?
- Acción puramente reactiva sin necesidad de historial: pulsar un botón que dispara una bala. Es un método. No lo envuelvas.
- Prototipo de game-jam: la abstracción cuesta tiempo. Llamadas directas, gana la jam, después si es necesario migra.
- Action games con física continua: lockstep determinista es muy duro con física no-determinista. Rocket League no es lockstep — es state-sync con interpolación.
En esta página vas a ver: qué es un command (con cola animada), replay determinista (grabas y reproduces), lockstep networking (dos clientes sincronizados con paquetes mínimos), undo/redo (con stacks visibles), una comparativa con Event Bus y la implementación en C#.
1. ¿Qué es un Command?
Un Command es una acción reificada: la conviertes en un objeto. La firma mínima es esta:
interface ICommand
function Execute()
function Undo() # opcional, si quieres undo gratis
Cada acción concreta del agente es una clase que implementa esa interfaz y guarda todo lo que necesita para ejecutarse y deshacerse:
class MoveCommand: ICommand
agent: Agent
target: Vec2
previousPos: Vec2 # para Undo
function Execute()
previousPos = agent.position
agent.MoveTo(target)
function Undo()
agent.position = previousPos
Tres consecuencias prácticas salen de eso:
- La acción persiste. Antes era una llamada que ya pasó. Ahora es un objeto que puedes guardar.
- La acción es serializable. Sus campos son datos planos: ID del agente, posición, ID de la skill. 12-30 bytes y listo.
- La acción es reversible (si implementaste
Undo). El comando “sabe” cómo deshacerse a sí mismo porque guardó el estado previo.
Cada acción del agente es un objeto Command. Encola unos cuantos y dale play: el agente los ejecuta en orden, uno por uno. Los comandos viven fuera del agente — los podés guardar, reordenar, mandar por la red.
Figura 1 — Encola comandos y pulsa play. El agente los ejecuta uno por uno. Nota que el comando vive fuera del agente: cuando termina, desaparece, pero podríamos haberlo guardado para grabarlo, mandarlo por la red o deshacerlo.
1.1 La cola: el patrón mínimo útil
Tener “una cola de comandos por agente” ya es la primera gran ganancia. En vez de que el agente decida en cada Update qué hacer, alguien (la IA, el jugador, un script) encola intenciones y el agente las ejecuta en orden:
class Agent
queue: Queue<ICommand>
current: ICommand?
function Tick(dt)
if current == null and queue.Count > 0
current = queue.Dequeue()
current.Execute()
if current != null and current.IsDone()
current = null
Con eso solo ya tienes:
- Combos: encolas
Move → Wait(0.2) → Attack → Wait(0.5) → Skill. - Cinemáticas scriptadas: el director encola comandos para 5 NPCs y los ejecuta sincronizados.
- Debugging: pausas el juego, miras la cola, ves que el agente “iba a atacar después de moverse” — y entiendes por qué hizo lo que hizo.
1.2 Comandos parametrizados, no clases por acción
Un error común: crear MoveTo10Command, MoveTo20Command, MoveTo30Command. No. Una clase por tipo de acción, con parámetros:
class MoveCommand: ICommand
target: Vec2 # parámetro
speed: float # parámetro
...
Así MoveCommand(target=(10,5), speed=3) y MoveCommand(target=(40,0), speed=8) son instancias distintas de la misma clase.
2. Replay determinista — el motor del ghost
Aquí empieza el primer “wow” del patrón. Si guardas la lista de comandos ejecutados con su frame (no su segundo), puedes reproducirla idéntica. Pixel a pixel. Una y otra vez.
Hacé clic en el canvas para encolar puntos a recorrer (grabación). Después pulsá Replay: se re-ejecuta el log entero. Con determinista (paso fijo) los caminos coinciden píxel a píxel; sin él, dt real + jitter desincronizan al ghost.
0px Figura 2 — Haz clic en el canvas para encolar puntos: el agente los recorre y se registran los comandos. Pulsa Replay y se re-ejecuta el log. Con paso fijo los caminos coinciden píxel a píxel. Sin él, dt real + jitter desincronizan al ghost.
2.1 Las dos reglas duras del determinismo
Para que un replay reproduzca exactamente lo grabado, no alcanza con guardar comandos. La simulación tiene que ser determinista. Eso significa:
-
Paso fijo (fixed timestep), no
Time.deltaTime. La simulación avanza siempre1/60s. Si el frame real es más largo, haces varios sub-pasos. Si es más corto, esperas.dtreal es enemigo del replay. -
RNG con seed. Si tu IA usa
Random.value, en el replay tiene que usar el mismo RNG con la misma seed. Cualquier llamada inesperada aRandom(un VFX, una animación) corrompe la secuencia.
class FixedStepLoop
accumulator: float = 0
const dt = 1/60
function Update(realDt)
accumulator += realDt
while accumulator >= dt
simulate(dt) # paso fijo
accumulator -= dt
2.2 La estructura del log
Cada entrada del log tiene frame (no tiempo) + payload:
struct LoggedCommand
frame: int
cmd: ICommand
Reproducir es trivial:
function Replay()
frame = 0
idx = 0
while idx < log.Count
while idx < log.Count and log[idx].frame == frame
log[idx].cmd.Execute()
idx++
simulate(dt)
frame++
2.3 Casos reales del replay
| Caso | Detalle |
|---|---|
| Ghost replay (Trackmania, racing games) | Grabas el input del jugador como comandos por frame; el ghost re-ejecuta. |
| Demo recording (Quake, CSGO) | El servidor manda comandos al cliente; el cliente los ejecuta. Las demos son listas de comandos. |
| Replay de partida (StarCraft, Age of Empires II) | El motor lockstep ya guarda comandos; “guardar replay” es serializar la cola. ~MBs por partida, no GBs. |
| Bug reports reproducibles | Tu QA mandó la seed + el log; tu programador re-ejecuta y ve el bug exacto. Determinismo = goldmine para soporte. |
| Automated testing | Grabas 200 partidas humanas; las re-ejecutas como suite de regresión. |
3. Networking lockstep — comandos en vez de estados
Mira cómo cambia el modelo de red al usar comandos. En vez de decir “el agente A está en (12.3, 8.5) ahora”, dices “el agente A recibe MoveCommand((12,9))”. El estado lo deduce cada cliente al ejecutar el comando.
Dos clientes que tienen que ver lo mismo. En lockstep mandás comandos (12 bytes) y ambos los ejecutan simultáneamente cuando llegan. En state-sync cada cliente publica posiciones todo el tiempo (mucho más tráfico, y con latencia se ven saltos).
0 paquetes en vuelototal: 0.00 KBLockstep: tráfico mínimo, ambos clientes idénticos.Figura 3 — Dos clientes tienen que mantener la misma simulación. En lockstep mandas comandos (12 bytes) que llegan a ambos con la latencia configurada y se ejecutan a la vez. En state-sync, cada cliente publica sus posiciones cada tick — más tráfico, y con latencia se ven teletransportes.
3.1 Lockstep paso a paso
- Tick fijo. Ambos clientes tickean a la misma frecuencia (típico: 10-20 Hz para RTS, 60 Hz para fighting).
- Cada input se vuelve un comando. El jugador clickea “atacar aquí”: eso genera un
AttackCommand(unitId, targetPos). - Los comandos se broadcastean a todos los clientes. Cada cliente acumula los comandos del tick.
- Cuando llegan los comandos de TODOS los clientes, el tick se ejecuta. Si falta uno, todos esperan. Esa es la pieza incómoda del lockstep — la latencia se siente en el control.
- Cada cliente ejecuta los mismos comandos en el mismo orden. La simulación es determinista (§2). Si todo está bien, ambos clientes ven exactamente lo mismo sin haberse mandado un solo byte de “estado”.
function ExecuteTick(commandsThisTick)
sortByPlayerId(commandsThisTick) # mismo orden en todos
for each cmd in commandsThisTick
cmd.Execute()
simulate(dt) # avanza la sim
3.2 El número clave: bytes/segundo
Comparemos un RTS con 200 unidades:
| Modelo | Cálculo | Ancho de banda |
|---|---|---|
| State-sync 30 Hz | 200 unidades × 16 B (pos+vel+rot) × 30 ticks/s × 8 jugadores | ~610 KB/s |
| Lockstep 20 Hz | ~3 comandos/jugador/tick × 12 B × 20 ticks/s × 8 jugadores | ~5 KB/s |
Dos órdenes de magnitud. Por eso StarCraft, Age of Empires, Civilization online y todos los RTS clásicos usaron lockstep. La banda ancha de los 90 lo exigía; en 2026 sigue siendo ventaja, especialmente en mobile.
3.3 Cuándo NO usar lockstep
- Juegos con física emergente (Rocket League, juegos con muchos objetos físicos). El determinismo se rompe con cualquier diferencia de hardware. Mejor state-sync con interpolación.
- Shooters competitivos modernos. Necesitas lag compensation (rewind del servidor para validar disparos). Lockstep + lag = sientes el ping. Mejor el modelo client-server autoritativo.
- Cuando la latencia variable entre jugadores es muy alta. Si uno tiene 300ms y otro 30ms, todos esperan al de 300. Frustrante.
4. Undo / redo — dos pilas y nada más
Si tus comandos implementan Undo(), el sistema de undo/redo es 15 líneas. Literalmente.
Cada botón crea un Command con execute() y undo(). Lo apilamos en el undo stack. Al deshacer, popea, ejecuta undo() y va al redo stack. Cualquier comando nuevo limpia el redo — exactamente como en Photoshop o tu IDE.
- vacío
- vacío
Figura 4 — Pulsa los botones para emitir comandos: cada uno se apila en undoStack. Undo lo desapila, llama cmd.Undo() y lo manda al redoStack. Cualquier comando nuevo limpia el redo — la regla de oro de cualquier editor.
4.1 La implementación entera
class History
undoStack: Stack<ICommand>
redoStack: Stack<ICommand>
function Execute(cmd)
cmd.Execute()
undoStack.Push(cmd)
redoStack.Clear() # cualquier acción nueva mata la rama redo
function Undo()
if undoStack.Count == 0: return
cmd = undoStack.Pop()
cmd.Undo()
redoStack.Push(cmd)
function Redo()
if redoStack.Count == 0: return
cmd = redoStack.Pop()
cmd.Execute()
undoStack.Push(cmd)
Eso es todo. La complejidad real está en que cada Command implemente Undo correctamente. Para un MoveCommand es trivial (guardas previousPos); para un SpawnEnemyCommand también (guardas el ID, lo destruyes); para un KillEnemyCommand ya es más caro porque tienes que resucitar al enemigo con todo su estado interno.
4.2 Cuándo Undo es barato y cuándo caro
| Acción | Costo de Undo |
|---|---|
Move | Trivial. Guardas from y to. |
Attack (resta HP) | Barato. Guardas HP previo. |
Spawn | Medio. Guardas referencia, destruyes en undo. |
Kill | Caro. Tienes que resucitar todo el estado interno del muerto. |
Cast con efectos sobre el mundo | Muy caro. ¿Volver el ambiente al estado previo? Probablemente snapshot. |
4.3 Strategy turn-based: el caso ideal
En Into the Breach puedes deshacer cualquier movimiento del turno actual. El motor está construido alrededor de Commands con Undo. Civilization permite deshacer el último movimiento de unidad. Cualquier strategy moderno, si quiere ofrecer “no-arrepentimientos”, se apoya en este patrón.
5. Comparativa con Event Bus
Si seguiste Arquitectura de IA, ya viste el Event Bus. Command y Event son primos cercanos pero no son lo mismo, y confundirlos rompe diseños.
| Command | Event Bus | |
|---|---|---|
| Naturaleza | Acción imperativa (haz esto) | Notificación de hecho (esto pasó) |
| Estado | Persistente (objeto que vive hasta ejecutarse o más) | Efímero (se dispara y se olvida) |
| Receptores | Uno: el agente al que le toca ejecutar | Muchos: todos los suscritos |
| Reversible | Sí, si implementa Undo() | No conceptualmente |
| Serializable | Sí (esa es media razón de existir) | Generalmente no — es ruido interno |
| Casos típicos | Replay, networking, undo, combos | Desacople intra-agente: HP→animación→SFX |
| Llega “después” | Cuando le toca en la cola/tick | Inmediatamente en el frame actual |
Regla práctica: si la acción tiene que persistir, viajar o deshacerse, es Command. Si es una señal interna que coordina módulos en el mismo frame, es Event. Y a menudo se usan juntos: ejecutar un AttackCommand publica un evento OnAttackStarted que Animation y Audio escuchan.
6. Pseudocódigo completo
interface ICommand
function Execute()
function Undo()
function IsDone() -> bool # opcional, para comandos con duración
class MoveCommand: ICommand
agent: Agent
target: Vec2
speed: float
previousPos: Vec2
function Execute()
previousPos = agent.position
agent.targetPos = target
agent.speed = speed
function Undo()
agent.position = previousPos
agent.targetPos = previousPos
function IsDone() -> bool
return distance(agent.position, target) < 0.05
class CommandQueue
queue: Queue<ICommand>
current: ICommand?
function Enqueue(cmd: ICommand)
queue.Enqueue(cmd)
function Tick()
if current == null and queue.Count > 0
current = queue.Dequeue()
current.Execute()
if current != null and current.IsDone()
current = null
class History
undoStack, redoStack: Stack<ICommand>
function Execute(cmd: ICommand)
cmd.Execute()
undoStack.Push(cmd)
redoStack.Clear()
function Undo()
if undoStack.Count > 0
var cmd = undoStack.Pop()
cmd.Undo()
redoStack.Push(cmd)
function Redo()
if redoStack.Count > 0
var cmd = redoStack.Pop()
cmd.Execute()
undoStack.Push(cmd)
class Replay
log: List<{frame: int, cmd: ICommand}>
frame: int = 0
function Record(cmd: ICommand)
log.Add({frame, cmd})
function Play()
frame = 0
idx = 0
while idx < log.Count
while idx < log.Count and log[idx].frame == frame
log[idx].cmd.Execute()
idx++
simulate(fixedDt)
frame++
7. Implementación en Unity / C#
Snippet representativo. El asset trae el motor completo (cola por agente, replay con compresión, lockstep client/server, undo con coalescing).
using System;
using System.Collections.Generic;
using UnityEngine;
// ---------- Contrato ----------
public interface ICommand {
void Execute();
void Undo();
bool IsDone();
}
// ---------- Comando concreto ----------
public class MoveCommand : ICommand {
readonly Transform t;
readonly Vector3 target;
readonly float speed;
Vector3 previous;
public MoveCommand(Transform t, Vector3 target, float speed) {
this.t = t; this.target = target; this.speed = speed;
}
public void Execute() {
previous = t.position;
// marca el destino; el agente lo mueve por su cuenta
t.GetComponent<Mover>()?.SetTarget(target, speed);
}
public void Undo() {
t.position = previous;
t.GetComponent<Mover>()?.SetTarget(previous, speed);
}
public bool IsDone() => Vector3.Distance(t.position, target) < 0.05f;
}
// ---------- Cola por agente ----------
public class CommandQueue {
readonly Queue<ICommand> queue = new();
ICommand current;
public void Enqueue(ICommand cmd) => queue.Enqueue(cmd);
public void Tick() {
if (current == null && queue.Count > 0) {
current = queue.Dequeue();
current.Execute();
}
if (current != null && current.IsDone()) current = null;
}
public bool IsBusy => current != null || queue.Count > 0;
}
// ---------- Undo / Redo ----------
public class History {
readonly Stack<ICommand> undoStack = new();
readonly Stack<ICommand> redoStack = new();
public void Execute(ICommand cmd) {
cmd.Execute();
undoStack.Push(cmd);
redoStack.Clear();
}
public void Undo() {
if (undoStack.Count == 0) return;
var cmd = undoStack.Pop();
cmd.Undo();
redoStack.Push(cmd);
}
public void Redo() {
if (redoStack.Count == 0) return;
var cmd = redoStack.Pop();
cmd.Execute();
undoStack.Push(cmd);
}
}
// ---------- Replay con paso fijo ----------
public class Replay {
public struct LoggedCmd { public int frame; public ICommand cmd; }
public List<LoggedCmd> Log = new();
public int Frame { get; private set; }
public const float FixedDt = 1f / 60f;
public void Record(ICommand cmd) => Log.Add(new LoggedCmd { frame = Frame, cmd = cmd });
public void Tick() => Frame++;
public IEnumerable<int> Play() {
Frame = 0;
var idx = 0;
while (idx < Log.Count) {
while (idx < Log.Count && Log[idx].frame == Frame) {
Log[idx].cmd.Execute();
idx++;
}
yield return Frame; // afuera el caller llama simulate(FixedDt)
Frame++;
}
}
}
8. En otros engines
- Godot: las
Resourceclases sirven perfecto como Commands serializables — el editor las muestra y las puedes guardar a disco. Para lockstep,MultiplayerSpawneryMultiplayerSynchronizerson state-sync; lockstep lo arms a mano sobreRPC. - Unreal:
UObjectpuro comoCommand. Para replay, Unreal trae elReplay Systemintegrado, basado en demos similares al de Quake. Para lockstep en RTS, te conviene un sistema custom o usar plugins como PlayFab + tick autoritativo. - JavaScript / TypeScript: clases ES6 puras. Para serialización usa
JSON.stringifycon un campo discriminator ({ kind: 'move', ... }), oprotobuf/msgpacksi la red importa.
9. Quiz
Pon a prueba lo que entendiste
Responde una por una. La explicación aparece al elegir, correcta o no.
Estás haciendo un RTS 1v1 que tiene que correr en mobile. Cada partida tiene 100 unidades. ¿Qué modelo de red eliges?
Grabaste 200 frames de un combate y quieres reproducirlo idéntico. Notas que el ghost se desincroniza después de 5 segundos. ¿Causa más probable?
Implementaste Undo en un strategy turn-based. Después de pulsar Undo varias veces y hacer un movimiento nuevo, el redoStack se vacía. ¿Por qué?
Tu HealthModule baja HP. Quieres que AnimationModule, AudioModule y FxModule reaccionen. ¿Command o Event?
¿Qué hace que un Command sea 'serializable' en el sentido útil de networking lockstep?
En Into the Breach puedes deshacer movimientos antes de confirmar el turno, pero no después. ¿Decisión arquitectónica o de diseño?
Tienes que hacer un sistema de combos para tu juego: el jugador encadena Mover→Esquivar→Atacar→Skill. ¿Cómo lo modelas con Commands?