Session Management
Altruist has two session systems that work at different levels:
| System | Purpose | Scope |
|---|---|---|
Game Sessions (IGameSessionService) | In-memory session with typed context storage | Real-time game state per connection |
Token Sessions (SessionTokenAuth) | Persistent token-based auth sessions | HTTP + 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
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:
| Event | Method | Description |
|---|---|---|
"handshake" | HandshakeAsync | Initial connection handshake |
"join-game" | JoinGameAsync | Player joins a game room |
"leave-game" | ExitGameAsync | Player leaves the game |
And these lifecycle callbacks:
| Callback | When |
|---|---|
OnConnectedAsync | Client connects |
OnDisconnectedAsync | Client disconnects (auto-clears session) |
OnHandshakeReceived | After handshake processing |
OnJoinGameReceived | After join processing |
OnExitGameReceived | After 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
- A client authenticates via HTTP (signup/login endpoint)
- The server creates an
AuthTokenSessionModelwith access + refresh tokens - Tokens are stored in the database and cached in memory
- Subsequent requests include the token —
SessionTokenAuthvalidates it - 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
| Feature | Description |
|---|---|
| Access + refresh tokens | Short-lived access token, long-lived refresh token |
| IP binding | Sessions are bound to the client's IP address |
| Fingerprint binding | Optional device/browser fingerprint for extra security |
| Auto-expiry | Expired sessions are cleaned up on access |
| Cache layer | Tokens are cached in memory for fast validation, synced to database |
Game Sessions vs Token Sessions
| Game Sessions | Token Sessions | |
|---|---|---|
| Storage | In-memory only | Database + cache |
| Survives restart | No | Yes |
| Purpose | Runtime game state | Authentication |
| Created by | Your code (CreateSession) | Auth endpoints (signup/login) |
| Typical data | Player position, combat state, inventory state | User identity, tokens, expiry |
| Protected by | Nothing (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.