
🛠️ Tools & Stack Link to heading
- Language: C (server), C (client)
- Network: WebSockets
💡 Intro Link to heading
Real-time multiplayer games are some of the most exciting (and complex) software systems to build. Behind every fast-paced shooter or battle royale lies a backend server that syncs dozens of players at 60+ frames per second — all while keeping the gameplay fair, responsive, and secure.
In this blog series, I’ll walk you through the journey of building a real-time multiplayer game server from scratch. We’ll cover everything from basic architecture and state synchronization to matchmaking, lag compensation, and scaling.
In Part 1, we’ll focus on laying the foundation: understanding what makes real-time game networking special, choosing the right technologies, and designing our server’s architecture.
📐 What Makes Game Servers Unique? Link to heading
-
Real-time responsiveness (low latency is critical)
-
Synchronizing player state across clients
-
Handling network dropouts, packet loss, cheating
-
Fast game loops, not request/response
🔌 Choosing the Right Protocol Link to heading
One of the first decisions you face when building a game server is choosing the network transport protocol. Most games use a mix of TCP and UDP, but each comes with important trade-offs.
Let’s break them down.
📦 TCP (Transmission Control Protocol) Link to heading
TCP is:
- Reliable: guarantees delivery, order, and no duplicates
- Connection-based: the client must handshake with the server
- Error-correcting: retransmits lost packets automatically
✅ Best for:
- Turn-based games
- Chat messages
- Matchmaking services
- Initial game loading
❌ Drawbacks for real-time games:
- Introduces latency due to retransmission delays
- Sends larger headers
- Causes the dreaded “TCP Head-of-Line Blocking”
⚡ UDP (User Datagram Protocol) Link to heading
UDP is:
- Connectionless: just send packets — no handshake needed
- Fast & lightweight: tiny headers, no built-in reliability
- Unreliable: no delivery guarantee, ordering, or duplication control
✅ Best for:
- Real-time position updates
- Snapshots of game state
- Frequent inputs like movement or aiming
❌ Drawbacks:
- You must manually handle packet loss
- Can be dropped silently by routers or firewalls
- Requires custom reliability if needed (e.g. ENet, RakNet)
🎮 In Practice: Use Both Link to heading
Most modern games use a hybrid model:
| System | Protocol |
|---|---|
| Login, Auth, Matchmaking | TCP |
| Chat & Store Data | TCP |
| Game State, Inputs, Movement | UDP |
| Combat / Hit Registration | UDP (with lag comp) |
This way, you get the performance of UDP for real-time play, and the reliability of TCP where it matters.
💡 What I’m Using Link to heading
For this project:
- I started with WebSockets (over TCP) for ease of testing and cross-platform support.
- Later posts will explore a full UDP-based server using raw sockets or a library like ENet.
🧱 Designing the Server Architecture Link to heading
Players (clients) <--> Game Server
|
Game Loop (tick)
|
Broadcast Position Updates
- Client-Server model
- Fixed timestep game loop
- Simple entity system: players, bullets, positions
What Is the Client-Server Model in Multiplayer Games? Link to heading
In multiplayer games, the Client-Server model is the most common network architecture — and for good reason. It ensures fairness, security, and centralized control over the game world.
🧠 TL;DR
The Client-Server model is like playing soccer with a referee. Everyone plays, but the referee (server) decides what counts. It’s the gold standard for fast, fair, and scalable multiplayer games.
The Concept Link to heading
A server hosts the authoritative game state (positions, health, scores, etc.). Each client (player) sends input to the server (like: move left, shoot, jump). The server processes these inputs, updates the game state, and sends updates back to all clients.
🧱 Think of the server as the referee. Link to heading
[ Client A ] [ Client B ]
| |
v v
----------- -------------
| Server | <---> | Game State |
----------- -------------
^ ^
| |
Input: WASD, Shoot Input: Move, Jump
✅ Why Use This Model?
- Fairness The server decides what “really” happened (no cheating with position or shots).
- Security The server hides key data and validates actions.
- Consistency All players get the same version of the truth.
- Flexibility Easy to add lobbies, matchmaking, replays, and AI-controlled players.
❌ Downsides?
- Slight lag — you have to wait for your input to reach the server and bounce back.
- Needs client-side prediction to feel responsive.
- Requires a well-managed server infrastructure.
For this project, the server is responsible for:
- Storing player positions, health, velocity
- Simulating the game loop (ticks)
- Receiving input and sending updated snapshots to clients
The clients:
- Send movement and action input
- Predict local movement
- Receive state snapshots from the server to render others
🔧 Code Example: A Minimal Client-Server Tick Loop Link to heading
🖥️ Server (C) Link to heading
This server listens for WebSocket connections, accepts movement input, simulates positions, and sends the updated state to all clients.
#include <websocketpp/config/asio_no_tls.hpp>
#include <websocketpp/server.hpp>
#include <nlohmann/json.hpp>
#include <unordered_map>
#include <mutex>
#include <thread>
#include <chrono>
#include <iostream>
using json = nlohmann::json;
using websocketpp::connection_hdl;
using Server = websocketpp::server<websocketpp::config::asio>;
struct Player {
double x = 0, y = 0;
double vx = 0, vy = 0;
connection_hdl conn;
};
std::unordered_map<std::string, Player> players;
std::mutex players_mutex;
Server server;
std::string hdl_to_string(const connection_hdl& hdl) {
std::stringstream ss;
ss << &hdl;
return ss.str();
}
void on_message(Server* s, connection_hdl hdl, Server::message_ptr msg) {
std::string id = hdl_to_string(hdl);
try {
json data = json::parse(msg->get_payload());
std::lock_guard<std::mutex> lock(players_mutex);
if (players.count(id)) {
players[id].vx = data.value("vx", 0.0);
players[id].vy = data.value("vy", 0.0);
}
} catch (...) {
std::cerr << "Invalid message from client.\n";
}
}
void on_open(Server* s, connection_hdl hdl) {
std::string id = hdl_to_string(hdl);
std::lock_guard<std::mutex> lock(players_mutex);
players[id] = Player{0, 0, 0, 0, hdl};
std::cout << "Player joined: " << id << std::endl;
}
void on_close(Server* s, connection_hdl hdl) {
std::string id = hdl_to_string(hdl);
std::lock_guard<std::mutex> lock(players_mutex);
players.erase(id);
std::cout << "Player left: " << id << std::endl;
}
void game_loop() {
while (true) {
std::this_thread::sleep_for(std::chrono::milliseconds(50)); // 20 TPS
json state;
{
std::lock_guard<std::mutex> lock(players_mutex);
for (const auto& [id, p] : players) {
state["players"][id] = {
{"x", p.x}, {"y", p.y}
};
}
for (auto& [id, p] : players) {
p.x += p.vx * 0.05;
p.y += p.vy * 0.05;
try {
server.send(p.conn, state.dump(), websocketpp::frame::opcode::text);
} catch (const websocketpp::exception& e) {
std::cerr << "Send failed: " << e.what() << std::endl;
}
}
}
}
}
int main() {
server.init_asio();
server.set_open_handler(std::bind(&on_open, &server, std::placeholders::_1));
server.set_close_handler(std::bind(&on_close, &server, std::placeholders::_1));
server.set_message_handler(std::bind(&on_message, &server, std::placeholders::_1, std::placeholders::_2));
server.listen(8765);
server.start_accept();
std::thread loop(game_loop);
server.run();
loop.join();
return 0;
}
🎮 Client (C++ with WebSockets) Link to heading
This C++ client sends movement input and prints position data it receives back from the server.
// Pseudo C++ example using WebSocket++
#include <websocketpp/config/asio_no_tls_client.hpp>
#include <websocketpp/client.hpp>
#include <iostream>
typedef websocketpp::client<websocketpp::config::asio_client> client;
void on_message(websocketpp::connection_hdl, client::message_ptr msg) {
std::cout << "Received state: " << msg->get_payload() << std::endl;
}
int main() {
client c;
c.init_asio();
c.set_message_handler(&on_message);
websocketpp::lib::error_code ec;
auto con = c.get_connection("ws://localhost:8765/ws", ec);
c.connect(con);
std::thread t([&c]() { c.run(); });
while (true) {
std::string input = R"({"vx": 100, "vy": 0})";
con->send(input);
std::this_thread::sleep_for(std::chrono::milliseconds(100));
}
t.join();
}
🧠 Explanation Link to heading
Clients send input (vx, vy) at a fixed interval (more details about this later.).
The server applies velocity to each player’s position every tick.
It then broadcasts the entire game state to all connected clients.
This is the core loop of our real-time multiplayer game, and we are going to explore more on later posts.
⏱️ What is a Fixed Timestep Game Loop? Link to heading
In multiplayer games (and simulations), we need the game to run consistently regardless of frame rate, latency, or performance differences. That’s where the fixed timestep (a.k.a. fixed update loop) comes in.
🔄 Fixed vs Variable Timestep Link to heading
Variable timestep = update game logic using the actual time since the last frame.
Smooth but can cause inconsistent physics.
Fixed timestep = update game logic at a fixed interval (e.g., every 50ms = 20 ticks/sec).
Ensures predictable, reproducible updates.
🎯 Why It Matters for Multiplayer Link to heading
✅ Deterministic simulation
✅ Consistent networking intervals (e.g., send updates every tick)
✅ Easier to implement lag compensation and rollback
✅ Server and clients can stay in sync over time
🖥️ Server (C++) Link to heading
#include <chrono>
#include <thread>
void gameLoop() {
const int tickRate = 20; // ticks per second
const std::chrono::milliseconds tickInterval(1000 / tickRate);
while (true) {
auto start = std::chrono::steady_clock::now();
// Game logic happens here
updatePlayerPositions();
broadcastGameState();
// Wait for the next tick
std::this_thread::sleep_until(start + tickInterval);
}
}
🔁 What Happens Each Tick? Link to heading
Process inputs from clients
Update world state (physics, movement)
Broadcast updated state
Wait for the next tick
📏 Choosing a Timestep Link to heading
| Tickrate (Hz) | Interval (ms) | Use case |
|---|---|---|
| 10–20 | 100–50ms | Turn-based, mobile |
| 30–60 | 33–16ms | Action, shooters |
| 128+ | <8ms | Competitive FPS (e.g., CS:GO servers) |
🧠 Higher tickrates = smoother feel, but more CPU and network load
💡 Interpolation & Prediction
*** A fixed tick loop doesn’t mean the game looks choppy — you can use interpolation to smooth movement between ticks on the client, and prediction to simulate locally.**
✅ What We Built Link to heading
In this post, we:
[✔️] Implemented a basic client-server loop using WebSockets
[✔️] Explained TCP vs UDP and when to use each
[✔️] Introduced the fixed timestep game loop
We now have a solid foundation for real-time multiplayer gameplay — and the server can already handle basic player movement and state updates.
🔜 What’s Next Link to heading
In the next post, we’ll level up by adding:
📡 Serialization and data oriented design
🎮 Input buffering and prediction (And add some real game)
This will get us closer to the buttery-smooth movement you expect in online games — even when there’s network delay.
📚 References Link to heading
- Gaffer on Games – Networking for Game Programmers
- Beej’s Guide to Network Programming
- ENet Reliable UDP Networking Library
- WebSocket++ (C++ Library)
- I Shot You First: Networking the Gameplay of Halo: Reach
- Game Programming Patterns by Robert Nystrom
- Unreal Networking Overview
🤝 Stay Connected Link to heading
- 📫 marcius@criogenio.com
- 💻 GitHub
- Thanks for reading — see you in the next devlog! 🕹️