BUILDING A MULTIPLAYER CARD GAME IN C++ FROM SCRATCH
Building a Multiplayer Card Game in C++ from Scratch
I wanted to understand how multiplayer actually works. Not the Unity/Unreal abstraction layer — the raw sockets, the threading, the state synchronization. So I built a Hearthstone-inspired card game in C++ with no engine, no framework, just POSIX sockets and the standard library.
Why No Engine
Engines are great for shipping games. They’re terrible for learning networking fundamentals. When you call RPCServer.broadcast() in Godot or use Unreal’s replication system, you skip the part where you actually understand what’s happening on the wire.
I wanted to know what happens. What does a game packet look like? How do you handle a client disconnecting mid-turn? What breaks when two threads touch the same game state?
Building from scratch answered all of those questions — usually by breaking in spectacular ways first.
Architecture
The game uses a client-server model over TCP. I considered UDP early on, but for a turn-based card game, TCP’s reliability guarantees made more sense than hand-rolling acknowledgment logic. The server is authoritative — clients send actions, the server validates them against the game rules, mutates state, and broadcasts the result.
Messages are JSON objects sent over the socket with a length prefix. Simple, debuggable, not particularly efficient. Here’s a stripped-down version of the server’s message handling loop:
void Server::run() {
while (running_) {
auto [client_fd, message] = message_queue_.pop(); // blocks until message available
auto json = nlohmann::json::parse(message);
std::string action = json["action"];
std::lock_guard<std::mutex> lock(game_state_mutex_);
if (action == "play_card") {
int card_id = json["card_id"];
int target = json["target"];
if (!game_state_.validate_play(client_fd, card_id, target)) {
send(client_fd, R"({"error": "invalid_play"})");
continue;
}
game_state_.apply_play(client_fd, card_id, target);
broadcast(game_state_.serialize());
} else if (action == "end_turn") {
game_state_.advance_turn();
broadcast(game_state_.serialize());
}
}
}
The message_queue_ is a thread-safe queue that network I/O threads push into. The game logic thread pops from it. This keeps networking and game logic on separate threads with a clean boundary.
Threading Model
Two types of threads run alongside the main game loop:
- Acceptor thread — listens for new TCP connections, spawns a reader thread per client.
- Reader threads — one per connected client, blocking on
recv(), parsing messages, pushing them into the shared message queue.
The game logic runs on the main thread, pulling from the queue and mutating state behind a mutex.
This model is simple and it mostly works. The problem is “mostly.”
The Race Conditions
My first bug was a classic: a client disconnects, the reader thread tries to erase the client from a shared std::map, and the game logic thread is iterating that same map to broadcast state. Iterator invalidation. Segfault.
The fix was straightforward — disconnect events go through the message queue too, not handled directly by the reader thread. Every mutation to shared state funnels through the single game logic thread.
The second bug was subtler. I was reading game_state_.current_turn outside the mutex lock to do a quick check before acquiring it. Textbook data race. The compiler was even reordering the read in optimized builds, so it only manifested in release mode. Cost me an entire evening with ThreadSanitizer before I found it.
Lesson learned: if it touches shared state, it holds the lock. No clever optimizations. Not worth the debugging time.
State Synchronization
The server owns all game state. Clients are thin — they send action requests and render whatever the server tells them. When a client connects mid-game (reconnection), the server sends the full serialized state. During play, it sends deltas after each action.
This keeps cheating trivial to prevent. The client can claim whatever it wants — the server validates every action against the actual game rules before applying it. If the client says “play card 7” but card 7 isn’t in their hand, the server rejects it.
The downside is latency. Every action round-trips to the server before the client sees the result. For a turn-based game this is fine. For anything real-time, you’d need client-side prediction and server reconciliation — a much harder problem.
What I’d Do Differently
UDP for time-sensitive data. Even in a card game, animations and visual effects could benefit from unreliable-but-fast delivery. A hybrid approach — TCP for game actions, UDP for cosmetic state — would be cleaner.
Protobuf instead of JSON. JSON is great for debugging with Wireshark. It’s terrible for bandwidth. Protobuf gives you schema validation, smaller payloads, and faster parsing. The debugging convenience wasn’t worth the overhead.
ECS architecture. My game state is a monolithic class with methods for every card interaction. An Entity-Component-System approach would have made it far easier to add new card types and effects without touching existing code.
Better error handling. My error paths are mostly “log and continue.” A production game needs graceful degradation — reconnection logic, state recovery, timeout handling. I skipped most of that and it shows.
The Takeaway
Building a networked game from scratch is the best way to understand what engines do for you — and what they hide from you. The threading bugs alone taught me more about synchronization than any concurrency textbook. When I use Godot’s multiplayer API now, I actually understand what it’s abstracting over, and I know where to look when it breaks.
The code lives in my university coursework repo. It’s not pretty, but it works. Two players can connect, draw cards, play them, and one of them eventually wins. For a learning project, that’s enough.