Your First Networked Multiplayer Game

By Carlos Montiel | Enterprise AI Specialist
Leer en español →
Published: 2026-07-28 | By: Carlos Montiel | Reading time: ~4 minutes

With basic sockets out of the way, today we make the real leap: a server that holds the game state, and clients that sync with it in real time.

The Architecture: Authoritative Server

In the previous lesson we connected a client and a server that greeted each other once. A real multiplayer game needs to keep that connection open and continuously sync state. We'll use the most common pattern in the industry: an authoritative server. The server is the single source of truth for where every player is; clients only send their input (which keys they're pressing) and receive the confirmed position of all players back from the server.

This prevents a malicious or buggy client from simply saying "I'm at position X" and cheating — the server decides.

Defining the Message Protocol

Before writing a single line of networking code, we define exactly what structures will be sent. This is critical: client and server must agree byte for byte on how messages are interpreted.

#include <cstdint> enum class MessageType : uint8_t { PLAYER_INPUT = 1, WORLD_STATE = 2 }; #pragma pack(push, 1) // prevents the compiler from padding between fields struct InputMessage { MessageType type = MessageType::PLAYER_INPUT; uint8_t player_id; float move_x; // -1.0, 0.0, or 1.0 float move_y; }; struct PlayerState { uint8_t player_id; float x, y; }; struct WorldStateMessage { MessageType type = MessageType::WORLD_STATE; uint8_t player_count; PlayerState players[4]; // up to 4 players in this simple version }; #pragma pack(pop)

`#pragma pack(push, 1)` tells the compiler not to add padding bytes between fields to align memory — without this, the exact byte size of the structure could vary between compilers or architectures, and client and server could interpret the same bytes differently.

Solving Message Framing

As we saw in the previous lesson, TCP doesn't respect message boundaries. The standard solution is to always send the message size first, and read exactly that many bytes on the other end.

#include <sys/socket.h> #include <cstdint> #include <cstring> bool send_message(int socket_fd, const void* data, uint32_t size) { uint32_t network_size = htonl(size); if (send(socket_fd, &network_size, sizeof(network_size), 0) != sizeof(network_size)) { return false; } return send(socket_fd, data, size, 0) == (ssize_t)size; } bool receive_exact(int socket_fd, void* buffer, size_t amount) { size_t total_received = 0; while (total_received < amount) { ssize_t received = recv(socket_fd, (char*)buffer + total_received, amount - total_received, 0); if (received <= 0) return false; // connection closed or error total_received += received; } return true; } bool receive_message(int socket_fd, void* buffer, uint32_t max_size) { uint32_t network_size; if (!receive_exact(socket_fd, &network_size, sizeof(network_size))) return false; uint32_t size = ntohl(network_size); if (size > max_size) return false; return receive_exact(socket_fd, buffer, size); }

The Server Loop: Receive Input, Update, Broadcast

To keep things simple and focus on the networking logic (handling multiple simultaneous clients with `select()` or threads is outside this lesson's introductory scope), this server handles one client at a time on its own `std::thread`.

#include <thread> #include <mutex> #include <vector> struct ServerState { PlayerState players[4]; int player_count = 0; std::mutex state_mutex; }; ServerState global_state; void handle_client(int client_socket, uint8_t player_id) { while (true) { InputMessage input; if (!receive_message(client_socket, &input, sizeof(input))) break; { std::lock_guard<std::mutex> lock(global_state.state_mutex); PlayerState& player = global_state.players[player_id]; const float SPEED = 200.0f; const float SERVER_DELTA = 0.016f; // simplified fixed step player.x += input.move_x * SPEED * SERVER_DELTA; player.y += input.move_y * SPEED * SERVER_DELTA; } WorldStateMessage world_state; { std::lock_guard<std::mutex> lock(global_state.state_mutex); world_state.player_count = global_state.player_count; for (int i = 0; i < global_state.player_count; ++i) { world_state.players[i] = global_state.players[i]; } } send_message(client_socket, &world_state, sizeof(world_state)); } close(client_socket); }

The `std::mutex` protects the shared state between the different threads handling each client — without it, two threads could read and write `global_state` at the same time and corrupt the data (a "race condition"). This is another concept worth asking your AI assistant for a minimal reproducible example of exactly what happens without the mutex, so you can watch it fail before trusting why you need it.

The Client Loop: Send Input, Receive and Apply State

void client_network_thread(int server_socket, PlayerState render_players[4], int* count, std::mutex* render_mutex) { while (true) { WorldStateMessage world_state; if (!receive_message(server_socket, &world_state, sizeof(world_state))) break; std::lock_guard<std::mutex> lock(*render_mutex); *count = world_state.player_count; for (int i = 0; i < *count; ++i) { render_players[i] = world_state.players[i]; } } } // In the main thread, inside the game loop: InputMessage my_input; my_input.player_id = my_id; my_input.move_x = 0; my_input.move_y = 0; if (keyboard[SDL_SCANCODE_D]) my_input.move_x = 1.0f; if (keyboard[SDL_SCANCODE_A]) my_input.move_x = -1.0f; send_message(server_socket, &my_input, sizeof(my_input));

Notice the separation: the network thread only updates `render_players` (protected by its own mutex), and SDL2's main thread only reads it to draw. Never call SDL2 functions from a thread other than the main one — most SDL2 functions aren't thread-safe.

Latency: Why Your Player Will Feel "Slow"

Even on a local network, there's a delay between pressing a key and seeing the result reflected, because the client waits for server confirmation before moving the player on screen. Professional multiplayer games solve this with client-side prediction (move the player immediately and locally, then smoothly correct if the server disagrees) — an advanced technique outside the scope of this introduction, but worth researching with your AI assistant once you've mastered this basic sync flow.

With the sockets and state-sync fundamentals handled, in the next lesson we'll switch topics entirely: how to make all of this code — networking included — run faster and use less memory.

Carlos Montiel
Enterprise AI Solutions Architect
Specialist in LLMs, Agents, and Orchestration
guatemalia.com/en/#contact · info@guatemalia.com

Need to implement AI at your company?

Carlos Montiel is an enterprise AI solutions architect. He implements LLMs, Agents, RAG, and orchestrators for companies across Guatemala and Latin America. Reach out for a consultation.

Contact Carlos Montiel

info@guatemalia.com