[Bug][Game] Dungeon crawler character creation hangs — component listens for "CharacterCreatedClient", server emits "CharacterCreated" #910

Closed
opened 2026-08-04 21:15:24 +00:00 by spikerj · 0 comments
Owner

Symptom

At /dungeon-crawler, creating your first character hangs after clicking Create — the creation HUD never closes and no error appears. Hard-refresh and the character is there: it was persisted.

Root cause

Events dispatch on the wire type field (game-server.service.ts:1120):

const handlers = this.eventHandlers.get(event.type);

The server maps CharacterCreatedClientEvent to the wire name "CharacterCreated" (SpikerSoft.GameServer/Network/MessagePackSerializer.cs:826, same in JsonPacketSerializer.cs:372). But the component registered:

// Note: Event type names are class names with "Event" stripped
// CharacterCreatedClientEvent -> "CharacterCreatedClient"
this.gameServer.on<CharacterCreatedClientEvent>("CharacterCreatedClient", (event) => {
    this.handleCharacterCreatedFromServer(event);
});

That comment is a wrong assumption about the wire protocol. The key never matched, so handleCharacterCreatedFromServer — the only place that does showCharacterCreationModal.set(false) (dungeon-crawler.component.ts:7280-7322) — never ran.

The client's own TypeScript already disagreed with the registration: CharacterCreatedClientEvent declares type: "CharacterCreated" (game-server.service.ts:3174), and the service's internal switch handles case "CharacterCreated" (:945) — which is why the character id was still captured and the character still persisted.

Why nothing recovered

  • SignalR fallback is inert. handleCharacterCreatedFromSignalR fires but its only action is gated behind if (this.showCharacterSelectModal()), which is false during creation.
  • No timeout closes the modal. CHARACTER_CREATION_TIMEOUT_MS (30s) only re-permits a retry; it doesn't close the HUD or surface an error.

Why it went unnoticed

The mismatch is isolated to the success path. "CharacterCreationFailed" and "CharacterList" are both correct, and the sibling space game gets it right (space-game.component.ts:3268 registers "ShipCreated" for ShipCreatedClientEvent).

I diffed all 105 server wire names against all 55 client registrations: CharacterCreatedClient is the only mismatch in the codebase.

The enabler

on() took an unconstrained string:

on<T extends GameEvent>(eventType: string, handler: (event: T) => void): () => void

so a key contradicting T["type"] compiled silently.

Fix

  1. Register "CharacterCreated" and replace the incorrect comment.
  2. Constrain the signature to eventType: T["type"], making this class of bug a compile error. Callers with no explicit generic infer T = GameEvent (type: string), so untyped registrations are unaffected.

Verified: nx build spikersoft is clean with the tightened signature (no collateral across the other 54 registrations), and restoring the old key now fails the build with TS2345: Argument of type '"CharacterCreatedClient"' is not assignable to parameter of type '"CharacterCreated"'.

Residual gap (not fixed here)

The type ties a registration to the TypeScript interface, not to the C# serializer. If the TS interface itself drifted from MessagePackSerializer, the compiler still wouldn't catch it. A cross-repo contract check (generate the wire-name table from C# and assert against the TS literals) would close that; it needs a home that can see both repos.

Separately: sendCommand (game-server.service.ts:837-841) silently returns when the socket isn't OPEN — no throw, no queue, no feedback — which produces an identical-looking hang whenever the WebSocket is down. Worth its own ticket.

## Symptom At `/dungeon-crawler`, creating your first character hangs after clicking **Create** — the creation HUD never closes and no error appears. Hard-refresh and the character is there: it *was* persisted. ## Root cause Events dispatch on the wire `type` field (`game-server.service.ts:1120`): ```ts const handlers = this.eventHandlers.get(event.type); ``` The server maps `CharacterCreatedClientEvent` to the wire name **`"CharacterCreated"`** (`SpikerSoft.GameServer/Network/MessagePackSerializer.cs:826`, same in `JsonPacketSerializer.cs:372`). But the component registered: ```ts // Note: Event type names are class names with "Event" stripped // CharacterCreatedClientEvent -> "CharacterCreatedClient" this.gameServer.on<CharacterCreatedClientEvent>("CharacterCreatedClient", (event) => { this.handleCharacterCreatedFromServer(event); }); ``` That comment is a wrong assumption about the wire protocol. The key never matched, so `handleCharacterCreatedFromServer` — the **only** place that does `showCharacterCreationModal.set(false)` (`dungeon-crawler.component.ts:7280-7322`) — never ran. The client's own TypeScript already disagreed with the registration: `CharacterCreatedClientEvent` declares `type: "CharacterCreated"` (`game-server.service.ts:3174`), and the service's internal switch handles `case "CharacterCreated"` (`:945`) — which is why the character id was still captured and the character still persisted. ### Why nothing recovered - **SignalR fallback is inert.** `handleCharacterCreatedFromSignalR` fires but its only action is gated behind `if (this.showCharacterSelectModal())`, which is false during creation. - **No timeout closes the modal.** `CHARACTER_CREATION_TIMEOUT_MS` (30s) only re-permits a retry; it doesn't close the HUD or surface an error. ### Why it went unnoticed The mismatch is isolated to the success path. `"CharacterCreationFailed"` and `"CharacterList"` are both correct, and the sibling space game gets it right (`space-game.component.ts:3268` registers `"ShipCreated"` for `ShipCreatedClientEvent`). I diffed all **105** server wire names against all **55** client registrations: `CharacterCreatedClient` is the only mismatch in the codebase. ## The enabler `on()` took an unconstrained string: ```ts on<T extends GameEvent>(eventType: string, handler: (event: T) => void): () => void ``` so a key contradicting `T["type"]` compiled silently. ## Fix 1. Register `"CharacterCreated"` and replace the incorrect comment. 2. Constrain the signature to `eventType: T["type"]`, making this class of bug a compile error. Callers with no explicit generic infer `T = GameEvent` (`type: string`), so untyped registrations are unaffected. Verified: `nx build spikersoft` is clean with the tightened signature (no collateral across the other 54 registrations), and restoring the old key now fails the build with `TS2345: Argument of type '"CharacterCreatedClient"' is not assignable to parameter of type '"CharacterCreated"'`. ## Residual gap (not fixed here) The type ties a registration to the **TypeScript** interface, not to the C# serializer. If the TS interface itself drifted from `MessagePackSerializer`, the compiler still wouldn't catch it. A cross-repo contract check (generate the wire-name table from C# and assert against the TS literals) would close that; it needs a home that can see both repos. Separately: `sendCommand` (`game-server.service.ts:837-841`) silently returns when the socket isn't `OPEN` — no throw, no queue, no feedback — which produces an identical-looking hang whenever the WebSocket is down. Worth its own ticket.
Sign in to join this conversation.