Skip to content

Network & sockets

All of the server's communication runs over TCP, in a non-blocking and multiplexed way. This page describes the complete path of a byte: from the creation of the listening socket to the dispatch of an AI command, through the client hierarchy and the buffers.

Creating the listening socket

createServerSocket(port) builds a classic IPv4 TCP socket, but with two essential settings: SO_REUSEADDR (to be able to restart the server immediately without waiting for TIME_WAIT) and switching to non-blocking mode.

// network/SocketUtils.cpp:24-48
int createServerSocket(int port)
{
    int fd = socket(AF_INET, SOCK_STREAM, 0);
    int opt = 1;
    sockaddr_in addr{};
    ...
    setsockopt(fd, SOL_SOCKET, SO_REUSEADDR, &opt, sizeof(opt));
    addr.sin_family = AF_INET;
    addr.sin_addr.s_addr = INADDR_ANY;
    addr.sin_port = htons(port);
    bind(fd, ...);
    listen(fd, SOMAXCONN);
    setNonBlocking(fd);
    return fd;
}

The switch to non-blocking is done via fcntl:

// network/SocketUtils.cpp:14-22
void setNonBlocking(int fd)
{
    int flags = fcntl(fd, F_GETFL, 0);
    ...
    fcntl(fd, F_SETFL, flags | O_NONBLOCK);
}

Why non-blocking

With a single blocking socket, one slow client would freeze the whole server. In non-blocking mode, recv/send/accept return immediately with EAGAIN/EWOULDBLOCK rather than waiting. Combined with poll(), this allows hundreds of clients to be handled in a single thread without ever blocking on any one of them.

The poll() loop

The server maintains a vector std::vector<pollfd> _pollFds (Server.hpp:51). The listening socket is added first with POLLIN. Before each poll(), updatePollEvents() recomputes the watched events for every client:

  • POLLIN is always armed (we always want to be able to read);
  • POLLOUT is armed only if the client has data pending to write (hasPendingWrite()).
// core/Server.cpp:635-648
void Server::updatePollEvents()
{
    for (pollfd &pfd : _pollFds) {
        if (pfd.fd == _serverFd) {
            pfd.events = POLLIN;
            continue;
        }
        pfd.events = POLLIN;
        auto it = _clients.find(pfd.fd);
        if (it != _clients.end() && it->second->hasPendingWrite())
            pfd.events |= POLLOUT;
    }
}

POLLOUT on demand

Arming POLLOUT permanently would make poll() return in a loop as soon as the socket is writable (almost always), hence for nothing. By arming it only when there is actually something left to send, the server avoids spinning needlessly.

The poll() timeout comes from the Scheduler (see Game loop & time). On each wake-up, the server copies _pollFds, iterates over the ready descriptors and handles them:

flowchart TD
    poll["poll() wakes up"]
    err{"POLLERR / POLLHUP / POLLNVAL ?"}
    srv{"fd == listening socket ?"}
    rd{"POLLIN ?"}
    wr{"POLLOUT ?"}

    poll --> err
    err -->|yes| remove["removeClient(fd)"]
    err -->|no| srv
    srv -->|yes| accept["acceptClient()<br/>(deferred to end of iteration)"]
    srv -->|no| rd
    rd -->|yes| read["readClient(fd)"]
    rd -->|no| wr
    read --> wr
    wr -->|yes| write["writeClient(fd)"]

The accept is deferred: a shouldAccept flag is raised and acceptClient() is called once the fd loop is finished, so as not to modify _clients/_pollFds during iteration.

Accepting a connection

// core/Server.cpp:425-438
void Server::acceptClient()
{
    int clientFd = accept(_serverFd, nullptr, nullptr);
    if (clientFd == -1)
        return;
    setNonBlocking(clientFd);
    _clients[clientFd] = new PendingClient(clientFd);
    _pollFds.push_back({clientFd, POLLIN | POLLOUT, 0});
}

Every new connection becomes a PendingClient: a client we don't yet know to be an AI or the GUI. Its construction immediately sends the welcome message.

The client hierarchy

classDiagram
    class AClient {
        <<abstract>>
        #int _fd
        #NetworkBuffer _readBuf
        #NetworkBuffer _writeBuf
        #bool _shouldClose
        +appendRead(data, size)
        +flushWrite() bool
        +nextLine(line) bool
        +queueWrite(data)
        +hasPendingWrite() bool
        +markForClose()
        +handleInput()* 
        +getType()* ClientType
    }
    class PendingClient {
        +handleInput()
        +isHandshakeDone() bool
        +getHandshakeName() string
    }
    class AIClient {
        -string _teamName
        -Player* _player
        -deque~string~ _pendingLines
        -bool _busy
        +handleInput()
        +popPendingLine() string
        +isBusy() bool
    }
    class GUIClient {
        +handleInput()
        +pushEvent(msg)
        +popCommand(line) bool
    }
    AClient <|-- PendingClient
    AClient <|-- AIClient
    AClient <|-- GUIClient
  • AClient (abstract) factors out all networking: descriptor _fd, read/write NetworkBuffers, _shouldClose flag, and the methods appendRead / flushWrite / nextLine / queueWrite / hasPendingWrite / markForClose. It declares two pure virtual methods: handleInput() and getType().
  • PendingClient sends WELCOME\n on construction and waits for a line (the handshake):
