Portals & Gates

Overview

Portals are the entry points for real-time client connections. They define how packets are routed and handled. Each portal is a class extending Portal, annotated with [Portal("/path")], and containing [Gate("event")] methods that handle specific events.

Creating a Portal

using Altruist;

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

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

    [Gate("chat")]
    public async Task OnChat(ChatPacket packet, string clientId)
    {
        await _router.Broadcast.SendAsync(packet, excludeClientId: clientId);
    }

    [Gate("whisper")]
    public async Task OnWhisper(WhisperPacket packet, string clientId)
    {
        await _router.Client.SendAsync(packet.TargetId, packet);
    }
}

The [Portal("/ws")] attribute maps this portal to the WebSocket path /ws. Each [Gate] method handles a specific event name.

Loading diagram...

How the Client Sends Events

The client sends a packet with messageCode: 4 and an event field. The framework routes it to the matching [Gate]:

[MessagePackObject]
public class EventPacket
{
    [Key(0)][JsonPropertyName("messageCode")] public uint MessageCode { get; set; } = 4;
    [Key(1)][JsonPropertyName("event")] public string Event { get; set; } = "";
}

Note:

messageCode: 4 is the routing packet. The event field determines which [Gate] receives it. All other fields in the JSON are deserialized into your packet type on the server.

What is a Gate?

A Gate defines the event that the portal receives. Methods annotated with [Gate("event-name")] are called when a client sends a packet with the matching event. Each gate method takes exactly two parameters:

  1. A packet — any struct/class implementing IPacketBase
  2. A clientId (string) — the ID of the client that sent the message
[Gate("join-game")]
public async Task JoinGame(JoinGamePacket packet, string clientId)
{
    // packet contains the deserialized client data
    // clientId identifies who sent it
}

Multiple Portals on the Same Path

A powerful pattern is registering multiple portals on the same endpoint. Each portal handles different events:

[Portal("/game")]
public class LoginPortal : Portal
{
    [Gate("login")]
    public async Task OnLogin(LoginPacket packet, string clientId) { ... }

    [Gate("character-select")]
    public async Task OnSelect(SelectPacket packet, string clientId) { ... }
}

[Portal("/game")]
public class ChatPortal : Portal
{
    [Gate("chat")]
    public async Task OnChat(ChatPacket packet, string clientId) { ... }
}

[Portal("/game")]
public class CombatPortal : Portal
{
    [Gate("attack")]
    public async Task OnAttack(AttackPacket packet, string clientId) { ... }
}

All three portals share the same connection path /game but handle completely different events. This keeps your code modular — split by domain (auth, chat, combat) rather than one giant class.

Paths and connections:

TCP/UDP: All portals share a single connection regardless of path. The [Portal] path is used for organizational purposes only — routing is done entirely by [Gate] event names. One TCP connection handles all events.

WebSocket: Each unique path is a separate WebSocket connection. [Portal("/game")] and [Portal("/chat")] require two WebSocket connections from the client. Portals on the same path share one connection. For most games, use a single path (e.g., /game) for all portals to keep things simple — the client only needs one connection.

Connection Lifecycle

Portals can hook into connection and disconnection events by implementing interfaces:

OnConnectingAsync

OnConnectingAsync runs after the transport accepts a connection, but before Altruist stores that connection in the connection manager. Use it when the portal needs to prepare the connection itself, especially when you want to replace the generated connection id with a stable application-level id.

[Portal("/dashboard")]
public class DashboardPortal : Portal, OnConnectingAsync
{
    public Task OnConnectingAsync(
        string clientId,
        ConnectionManager connectionManager,
        AltruistConnection connection)
    {
        connection.SetId("dashboard");
        return Task.CompletedTask;
    }
}

After this hook returns, Altruist uses connection.ConnectionId as the stored connection id. In the example above the connection is stored as dashboard instead of the generated clientId, so dashboard-specific tooling can reconnect without leaving random connection keys behind.

This hook is useful for:

  • assigning stable ids to singleton tool connections, such as dashboards or admin consoles
  • deriving a connection id from authentication details, tenant information, or a device id
  • preparing connection metadata before the connection appears in rooms or connection listings

Keep OnConnectingAsync small and deterministic. If the id does not need to change, leave connection.ConnectionId untouched and use OnConnectedAsync for normal setup work.

OnConnectedAsync

OnConnectedAsync runs after the connection has been stored and added to the default waiting room. Use it for normal post-connect work such as loading session state, joining rooms, or sending initial packets.

