All articles

OfficeFighters — real-time game communication with SignalR and WebSocket

Inputs and snapshots through one hub, with reconnect recovery, bounded client queues and state coalescing that preserves events.

t.Turgay
OfficeFighters — real-time game communication with SignalR and WebSocket

OfficeFighters separates player input, authoritative simulation, outgoing messages and Phaser rendering. SignalR supplies the transport-facing layer. Reading GameHub, the connection factory and ConnectionOutbox shows why the design is as much about message lifetime and slow clients as it is about sending JSON.

One connection with an explicit transport

The client factory selects WebSockets and enables skipNegotiation. The server maps /hubs/game with the same transport restriction. Relative URLs work through the development proxy and the deployed application origin. The access-token factory supplies current authentication when the connection needs it.

// OfficeFighters hub factory: selected transport and auth.
new HubConnectionBuilder()
  .withUrl(options.url, {
    transport: HttpTransportType.WebSockets,
    skipNegotiation: true,
    accessTokenFactory: options.accessTokenFactory
  })
  .withAutomaticReconnect(options.retry)
  .configureLogging(LogLevel.None)
  .build();

The factory also selects LogLevel.None. Application connection state can still drive visible recovery messages without exposing low-level connection details in routine browser logs.

Input describes intent

GameRoom drains its input queue, identifies the room player and passes sequence numbers and buttons through InputGate. The client supplies intent; simulation produces positions, hits and results. A completed hub call does not itself prove that an attack landed. Tests therefore need duplicate sequences, delayed older packets and inputs from users who do not belong to the room.

State and events have different lifetimes

A newer snapshot can replace an older position. A hit event carried inside that older snapshot can still matter. The outgoing queue preserves this distinction.

// ConnectionOutbox: limits for waiting messages.
public const int SnapshotCapacity = 8;
public const int CriticalCapacity = 64;
public const int OtherCapacity = 16;

// When an old snapshot is removed, preserve its events:
newer with { Events = [.. oldest.Events, .. newer.Events] };

When eight snapshots are waiting, the oldest eligible snapshot is removed and its events are moved into the next snapshot. Critical messages have their own counter and terminate the connection if the limit is exceeded. The other-message class is bounded separately. These limits keep a slow client's backlog from growing indefinitely.

This is an in-memory connection queue. Its guarantees concern messages within that active connection pipeline. A terminated connection requires application recovery rather than an assumption that every queued message remains durably deliverable.

Keep network writes away from the simulation clock

A separate pump sends queued messages in order. The game loop enqueues output instead of waiting for the socket write. This protects other rooms on the same shard from directly waiting for that client. The queue limits are essential: moving writes into a task without bounding pending work would simply move the bottleneck into memory.

Reconnect the application state too

The factory enables automatic reconnect with a custom retry policy. SignalR's documentation distinguishes initial start failure from recovery after a connection was established. Those paths also need separate handling in the application. A socket returning to Connected does not by itself restore an accurate room view.

The inspected room implementation sends a critical snapshot to a reconnecting player. UI code needs a clear point at which it enables gameplay again, and stale responses from an earlier connection must not overwrite the current session. Recovery therefore includes authentication, room state and visible connection feedback.

Track ticks separately from event identifiers

SnapshotBuffer rejects a state with a tick at or behind the newest buffered state, but still extracts unseen events from it. Increasing eventId values deduplicate effects. This matches the server's event-carrying behavior when snapshots are coalesced: redundant state does not necessarily mean redundant events.

Useful checks interrupt the connection, generate snapshots faster than the sender can drain them, fill the critical backlog and deliver a new event inside an old-tick packet. Dropped-snapshot counts, reconnect duration and queue pressure explain different problems; a single latency number cannot replace them.

Kaynaklar / References

Microsoft Learn — SignalR JavaScript client

More in the OfficeFighters series

You have reached the end of this orbit.
Explore more articles
turgay.A little code, a few pixels, a lot of curiosity.© 2026 · Made on Earth