Packet Handler Performance Guide
This guide explains how to write packet handlers and event listeners that don't block the game loop.
Architecture Overview
Client -> NetworkService -> PacketDispatchService -> IPacketListener (your handler)
|
Game Loop Thread
(synchronous dispatch)
PacketDispatchService runs on the game loop thread and dispatches packets synchronously. If your handler blocks, the entire game loop stalls - no other packets are processed, no ticks advance.
The outgoing path is separate: IOutgoingPacketQueue is a thread-safe queue drained by a dedicated send thread. Enqueuing packets is always non-blocking.
Rule 1: Never Block the Game Loop
PacketDispatchService.NotifyListenerSafe calls your handler synchronously:
var task = listener.HandlePacketAsync(session, packet);
if (!task.IsCompletedSuccessfully)
{
task.GetAwaiter().GetResult(); // blocks game loop until handler completes
}
If your HandleCoreAsync awaits something slow (DB query, network call), the game loop blocks for the entire duration.
Bad: Blocking event publish
protected override Task<bool> HandleCoreAsync(GameSession session, IGameNetworkPacket packet)
{
// This publishes an event and BLOCKS until ALL listeners complete.
// If any listener does a DB query, the game loop stalls.
_gameEventBusService
.PublishAsync(gameEvent)
.AsTask()
.GetAwaiter()
.GetResult();
return Task.FromResult(true);
}
Good: Fire-and-forget event publish
protected override Task<bool> HandleCoreAsync(GameSession session, IGameNetworkPacket packet)
{
// Validate, build response packets, enqueue them...
// Publish event without blocking the game loop
PublishEventFireAndForget(new SomeGameEvent(session.SessionId, ...));
return Task.FromResult(true);
}
private void PublishEventFireAndForget<TEvent>(TEvent gameEvent) where TEvent : IGameEvent
{
var task = _gameEventBusService.PublishAsync(gameEvent);
if (!task.IsCompletedSuccessfully)
{
task.AsTask().ContinueWith(
static t => Log.ForContext<MyHandler>()
.Error(t.Exception, "Event publish failed for {EventType}", typeof(TEvent).Name),
TaskContinuationOptions.OnlyOnFaulted
);
}
}
Why this works:
IsCompletedSuccessfully- if the event bus completes synchronously (most single-listener events do), no allocation happens.ContinueWith(OnlyOnFaulted)- only allocates a continuation if the task actually fails.- The game loop returns immediately; event listeners run asynchronously.
Rule 2: Enqueue Packets, Don't Send Directly
Always use the outgoing packet queue. Never write to the socket directly from a handler.
// Good: non-blocking enqueue
Enqueue(session, new MoveAcceptPacket(character, sequence));
// Also good: enqueue by session ID
_outgoingPacketQueue.Enqueue(session.SessionId, new SomePacket(...));
The queue is drained by OutboundPacketSender on a separate thread. Enqueuing is O(1) and lock-free.
Rule 3: Avoid Lazy-Loading in the Hot Path
Spatial queries like GetNearbyMobiles() and GetNearbyItems() trigger EnsureSectorLoaded() which synchronously loads sector data from persistence if the sector hasn't been warmed up yet.
Bad: Triggering lazy load during movement validation
// This can block for 200ms+ if the sector isn't loaded
var nearby = _spatialWorldService.GetNearbyMobiles(destination, 1, mapId);
Good: Pre-warm sectors proactively
Sectors are warmed at login (WarmupAroundSectorAsync) and on sector change (WarmupSectorsFireAndForget). If you need spatial queries in a hot path, ensure the sectors are already warmed:
// SpatialWorldService automatically warms sectors when:
// 1. Player logs in (SectorWarmupRadius around spawn)
// 2. Player crosses sector boundary (SectorWarmupRadius around new sector)
//
// If your handler runs AFTER these events, sectors will be loaded.
// Don't add new EnsureSectorLoaded calls in packet handlers.
Rule 4: Delta Sync on Sector Change
When a player crosses a sector boundary, don't re-sync all sectors in the radius - only sync the NEW sectors that weren't visible before.
Bad: Full re-sync every sector crossing
// With radius 3, this sends packets for 49 sectors (7x7) on EVERY sector crossing
// Even though 42 of those sectors were already sent last time
for (var x = center.SectorX - radius; x <= center.SectorX + radius; x++)
{
for (var y = center.SectorY - radius; y <= center.SectorY + radius; y++)
{
SyncSectorForPlayer(session, mapId, x, y);
}
}
Good: Only sync delta sectors
for (var sectorX = newCenter.SectorX - radius; sectorX <= newCenter.SectorX + radius; sectorX++)
{
for (var sectorY = newCenter.SectorY - radius; sectorY <= newCenter.SectorY + radius; sectorY++)
{
// Skip sectors that were already in the old sync radius
if (oldSector is not null &&
sectorX >= oldCenter.SectorX - radius &&
sectorX <= oldCenter.SectorX + radius &&
sectorY >= oldCenter.SectorY - radius &&
sectorY <= oldCenter.SectorY + radius)
{
continue;
}
SyncSingleSectorForPlayer(sessionId, mobileEntity, mapId, sectorX, sectorY, z);
}
}
With radius 3, moving one sector in any direction syncs ~13 new sectors instead of 49 (~73% reduction).
Rule 5: Use Spatial Helpers for Broadcast
Don't iterate all sessions manually. Use the spatial broadcast methods:
// Broadcasts to all players within sector radius of a location
await _spatialWorldService.BroadcastToPlayersInUpdateRadiusAsync(
packet, mapId, location, excludeSessionId: session.SessionId
);
// Broadcasts to players within a specific range (tile-based)
await _spatialWorldService.BroadcastToPlayersAsync(
packet, mapId, location, range: 18, excludeSessionId: session.SessionId
);
These methods resolve sessions from the spatial index instead of scanning all connected players.
Event Listener Pattern
Event listeners implement IGameEventListener<TEvent> and are registered with [RegisterGameEventListener]:
[RegisterGameEventListener]
public class MyHandler : IGameEventListener<SomeGameEvent>
{
public Task HandleAsync(SomeGameEvent gameEvent, CancellationToken cancellationToken = default)
{
// This runs asynchronously from the game loop (if publisher used fire-and-forget).
// You CAN await async operations here without blocking the game loop.
// But keep it fast - other listeners for the same event type run in parallel.
return Task.CompletedTask;
}
}
Key difference from packet handlers:
- Packet handlers run ON the game loop thread (must not block).
- Event listeners run asynchronously IF the publisher uses fire-and-forget.
- Event listeners for the same event run in parallel via
Task.WhenAll.
Quick Reference
| Operation | Blocking? | Where to use |
|---|---|---|
Enqueue(session, packet) |
No | Anywhere |
_gameEventBusService.PublishAsync(e).AsTask().GetAwaiter().GetResult() |
YES | Never in packet handlers |
PublishEventFireAndForget(e) |
No | Packet handlers |
await _gameEventBusService.PublishAsync(e) |
Awaits | Event listeners only |
GetNearbyMobiles() on warmed sector |
No | Anywhere |
GetNearbyMobiles() on cold sector |
YES (lazy load) | Avoid in hot paths |
BroadcastToPlayersAsync() |
No (enqueue only) | Anywhere |
Monitoring
PacketDispatchService logs slow handlers automatically:
[WRN] Slow packet listener opcode=0x02 listener=MovementHandler elapsed=257ms
Threshold: 50ms per listener, 100ms per opcode total. If you see these warnings, your handler is blocking the game loop.
Previous: Packet System