[Portal("/ws")]
public class MyPortal : Portal, OnConnectedAsync
{
    public async Task OnConnectedAsync(
        string clientId,
        ConnectionManager connectionManager,
        AltruistConnection connection)
    {
        // Called after the connection is registered
    }
}

OnDisconnectedAsync

[Portal("/ws")]
public class MyPortal : Portal, OnDisconnectedAsync
{
    public async Task OnDisconnectedAsync(string clientId, Exception? exception)
    {
        // Called when a client disconnects
        // exception is non-null if the disconnect was due to an error
    }
}

Portal Lifetime and State

Portals are created by dependency injection as transient services: the framework may build more than one instance of a portal class (for example one that owns the gate handlers and others for lifecycle callbacks). Do not keep per-connection state in portal fields — put it in a [Service] singleton and inject that:

[Service]
public sealed class PlayerSockets
{
    private readonly ConcurrentDictionary<string, string> _accountOfClient = new();
    public void Bind(string clientId, string accountId) => _accountOfClient[clientId] = accountId;
    public bool Unbind(string clientId) => _accountOfClient.TryRemove(clientId, out _);
}

[Portal("/game")]
public class GamePortal(PlayerSockets sockets) : Portal, OnConnectedAsync, OnDisconnectedAsync
{
    public Task OnConnectedAsync(string clientId, ConnectionManager manager, AltruistConnection connection)
    {
        sockets.Bind(clientId, connection.AuthDetails?.PrincipalId ?? "");
        return Task.CompletedTask;
    }

    public Task OnDisconnectedAsync(string clientId, Exception? exception)
    {
        sockets.Unbind(clientId);
        return Task.CompletedTask;
    }
}

Singleton services are constructed once per boot, so every portal instance shares the same state.

Interceptors

An interceptor runs before every gate handler and can veto the packet — the natural place for rate limits or payload caps. Register it as a service and the connection manager installs it automatically:

[Service(typeof(IInterceptor))]
public sealed class GameRateLimit : IInterceptor
{
    public Task Intercept(InterceptContext context, IPacket eventData)
    {
        // context.EventName  – the gate ("input", "chat", ...), also for unknown events
        // context.ClientId   – the connection
        // context.Route      – the portal path of the connection, e.g. "/game"
        // context.PayloadLength – raw payload size in bytes
        // eventData is null for events without a gate
        if (context.Route == "/game" && context.PayloadLength > 64 * 1024)
            context.Reject();   // the gate handler does not run
        return Task.CompletedTask;
    }
}

All interceptors complete before the handler starts; one rejection is enough to drop the packet. Interceptors registered in DI apply to every portal — check context.Route to limit one to a single portal. IConnectionManager.AddInterceptor still works for interceptors created by hand; adding an instance that is already installed is a no-op.

For per-connection rate limits you don't need to write one: the built-in RateLimitInterceptor is configured in config.yml (see Rate Limiting). An interceptor that keeps per-connection state can implement IConnectionStateInterceptor; the connection manager calls its Forget(clientId) once the connection is gone.

Note:

An interceptor that needs the connection manager (for example to close an abusive connection) must take it as Lazy<IConnectionManager>: the connection manager itself is built with the registered interceptors.

Portal with Router

Every portal can inject IAltruistRouter to send messages:

// Send to a specific client
await _router.Client.SendAsync(clientId, packet);

// Send to all clients in a room
await _router.Room.SendAsync(roomId, packet);

// Broadcast to all connected clients
await _router.Broadcast.SendAsync(packet);

// Broadcast but exclude one client
await _router.Broadcast.SendAsync(packet, excludeClientId: senderId);

Connection & Room Management

Portals have access to connection and room management through the IConnectionManager / ISocketManager services:

MethodDescription
AddConnectionAsyncAdd a new client connection
RemoveConnectionAsyncRemove a client connection
GetConnectionAsyncGet a connection by ID
GetAllConnectionsAsyncGet all active connections
CreateRoomAsyncCreate a new room
JoinRoomAsyncAdd a client to a room
GetConnectionsInRoomAsyncGet all connections in a room
FindAvailableRoomAsyncFind a room with available capacity
DeleteRoomAsyncDelete a room

Securing Portals

Use the [Shield] attribute to require authentication:

[Portal("/ws")]
[Shield(typeof(JwtAuth))]
public class SecurePortal : Portal
{
    // Only authenticated clients can access this portal
}

See Authentication for details.