Proyecto final: un servidor de videojuegos concurrente y la bibliografía del curso

Por: Artiko
sistemas-operativosadaelixirconcurrenciasocketstcptick-looplatenciaservidor-de-juegosbibliografia

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, epoll y kqueue.
  • 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.

AspectoServidor por turnosServidor de tiempo real
Qué dispara el avance del estadoLa llegada de mensajes de los clientesEl reloj, a frecuencia fija
Qué hace si un cliente callaEspera (con timeout)Avanza igual, con la última entrada conocida
Modelo de concurrencia naturalUn proceso por partida, bloqueanteUn proceso de mundo con bucle, no bloqueante
Sensibilidad a la latenciaBaja: 300 ms son tolerablesAlta: 100 ms ya se notan
Coste por jugador inactivoCasi ceroConstante, se simula igual
EjemplosAjedrez, cartas, cachipúnShooters, deportes, plataformas
Protocolo típicoPetición-respuesta sobre TCPFlujo 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:

  1. 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.
  2. 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.
  3. 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.
  4. Control de flujo y de congestión. El kernel decide cuánto puede haber en vuelo. Esto significa que un write puede 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:

  • accept devuelve 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_REUSEADDR no es opcional en desarrollo. Sin él, cuando reinicias el servidor el puerto queda inutilizable durante el tiempo de espera del estado TIME_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 \ J2PiedraPapelTijerasLagartoSpock
Piedra01221
Papel20112
Tijeras12021
Lagarto12102
Spock21210

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ónMensajeSignificado
S → CBIENVENIDO RPSLS/1.0Saludo inicial y versión del protocolo
C → SNOMBRE <texto>El cliente se identifica
S → COK JUGADOR <1|2>Registro aceptado y número asignado
S → CESPERANDO_RIVALAún no hay dos jugadores en la sala
S → CRONDA <n>Comienza la ronda número n
S → CPIDE_JUGADA <ms>Solicita jugada con un plazo en milisegundos
C → SJUGADA <opcion>Envía piedra, papel, tijera, lagarto o spock
S → CRESULTADO <mia> <suya> <codigo>Jugadas de ambos y código 0, 1 o 2 desde la perspectiva del receptor
S → CMARCADOR <propio> <rival>Puntaje acumulado
C → SPING <token>Sonda de latencia
S → CPONG <token>Eco inmediato de la sonda
C → SSALIRCierre voluntario
S → CERROR <codigo> <texto>Mensaje malformado o fuera de estado
S → CFIN <motivo>El servidor cierra la sesión

Reglas de robustez que el protocolo impone y que conviene escribir antes de programar:

  1. Todo comando desconocido produce ERROR pero no cierra la conexión. Cerrar ante lo inesperado hace imposible evolucionar el protocolo.
  2. Las jugadas se comparan sin distinguir mayúsculas y sin acentos. TIJERA, tijera y Tijera son la misma cosa; tijeras en plural también se acepta, porque es el error más frecuente.
  3. 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.
  4. PING se 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: :line hace 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: false seguido de active: :once es 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 :once pides 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: true desactiva 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_process existe 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_plazo de 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:

  1. 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.
  2. 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.
  3. 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 ratedtUso típicoPresupuesto por tick
10 Hz100 msEstrategia por turnos con animación, MMO de mundo abierto100 ms
20 Hz50 msMMO de acción, juegos de supervivencia50 ms
30 Hz33.3 msEstándar de muchos shooters de consola33 ms
60 Hz16.7 msShooters competitivos, peleas16 ms
128 Hz7.8 msServidores premium de shooters tácticos7.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í:

ComponenteRango típicoQuién lo controla
Muestreo de la entrada en el cliente0 a 16 msEl cliente, según su frecuencia de cuadro
Encolado en el buffer de salida del sistema operativo0 a 5 msEl sistema operativo y la opción nodelay
Propagación por la red, ida5 a 150 msLa geografía
Encolado en el buffer de recepción del servidor0 a 5 msEl sistema operativo
Espera hasta el próximo tick0 a dtTu tick rate
Procesamiento del tick1 a 20 msTu código
Serialización y envío0.1 a 5 msTu formato de mensajes
Propagación por la red, vuelta5 a 150 msLa geografía
Espera hasta el próximo cuadro del cliente0 a 16 msEl cliente
Buffer de interpolación del cliente50 a 100 msTu 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