// network/PendingClient.cpp:15-29
PendingClient::PendingClient(int fd) : AClient(fd)
{
    queueWrite("WELCOME\n");
}
void PendingClient::handleInput()
{
    std::string line;
    if (nextLine(line)) {
        _handshakeDone = true;
        _handshakeName = line;
    }
}

Depending on the name received (GRAPHIC or a team name), ClientSessionManager promotes the PendingClient to a GUIClient or an AIClient. - AIClient owns a Player*, a std::deque<std::string> _pendingLines (the raw lines awaiting parsing) and a _busy flag indicating that a command is currently executing on the server side. - GUIClient receives events via pushEvent (queued for writing) and exposes its incoming commands via popCommand.

For the detail of the handshake (WELCOME → team name → CLIENT-NUM then X Y), see Protocol.

The buffers: NetworkBuffer

Each client has two NetworkBuffers, one for reading and one for writing. A NetworkBuffer wraps a std::string with a 64 KiB cap (MAX_BUFFER_SIZE = 65536, AClient.hpp:26). Any append that would exceed that cap fails and marks the client for closure — a protection against a client flooding the server.

// network/NetworkBuffer.cpp:8-17
bool NetworkBuffer::append(const char *data, std::size_t size)
{
    if (_buffer.size() + size > _maxSize) {
        _tooLarge = true;
        return false;
    }
    _buffer.append(data, size);
    return true;
}

Line extraction (popLine) cuts at the first \n and strips a trailing \r if present (tolerance for Windows line endings):

// network/NetworkBuffer.cpp:24-38
bool NetworkBuffer::popLine(std::string &line)
{
    std::size_t pos = _buffer.find('\n');
    if (pos == std::string::npos)
        return false;
    line = _buffer.substr(0, pos);
    if (!line.empty() && line.back() == '\r')
        line.pop_back();
    _buffer.erase(0, pos + 1);
    return true;
}

The read / write path

ReadreadClient(fd) reads into a 4096-byte local buffer:

// core/Server.cpp:440-460 (excerpt)
char buffer[4096];
ssize_t bytes = recv(fd, buffer, sizeof(buffer), 0);
if (bytes < 0) {
    if (errno == EINTR || errno == EAGAIN || errno == EWOULDBLOCK)
        return;
    removeClient(fd);
    return;
}
if (bytes == 0) {       // peer closed cleanly
    removeClient(fd);
    return;
}
_clients[fd]->appendRead(buffer, bytes);

After the appendRead, the server calls handleInput() (which splits the lines), then dispatches according to the client type:

  • AIAICommandDispatcher::handleIncomingLines;
  • PendingClientSessionManager::upgradePendingClient (promotion);
  • GUIGuiCommandHandler::handleCommands.

WritewriteClient(fd) calls flushWrite(), which does a non-blocking send and removes from the buffer only the bytes actually sent:

// network/AClient.cpp:31-60 (excerpt)
ssize_t bytes = send(_fd, _writeBuf.str().c_str(), _writeBuf.size(), 0);
if (bytes < 0) {
    if (errno == EINTR || errno == EAGAIN || errno == EWOULDBLOCK)
        return true;            // we'll retry on the next POLLOUT
    markForClose();             // EPIPE / ECONNRESET / other
    return false;
}
_writeBuf.erase(0, bytes);

If not everything could be sent, the remainder stays in the buffer; POLLOUT will remain armed and the rest will be sent on the next wake-up. The close is deferred: a client marked shouldClose() is only really destroyed once its write buffer is empty, which guarantees that the last message (for example dead\n) leaves before the cut.

The AICommandDispatcher and the 10-command rule

Each AI client owns a state AICommandState { deque<ACommand*> commands; int pendingResponses; }. When lines arrive, they are parsed one by one. The subject mandates that a client may have at most 10 commands pending: beyond that, the server rejects the command with ko\n without queuing it.

// core/AICommandDispatcher.cpp:21-43
void AICommandDispatcher::handleIncomingLines(AIClient &ai)
{
    while (ai.hasPendingLine()) {
        std::string line = ai.popPendingLine();
        ACommand *cmd = _parseCommand(line);

        if (!cmd) {                       // unrecognised line
            ai.queueWrite("ko\n");
            continue;
        }
        AICommandState &state = _states[ai.getFd()];
        if (state.pendingResponses >= 10) {   // queue full
            ai.queueWrite("ko\n");
            delete cmd;
            continue;
        }
        state.commands.push_back(cmd);
        state.pendingResponses++;
    }
}

Two reasons to get ko

A ko\n can mean either that the line is unparseable (unknown command or invalid arguments), or that the queue of 10 is already full. The pendingResponses counter is decremented as each command completes and its response leaves.

The main loop consumes only one command per AI client per iteration, and only if the client is neither busy (isBusy) nor mid-incantation. The command's delay determines at what future moment its response is produced — that is the subject of Game loop & time and Commands & delays.