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))
- Loop on
select.select([sock], [], [], 0.1)until a packet arrives; if the first decoded line equalsWELCOME, the loop breaks. - Send
team_name + "\n". - Read one packet, assumed to contain both
CLIENT-NUMandX 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.feedaccumulates bytes;InputBuffer.pop_lineextracts the first complete line (split(b"\n", 1), UTF-8 decoded) or returnsNonewhen no full line is available yet.OutputBuffer.pushencodes and queues a string;flush_tosends as many bytes as possible and keeps the remainder on a partial send (self._buffer = self._buffer[sent:]), swallowingBlockingIOError.
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()returnspending() < 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_responsepops the head of the queue and invokes the matching callback with the raw line. Look/Inventorydisambiguation: after each response, the command is stored in_last_command.peek_last_command()lets the parser tell whether a[...]is aLookor anInventory.- Unsolicited follow-ups: if
_inflightis empty but_last_commandexists,on_responsere-invokes the last callback (ai/src/network/command_queue.py:128-134). This is how theCurrent level: kthat follows a successful incantation is handled. - Death: if the received line is exactly
"dead",on_responsecallssys.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¶
- Protocol layer — how raw lines become typed messages.
- Decision loop — the full receive → decide → send chain.
- Known limitations — handshake fragility, dead code.