Session Management

Altruist has two session systems that work at different levels:

SystemPurposeScope
Game Sessions (IGameSessionService)In-memory session with typed context storageReal-time game state per connection
Token Sessions (SessionTokenAuth)Persistent token-based auth sessionsHTTP + socket authentication

Why Sessions Matter

In a real-time server, every connected client accumulates state — who they are, what character they're playing, what room they're in, their combat context, their pending trade. Without a session system, this state scatters across dozens of services and dictionaries, each with its own cleanup logic (or lack thereof).

Altruist's session system solves this by giving each connection a single, centralized state container:

  • Automatic cleanup — when a session expires or a client disconnects, all attached context data is cleared in one call. No orphaned state, no memory leaks from forgotten dictionaries.
  • Typed context store — attach any object to a session by type. No casting from object, no string-keyed bags. session.GetContext<CombatState>(id) returns exactly what you stored.
  • Expiration — sessions have a built-in TTL. Stale sessions from crashed clients are cleaned up automatically on next access or via periodic Cleanup(). No need for manual timers.
  • Migration — when a player upgrades from anonymous (HTTP auth) to authenticated (game socket), migrate all context data in one call without losing state.
  • Cross-service queries — find all sessions with a specific context type (e.g., "all players currently in combat") without maintaining a separate index.

Note:

Without sessions, a typical disconnect handler needs to: remove from player list, remove from room, remove from combat tracker, remove from trade system, remove from party, flush inventory, clear visibility... With sessions, you call ClearSession(id) and every context attached to that session is gone.

Example: Login → Character Select → Enter World

A typical game flow uses HTTP for authentication, then upgrades to a real-time socket connection. Sessions carry state across this transition.

1. Login (HTTP) — Create a session with account info

[ApiController]
[Route("api/auth")]
public class AuthController : ControllerBase
{
    private readonly IGameSessionService _sessions;
    private readonly IVault<AccountVault> _accounts;

    public AuthController(IGameSessionService sessions, IVault<AccountVault> accounts)
    {
        _sessions = sessions;
        _accounts = accounts;
    }

    [HttpPost("login")]
    public async Task<IActionResult> Login([FromBody] LoginRequest req)
    {
        var account = await _accounts
            .Where(a => a.Username == req.Username)
            .FirstOrDefaultAsync();

        if (account == null || !VerifyPassword(req.Password, account.PasswordHash))
            return Unauthorized();

        // Create session keyed by account ID, valid for 4 hours
        var session = _sessions.CreateSession(
            account.StorageId,
            DateTime.UtcNow.AddHours(4));

        // Store account info as context
        session.SetContext(account.StorageId, new AccountContext
        {
            AccountId = account.StorageId,
            Username = account.Username,
        });

        return Ok(new { token = account.StorageId }); // simplified
    }
}

public class AccountContext
{
    public string AccountId { get; set; } = "";
    public string Username { get; set; } = "";
}

2. Character Select (HTTP) — Add character info to the existing session

[HttpPost("select-character")]
public async Task<IActionResult> SelectCharacter([FromBody] SelectCharacterRequest req)
{
    // Session already exists from login
    var session = _sessions.GetSession(req.AccountId);
    if (session == null)
        return Unauthorized("Session expired, please login again");

    var character = await _characters
        .Where(c => c.StorageId == req.CharacterId)
        .FirstOrDefaultAsync();

    if (character == null)
        return NotFound("Character not found");

    // Add character context to the same session
    session.SetContext(req.AccountId, new CharacterContext
    {
        CharacterId = character.StorageId,
        Name = character.Name,
        Level = character.Level,
        X = character.X,
        Y = character.Y,
    });

    return Ok(new { status = "ready", character = character.Name });
}

public class CharacterContext
{
    public string CharacterId { get; set; } = "";
    public string Name { get; set; } = "";
    public int Level { get; set; }
    public int X { get; set; }
    public int Y { get; set; }
}

3. Enter World (Real-Time) — Upgrade to socket, read session state

The client now connects via WebSocket/TCP. The portal reads the session that was prepared by the HTTP endpoints:

[Portal("/game")]
public class GamePortal : AltruistGameSessionPortal
{
    private readonly IGameWorldOrganizer3D _worlds;
    private readonly IVisibilityTracker _visibility;

    public GamePortal(
        IGameSessionService sessions,
        IAltruistRouter router,
        IGameWorldOrganizer3D worlds,
        IVisibilityTracker visibility)
        : base(sessions, router)
    {
        _worlds = worlds;
        _visibility = visibility;
    }

