Skip to main content
repod uses a classic one-channel-per-client architecture. Each time a client connects, the server creates a new Channel instance to represent that connection.

Server Architecture

The Server class is responsible for:
  • Binding to a TCP port and accepting connections
  • Creating a Channel instance for each client
  • Managing the list of active channels
  • Broadcasting messages to all clients

Server Lifecycle

A server goes through these stages:
1

Initialization

Create the server with Server(host, port). This doesn’t bind the socket yet.
server = GameServer(host="0.0.0.0", port=5071)
2

Start

Call await start() to bind to the port and begin accepting connections.
await server.start()
This calls asyncio.start_server() internally and configures the _handle_client callback.
3

Run

Call await run() to keep the server alive. This blocks until the task is cancelled.
await server.run()  # Blocks forever
4

Stop

Call await stop() to disconnect all clients and close the server socket.
await server.stop()

launch() Convenience Method

For simple servers, use launch() to handle everything:
server.py
from repod import Server, Channel

class GameChannel(Channel):
    def Network_chat(self, data: dict) -> None:
        self.server.send_to_all(data)

class GameServer(Server):
    channel_class = GameChannel

GameServer(host="0.0.0.0", port=5071).launch()
From server.py:93-117:
server.py
def launch(self) -> None:
    """Start the server and block forever.

    Convenience wrapper that hides asyncio boilerplate.  Equivalent
    to calling :meth:`start` then :meth:`run` inside
    ``asyncio.run()``.  Handles ``KeyboardInterrupt`` gracefully.
    """

    async def _main() -> None:
        await self.start()
        try:
            await self.run()
        except asyncio.CancelledError:
            pass
        finally:
            await self.stop()

    try:
        asyncio.run(_main())
    except KeyboardInterrupt:
        log.info("server_stopped")
This:
  1. Creates a new asyncio event loop
  2. Starts the server
  3. Runs until interrupted
  4. Handles Ctrl+C gracefully
  5. Cleans up all channels

Channel Instances

Each connected client gets its own Channel instance. Channels are created automatically by the server when a client connects.

Channel Lifecycle

From server.py:181-210, here’s what happens when a client connects:
server.py
async def _handle_client(
    self,
    reader: asyncio.StreamReader,
    writer: asyncio.StreamWriter,
) -> None:
    """Handle a newly connected client."""
    # 1. Create the channel instance
    channel = self.channel_class(reader, writer, server=self)
    self.channels.append(channel)
    addr = writer.get_extra_info("peername")

    log.info(
        "client_connected",
        addr=f"{addr[0]}:{addr[1]}",
        clients=len(self.channels),
    )

    # 2. Send initial connected message
    channel.send({"action": "connected"})
    
    # 3. Call lifecycle hooks
    channel.on_connect()
    self.on_connect(channel, addr)

    # 4. Run the channel's read/write/process loops
    try:
        await asyncio.gather(
            channel._read_loop(),
            channel._write_loop(),
            self._process_loop(channel),
        )
    except Exception as e:
        channel.on_error(e)
    finally:
        await self._remove_channel(channel)
1

Channel creation

The server instantiates self.channel_class (your custom Channel subclass) and adds it to self.channels.
2

Initial message

The server sends {"action": "connected"} to the client automatically.
3

Lifecycle hooks

Both channel.on_connect() and server.on_connect(channel, addr) are called.
4

Concurrent loops

Three asyncio tasks run concurrently:
  • _read_loop(): reads from socket, parses frames, enqueues messages
  • _write_loop(): drains send queue to socket
  • _process_loop(): drains receive queue, dispatches to Network_* methods
5

Cleanup

When any loop exits (disconnect, error, etc.), the channel is removed and on_disconnect() is called.

Channel Attributes

Each channel has these useful attributes:
AttributeTypeDescription
self.serverServerReference to the parent server
self.addrtuple[str, int]Remote client’s (host, port)
self.is_connectedboolWhether the channel is still active
channel.py
class GameChannel(Channel):
    def Network_chat(self, data: dict) -> None:
        print(f"Message from {self.addr[0]}:{self.addr[1]}")
        
        # Broadcast to all OTHER clients
        for channel in self.server.channels:
            if channel != self:
                channel.send(data)

Channel Hooks

Override these methods to customize behavior:

on_connect()

Called immediately after the channel is created, before any messages are processed.
channel.py
class GameChannel(Channel):
    def on_connect(self) -> None:
        # Per-client setup
        self.player_id = generate_id()
        self.send({"action": "welcome", "id": self.player_id})

on_close()

Called when the connection is closed (gracefully or due to error).
channel.py
class GameChannel(Channel):
    def on_close(self) -> None:
        # Per-client cleanup
        print(f"Client {self.addr} disconnected")

on_error(error)

Called when an exception occurs in the channel’s loops.
channel.py
class GameChannel(Channel):
    def on_error(self, error: Exception) -> None:
        print(f"Channel error: {error}")
        # Custom error handling

Client Connection Process

The Client class handles the client side of the connection.

Low-Level Client

The Client class runs asyncio in a background thread:
low_level.py
from repod import Client

client = Client(host="localhost", port=5071)
client.start_background()

