Background
I started this project because I wanted to build a C++ game server as part of my portfolio. The goal is not just to make something that runs, but also to document the design decisions and the debugging process along the way. Ideally, I want to be able to answer questions like “Why did you implement it this way?” in an interview.
What I did today
Set up the directory structure
First, I separated include and src, then divided the code into network and server.
cpp-2d-arena-shooter-server/
├── include/
│ ├── common/
│ │ └── pch.h
│ ├── network/
│ │ ├── tcp_listener.h
│ │ └── session.h
│ └── server/
│ └── game_server.h
├── src/
│ ├── network/
│ │ ├── tcp_listener.cpp
│ │ └── session.cpp
│ ├── server/
│ │ └── game_server.cpp
│ └── main.cpp
└── CMakeLists.txt
Decided to start with thread-per-client
I do want to try epoll later, but for now I decided that it makes more sense to build the basic structure first. Since this is an arena shooter and each room will have at most around 16 players, the number of threads should not explode.
Also, replacing the thread-per-client model with epoll later can become a meaningful part of the commit history. From a portfolio perspective, showing that transition may actually be a good thing.
Passing the entire Session to a thread after accept
Whenever a new connection is accepted, I create a Session and move a unique_ptr into the thread.
auto session = std::make_unique<Session>(clientSocket);
std::thread(&Session::handle, std::move(session)).detach();
If I pass a raw pointer, it becomes unclear who should delete it. If I create it on the stack, it may be destroyed before the thread starts using it. So I chose unique_ptr.
By moving it into the thread, the ownership is transferred to the thread. This makes it clear in the code that “this Session is now owned by this thread.”
With this approach, when the thread finishes, the Session destructor is called automatically. Since the destructor calls disconnect(), I do not need to manage the file descriptor separately.
Session::~Session()
{
disconnect(); // The destructor automatically closes the fd
}
Debugging notes
Called SO_REUSEADDR before socket()
_serverSocket = -1;
setsockopt(_serverSocket, ...); // fd is still -1 here
_serverSocket = socket(...); // the socket is created after that
Every time I restarted the server, I got an “Address already in use” error. After debugging, I found that I was calling setsockopt before calling socket().
In other words, I was trying to set an option on an fd with the value -1.
Double close
When bind or listen failed, I called close and returned immediately. But I forgot to reset _serverSocket to -1, so the destructor tried to close an already closed fd again.
If the OS had already reused that fd, this could accidentally close an unrelated fd.
::close(_serverSocket);
_serverSocket = -1; // This was missing
return;
Uninitialized sockaddr_in
I had declared sockaddr_in serverAddr; without initialization, so some padding bytes contained garbage values. This was fixed by initializing it with {}.
Next
- Design the packet structure
Advice and feedback are welcome.
Top comments (0)