Skip to content

Network & command queue

The network/ layer is the client's transport: it opens the socket, runs the handshake, splits the TCP stream into lines and enforces the 10 in-flight commands rule. It knows nothing about game semantics — it handles raw strings and delegates parsing to the protocol layer.

Four components cooperate:

Component File Role
Connection ai/src/network/connection.py Non-blocking TCP socket + initial handshake
Client ai/src/network/client.py Send/receive orchestration
CommandQueue ai/src/network/command_queue.py In-flight FIFO queue (cap of 10)
InputBuffer / OutputBuffer ai/src/network/buffers.py Line-splitting of the TCP stream

Connection — socket & handshake

Connecting

connect() (ai/src/network/connection.py:32) creates an AF_INET / SOCK_STREAM socket, connects in blocking mode, then switches to non-blocking:

self._sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
try:
    self.sock.connect((self._host, self._port))
except ConnectionRefusedError as e:
    ...
    raise Exception(f"Failed to connect to {self._host}:{self._port}") from e
self._sock.setblocking(False)

Connecting in blocking mode then switching to non-blocking avoids having to deal with EINPROGRESS. A refused connection is turned into an Exception, which bubbles up to run() and exits with code 84.

Handshake

do_handshake() (ai/src/network/connection.py:65) follows the protocol sequence and returns (client_num, (width, height)):

sequenceDiagram
    participant S as Server
    participant C as Connection

    S->>C: WELCOME
    C->>S: <team_name>\n
    S->>C: <CLIENT-NUM>\n
    S->>C: <X> <Y>\n
    Note over C: returns (client_num, (width, height))
  1. Loop on select.select([sock], [], [], 0.1) until a packet arrives; if the first decoded line equals WELCOME, the loop breaks.
  2. Send team_name + "\n".
  3. Read one packet, assumed to contain both CLIENT-NUM and X Y: splitlines()[0] must be an integer (number of free slots for the team), splitlines()[1] must contain a space (width height).

Fragile handshake

The handshake assumes CLIENT-NUM and X Y arrive in the same TCP packet. If they are fragmented, splitlines()[1] raises an IndexError. See Known limitations.

Client — orchestration

Client (ai/src/network/client.py) holds a CommandQueue, an InputBuffer, an OutputBuffer and the Connection. It exposes two key operations.

Sending: send_command

send_command(command, callback) (ai/src/network/client.py:55):

if not self.command_queue.can_send():
    ...                                       # cap of 10 reached -> drop
    return
self.output_buffer.push(command + "\n")       # appends the trailing \n
self.command_queue.enqueue(command, callback) # records (command, callback)
...
self.output_buffer.flush_to(self.connection.sock)  # immediate synchronous send

Three steps: (1) check the cap; (2) push command + "\n" into the OutputBuffer (this is where the \n is added, not in the command builders) and record the callback in the CommandQueue; (3) flush the buffer immediately to the socket. The send is therefore synchronous: there is no deferred write phase in the main loop.

flush_output is unused

Client.flush_output (ai/src/network/client.py:126) is implemented but never called from the main loop: writing always goes through the synchronous flush_to inside send_command.

Receiving: receive

receive() (ai/src/network/client.py:84) polls the socket with select.select([sock], [], [], 0.1) (a 100 ms poll). An empty recv signals a server close (it clears connection). Otherwise the bytes go to receive_data:

self.input_buffer.feed(data)
while True:
    line = self.input_buffer.pop_line()
    if line is None:
        break
    ...
    self.command_queue.on_response(line)      # RAW lines, not parsed here

The Client passes raw lines to CommandQueue.on_response; parsing into typed messages happens later, in the callbacks (see Protocol).

Buffers — from stream to lines

The Zappy protocol is line-oriented (\n), but TCP does not preserve message boundaries. The buffers (ai/src/network/buffers.py) reconcile the two.

  • InputBuffer.feed accumulates bytes; InputBuffer.pop_line extracts the first complete line (split(b"\n", 1), UTF-8 decoded) or returns None when no full line is available yet.
  • OutputBuffer.push encodes and queues a string; flush_to sends as many bytes as possible and keeps the remainder on a partial send (self._buffer = self._buffer[sent:]), swallowing BlockingIOError.

CommandQueue — the 10 in-flight commands

The Zappy server accepts at most 10 requests without a response per client (beyond that they are ignored) and always answers in receipt order. The CommandQueue (ai/src/network/command_queue.py) leverages this FIFO guarantee.

  • Cap: can_send() returns pending() < MAX_PENDING_COMMANDS (MAX_PENDING_COMMANDS = 10, ai/src/config.py:29). This is what the main loop checks before asking the FSM for a command.
  • FIFO matching: enqueue(command, callback) pushes onto _inflight; on_response pops the head of the queue and invokes the matching callback with the raw line.
  • Look / Inventory disambiguation: after each response, the command is stored in _last_command. peek_last_command() lets the parser tell whether a [...] is a Look or an Inventory.
  • Unsolicited follow-ups: if _inflight is empty but _last_command exists, on_response re-invokes the last callback (ai/src/network/command_queue.py:128-134). This is how the Current level: k that follows a successful incantation is handled.
  • Death: if the received line is exactly "dead", on_response calls sys.exit(0) (ai/src/network/command_queue.py:116-120).

Dead _callbacks list

add_callback pushes onto _callbacks, but that list is never read back in on_response: only the _last_command mechanism above is effective. See Known limitations.

Send-then-receive sequence

sequenceDiagram
    participant FSM
    participant Cl as Client
    participant OB as OutputBuffer
    participant CQ as CommandQueue
    participant S as Server
    participant IB as InputBuffer

    Note over FSM,S: Send
    FSM->>Cl: send_command("Look", cb)
    Cl->>CQ: can_send() ? (pending < 10)
    Cl->>OB: push("Look\n")
    Cl->>CQ: enqueue("Look", cb)
    Cl->>OB: flush_to(sock)
    OB->>S: Look\n

    Note over FSM,S: Receive
    S-->>Cl: "[ player, food, ... ]"
    Cl->>IB: feed(data)
    IB-->>Cl: pop_line() -> "[ ... ]"
    Cl->>CQ: on_response("[ ... ]")
    CQ->>CQ: pop _inflight (FIFO) -> ("Look", cb)
    CQ->>cb: cb("[ ... ]")

Going further