# Send messages from main thread
client.send({"action": "hello"})

# Poll for received messages
while not client._receive_queue.empty():
    message = client._receive_queue.get()
    print(message)
From client.py:81-86:
client.py
def start_background(self) -> None:
    """Start the network event loop in a daemon thread."""
    if self._thread is None or not self._thread.is_alive():
        self._thread = threading.Thread(target=self._run_loop, daemon=True)
        self._thread.start()

High-Level ConnectionListener

Most users prefer ConnectionListener, which wraps Client and provides a clean callback API:
game_client.py
import time
from repod import ConnectionListener

class GameClient(ConnectionListener):
    def Network_connected(self, data: dict) -> None:
        print("Connected!")
        self.send({"action": "hello"})
    
    def Network_chat(self, data: dict) -> None:
        print(f"Chat: {data['text']}")

client = GameClient()
client.connect("localhost", 5071)

while True:
    client.pump()  # Process queued messages
    time.sleep(0.01)
From client.py:219-235:
client.py
def connect(
    self,
    host: str = DEFAULT_HOST,
    port: int = DEFAULT_PORT,
) -> None:
    """Connect to a remote server.

    Creates a :class:`Client` and starts its background network
    thread.
    """
    log.info("connecting", host=host, port=port)
    self._connection = Client(host, port)
    self._connection.start_background()
The connect() method:
  1. Creates a Client instance
  2. Calls start_background() to spawn the network thread
  3. The background thread connects asynchronously

pump() Method

The pump() method is the heart of the client-side API. Call it once per frame in your game loop. From client.py:242-266:
client.py
def pump(self) -> None:
    """Process all pending network messages.

    Call this once per frame in your game loop.  Each queued message
    is dispatched to the matching ``Network_{action}`` method, or to
    :meth:`network_received` as a fallback.
    """
    if self._connection is None:
        return

    while not self._connection._receive_queue.empty():
        try:
            data = self._connection._receive_queue.get_nowait()
        except queue.Empty:
            break

        action = data.get("action", "")
        method_name = f"Network_{action}"
        handler = getattr(self, method_name, None)
        if handler is not None:
            log.debug("message_dispatched", action=action)
            handler(data)
        else:
            log.debug("message_unhandled", action=action)
            self.network_received(data)
pump() must be called from your main game loop thread. It is not thread-safe.

Background Thread Model

repod uses threads to keep your main game loop synchronous and simple.

Server Background Mode

Use start_background() when you need the main thread free for other work (e.g., hosting a game while playing):
host_and_play.py
from repod import Server, Channel, ConnectionListener
import time

class GameChannel(Channel):
    def Network_move(self, data: dict) -> None:
        self.server.send_to_all(data)

class GameServer(Server):
    channel_class = GameChannel

class GameClient(ConnectionListener):
    def Network_move(self, data: dict) -> None:
        print(f"Player moved: {data}")

# Start server in background
server = GameServer(host="127.0.0.1", port=5071)
server_thread = server.start_background()

# Connect as a client in main thread
client = GameClient()
client.connect("127.0.0.1", 5071)

# Game loop
while True:
    client.pump()
    # Your game logic here
    time.sleep(0.01)
From server.py:168-179:
server.py
def _run_in_thread(self) -> None:
    """Create a new event loop and run the server inside it."""
    loop = asyncio.new_event_loop()
    asyncio.set_event_loop(loop)
    loop.run_until_complete(self.start())
    try:
        loop.run_until_complete(self.run())
    except Exception as exc:
        log.error("background_server_error", error=str(exc))
    finally:
        loop.run_until_complete(self.stop())
        loop.close()
The background thread:
  1. Creates a new asyncio event loop
  2. Runs start() and run() in that loop
  3. Handles exceptions
  4. Cleans up on exit

Client Background Thread

The client always uses a background thread for networking. From client.py:116-125:
client.py
def _run_loop(self) -> None:
    """Run the asyncio event loop in the background thread."""
    self._loop = asyncio.new_event_loop()
    asyncio.set_event_loop(self._loop)
    try:
        self._loop.run_until_complete(self._network_task())
    except Exception as exc:
        log.error("network_thread_error", error=str(exc))
    finally:
        self._loop.close()
Thread Safety
  • send() is thread-safe (uses thread-safe queues)
  • pump() is NOT thread-safe (must only be called from main thread)
  • close() is thread-safe

TCP_NODELAY Optimization

Both Client and Channel disable Nagle’s algorithm for low-latency real-time communication. From channel.py:69-73:
channel.py
# Disable Nagle's algorithm for low-latency real-time communication.
sock: socket.socket | None = writer.get_extra_info("socket")
if sock is not None:
    with contextlib.suppress(OSError):
        sock.setsockopt(socket.IPPROTO_TCP, socket.TCP_NODELAY, 1)
Nagle’s algorithm batches small packets to reduce overhead, but this adds latency. For games, we want messages sent immediately, so TCP_NODELAY is enabled.

Next Steps

Actions & Dispatch

Learn how messages are routed to Network_* methods

Server API

Full API reference for the Server class

Channel API

Full API reference for the Channel class

Client API

Full API reference for Client and ConnectionListener