CriterioTCPUDP
Entrega garantizadaNo, la implementas tú si la necesitas
Orden garantizadoNo
Bloqueo de cabeza de líneaSí: un paquete perdido detiene todo lo posteriorNo: cada datagrama es independiente
Costo de una pérdidaAl menos un RTT de retransmisión para todo el flujoCero si el dato ya caducó
Fronteras de mensajeNo las hay, hay que hacer framingSí, cada datagrama es un mensaje
EstablecimientoSaludo de tres víasNinguno
Atraviesa NAT y cortafuegosCasi siempreA veces bloqueado
Complejidad de implementaciónBajaAlta si necesitas confiabilidad parcial
Uso recomendadoLobby, chat, inventario, cualquier cosa que deba llegarPosiciones, 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:

  1. El buffer de entradas del cliente, que guarda cada entrada enviada junto con su número de secuencia hasta que el servidor la confirma.
  2. El acuse de secuencia en cada instantánea, que es la razón por la que el servidor guarda ultima_seq por entidad en el código de arriba.
  3. 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/PONG del 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 observadoCausa realSolución
Los mensajes llegan pegados o cortados a la mitadTCP no preserva fronteras de mensaje; se asumió que cada read devuelve un mensaje completoUsar framing explícito: packet: :line en Erlang, o prefijo de longitud, o leer hasta el terminador
eaddrinuse al reiniciar el servidorEl puerto quedó en TIME_WAIT por el cierre anteriorActivar reuseaddr: true antes del bind
El servidor acepta la conexión pero nunca respondeEl aceptador no transfirió la propiedad del socket; los datos llegan a su buzónLlamar a controlling_process y recién entonces activar el socket
El proceso de conexión crece hasta agotar la memoriaSocket en modo active: true: la máquina virtual empuja todo lo que llega sin contrapresiónUsar active: :once y reactivar tras procesar cada mensaje
Retraso constante de 40 ms en mensajes pequeñosAlgoritmo de Nagle agrupando escrituras diminutasnodelay: true en servidor y cliente
El servidor se va atrasando: tras una hora corre notoriamente lentoDeriva 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 permanenteEspiral de la muerte: el acumulador crece más rápido de lo que se drenaLimitar los pasos por tick y descartar el acumulador restante, registrando el evento
Una ronda se resuelve dos vecesTemporizador de la ronda anterior no cancelado al recibir ambas jugadasGuardar la referencia del temporizador en el estado y cancelarla al resolver
Los jugadores se mueven más rápido en diagonalSe sumó el desplazamiento de ambos ejes sin normalizar el vectorNormalizar la dirección en el servidor, nunca confiar en la magnitud del cliente
Un jugador aparece teletransportándoseEl cliente envía posiciones absolutas en vez de intencionesAceptar solo dirección o entrada; el servidor calcula la posición
Los rivales se ven a saltosSe dibuja la última instantánea recibida sin interpolarRetrasar el render de entidades ajenas dos ticks e interpolar entre instantáneas
El cliente corrige la posición del propio jugador todo el tiempoLa función de avance del cliente difiere de la del servidorCompartir la misma lógica de simulación y aplicar el mismo dt fijo
La partida queda colgada esperando a alguien que se fueSe esperó un mensaje sin plazo ni monitor sobre el procesoPlazo en cada espera y Process.monitor sobre los participantes
La cola de espera se llena de jugadores fantasmaNo se detecta la muerte del proceso que esperaba rivalMonitorear cada jugador encolado y removerlo al recibir :DOWN
El servidor cierra la conexión ante un comando desconocidoSe trató lo inesperado como error fatalResponder ERROR y continuar; solo cerrar ante violaciones de seguridad
Todas las tareas de Ada se congelan en un puntoE/S bloqueante dentro de un objeto protegidoSacar la E/S fuera del objeto; dentro solo manipular estado en memoria
El servidor funciona con dos clientes y muere con doscientosCada conexión creó un hilo del sistema operativo con pila de 8 MBUsar procesos livianos, o un pool de hilos con E/S multiplexada
El promedio de latencia es bueno pero el juego se siente malSe midió el promedio y no el p99 ni el jitterMedir percentiles y desviación; el promedio oculta las colas

Ejercicios propuestos

  1. Framing sin ayuda. Reescribe el servidor Elixir con packet: :raw y 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.

  2. 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.

  3. 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.

  4. 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?

  5. 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.

  6. 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 dt y observa el comportamiento con y sin el tope de pasos por vuelta. Grafica el acumulador en el tiempo en ambos casos.

  7. 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.

  8. 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.

  9. 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.

  10. Á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.

  11. 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.

  12. 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 con tc netem.

  13. 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.

  14. Prueba de carga con perfil realista. Extiende Arena.Carga para 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 de dt.

  15. 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

ObraAutoríaNotas
Modern Operating Systems, 5.ª edición (2022)Andrew S. Tanenbaum y Herbert BosFundamental. 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ónThomas Anderson y Michael DahlinISBN 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 PiecesRemzi y Andrea Arpaci-DusseauDisponible en línea sin costo. Organizado en tres ejes: virtualización, concurrencia y persistencia
Fundamentos de sistemas operativos (sistop.org)Comunidad hispanohablanteMaterial en español de acceso abierto, útil para fijar vocabulario técnico
Fundamentos de Sistemas Operativos: una aproximación práctica usando Linuxhonecomp.github.io/librossoo.htmlEnfoque práctico apoyado en herramientas reales de Linux
Operating System ConceptsSilberschatz, Galvin y GagneEl “libro del dinosaurio”, referencia clásica en cursos universitarios