    [Gate("enter-world")]
    public async Task OnEnterWorld(EnterWorldPacket packet, string clientId)
    {
        // Migrate session from account ID to client connection ID
        var session = _gameSessionService.MigrateSession(
            fromSessionId: packet.AccountId,
            toSessionId: clientId,
            newExpiresAtUtc: DateTime.UtcNow.AddHours(4));

        if (session == null)
        {
            await _router.Client.SendAsync(clientId, new ErrorPacket("Session expired"));
            return;
        }

        // All context from login + character select is now on this session
        var account = session.GetContext<AccountContext>(clientId);
        var character = session.GetContext<CharacterContext>(clientId);

        // Spawn player into world using the character data
        var world = _worlds.GetWorld(0);
        var player = new PlayerEntity(character!.Name, character.X, character.Y);
        player.ClientId = clientId;
        await world.SpawnDynamicObject(player);

        // Store the player entity in the session for later use
        session.SetContext(clientId, player);

        _visibility.RefreshObserver(clientId);
    }

    public override async Task OnDisconnectedAsync(string clientId, Exception? ex)
    {
        // Get player from session before it's cleared
        var session = _gameSessionService.GetSession(clientId);
        var player = session?.GetContext<PlayerEntity>(clientId);

        if (player != null)
        {
            _visibility.RemoveObserver(clientId);
            _worlds.GetWorld(0)?.DestroyObject(player);
        }

        // ClearSession removes ALL contexts (account, character, player) at once
        await base.OnDisconnectedAsync(clientId, ex);
    }
}

The Flow Visualized

Loading diagram...

Note:

The key insight: HTTP endpoints prepare the session (login, character select), and the real-time portal consumes it. MigrateSession bridges the two by moving all context data from the HTTP-phase session key (account ID) to the socket-phase session key (client connection ID).

Game Sessions

IGameSessionService manages lightweight, in-memory sessions for real-time game connections. Each session has:

  • A unique ID (typically the player/account ID)
  • An expiration time
  • A typed context store for attaching any data

Creating a Session

[Service]
public class PlayerManager
{
    private readonly IGameSessionService _sessions;

    public PlayerManager(IGameSessionService sessions)
    {
        _sessions = sessions;
    }

    public void OnPlayerLogin(string clientId, string accountId)
    {
        // Create session that expires in 1 hour
        var session = _sessions.CreateSession(
            accountId,
            DateTime.UtcNow.AddHours(1));

        // Attach typed context data
        session.SetContext(clientId, new PlayerContext
        {
            AccountId = accountId,
            ClientId = clientId,
            LoginTime = DateTime.UtcNow,
        });
    }
}

// Your context class — any shape you need
public class PlayerContext
{
    public string AccountId { get; set; } = "";
    public string ClientId { get; set; } = "";
    public DateTime LoginTime { get; set; }
    public string? CharacterId { get; set; }
    public int WorldIndex { get; set; }
}

Note:

CreateSession is idempotent — if a non-expired session already exists for that ID, it renews the expiry and returns the existing session with all its contexts intact.

Retrieving Session Data

// Get session by ID
var session = _sessions.GetSession(accountId);
if (session == null)
{
    // Session doesn't exist or has expired
    return;
}

// Get typed context
var playerCtx = session.GetContext<PlayerContext>(clientId);

Context Store

The session context store is a keyed, type-based dictionary. You can store multiple types per context ID, and multiple context IDs per session:

var session = _sessions.GetSession(accountId);

// Store different types under the same context ID
session.SetContext(clientId, new PlayerContext { ... });
session.SetContext(clientId, new InventoryState { ... });
session.SetContext(clientId, new CombatState { ... });

// Retrieve by type
var player = session.GetContext<PlayerContext>(clientId);
var inventory = session.GetContext<InventoryState>(clientId);
var combat = session.GetContext<CombatState>(clientId);

// Remove a specific type
session.RemoveContext<CombatState>(clientId);

// Clear everything
session.ClearAllContexts();

Note:

For each (contextId, type) pair, only one value is kept — last write wins. This means SetContext<PlayerContext>(clientId, newCtx) replaces the previous PlayerContext for that client.

Session Migration

When a player upgrades from an anonymous session (e.g., HTTP auth) to an authenticated game session, migrate contexts:

// Move all contexts from temp session to permanent session
var session = _sessions.MigrateSession(
    fromSessionId: tempClientId,
    toSessionId: accountId,
    newExpiresAtUtc: DateTime.UtcNow.AddHours(4));

