Table of Contents

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