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
| Component | Property | Description |
|---|---|---|
| ClientSender | Client | Send to a specific client by ID |
| RoomSender | Room | Send to all clients in a room |
| BroadcastSender | Broadcast | Send to all connected clients |
| ClientSynchronizator | Synchronize | Sync entity state to clients |
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:
- Your
IPacketBasepacket is wrapped in aMessageEnvelopewith aPacketHeader(sender, receiver, timestamp) - The envelope is encoded using the configured codec (JSON or MessagePack)
- 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:
| Mode | SendAsync | Use for |
|---|---|---|
direct (default) | encodes and awaits the socket write on the caller, as in every version before 0.9.9 | request/response handlers, low traffic |
queued | hands 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 thanmax-queued-per-clientpackets 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'sEngineClientSender, and the room and broadcast senders built on them) shares the process-wideOutboundQueuesservice, soEnqueue,CloseAfterFlushand queued-modeSendAsynckeep one order per client. CloseAfterFlushcloses 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, callForgetagain 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).