Parte: 7 — Multijugador y networking · Fuente: Glazer & Madhav, "Multiplayer Game Programming" + charlas de GDC sobre netcode (Valve, Riot) ⏱️ Duración estimada: 50 min · Nivel: Avanzado
Elegir con criterio la arquitectura de red de tu juego antes de escribir la lógica de juego, porque ese cambio es carísimo a mitad de proyecto. Al terminar distinguirás P2P (con lockstep) de cliente-servidor, entenderás la diferencia crucial entre un servidor autoritativo y uno que confía en el cliente, conocerás la host-migración y montarás el esqueleto de un servidor autoritativo en Godot 4 donde el servidor decide y los clientes solo envían input.
Al finalizar, el alumno podrá:
| # | Tema | Por qué importa |
|---|---|---|
| 1 | P2P y topología de malla | Modelo simple pero frágil y difícil de asegurar |
| 2 | Lockstep determinista | Base de RTS; todo cliente simula lo mismo |
| 3 | Cliente-servidor (estrella) | Un árbitro central simplifica estado y seguridad |
| 4 | Servidor autoritativo | El servidor es la única fuente de verdad |
| 5 | Cliente confiado vs validado | Diferencia entre juego trampeable y seguro |
| 6 | Host (listen server) vs dedicado | Quién hace de servidor y sus implicaciones |
| 7 | Host-migración | Qué ocurre si el anfitrión se cae |
| 8 | Seguridad y superficie de ataque | Nunca confíes en datos del cliente |
Con Godot 4.x basta. Vamos a esbozar arquitecturas y luego montar un esqueleto autoritativo probado con dos instancias en localhost. Ten a la vista la guía de multijugador de alto nivel de Godot y, como lectura de arquitectura, la charla clásica de Source Multiplayer Networking. Antes de programar, dibuja en papel las tres topologías (malla P2P, estrella cliente-servidor y autoritativo con flechas de input/estado): ese diagrama guiará tu código.
Montaremos el esqueleto de un servidor autoritativo. Regla de oro: el cliente no mueve nada por su cuenta; solo manda su intención de movimiento al servidor, y el servidor calcula la posición real y la reparte. Crea Autoritativo.tscn con un Node raíz Juego.
extends Node
const PUERTO := 9998
const VELOCIDAD := 200.0
# El servidor guarda el estado de cada jugador: id -> posición.
var _estado: Dictionary = {}
func _ready() -> void:
multiplayer.peer_connected.connect(_on_peer_conectado)
multiplayer.peer_disconnected.connect(_on_peer_desconectado)
if "--server" in OS.get_cmdline_args():
_iniciar_servidor()
else:
_iniciar_cliente()
func _iniciar_servidor() -> void:
var peer := ENetMultiplayerPeer.new()
peer.create_server(PUERTO, 8)
multiplayer.multiplayer_peer = peer
print("[SERVIDOR] Autoritativo listo.")
func _iniciar_cliente() -> void:
var peer := ENetMultiplayerPeer.new()
peer.create_client("127.0.0.1", PUERTO)
multiplayer.multiplayer_peer = peer
print("[CLIENTE] Conectando...")
func _on_peer_conectado(id: int) -> void:
if multiplayer.is_server():
_estado[id] = Vector2.ZERO
print("[SERVIDOR] Jugador %d entra. Estado inicial (0,0)." % id)
func _on_peer_desconectado(id: int) -> void:
if multiplayer.is_server():
_estado.erase(id)
Ahora el flujo autoritativo. El cliente lee su teclado y solo envía el vector de input al servidor por RPC. El servidor valida, integra la posición y difunde el estado a todos.
func _physics_process(delta: float) -> void:
if not multiplayer.is_server() and multiplayer.multiplayer_peer != null:
# CLIENTE: mando mi intención, no mi posición.
var input := Input.get_vector("ui_left", "ui_right", "ui_up", "ui_down")
if input != Vector2.ZERO:
enviar_input.rpc_id(1, input)
if multiplayer.is_server():
# SERVIDOR: reparte el estado ya calculado.
difundir_estado.rpc(_estado)
@rpc("any_peer", "call_remote", "reliable")
func enviar_input(direccion: Vector2) -> void:
# Se ejecuta SOLO en el servidor.
var id := multiplayer.get_remote_sender_id()
if not _estado.has(id):
return
# Validación: normalizamos para que nadie "acelere" mandando vectores gigantes.
direccion = direccion.limit_length(1.0)
_estado[id] += direccion * VELOCIDAD * get_physics_process_delta_time()
@rpc("authority", "call_remote", "unreliable_ordered")
func difundir_estado(estado: Dictionary) -> void:
# Se ejecuta en los clientes: aquí actualizarías sprites por id.
for id in estado:
print("Jugador %d -> %s" % [id, estado[id]])
Cómo probarlo con dos instancias: lanza una con --server y otra como cliente. Al pulsar las flechas en el cliente verás cómo el servidor es quien recalcula y difunde la posición (los prints de difundir_estado aparecen en el cliente con coordenadas que crecen). Nota clave: si un cliente intentara "teletransportarse", no podría, porque nunca envía posición, solo dirección, y el servidor la limita.
print por Sprite2D reales instanciados por id para ver moverse a cada jugador.difundir_estado para que solo mande jugadores que cambiaron de posición.server_disconnected se dispara, muestra "El host se cayó".Extiende el esqueleto a un mini "captura la bandera" de posiciones: dos jugadores mueven su punto por el servidor autoritativo, y cuando dos puntos están a menos de 20 px el servidor declara "colisión" y lo anuncia a todos por RPC. Toda la lógica de distancia debe correr en el servidor.
Criterio de aceptación: con 1 servidor + 2 clientes, mover ambos puntos hasta juntarlos hace que los dos clientes impriman "¡Colisión!" simultáneamente; ningún cálculo de colisión ocurre en el cliente.
| Síntoma | Causa y arreglo |
|---|---|
| El cliente se mueve solo pero el servidor no lo sabe | Estás moviendo en el cliente; en autoritativo el cliente solo envía input |
| Todos ven posiciones distintas | La lógica de simulación corre en varios sitios; céntrala en el servidor |
enviar_input se ejecuta en el cliente |
Falta comprobar multiplayer.is_server() o usaste rpc() en vez de rpc_id(1, ...) |
| El juego es trampeable | Estás confiando datos del cliente; valida y recalcula siempre en el servidor |
| Nadie se mueve al soltar teclas | Solo envías input si input != Vector2.ZERO; es correcto, pero recuerda que el estado no se resetea solo |
Clase 138 - Fundamentos de redes para juegos: TCP, UDP y latencia
Clase 140 - El multijugador de alto nivel de Godot (MultiplayerAPI)