A concurrent, multi-process tic-tac-toe game server written in C++ that communicates with player processes over Unix domain sockets using a custom binary protocol. Supports configurable grid sizes, variable streak lengths, and any number of players — all running as independent processes.
Built to explore Unix systems programming concepts: process management, IPC via socketpair, non-blocking I/O with
poll(), and binary protocol design.
┌────────────────────────────────────────────────┐
│ SERVER PROCESS │
│ │
│ ┌──────────┐ ┌──────────┐ ┌──────────────┐ │
│ │ Config │ │ Game │ │ Event Loop │ │
│ │ Parser │→ │ State │← │ (poll) │ │
│ └──────────┘ └──────────┘ └──────┬───────┘ │
│ │ │
│ ┌──────────┬────────────────┤ │
│ │ │ │ │
│ ┌────┴────┐┌────┴────┐ ┌────┴────┐ │
│ │SocketPair││SocketPair│ │SocketPair│ │
│ │ (fd) ││ (fd) │ │ (fd) │ │
│ └────┬────┘└────┬────┘ └────┬────┘ │
└─────────┼──────────┼───────────────┼───────────┘
│ │ │
┌────┴────┐┌────┴────┐ ┌────┴────┐
│ Player ││ Player │ │ Player │
│ Process ││ Process │ │ Process │
│ (A) ││ (B) │ │ (N) │
└─────────┘└─────────┘ └─────────┘
The server forks a child process for each player, connecting them through bidirectional Unix domain sockets (socketpair). The main event loop uses poll() to concurrently monitor all player sockets without blocking.
Each player runs as a separate process. Communication happens through socketpair(AF_UNIX, SOCK_STREAM, PF_UNIX, fd), which creates a pair of connected Unix domain sockets — one end for the server, one for the player.
// Create bidirectional pipe using socketpair
int pipe_fd[2];
socketpair(AF_UNIX, SOCK_STREAM, PF_UNIX, pipe_fd);
// pipe_fd[0] → server end
// pipe_fd[1] → player end (redirected to stdin/stdout)After fork(), the child process redirects its stdin/stdout to the socket and calls execvp() to replace itself with the player executable:
// Child process setup
dup2(pipe_fd[1], STDIN_FILENO); // Player reads from socket
dup2(pipe_fd[1], STDOUT_FILENO); // Player writes to socket
execvp(executable, args); // Replace with player binaryThe server must handle messages from multiple players concurrently without blocking on any single player. This is achieved using poll():
std::vector<pollfd> poll_fds(player_count);
for (int i = 0; i < player_count; i++) {
poll_fds[i].fd = players[i].pipe_fd[0];
poll_fds[i].events = POLLIN; // Monitor for incoming data
}
// Main game loop
while (game_running) {
int ret = poll(poll_fds.data(), poll_fds.size(), 1); // 1ms timeout
for (int i = 0; i < player_count; i++) {
if (poll_fds[i].revents & POLLIN) {
// Handle message from player i
}
}
}The 1ms timeout prevents CPU hogging while keeping the server responsive. This pattern is the foundation of most event-driven server architectures.
The server and players communicate using fixed-size binary structs sent over the socket. This avoids the overhead of text parsing and ensures consistent message boundaries.
Client → Server Messages:
| Message Type | Fields | Description |
|---|---|---|
START |
type |
Player signals it's ready |
MARK |
type, position {x, y} |
Player makes a move |
Server → Client Messages:
| Message Type | Fields | Description |
|---|---|---|
RESULT |
type, success, filled_count + grid data |
Response to START/MARK |
END |
type, success |
Game over notification |
┌─────────────┐ START ┌─────────────┐
│ Player │ ──────────────► │ Server │
│ Process │ ◄────────────── │ Process │
│ │ RESULT + │ │
│ │ grid_data[] │ │
│ │ │ │
│ │ MARK(x,y) │ │
│ │ ──────────────► │ │
│ │ ◄────────────── │ │
│ │ RESULT + │ │
│ │ grid_data[] │ │
│ │ │ │
│ │ ◄────────────── │ │
│ │ END │ │
└─────────────┘ └─────────────┘
The server checks four directions after every valid move to detect a winning streak:
- Horizontal →
(x, y), (x+1, y), (x+2, y), ... - Vertical →
(x, y), (x, y+1), (x, y+2), ... - Diagonal ↘ →
(x, y), (x+1, y+1), (x+2, y+2), ... - Diagonal ↙ →
(x, y), (x-1, y+1), (x-2, y+2), ...
For each direction, the algorithm scans every possible starting position and checks if streak_size consecutive cells contain the same character. This runs in O(rows × cols × streak_size) time per check.
- GCC/G++ with C++11 support
- Unix-like system (Linux / macOS) — requires POSIX APIs
make # Compiles and links → produces 'server' executable
make clean # Removes build artifactsThe server reads its configuration from stdin. You can pipe a config file or type it in manually:
echo "3 3 3 2 A 2 ./player_a arg1 B 2 ./player_b arg1" | ./server<grid_width> <grid_height> <streak_size> <player_count>
<char_1> <arg_count_1> <executable_1> [args...]
<char_2> <arg_count_2> <executable_2> [args...]
...
| Field | Description |
|---|---|
grid_width |
Number of columns in the game grid |
grid_height |
Number of rows in the game grid |
streak_size |
Consecutive marks needed to win |
player_count |
Number of players |
char |
Single letter (A–Z, a–z) representing the player |
arg_count |
Total number of arguments for the player executable |
executable |
Path to the player process binary |
args |
Command-line arguments for the player |
Example — Classic 3×3 Tic-Tac-Toe with 2 players:
3 3 3 2
X 1 ./player_x
O 1 ./player_o
Example — 5×5 grid, streak of 4, 3 players:
5 5 4 3
A 1 ./smart_player
B 1 ./random_player
C 1 ./random_player
.
├── server.cpp # Main server: config parsing, process management,
│ # event loop, game logic, protocol handling
├── game_structs.h # Shared data structures (coordinate, messages, grid_data)
├── print_output.h # Logging function declarations
├── print_output.c # Logging implementation (prints protocol messages)
├── Makefile # Build configuration
└── .gitignore
- Parse Configuration — Read grid dimensions, streak size, player info from stdin
- Fork Players — For each player: create a
socketpair,fork(), redirect child's stdin/stdout to the socket,exec()the player binary - Enter Event Loop — Use
poll()to monitor all player sockets with a 1ms timeout - Handle Messages:
- On
START→ respond with current game state (RESULT+ filled positions) - On
MARK(x, y)→ validate move, update grid if valid, check for winner, respond withRESULT
- On
- End Game — When a player wins or the grid is full, send
ENDto all players, print the result, and reap child processes withwait()
- The game is not turn-based: any player can send a
MARKat any time, making concurrency handling critical - Each grid cell can only be marked once — subsequent marks on the same cell are rejected
- The server maintains a running list of filled positions to efficiently respond to
RESULTqueries without rescanning the entire grid - Child processes are properly reaped using
wait()to prevent zombie processes - The server processes messages in the order they are received by
poll(), ensuring fairness
This project is open source and available under the MIT License.