Altruist Router

The IAltruistRouter is the primary way to send packets from the server to clients. It provides four routing components for different communication patterns.

Router Components

ComponentPropertyDescription
ClientSenderClientSend to a specific client by ID
RoomSenderRoomSend to all clients in a room
BroadcastSenderBroadcastSend to all connected clients
ClientSynchronizatorSynchronizeSync entity state to clients
Loading diagram...

Injecting the Router

Inject IAltruistRouter via constructor in any portal or service:

[Portal("/game")]
public class MyPortal : Portal
{
    private readonly IAltruistRouter _router;

    public MyPortal(IAltruistRouter router)
    {
        _router = router;
    }
}

Sending to a Specific Client

await _router.Client.SendAsync(clientId, new ChatPacket("Hello!"));

The ClientSender looks up the connection by ID and sends the encoded packet directly.

Sending to a Room

await _router.Room.SendAsync(roomId, new GameStatePacket { ... });

The RoomSender retrieves all connections in the room and sends the packet to each one.

Broadcasting to All Clients

// Send to everyone
await _router.Broadcast.SendAsync(new ServerAnnouncement("Maintenance in 5 minutes"));

// Send to everyone except one client
await _router.Broadcast.SendAsync(packet, excludeClientId: senderId);

Entity Synchronization

The Synchronize component enables efficient delta-based synchronization of entities:

await _router.Synchronize.SendAsync(playerEntity);

This detects changed properties (via [Synced] attributes) and sends only the delta to connected clients. See Entity Synchronization for details.

How Packets Are Sent

When you call any SendAsync method:

  1. Your IPacketBase packet is wrapped in a MessageEnvelope with a PacketHeader (sender, receiver, timestamp)
  2. The envelope is encoded using the configured codec (JSON or MessagePack)
  3. The raw bytes are sent through the transport connection (WebSocket, TCP, or UDP)

This means clients always receive:

{
  "messageCode": 1000,
  "header": {
    "timestamp": 638790123456789,
    "receiver": "client-id-here",
    "sender": "server"
  },
  "message": {
    "text": "Hello!"
  }
}

Direct and Queued Sending

How SendAsync delivers is set by altruist:server:transport:outbound:mode:

ModeSendAsyncUse for
direct (default)encodes and awaits the socket write on the caller, as in every version before 0.9.9request/response handlers, low traffic
queuedhands the packet to the client's outbound queue and returns at once (so do Room.SendAsync and Broadcast.SendAsync, which send through the ClientSender)game loops, broadcasts to many clients

Whatever the mode, ClientSender offers the queue explicitly:

// Never waits on a socket: a pump encodes and sends it off the caller, in order per client.
_router.Client.Enqueue(clientId, new WorldSnapshot { Tick = tick });

// Close the connection once everything queued before has been sent (e.g. after a reject message).
_router.Client.Enqueue(clientId, new Rejected { Reason = "Signed in from another window." });
_router.Client.CloseAfterFlush(clientId);

// Drop whatever is still queued for a client (the connection manager also does this on disconnect).
_router.Client.Forget(clientId);

The outbound queue

  • One queue and one pump per client. The caller only enqueues; the pump runs on the thread pool and writes one packet at a time, so a slow or stalled client never delays the caller (for example your game tick) or any other client. Packets reach each client in the order they were queued.
  • Slow readers are dropped. A client whose send has been in flight longer than stuck-send-seconds, or that has more than max-queued-per-client packets waiting, is not reading: its connection is aborted and the normal disconnect path (OnDisconnectedAsync, session cleanup) runs. Both limits are checked when a packet is queued.
  • Reused encode buffers. With a codec that implements IBufferEncoder (MessagePack does), each client's packets are encoded into one reused buffer instead of a new array per packet.
  • One queue per client for the whole process. Every sender resolved from DI (ClientSender, the engine's EngineClientSender, and the room and broadcast senders built on them) shares the process-wide OutboundQueues service, so Enqueue, CloseAfterFlush and queued-mode SendAsync keep one order per client.
  • CloseAfterFlush closes the output side of the connection; the read loop then sees the close (or the idle timeout) and runs the disconnect path exactly once.
  • When a connection is gone, the connection manager drops its queue (after the portals' OnDisconnectedAsync). If your code queues for a client after that, call Forget again once you are done with it.

Coalescing: keep only the newest state

Mark a packet type with [Coalesce("key")] and a client's queue keeps at most one unsent packet with that key: a newer one replaces the queued one and moves behind everything queued before it. A client that reads slowly then skips stale states instead of building a backlog — the usual choice for world snapshots:

[MessagePackObject]
[Coalesce("snapshot")]
public class WorldSnapshot : IPacketBase
{
    [Key(0)] public uint MessageCode { get; set; } = 2002;
    [Key(1)] public int Tick { get; set; }
}

Packet types that share a key supersede each other too. Packets without the attribute are never dropped (only an aborted client loses its queue). Coalescing applies to queued sends (Enqueue, and SendAsync in queued mode).

Configuration

altruist:
  server:
    transport:
      outbound:
        mode: direct               # direct | queued — how SendAsync delivers
        max-queued-per-client: 256 # more waiting packets abort the connection
        stuck-send-seconds: 5      # a send in flight longer than this aborts the connection
        encode-buffer-bytes: 2048  # initial size of each client's reused encode buffer

All keys are optional; the defaults are shown. Existing apps need no changes: direct keeps the previous behaviour, and Enqueue / CloseAfterFlush are new.

Metrics

Register an IOutboundMetrics to receive the queue counters (none is registered by default). It is called from the senders and the pumps, so keep it thread-safe and cheap:

[Service(typeof(IOutboundMetrics))]
public sealed class OutboundCounters : IOutboundMetrics
{
    public static long Depth, Coalesced, SlowClients, Packets, Bytes, Errors;

    public void QueueDepthChanged(int delta) => Interlocked.Add(ref Depth, delta); // packets waiting, all clients
    public void ClientQueueLength(int length) { }                                   // one client's queue after an enqueue
    void IOutboundMetrics.Coalesced() => Interlocked.Increment(ref Coalesced);
    public void SlowClientClosed() => Interlocked.Increment(ref SlowClients);
    public void Sent(int bytes) { Interlocked.Increment(ref Packets); Interlocked.Add(ref Bytes, bytes); }
    public void SendError() => Interlocked.Increment(ref Errors);
}

Dashboard packet capture records queued sends as well (from the pump, after the socket write).