Note:

Migration moves all context data from the source to the target session, then removes the source. If the target already exists, its contexts are merged (source values win for duplicate types).

Session Expiry and Cleanup

Sessions expire automatically. When you call GetSession on an expired session, it returns null and cleans up:

var session = _sessions.GetSession(accountId);
// Returns null if expired — cleanup happens automatically

For bulk cleanup of all expired sessions:

await _sessions.Cleanup();

Note:

The AltruistGameSessionPortal calls ClearSession automatically when a client disconnects. You don't need to clean up manually for normal disconnect flows.

Querying Across Sessions

Find contexts across all active sessions:

// Find all PlayerContext instances across all sessions
var allPlayers = _sessions.FindAllContexts<PlayerContext>();

// Find all contexts for a specific session
var sessionContexts = _sessions.FindAllContexsts(accountId);

// Find specific types in a session
var contexts = _sessions.FindContexts(accountId, typeof(PlayerContext), typeof(CombatState));

Game Session Portal

AltruistGameSessionPortal is a base portal that integrates game sessions with the connection lifecycle:

[Portal("/game")]
public class MyGamePortal : AltruistGameSessionPortal
{
    public MyGamePortal(
        IGameSessionService sessionService,
        IAltruistRouter router)
        : base(sessionService, router) { }

    // Override hooks to customize behavior
    protected override async Task<IResultPacket> OnJoinGameReceived(
        JoinGamePacket message, string clientId, IResultPacket result)
    {
        // Create session on join
        var session = _gameSessionService.CreateSession(
            clientId, DateTime.UtcNow.AddHours(2));

        session.SetContext(clientId, new PlayerContext
        {
            ClientId = clientId,
            CharacterId = message.Name,
        });

        return result;
    }
}

This gives you these Gate handlers automatically:

EventMethodDescription
"handshake"HandshakeAsyncInitial connection handshake
"join-game"JoinGameAsyncPlayer joins a game room
"leave-game"ExitGameAsyncPlayer leaves the game

And these lifecycle callbacks:

CallbackWhen
OnConnectedAsyncClient connects
OnDisconnectedAsyncClient disconnects (auto-clears session)
OnHandshakeReceivedAfter handshake processing
OnJoinGameReceivedAfter join processing
OnExitGameReceivedAfter exit processing

Note:

OnDisconnectedAsync calls ClearSession(clientId) by default. If you need to preserve session data across reconnects (e.g., for reconnection windows), override this method and don't clear immediately.

Token Sessions (HTTP Auth)

For HTTP-level authentication, Altruist provides token-based sessions backed by the vault system. Enable them in config:

altruist:
  security:
    mode: session
    key: "your-secret-key-base64"

How Token Sessions Work

  1. A client authenticates via HTTP (signup/login endpoint)
  2. The server creates an AuthTokenSessionModel with access + refresh tokens
  3. Tokens are stored in the database and cached in memory
  4. Subsequent requests include the token — SessionTokenAuth validates it
  5. The [SessionShield] attribute protects portals and endpoints

Protecting Endpoints

[SessionShield]
[Portal("/game")]
public class SecureGamePortal : AltruistPortal
{
    [Gate("action")]
    public async Task OnAction(ActionPacket packet, string clientId)
    {
        // Only authenticated clients reach here
    }
}

Note:

[SessionShield] is an alias for [Shield(typeof(SessionTokenAuth))]. It validates the session token from the connection's auth header, checks expiry, and verifies IP binding.

Token Session Features

FeatureDescription
Access + refresh tokensShort-lived access token, long-lived refresh token
IP bindingSessions are bound to the client's IP address
Fingerprint bindingOptional device/browser fingerprint for extra security
Auto-expiryExpired sessions are cleaned up on access
Cache layerTokens are cached in memory for fast validation, synced to database

Game Sessions vs Token Sessions

Game SessionsToken Sessions
StorageIn-memory onlyDatabase + cache
Survives restartNoYes
PurposeRuntime game stateAuthentication
Created byYour code (CreateSession)Auth endpoints (signup/login)
Typical dataPlayer position, combat state, inventory stateUser identity, tokens, expiry
Protected byNothing (internal use)[SessionShield] attribute

Note:

In a typical game server, you use both: token sessions for the initial HTTP authentication flow, then game sessions for the real-time socket connection state. The auth flow creates a token session, then your game portal creates a game session and attaches player context to it.