Proyecto final: un servidor de videojuegos concurrente y la bibliografía del curso
Proyecto final: un servidor de videojuegos concurrente y la bibliografía del curso
En el capítulo 16 resolviste un problema de concurrencia dentro de un solo proceso: varios flujos de ejecución compartiendo memoria en la misma máquina, coordinados con las primitivas que fuimos construyendo desde el capítulo 7. Todo el estado vivía en un espacio de direcciones que podías inspeccionar de una sola vez, y la peor falla imaginable era un entrelazado desafortunado.
Este proyecto rompe esa comodidad en tres direcciones a la vez. Primero, los participantes ya no comparten memoria: viven en máquinas distintas y solo pueden intercambiar bytes por un socket, que es el mecanismo de comunicación entre procesos que vimos en el capítulo de IPC llevado más allá del límite de la máquina. Segundo, aparece el tiempo real: el servidor no puede esperar tranquilamente a que todos hablen, tiene que avanzar la simulación a una frecuencia fija aunque alguien esté callado. Tercero, aparece la falla parcial: un cliente puede desaparecer sin avisar, y el servidor tiene que seguir funcionando como si nada.
El resultado es un sistema donde conviven todo lo que estudiaste: planificación, cambios de contexto, llamadas al sistema bloqueantes y no bloqueantes, sincronización de estado compartido, colas de productor-consumidor, tolerancia a fallos y medición de rendimiento. Vamos a construirlo en dos etapas. La primera es un servidor por turnos del juego cachipún en su variante RPSLS —piedra, papel, tijeras, lagarto, Spock— que se comunica por TCP con un protocolo de texto interoperable. La segunda convierte ese servidor por turnos en un servidor de tiempo real con un tick loop autoritativo, y ahí es donde aparecen la latencia, el jitter y la predicción del cliente.
Al final del capítulo está la bibliografía recomendada del curso completo y un cierre que recorre los diecisiete capítulos.
Qué es exactamente un servidor de juego
Un servidor de juego es un proceso que mantiene la versión oficial de un estado compartido y que acepta, valida y aplica las modificaciones que le proponen varios clientes remotos. La palabra clave es oficial: si dos jugadores discrepan sobre dónde está un personaje, gana el servidor. Esa propiedad se llama autoridad del servidor y no es un detalle de diseño, es la única defensa estructural contra clientes modificados.
Desde la perspectiva del sistema operativo, un servidor de juego es un caso de estudio casi perfecto porque combina tres presiones que normalmente se estudian por separado:
- Concurrencia de E/S. Decenas o miles de sockets abiertos, cada uno pudiendo tener datos disponibles en cualquier momento. Esto es exactamente el problema que motivó
select,poll,epollykqueue. - Concurrencia de cómputo con estado compartido. Todas esas conexiones tocan un mismo mundo. Cada mensaje entrante es una propuesta de escritura sobre datos que otros están leyendo.
- Restricción temporal dura. No basta con que el resultado sea correcto: tiene que llegar a tiempo. Un resultado correcto que llega 400 ms tarde es, en un juego, un resultado incorrecto.
La combinación de las tres es lo que hace del proyecto un buen examen final.
Los dos modelos de servidor
Antes de escribir código conviene fijar la diferencia entre los dos regímenes que vas a implementar.
| Aspecto | Servidor por turnos | Servidor de tiempo real |
|---|---|---|
| Qué dispara el avance del estado | La llegada de mensajes de los clientes | El reloj, a frecuencia fija |
| Qué hace si un cliente calla | Espera (con timeout) | Avanza igual, con la última entrada conocida |
| Modelo de concurrencia natural | Un proceso por partida, bloqueante | Un proceso de mundo con bucle, no bloqueante |
| Sensibilidad a la latencia | Baja: 300 ms son tolerables | Alta: 100 ms ya se notan |
| Coste por jugador inactivo | Casi cero | Constante, se simula igual |
| Ejemplos | Ajedrez, cartas, cachipún | Shooters, deportes, plataformas |
| Protocolo típico | Petición-respuesta sobre TCP | Flujo de estado sobre UDP o TCP con nodelay |
La primera etapa del proyecto vive en la columna izquierda; la segunda, en la derecha. Muchos servidores comerciales son híbridos: el lobby y el emparejamiento funcionan por turnos, y la partida corre en tiempo real.
Sockets: el IPC que cruza la máquina
Un socket es un descriptor de archivo que en vez de apuntar a un archivo en disco apunta a un extremo de un canal de comunicación. Esa decisión de diseño —hecha en Berkeley a comienzos de los ochenta— es la razón de que puedas usar read y write sobre una conexión de red igual que sobre un archivo: el sistema operativo te da la misma interfaz y se encarga de que abajo haya una pila TCP/IP en vez de un sistema de archivos.
Para el proyecto usamos sockets de tipo stream sobre TCP. Sus propiedades relevantes son cuatro:
- Orientado a conexión. Hay un establecimiento previo (el saludo de tres vías) y un cierre ordenado. Mientras la conexión existe, el sistema operativo mantiene estado en el kernel de ambos extremos.
- Flujo de bytes sin fronteras. TCP no preserva los límites de tus escrituras. Si escribes
"HOLA\n"y luego"CHAO\n", el otro extremo puede recibir"HOLA\nCH"en una lectura y"AO\n"en la siguiente. Esto se llama framing y es la primera fuente de errores en todo servidor escrito a mano. - Entrega confiable y ordenada. Lo que sale llega, y llega en orden. El precio es que si un segmento se pierde, todo lo que venga detrás espera a que se retransmita: es el bloqueo de cabeza de línea.
- Control de flujo y de congestión. El kernel decide cuánto puede haber en vuelo. Esto significa que un
writepuede bloquearse aunque la red esté sana, simplemente porque el receptor no está leyendo.
El recorrido de una conexión
sequenceDiagram
participant C as Cliente
participant KC as Kernel cliente
participant KS as Kernel servidor
participant S as Servidor (proceso)
S->>KS: socket()
S->>KS: setsockopt(SO_REUSEADDR)
S->>KS: bind(0.0.0.0:6502)
S->>KS: listen(backlog)
Note over KS: se crea la cola de conexiones pendientes
S->>KS: accept() -- bloquea
C->>KC: socket()
C->>KC: connect(host:6502)
KC->>KS: SYN
KS->>KC: SYN + ACK
KC->>KS: ACK
Note over KS: conexión completa, entra a la cola
KS-->>S: accept() retorna un socket nuevo
S->>KS: write("BIENVENIDO RPSLS/1.0")
KS->>KC: segmento con datos
KC-->>C: read() retorna la línea
C->>KC: write("NOMBRE ana")
KC->>KS: segmento con datos
KS-->>S: read() retorna la línea
Note over C,S: la partida continúa sobre este socket
C->>KC: close()
KC->>KS: FIN
KS-->>S: read() retorna 0 bytes (EOF)
Tres observaciones sobre este diagrama que suelen pasarse por alto:
acceptdevuelve un socket distinto del que escucha. El socket de escucha nunca transporta datos; solo produce sockets conectados. Esto permite que un solo puerto atienda miles de conexiones simultáneas: la conexión se identifica por la tupla completa de dirección y puerto de origen y destino, no solo por el puerto local.SO_REUSEADDRno es opcional en desarrollo. Sin él, cuando reinicias el servidor el puerto queda inutilizable durante el tiempo de espera del estadoTIME_WAIT, típicamente uno o dos minutos, y obtienes un error de dirección en uso.- La lectura que devuelve cero bytes es el cierre limpio. No es un error, es el fin del flujo. Cualquier otro resultado negativo sí lo es. Confundirlos produce servidores que dejan conexiones zombis.
El juego: RPSLS y su tabla de decisión
El cachipún clásico tiene un empate estructural cuando ambos juegan lo mismo y una relación cíclica de tres elementos. La variante RPSLS agrega dos opciones para que el ciclo sea de cinco y cada opción venza exactamente a otras dos y pierda contra otras dos. Esto reduce la probabilidad de empate de un tercio a un quinto y elimina algunas estrategias humanas predecibles.
Las relaciones son:
- Piedra aplasta tijeras y aplasta lagarto.
- Papel cubre piedra y refuta a Spock.
- Tijeras cortan papel y decapitan al lagarto.
- Lagarto come papel y envenena a Spock.
- Spock rompe tijeras y vaporiza piedra.
La codificación del resultado que usaremos en el protocolo es la de la guía original: 0 es empate, 1 es que el jugador consultado pierde, 2 es que gana. Con eso, la tabla completa vista desde la jugada del jugador 1 contra la del jugador 2 queda así:
| J1 \ J2 | Piedra | Papel | Tijeras | Lagarto | Spock |
|---|---|---|---|---|---|
| Piedra | 0 | 1 | 2 | 2 | 1 |
| Papel | 2 | 0 | 1 | 1 | 2 |
| Tijeras | 1 | 2 | 0 | 2 | 1 |
| Lagarto | 1 | 2 | 1 | 0 | 2 |
| Spock | 2 | 1 | 2 | 1 | 0 |
Hay una regla adicional del enunciado que vale la pena subrayar porque tiene consecuencias de diseño: una jugada inválida se considera empate. Esto convierte el manejo de entrada basura en algo trivial y, sobre todo, elimina la tentación de dejar al servidor esperando indefinidamente a que el cliente se corrija. En un servidor concurrente, “esperar a que el otro se comporte bien” es la definición operativa de un cuelgue.
Existe una forma cerrada de calcular el resultado sin tabla. Si asignas índices 0 a 4 a las opciones en el orden piedra, Spock, papel, lagarto, tijeras —es decir, ordenadas de modo que cada una venza a las dos siguientes en sentido circular— entonces el resultado depende solo de la diferencia módulo 5. Mantendremos la tabla explícita en el código porque es más legible y porque el objetivo del proyecto no es la aritmética modular, pero conviene saber que el atajo existe.
El protocolo: hacerlo interoperable a propósito
El enunciado exige que tu cliente funcione contra el servidor de cualquier compañero y que tu servidor acepte el cliente de cualquiera. Esa exigencia parece burocrática y es en realidad la lección más transferible del proyecto: un protocolo no es lo que tu código hace, es lo que está escrito y ambos extremos acordaron.
Definimos un protocolo de texto, orientado a líneas, con \n como terminador y tokens separados por espacios. Texto en vez de binario porque se depura con telnet o nc, y orientado a líneas porque nos resuelve el problema del framing de la manera más simple posible.
| Dirección | Mensaje | Significado |
|---|---|---|
| S → C | BIENVENIDO RPSLS/1.0 | Saludo inicial y versión del protocolo |
| C → S | NOMBRE <texto> | El cliente se identifica |
| S → C | OK JUGADOR <1|2> | Registro aceptado y número asignado |
| S → C | ESPERANDO_RIVAL | Aún no hay dos jugadores en la sala |
| S → C | RONDA <n> | Comienza la ronda número n |
| S → C | PIDE_JUGADA <ms> | Solicita jugada con un plazo en milisegundos |
| C → S | JUGADA <opcion> | Envía piedra, papel, tijera, lagarto o spock |
| S → C | RESULTADO <mia> <suya> <codigo> | Jugadas de ambos y código 0, 1 o 2 desde la perspectiva del receptor |
| S → C | MARCADOR <propio> <rival> | Puntaje acumulado |
| C → S | PING <token> | Sonda de latencia |
| S → C | PONG <token> | Eco inmediato de la sonda |
| C → S | SALIR | Cierre voluntario |
| S → C | ERROR <codigo> <texto> | Mensaje malformado o fuera de estado |
| S → C | FIN <motivo> | El servidor cierra la sesión |
Reglas de robustez que el protocolo impone y que conviene escribir antes de programar:
- Todo comando desconocido produce
ERRORpero no cierra la conexión. Cerrar ante lo inesperado hace imposible evolucionar el protocolo. - Las jugadas se comparan sin distinguir mayúsculas y sin acentos.
TIJERA,tijerayTijerason la misma cosa;tijerasen plural también se acepta, porque es el error más frecuente. - El plazo lo fija el servidor y viaja en el mensaje. Así el cliente sabe cuánto tiene, y el servidor puede cambiar de política sin romper clientes.
PINGse responde en cualquier estado. Es la única forma de que el cliente mida latencia sin interferir con el juego.
Y una regla de arquitectura: el servidor nunca confía en el cliente. La validación de la jugada ocurre en el servidor, la puntuación se lleva en el servidor, y el reloj que decide si una jugada llegó a tiempo es el del servidor.
La máquina de estados de una sesión
stateDiagram-v2
[*] --> Conectado: accept()
Conectado --> Identificado: NOMBRE valido
Conectado --> Cerrado: timeout de saludo
Identificado --> EnEspera: sala incompleta
EnEspera --> EnRonda: llega el segundo jugador
Identificado --> EnRonda: sala ya tenia un jugador
EnRonda --> EsperandoJugada: PIDE_JUGADA enviado
EsperandoJugada --> Resolviendo: JUGADA recibida
EsperandoJugada --> Resolviendo: vence el plazo (cuenta como invalida)
Resolviendo --> EnRonda: RESULTADO y MARCADOR enviados
EnRonda --> Cerrado: SALIR o EOF
EsperandoJugada --> Cerrado: EOF del socket
Cerrado --> [*]
note right of EsperandoJugada
PING/PONG se atienden
sin cambiar de estado
end note
Que la transición por vencimiento del plazo lleve al mismo lugar que la jugada recibida es lo que garantiza que la partida nunca se cuelgue por un cliente lento o malicioso.
Etapa 1: el servidor por turnos en Elixir
Elixir es una elección natural para este proyecto porque su modelo de concurrencia es exactamente el que necesita un servidor de red: procesos livianos aislados, uno por conexión, sin memoria compartida, con paso de mensajes y con supervisión. El planificador de la máquina virtual de Erlang es preemptivo y equitativo, así que un proceso que se traba no monopoliza el núcleo.
Estructura de procesos
flowchart TD
APP[Aplicacion Cachipun] --> SUP[Supervisor raiz<br/>estrategia one_for_one]
SUP --> REG[Registry<br/>nombres de salas]
SUP --> DSUP_S[DynamicSupervisor<br/>salas]
SUP --> DSUP_C[DynamicSupervisor<br/>conexiones]
SUP --> EMP[Emparejador<br/>GenServer con cola FIFO]
SUP --> ACC[Acceptor<br/>Task con listen y accept]
ACC -->|accept devuelve socket| DSUP_C
DSUP_C --> C1[Conexion jugador A<br/>GenServer, dueno del socket]
DSUP_C --> C2[Conexion jugador B<br/>GenServer, dueno del socket]
C1 -->|entrar| EMP
C2 -->|entrar| EMP
EMP -->|dos en cola: crear sala| DSUP_S
DSUP_S --> SALA[Sala<br/>GenServer, dueno del estado de la partida]
SALA -->|PIDE_JUGADA| C1
SALA -->|PIDE_JUGADA| C2
C1 -->|JUGADA| SALA
C2 -->|JUGADA| SALA
style SALA fill:#2b6cb0,color:#fff
style ACC fill:#2f855a,color:#fff
Fíjate en la propiedad central: cada dato tiene exactamente un dueño. El socket lo posee su proceso de conexión y nadie más escribe en él; el estado de la partida lo posee la sala y nadie más lo modifica. No hay candados en todo el sistema porque no hay nada que proteger: la exclusión mutua está garantizada por el hecho de que un proceso atiende sus mensajes de a uno. Esta es la traducción práctica de lo que en el capítulo de monitores llamábamos “serialización por encapsulamiento”.
El proyecto
mix new cachipun --sup
cd cachipun
mix.exs:
defmodule Cachipun.MixProject do
use Mix.Project
def project do
[
app: :cachipun,
version: "1.0.0",
elixir: "~> 1.15",
start_permanent: Mix.env() == :prod,
deps: []
]
end
def application do
[
extra_applications: [:logger],
mod: {Cachipun.Application, []}
]
end
end
Las reglas del juego, aisladas y puras
Separar las reglas del transporte es lo que permite probarlas sin abrir un socket.
defmodule Cachipun.Reglas do
@moduledoc """
Reglas de RPSLS. Modulo puro: sin procesos, sin sockets, sin estado.
Codigos de resultado, desde la perspectiva del primer argumento:
0 = empate, 1 = pierde, 2 = gana.
"""
@opciones [:piedra, :papel, :tijera, :lagarto, :spock]
# Cada opcion vence a las dos que aparecen en su lista.
@vence %{
piedra: [:tijera, :lagarto],
papel: [:piedra, :spock],
tijera: [:papel, :lagarto],
lagarto: [:papel, :spock],
spock: [:tijera, :piedra]
}
@sinonimos %{
"piedra" => :piedra,
"roca" => :piedra,
"rock" => :piedra,
"papel" => :papel,
"paper" => :papel,
"tijera" => :tijera,
"tijeras" => :tijera,
"scissors" => :tijera,
"lagarto" => :lagarto,
"lizard" => :lagarto,
"spock" => :spock
}
def opciones, do: @opciones
@doc """
Convierte texto libre en una opcion valida.
Devuelve {:ok, opcion} o :invalida. Nunca lanza excepciones:
la entrada viene de la red y no se confia en ella.
"""
def parsear(texto) when is_binary(texto) do
normalizado =
texto
|> String.trim()
|> String.downcase()
|> quitar_acentos()
case Map.fetch(@sinonimos, normalizado) do
{:ok, opcion} -> {:ok, opcion}
:error -> :invalida
end
end
def parsear(_), do: :invalida
defp quitar_acentos(texto) do
texto
|> String.replace("á", "a")
|> String.replace("é", "e")
|> String.replace("í", "i")
|> String.replace("ó", "o")
|> String.replace("ú", "u")
end
@doc """
Resuelve una ronda. Acepta :invalida como jugada.
Si cualquiera de las dos es invalida, el enunciado manda empate.
"""
def resolver(:invalida, _), do: 0
def resolver(_, :invalida), do: 0
def resolver(misma, misma), do: 0
def resolver(mia, suya) do
if suya in Map.fetch!(@vence, mia), do: 2, else: 1
end
@doc "Invierte el codigo para enviarlo al otro jugador."
def invertir(0), do: 0
def invertir(1), do: 2
def invertir(2), do: 1
end
Una prueba de propiedades que vale más que veinte casos sueltos:
defmodule Cachipun.ReglasTest do
use ExUnit.Case, async: true
alias Cachipun.Reglas
test "toda opcion empata consigo misma" do
for o <- Reglas.opciones() do
assert Reglas.resolver(o, o) == 0
end
end
test "la relacion es antisimetrica" do
for a <- Reglas.opciones(), b <- Reglas.opciones(), a != b do
assert Reglas.resolver(a, b) == Reglas.invertir(Reglas.resolver(b, a))
end
end
test "cada opcion gana exactamente dos veces y pierde dos veces" do
for a <- Reglas.opciones() do
resultados = for b <- Reglas.opciones(), do: Reglas.resolver(a, b)
assert Enum.count(resultados, &(&1 == 2)) == 2
assert Enum.count(resultados, &(&1 == 1)) == 2
assert Enum.count(resultados, &(&1 == 0)) == 1
end
end
test "entrada basura es invalida y produce empate" do
assert Reglas.parsear("banana") == :invalida
assert Reglas.resolver(:invalida, :piedra) == 0
end
test "acepta sinonimos y variantes de escritura" do
assert Reglas.parsear("TIJERAS") == {:ok, :tijera}
assert Reglas.parsear(" Spock ") == {:ok, :spock}
end
end
El árbol de supervisión
defmodule Cachipun.Application do
use Application
require Logger
@puerto 6502
@impl true
def start(_tipo, _args) do
puerto = puerto_configurado()
hijos = [
{Registry, keys: :unique, name: Cachipun.Registro},
{DynamicSupervisor, name: Cachipun.SupervisorSalas, strategy: :one_for_one},
{DynamicSupervisor, name: Cachipun.SupervisorConexiones, strategy: :one_for_one},
Cachipun.Emparejador,
{Cachipun.Acceptor, puerto}
]
Logger.info("Servidor RPSLS escuchando en el puerto #{puerto}")
Supervisor.start_link(hijos, strategy: :one_for_one, name: Cachipun.Supervisor)
end
defp puerto_configurado do
case System.get_env("PUERTO") do
nil -> @puerto
valor -> String.to_integer(valor)
end
end
end
El orden de los hijos importa: el aceptador va último porque necesita que el emparejador y los supervisores dinámicos ya existan cuando entre la primera conexión.
El aceptador
defmodule Cachipun.Acceptor do
@moduledoc """
Abre el socket de escucha y acepta conexiones en un bucle.
Cada conexion aceptada se convierte en un proceso supervisado.
"""
use Task, restart: :permanent
require Logger
def start_link(puerto), do: Task.start_link(__MODULE__, :run, [puerto])
def run(puerto) do
opciones = [
:binary,
packet: :line,
active: false,
reuseaddr: true,
nodelay: true,
backlog: 128
]
{:ok, escucha} = :gen_tcp.listen(puerto, opciones)
bucle(escucha)
end
defp bucle(escucha) do
case :gen_tcp.accept(escucha) do
{:ok, socket} ->
{:ok, pid} =
DynamicSupervisor.start_child(
Cachipun.SupervisorConexiones,
{Cachipun.Conexion, socket}
)
# Transferir la propiedad del socket al proceso que lo va a usar.
# Hasta que esto ocurre, los mensajes del socket llegarian aqui.
:ok = :gen_tcp.controlling_process(socket, pid)
send(pid, :socket_listo)
bucle(escucha)
{:error, :closed} ->
Logger.warning("Socket de escucha cerrado, terminando aceptador")
:ok
{:error, motivo} ->
Logger.error("accept fallo: #{inspect(motivo)}")
bucle(escucha)
end
end
end
Cuatro decisiones merecen explicación:
packet: :linehace que la máquina virtual haga el framing por ti: solo recibes mensajes que terminan en salto de línea. Sin esto tendrías que acumular fragmentos a mano, que es donde se rompe la mitad de los servidores caseros.active: falseseguido deactive: :oncees el patrón de contrapresión. En modo activo permanente, la máquina virtual empuja al buzón del proceso todo lo que llegue, y un cliente hostil puede agotar tu memoria. Con:oncepides un mensaje a la vez y el control de flujo de TCP hace el resto: si no lees, la ventana se cierra y el emisor se frena.nodelay: truedesactiva el algoritmo de Nagle, que agrupa escrituras pequeñas para ahorrar cabeceras. Ahorrar cabeceras es excelente para transferir archivos y pésimo para un juego: agrega hasta 40 ms de retraso a mensajes que ya eran diminutos.controlling_processexiste porque en Erlang el socket pertenece al proceso que lo creó y sus mensajes van a ese buzón. Si te olvidas de transferirlo, el aceptador recibe los datos del jugador y la conexión nunca responde.
El proceso de conexión
defmodule Cachipun.Conexion do
@moduledoc """
Un proceso por cliente. Es el unico dueno del socket: nadie mas
escribe en el. Traduce entre lineas de texto y mensajes internos.
"""
use GenServer, restart: :temporary
require Logger
alias Cachipun.Reglas
@plazo_saludo 15_000
def start_link(socket), do: GenServer.start_link(__MODULE__, socket)
# API que usa la Sala para hablar con este cliente.
def enviar_linea(pid, linea), do: GenServer.cast(pid, {:enviar, linea})
def asignar_sala(pid, sala, numero), do: GenServer.cast(pid, {:sala, sala, numero})
@impl true
def init(socket) do
estado = %{
socket: socket,
nombre: nil,
sala: nil,
numero: nil,
esperando_jugada: false
}
{:ok, estado}
end
@impl true
def handle_info(:socket_listo, estado) do
escribir(estado.socket, "BIENVENIDO RPSLS/1.0")
:ok = :inet.setopts(estado.socket, active: :once)
{:noreply, estado, @plazo_saludo}
end
# Timeout del GenServer: el cliente se conecto y no dijo nada.
def handle_info(:timeout, %{nombre: nil} = estado) do
escribir(estado.socket, "FIN sin_identificacion")
{:stop, :normal, estado}
end
def handle_info(:timeout, estado), do: {:noreply, estado}
def handle_info({:tcp, socket, datos}, estado) do
nuevo = procesar(String.trim(datos), estado)
:ok = :inet.setopts(socket, active: :once)
case nuevo do
{:stop, e} -> {:stop, :normal, e}
e when is_map(e) and e.nombre == nil -> {:noreply, e, @plazo_saludo}
e -> {:noreply, e}
end
end
def handle_info({:tcp_closed, _socket}, estado) do
avisar_salida(estado)
{:stop, :normal, estado}
end
def handle_info({:tcp_error, _socket, motivo}, estado) do
Logger.warning("error de socket: #{inspect(motivo)}")
avisar_salida(estado)
{:stop, :normal, estado}
end
@impl true
def handle_cast({:enviar, linea}, estado) do
escribir(estado.socket, linea)
{:noreply, estado}
end
def handle_cast({:sala, sala, numero}, estado) do
escribir(estado.socket, "OK JUGADOR #{numero}")
{:noreply, %{estado | sala: sala, numero: numero}}
end
@impl true
def terminate(_motivo, estado) do
:gen_tcp.close(estado.socket)
:ok
end
# --- Interprete del protocolo ---
defp procesar("PING " <> token, estado) do
escribir(estado.socket, "PONG #{token}")
estado
end
defp procesar("SALIR", estado) do
escribir(estado.socket, "FIN adios")
avisar_salida(estado)
{:stop, estado}
end
defp procesar("NOMBRE " <> nombre, %{nombre: nil} = estado) do
limpio = nombre |> String.trim() |> String.slice(0, 24)
if limpio == "" do
escribir(estado.socket, "ERROR 400 nombre_vacio")
estado
else
Cachipun.Emparejador.entrar(self(), limpio)
%{estado | nombre: limpio}
end
end
defp procesar("NOMBRE " <> _, estado) do
escribir(estado.socket, "ERROR 409 ya_identificado")
estado
end
defp procesar("JUGADA " <> texto, %{sala: sala} = estado) when sala != nil do
jugada =
case Reglas.parsear(texto) do
{:ok, opcion} -> opcion
:invalida -> :invalida
end
Cachipun.Sala.recibir_jugada(sala, self(), jugada)
estado
end
defp procesar("JUGADA " <> _, estado) do
escribir(estado.socket, "ERROR 425 sin_sala")
estado
end
defp procesar("", estado), do: estado
defp procesar(otro, estado) do
comando = otro |> String.split(" ", parts: 2) |> hd()
escribir(estado.socket, "ERROR 400 comando_desconocido:#{comando}")
estado
end
defp avisar_salida(%{sala: nil}), do: :ok
defp avisar_salida(%{sala: sala}) do
Cachipun.Sala.salir(sala, self())
end
defp escribir(socket, linea) do
case :gen_tcp.send(socket, linea <> "\n") do
:ok -> :ok
{:error, _} -> :ok
end
end
end
Observa el manejo de :gen_tcp.send que ignora el error. No es descuido: si el envío falla, el socket ya está roto y el mensaje {:tcp_closed, _} va a llegar de todos modos. Propagar el error aquí solo duplicaría el camino de terminación.
El emparejador
defmodule Cachipun.Emparejador do
@moduledoc """
Cola FIFO de jugadores esperando rival. Cuando hay dos, crea una sala.
Es un cuello de botella deliberado: serializa el emparejamiento y por eso
no puede formar salas con tres jugadores ni perder a nadie en el camino.
"""
use GenServer
require Logger
def start_link(_), do: GenServer.start_link(__MODULE__, :ok, name: __MODULE__)
def entrar(pid, nombre), do: GenServer.cast(__MODULE__, {:entrar, pid, nombre})
@impl true
def init(:ok), do: {:ok, %{cola: :queue.new(), monitores: %{}, contador: 0}}
@impl true
def handle_cast({:entrar, pid, nombre}, estado) do
ref = Process.monitor(pid)
cola = :queue.in({pid, nombre, ref}, estado.cola)
estado = %{estado | cola: cola, monitores: Map.put(estado.monitores, ref, pid)}
{:noreply, intentar_emparejar(estado)}
end
@impl true
def handle_info({:DOWN, ref, :process, _pid, _motivo}, estado) do
# Un jugador se fue mientras esperaba: se saca de la cola.
cola = :queue.filter(fn {_p, _n, r} -> r != ref end, estado.cola)
{:noreply, %{estado | cola: cola, monitores: Map.delete(estado.monitores, ref)}}
end
defp intentar_emparejar(estado) do
case :queue.out(estado.cola) do
{{:value, primero}, resto} ->
case :queue.out(resto) do
{{:value, segundo}, resto2} ->
crear_sala(primero, segundo, estado.contador + 1)
desmonitorear(primero)
desmonitorear(segundo)
%{estado | cola: resto2, contador: estado.contador + 1}
{:empty, _} ->
avisar_espera(primero)
estado
end
{:empty, _} ->
estado
end
end
defp crear_sala({pid1, n1, _}, {pid2, n2, _}, numero) do
spec = {Cachipun.Sala, %{jugadores: [{pid1, n1}, {pid2, n2}], numero: numero}}
{:ok, _sala} = DynamicSupervisor.start_child(Cachipun.SupervisorSalas, spec)
Logger.info("Sala #{numero}: #{n1} contra #{n2}")
end
defp avisar_espera({pid, _nombre, _ref}) do
Cachipun.Conexion.enviar_linea(pid, "ESPERANDO_RIVAL")
end
defp desmonitorear({_pid, _nombre, ref}), do: Process.demonitor(ref, [:flush])
end
El Process.monitor sobre el jugador en espera resuelve un problema real: alguien se conecta, dice su nombre, cierra la ventana y se va. Sin monitor, ese proceso muerto queda en la cola y el próximo jugador se empareja con un fantasma que nunca responderá. Con monitor, la cola se limpia sola. Este es el equivalente distribuido de liberar un recurso cuando el dueño muere, y es exactamente el problema que en el capítulo de semáforos no tenía solución limpia.
La sala
defmodule Cachipun.Sala do
@moduledoc """
Duena del estado de una partida. Todo el estado compartido de la partida
vive aqui y solo este proceso lo modifica.
"""
use GenServer, restart: :temporary
require Logger
alias Cachipun.{Conexion, Reglas}
@plazo_jugada 10_000
def start_link(args), do: GenServer.start_link(__MODULE__, args)
def recibir_jugada(sala, pid, jugada),
do: GenServer.cast(sala, {:jugada, pid, jugada})
def salir(sala, pid), do: GenServer.cast(sala, {:salir, pid})
@impl true
def init(%{jugadores: [{pid1, n1}, {pid2, n2}], numero: numero}) do
Process.monitor(pid1)
Process.monitor(pid2)
Conexion.asignar_sala(pid1, self(), 1)
Conexion.asignar_sala(pid2, self(), 2)
estado = %{
numero: numero,
jugadores: %{pid1 => %{nombre: n1, puntos: 0}, pid2 => %{nombre: n2, puntos: 0}},
orden: [pid1, pid2],
jugadas: %{},
ronda: 0,
temporizador: nil
}
{:ok, iniciar_ronda(estado)}
end
@impl true
def handle_cast({:jugada, pid, jugada}, estado) do
cond do
not Map.has_key?(estado.jugadores, pid) ->
{:noreply, estado}
Map.has_key?(estado.jugadas, pid) ->
Conexion.enviar_linea(pid, "ERROR 409 jugada_duplicada")
{:noreply, estado}
true ->
estado = %{estado | jugadas: Map.put(estado.jugadas, pid, jugada)}
if map_size(estado.jugadas) == 2 do
{:noreply, resolver_ronda(estado)}
else
{:noreply, estado}
end
end
end
def handle_cast({:salir, pid}, estado), do: cerrar_por(pid, estado)
@impl true
def handle_info(:vence_plazo, estado) do
# Quien no jugo queda con :invalida, que segun el enunciado es empate.
faltantes = Enum.reject(estado.orden, &Map.has_key?(estado.jugadas, &1))
jugadas = Enum.reduce(faltantes, estado.jugadas, &Map.put(&2, &1, :invalida))
{:noreply, resolver_ronda(%{estado | jugadas: jugadas})}
end
def handle_info({:DOWN, _ref, :process, pid, _motivo}, estado),
do: cerrar_por(pid, estado)
# --- Logica de ronda ---
defp iniciar_ronda(estado) do
ronda = estado.ronda + 1
Enum.each(estado.orden, fn pid ->
Conexion.enviar_linea(pid, "RONDA #{ronda}")
Conexion.enviar_linea(pid, "PIDE_JUGADA #{@plazo_jugada}")
end)
temporizador = Process.send_after(self(), :vence_plazo, @plazo_jugada)
%{estado | ronda: ronda, jugadas: %{}, temporizador: temporizador}
end
defp resolver_ronda(estado) do
cancelar_temporizador(estado.temporizador)
[pid1, pid2] = estado.orden
j1 = Map.fetch!(estado.jugadas, pid1)
j2 = Map.fetch!(estado.jugadas, pid2)
codigo1 = Reglas.resolver(j1, j2)
codigo2 = Reglas.invertir(codigo1)
jugadores =
estado.jugadores
|> sumar_punto(pid1, codigo1)
|> sumar_punto(pid2, codigo2)
enviar_resultado(pid1, pid2, j1, j2, codigo1, jugadores)
enviar_resultado(pid2, pid1, j2, j1, codigo2, jugadores)
Logger.debug("Sala #{estado.numero} ronda #{estado.ronda}: #{j1} vs #{j2} -> #{codigo1}")
iniciar_ronda(%{estado | jugadores: jugadores})
end
defp enviar_resultado(pid, rival, mia, suya, codigo, jugadores) do
Conexion.enviar_linea(pid, "RESULTADO #{mia} #{suya} #{codigo}")
propio = jugadores[pid].puntos
ajeno = jugadores[rival].puntos
Conexion.enviar_linea(pid, "MARCADOR #{propio} #{ajeno}")
end
defp sumar_punto(jugadores, pid, 2),
do: update_in(jugadores, [pid, :puntos], &(&1 + 1))
defp sumar_punto(jugadores, _pid, _codigo), do: jugadores
defp cerrar_por(pid, estado) do
cancelar_temporizador(estado.temporizador)
estado.orden
|> Enum.reject(&(&1 == pid))
|> Enum.each(&Conexion.enviar_linea(&1, "FIN rival_desconectado"))
{:stop, :normal, estado}
end
defp cancelar_temporizador(nil), do: :ok
defp cancelar_temporizador(ref), do: Process.cancel_timer(ref)
end
Este módulo concentra la parte interesante del ejercicio. Nota tres cosas:
- La sala nunca lee del socket. Habla con procesos, no con descriptores. Esa separación permite escribir pruebas de la sala usando procesos falsos en lugar de conexiones reales.
- El temporizador es parte del estado. Si llegan las dos jugadas antes del plazo, hay que cancelarlo; si no, el
:vence_plazode la ronda anterior resolvería una ronda que ya terminó. Este es un error clásico de temporizadores rezagados. - Cuando un jugador cae, la sala muere. Con
restart: :temporary, el supervisor no la reinicia, que es lo correcto: no tiene sentido revivir una partida cuyos participantes ya no están. La política de reinicio es una decisión de diseño, no un valor por defecto que se acepta sin pensar.
El cliente
defmodule Cachipun.Cliente do
@moduledoc """
Cliente de linea de comandos. Compatible con cualquier servidor
que hable el protocolo RPSLS/1.0.
Uso: Cachipun.Cliente.jugar("localhost", 6502, "ana")
"""
def jugar(host, puerto, nombre) do
opciones = [:binary, packet: :line, active: false, nodelay: true]
{:ok, socket} = :gen_tcp.connect(to_charlist(host), puerto, opciones)
{:ok, saludo} = :gen_tcp.recv(socket, 0, 5_000)
IO.puts("<- #{String.trim(saludo)}")
:ok = :gen_tcp.send(socket, "NOMBRE #{nombre}\n")
medir_latencia(socket)
bucle(socket)
end
defp bucle(socket) do
case :gen_tcp.recv(socket, 0, :infinity) do
{:ok, linea} ->
linea = String.trim(linea)
IO.puts("<- #{linea}")
responder(socket, linea)
bucle(socket)
{:error, :closed} ->
IO.puts("Conexion cerrada por el servidor")
:ok
{:error, motivo} ->
IO.puts("Error: #{inspect(motivo)}")
:ok
end
end
defp responder(socket, "PIDE_JUGADA " <> _plazo) do
jugada = IO.gets("Tu jugada (piedra/papel/tijera/lagarto/spock): ") |> String.trim()
:gen_tcp.send(socket, "JUGADA #{jugada}\n")
end
defp responder(_socket, "FIN " <> _), do: :ok
defp responder(_socket, _), do: :ok
@doc "Mide el tiempo de ida y vuelta enviando cinco sondas."
def medir_latencia(socket, muestras \\ 5) do
tiempos =
for i <- 1..muestras do
inicio = System.monotonic_time(:microsecond)
:ok = :gen_tcp.send(socket, "PING #{i}\n")
{:ok, _pong} = esperar_pong(socket, i)
System.monotonic_time(:microsecond) - inicio
end
ordenados = Enum.sort(tiempos)
mediana = Enum.at(ordenados, div(muestras, 2)) / 1000
peor = List.last(ordenados) / 1000
IO.puts("RTT mediana: #{Float.round(mediana, 2)} ms, peor: #{Float.round(peor, 2)} ms")
end
defp esperar_pong(socket, token) do
esperado = "PONG #{token}"
case :gen_tcp.recv(socket, 0, 5_000) do
{:ok, linea} ->
if String.trim(linea) == esperado, do: {:ok, linea}, else: esperar_pong(socket, token)
otro ->
otro
end
end
end
Para probar sin escribir cliente, nc localhost 6502 en dos terminales alcanza. Esa es la ventaja de un protocolo de texto orientado a líneas.
Etapa 2: del turno al tiempo real, el tick loop
Hasta aquí el servidor es reactivo: solo hace algo cuando llega un mensaje. Un juego de acción no puede funcionar así. La simulación tiene que avanzar aunque nadie escriba, porque hay proyectiles en vuelo, gravedad, temporizadores de habilidades y bots. El mecanismo que resuelve esto es el tick loop: un bucle que avanza el estado del mundo en pasos de duración fija.
Por qué el paso debe ser fijo
Podrías avanzar la simulación usando el tiempo transcurrido real desde el paso anterior. Esa técnica, llamada paso variable, tiene tres defectos graves en un servidor:
- No es determinista. Dos ejecuciones con las mismas entradas producen resultados distintos porque los intervalos difieren. Adiós a las repeticiones, a la depuración reproducible y a la validación cruzada entre servidor y cliente.
- La física se rompe. Los integradores numéricos simples acumulan error en proporción al tamaño del paso. Un salto que dura 12 pasos de 16 ms no es el mismo salto que en 3 pasos de 64 ms; en el segundo caso el personaje puede atravesar una pared.
- Es explotable. Si el paso depende del tiempo real y el servidor se satura, la simulación se vuelve lenta para todos. Un atacante que sabe esto puede provocar la saturación a propósito.
La solución estándar es el acumulador de tiempo: se mide el tiempo real transcurrido, se suma a un acumulador y se ejecutan tantos pasos fijos como quepan, dejando el resto para la próxima vuelta.
flowchart TD
A[Inicio del tick] --> B[ahora = reloj monotonico]
B --> C[acumulador += ahora - anterior]
C --> D{acumulador >= dt?}
D -->|si| E[Drenar buzon de entradas<br/>hasta vaciarlo]
E --> F[Aplicar entradas al mundo]
F --> G[Avanzar simulacion un paso fijo dt]
G --> H[acumulador -= dt]
H --> I{pasos ejecutados > maximo?}
I -->|no| D
I -->|si| J[Descartar el resto del acumulador<br/>evitar espiral de la muerte]
J --> K
D -->|no| K[Construir instantanea del mundo]
K --> L[Enviar a cada cliente<br/>estado o delta]
L --> M[proximo = anterior + dt<br/>calculado sobre el ideal, no sobre ahora]
M --> N{proximo <= ahora?}
N -->|si, vamos atrasados| O[Registrar retraso y avanzar<br/>proximo hasta superar ahora]
N -->|no| P[Dormir hasta proximo]
O --> P
P --> A
style G fill:#2b6cb0,color:#fff
style J fill:#c53030,color:#fff
Dos detalles del diagrama son la diferencia entre un tick loop correcto y uno que se degrada con el tiempo:
- La deriva. Si calculas el próximo instante como “ahora más dt”, cada retraso se acumula para siempre y en una hora el servidor corre notoriamente lento. Hay que calcularlo sobre el instante ideal anterior, no sobre el actual. Es la misma diferencia que entre un reloj que suma segundos y uno que consulta la hora.
- La espiral de la muerte. Si un tick tarda más que
dt, el acumulador crece; en el tick siguiente hay que ejecutar dos pasos, que tardan más aún, y el acumulador crece más. Sin un tope de pasos por vuelta, el servidor entra en una espiral de la que no sale. El tope convierte una falla catastrófica en una degradación visible: el mundo va en cámara lenta, pero el servidor responde y los registros lo delatan.
Frecuencias típicas
| Tick rate | dt | Uso típico | Presupuesto por tick |
|---|---|---|---|
| 10 Hz | 100 ms | Estrategia por turnos con animación, MMO de mundo abierto | 100 ms |
| 20 Hz | 50 ms | MMO de acción, juegos de supervivencia | 50 ms |
| 30 Hz | 33.3 ms | Estándar de muchos shooters de consola | 33 ms |
| 60 Hz | 16.7 ms | Shooters competitivos, peleas | 16 ms |
| 128 Hz | 7.8 ms | Servidores premium de shooters tácticos | 7.8 ms |
El presupuesto por tick es un límite duro: incluye leer entradas, simular y serializar y enviar el estado a todos los clientes. Si tienes 100 jugadores a 60 Hz, tu servidor hace 6000 envíos por segundo solo de estado. Por eso el ancho de banda, no la CPU, suele ser la primera pared que se golpea.
El mundo con tick loop en Elixir
defmodule Arena.Mundo do
@moduledoc """
Simulacion autoritativa a frecuencia fija.
Unico dueno del estado del mundo. Las conexiones le envian intenciones,
nunca posiciones.
"""
use GenServer
require Logger
@hz 20
@dt_ms div(1000, @hz)
@max_pasos_por_tick 5
@velocidad 220.0
@limite 1000.0
def start_link(opts), do: GenServer.start_link(__MODULE__, opts, name: __MODULE__)
# --- API: las conexiones solo declaran intenciones ---
def unirse(pid, nombre), do: GenServer.cast(__MODULE__, {:unirse, pid, nombre})
def salir(pid), do: GenServer.cast(__MODULE__, {:salir, pid})
def intencion(pid, dx, dy, seq), do: GenServer.cast(__MODULE__, {:intencion, pid, dx, dy, seq})
def metricas, do: GenServer.call(__MODULE__, :metricas)
@impl true
def init(_opts) do
ahora = System.monotonic_time(:millisecond)
estado = %{
entidades: %{},
tick: 0,
acumulador: 0,
ultimo: ahora,
ideal: ahora + @dt_ms,
retrasos: 0,
duraciones: []
}
Process.send_after(self(), :tick, @dt_ms)
Logger.info("Mundo iniciado a #{@hz} Hz (dt = #{@dt_ms} ms)")
{:ok, estado}
end
@impl true
def handle_cast({:unirse, pid, nombre}, estado) do
Process.monitor(pid)
entidad = %{
nombre: nombre,
x: :rand.uniform() * @limite,
y: :rand.uniform() * @limite,
dx: 0.0,
dy: 0.0,
ultima_seq: 0
}
{:noreply, put_in(estado.entidades[pid], entidad)}
end
def handle_cast({:salir, pid}, estado),
do: {:noreply, update_in(estado.entidades, &Map.delete(&1, pid))}
def handle_cast({:intencion, pid, dx, dy, seq}, estado) do
case Map.fetch(estado.entidades, pid) do
{:ok, entidad} when seq > entidad.ultima_seq ->
# Normalizar: el cliente no decide su velocidad, solo su direccion.
{ndx, ndy} = normalizar(dx, dy)
actualizada = %{entidad | dx: ndx, dy: ndy, ultima_seq: seq}
{:noreply, put_in(estado.entidades[pid], actualizada)}
_ ->
# Intencion vieja o de un pid desconocido: se descarta en silencio.
{:noreply, estado}
end
end
@impl true
def handle_call(:metricas, _from, estado) do
muestras = estado.duraciones
resumen =
if muestras == [] do
%{muestras: 0}
else
ordenadas = Enum.sort(muestras)
n = length(ordenadas)
%{
muestras: n,
p50: Enum.at(ordenadas, div(n * 50, 100)),
p95: Enum.at(ordenadas, min(div(n * 95, 100), n - 1)),
p99: Enum.at(ordenadas, min(div(n * 99, 100), n - 1)),
max: List.last(ordenadas),
retrasos: estado.retrasos,
tick: estado.tick
}
end
{:reply, resumen, estado}
end
@impl true
def handle_info(:tick, estado) do
comienzo = System.monotonic_time(:microsecond)
ahora = System.monotonic_time(:millisecond)
estado = %{estado | acumulador: estado.acumulador + (ahora - estado.ultimo), ultimo: ahora}
estado = ejecutar_pasos(estado, 0)
difundir(estado)
duracion = System.monotonic_time(:microsecond) - comienzo
estado = registrar(estado, duracion)
{espera, estado} = programar_siguiente(estado, ahora)
Process.send_after(self(), :tick, espera)
{:noreply, estado}
end
def handle_info({:DOWN, _ref, :process, pid, _motivo}, estado),
do: {:noreply, update_in(estado.entidades, &Map.delete(&1, pid))}
# --- Nucleo de la simulacion ---
defp ejecutar_pasos(estado, pasos) when pasos >= @max_pasos_por_tick do
# Tope alcanzado: se descarta el atraso para no entrar en espiral.
Logger.warning("tick #{estado.tick}: tope de pasos alcanzado, se descarta el acumulador")
%{estado | acumulador: 0}
end
defp ejecutar_pasos(%{acumulador: acc} = estado, pasos) when acc >= @dt_ms do
entidades =
Map.new(estado.entidades, fn {pid, e} -> {pid, avanzar(e, @dt_ms / 1000)} end)
estado
|> Map.put(:entidades, entidades)
|> Map.put(:acumulador, acc - @dt_ms)
|> Map.put(:tick, estado.tick + 1)
|> ejecutar_pasos(pasos + 1)
end
defp ejecutar_pasos(estado, _pasos), do: estado
defp avanzar(e, dt_s) do
x = acotar(e.x + e.dx * @velocidad * dt_s)
y = acotar(e.y + e.dy * @velocidad * dt_s)
%{e | x: x, y: y}
end
defp acotar(v) when v < 0.0, do: 0.0
defp acotar(v) when v > @limite, do: @limite
defp acotar(v), do: v
defp normalizar(0.0, 0.0), do: {0.0, 0.0}
defp normalizar(dx, dy) do
magnitud = :math.sqrt(dx * dx + dy * dy)
if magnitud == 0.0, do: {0.0, 0.0}, else: {dx / magnitud, dy / magnitud}
end
# --- Difusion del estado ---
defp difundir(estado) do
instantanea =
estado.entidades
|> Enum.map(fn {_pid, e} ->
"#{e.nombre}:#{Float.round(e.x, 1)}:#{Float.round(e.y, 1)}"
end)
|> Enum.join(" ")
Enum.each(estado.entidades, fn {pid, e} ->
send(pid, {:estado, estado.tick, e.ultima_seq, instantanea})
end)
end
defp programar_siguiente(estado, ahora) do
ideal = estado.ideal + @dt_ms
if ideal <= ahora do
# Vamos atrasados: saltar al proximo instante futuro sin acumular deuda.
saltos = div(ahora - ideal, @dt_ms) + 1
nuevo_ideal = ideal + saltos * @dt_ms
{max(nuevo_ideal - ahora, 0), %{estado | ideal: nuevo_ideal, retrasos: estado.retrasos + 1}}
else
{ideal - ahora, %{estado | ideal: ideal}}
end
end
defp registrar(estado, duracion_us) do
duraciones = Enum.take([duracion_us | estado.duraciones], 600)
%{estado | duraciones: duraciones}
end
end
Este módulo es el corazón del proyecto. Vale la pena detenerse en tres decisiones.
El cliente envía dirección, no posición. Si aceptaras POSICION x y, cualquiera podría teletransportarse editando el cliente. Al aceptar solo una dirección, el servidor decide cuánto se mueve y hacia dónde puede moverse. La normalización del vector impide además el clásico truco de moverse más rápido en diagonal.
Cada intención lleva un número de secuencia. Como TCP entrega en orden, la secuencia parece redundante, pero no lo es: sirve para que el servidor le diga al cliente cuál fue la última entrada que aplicó, y eso es lo que hace posible la reconciliación que veremos enseguida.
Las métricas se llevan desde el primer día. Un servidor de tiempo real sin medición de la duración de sus ticks es un servidor que no sabes si funciona. Guardar las últimas 600 muestras y exponer percentiles cuesta veinte líneas y evita discusiones basadas en impresiones.
Latencia: el enemigo estructural
La latencia no es un problema de rendimiento que se resuelva optimizando código. Es una propiedad física del canal. Un paquete entre Santiago y Madrid recorre unos 11.000 kilómetros; a la velocidad de la luz en fibra —cerca de dos tercios de la velocidad en el vacío— eso son unos 55 ms de ida, 110 ms de ida y vuelta, antes de sumar un solo router. Ninguna optimización de tu código va a mejorar eso.
Lo que sí puedes hacer es presupuestar la latencia y ocultarla.
Presupuesto de latencia
El retraso que percibe un jugador entre apretar una tecla y ver el efecto se descompone así:
| Componente | Rango típico | Quién lo controla |
|---|---|---|
| Muestreo de la entrada en el cliente | 0 a 16 ms | El cliente, según su frecuencia de cuadro |
| Encolado en el buffer de salida del sistema operativo | 0 a 5 ms | El sistema operativo y la opción nodelay |
| Propagación por la red, ida | 5 a 150 ms | La geografía |
| Encolado en el buffer de recepción del servidor | 0 a 5 ms | El sistema operativo |
| Espera hasta el próximo tick | 0 a dt | Tu tick rate |
| Procesamiento del tick | 1 a 20 ms | Tu código |
| Serialización y envío | 0.1 a 5 ms | Tu formato de mensajes |
| Propagación por la red, vuelta | 5 a 150 ms | La geografía |
| Espera hasta el próximo cuadro del cliente | 0 a 16 ms | El cliente |
| Buffer de interpolación del cliente | 50 a 100 ms | Tu diseño |
Dos conclusiones que se sacan de mirar esta tabla:
- La espera hasta el próximo tick es tuya y es cara. A 20 Hz aportas en promedio 25 ms de retraso solo por el hecho de discretizar el tiempo. Subir a 60 Hz baja ese aporte a 8 ms, pero triplica el ancho de banda y el cómputo.
- El buffer de interpolación es la mayor contribución evitable. Existe porque el cliente necesita al menos dos instantáneas para dibujar movimiento suave entre ellas. Es un intercambio consciente entre suavidad y retraso.
TCP o UDP
| Criterio | TCP | UDP |
|---|---|---|
| Entrega garantizada | Sí | No, la implementas tú si la necesitas |
| Orden garantizado | Sí | No |
| Bloqueo de cabeza de línea | Sí: un paquete perdido detiene todo lo posterior | No: cada datagrama es independiente |
| Costo de una pérdida | Al menos un RTT de retransmisión para todo el flujo | Cero si el dato ya caducó |
| Fronteras de mensaje | No las hay, hay que hacer framing | Sí, cada datagrama es un mensaje |
| Establecimiento | Saludo de tres vías | Ninguno |
| Atraviesa NAT y cortafuegos | Casi siempre | A veces bloqueado |
| Complejidad de implementación | Baja | Alta si necesitas confiabilidad parcial |
| Uso recomendado | Lobby, chat, inventario, cualquier cosa que deba llegar | Posiciones, entradas, estado que se reemplaza cada tick |
La regla que usa la industria es simple: si un dato va a ser reemplazado por otro en 50 ms, no vale la pena retransmitirlo. La posición de un jugador es el ejemplo perfecto: si se pierde el paquete del tick 100, el del tick 101 ya trae la información actualizada y llegar tarde con el 100 solo empeora las cosas. En cambio, “compraste este objeto” tiene que llegar sí o sí.
Este proyecto usa TCP porque el objetivo es aprender sockets y concurrencia, no reimplementar confiabilidad parcial. Con nodelay: true y mensajes pequeños, TCP es perfectamente usable para juegos de ritmo moderado. Si más adelante quieres UDP, la ruta natural es adoptar QUIC, que resuelve el bloqueo de cabeza de línea manteniendo cifrado y control de congestión.
Predicción del cliente y reconciliación
La técnica que hace jugables los juegos en línea es no esperar la respuesta del servidor para dibujar. El cliente aplica su propia entrada de inmediato sobre una copia local del mundo, y luego corrige cuando llega la verdad oficial.
sequenceDiagram
autonumber
participant J as Jugador
participant C as Cliente
participant S as Servidor (20 Hz)
J->>C: mantiene la tecla derecha
C->>C: seq=17, aplica localmente, x=100 -> 104
C->>S: INTENCION 1 0 seq=17
C->>C: seq=18, aplica localmente, x=104 -> 108
C->>S: INTENCION 1 0 seq=18
Note over C: las entradas 17 y 18 quedan<br/>guardadas en el buffer local
S->>S: tick 500: aplica seq=17 y seq=18<br/>pero detecta colision con un muro en x=106
S-->>C: ESTADO tick=500 ack_seq=18 x=106
C->>C: descarta del buffer todo lo <= 18
C->>C: reconcilia: parte de x=106<br/>y reaplica las entradas 19 y 20 pendientes
C->>C: si la diferencia es pequena, suaviza<br/>en varios cuadros en vez de saltar
Note over J: el jugador ve una correccion<br/>de dos pixeles, no un teletransporte
Los tres elementos que hacen funcionar esto son:
- El buffer de entradas del cliente, que guarda cada entrada enviada junto con su número de secuencia hasta que el servidor la confirma.
- El acuse de secuencia en cada instantánea, que es la razón por la que el servidor guarda
ultima_seqpor entidad en el código de arriba. - La reaplicación determinista, que exige que el cliente y el servidor usen exactamente la misma función de avance. Si divergen, el jugador ve correcciones constantes.
Para las entidades ajenas no se predice, se interpola: el cliente dibuja a los otros jugadores en el pasado, entre las dos últimas instantáneas recibidas, con un retraso deliberado de aproximadamente dos ticks. Ver a los demás 100 ms en el pasado es imperceptible; verlos saltar entre posiciones no lo es.
La contracara es la compensación de retraso en el servidor: cuando un jugador dispara, el servidor rebobina las posiciones de los demás al instante que el tirador estaba viendo, verifica el impacto ahí y luego vuelve al presente. Es lo que produce la sensación de “me mataron detrás de la pared”: para el tirador, el disparo era legítimo. No hay solución perfecta; hay una elección sobre a quién favorecer.
La misma arquitectura en Ada
Ada aborda el mismo problema con herramientas distintas: tareas en vez de procesos, objetos protegidos en vez de mensajes, y memoria realmente compartida. Es el contraste didáctico que hemos usado en todo el curso, y aquí queda especialmente claro porque el estado del mundo pasa de estar encerrado en un proceso a estar encerrado en un objeto protegido.
El servidor RPSLS en Ada
with Ada.Text_IO; use Ada.Text_IO;
with Ada.Streams; use Ada.Streams;
with GNAT.Sockets; use GNAT.Sockets;
procedure Servidor_RPSLS is
Puerto : constant Port_Type := 6502;
type Opcion is (Piedra, Papel, Tijera, Lagarto, Spock, Invalida);
-- Tabla de resultados vista desde la primera jugada.
-- 0 = empate, 1 = pierde, 2 = gana.
type Codigo is range 0 .. 2;
type Tabla is array (Opcion range Piedra .. Spock,
Opcion range Piedra .. Spock) of Codigo;
Resultado : constant Tabla :=
-- Piedra Papel Tijera Lagarto Spock
(Piedra => ( 0, 1, 2, 2, 1),
Papel => ( 2, 0, 1, 1, 2),
Tijera => ( 1, 2, 0, 2, 1),
Lagarto => ( 1, 2, 1, 0, 2),
Spock => ( 2, 1, 2, 1, 0));
function Parsear (Texto : String) return Opcion is
Limpio : String := Texto;
begin
for I in Limpio'Range loop
if Limpio (I) in 'A' .. 'Z' then
Limpio (I) :=
Character'Val (Character'Pos (Limpio (I)) + 32);
end if;
end loop;
if Limpio = "piedra" then return Piedra;
elsif Limpio = "papel" then return Papel;
elsif Limpio = "tijera" then return Tijera;
elsif Limpio = "tijeras" then return Tijera;
elsif Limpio = "lagarto" then return Lagarto;
elsif Limpio = "spock" then return Spock;
else return Invalida;
end if;
end Parsear;
function Resolver (Mia, Suya : Opcion) return Codigo is
begin
if Mia = Invalida or else Suya = Invalida then
return 0;
else
return Resultado (Mia, Suya);
end if;
end Resolver;
-- Envio de una linea de texto por el socket.
procedure Enviar (Socket : Socket_Type; Linea : String) is
Datos : Stream_Element_Array (1 .. Stream_Element_Offset (Linea'Length + 1));
Ultimo : Stream_Element_Offset;
Indice : Stream_Element_Offset := 1;
begin
for C of Linea loop
Datos (Indice) := Stream_Element (Character'Pos (C));
Indice := Indice + 1;
end loop;
Datos (Indice) := Stream_Element (Character'Pos (ASCII.LF));
Send_Socket (Socket, Datos, Ultimo);
end Enviar;
-- Lectura de una linea, byte a byte. No es eficiente, pero es correcta
-- y hace explicito el problema del framing sobre un flujo de bytes.
procedure Recibir (Socket : Socket_Type;
Linea : out String;
Largo : out Natural;
Cerrado : out Boolean) is
Byte : Stream_Element_Array (1 .. 1);
Ultimo : Stream_Element_Offset;
C : Character;
begin
Largo := 0;
Cerrado := False;
loop
Receive_Socket (Socket, Byte, Ultimo);
if Ultimo = 0 then
Cerrado := True; -- cero bytes significa cierre limpio
return;
end if;
C := Character'Val (Natural (Byte (1)));
exit when C = ASCII.LF;
if C /= ASCII.CR and then Largo < Linea'Length then
Largo := Largo + 1;
Linea (Linea'First + Largo - 1) := C;
end if;
end loop;
end Recibir;
-- Estado compartido de la partida, protegido de accesos simultaneos.
-- Este es el equivalente Ada del GenServer Sala del codigo Elixir.
protected type Mesa is
entry Jugar (Quien : Positive; Que : Opcion; Contra : out Opcion);
procedure Reiniciar;
private
Jugadas : array (1 .. 2) of Opcion := (others => Invalida);
Presentes : Natural := 0;
Listo : Boolean := False;
end Mesa;
protected body Mesa is
entry Jugar (Quien : Positive; Que : Opcion; Contra : out Opcion)
when not Listo or else Presentes = 2
is
begin
if not Listo then
Jugadas (Quien) := Que;
Presentes := Presentes + 1;
if Presentes = 2 then
Listo := True;
end if;
end if;
if Presentes = 2 then
Contra := Jugadas (3 - Quien);
else
Contra := Invalida;
requeue Jugar with abort; -- espera al rival sin consumir CPU
end if;
end Jugar;
procedure Reiniciar is
begin
Jugadas := (others => Invalida);
Presentes := 0;
Listo := False;
end Reiniciar;
end Mesa;
La_Mesa : Mesa;
task type Atender_Jugador (Numero : Positive) is
entry Comenzar (S : Socket_Type);
end Atender_Jugador;
task body Atender_Jugador is
Socket : Socket_Type;
Linea : String (1 .. 256);
Largo : Natural;
Cerrado : Boolean;
Mia, Suya : Opcion;
Res : Codigo;
begin
accept Comenzar (S : Socket_Type) do
Socket := S;
end Comenzar;
Enviar (Socket, "BIENVENIDO RPSLS/1.0");
Enviar (Socket, "OK JUGADOR" & Positive'Image (Numero));
loop
Enviar (Socket, "PIDE_JUGADA 10000");
Recibir (Socket, Linea, Largo, Cerrado);
exit when Cerrado;
if Largo > 7 and then Linea (1 .. 7) = "JUGADA " then
Mia := Parsear (Linea (8 .. Largo));
else
Mia := Invalida;
end if;
La_Mesa.Jugar (Numero, Mia, Suya);
Res := Resolver (Mia, Suya);
Enviar (Socket, "RESULTADO " & Opcion'Image (Mia) &
" " & Opcion'Image (Suya) &
" " & Codigo'Image (Res));
if Numero = 1 then
delay 0.05; -- deja que el rival lea antes de reiniciar
La_Mesa.Reiniciar;
end if;
end loop;
Close_Socket (Socket);
Put_Line ("Jugador" & Positive'Image (Numero) & " desconectado");
end Atender_Jugador;
Escucha : Socket_Type;
Cliente : Socket_Type;
Direccion : Sock_Addr_Type;
Par : Sock_Addr_Type;
Jugador_1 : access Atender_Jugador;
Jugador_2 : access Atender_Jugador;
begin
Create_Socket (Escucha);
Set_Socket_Option (Escucha, Socket_Level, (Reuse_Address, True));
Set_Socket_Option (Escucha, IP_Protocol_For_TCP_Level, (No_Delay, True));
Direccion.Addr := Any_Inet_Addr;
Direccion.Port := Puerto;
Bind_Socket (Escucha, Direccion);
Listen_Socket (Escucha, 8);
Put_Line ("Servidor RPSLS escuchando en el puerto" & Port_Type'Image (Puerto));
Accept_Socket (Escucha, Cliente, Par);
Put_Line ("Jugador 1 conectado desde " & Image (Par));
Jugador_1 := new Atender_Jugador (1);
Jugador_1.Comenzar (Cliente);
Accept_Socket (Escucha, Cliente, Par);
Put_Line ("Jugador 2 conectado desde " & Image (Par));
Jugador_2 := new Atender_Jugador (2);
Jugador_2.Comenzar (Cliente);
Close_Socket (Escucha);
end Servidor_RPSLS;
Compilación con Alire:
alr init --bin servidor_rpsls
cd servidor_rpsls
# En alire.toml no hace falta agregar dependencias: GNAT.Sockets viene con GNAT.
alr build
./bin/servidor_rpsls
La pieza que conviene estudiar es la barrera del objeto protegido Mesa. La entrada Jugar tiene una guarda —when not Listo or else Presentes = 2— y usa requeue para reencolar al primer jugador hasta que llegue el segundo. Esa es una barrera de dos participantes escrita sin un solo semáforo y sin espera activa: el runtime de Ada suspende la tarea y la reactiva cuando la guarda vuelve a ser verdadera. Es el mismo comportamiento que en Elixir logramos guardando el estado y esperando el segundo mensaje, pero expresado como sincronización sobre memoria compartida en lugar de paso de mensajes.
El tick loop en Ada
En Ada el tick loop se escribe con Ada.Real_Time y delay until, que es una espera absoluta y por lo tanto no acumula deriva.
with Ada.Text_IO; use Ada.Text_IO;
with Ada.Real_Time; use Ada.Real_Time;
procedure Tick_Loop is
Hz : constant := 20;
Periodo : constant Time_Span := Milliseconds (1000 / Hz);
Max_Ticks : constant := 200;
-- Estado del mundo, protegido porque las tareas de red lo tocan.
protected Mundo is
procedure Fijar_Direccion (Id : Positive; DX, DY : Float);
procedure Avanzar (DT : Float);
function Instantanea return String;
private
X, Y : array (1 .. 4) of Float := (others => 0.0);
VX, VY : array (1 .. 4) of Float := (others => 0.0);
end Mundo;
protected body Mundo is
procedure Fijar_Direccion (Id : Positive; DX, DY : Float) is
Magnitud : constant Float := Float'Max (0.0001, (DX * DX + DY * DY) ** 0.5);
begin
VX (Id) := DX / Magnitud;
VY (Id) := DY / Magnitud;
end Fijar_Direccion;
procedure Avanzar (DT : Float) is
begin
for I in X'Range loop
X (I) := Float'Min (1000.0, Float'Max (0.0, X (I) + VX (I) * 220.0 * DT));
Y (I) := Float'Min (1000.0, Float'Max (0.0, Y (I) + VY (I) * 220.0 * DT));
end loop;
end Avanzar;
function Instantanea return String is
begin
return "e1=" & Integer'Image (Integer (X (1))) &
"," & Integer'Image (Integer (Y (1)));
end Instantanea;
end Mundo;
Proximo : Time := Clock;
DT : constant Float := 1.0 / Float (Hz);
Comienzo : Time;
Duracion : Time_Span;
Peor : Time_Span := Milliseconds (0);
Atrasados : Natural := 0;
begin
Put_Line ("Tick loop a" & Integer'Image (Hz) & " Hz");
Mundo.Fijar_Direccion (1, 1.0, 0.5);
for Tick in 1 .. Max_Ticks loop
Proximo := Proximo + Periodo;
Comienzo := Clock;
Mundo.Avanzar (DT);
Duracion := Clock - Comienzo;
if Duracion > Peor then
Peor := Duracion;
end if;
if Tick mod 20 = 0 then
Put_Line ("tick" & Integer'Image (Tick) & " " & Mundo.Instantanea);
end if;
-- Si ya pasamos el instante objetivo, estamos atrasados.
if Clock > Proximo then
Atrasados := Atrasados + 1;
-- Reajustar para no arrastrar la deuda hacia adelante.
while Proximo < Clock loop
Proximo := Proximo + Periodo;
end loop;
end if;
delay until Proximo;
end loop;
Put_Line ("Ticks atrasados:" & Natural'Image (Atrasados));
Put_Line ("Peor duracion de tick:" &
Duration'Image (To_Duration (Peor)) & " s");
end Tick_Loop;
delay until es una construcción del lenguaje pensada exactamente para esto: espera hasta un instante absoluto, no una duración relativa. La diferencia con delay 0.05 es la misma que discutimos con el acumulador: el retraso relativo acumula deriva, el absoluto no.
Nota también que la salida al terminal está fuera del objeto protegido cuando es posible. Una regla dura de los objetos protegidos: nunca hagas E/S bloqueante dentro de uno, porque bloqueas a todas las tareas que esperan entrar. Es el mismo principio que en el capítulo de monitores: la sección crítica debe ser corta y no debe llamar a nada que pueda dormirse.
Medir antes de opinar
Un servidor de tiempo real se juzga por percentiles, no por promedios. Un promedio de 8 ms con un percentil 99 de 300 ms produce un juego injugable, y el promedio no lo delata.
Las tres métricas que hay que instrumentar desde el principio:
- Duración del tick. Cuánto tarda simular y difundir. Si el p99 se acerca a
dt, estás al borde. - Retraso del tick. Diferencia entre el instante en que el tick debía empezar y el que realmente empezó. Mide si el planificador te está postergando.
- RTT por cliente. Con el
PING/PONGdel protocolo, muestreado cada pocos segundos. El jitter, que es la desviación del RTT, importa tanto como el RTT medio.
Un banco de pruebas mínimo con clientes sintéticos:
defmodule Arena.Carga do
@moduledoc """
Genera N clientes que envian intenciones a una tasa fija y miden el RTT.
Uso: Arena.Carga.correr("localhost", 6502, 50, 30_000)
"""
def correr(host, puerto, clientes, duracion_ms) do
padre = self()
pids =
for i <- 1..clientes do
spawn_link(fn -> cliente(padre, host, puerto, i, duracion_ms) end)
end
muestras = recolectar(length(pids), [])
resumen(muestras)
end
defp cliente(padre, host, puerto, id, duracion_ms) do
opciones = [:binary, packet: :line, active: false, nodelay: true]
{:ok, sock} = :gen_tcp.connect(to_charlist(host), puerto, opciones)
{:ok, _saludo} = :gen_tcp.recv(sock, 0, 5_000)
:ok = :gen_tcp.send(sock, "NOMBRE bot#{id}\n")
fin = System.monotonic_time(:millisecond) + duracion_ms
rtts = medir(sock, fin, [])
:gen_tcp.close(sock)
send(padre, {:resultado, rtts})
end
defp medir(sock, fin, acc) do
if System.monotonic_time(:millisecond) >= fin do
acc
else
t0 = System.monotonic_time(:microsecond)
:gen_tcp.send(sock, "PING x\n")
acc =
case :gen_tcp.recv(sock, 0, 2_000) do
{:ok, _} -> [System.monotonic_time(:microsecond) - t0 | acc]
_ -> acc
end
Process.sleep(50)
medir(sock, fin, acc)
end
end
defp recolectar(0, acc), do: acc
defp recolectar(n, acc) do
receive do
{:resultado, rtts} -> recolectar(n - 1, rtts ++ acc)
after
60_000 -> acc
end
end
defp resumen([]), do: IO.puts("sin muestras")
defp resumen(muestras) do
ordenadas = Enum.sort(muestras)
n = length(ordenadas)
pct = fn p -> Enum.at(ordenadas, min(div(n * p, 100), n - 1)) / 1000 end
IO.puts("""
muestras: #{n}
p50: #{Float.round(pct.(50), 2)} ms
p95: #{Float.round(pct.(95), 2)} ms
p99: #{Float.round(pct.(99), 2)} ms
max: #{Float.round(List.last(ordenadas) / 1000, 2)} ms
""")
end
end
Para observar el servidor desde afuera, además de tus propias métricas conviene mirar el sistema operativo. ss -tin muestra por conexión el RTT estimado por el kernel, la ventana de congestión y las retransmisiones; ss -tlnp muestra si la cola de aceptación está desbordada, lo que se manifiesta como conexiones que tardan en establecerse. La columna de conexiones descartadas por cola llena es la primera pista de que tu aceptador es el cuello de botella.
Errores comunes
| Síntoma observado | Causa real | Solución |
|---|---|---|
| Los mensajes llegan pegados o cortados a la mitad | TCP no preserva fronteras de mensaje; se asumió que cada read devuelve un mensaje completo | Usar framing explícito: packet: :line en Erlang, o prefijo de longitud, o leer hasta el terminador |
eaddrinuse al reiniciar el servidor | El puerto quedó en TIME_WAIT por el cierre anterior | Activar reuseaddr: true antes del bind |
| El servidor acepta la conexión pero nunca responde | El aceptador no transfirió la propiedad del socket; los datos llegan a su buzón | Llamar a controlling_process y recién entonces activar el socket |
| El proceso de conexión crece hasta agotar la memoria | Socket en modo active: true: la máquina virtual empuja todo lo que llega sin contrapresión | Usar active: :once y reactivar tras procesar cada mensaje |
| Retraso constante de 40 ms en mensajes pequeños | Algoritmo de Nagle agrupando escrituras diminutas | nodelay: true en servidor y cliente |
| El servidor se va atrasando: tras una hora corre notoriamente lento | Deriva por calcular el próximo tick como “ahora + dt” en lugar de “ideal + dt” | Mantener un instante ideal y programar sobre él; en Ada, usar delay until |
| Un pico de carga deja al servidor en cámara lenta permanente | Espiral de la muerte: el acumulador crece más rápido de lo que se drena | Limitar los pasos por tick y descartar el acumulador restante, registrando el evento |
| Una ronda se resuelve dos veces | Temporizador de la ronda anterior no cancelado al recibir ambas jugadas | Guardar la referencia del temporizador en el estado y cancelarla al resolver |
| Los jugadores se mueven más rápido en diagonal | Se sumó el desplazamiento de ambos ejes sin normalizar el vector | Normalizar la dirección en el servidor, nunca confiar en la magnitud del cliente |
| Un jugador aparece teletransportándose | El cliente envía posiciones absolutas en vez de intenciones | Aceptar solo dirección o entrada; el servidor calcula la posición |
| Los rivales se ven a saltos | Se dibuja la última instantánea recibida sin interpolar | Retrasar el render de entidades ajenas dos ticks e interpolar entre instantáneas |
| El cliente corrige la posición del propio jugador todo el tiempo | La función de avance del cliente difiere de la del servidor | Compartir la misma lógica de simulación y aplicar el mismo dt fijo |
| La partida queda colgada esperando a alguien que se fue | Se esperó un mensaje sin plazo ni monitor sobre el proceso | Plazo en cada espera y Process.monitor sobre los participantes |
| La cola de espera se llena de jugadores fantasma | No se detecta la muerte del proceso que esperaba rival | Monitorear cada jugador encolado y removerlo al recibir :DOWN |
| El servidor cierra la conexión ante un comando desconocido | Se trató lo inesperado como error fatal | Responder ERROR y continuar; solo cerrar ante violaciones de seguridad |
| Todas las tareas de Ada se congelan en un punto | E/S bloqueante dentro de un objeto protegido | Sacar la E/S fuera del objeto; dentro solo manipular estado en memoria |
| El servidor funciona con dos clientes y muere con doscientos | Cada conexión creó un hilo del sistema operativo con pila de 8 MB | Usar procesos livianos, o un pool de hilos con E/S multiplexada |
| El promedio de latencia es bueno pero el juego se siente mal | Se midió el promedio y no el p99 ni el jitter | Medir percentiles y desviación; el promedio oculta las colas |
Ejercicios propuestos
-
Framing sin ayuda. Reescribe el servidor Elixir con
packet: :rawy acumula los bytes a mano hasta encontrar el salto de línea. Escribe un cliente que envíe un mensaje partido en tres escrituras con pausas entre ellas y verifica que tu servidor lo reconstruye. Luego envía dos mensajes en una sola escritura y verifica que los separa. -
Interoperabilidad real. Intercambia el cliente con un compañero y ejecuta su cliente contra tu servidor. Documenta cada incompatibilidad que aparezca y decide, para cada una, si tu implementación era demasiado estricta o el protocolo estaba mal especificado. Esta distinción es la esencia del diseño de protocolos.
-
Mejor de N. Modifica la sala para jugar al mejor de cinco rondas, enviar un mensaje de fin de partida con el ganador y devolver a ambos jugadores a la cola del emparejador. Cuida que el estado de la sala anterior no se filtre a la nueva.
-
Espectadores. Agrega un comando
MIRAR <sala>que suscriba a un cliente a las notificaciones de una partida sin poder jugar. Piensa qué pasa si el espectador es lento leyendo: ¿debe frenar la partida o debe descartarse su suscripción? -
Mide la deriva. Ejecuta el tick loop de Elixir durante diez minutos y registra la diferencia entre el número de ticks ejecutados y el esperado por el reloj de pared. Luego reemplaza el cálculo del instante ideal por “ahora más dt” y repite la medición. La diferencia entre ambas cifras es el valor de una línea de código.
-
Provoca la espiral de la muerte. Agrega dentro del paso de simulación una operación artificialmente costosa que dependa del número de entidades. Aumenta las entidades hasta que un tick tarde más que
dty observa el comportamiento con y sin el tope de pasos por vuelta. Grafica el acumulador en el tiempo en ambos casos. -
Predicción y reconciliación completas. Implementa en el cliente el buffer de entradas, la reaplicación tras cada instantánea y el suavizado de correcciones pequeñas. Agrega un retardo artificial de 150 ms en el envío y comprueba que el movimiento propio sigue siendo instantáneo.
-
Interpolación de entidades ajenas. Guarda en el cliente las últimas instantáneas con su marca de tick y dibuja a los otros jugadores interpolando entre las dos más recientes, con un retraso de dos ticks. Compara visualmente con la versión que dibuja la última recibida.
-
Deltas en vez de instantáneas. Cambia la difusión para enviar solo las entidades que cambiaron desde el último envío a ese cliente. Mide la reducción de bytes por segundo con 50 jugadores, de los cuales 40 están quietos. Considera qué pasa si un delta se pierde.
-
Área de interés. Envía a cada jugador únicamente las entidades dentro de un radio. Mide cómo escala el ancho de banda con el número de jugadores antes y después. Este es el cambio de complejidad cuadrática a aproximadamente lineal que hace posibles los mundos grandes.
-
Compensación de retraso. Guarda en el servidor un historial circular de las posiciones de los últimos 20 ticks. Cuando llegue una acción con la marca de tick que el cliente estaba viendo, verifica el impacto contra esas posiciones históricas. Documenta el caso en que un jugador es alcanzado después de haberse cubierto.
-
Cambia el transporte. Reimplementa la difusión de estado sobre UDP con
:gen_udp, dejando el lobby y el registro sobre TCP. Agrega números de secuencia y descarta los datagramas que lleguen fuera de orden. Compara el comportamiento de ambos transportes bajo pérdida de paquetes inducida contc netem. -
Ada con más de dos jugadores. Generaliza el servidor de Ada a N jugadores usando un arreglo de tareas y un objeto protegido con una barrera de N participantes. Compara la complejidad del resultado con la versión Elixir equivalente y explica de dónde viene la diferencia.
-
Prueba de carga con perfil realista. Extiende
Arena.Cargapara que los clientes sintéticos alternen entre ráfagas de actividad y pausas, imitando a jugadores reales. Encuentra experimentalmente el número de clientes en que el p99 de la duración del tick supera la mitad dedt. -
Reinicio sin perder partidas. Investiga qué haría falta para que el servidor pueda reiniciarse sin desconectar a los jugadores. Como mínimo necesitas separar el estado del código, persistir el estado de las salas y traspasar los sockets. Escribe el diseño aunque no lo implementes: identificar por qué es difícil ya es el aprendizaje.
Bibliografía recomendada del curso
Esta es la bibliografía sobre la que se apoya el curso completo, organizada por tema. Las obras marcadas como fundamentales son las que conviene tener a mano incluso después de terminar.
Sistemas operativos, obras generales
| Obra | Autoría | Notas |
|---|---|---|
| Modern Operating Systems, 5.ª edición (2022) | Andrew S. Tanenbaum y Herbert Bos | Fundamental. ISBN 978-0-13-761887-3. Cobertura completa y ordenada de procesos, memoria, E/S, sistemas de archivos y sistemas distribuidos |
| Operating Systems: Principles and Practice, 2.ª edición | Thomas Anderson y Michael Dahlin | ISBN 978-0-9856735-2-9. Más orientado a principios de diseño y a la justificación de cada mecanismo |
| Operating Systems: Three Easy Pieces | Remzi y Andrea Arpaci-Dusseau | Disponible en línea sin costo. Organizado en tres ejes: virtualización, concurrencia y persistencia |
| Fundamentos de sistemas operativos (sistop.org) | Comunidad hispanohablante | Material en español de acceso abierto, útil para fijar vocabulario técnico |
| Fundamentos de Sistemas Operativos: una aproximación práctica usando Linux | honecomp.github.io/librossoo.html | Enfoque práctico apoyado en herramientas reales de Linux |
| Operating System Concepts | Silberschatz, Galvin y Gagne | El “libro del dinosaurio”, referencia clásica en cursos universitarios |
Artículos y textos históricos
| Obra | Autoría | Por qué importa |
|---|---|---|
| Bell’s Law for the birth and death of computer classes (1972, reeditado en CACM 51(1), 86-94) | Gordon Bell | Explica por qué aparecen y desaparecen clases enteras de computadoras, y con ellas sus sistemas operativos |
| Cooperating Sequential Processes (1965) | Edsger W. Dijkstra | Origen del semáforo y del planteo formal de la exclusión mutua |
| Solution of a Problem in Concurrent Programming Control (1965) | Edsger W. Dijkstra | Primera solución por software al problema de la sección crítica |
| Monitors: An Operating System Structuring Concept (1974) | C. A. R. Hoare | Origen del monitor y de las variables de condición |
| The UNIX Time-Sharing System (1974) | Dennis Ritchie y Ken Thompson | El artículo que define el diseño que todavía usamos |
| A Note on Distributed Computing (1994) | Waldo, Wyant, Wollrath y Kendall | Por qué la llamada remota nunca puede ser igual a la local: latencia, falla parcial y concurrencia |
| Time, Clocks, and the Ordering of Events in a Distributed System (1978) | Leslie Lamport | Base conceptual de la causalidad en sistemas sin reloj común |
Programación en Ada
| Recurso | Ubicación | Uso |
|---|---|---|
| GNAT User’s Guide, sección de archivos y directorios | gcc.gnu.org/onlinedocs/ | Cómo el compilador ubica y nombra las unidades de compilación |
| Alire, documentación oficial | alire.ada.dev/docs/ | Gestor de paquetes y de proyectos; la forma moderna de compilar Ada |
| libhello, biblioteca de demostración | github.com/alire-project/libhello | Ejemplo mínimo de una biblioteca publicada con Alire |
| ada-lang.io, tutorial | ada-lang.io/docs/learn/tutorial/ | Introducción actualizada, incluye argumentos de línea de comandos |
Ada Reference Manual, Ada.Strings.Unbounded | adaic.com/resources/standards/ | Manejo de cadenas de longitud variable, con anotaciones de Randall Brukardt |
| Lovelace Tutorial, sección 8.3 | David Wheeler | Tipos String y sus operaciones básicas |
| Programación en Ada, Wikilibros | es.wikibooks.org | Material en español, útil para Ada.Strings.Unbounded |
| Discusión sobre múltiples procedimientos por archivo | comp.lang.ada en Google Groups | Aclara una duda recurrente sobre la organización del código fuente |
| Concurrent and Real-Time Programming in Ada | Alan Burns y Andy Wellings | Fundamental para tareas, objetos protegidos, requeue y perfil Ravenscar |
Elixir, Erlang y concurrencia por mensajes
| Obra | Autoría | Notas |
|---|---|---|
| Programming Elixir | Dave Thomas | Introducción al lenguaje y a su modelo de datos inmutables |
| Elixir in Action, 3.ª edición | Saša Jurić | Fundamental para este curso: procesos, GenServer, supervisión y OTP explicados desde la concurrencia |
| Designing for Scalability with Erlang/OTP | Francesco Cesarini y Steve Vinoski | Arquitecturas de sistemas tolerantes a fallos con OTP |
| Making reliable distributed systems in the presence of software errors (2003) | Joe Armstrong | Tesis doctoral que fundamenta el modelo de “dejar que falle” |
Documentación de :gen_tcp, :inet y :gen_udp | erlang.org/doc | Referencia obligada para las opciones de socket usadas en el proyecto |
Redes, sockets y sistemas distribuidos
| Obra | Autoría | Notas |
|---|---|---|
| UNIX Network Programming, Volume 1: The Sockets Networking API | W. Richard Stevens, Bill Fenner y Andrew Rudoff | Fundamental. La referencia definitiva sobre la API de sockets |
| TCP/IP Illustrated, Volume 1: The Protocols | W. Richard Stevens y Kevin Fall | Qué ocurre realmente en el cable, incluidos Nagle, ventanas y retransmisión |
| Computer Networking: A Top-Down Approach | James Kurose y Keith Ross | Introducción ordenada a la pila completa |
| Designing Data-Intensive Applications | Martin Kleppmann | Consistencia, replicación y particionamiento; útil cuando el servidor deja de ser uno solo |
| RFC 9293, Transmission Control Protocol | IETF | Especificación consolidada de TCP |
| RFC 9000, QUIC: A UDP-Based Multiplexed and Secure Transport | IETF | El camino moderno para transporte de baja latencia |
Programación de juegos en red
| Recurso | Autoría | Notas |
|---|---|---|
| What Every Programmer Needs To Know About Game Networking | Glenn Fiedler (Gaffer On Games) | Serie fundamental sobre paso fijo, protocolos sobre UDP y sincronización de estado |
| Fast-Paced Multiplayer | Gabriel Gambetta | Explicación clara y visual de predicción, reconciliación e interpolación de entidades |
| Latency Compensating Methods in Client/Server Protocol Design (2001) | Yahn Bernier, Valve | El artículo que popularizó la compensación de retraso |
| Source Multiplayer Networking | Valve Developer Community | Documentación práctica de un motor real: tick rate, interpolación, historial de posiciones |
| Fix Your Timestep! | Glenn Fiedler | El artículo canónico sobre acumuladores y paso fijo |
| Game Engine Architecture | Jason Gregory | Capítulos sobre el bucle principal y la gestión del tiempo |
Herramientas de observación
Estas no son bibliografía en sentido estricto, pero conviene tenerlas en el mismo lugar: ss y ip para el estado de las conexiones y las rutas, tcpdump y Wireshark para ver los paquetes, tc con netem para inyectar pérdida y retraso artificiales, strace y ltrace para observar las llamadas al sistema que hace tu servidor, perf para perfilar, y :observer.start() en Erlang para ver procesos, buzones y planificadores en vivo.
Cierre del curso
Llegaste al final de un recorrido que empezó preguntando qué es un sistema operativo y termina con un servidor concurrente escrito por ti.
En los primeros capítulos establecimos el terreno: qué problema resuelve un sistema operativo, cómo llegó a ser lo que es, qué roles cumple y sobre qué máquina se apoya. Vimos que casi todo lo que el sistema operativo hace es una respuesta a una limitación del hardware o a un conflicto entre programas que quieren el mismo recurso.
Después entramos al kernel y a sus estructuras: el proceso como abstracción central, el hilo como unidad de planificación, el cambio de contexto como el precio que se paga por la ilusión de simultaneidad, y las llamadas al sistema como la frontera entre lo que un programa puede hacer solo y lo que debe pedir prestado.
El bloque de concurrencia fue el corazón del curso. Empezó con el problema —la condición de carrera y la sección crítica—, siguió con los mecanismos en orden histórico y de sofisticación creciente —semáforos, monitores, paso de mensajes—, y desembocó en los problemas clásicos que sirven de banco de pruebas para cualquier mecanismo nuevo. En paralelo aprendiste dos lenguajes que representan dos filosofías opuestas: Ada, que hace la concurrencia segura mediante verificación en tiempo de compilación y encapsulamiento de la memoria compartida; y Elixir, que la hace segura eliminando la memoria compartida por completo.
Los últimos capítulos fueron proyectos, porque la única forma de saber si entendiste un mecanismo de sincronización es usarlo cuando nadie te dice cuál usar.
Lo que te llevas no es una lista de definiciones. Es un conjunto de preguntas que ahora sabes hacer frente a cualquier sistema: quién es el dueño de este dato, qué pasa si dos flujos llegan aquí al mismo tiempo, qué ocurre si el otro extremo desaparece justo ahora, cuánto tarda esto en el peor caso y no en el promedio, y quién decide cuando hay desacuerdo. Esas preguntas no caducan con la tecnología.
El temario completo, con los diecisiete capítulos y sus enlaces, está en el índice general del curso. Si quieres repasar el proyecto anterior antes de comparar ambos enfoques, está en el capítulo 16; y si prefieres volver a los fundamentos que sostienen todo lo que hiciste aquí, el capítulo 7 sigue siendo el mejor punto de partida.