Publicado: 2026-07-28 | Por: Carlos Montiel | Lectura: ~4 minutos
Con los sockets básicos resueltos, hoy damos el salto real: un servidor que sostiene el estado del juego, y clientes que se sincronizan con él en tiempo real.
La arquitectura: servidor autoritativo
En la lección anterior conectamos un cliente y un servidor que se saludaban una sola vez. Un juego multijugador real necesita mantener esa conexión abierta y sincronizar estado continuamente. Vamos a usar el patrón más común de la industria: servidor autoritativo. El servidor es la única fuente de verdad sobre dónde está cada jugador; los clientes solo envían su input (qué teclas presionan) y reciben del servidor la posición confirmada de todos los jugadores.
Esto evita que un cliente malicioso o con un bug pueda simplemente decir "estoy en la posición X" y hacer trampa — el servidor decide.
Definiendo el protocolo de mensajes
Antes de escribir una sola línea de red, definimos exactamente qué estructuras se van a enviar. Esto es crítico: cliente y servidor deben coincidir byte por byte en cómo interpretan los mensajes.
#include <cstdint>
enum class TipoMensaje : uint8_t {
INPUT_JUGADOR = 1,
ESTADO_MUNDO = 2
};
#pragma pack(push, 1) // evita relleno (padding) del compilador entre campos
struct MensajeInput {
TipoMensaje tipo = TipoMensaje::INPUT_JUGADOR;
uint8_t id_jugador;
float mover_x; // -1.0, 0.0 o 1.0
float mover_y;
};
struct EstadoJugador {
uint8_t id_jugador;
float x, y;
};
struct MensajeEstadoMundo {
TipoMensaje tipo = TipoMensaje::ESTADO_MUNDO;
uint8_t cantidad_jugadores;
EstadoJugador jugadores[4]; // hasta 4 jugadores en esta versión simple
};
#pragma pack(pop)
`#pragma pack(push, 1)` le dice al compilador que no agregue bytes de relleno entre campos para alinear la memoria — sin esto, el tamaño exacto en bytes de la estructura podría variar entre compiladores o arquitecturas, y cliente y servidor podrían interpretar los mismos bytes de forma distinta.
Resolviendo el framing de mensajes
Como vimos en la lección anterior, TCP no respeta límites de mensaje. La solución estándar es enviar siempre primero el tamaño del mensaje, y leer exactamente esa cantidad de bytes del otro lado.
#include <sys/socket.h>
#include <cstdint>
#include <cstring>
bool enviar_mensaje(int socket_fd, const void* datos, uint32_t tamano) {
uint32_t tamano_red = htonl(tamano);
if (send(socket_fd, &tamano_red, sizeof(tamano_red), 0) != sizeof(tamano_red)) {
return false;
}
return send(socket_fd, datos, tamano, 0) == (ssize_t)tamano;
}
bool recibir_exacto(int socket_fd, void* buffer, size_t cantidad) {
size_t total_recibido = 0;
while (total_recibido < cantidad) {
ssize_t leidos = recv(socket_fd, (char*)buffer + total_recibido,
cantidad - total_recibido, 0);
if (leidos <= 0) return false; // conexión cerrada o error
total_recibido += leidos;
}
return true;
}
bool recibir_mensaje(int socket_fd, void* buffer, uint32_t tamano_maximo) {
uint32_t tamano_red;
if (!recibir_exacto(socket_fd, &tamano_red, sizeof(tamano_red))) return false;
uint32_t tamano = ntohl(tamano_red);
if (tamano > tamano_maximo) return false;
return recibir_exacto(socket_fd, buffer, tamano);
}
El bucle del servidor: recibir input, actualizar, difundir
Para simplificar y enfocarnos en la lógica de red (el manejo de múltiples clientes simultáneos con `select()` o hilos queda fuera del alcance introductorio de esta lección), este servidor atiende un cliente a la vez en su propio hilo con `std::thread`.
#include <thread>
#include <mutex>
#include <vector>
struct EstadoServidor {
EstadoJugador jugadores[4];
int cantidad_jugadores = 0;
std::mutex mutex_estado;
};
EstadoServidor estado_global;
void atender_cliente(int socket_cliente, uint8_t id_jugador) {
while (true) {
MensajeInput input;
if (!recibir_mensaje(socket_cliente, &input, sizeof(input))) break;
{
std::lock_guard<std::mutex> bloqueo(estado_global.mutex_estado);
EstadoJugador& jugador = estado_global.jugadores[id_jugador];
const float VELOCIDAD = 200.0f;
const float DELTA_SERVIDOR = 0.016f; // paso fijo simplificado
jugador.x += input.mover_x * VELOCIDAD * DELTA_SERVIDOR;
jugador.y += input.mover_y * VELOCIDAD * DELTA_SERVIDOR;
}
MensajeEstadoMundo estado_mundo;
{
std::lock_guard<std::mutex> bloqueo(estado_global.mutex_estado);
estado_mundo.cantidad_jugadores = estado_global.cantidad_jugadores;
for (int i = 0; i < estado_global.cantidad_jugadores; ++i) {
estado_mundo.jugadores[i] = estado_global.jugadores[i];
}
}
enviar_mensaje(socket_cliente, &estado_mundo, sizeof(estado_mundo));
}
close(socket_cliente);
}
El `std::mutex` protege el estado compartido entre los distintos hilos que atienden a cada cliente — sin él, dos hilos podrían leer y escribir `estado_global` al mismo tiempo y corromper los datos (una "race condition"). Este es otro concepto donde vale la pena pedirle a tu asistente de IA un ejemplo mínimo reproducible de qué pasa exactamente sin el mutex, para verlo fallar antes de confiar en por qué lo necesitas.
El bucle del cliente: enviar input, recibir y aplicar estado
void hilo_red_cliente(int socket_servidor, EstadoJugador jugadores_render[4], int* cantidad, std::mutex* mutex_render) {
while (true) {
MensajeEstadoMundo estado_mundo;
if (!recibir_mensaje(socket_servidor, &estado_mundo, sizeof(estado_mundo))) break;
std::lock_guard<std::mutex> bloqueo(*mutex_render);
*cantidad = estado_mundo.cantidad_jugadores;
for (int i = 0; i < *cantidad; ++i) {
jugadores_render[i] = estado_mundo.jugadores[i];
}
}
}
// En el hilo principal, dentro del game loop:
MensajeInput mi_input;
mi_input.id_jugador = mi_id;
mi_input.mover_x = 0; mi_input.mover_y = 0;
if (teclado[SDL_SCANCODE_D]) mi_input.mover_x = 1.0f;
if (teclado[SDL_SCANCODE_A]) mi_input.mover_x = -1.0f;
enviar_mensaje(socket_servidor, &mi_input, sizeof(mi_input));
Nota la separación: el hilo de red solo actualiza `jugadores_render` (protegido por su propio mutex), y el hilo principal de SDL2 solo lo lee para dibujar. Nunca mezcles llamadas a SDL2 desde un hilo que no sea el principal — la mayoría de las funciones de SDL2 no son thread-safe.
Latencia: por qué tu jugador se sentirá "lento"
Incluso en una red local, hay un retraso entre que presionas una tecla y ves el resultado reflejado, porque el cliente espera la confirmación del servidor antes de mover al jugador en pantalla. Los juegos multijugador profesionales resuelven esto con predicción del lado del cliente (mover al jugador de inmediato de forma local, y corregir suavemente si el servidor discrepa) — una técnica avanzada que queda fuera del alcance de esta introducción, pero que vale la pena investigar con tu asistente de IA una vez que domines este flujo básico de sincronización.
Con las bases de sockets y sincronización de estado resueltas, en la próxima lección vamos a cambiar de tema por completo: cómo hacer que todo este código — el de red incluido — corra más rápido y consuma menos memoria.