Artículos y textos históricos

ObraAutoríaPor qué importa
Bell’s Law for the birth and death of computer classes (1972, reeditado en CACM 51(1), 86-94)Gordon BellExplica por qué aparecen y desaparecen clases enteras de computadoras, y con ellas sus sistemas operativos
Cooperating Sequential Processes (1965)Edsger W. DijkstraOrigen del semáforo y del planteo formal de la exclusión mutua
Solution of a Problem in Concurrent Programming Control (1965)Edsger W. DijkstraPrimera solución por software al problema de la sección crítica
Monitors: An Operating System Structuring Concept (1974)C. A. R. HoareOrigen del monitor y de las variables de condición
The UNIX Time-Sharing System (1974)Dennis Ritchie y Ken ThompsonEl artículo que define el diseño que todavía usamos
A Note on Distributed Computing (1994)Waldo, Wyant, Wollrath y KendallPor 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 LamportBase conceptual de la causalidad en sistemas sin reloj común

Programación en Ada

RecursoUbicaciónUso
GNAT User’s Guide, sección de archivos y directoriosgcc.gnu.org/onlinedocs/Cómo el compilador ubica y nombra las unidades de compilación
Alire, documentación oficialalire.ada.dev/docs/Gestor de paquetes y de proyectos; la forma moderna de compilar Ada
libhello, biblioteca de demostracióngithub.com/alire-project/libhelloEjemplo mínimo de una biblioteca publicada con Alire
ada-lang.io, tutorialada-lang.io/docs/learn/tutorial/Introducción actualizada, incluye argumentos de línea de comandos
Ada Reference Manual, Ada.Strings.Unboundedadaic.com/resources/standards/Manejo de cadenas de longitud variable, con anotaciones de Randall Brukardt
Lovelace Tutorial, sección 8.3David WheelerTipos String y sus operaciones básicas
Programación en Ada, Wikilibroses.wikibooks.orgMaterial en español, útil para Ada.Strings.Unbounded
Discusión sobre múltiples procedimientos por archivocomp.lang.ada en Google GroupsAclara una duda recurrente sobre la organización del código fuente
Concurrent and Real-Time Programming in AdaAlan Burns y Andy WellingsFundamental para tareas, objetos protegidos, requeue y perfil Ravenscar

Elixir, Erlang y concurrencia por mensajes

ObraAutoríaNotas
Programming ElixirDave ThomasIntroducción al lenguaje y a su modelo de datos inmutables
Elixir in Action, 3.ª ediciónSaša JurićFundamental para este curso: procesos, GenServer, supervisión y OTP explicados desde la concurrencia
Designing for Scalability with Erlang/OTPFrancesco Cesarini y Steve VinoskiArquitecturas de sistemas tolerantes a fallos con OTP
Making reliable distributed systems in the presence of software errors (2003)Joe ArmstrongTesis doctoral que fundamenta el modelo de “dejar que falle”
Documentación de :gen_tcp, :inet y :gen_udperlang.org/docReferencia obligada para las opciones de socket usadas en el proyecto

Redes, sockets y sistemas distribuidos

ObraAutoríaNotas
UNIX Network Programming, Volume 1: The Sockets Networking APIW. Richard Stevens, Bill Fenner y Andrew RudoffFundamental. La referencia definitiva sobre la API de sockets
TCP/IP Illustrated, Volume 1: The ProtocolsW. Richard Stevens y Kevin FallQué ocurre realmente en el cable, incluidos Nagle, ventanas y retransmisión
Computer Networking: A Top-Down ApproachJames Kurose y Keith RossIntroducción ordenada a la pila completa
Designing Data-Intensive ApplicationsMartin KleppmannConsistencia, replicación y particionamiento; útil cuando el servidor deja de ser uno solo
RFC 9293, Transmission Control ProtocolIETFEspecificación consolidada de TCP
RFC 9000, QUIC: A UDP-Based Multiplexed and Secure TransportIETFEl camino moderno para transporte de baja latencia

Programación de juegos en red

RecursoAutoríaNotas
What Every Programmer Needs To Know About Game NetworkingGlenn Fiedler (Gaffer On Games)Serie fundamental sobre paso fijo, protocolos sobre UDP y sincronización de estado
Fast-Paced MultiplayerGabriel GambettaExplicació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, ValveEl artículo que popularizó la compensación de retraso
Source Multiplayer NetworkingValve Developer CommunityDocumentación práctica de un motor real: tick rate, interpolación, historial de posiciones
Fix Your Timestep!Glenn FiedlerEl artículo canónico sobre acumuladores y paso fijo
Game Engine ArchitectureJason GregoryCapí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.