Parte: 7 — Multijugador y networking · Fuente: Glenn Fiedler, "Gaffer On Games" (Reading and Writing Packets / Serialization Strategies) + documentación de PackedByteArray de Godot 4 ⏱️ Duración estimada: 50 min · Nivel: Avanzado
El ancho de banda cuesta dinero y latencia: cuantos más bytes envías, más se satura la red y peor escala tu servidor. En esta clase reduces el tráfico de un sincronizador aplicando cuatro palancas: bajar la frecuencia de envío (tick rate), cuantizar floats (posiciones con menos precisión), enviar solo lo que cambió (deltas) y lo relevante (interés/AOI), y elegir bien reliable vs unreliable. Empaquetarás el estado en un PackedByteArray y medirás los bytes por segundo antes y después.
Al finalizar, el alumno podrá:
PackedByteArray y desempaquetarlo correctamente.| # | Tema | Por qué importa |
|---|---|---|
| 1 | Coste del ancho de banda | Escala con jugadores × tick rate × tamaño |
| 2 | Tick rate de envío | Menos envíos = menos tráfico (con más responsabilidad del interp) |
| 3 | Cuantización de floats | Un float de 4 B puede caber en 2 B |
| 4 | Bitpacking | Empaquetar flags y valores en pocos bits |
| 5 | Deltas y dirty flags | No reenviar lo que no cambió |
| 6 | Interés / AOI | No mandar lo que el jugador no puede ver |
| 7 | Reliable vs unreliable | Fiabilidad vs coste; elegir por tipo de dato |
PackedByteArray: buffer binario de Godot con encode_*/decode_* y put_* para serializar de forma compacta.Godot 4.x, dos instancias. En vez de replicar propiedades sueltas con MultiplayerSynchronizer, construiremos un paquete binario propio con PackedByteArray y lo enviaremos por un RPC no fiable a una frecuencia controlada. Mediremos bytes con un contador propio (sumando paquete.size()) y con el Network Profiler de Godot (Debug → Network Profiler). Repasa PackedByteArray y sus métodos encode_u16/decode_u16.
1. Punto de partida y medición. Antes de optimizar, mide. Envía la posición como dos floats a 30 Hz y suma bytes.
var bytes_enviados := 0
var _acum := 0.0
func _process(delta: float) -> void:
if not multiplayer.is_server():
return
_acum += delta
if _acum >= 1.0:
print("Bytes/seg: ", bytes_enviados)
bytes_enviados = 0
_acum = 0.0
2. Cuantizar la posición. Un mapa de -10000..10000 px cabe en un entero de 16 bits por eje si aceptamos precisión de ~0.3 px. Pasamos de 8 B (2 floats) a 4 B (2 u16).
const RANGO := 20000.0 # ancho total del mundo en px
const MAX_U16 := 65535.0
func cuantizar(v: float) -> int:
var t := clampf((v + RANGO * 0.5) / RANGO, 0.0, 1.0)
return int(round(t * MAX_U16))
func descuantizar(q: int) -> float:
return (q / MAX_U16) * RANGO - RANGO * 0.5
3. Empaquetar en PackedByteArray. Serializa id (u16) + x (u16) + y (u16) = 6 bytes por entidad, más compacto que un Dictionary var-serializado.
func empaquetar_estado(entidades: Array) -> PackedByteArray:
var buf := PackedByteArray()
buf.resize(2 + entidades.size() * 6) # 2 B de conteo + 6 B por entidad
var off := 0
buf.encode_u16(off, entidades.size()); off += 2
for e in entidades:
buf.encode_u16(off, e.id); off += 2
buf.encode_u16(off, cuantizar(e.pos.x)); off += 2
buf.encode_u16(off, cuantizar(e.pos.y)); off += 2
return buf
func desempaquetar_estado(buf: PackedByteArray) -> Array:
var res: Array = []
var off := 0
var n := buf.decode_u16(off); off += 2
for i in n:
var id := buf.decode_u16(off); off += 2
var x := descuantizar(buf.decode_u16(off)); off += 2
var y := descuantizar(buf.decode_u16(off)); off += 2
res.append({"id": id, "pos": Vector2(x, y)})
return res
4. Enviar solo deltas + AOI, a menor frecuencia. Manda cada entidad únicamente si se movió, y solo las cercanas al destinatario, a 15 Hz en vez de 30.
const AOI_RADIO := 1200.0
const UMBRAL_MOV := 1.0
var ultima_pos: Dictionary = {} # id -> Vector2 ya enviada
func difundir_estado() -> void: # llamar ~15 veces/seg con un Timer
if not multiplayer.is_server():
return
for peer_id in multiplayer.get_peers():
var receptor := get_node_or_null(str(peer_id))
if receptor == null:
continue
var visibles: Array = []
for e in get_tree().get_nodes_in_group("jugadores"):
var eid := e.name.to_int()
# AOI: solo lo cercano al receptor
if receptor.position.distance_to(e.position) > AOI_RADIO:
continue
# Delta: solo si se movió lo suficiente
var previa: Vector2 = ultima_pos.get(eid, Vector2(1e9, 1e9))
if e.position.distance_to(previa) < UMBRAL_MOV:
continue
ultima_pos[eid] = e.position
visibles.append({"id": eid, "pos": e.position})
if visibles.is_empty():
continue
var paquete := empaquetar_estado(visibles)
bytes_enviados += paquete.size()
recibir_estado.rpc_id(peer_id, paquete)
@rpc("authority", "call_remote", "unreliable")
func recibir_estado(buf: PackedByteArray) -> void:
for e in desempaquetar_estado(buf):
var nodo := get_node_or_null(str(e["id"]))
if nodo:
nodo.position = e["pos"] # (idealmente alimenta el interpolador de la clase 146)
5. Mide de nuevo. Compara el contador bytes_enviados antes (floats a 30 Hz, todo a todos) y después (u16, deltas, AOI, 15 Hz). Deberías ver una reducción grande, sobre todo cuando hay jugadores quietos o lejos. Combínalo con la interpolación (clase 146) para que bajar el tick rate no se note en fluidez.
AOI_RADIO dependiente del zoom de cámara del receptor.var_to_bytes(dict) del mismo estado y anota la diferencia.Reduce el tráfico de un sincronizador de jugadores: baja el tick rate de envío, cuantiza la posición, envía solo deltas y aplica AOI, empaquetando en PackedByteArray. Mide bytes/seg antes y después.
Criterio de aceptación: el contador de bytes/seg tras optimizar es netamente menor que el de partida (idealmente menos de la mitad con jugadores dispersos); jugadores quietos o fuera del AOI dejan de generar tráfico; el movimiento sigue viéndose fluido gracias a la interpolación.
| Síntoma | Causa y arreglo |
|---|---|
| Posiciones "tembleques" tras cuantizar | Precisión insuficiente; usa u16 o amplía menos el rango |
| Desempaquetado corrupto | Los offsets de encode/decode no coinciden; usa el mismo orden y tamaño de tipos |
| El estado remoto "salta" a 15 Hz | No interpolas; combina con la clase 146 |
| Jugadores lejanos desaparecen mal | AOI sin histéresis; añade margen al entrar/salir del radio |
| No baja el tráfico | Envías todo cada tick sin comparar con ultima_pos (no hay deltas reales) |
¿Cuánto puedo bajar el tick rate? Depende del juego: shooters rápidos usan 20-60 Hz; juegos más lentos, 10-15 Hz. Cuanto más bajes, más trabajo hace la interpolación/predicción.
¿Cuantizar no rompe la física? No, porque solo cuantizas lo que envías para render; la simulación autoritativa sigue con floats completos en el servidor.
¿Reliable o unreliable para el estado? Unreliable: el estado se reenvía constantemente, perder un paquete no importa. Reserva reliable para eventos que no se repiten (disparo, recogida, muerte).
¿AOI merece la pena con pocos jugadores? Con 2-4 no tanto; su valor crece con decenas o cientos de entidades, donde mandar todo a todos es O(n²) en tráfico.
Clase 148 - Servidor autoritativo y anti-cheat básico