Skip to content

Modern Player Web Client

Version: 3.1
Status: Draft
Author: Systems Architecture Team
Date: 2025-07-18
Priority: P1 — High


1. Executive Summary

The MAID engine currently ships with a minimal inline HTML client embedded in net/web/server.py (lines 601-757). This ~155-line client provides basic terminal-style interaction but lacks ANSI color support, structured data panels, map visualization, mobile responsiveness, proper authentication UI, and accessibility features. Meanwhile, the admin frontend (packages/maid-engine/admin_frontend/) has already validated React 18 + TypeScript + Vite + Zustand + Tailwind as a viable toolchain for MAID's web layer.

This document proposes a Modern Player Web Client — a standalone React application served at /play/ that replaces the inline client with a full-featured MUD interface. The client will parse ANSI color codes, render structured GMCP data in dedicated panels (character stats, inventory, map, chat channels), support AI-powered NPC dialogue with a purpose-built conversation UI, and provide a responsive layout that works from desktop to mobile. It reuses the same technology stack proven in the admin frontend while introducing MUD-specific libraries for terminal emulation and ANSI parsing.

The project is scoped to 13 weeks across five phases. The client ships as a static build served by FastAPI at /play/, mirroring the admin frontend's deployment model.

1.1 Goals and Objectives

The primary goals of this project are:

  1. Enhanced Visual Experience: Transform the plain-text MUD experience into a visually rich interface with proper color rendering, styled panels, and modern UI elements while preserving the classic MUD feel.

  2. Improved Accessibility: Ensure all players, including those using assistive technologies, can fully participate in the game experience through WCAG 2.1 AA compliance.

  3. Cross-Platform Support: Deliver a consistent experience across desktop browsers, tablets, and mobile devices through responsive design.

  4. AI Integration: Provide a first-class UI for AI-powered NPC dialogue, making conversations feel natural and engaging.

  5. Extensibility: Create a component architecture that allows future enhancements such as custom themes, plugin panels, and user scripts.

1.2 Success Metrics

The following metrics will be used to measure the success of this project:

Metric Target Measurement Method
Bundle Size (gzipped) < 250KB initial Vite bundle analyzer
Time to Interactive < 2s on 3G Lighthouse audit
Accessibility Score 100% WCAG 2.1 AA axe-core automated testing
User Satisfaction > 4.0/5.0 Post-launch survey
Session Duration +20% vs inline client Analytics comparison
Mobile Usage +50% vs inline client Device analytics

2. Current State Analysis

2.1 Inline Client Limitations

Reference net/web/server.py lines 601-757. The _get_default_client() method returns a hardcoded HTML string with the following characteristics:

Visual Presentation: - Dark terminal appearance (#1a1a1a background, Courier New font) - Single <div id="output"> for all game text — no structure, no panels - Fixed-width layout that breaks on mobile screens - No support for custom themes or font preferences

Communication: - WebSocket connection to /ws/game using JSON protocol - Auto-reconnect after 3-second timeout on connection loss - No offline command queue during disconnection - No connection state indicator for users

Input Handling: - Command history via arrow keys (JavaScript array, up/down key handlers) - No tab completion support - No command aliases or macros - No multi-line input support

Message Processing: - Message type handling: text → append to output div, system → yellow styling, error → red styling, prompt → update prompt display, gmcpconsole.log() only - No ANSI color code parsing — escape sequences render as raw text - No structured data display — GMCP vitals, inventory, room info are all ignored - No chat channel separation — all text goes to the same output div

Missing Features: - No authentication UI — login happens via text commands over WebSocket - No accessibility — no ARIA attributes, no screen reader support - No map visualization — Room.Info coordinates and exits are discarded - No settings persistence — preferences lost on page refresh

2.2 Admin Frontend as Precedent

The admin frontend at packages/maid-engine/admin_frontend/ demonstrates the viability of our chosen technology stack:

Build Toolchain: - React 18.3 with TypeScript 5.6 compiles and bundles with Vite 5.4 - Hot module replacement works correctly for development - Production builds are optimized and tree-shaken - Source maps are generated for debugging

State Management: - Zustand v5 for client state with persist middleware - TanStack React Query v5 for server state and caching - Optimistic updates for responsive UI - DevTools integration for debugging

Styling: - Tailwind CSS v3 with custom color themes works in MAID's build pipeline - Component variants using class-variance-authority - Dark mode support with CSS custom properties - Responsive design utilities

WebSocket Integration: - WebSocket integration with auto-reconnect via createWebSocket() utility - Message serialization and deserialization - Connection state management - Error handling and recovery

Authentication: - Zustand auth store for client-side state - HttpOnly cookies for secure token storage - Automatic token refresh on 401 responses - CSRF protection with X-CSRF-Token header

Component Library: - Button component with primary, secondary, ghost, and danger variants - Card component with header, body, and footer slots - Input component with validation states - Modal component with focus trapping - Table component with generic type support

Project Configuration: - Path alias @src/ for clean imports - Build output serves from a sub-path (/admin-ui/) via FastAPI static file mounting - Environment variable support for configuration

2.3 What Works Today

The following components are already implemented and can be leveraged:

Protocol Layer: - WebSocket JSON protocol is stable and well-documented - Message types are clearly defined with TypeScript-compatible schemas - GMCP package negotiation is implemented in the Telnet handler and works over WebSocket - Session management (UUID-based sessions, rate limiting) is protocol-agnostic

API Endpoints: - REST API endpoints (/api/v1/health, /api/v1/status) exist for health checks - The admin frontend's apiRequest<T>() pattern handles CSRF and auth - JSON schema validation ensures type safety - OpenAPI documentation is auto-generated

Authentication: - JWT token generation and validation - Refresh token rotation - Session invalidation - Rate limiting per user

2.4 What Needs Building

The following components must be developed for the new client:

Core Rendering: - ANSI escape code → HTML conversion pipeline - Virtual scrolling for large output buffers - Terminal font rendering with proper character sizing

Data Panels: - GMCP data routing to structured UI panels - Character stats display with real-time updates - Inventory management with drag-and-drop - Map rendering from Room.Info coordinates and exit data

Authentication UI: - Form-based authentication (current auth is text-based over WS) - Character selection screen - Account management

Layout System: - Responsive multi-panel layout with configurable arrangement - Panel resizing and reordering - Mobile-specific layouts

Communication: - Chat channel management with per-channel message history - NPC dialogue conversation UI for AI-powered interactions - System message filtering and routing

Accessibility: - ARIA live regions for dynamic content - Keyboard navigation for all interactive elements - Screen reader support with meaningful announcements - High contrast theme option

2.5 Server Prerequisites

The protocol design in this document (§8.1) describes a v2 WebSocket protocol that does not exist in the current server. The current server (net/web/server.py) uses a flat JSON format without envelope, sequencing, or handshake negotiation. The following table identifies all server-side capabilities required by this design and their current implementation status.

WebSocket Protocol Capabilities

Capability Status Description Server Location
Unified message envelope (id, type, ts, seq) ❌ NEEDS IMPLEMENTATION All messages wrapped in envelope with sequence numbers net/web/server.py _connection_handler
hello handshake on connect ❌ NEEDS IMPLEMENTATION Server sends protocol version, capabilities, GMCP list, resumeToken net/web/server.py
init handshake from client ❌ NEEDS IMPLEMENTATION Client responds with version, GMCP subscriptions, optional resumeToken net/web/server.py
Heartbeat (ping/pong) ❌ NEEDS IMPLEMENTATION Server responds to client ping with pong including echo timestamp net/web/server.py
ack/nack for commands ❌ NEEDS IMPLEMENTATION Server acknowledges or rejects commands with correlated requestId net/web/server.py
sync_complete after reconnect ❌ NEEDS IMPLEMENTATION Server sends full state snapshot then sync_complete on session resume net/web/server.py
gmcp_batch message type ❌ NEEDS IMPLEMENTATION Atomic multi-GMCP-package delivery in single message net/web/server.py
completion response ❌ NEEDS IMPLEMENTATION Tab completion results correlated to complete request via requestId net/web/server.py
Sequence number tracking ❌ NEEDS IMPLEMENTATION Per-connection monotonic seq counter on all outbound messages net/web/server.py
Resume token generation ❌ NEEDS IMPLEMENTATION Generate and validate resumeToken for session resumption net/web/server.py
Server-side message buffering ❌ NEEDS IMPLEMENTATION Buffer messages during disconnect for replay on resume (see §11.6) net/web/server.py
Origin header validation ❌ NEEDS IMPLEMENTATION Validate Origin header on WebSocket upgrade (see §10.3) net/web/server.py
WebSocket max frame size ❌ NEEDS IMPLEMENTATION Server-side max frame size of 256KB (see §10.5) net/web/server.py
GMCP over WebSocket ✅ EXISTS GMCP package negotiation works over WebSocket net/web/server.py
Session management (UUID) ✅ EXISTS UUID-based sessions with rate limiting net/web/server.py
JSON message format ✅ EXISTS (flat, needs migration to envelope) Basic JSON message types: text, system, error, prompt, gmcp net/web/server.py

REST API Endpoints

Endpoint Status Notes
GET /api/v1/health ✅ EXISTS Health check
GET /api/v1/status ✅ EXISTS Server statistics
POST /api/v1/auth/login ❌ NEEDS IMPLEMENTATION JWT login returning HttpOnly cookies. Current auth is text-based over WebSocket only.
POST /api/v1/auth/refresh ❌ NEEDS IMPLEMENTATION Refresh token rotation
POST /api/v1/auth/logout ❌ NEEDS IMPLEMENTATION Session invalidation
GET /api/v1/auth/me ❌ NEEDS IMPLEMENTATION Session validation for existing cookies
GET /api/v1/characters ❌ NEEDS IMPLEMENTATION List player's characters
POST /api/v1/characters ❌ NEEDS IMPLEMENTATION Create new character
GET /api/v1/characters/:id ❌ NEEDS IMPLEMENTATION Character details
GET /api/v1/settings ❌ NEEDS IMPLEMENTATION Retrieve saved user settings
PUT /api/v1/settings ❌ NEEDS IMPLEMENTATION Save user settings

WebSocket Path

The current server handles player WebSocket connections at /ws/game via _connection_handler() in net/web/server.py. The new v2 protocol described in this document will be implemented as a new handler at /ws/game/v2. The existing /ws/game handler remains unchanged for backward compatibility with traditional MUD clients. The player web client connects exclusively to /ws/game/v2.

Implementation note: The v2 handler can share the same session management, GMCP routing, and command dispatch infrastructure as the v1 handler. The primary difference is the message envelope format and handshake negotiation. A WebSocketProtocolAdapter class abstracts the wire format so both handlers share game logic.

WebSocketProtocolAdapter Design

The WebSocketProtocolAdapter is a server-side abstraction that lets v1 (flat JSON) and v2 (envelope) handlers share all game logic. The adapter translates between wire format and internal representation at the boundary, so systems, commands, and GMCP routing never know which protocol version the client speaks.

# net/web/protocol_adapter.py
from abc import ABC, abstractmethod
from typing import Any
from dataclasses import dataclass

@dataclass
class InternalMessage:
    """Protocol-agnostic internal message representation."""
    id: str
    type: str  # "text", "gmcp", "command", "system", etc.
    payload: dict[str, Any]
    request_id: str | None = None

class WebSocketProtocolAdapter(ABC):
    """Translates between wire format and InternalMessage."""

    @abstractmethod
    async def decode(self, raw: str | bytes) -> InternalMessage:
        """Parse raw WebSocket frame into InternalMessage."""
        ...

    @abstractmethod
    async def encode(self, message: InternalMessage, seq: int) -> str:
        """Serialize InternalMessage to wire format with sequence number."""
        ...

    @abstractmethod
    async def send_hello(self, websocket: WebSocket, session: Session) -> None:
        """Send protocol-specific handshake (v2 sends envelope; v1 is a no-op)."""
        ...

    @abstractmethod
    async def handle_init(self, raw: str | bytes, session: Session) -> list[str]:
        """Process client init response; return list of subscribed GMCP packages."""
        ...

class V1ProtocolAdapter(WebSocketProtocolAdapter):
    """Flat JSON format for traditional MUD clients (backward compatible)."""
    ...

class V2ProtocolAdapter(WebSocketProtocolAdapter):
    """Envelope format with id, type, ts, seq, payload for web client."""
    ...

The connection handler selects the adapter based on the WebSocket path:

# net/web/server.py
async def _connection_handler(self, websocket: WebSocket, path: str) -> None:
    adapter: WebSocketProtocolAdapter
    if path == "/ws/game/v2":
        adapter = V2ProtocolAdapter()
    else:
        adapter = V1ProtocolAdapter()

    # All subsequent game logic uses adapter.decode() / adapter.encode()
    # and is protocol-agnostic.

Both adapters share the same Session, GMCP routing, command dispatch, and rate limiting infrastructure. The v2 adapter additionally manages sequence numbers, resume tokens, and heartbeat state.


3. Requirements

3.1 Functional Requirements

ID Requirement Priority Notes
FR-1 ANSI color parsing P0 Parse SGR sequences (0-107), 256-color, 24-bit RGB. Must handle malformed sequences gracefully.
FR-2 WebSocket communication P0 JSON message protocol, auto-reconnect with exponential backoff, offline command queue during disconnection.
FR-3 Command input P0 Single-line input with command history (up/down arrows, 500 entries), tab completion via server round-trip.
FR-4 Authentication UI P0 Login form with username/password, character selection screen, session persistence across page reloads.
FR-5 Character stats panel P1 HP/MP/SP progress bars with color coding, level display, XP progress bar, active status effects with duration.
FR-6 Inventory panel P1 Scrollable item list with quantities, equip/unequip actions, drag-and-drop reordering, right-click context menus.
FR-7 Room info display P1 Room name and description, exit buttons, player/NPC/item lists, area name.
FR-8 Map visualization P1 SVG grid map from Room.Info coordinates, current room highlight, fog of war for unvisited rooms, exit connections as lines.
FR-9 Chat channel tabs P1 Separate tab per channel (say, tell, guild, ooc), unread message count badges, channel join/leave commands.
FR-10 NPC dialogue UI P1 Chat bubble interface for AI-powered NPC conversations, typing indicator during AI generation, conversation history, suggested responses.
FR-11 Theme support P2 Dark, light, and high-contrast themes via CSS custom properties, user-customizable color schemes stored in localStorage.
FR-12 Mobile layout P1 Responsive breakpoints at 640px and 1024px, touch-friendly controls, swipe gesture navigation between panels.
FR-13 Settings persistence P2 Font size, font family, layout preferences, keybindings stored in localStorage with export/import as JSON.
FR-14 Aliases and macros P2 User-defined command aliases (e.g., 'k' -> 'kill'), macro recording and playback with configurable triggers.
FR-15 Clipboard integration P2 Copy selected output text to clipboard, paste text into command input, copy room descriptions.

3.2 Non-Functional Requirements

ID Requirement Target
NFR-1 Bundle size (gzipped) < 250KB initial, < 500KB total with lazy loading
NFR-2 Time to interactive < 2 seconds on 3G connection
NFR-3 Scrollback buffer 10,000 lines with virtual scrolling
NFR-4 Reconnection time < 1 second for network blips
NFR-5 Accessibility WCAG 2.1 AA compliance
NFR-6 Desktop browsers Chrome 90+, Firefox 90+, Safari 15+, Edge 90+
NFR-7 Mobile browsers iOS Safari 15+, Chrome Android 90+
NFR-8 Code quality TypeScript strict mode with zero any types
NFR-9 Test coverage > 80% for business logic, > 60% overall
NFR-10 Performance Lighthouse performance score > 90

4. Technology Stack

Technology Version Purpose Justification
React 18.3 UI framework Already proven in admin frontend, excellent TypeScript support, large ecosystem
TypeScript 5.6 Type safety Strict mode catches bugs at compile time, improves IDE experience
Vite 5.4 Build tool Fast HMR, optimized production builds, already configured for MAID
Zustand v5 Client state Minimal boilerplate, TypeScript-first, persist middleware for localStorage
TanStack React Query v5 Server state Caching, background refetch, optimistic updates, devtools
Tailwind CSS v3 Styling Utility-first, easy theming, small production bundle
React Router v6 Routing Standard React routing, supports code splitting
WebSocket wrapper Direct WebSocket wrapper in @maid/shared (see §4.1). No third-party WebSocket library.
@xterm/xterm 5.x Terminal emulation Full VT100 support, ANSI parsing, virtual scrolling, ligatures. Primary terminal renderer — handles color parsing and scrollback natively.
vitest 2.x Unit testing Fast, Vite-native, Jest-compatible API
@testing-library/react 16.x Component testing User-centric testing, accessibility checks
Playwright 1.48 E2E testing Cross-browser, reliable, good debugging
axe-core 4.x Accessibility testing Industry standard, integrates with test runners
react-window 1.8.x Virtual list rendering Used for non-terminal scrollable lists only (inventory, chat history, player lists). NOT used in terminal output — xterm.js handles its own virtual scrolling.
DOMPurify 3.x HTML sanitization Mandatory sanitization for any ANSI→HTML conversion outside the xterm terminal (e.g., chat panels, tooltips)
react-i18next 15.x Internationalization Translation keys for all UI labels, lazy-loaded locale bundles per language

4.1 Libraries NOT Chosen and Why

ansi-to-html: Dropped in favor of xterm.js, which handles ANSI parsing natively. Using both creates redundant parsing paths and divergent rendering. xterm.js provides a battle-tested VT100 terminal with built-in SGR support, 256-color, 24-bit RGB, and virtual scrolling — eliminating the need for a separate ANSI parser, ansi-to-html conversion, and react-window for the terminal output.

Socket.IO: We are not using Socket.IO because the MAID server uses native WebSocket protocol, not Socket.IO's custom protocol. Adding Socket.IO would require server-side changes and adds unnecessary abstraction.

react-use-websocket: This library wraps the browser WebSocket API in React hooks, but adds complexity without proportional value for our use case. Our WebSocket needs (reconnect with backoff/jitter, offline queue, heartbeat, protocol handshake, init on reconnect) are specific enough that a custom ~150-line wrapper in @maid/shared is simpler to debug and has no third-party upgrade risk. The custom GameWebSocket class encapsulates connection lifecycle, message queuing, and heartbeat — then a thin useGameWebSocket hook exposes it to React.

MUI / Material UI: Material UI would add significant bundle size (~100KB gzipped) for components we don't need. Our UI is terminal-focused, not form-heavy. Tailwind provides sufficient styling capability with a much smaller footprint. Material Design's aesthetic also conflicts with the classic MUD terminal feel.

Styled Components: Runtime CSS-in-JS adds overhead we don't need. Tailwind's utility classes compile to static CSS at build time. The admin frontend already uses Tailwind successfully. Switching would create inconsistency.

Redux / Redux Toolkit: Redux adds significant boilerplate even with Redux Toolkit. Zustand is simpler, has fewer concepts to learn, and is already used in the admin frontend. Our state is not complex enough to warrant Redux's architecture.

Emotion: Same reasoning as Styled Components — runtime overhead and no benefit over Tailwind for our use case.

4.2 Shared Code with Admin Frontend

Decision: npm workspace package in Phase 0. Rather than accept code duplication, Phase 0 (§14) creates an internal @maid/shared npm workspace package under packages/maid-engine/shared_frontend/. This avoids divergence of auth logic, API utilities, and base components between the admin and player frontends. The workspace root package.json at packages/maid-engine/ declares both admin_frontend/ and player_frontend/ as workspace members. The shared package is never published — it is consumed via workspace protocol ("@maid/shared": "workspace:*").

Extracted to @maid/shared package: - apiRequest<T>() utility for REST API calls with CSRF and auth - GameWebSocket class — direct WebSocket wrapper with reconnect, backoff/jitter, queue, and lifecycle hooks - createWebSocket() utility for WebSocket connection management - Authentication store slice (login, logout, token refresh) - Base component styles (Button, Card, Input, Modal) - Tailwind configuration preset and custom theme colors - TypeScript type definitions for API responses - SafeHTML component for sanitized HTML rendering (see §10.1)

NOT shared (player client specific): - Terminal emulation via xterm.js - GMCP routing and MessageBus - Game-specific stores (character, room, inventory, map) - Game-specific panels (stats, inventory, map) - NPC dialogue interface - Mobile macro bar and gestures


5. Architecture Design

5.1 Component Architecture

App
+-- AuthProvider
|   +-- LoginPage
|   |   +-- LoginForm
|   |   |   +-- UsernameInput
|   |   |   +-- PasswordInput
|   |   |   +-- RememberMeCheckbox
|   |   |   +-- SubmitButton
|   |   +-- ServerStatus
|   |       +-- ConnectionIndicator
|   |       +-- PlayerCount
|   |
|   +-- CharacterSelectPage
|   |   +-- CharacterList
|   |   |   +-- CharacterCard (repeating)
|   |   |       +-- CharacterAvatar
|   |   |       +-- CharacterName
|   |   |       +-- CharacterLevel
|   |   |       +-- LastPlayedTime
|   |   +-- CharacterPreview
|   |   |   +-- StatsSummary
|   |   |   +-- EquipmentPreview
|   |   |   +-- LocationPreview
|   |   +-- CreateCharacterForm
|   |       +-- NameInput
|   |       +-- ClassSelector
|   |       +-- RaceSelector
|   |       +-- ConfirmButton
|   |
|   +-- GamePage
|       +-- WebSocketProvider
|           |
|           |   NOTE: GMCP routing is NOT a React component.
|           |   GMCPRouter is a plain TypeScript class instantiated
|           |   inside useGameWebSocket. It receives raw GMCP messages,
|           |   dispatches to handlers, and updates Zustand stores directly.
|           |   See section 5.5 (GMCP Service Layer).
|           |
|           +-- ReconnectionOverlay (shown during reconnect/sync)
|           |   +-- AttemptCounter
|           |   +-- ElapsedTimer
|           |   +-- ManualRetryButton
|           |   +-- ReturnToLoginButton
|           |
|           +-- GameLayout (single responsive layout using CSS Grid + Tailwind)
|               |
|               |   Desktop (>1024px): 3-column grid
|               |   Tablet (640-1024px): 2-column grid, collapsible sidebar
|               |   Mobile (<640px): single column, bottom tab navigation
|               |
|               +-- LeftSidebar (hidden on mobile, collapsible on tablet)
|               |   +-- MapPanel
|               |   |   +-- MapCanvas
|               |   |   +-- ZoomControls
|               |   |   +-- MapLegend
|               |   +-- RoomInfoPanel
|               |       +-- RoomTitle
|               |       +-- RoomDescription
|               |       +-- ExitButtons
|               |       +-- PlayerList
|               |       +-- NPCList
|               |       +-- ItemList
|               |
|               +-- MainColumn
|               |   +-- TerminalOutput (xterm.js — handles ANSI + virtual scrolling)
|               |   +-- NPCDialogueOverlay
|               |   |   +-- DialogueBubble
|               |   |   +-- TypingIndicator
|               |   |   +-- SuggestedResponses
|               |   +-- CommandInput
|               |       +-- InputField
|               |       +-- HistoryNavigator
|               |       +-- TabCompleter
|               |
|               +-- RightSidebar (hidden on mobile, collapsible on tablet)
|               |   +-- CharacterStatsPanel
|               |   |   +-- HPBar
|               |   |   +-- MPBar
|               |   |   +-- SPBar
|               |   |   +-- XPBar
|               |   |   +-- StatusEffects
|               |   +-- InventoryPanel
|               |   |   +-- InventoryTabs
|               |   |   +-- ItemGrid (react-window for large inventories)
|               |   |   +-- EquipmentSlots
|               |   +-- QuestLogPanel
|               |   |   +-- ActiveQuests (collapsible list)
|               |   |   +-- QuestDetail (objectives, progress)
|               |   |   +-- CompletedQuestsToggle
|               |   +-- ChatPanel
|               |       +-- ChannelTabs
|               |       +-- MessageList (react-window for long history)
|               |       +-- QuickReply
|               |
|               +-- MobileTabBar (visible <640px only)
|               |   +-- TabButton (Game | Map | Stats | Quests | Chat)
|               |
|               +-- VirtualDPad (visible <640px only, overlays terminal)
|               |   +-- DirectionButton (N, S, E, W, NE, NW, SE, SW)
|               |   +-- UpDownButton (up, down — for vertical movement)
|               |   +-- PositionHandle (draggable to reposition)
|               |
|               +-- MacroBar (mobile: swipeable button bank above input)
|                   +-- MacroButton (configurable, swipeable banks)

5.2 State Management Design

Four Zustand stores manage client state with clear separation of concerns. Each store subscribes only to the GMCP packages it needs, reducing unnecessary re-renders.

Store GMCP Subscriptions UI Consumers
useCharacterStore Char.Vitals, Char.Status CharacterStatsPanel, StatusEffects
useRoomStore Room.Info, Room.Players, Room.NPCs, Room.Items RoomInfoPanel, ExitButtons
useInventoryStore Char.Items.List, Char.Items.Add, Char.Items.Remove InventoryPanel, EquipmentSlots
useMapStore Room.Info (coordinates only) MapPanel
useChatStore Comm.Channel.Text, Comm.Channel.List ChatPanel, ChannelTabs
useDialogueStore Dialogue.Start, Dialogue.Response, Dialogue.Typing, Dialogue.End NPCDialogueOverlay
useConnectionStore (WebSocket lifecycle) StatusBar, ReconnectionOverlay

5.2.1 Connection Store

interface ConnectionStore {{
  // State
  status: "disconnected" | "connecting" | "connected" | "reconnecting" | "stale" | "error";
  sessionId: string | null;
  characterId: string | null;
  resumeToken: string | null;
  lastSeq: number | null;
  latency: number;
  reconnectAttempts: number;
  offlineQueue: QueuedCommand[];
  lastError: string | null;
  lastConnectedAt: Date | null;

  // Actions
  connect: (url: string) => Promise<void>;
  disconnect: () => void;
  send: (message: GameMessage) => void;
  setStatus: (status: ConnectionStatus) => void;
  addToQueue: (command: string) => void;
  flushQueue: () => void;
  clearQueue: () => void;
  updateLatency: (latency: number) => void;
}}

interface QueuedCommand {{
  id: string;
  command: string;
  timestamp: Date;
  status: "pending" | "confirmed" | "failed";
}}

// Queue constraints:
// - Max 20 commands queued (reject with user notification beyond this)
// - 30s TTL per command (discard stale commands on flush)
// - No automatic retries — server sends full state snapshot before queue flush
// - Show flush confirmation overlay after reconnect: "N commands queued — Send?"

5.2.2 Character Store

interface CharacterStore {{
  // Character identity and vitals
  character: Character | null;
  setCharacter: (character: Character) => void;
  updateVitals: (vitals: Partial<Vitals>) => void;

  // Status effects
  statusEffects: StatusEffect[];
  addStatusEffect: (effect: StatusEffect) => void;
  removeStatusEffect: (effectId: string) => void;
  updateStatusEffect: (effectId: string, updates: Partial<StatusEffect>) => void;
  tickStatusEffects: () => void;
}}

// Subscribes to GMCP: Char.Vitals, Char.Status

interface Character {{
  id: string;
  name: string;
  level: number;
  class: string;
  race: string;
  vitals: Vitals;
}}

interface Vitals {{
  hp: number;
  maxHp: number;
  mp: number;
  maxMp: number;
  sp: number;
  maxSp: number;
  xp: number;
  xpToLevel: number;
}}

interface StatusEffect {{
  id: string;
  name: string;
  icon: string;
  duration: number;
  remainingTime: number;
  stacks: number;
  positive: boolean;
}}

5.2.3 Room Store

interface RoomStore {{
  // Room state
  room: Room | null;
  setRoom: (room: Room) => void;
  updateRoom: (updates: Partial<Room>) => void;
}}

// Subscribes to GMCP: Room.Info, Room.Players, Room.NPCs, Room.Items

interface Room {{
  id: string;
  name: string;
  description: string;
  area: string;
  coordinates: {{ x: number; y: number; z: number }};
  exits: Exit[];
  players: EntityInfo[];
  npcs: EntityInfo[];
  items: EntityInfo[];
}}

interface Exit {{
  direction: string;
  roomId: string;
  locked: boolean;
  hidden: boolean;
}}

interface EntityInfo {{
  id: string;
  name: string;
  shortDesc: string;
}}

5.2.4 Inventory Store

interface InventoryStore {{
  // Inventory state
  inventory: InventoryItem[];
  equipment: EquipmentSlots;
  setInventory: (items: InventoryItem[]) => void;
  addItem: (item: InventoryItem) => void;
  removeItem: (itemId: string) => void;
  updateItem: (itemId: string, updates: Partial<InventoryItem>) => void;
  equipItem: (slot: EquipmentSlot, item: InventoryItem) => void;
  unequipItem: (slot: EquipmentSlot) => void;
}}

// Subscribes to GMCP: Char.Items.List, Char.Items.Add, Char.Items.Remove

interface InventoryItem {{
  id: string;
  name: string;
  quantity: number;
  weight: number;
  type: ItemType;
  equipped: boolean;
  slot: EquipmentSlot | null;
}}

5.2.5 Map Store

interface MapStore {{
  // Map state
  // NOTE: visitedRooms uses Record<string, true> instead of Set<string>
  // because Zustand's persist middleware cannot serialize Set objects.
  // Use `roomId in visitedRooms` for lookups.
  map: MapData;
  visitedRooms: Record<string, true>;
  updateMap: (roomInfo: RoomInfo) => void;
  addVisitedRoom: (roomId: string) => void;
  clearMap: () => void;
  setViewport: (center: {{ x: number; y: number }}, zoom: number) => void;
}}

// Subscribes to GMCP: Room.Info (coordinates only — room details go to RoomStore)

5.2.6 Chat Store

interface ChatStore {{
  // Channel state
  // NOTE: Uses Record<string, V> instead of Map<string, V> because
  // Zustand's persist middleware cannot serialize Map objects.
  channels: Record<string, ChatChannel>;
  activeChannel: string;
  unreadCounts: Record<string, number>;

  // Actions
  addMessage: (channel: string, message: ChatMessage) => void;
  setActiveChannel: (channel: string) => void;
  markRead: (channel: string) => void;
  createChannel: (channel: ChatChannel) => void;
  removeChannel: (channelId: string) => void;
  clearChannel: (channelId: string) => void;
  getUnreadTotal: () => number;
}}

interface ChatChannel {{
  id: string;
  name: string;
  type: "say" | "tell" | "guild" | "ooc" | "system";
  messages: ChatMessage[];
  maxMessages: number;
  isJoined: boolean;
}}

interface ChatMessage {{
  id: string;
  sender: string;
  content: string;
  timestamp: Date;
  type: "player" | "npc" | "system";
  isRead: boolean;
}}

5.2.7 Dialogue Store

interface DialogueStore {{
  // Active conversation state
  activeDialogue: ActiveDialogue | null;
  history: DialogueMessage[];

  // Actions
  startDialogue: (npc: DialogueNPC) => void;
  addResponse: (npcId: string, text: string, suggestions: string[]) => void;
  setTyping: (npcId: string, typing: boolean) => void;
  addPlayerMessage: (text: string) => void;
  endDialogue: (reason: "player" | "npc" | "timeout" | "error") => void;
  clearHistory: () => void;
}}

// Subscribes to GMCP: Dialogue.Start, Dialogue.Response, Dialogue.Typing, Dialogue.End

interface ActiveDialogue {{
  npcId: string;
  npcName: string;
  npcTitle: string;
  portrait?: string;
  isTyping: boolean;
  suggestions: string[];
}}

interface DialogueMessage {{
  id: string;
  sender: "player" | "npc";
  senderName: string;
  text: string;
  timestamp: Date;
}}

interface DialogueNPC {{
  npcId: string;
  npcName: string;
  npcTitle: string;
  portrait?: string;
  greeting: string;
}}

5.2.8 Settings Store

interface SettingsStore {{
  // Appearance
  theme: "dark" | "light" | "high-contrast";
  fontSize: number;
  fontFamily: string;
  compactMode: boolean;

  // Behavior
  scrollbackLimit: number;
  showTimestamps: boolean;
  soundEnabled: boolean;
  notificationsEnabled: boolean;

  // Layout
  layout: LayoutConfig;

  // Commands
  // NOTE: Uses Record<string, string> instead of Map<string, string> because
  // Zustand's persist middleware cannot serialize Map objects.
  aliases: Record<string, string>;
  macros: Macro[];
  keybindings: Record<string, string>;

  // Actions
  updateSetting: <K extends keyof SettingsStore>(key: K, value: SettingsStore[K]) => void;
  resetDefaults: () => void;
  exportSettings: () => string;
  importSettings: (json: string) => boolean;
  addAlias: (alias: string, command: string) => void;
  removeAlias: (alias: string) => void;
  addMacro: (macro: Macro) => void;
  removeMacro: (macroId: string) => void;
  setKeybinding: (key: string, action: string) => void;
}}

interface LayoutConfig {{
  leftSidebarWidth: number;
  rightSidebarWidth: number;
  leftPanels: PanelConfig[];
  rightPanels: PanelConfig[];
  showLeftSidebar: boolean;
  showRightSidebar: boolean;
}}

interface PanelConfig {{
  id: string;
  type: PanelType;
  collapsed: boolean;
  height: number;
}}

interface Macro {{
  id: string;
  name: string;
  trigger: string;
  commands: string[];
  delay: number;
}}

5.3 Message Bus (Decoupling Layer)

The WebSocket hook does NOT import or call stores directly. Instead, a MessageBus decouples the WebSocket transport from state management.

Dependency injection: The MessageBus is NOT a global singleton. It is created via a factory function and provided through React context, making it injectable for testing and enabling multiple independent instances (e.g., one per test case).

// lib/message-bus.ts — plain TypeScript, no React dependency
type MessageHandler = (payload: unknown) => void;

export class MessageBus {{
  private subscribers = new Map<string, Set<MessageHandler>>();

  on(messageType: string, handler: MessageHandler): () => void {{
    if (!this.subscribers.has(messageType)) {{
      this.subscribers.set(messageType, new Set());
    }}
    this.subscribers.get(messageType)!.add(handler);
    return () => this.subscribers.get(messageType)?.delete(handler);
  }}

  emit(messageType: string, payload: unknown): void {{
    this.subscribers.get(messageType)?.forEach((handler) => handler(payload));
  }}
}}

export function createMessageBus(): MessageBus {{
  return new MessageBus();
}}

// React context for dependency injection
// providers/message-bus-provider.tsx
const MessageBusContext = React.createContext<MessageBus | null>(null);

export function MessageBusProvider({{ children, bus }}: {{ children: React.ReactNode; bus?: MessageBus }}) {{
  const instance = React.useMemo(() => bus ?? createMessageBus(), [bus]);
  return <MessageBusContext.Provider value={{instance}}>{{children}}</MessageBusContext.Provider>;
}}

export function useMessageBus(): MessageBus {{
  const bus = React.useContext(MessageBusContext);
  if (!bus) throw new Error("useMessageBus must be used within MessageBusProvider");
  return bus;
}}

Testing: In tests, create an isolated MessageBus per test case and pass it via MessageBusProvider. No global state leaks between tests.

Subscription pattern: Stores do NOT subscribe at module-level create() time — that would execute at import time before the React context provides the MessageBus instance. Instead, each store exports a connectStore(bus: MessageBus) function that is called lazily from a useEffect in MessageBusProvider after the bus is available:

// stores/character-store.ts
const useCharacterStore = create<CharacterStore>((set) => ({{
  /* initial state and actions — NO subscriptions here */
}}));

// Called lazily from MessageBusProvider, NOT at import time
export function connectCharacterStore(bus: MessageBus): () => void {{
  const unsub1 = bus.on("gmcp:Char.Vitals", (data) => useCharacterStore.setState(/* update vitals */));
  const unsub2 = bus.on("gmcp:Char.Status", (data) => useCharacterStore.setState(/* update effects */));
  return () => {{ unsub1(); unsub2(); }};
}}

// providers/message-bus-provider.tsx — connects all stores after bus is created
export function MessageBusProvider({{ children, bus }}: {{ children: React.ReactNode; bus?: MessageBus }}) {{
  const instance = React.useMemo(() => bus ?? createMessageBus(), [bus]);

  React.useEffect(() => {{
    const teardowns = connectStores(instance); // calls all connectXxxStore() functions
    return teardowns;
  }}, [instance]);

  return <MessageBusContext.Provider value={{instance}}>{{children}}</MessageBusContext.Provider>;
}}

5.4 WebSocket Integration Layer

The WebSocket layer handles all real-time communication with the game server. It provides:

  • Connection lifecycle: connect -> handshake -> authenticate -> subscribe GMCP -> ready
  • Auto-reconnect: Exponential backoff (1s, 2s, 4s, 8s, 16s cap) with jitter
  • Message routing: Parse JSON, validate envelope, emit to MessageBus
  • Offline queue: Commands sent during reconnection are queued (max 20, 30s TTL)
  • Rate limiting: 10 commands/second client-side
  • Heartbeat: Client sends ping every 30s, expects pong within 10s
  • Protocol versioning: Handshake negotiates protocol version and capabilities

useGameWebSocket Hook

The WebSocket hook's sole responsibility is JSON parsing, validation, and emitting to the MessageBus. It does NOT import any game stores. It uses a direct GameWebSocket wrapper (from @maid/shared) instead of a third-party WebSocket library.

export function useGameWebSocket() {{
  const {{ status, sessionId, resumeToken, lastSeq, setStatus, addToQueue, flushQueue, updateLatency }} =
    useConnectionStore();
  const messageBus = useMessageBus();

  // GMCPRouter is a service-layer class, not a React component
  const gmcpRouter = useRef(new GMCPRouter(messageBus)).current;

  // Heartbeat state
  const lastPingSent = useRef<number>(0);
  const heartbeatTimer = useRef<ReturnType<typeof setInterval>>();
  const pongTimeout = useRef<ReturnType<typeof setTimeout>>();
  const parseErrorCount = useRef(0);
  const parseErrorWindowStart = useRef(Date.now());

  // GameWebSocket wraps native WebSocket with reconnect, queue, and lifecycle
  const ws = useRef<GameWebSocket | null>(null);

  useEffect(() => {{
    ws.current = new GameWebSocket(getWebSocketUrl(), {{
      reconnectAttempts: 10,
      reconnectInterval: (attemptNumber: number) =>
        Math.min(1000 * Math.pow(2, attemptNumber), 16000) +
        Math.random() * 1000, // jitter
      onOpen: () => {{
        setStatus("connected");
        parseErrorCount.current = 0;
        startHeartbeat();
        // Send init message to bind character and resume session (§9.1 step 10, §11.6)
        ws.current?.send(JSON.stringify({{
          id: crypto.randomUUID(),
          type: "init",
          ts: Date.now(),
          seq: 0,
          payload: {{
            protocolVersion: "1.0",
            clientVersion: __APP_VERSION__,
            gmcpPackages: SUBSCRIBED_GMCP_PACKAGES,
            characterId: useConnectionStore.getState().characterId,
            resumeToken: resumeToken ?? null,
            lastSeq: lastSeq ?? null,
          }},
        }}));
      }},
      onClose: () => {{
        setStatus("reconnecting");
        stopHeartbeat();
      }},
      onError: (event: Event) => {{
        console.error("WebSocket error:", event);
        setStatus("error");
      }},
      onMessage: (event: MessageEvent) => handleMessage(event),
    }});
    return () => ws.current?.close();
  }}, []);

  // --- Heartbeat / keep-alive ---
  function startHeartbeat() {{
    heartbeatTimer.current = setInterval(() => {{
      lastPingSent.current = Date.now();
      sendMessage(JSON.stringify({{ type: "ping", ts: lastPingSent.current }}));
      pongTimeout.current = setTimeout(() => {{
        setStatus("stale");
      }}, 10_000);
    }}, 30_000);
  }}

  function stopHeartbeat() {{
    clearInterval(heartbeatTimer.current);
    clearTimeout(pongTimeout.current);
  }}

  // --- Message handling with error protection ---
  function handleMessage(event: MessageEvent) {{
    const raw = event.data;

    let message: ServerMessage;
    try {{
      message = JSON.parse(raw);
    }} catch (e) {{
      console.error("Failed to parse server message:", e, raw);
      parseErrorCount.current++;
      // Force reconnect if >5 parse errors in 10s
      const now = Date.now();
      if (now - parseErrorWindowStart.current > 10_000) {{
        parseErrorCount.current = 1;
        parseErrorWindowStart.current = now;
      }} else if (parseErrorCount.current > 5) {{
        console.error("Too many parse errors, forcing reconnect");
        setStatus("reconnecting");
      }}
      return;
    }}

    if (!validateServerMessage(message)) {{
      console.warn("Unknown or malformed message type:", message);
      return;
    }}

    // Handle pong for latency tracking
    if (message.type === "pong") {{
      clearTimeout(pongTimeout.current);
      updateLatency(Date.now() - lastPingSent.current);
      if (status === "stale") setStatus("connected");
      return;
    }}

    // Handle sync_complete (reconnection state reconciliation)
    if (message.type === "sync_complete") {{
      messageBus.emit("sync_complete", message.payload);
      flushQueue(); // Safe to flush now that state is reconciled
      return;
    }}

    // Emit to MessageBus — stores subscribe to what they need
    switch (message.type) {{
      case "text":
        messageBus.emit("text", message.payload);
        break;
      case "gmcp":
        gmcpRouter.dispatch(message.payload.package, message.payload.data);
        break;
      case "gmcp_batch":
        gmcpRouter.dispatchBatch(message.payload.messages);
        break;
      case "prompt":
        messageBus.emit("prompt", message.payload);
        break;
      case "system":
        messageBus.emit("system", message.payload);
        break;
      case "error":
        messageBus.emit("error", message.payload);
        break;
      case "ack":
      case "nack":
        messageBus.emit(message.type, message.payload);
        break;
    }}
  }}

  // --- Page lifecycle handling ---
  useEffect(() => {{
    const handleVisibility = () => {{
      if (document.hidden) {{
        stopHeartbeat(); // Pause timers when tab is hidden
      }} else {{
        // Tab visible again — check connection health
        if (status === "connected" || status === "stale") {{
          startHeartbeat();
        }} else {{
          setStatus("reconnecting");
        }}
      }}
    }};

    const handleBeforeUnload = () => {{
      // Best-effort disconnect notification via WebSocket close frame.
      // No sendBeacon — no REST endpoint exists for disconnect (see §11.2).
      // Server-side heartbeat timeout handles cleanup if close frame is lost.
    }};

    document.addEventListener("visibilitychange", handleVisibility);
    window.addEventListener("beforeunload", handleBeforeUnload);
    return () => {{
      document.removeEventListener("visibilitychange", handleVisibility);
      window.removeEventListener("beforeunload", handleBeforeUnload);
    }};
  }}, [status, sessionId]);

  return {{
    send: (data: string) => ws.current?.send(data),
    status,
    sessionId,
  }};
}}

// Type guard for server messages
function validateServerMessage(msg: unknown): msg is ServerMessage {{
  if (typeof msg !== "object" || msg === null) return false;
  const {{ type }} = msg as {{ type: unknown }};
  return typeof type === "string" &&
    ["text", "system", "error", "prompt", "gmcp", "gmcp_batch", "completion",
     "pong", "ack", "nack", "sync_complete", "hello"].includes(type);
}}

5.5 GMCP Service Layer

GMCPRouter is a plain TypeScript class, not a React component. It renders zero UI. It is instantiated inside the WebSocket hook and dispatches GMCP messages to the appropriate store via the MessageBus.

// lib/gmcp-router.ts — no React imports
class GMCPRouter {{
  constructor(private bus: MessageBus) {{}}

  dispatch(packageName: string, data: unknown): void {{
    // Emit normalized event for each GMCP package
    this.bus.emit(`gmcp:${{packageName}}`, data);
  }}

  // Batch support: server can send GMCPBatch for atomic multi-updates.
  // No requestAnimationFrame wrapper needed — React 18's automatic batching
  // coalesces all synchronous setState calls within the same microtask into
  // a single render. Synchronous dispatch is simpler and testable.
  dispatchBatch(messages: Array<{{ package: string; data: unknown }}>): void {{
    for (const msg of messages) {{
      this.bus.emit(`gmcp:${{msg.package}}`, msg.data);
    }}
  }}
}}

5.6 GMCP Data Flow

The following table maps GMCP packages to their handlers, stores, and UI components:

GMCP Package Store UI Component
Char.Vitals characterStore.vitals CharacterStatsPanel
Char.Status characterStore.statusEffects StatusEffects
Char.Items.List inventoryStore.inventory InventoryPanel
Char.Items.Add inventoryStore.addItem InventoryPanel
Char.Items.Remove inventoryStore.removeItem InventoryPanel
Room.Info roomStore.room, mapStore.updateMap RoomInfoPanel, MapPanel
Room.Players roomStore.room.players RoomInfoPanel
Comm.Channel.Text chatStore.addMessage ChatPanel
Comm.Channel.List chatStore.channels ChannelTabs
Dialogue.Start dialogueStore.startDialogue NPCDialogueOverlay
Dialogue.Response dialogueStore.addResponse NPCDialogueOverlay
Dialogue.Typing dialogueStore.setTyping TypingIndicator
Dialogue.End dialogueStore.endDialogue NPCDialogueOverlay
GMCP Data Flow Diagram:

+-------------+
|   Server    |
+------+------+
       | WebSocket JSON
       v
+-------------+
| useGame     |
| WebSocket   |  (JSON.parse + validate + emit)
+------+------+
       | messageBus.emit("gmcp:*", data)
       v
+------------------+
|   GMCPRouter     |  (plain TS class — NOT a React component)
| (service layer)  |
+------+-----------+
       | messageBus.emit("gmcp:{Package}", data)
       v
+-------------------------------------+
|         Zustand Stores              |
| (each subscribes to its packages)  |
+----------+----------+-------+------+
|character | room     |invent.|  map |
|Store     | Store    | Store | Store|
+----+-----+----+-----+--+---+--+---+
| chat     | dialogue |
| Store    | Store    |
+----+-----+----+-----+
     |          |         |      |
     v          v         v      v
+-------------------------------------+
|        React Components             |
+----------+----------+-------+------+
| Stats    | RoomInfo | Inven | Map  |
| Panel    | Panel    | Panel | Panel|
+----------+----------+-------+------+
| Chat     | Dialogue |
| Panel    | Overlay  |
+----------+----------+

Note: For GMCPBatch messages (atomic multi-update), the
GMCPRouter dispatches all events synchronously. React 18's
automatic batching coalesces the resulting store updates
into a single render — no requestAnimationFrame needed.

6. UI/UX Design

6.1 Layout System

The client uses a single GameLayout component with CSS Grid and Tailwind responsive utilities. There are NOT three separate layout components — one layout adapts to all viewport sizes via responsive classes.

// layouts/GameLayout.tsx — single component, responsive via Tailwind
<div className="grid h-screen
  grid-cols-1                          /* mobile: single column */
  md:grid-cols-[280px_1fr]             /* tablet: sidebar + main */
  lg:grid-cols-[280px_1fr_280px]       /* desktop: 3 columns */
  grid-rows-[auto_1fr_auto_auto]">
  {/* ... */}
</div>

Desktop (>1024px) — 3-column grid

+------------------------------------------------------------------------------------+
|  MAID - Modern Adventure Interface Display                    [Sound] [Settings]  |
+----------------+-------------------------------------------+-------------------+
|                |                                           |                   |
|   +---------+  |  The Crossroads                           |  +-------------+  |
|   |  MAP    |  |  -----------------------------------------|  | CHARACTER   |  |
|   |         |  |  You stand at a busy crossroads.          |  |             |  |
|   |   [.]   |  |  Merchants hawk their wares from          |  | HP ======== |  |
|   |  / | \  |  |  colorful stalls while travelers         |  |    150/150  |  |
|   | [.]-[@] |  |  hurry past in all directions.            |  |             |  |
|   |  \ | /  |  |  The air smells of spices.               |  | MP ======-- |  |
|   |   [.]   |  |                                           |  |    75/100   |  |
|   |         |  |  > look                                   |  |             |  |
|   +---------+  |  A weathered signpost points the way.     |  | SP ====---- |  |
|                |                                           |  |    60/100   |  |
|   +---------+  |  > examine signpost                       |  |             |  |
|   |  ROOM   |  |  The signpost reads:                      |  | XP ===------ |  |
|   |         |  |    North - Village Square                 |  |    3250     |  |
|   | Crossroads|  |    East  - Market District              |  |    /5000    |  |
|   |         |  |    South - City Gates                     |  +-------------+  |
|   | Exits:  |  |    West  - Residential                    |                   |
|   | [N][E]  |  |                                           |  +-------------+  |
|   | [S][W]  |  |  > north                                  |  | INVENTORY   |  |
|   |         |  |  You head north toward the square.        |  | - Iron Sword|  |
|   | Players:|  |                                           |  | - Leather   |  |
|   | - Aria  |  |  Village Square                           |  |   Armor     |  |
|   | - Kael  |  |  -----------------------------------------|  | - Health    |  |
|   |         |  |  The heart of the village bustles.        |  |   Potion x3 |  |
|   +---------+  |                                           |  +-------------+  |
|                |                                           |                   |
+----------------+-------------------------------------------+-------------------+
| > _                                                                            |
+--------------------------------------------------------------------------------+
| Connected | Latency: 45ms | 127 players online | 14:32:05                      |
+--------------------------------------------------------------------------------+

Tablet (640-1024px) — 2-column grid with collapsible sidebar

+---------------------------------------------------------------------+
|  MAID                                              [Sound] [Settings]|
+-------------------------------------------------+-------------------+
|                                                 |                   |
|  The Crossroads                                 | [Stats][Inv]      |
|  -----------------------------------------------| [Map][Chat]       |
|  You stand at a busy crossroads. Merchants      |                   |
|  hawk their wares from colorful stalls while    | +-----------+     |
|  travelers hurry past in all directions.        | | HP ====== |     |
|                                                 | |   150/150 |     |
|  > look                                         | |           |     |
|  A weathered signpost points the way.           | | MP ====-- |     |
|                                                 | |   75/100  |     |
|  > examine signpost                             | +-----------+     |
|  The signpost reads:                            |                   |
|    North - Village Square                       | [< Collapse]      |
|    East  - Market District                      |                   |
|                                                 |                   |
+-------------------------------------------------+-------------------+
| > _                                                                 |
+---------------------------------------------------------------------+
| Connected | 45ms | 127 online                                       |
+---------------------------------------------------------------------+

Mobile (<640px) — single column with bottom tab navigation and macro bar

+---------------------------------+
| Crossroads          HP ======  |
+---------------------------------+
|                                 |
| The Crossroads                  |
| ------------------------------- |
| You stand at a busy crossroads. |
| Merchants hawk their wares.     |
|                                 |
| > look                          |
| A weathered signpost points     |
| the way.                        |
|                                 |
| > examine signpost              |
| The signpost reads:             |
|   North - Village Square        |
|   East  - Market District       |
|                                 |
+---------------------------------+
| > _                             |
+---------------------------------+
| [⚔️ Attack] [🧪 Heal] [🏃 Flee] | ← Macro bar (swipeable banks)
+---------------------------------+
| [Game] [Map] [Stats] [Q] [Chat]|  ← Q = Quests
+---------------------------------+

Virtual D-Pad (mobile only): A translucent directional pad overlays the bottom-right corner of the terminal, providing touch-based movement without typing. The D-Pad renders 8 cardinal/ordinal direction buttons (N, S, E, W, NE, NW, SE, SW) plus an inner up/down toggle for vertical movement. Tapping a direction sends the corresponding movement command. The D-Pad can be repositioned by dragging and hidden via a toggle in settings. It respects prefers-reduced-motion by disabling tap animations.

+-----+
|  NW  N  NE |
|  W  [·]  E |    ← Translucent overlay, bottom-right corner
|  SW  S  SE |      [·] = up/down toggle
+-----+

The macro bar is a configurable row of swipeable soft-key buttons above the command input. Players can define multiple "banks" of macros (e.g., Combat, Travel, Social) and swipe between them. Each button sends a predefined command.

6.2 Theme System

Three built-in themes with CSS custom properties:

Dark Theme (Default)

:root[data-theme="dark"] {{
  --color-bg-primary: #1a1a1a;
  --color-bg-secondary: #252525;
  --color-bg-tertiary: #2d2d2d;
  --color-text-primary: #e0e0e0;
  --color-text-secondary: #a0a0a0;
  --color-text-muted: #666666;
  --color-border: #3d3d3d;
  --color-accent: #4a9eff;
  --color-success: #4caf50;
  --color-warning: #ff9800;
  --color-danger: #f44336;
  --color-hp: #e53935;
  --color-mp: #2196f3;
  --color-sp: #ffeb3b;
  --color-xp: #9c27b0;
}}

Light Theme

:root[data-theme="light"] {{
  --color-bg-primary: #ffffff;
  --color-bg-secondary: #f5f5f5;
  --color-bg-tertiary: #eeeeee;
  --color-text-primary: #212121;
  --color-text-secondary: #616161;
  --color-text-muted: #9e9e9e;
  --color-border: #e0e0e0;
  --color-accent: #1976d2;
  --color-success: #388e3c;
  --color-warning: #f57c00;
  --color-danger: #d32f2f;
  --color-hp: #c62828;
  --color-mp: #1565c0;
  --color-sp: #f9a825;
  --color-xp: #7b1fa2;
}}

High Contrast Theme

:root[data-theme="high-contrast"] {{
  --color-bg-primary: #000000;
  --color-bg-secondary: #0a0a0a;
  --color-bg-tertiary: #141414;
  --color-text-primary: #ffffff;
  --color-text-secondary: #ffffff;
  --color-text-muted: #cccccc;
  --color-border: #ffffff;
  --color-accent: #00ffff;
  --color-success: #00ff00;
  --color-warning: #ffff00;
  --color-danger: #ff0000;
  --color-hp: #ff0000;
  --color-mp: #00ffff;
  --color-sp: #ffff00;
  --color-xp: #ff00ff;
}}

6.3 Responsive Breakpoints

Breakpoint Range Layout Key Changes
Mobile < 640px Single column Bottom tab navigation, swipe gestures, compact stats
Tablet 640-1024px Two columns Collapsible side panel, tabbed secondary content
Desktop > 1024px Three columns All panels visible, resizable sidebars

6.4 Accessibility

The client implements WCAG 2.1 AA compliance through comprehensive ARIA implementation, keyboard navigation, and screen reader support.

ARIA Implementation: - Live regions for dynamic content with appropriate politeness levels - Landmark regions for navigation structure - Status updates with meaningful labels - Focus management for modal dialogs

Keyboard Navigation: - All interactive elements are focusable with Tab - Arrow keys navigate within panels - Escape closes modals and overlays - Enter submits forms and activates buttons - Custom keyboard shortcuts documented and configurable

Screen Reader Support: - Meaningful alt text for all visual elements - ARIA labels for icon-only buttons - Game output announced as it arrives (polite live region) - Combat and important events announced assertively - Combat message aggregation: During rapid combat sequences, messages are batched and summarized every 3-5 seconds instead of announcing each line individually. Example: "You dealt 45 damage over 3 attacks. Goblin is wounded." This prevents screen readers from falling hopelessly behind during fast combat.

Reduced Motion: - Respect prefers-reduced-motion media query - Disable pulsing HP animation, typing indicators, and transitions - Provide static alternatives for all animated elements


7. Key Components

7.1 Terminal Output Pane

The terminal output pane is the central component of the game interface, responsible for displaying all game text with proper formatting.

xterm.js as Single Terminal Renderer:

The terminal output uses @xterm/xterm as the sole rendering engine. xterm.js handles ANSI parsing, virtual scrolling, and text rendering natively — there is no separate ANSI→HTML parser, no ansi-to-html library, and no react-window for the terminal. This eliminates triple redundancy and ensures consistent VT100 behavior.

  • xterm.js handles all SGR sequences: basic colors (30-37, 40-47, 90-97, 100-107), 256-color (38;5;n, 48;5;n), 24-bit RGB (38;2;r;g;b, 48;2;r;g;b), text attributes (bold, italic, underline, strikethrough, dim, blink, inverse), reset sequences, and malformed sequence recovery.
  • xterm.js provides its own virtual scrolling — only visible lines are in the DOM.
  • Scrollback buffer is configured to 10,000 lines (adjustable via settings).
  • The react-window library is used ONLY for non-terminal scrollable lists (inventory, chat history, player lists) — never for the terminal output.

DOMPurify for non-terminal HTML:

Any ANSI→HTML conversion outside the xterm terminal (e.g., chat panel messages, tooltips) MUST be sanitized with DOMPurify before insertion. Allowed: <span> with style attribute only. All other tags and attributes are stripped.

7.2 Command Input Bar

The command input bar handles user input with history, tab completion, and alias expansion:

Features: - Command history (500 entries, navigable with up/down arrows) - Tab completion with server round-trip (see below) - Alias expansion before command execution - Macro trigger detection and expansion - Keyboard shortcuts (configurable) - Macro/alias loop protection: Recursion depth limit of 5 nested expansions. Maximum 20 commands per single macro expansion. Throttle: max 10 macro-triggered commands per second. If limits are exceeded, expansion is halted and an error is shown in the terminal.

Tab Completion Protocol: - Debounce: Tab key triggers a 150ms debounce timer before sending a complete request. Rapid repeated tabs within the window reset the timer, avoiding server spam. - Request tracking: Each complete request carries a unique requestId. The client tracks the latest pending requestId and ignores completion responses whose requestId doesn't match (stale results from slower earlier requests). - Timeout: If no completion response arrives within 2 seconds, the pending request is discarded and the tab-complete indicator is hidden. No retry is attempted. - UI feedback: While a completion request is in-flight, a subtle spinner or "..." indicator appears next to the input. If multiple completions are returned, a dropdown menu is shown for selection.

7.3 Map Visualization

The map panel renders an SVG grid showing the player's surroundings:

Features: - Coordinate-based room positioning from Room.Info GMCP - Current room highlighting with pulsing indicator - Fog of war for unvisited rooms - Exit connections rendered as lines - Zoom and pan controls - Terrain-based color coding - Map legend

7.4 Character Stats Panel

Features: - HP/MP/SP progress bars with color coding - Low health warning with pulsing animation - XP progress bar with percentage display - Level and class display - Status effects with icons and duration timers - Stacking indicators for multiple instances

Status Effect Timer Reconciliation:

Status effect remainingTime is decremented client-side via a 1-second setInterval for responsive UI. However, client-side countdown drifts over time (JavaScript timers are not precise, tabs may be backgrounded). To prevent drift:

  • The server sends Char.Status updates at least every 30 seconds during active effects, even if nothing changed.
  • On each Char.Status update, the client replaces all local remainingTime values with the server's authoritative values.
  • When the tab becomes visible after being hidden, the client immediately requests a Char.Status refresh rather than trusting stale local timers.
  • Client-side timers are purely cosmetic — all effect expiration decisions are server-authoritative.

7.5 Inventory Panel

Features: - Tabbed view (All, Equipment, Consumables) - Equipment slots with drag-and-drop - Item context menus (Use, Equip, Drop) - Quantity display for stacked items - Weight tracking - Search/filter functionality

7.6 NPC Dialogue Interface

Behavior: The NPC dialogue UI renders as a side panel overlay on desktop/tablet, not a blocking modal. On desktop, it slides in from the right edge and overlays the right sidebar, keeping the terminal fully visible and interactive. On tablet, it replaces the collapsible sidebar content. On mobile (<640px), it renders as a bottom sheet occupying the lower 60% of the screen, with the terminal still scrollable above it. The player can continue typing commands in the terminal while a dialogue is active — the dialogue panel does not capture keyboard focus unless the player clicks into it.

Features: - Chat bubble UI for natural conversation feel - Typing indicator during AI generation - Suggested responses for quick replies (clickable chips) - Conversation history scrollback - NPC portrait and name display - Close button to end conversation (sends endconversation command) - Dismiss via Escape key or clicking outside the panel

7.7 Chat/Communication Panel

Features: - Tabbed interface (Say, Tell, Guild, OOC, System) - Unread message count badges - Channel join/leave functionality - Message timestamps - Player name highlighting - Quick reply input

7.8 Quest Log Panel

Features: - Collapsible list of active quests with progress indicators - Quest detail view showing objectives with completion checkmarks - Toggle to show/hide completed quests - Quest objective updates highlighted briefly on change - Subscribes to GMCP Quest.List and Quest.Update packages (if available from content pack) - Gracefully hidden if no quest system GMCP packages are negotiated

Note: The quest log panel depends on a Quest GMCP package being provided by a content pack (e.g., maid-classic-rpg). If no quest package is available in the hello handshake, the panel is not rendered and the "Quests" tab is hidden from the mobile tab bar.


8. API Integration

8.1 WebSocket Protocol

All game communication uses WebSocket with JSON message format. Every message uses a unified envelope structure for consistency and traceability.

8.1.1 Unified Message Envelope

All messages (server→client and client→server) MUST use this envelope:

interface MessageEnvelope {{
  id: string;       // Unique message ID (UUID or monotonic counter)
  type: string;     // Message type discriminator
  ts: number;       // Unix timestamp (ms) when message was created
  seq: number;      // Monotonically increasing sequence number per connection
  requestId?: string; // For responses: the id of the request this responds to
  payload: unknown;  // Type-specific payload
}}

Why: Enables message ordering verification, duplicate detection, request/response correlation, and latency measurement. The seq field lets the client detect missed messages.

8.1.2 WebSocket Handshake

On WebSocket connection, the server sends a hello message before any game data. The client MUST wait for hello before sending commands.

// Server → Client: hello (first message after connection)
{{
  "id": "msg_001",
  "type": "hello",
  "ts": 1721300000000,
  "seq": 0,
  "payload": {{
    "protocolVersion": "1.0",
    "serverVersion": "3.1.0",
    "serverCaps": ["gmcp", "compression", "batch"],
    "gmcpPackagesAvailable": [
      "Core 1", "Char 1", "Char.Vitals 1", "Char.Items 1",
      "Char.Status 1", "Room 1", "Room.Info 1", "Comm 1",
      "Comm.Channel 1", "Dialogue 1"
    ],
    "compression": "none",
    "resumeToken": "tok_abc123",
    "sessionId": "sess_xyz789"
  }}
}}

// Client → Server: init (response to hello)
{{
  "id": "msg_c001",
  "type": "init",
  "ts": 1721300000050,
  "seq": 0,
  "payload": {{
    "protocolVersion": "1.0",
    "clientVersion": "1.0.0",
    "gmcpPackages": [
      "Core 1", "Char.Vitals 1", "Char.Items 1", "Char.Status 1",
      "Room.Info 1", "Comm.Channel 1", "Dialogue 1"
    ],
    "characterId": "char_abc123",
    "resumeToken": null,
    "lastSeq": null
  }}
}}

8.1.3 Wire Format Reference

The following shows the exact JSON payloads for each message type. Field names are normalized — the server and client use identical field names.

Server → Client Messages:

// text — Game output to display in terminal
{{
  "id": "msg_042", "type": "text", "ts": 1721300001000, "seq": 42,
  "payload": {{
    "text": "\u001b[1;32mYou strike the goblin!\u001b[0m",
    "channel": "main"
  }}
}}

// system — System notification
{{
  "id": "msg_043", "type": "system", "ts": 1721300001100, "seq": 43,
  "payload": {{
    "text": "Server will restart in 5 minutes.",
    "level": "warning"
  }}
}}

// error — Error response (optionally correlated to a request)
{{
  "id": "msg_044", "type": "error", "ts": 1721300001200, "seq": 44,
  "requestId": "msg_c010",
  "payload": {{
    "text": "You can't go that way.",
    "code": "NO_EXIT"
  }}
}}

// prompt — Prompt string update
{{
  "id": "msg_045", "type": "prompt", "ts": 1721300001300, "seq": 45,
  "payload": {{
    "text": "HP:150/150 MP:75/100 SP:60/100 >"
  }}
}}

// gmcp — GMCP package data
{{
  "id": "msg_046", "type": "gmcp", "ts": 1721300001400, "seq": 46,
  "payload": {{
    "package": "Char.Vitals",
    "data": {{
      "hp": 150, "maxHp": 150, "mp": 75, "maxMp": 100,
      "sp": 60, "maxSp": 100, "xp": 3250, "xpToLevel": 5000, "level": 5
    }}
  }}
}}

// gmcp_batch — Atomic multi-update (e.g., after room change)
{{
  "id": "msg_047", "type": "gmcp_batch", "ts": 1721300001500, "seq": 47,
  "payload": {{
    "messages": [
      {{ "package": "Room.Info", "data": {{ "id": "room_01", "name": "Village Square", ... }} }},
      {{ "package": "Room.Players", "data": {{ "players": [...] }} }},
      {{ "package": "Char.Vitals", "data": {{ "hp": 148, ... }} }}
    ]
  }}
}}

// ack — Command acknowledged
{{
  "id": "msg_048", "type": "ack", "ts": 1721300001600, "seq": 48,
  "requestId": "msg_c010",
  "payload": {{}}
}}

// nack — Command rejected
{{
  "id": "msg_049", "type": "nack", "ts": 1721300001700, "seq": 49,
  "requestId": "msg_c010",
  "payload": {{ "reason": "rate_limited", "retryAfter": 1000 }}
}}

// completion — Tab completion response
{{
  "id": "msg_050", "type": "completion", "ts": 1721300001800, "seq": 50,
  "requestId": "msg_c005",
  "payload": {{
    "completions": ["north", "northeast"]
  }}
}}

// sync_complete — State reconciliation finished (after reconnect)
{{
  "id": "msg_051", "type": "sync_complete", "ts": 1721300002000, "seq": 51,
  "payload": {{ "domains": ["Char", "Room", "Inventory", "Comm"] }}
}}

// pong — Heartbeat response
{{
  "id": "msg_052", "type": "pong", "ts": 1721300002100, "seq": 52,
  "payload": {{ "echo": 1721300002000 }}
}}

Client → Server Messages:

// command — Player command input
{{
  "id": "msg_c010", "type": "command", "ts": 1721300001000, "seq": 10,
  "payload": {{
    "text": "north"
  }}
}}

// gmcp — GMCP data from client
{{
  "id": "msg_c011", "type": "gmcp", "ts": 1721300001100, "seq": 11,
  "payload": {{
    "package": "Core.Supports.Set",
    "data": ["Char.Vitals 1", "Room.Info 1"]
  }}
}}

// complete — Tab completion request
{{
  "id": "msg_c012", "type": "complete", "ts": 1721300001200, "seq": 12,
  "payload": {{
    "partial": "nor"
  }}
}}

// ping — Heartbeat
{{
  "id": "msg_c013", "type": "ping", "ts": 1721300001300, "seq": 13,
  "payload": {{}}
}}

Field name mapping from legacy server fields:

Server legacy field Normalized field Notes
message payload.text All text content uses text in payload
prompt payload.text (in type: "prompt") Prompt string
command payload.text (in type: "command") Command text
content payload.text Deprecated alias; server MUST send text
package + data payload.package + payload.data GMCP data nested in payload

8.2 REST API Usage

The client uses REST endpoints for non-real-time operations. See §2.5 for implementation status of each endpoint.

⚠️ Server implementation required: Most endpoints below do not exist yet. The current server only exposes /api/v1/health and /api/v1/status. All auth and character endpoints must be implemented as part of the server-side work track (see Phase 0-1 in §14).

Endpoint Method Purpose Status
/api/v1/health GET Health check, used for server status indicator ✅ EXISTS
/api/v1/status GET Server statistics (player count, uptime) ✅ EXISTS
/api/v1/auth/login POST Initial authentication, returns JWT in HttpOnly cookies ❌ NEEDS IMPLEMENTATION
/api/v1/auth/refresh POST Refresh access token via refresh token rotation ❌ NEEDS IMPLEMENTATION
/api/v1/auth/logout POST Invalidate session and revoke tokens ❌ NEEDS IMPLEMENTATION
/api/v1/auth/me GET Validate existing session from cookies ❌ NEEDS IMPLEMENTATION
/api/v1/characters GET List player's characters ❌ NEEDS IMPLEMENTATION
/api/v1/characters POST Create new character ❌ NEEDS IMPLEMENTATION
/api/v1/characters/:id GET Get character details ❌ NEEDS IMPLEMENTATION
/api/v1/settings GET Retrieve saved user settings ❌ NEEDS IMPLEMENTATION
/api/v1/settings PUT Save user settings ❌ NEEDS IMPLEMENTATION

8.3 GMCP Subscription

Upon WebSocket connection, the client negotiates GMCP support:

Supported GMCP Packages:

Package Version Description
Core 1 Basic protocol negotiation
Char 1 Character identity and status
Char.Vitals 1 HP, MP, SP, XP values
Char.Items 1 Inventory management
Char.Status 1 Status effects and conditions
Room 1 Room information and exits
Room.Info 1 Detailed room data with coordinates
Comm 1 Communication channels
Comm.Channel 1 Channel-specific messaging
Dialogue 1 NPC dialogue start, response, typing, end

8.3.1 Content Pack GMCP Extensibility

Content packs can declare custom GMCP packages beyond the core set above. The mechanism works as follows:

  1. Server-side: Each ContentPack may implement an optional get_gmcp_packages() method returning a list of GMCP package descriptors (name, version, schema). The GameEngine collects all registered GMCP packages from loaded packs and includes them in the hello handshake's gmcpPackagesAvailable list.

  2. Client-side: The client reads gmcpPackagesAvailable from the hello payload and subscribes to all recognized packages. Unrecognized packages from content packs are routed through the GMCPRouter as gmcp:PackageName events on the MessageBus, making them available to client plugins (§12.1) without requiring client code changes.

  3. Plugin integration: A client plugin can declare GMCP subscriptions in its manifest's permissions array (e.g., "read:gmcp:Quest.List"). The PluginAPI.subscribe() method allows plugins to listen for custom GMCP packages via the MessageBus.

# Example: Content pack declaring custom GMCP packages
class ClassicRPGContentPack:
    def get_gmcp_packages(self) -> list[GMCPPackageDescriptor]:
        return [
            GMCPPackageDescriptor(name="Quest", version=1, sub_packages=["List", "Update"]),
            GMCPPackageDescriptor(name="Craft", version=1, sub_packages=["Recipe", "Progress"]),
        ]

9. Authentication Flow

Two auth systems: The admin frontend (§CLAUDE.md "Admin API") and the player client use separate authentication flows because they serve different trust levels and session models. The admin API uses POST /admin/auth/login with admin-scoped JWTs and RBAC. The player client uses POST /api/v1/auth/login with player-scoped JWTs. These are intentionally separate because: - Admin tokens grant destructive server operations; player tokens grant in-game actions only. - Admin sessions have shorter expiry (1 hour) and require re-authentication more frequently. - Merging would require complex scope intersection logic with minimal benefit.

Cookie isolation: Admin cookies use path /admin/ and player cookies use path /api/v1/ and /ws/. This prevents cookies from colliding or being sent to the wrong endpoint. Both use HttpOnly, Secure, and SameSite=Strict.

9.1 Authentication Flow

The web client always uses REST-first authentication. There is no WebSocket-only auth fallback — the web client is not a traditional MUD client and does not need text-based login.

  1. User loads /play/
  2. Client checks for existing session via GET /api/v1/auth/me
  3. If no session, display login form
  4. User submits credentials to POST /api/v1/auth/login
  5. Server validates credentials, returns JWT in HttpOnly cookie (path /api/v1/)
  6. Client receives character list in response
  7. User selects character
  8. Client opens WebSocket to /ws/game/v2
  9. WebSocket connection includes cookie automatically (path includes /ws/)
  10. Server validates cookie, attaches session to connection
  11. Server sends hello handshake message (see §8.1)
  12. Client sends init message with characterId, GMCP subscriptions, and optional resumeToken/lastSeq for session resume — this binds the selected character to the WebSocket connection
  13. Server validates characterId belongs to the authenticated player, loads character state
  14. Server sends initial GMCP state (vitals, room, inventory)
  15. Game begins

9.2 Auth State Machine

INITIAL
CHECKING ──(no session)──► LOGIN_FORM
  │                            │
  │(valid session)             │(submit credentials)
  │                            ▼
  │                      AUTHENTICATING
  │                            │
  │                  ┌─────────┤
  │                  │(fail)   │(success)
  │                  ▼         ▼
  │            LOGIN_FORM  CHARACTER_SELECT
  │                            │
  ▼                            │(select character)
  │                            ▼
  │                  CONNECTING_WEBSOCKET
  │                            │
  ├────────────────────────────┤
  │                            │(hello received)
  ▼                            ▼
                           IN_GAME
                       (connection lost)
                         RECONNECTING
                        ┌──────┴──────┐
                   (recovered)    (max retries)
                        ▼             ▼
                     IN_GAME     LOGIN_FORM

States: - INITIAL: App loaded, no state determined - CHECKING: Validating existing session via /api/v1/auth/me - LOGIN_FORM: Displaying login form to user - AUTHENTICATING: REST login request in flight - CHARACTER_SELECT: Authenticated, choosing character - CONNECTING_WEBSOCKET: Opening WebSocket, sending init with characterId, waiting for hello - IN_GAME: Fully connected and playing - RECONNECTING: WebSocket dropped, attempting recovery

9.3 JWT + HttpOnly Cookies

  • Access token: 15 minute expiry, HttpOnly, Secure, SameSite=Strict, Path=/api/v1/
  • Refresh token: 7 day expiry, HttpOnly, Secure, SameSite=Strict, Path=/api/v1/auth/refresh
  • WebSocket auth cookie: mirrors access token, Path=/ws/ (so it's sent on WebSocket upgrade)
  • Token rotation on refresh for security
  • Family tracking for refresh token reuse detection

Cookie paths ensure player auth cookies are never sent to /admin/ endpoints and vice versa. The WebSocket auth cookie is a copy of the access token set with Path=/ws/ so the browser includes it in the WebSocket upgrade request to /ws/game/v2.

9.4 Session Reconnection and State Reconciliation

When a WebSocket connection drops: 1. Client detects disconnection 2. Client shows ReconnectionOverlay with attempt count, elapsed time, manual retry button, and "Return to Login" option 3. Exponential backoff: 1s, 2s, 4s, 8s, 16s (capped) with random jitter 4. On reconnect, cookie is automatically included 5. Server validates session is still active 6. Server sends full state snapshot across all domains: - Char.Vitals — current HP/MP/SP/XP - Char.Status — active status effects - Char.Items.List — full inventory - Room.Info — current room with exits, players, NPCs, items - Comm.Channel.List — current channel subscriptions - Any other domain-specific state 7. Server sends sync_complete message 8. Client hides "Synchronizing..." overlay 9. Any queued commands are flushed (with user confirmation if >0 queued) 10. Client resumes normal operation

The sync_complete message prevents the client from processing queued commands against stale state. The overlay blocks user input until reconciliation finishes.


10. Security

10.1 Content Sanitization

Mandatory <SafeHTML> component — no ad-hoc DOMPurify:

All server-originated text rendered as HTML outside of xterm.js (chat panels, tooltips, NPC dialogue) MUST use the <SafeHTML> component. Direct use of dangerouslySetInnerHTML is banned via ESLint rule — the linter rejects any file containing dangerouslySetInnerHTML outside of SafeHTML.tsx itself.

// components/common/SafeHTML.tsx — the ONLY place dangerouslySetInnerHTML is allowed
import DOMPurify from "dompurify";

const ALLOWED_TAGS = ["span"];
const ALLOWED_ATTR = ["style"];
const ALLOWED_CSS = ["color", "background-color", "font-weight", "font-style", "text-decoration"];

const purifyConfig = {{
  ALLOWED_TAGS,
  ALLOWED_ATTR,
  ALLOWED_CSS_PROPERTIES: ALLOWED_CSS,
}};

interface SafeHTMLProps {{
  html: string;
  className?: string;
  as?: keyof JSX.IntrinsicElements;
}}

export function SafeHTML({{ html, className, as: Tag = "span" }}: SafeHTMLProps) {{
  const clean = DOMPurify.sanitize(html, purifyConfig);
  return <Tag className={{className}} dangerouslySetInnerHTML={{{{ __html: clean }}}} />;
}}

ESLint rule (.eslintrc.cjs):

// Ban dangerouslySetInnerHTML everywhere except SafeHTML.tsx
"no-restricted-syntax": ["error", {{
  selector: "JSXAttribute[name.name='dangerouslySetInnerHTML']",
  message: "Use <SafeHTML> component instead of dangerouslySetInnerHTML. See §10.1.",
}}]
// Override in SafeHTML.tsx via eslint-disable comment

xterm.js is exempt because it renders to a canvas/DOM structure it fully controls and does not interpret HTML from server text.

10.2 Content Security Policy (CSP)

The /play/ page MUST include a strict CSP header:

Content-Security-Policy:
  default-src 'none';
  script-src 'self';
  style-src 'self' 'nonce-{random}';
  connect-src 'self' wss://{host};
  img-src 'self' data:;
  font-src 'self';
  frame-src 'none';
  base-uri 'none';
  form-action 'self';

Notes: - No unsafe-eval for scripts — Vite production builds do not require it. - unsafe-inline is NOT used for styles. Tailwind CSS v3 compiles to static CSS at build time — it does not inject inline styles at runtime. The earlier justification ("Tailwind runtime needs inline styles") was incorrect; Tailwind's JIT compiler runs at build time, not in the browser. Instead, a per-request nonce ('nonce-{random}') is used for any styles that must be injected dynamically (e.g., xterm.js theme overrides). The server generates a cryptographic nonce per response and injects it into both the CSP header and the relevant <style nonce="..."> tags. - connect-src restricts WebSocket connections to same origin only. - frame-src 'none' prevents clickjacking via iframes.

Implementation: The nonce is generated server-side in the FastAPI route handler for /play/ and passed to the HTML template via a template variable. Vite's html plugin can be configured to inject the nonce into <style> and <link> tags during production builds.

10.3 Cross-Site WebSocket Hijacking (CSWSH) Protection

The server MUST validate the Origin header on WebSocket upgrade requests:

# Server-side (maid_engine/net/web/server.py)
async def websocket_connect(websocket: WebSocket):
    origin = websocket.headers.get("origin", "")
    allowed_origins = settings.web.allowed_origins  # e.g., ["https://maid.example.com"]
    if origin not in allowed_origins:
        await websocket.close(code=4003, reason="Origin not allowed")
        return

This prevents malicious pages from opening WebSocket connections to the MAID server using the victim's cookies.

10.4 Additional Security Measures

  • Rate limiting: Client-side 10 commands/second limit. Server enforces its own rate limit.
  • Token storage: JWT stored in HttpOnly, Secure, SameSite=Strict cookies only. Never in localStorage.
  • CSRF: All REST API calls include X-CSRF-Token header. The CSRF token is obtained from GET /api/v1/auth/csrf which returns a token in a non-HttpOnly cookie (XSRF-TOKEN). The client reads this cookie via JavaScript and sends it as the X-CSRF-Token request header. The server validates that the header matches the cookie (double-submit cookie pattern). The CSRF token is rotated on each login. This is consistent with the admin frontend's CSRF approach.
  • Input validation: All user input is validated before sending to server. Command length capped at 2000 characters.

10.5 WebSocket Frame Size Limits

Payload size limits are enforced server-side only. The client does NOT reject incoming messages based on size — legitimate game messages (state snapshots during reconnection, large inventory lists, batch GMCP updates) routinely exceed small limits and would be silently dropped, causing state corruption.

Server-side configuration:

# net/web/server.py — WebSocket server configuration
WEBSOCKET_MAX_MESSAGE_SIZE = 256 * 1024  # 256KB max frame size

# Applied via websockets library or Starlette config:
# websockets: max_size=WEBSOCKET_MAX_MESSAGE_SIZE
# Starlette:  WebSocket(scope, max_message_size=WEBSOCKET_MAX_MESSAGE_SIZE)
Direction Max Frame Size Enforcement Rationale
Server → Client 256KB Server-side (configuration) State snapshots and batch GMCP easily reach 10-50KB; 256KB provides headroom for large inventories and world state
Client → Server 8KB Server-side (reject + close) Client messages are commands (< 2KB) and GMCP subscriptions. 8KB is generous for any legitimate client message

The server closes connections that send frames exceeding the limit with WebSocket close code 1009 (Message Too Big). The client's 2000-character command length cap (§10.4) provides an additional layer of protection on outbound messages.

Why not client-side enforcement: The original 4KB client-side check would silently drop legitimate server messages — reconnection state snapshots (§9.4), full inventory lists (Char.Items.List), and gmcp_batch room transitions commonly exceed 4KB. Client-side size checking provides no security benefit since the attacker controls the server in a compromised scenario. Server-side enforcement protects the server from malicious clients.


11. Reliability

11.1 WebSocket Heartbeat / Keep-Alive

The client sends a ping message every 30 seconds and expects a pong response within 10 seconds.

Parameter Value
Ping interval 30 seconds
Pong timeout 10 seconds
On pong miss Set status to "stale"
On 2nd consecutive miss Force reconnect
Latency derivation pong.ts - ping.ts (RTT)

The "stale" connection status is displayed to the user as a warning indicator. The stale state does not immediately reconnect — it allows one more ping/pong cycle before forcing reconnection. Latency derived from ping/pong RTT is displayed in the status bar.

11.2 Page Lifecycle Handling

Browser tab visibility and unload events are handled to maintain connection integrity:

Event Action
visibilitychange → hidden Pause heartbeat timers. Do NOT disconnect.
visibilitychange → visible Immediately send heartbeat. If pong received, resume. If not, reconnect.
beforeunload Send WebSocket close frame with code 4000 ("client navigating away"). The server detects the close and cleans up the session.

Note: navigator.sendBeacon() is NOT used for disconnect notification because no corresponding REST endpoint exists and adding one solely for this purpose is unnecessary. The WebSocket onclose event on the server side is sufficient for session cleanup. If the WebSocket close frame is lost (e.g., browser crash), the server's heartbeat timeout (no ping received for 60s) handles cleanup.

11.3 JSON.parse Error Handling

All JSON.parse calls on WebSocket messages are wrapped in try/catch:

  • On parse error: log error with raw message data, increment error counter
  • Error window: 10 seconds
  • Threshold: >5 parse errors within window triggers forced reconnect
  • After reconnect: error counter resets
  • validateServerMessage() type guard rejects unknown message types with a console warning

11.4 GMCP Message Batching

The server can send gmcp_batch messages for atomic multi-updates (e.g., room change triggers Room.Info + Room.Players + Char.Vitals simultaneously):

// Server sends one gmcp_batch instead of 3 separate gmcp messages
{{ type: "gmcp_batch", payload: {{ messages: [...] }} }}

The GMCPRouter processes batches synchronously — React 18's automatic batching coalesces all synchronous setState calls within the same event handler into a single render. No requestAnimationFrame wrapper is needed. This prevents flickering during rapid state changes (e.g., entering a new room) without the complexity and testability issues of rAF.

For vitals-heavy combat sequences, the client additionally coalesces vitals updates within a 100ms debounce window before triggering a React re-render.

11.5 Offline Command Queue

Commands entered while disconnected or reconnecting are queued with strict limits:

Parameter Value
Max queued commands 20
Command TTL 30 seconds
Flush behavior Server sends full state snapshot first, then client shows "N commands queued — Send All / Discard?" confirmation
Stale command handling Commands older than 30s are discarded before flush
Visual feedback Queued commands shown with pending/confirmed/failed status icons

Commands are rendered optimistically in the terminal with a "pending" indicator (dimmed text, spinner icon). After server acknowledgment (ack), the indicator updates to confirmed. On nack, the command shows as failed with the rejection reason.

11.6 Backpressure and Session Resume

When a client disconnects, the server buffers outbound messages for a configurable window to support seamless reconnection:

Parameter Value
resumeToken Opaque token issued in hello, sent back in init on reconnect
Server buffer size Last 200 messages per session
Buffer TTL 5 minutes (after disconnect, buffer is discarded)
seq tracking Client tracks last received seq; on reconnect, sends lastSeq in init
Replay behavior Server replays all buffered messages with seq > lastSeq, then sends sync_complete

Protocol flow on reconnect:

Client                          Server
  |--- WebSocket connect -------->|
  |<-- hello (resumeToken) -------|
  |--- init (resumeToken,         |
  |         lastSeq: 147) ------->|
  |                                |  (validate resumeToken, find buffer)
  |<-- replay msg seq=148 --------|
  |<-- replay msg seq=149 --------|
  |<-- ... (buffered messages) ---|
  |<-- sync_complete -------------|
  |                                |  (client flushes offline queue)
  |--- queued command 1 --------->|
  |--- queued command 2 --------->|

If the resumeToken is invalid or expired, the server sends a full state snapshot instead of replaying buffered messages. The client handles both paths transparently — sync_complete is the signal to resume regardless of which path was taken.

11.7 xterm.js Security Configuration

The xterm.js terminal MUST be configured with restrictive security settings to prevent escape sequence attacks from malicious server output:

const terminal = new Terminal({{
  // Disable OSC 52 clipboard access — prevents server from reading/writing clipboard
  allowProposedApi: false,

  // Restrict window manipulation sequences
  windowOptions: {{
    setWinSizeChars: false,
    setWinSizePixels: false,
    getWinSizeChars: false,
    getWinSizePixels: false,
    getWinPosition: false,
    getIconTitle: false,
    getWinTitle: false,
    pushTitle: false,
    popTitle: false,
    setWinLines: false,
  }},

  // Disable link handling (prevents terminal link injection attacks)
  linkHandler: null,
}});

Rationale: A compromised or malicious MUD server could send terminal escape sequences that manipulate the clipboard (OSC 52), resize the browser window, or inject clickable links that point to phishing URLs. These features have no legitimate use in a MUD context and are disabled by default.

11.8 Paste Protection (Anti-Pastejacking)

Multi-line paste operations into the command input are intercepted to prevent pastejacking attacks (where copied text contains hidden commands):

Condition Action
Single-line paste Allow without confirmation
Multi-line paste (2-5 lines) Show preview modal: "You are pasting N lines. Each line will be sent as a separate command." with Send All / Cancel
Multi-line paste (>5 lines) Show preview modal with scrollable preview of all lines, require explicit confirmation
Paste containing control characters Strip non-printable characters (except newlines), show warning

The paste preview modal displays each line that will be sent as a command, allowing the player to review before sending. This prevents attacks where a malicious website puts hidden commands (e.g., drop all\ngive gold to attacker) into the clipboard.


12. Client Extensibility

12.1 Client Plugin System

The client supports a plugin system for community-contributed UI extensions.

Isolation model: Explicit trust with capability-scoped API. Plugins run in the main thread (not Web Workers or iframes) because they need synchronous access to React's rendering pipeline for panel registration. This is an explicit trust model — plugins are reviewed before inclusion in the registry (see maid-registry). The tradeoff: untrusted plugins could access the full DOM. Mitigation: plugins receive only a capability-scoped PluginAPI object; they never receive direct references to stores, the MessageBus, or the WebSocket. The PluginAPI enforces read-only access gated by declared permissions.

Future consideration: If community plugin volume grows to the point where manual review becomes infeasible, migrate to Web Worker isolation with a postMessage-based PluginAPI proxy. This would require making all plugin API calls async. The current PluginAPI interface is designed to be forward-compatible with this change (all methods could become Promise-returning without breaking the contract).

interface ClientPlugin {{
  manifest: {{
    id: string;           // e.g., "minimap-overlay"
    name: string;
    version: string;
    author: string;
    description: string;
    permissions: string[]; // e.g., ["read:room", "read:vitals"]
  }};

  // Lifecycle hooks
  onLoad(api: PluginAPI): void;
  onUnload(): void;
}}

interface PluginAPI {{
  // Extension points
  registerPanel(config: PanelConfig): void;
  registerCommand(name: string, handler: CommandHandler): void;

  // Read-only access to game state (scoped by permissions).
  // Calls to subscribe() for events outside declared permissions throw an error.
  subscribe(event: string, handler: (data: unknown) => void): () => void;

  // UI integration
  showNotification(text: string, level: "info" | "warning"): void;
}}

Plugins cannot send commands directly — only register handlers that users must explicitly trigger. The PluginAPI does not expose MessageBus, store references, or WebSocket — it is the only interface plugins interact with.

12.2 Internationalization (i18n)

All UI labels use react-i18next translation keys. No hardcoded user-facing strings.

// Usage in components
const {{ t }} = useTranslation();
<button>{{t("panel.inventory.equip")}}</button>

// Locale files lazy-loaded per language
// public/locales/en/translation.json
// public/locales/es/translation.json
  • Default language: English
  • Locale files are lazy-loaded when language is changed
  • Game text from the server is NOT translated (it's author-controlled content)
  • Only client UI chrome is translated: panel headers, buttons, tooltips, error messages

13. Build and Deployment

13.1 Vite Configuration

The Vite configuration includes: - React plugin - Base path set to /play/ - Path alias @ pointing to ./src - Manual chunks for vendor code splitting - Proxy configuration for development - Vitest configuration for testing

13.2 Package Structure

packages/maid-engine/player_frontend/
+-- public/
|   +-- favicon.ico
|   +-- manifest.json
+-- src/
|   +-- components/
|   |   +-- common/
|   |   +-- auth/
|   |   +-- game/
|   |   +-- panels/
|   |   +-- dialogue/
|   |   +-- layouts/
|   +-- hooks/
|   +-- stores/
|   |   +-- character-store.ts
|   |   +-- room-store.ts
|   |   +-- inventory-store.ts
|   |   +-- map-store.ts
|   |   +-- chat-store.ts
|   |   +-- dialogue-store.ts
|   |   +-- connection-store.ts
|   |   +-- settings-store.ts
|   +-- lib/
|   |   +-- gmcp-router.ts
|   |   +-- message-bus.ts
|   |   +-- sanitize.ts
|   |   +-- api/
|   |   +-- websocket/
|   +-- types/
|   +-- styles/
|   +-- pages/
|   +-- locales/
|   |   +-- en/
|   |   +-- es/
|   +-- test/
|   +-- App.tsx
|   +-- main.tsx
+-- index.html
+-- package.json
+-- tsconfig.json
+-- tailwind.config.js
+-- vite.config.ts

13.3 Static File Serving

The player client is served alongside the admin frontend via FastAPI. If the built frontend exists, it's mounted at /play. Otherwise, the inline client fallback is used for development.

FastAPI mount order matters for SPA routing. Routes must be registered in this order to avoid catch-all routes shadowing API endpoints:

# net/web/server.py — mount order
app.include_router(api_router, prefix="/api/v1")       # 1. REST API
app.include_router(admin_router, prefix="/admin")       # 2. Admin API + admin SPA
app.mount("/ws", websocket_app)                         # 3. WebSocket endpoints

# 4. Player client SPA — catch-all for /play/* routes
if player_frontend_dist.exists():
    app.mount("/play", StaticFiles(directory=player_frontend_dist, html=True))
else:
    # Fallback: inline client
    @app.get("/play")
    async def inline_client(): ...

# 5. Root redirect or landing page (last)
@app.get("/")
async def root():
    return RedirectResponse("/play/")

The html=True parameter in StaticFiles enables SPA catch-all routing: any request to /play/foo/bar that doesn't match a static file returns index.html, allowing React Router to handle client-side routing. This is critical for deep links and browser refresh on SPA routes.

13.4 Bundle Size Budget

Note: xterm.js (~80KB gzipped) is required for first render and cannot be lazy-loaded, so it is included in the initial bundle calculation. The NFR-1 target of <250KB initial applies to everything needed before the terminal is interactive.

Chunk Budget Contents Loading
initial + terminal < 180KB Core React, router, xterm.js, connection, initial UI Eager (required for first render)
react-vendor < 50KB React, ReactDOM, React Router Eager (included in initial)
state < 30KB Zustand, React Query Eager (included in initial)
panels < 40KB Side panels (stats, inventory, room, chat) Lazy (loaded after game connect)
dialogue < 20KB NPC dialogue UI Lazy (loaded on first talk command)
i18n < 10KB per locale Translation bundles Lazy (loaded on language change)
Total < 330KB All chunks combined

The initial bundle (everything before lazy chunks) is ~180KB gzipped, within the 250KB NFR-1 target. The previous budget incorrectly listed xterm.js as a separate lazy-loaded chunk — it must be in the initial bundle since the terminal is the primary UI.

13.5 CDN Considerations

For production deployments: - Content-hashed filenames for cache busting - Long cache headers for hashed assets - No-cache for index.html - Compression enabled


14. Implementation Plan

Two parallel work tracks: This plan has a Client Track (frontend development) and a Server Track (backend capabilities required by this design). Both tracks must be coordinated — the client cannot proceed past Phase 2 without the corresponding server-side protocol work. Server tasks are called out explicitly in each phase.

Phase 0: Infrastructure (Week 0 — pre-sprint)

Client Track: - Create npm workspace structure: packages/maid-engine/package.json as workspace root declaring admin_frontend/, player_frontend/, and shared_frontend/ - Extract shared code from admin frontend into @maid/shared package: apiRequest<T>(), createWebSocket(), auth store slice, base components (Button, Card, Input, Modal), Tailwind preset, API type definitions, SafeHTML component - Update admin frontend to consume @maid/shared via "workspace:*" — verify admin frontend still builds and passes tests - Configure ESLint rule banning dangerouslySetInnerHTML outside SafeHTML.tsx

Server Track: - Implement WebSocketProtocolAdapter abstraction (§2.5) to support both v1 (flat JSON) and v2 (envelope) wire formats in net/web/server.py - Implement /ws/game/v2 handler with envelope format (id, type, ts, seq, payload) - Implement hello/init handshake on v2 WebSocket connect - Add Origin header validation on all WebSocket upgrade requests (§10.3) - Configure server-side WebSocket max frame size: 256KB inbound from server, 8KB inbound from client (§10.5) - Add CSP headers with nonce generation to /play/ route (§10.2)

Phase 1: Foundation (Weeks 1-3)

Week 1: Project Setup + Auth UI (Client) - Initialize Vite + React + TypeScript project in packages/maid-engine/player_frontend/ - Configure Tailwind CSS using shared preset from @maid/shared - Set up testing infrastructure (Vitest, Testing Library) - Import base components from @maid/shared - Build LoginPage with form (REST-first, no WebSocket auth fallback) - Implement REST auth API integration using @maid/shared auth utilities - Create CharacterSelectPage with character list - Implement SettingsStore with localStorage persistence

Week 1: REST Auth (Server) - Implement POST /api/v1/auth/login — JWT in HttpOnly cookies (path /api/v1/) - Implement POST /api/v1/auth/refresh — refresh token rotation - Implement POST /api/v1/auth/logout — session invalidation - Implement GET /api/v1/auth/me — session validation from cookies - Implement GET /api/v1/auth/csrf — CSRF token distribution (double-submit cookie)

Week 2: Core Terminal (Client) - Build TerminalOutput component using xterm.js (handles ANSI + virtual scrolling) - Create CommandInput with history navigation - Set up WebSocket connection hook with injectable MessageBus (§5.3) - Implement ConnectionStore with reconnect logic and heartbeat - Add session persistence and token refresh handling

Week 2: Character & Settings API (Server) - Implement GET /api/v1/characters — list player's characters - Implement POST /api/v1/characters — create character - Implement GET /api/v1/characters/:id — character details - Implement GET /PUT /api/v1/settings — user settings persistence

Week 3: Basic Game Loop (Client) - Integrate WebSocket message routing via MessageBus - Implement CharacterStore, RoomStore, InventoryStore, MapStore, DialogueStore - Create basic desktop layout (GameLayout with single-column initial) - Handle text, system, error message types - Add loading and error states

Week 3: Protocol (Server) - Implement ping/pong heartbeat in v2 handler - Implement ack/nack command acknowledgment with requestId correlation - Implement completion response for tab-complete requests - Add per-connection seq counter to all outbound messages - Implement characterId binding in init handler

Phase 2: GMCP Integration (Weeks 4-6)

Week 4: GMCP Protocol (Client) - Implement GMCP negotiation via hello/init handshake - Create GMCPRouter as plain TypeScript service class (NOT a React component) - Build MessageBus subscribers for Char.Vitals, Room.Info - Add development tools for GMCP debugging

Week 4: Batch & Sync (Server) - Implement gmcp_batch message type for atomic multi-GMCP delivery - Implement session resume: generate resumeToken in hello, validate on reconnect - Implement server-side message buffering: buffer last N messages per session during disconnect - Implement sync_complete flow: on resume, replay buffered messages then send sync_complete

Week 5: Character Panels (Client) - Create CharacterStatsPanel with vital bars - Implement StatusEffects display - Build InventoryPanel with list view - Add equipment slots visualization - Implement drag-and-drop for equipment

Week 6: Room Panels (Client) - Create RoomInfoPanel with description - Build exit buttons with direction icons - Implement player/NPC/item lists - Add room name to header bar

Phase 3: Map and Chat (Weeks 7-9)

Week 7: Map Visualization - Create MapPanel with SVG grid - Implement coordinate-based room positioning - Add fog of war for unvisited rooms - Build zoom and pan controls - Create map legend

Week 8: Chat System - Implement ChatStore with channels - Create ChatPanel with tabs - Build unread message badges - Add channel join/leave functionality - Implement message scrollback

Week 9: NPC Dialogue - Create NPCDialogueOverlay component (side panel, not modal — see §7.6) - Build chat bubble UI - Implement typing indicator - Add suggested responses - Integrate with AI dialogue GMCP

Phase 4: Polish and Mobile (Weeks 10-11)

Week 10: Responsive Layout and Mobile - Verify GameLayout responsive behavior across breakpoints - Build mobile macro bar with swipeable banks - Implement Virtual D-Pad for mobile movement (§5.1, §6.1) - Implement swipe gestures for mobile panel switching - Build QuestLogPanel (if quest GMCP available) - PWA manifest and basic service worker for offline asset caching

Week 11: Security Hardening and Polish - Verify <SafeHTML> component is used for all non-terminal HTML rendering - Verify CSP headers with nonce-based styles (§10.2) - Configure xterm.js security settings (§11.7) - Implement paste protection modal (§11.8) - Verify CSRF double-submit cookie flow - Test across devices

Phase 5: Testing and Launch (Weeks 12-13)

Week 12: Testing - Write unit tests for stores and utilities (with injectable MessageBus) - Create component tests for all panels - Implement E2E tests with Playwright - Run accessibility audits with axe-core - Performance testing and optimization

Week 13: Launch Preparation - Final bug fixes from testing - Documentation updates - Production build optimization - Deployment configuration - Soft launch to beta testers

Gantt Chart

Week    0    1    2    3    4    5    6    7    8    9   10   11   12   13
        |----|----|----|----|----|----|----|----|----|----|----|----|----|----|

CLIENT TRACK:
Phase 0 ====
        [Shared pkg]

Phase 1      ================
             [Auth+Setup][Terminal][Loop]

Phase 2                   ====================
                          [GMCP] [Char] [Room]

Phase 3                                  ====================
                                         [Map]  [Chat][NPC]

Phase 4                                                ============
                                                       [Mobile][Security]

Phase 5                                                          ========
                                                                 [Test][Launch]

SERVER TRACK:
Phase 0 ====
        [v2 proto, Origin, CSP]

Phase 1      ============
             [REST auth+CSRF][Characters][Settings][ping/pong/ack]

Phase 2                   ========
                          [Batch][Resume][Sync][Buffer]

Milestones:
  Week 0:  * Shared package extracted, v2 protocol handler scaffolded
  Week 1:  * Auth UI playable (login → character select → connect)
  Week 3:  * Basic playable client (server has REST auth + v2 protocol)
  Week 6:  * Full GMCP integration (server has batch + resume)
  Week 9:  * Feature complete
  Week 11: * Mobile ready, security hardened
  Week 13: * Production release

15. Performance Considerations

15.1 Bundle Optimization

Code Splitting Strategy: - Lazy load heavy components (MapPanel, InventoryPanel, NPCDialogue) - Use Suspense with skeleton fallbacks - Manual chunks for vendor code

Tree Shaking: - Only import specific functions from libraries - Use barrel exports carefully to avoid pulling in unused code - Analyze bundle with vite-bundle-analyzer

15.2 Rendering Optimization

Memoization Strategy: - Memoize expensive components with React.memo - Use useCallback for event handlers - Use useMemo for derived data - Avoid creating new objects in render

Virtual Scrolling: - TerminalOutput uses xterm.js built-in virtual scrolling for 10,000+ line scrollback - Only visible lines are in DOM - react-window is used for non-terminal lists (inventory, chat) only

15.3 WebSocket Optimization

Message Batching: - Server batches rapid updates (< 50ms apart) into single message - Client debounces GMCP vitals updates (100ms) - Reduces re-renders during combat

Compression: - Consider WebSocket compression for verbose GMCP data - Per-message deflate for text-heavy sessions

15.4 Memory Management

Scrollback Limits: - Default 10,000 lines - Oldest lines removed when limit reached - Configurable per user preference - Memory usage: ~100KB for 10,000 short lines

Map Data: - Only store visited rooms in client - Prune rooms > 100 away from current position - Lazy load room details on hover

15.5 Network Optimization

Reconnection Strategy: - Exponential backoff prevents server flood - Jitter added to prevent thundering herd - Offline queue prevents command loss


16. Testing Strategy

16.1 Unit Testing

Focus on business logic and utilities: - MessageBus event routing - GMCPRouter dispatch logic - Store actions and selectors - Utility functions - Type validators and message guards

16.2 Component Testing

Test components in isolation: - Render with various props - User interaction simulation - Accessibility verification - Snapshot testing for UI stability

16.3 Integration Testing

Test store and component interaction: - GMCP message handling - WebSocket connection lifecycle - Authentication flow - State synchronization

16.4 E2E Testing

Full user flow tests with Playwright: - Login and character selection - Basic gameplay loop - Reconnection scenarios - Mobile viewport testing

16.5 Accessibility Testing

Automated and manual accessibility checks: - axe-core integration - Keyboard navigation testing - Screen reader testing - Color contrast verification


17. Risks and Mitigations

ID Risk Probability Impact Mitigation
R-1 ANSI parser performance issues with rapid output Medium High xterm.js handles ANSI parsing natively on the main thread with hardware-accelerated canvas rendering. Mitigate with output throttling (coalesce writes within 16ms frames) and scrollback buffer limits. Web Workers are not applicable — xterm.js requires DOM access.
R-2 WebSocket reconnection fails repeatedly Low Critical Implement exponential backoff with jitter, fall back to polling status endpoint
R-3 Mobile browser compatibility issues Medium Medium Test early on real devices, use feature detection, provide graceful degradation
R-4 Bundle size exceeds budget Medium Medium Continuous monitoring with bundle analyzer, aggressive code splitting
R-5 GMCP protocol changes break client Low High Version negotiation, backward compatibility layer, feature flags
R-6 Memory leaks in long sessions Medium High Scrollback limits, proper cleanup in useEffect, periodic profiling
R-7 Screen reader compatibility issues Medium High Early accessibility testing, user feedback from accessibility community
R-8 Touch gesture conflicts with browser Medium Medium Use established gesture library, test across mobile browsers, provide alternatives

18. Open Questions

  1. Should the player client share a build system with the admin frontend, or remain fully separate?
  2. Resolved: Phase 0 creates an npm workspace with @maid/shared package. The two frontends share code but build independently. See §4.2 and §14 Phase 0.

  3. What is the maximum reasonable bundle size for mobile users on slow connections?

  4. Current budget is 250KB initial, but may need adjustment based on user feedback

  5. Should we support offline mode with service workers?

  6. Resolved: A minimal service worker is added in Phase 4 (Week 10) for asset caching only — it caches the static bundle (JS, CSS, fonts, images) so the app shell loads instantly on repeat visits and survives brief network interruptions. The service worker does NOT cache game data or API responses. Full offline command composition is deferred as the complexity of cache invalidation and state sync outweighs the benefit for an inherently online game. The manifest.json enables Add-to-Home-Screen on mobile.

  7. How should we handle MXP (MUD eXtension Protocol) content?

  8. Some MUDs use MXP for clickable links and embedded images
  9. Could be future enhancement if demand exists

  10. Should macros support conditional logic?

  11. Resolved: Keep simple for now. Macro expansion is limited to 5 recursion depth and 20 commands per expansion. Advanced scripting deferred to client plugin system (§12.1).

  12. What analytics should we collect for UX improvement?

  13. Session duration, feature usage, error rates
  14. Must balance insights with privacy

  15. Should the map persist across sessions?

  16. Currently cleared on disconnect
  17. Could store in localStorage or server-side

  18. How should we handle server-side ANSI color themes?

  19. Server may send colors that conflict with client theme
  20. Options: override, blend, or user preference

  21. What's the accessibility impact of rapid text updates during combat?

  22. Resolved: Combat messages are aggregated every 3-5 seconds for screen readers. See §6.4 Accessibility.

Appendix A: Detailed TypeScript Interfaces

A.1 Complete GMCP Type Definitions

// GMCP Core Types
interface GMCPMessage {{
  type: "gmcp";
  package: string;
  data: unknown;
}}

// Character-related GMCP
interface GMCPCharVitals {{
  hp: number;
  maxHp: number;
  mp: number;
  maxMp: number;
  sp: number;
  maxSp: number;
  xp: number;
  xpToLevel: number;
  level: number;
}}

interface GMCPCharStatus {{
  effects: GMCPStatusEffect[];
}}

interface GMCPStatusEffect {{
  id: string;
  name: string;
  duration: number;
  positive: boolean;
  stacks: number;
}}

// Room-related GMCP
interface GMCPRoomInfo {{
  id: string;
  name: string;
  description: string;
  area: string;
  x: number;
  y: number;
  z: number;
  exits: GMCPExit[];
  environment: string;
}}

interface GMCPExit {{
  direction: string;
  target: string;
  locked: boolean;
  hidden: boolean;
  door: boolean;
}}

interface GMCPRoomPlayers {{
  players: GMCPEntityInfo[];
}}

interface GMCPRoomNPCs {{
  npcs: GMCPEntityInfo[];
}}

interface GMCPRoomItems {{
  items: GMCPEntityInfo[];
}}

interface GMCPEntityInfo {{
  id: string;
  name: string;
  shortDesc: string;
  icon?: string;
}}

// Inventory-related GMCP
interface GMCPCharItemsList {{
  location: "inventory" | "equipment";
  items: GMCPItem[];
}}

interface GMCPItem {{
  id: string;
  name: string;
  quantity: number;
  weight: number;
  type: string;
  slot: string | null;
  equipped: boolean;
  properties: Record<string, unknown>;
}}

interface GMCPCharItemsAdd {{
  location: "inventory" | "equipment";
  item: GMCPItem;
}}

interface GMCPCharItemsRemove {{
  location: "inventory" | "equipment";
  itemId: string;
}}

// Communication-related GMCP
interface GMCPCommChannelList {{
  channels: GMCPChannel[];
}}

interface GMCPChannel {{
  id: string;
  name: string;
  type: string;
  joined: boolean;
}}

interface GMCPCommChannelText {{
  channel: string;
  sender: string;
  text: string;
  timestamp: number;
}}

// NPC Dialogue GMCP
interface GMCPDialogueStart {{
  npcId: string;
  npcName: string;
  npcTitle: string;
  portrait?: string;
  greeting: string;
}}

interface GMCPDialogueResponse {{
  npcId: string;
  text: string;
  suggestions: string[];
}}

interface GMCPDialogueTyping {{
  npcId: string;
  typing: boolean;
}}

interface GMCPDialogueEnd {{
  npcId: string;
  reason: "player" | "npc" | "timeout" | "error";
}}

A.2 Complete WebSocket Message Types

All messages use the unified envelope defined in §8.1.1.

// Unified message envelope
interface MessageEnvelope {{
  id: string;
  type: string;
  ts: number;
  seq: number;
  requestId?: string;
  payload: unknown;
}}

// Server to Client payloads
interface TextPayload {{
  text: string;
  channel?: string;
}}

interface SystemPayload {{
  text: string;
  level: "info" | "warning" | "error";
}}

interface ErrorPayload {{
  text: string;
  code?: string;
}}

interface PromptPayload {{
  text: string;
}}

interface GMCPPayload {{
  package: string;
  data: unknown;
}}

interface GMCPBatchPayload {{
  messages: Array<{{ package: string; data: unknown }}>;
}}

interface CompletionPayload {{
  completions: string[];
}}

interface HelloPayload {{
  protocolVersion: string;
  serverVersion: string;
  serverCaps: string[];
  gmcpPackagesAvailable: string[];
  compression: string;
  resumeToken: string;
  sessionId: string;
}}

interface SyncCompletePayload {{
  domains: string[];
}}

interface AckPayload {{}}
interface NackPayload {{
  reason: string;
  retryAfter?: number;
}}

interface PongPayload {{
  echo: number;
}}

// Client to Server payloads
interface CommandPayload {{
  text: string;
}}

interface CompletePayload {{
  partial: string;
}}

interface InitPayload {{
  protocolVersion: string;
  clientVersion: string;
  gmcpPackages: string[];
  characterId: string | null;  // Binds selected character to WebSocket connection (§9.1 step 12)
  resumeToken: string | null;
  lastSeq: number | null;  // Last received seq for replay on reconnect (§11.6)
}}

interface PingPayload {{}}

// Discriminated unions using envelope + payload
type ServerMessageType =
  | "text" | "system" | "error" | "prompt" | "gmcp" | "gmcp_batch"
  | "completion" | "hello" | "sync_complete" | "ack" | "nack" | "pong";

type ClientMessageType = "command" | "gmcp" | "complete" | "init" | "ping";

// Type-safe message construction
type ServerMessage = MessageEnvelope & {{
  type: ServerMessageType;
  payload: TextPayload | SystemPayload | ErrorPayload | PromptPayload
    | GMCPPayload | GMCPBatchPayload | CompletionPayload | HelloPayload
    | SyncCompletePayload | AckPayload | NackPayload | PongPayload;
}};

type ClientMessage = MessageEnvelope & {{
  type: ClientMessageType;
  payload: CommandPayload | GMCPPayload | CompletePayload | InitPayload | PingPayload;
}};

A.3 Complete Store Type Definitions

// Connection Store Types
type ConnectionStatus = "disconnected" | "connecting" | "connected" | "reconnecting" | "stale" | "error";

interface ConnectionState {{
  status: ConnectionStatus;
  sessionId: string | null;
  characterId: string | null;
  resumeToken: string | null;
  lastSeq: number | null;
  latency: number;
  reconnectAttempts: number;
  offlineQueue: QueuedCommand[];
  lastError: string | null;
  lastConnectedAt: Date | null;
}}

interface ConnectionActions {{
  connect: (url: string) => Promise<void>;
  disconnect: () => void;
  send: (message: ClientMessage) => void;
  setStatus: (status: ConnectionStatus) => void;
  addToQueue: (command: string) => void;
  flushQueue: () => void;
  clearQueue: () => void;
  updateLatency: (latency: number) => void;
  resetReconnectAttempts: () => void;
}}

// Character Store Types (split from former GameStore)
interface CharacterState {{
  character: Character | null;
  statusEffects: StatusEffect[];
}}

interface CharacterActions {{
  setCharacter: (character: Character) => void;
  updateVitals: (vitals: Partial<Vitals>) => void;
  addStatusEffect: (effect: StatusEffect) => void;
  removeStatusEffect: (effectId: string) => void;
  updateStatusEffect: (effectId: string, updates: Partial<StatusEffect>) => void;
  tickStatusEffects: () => void;
}}

// Room Store Types
interface RoomState {{
  room: Room | null;
}}

interface RoomActions {{
  setRoom: (room: Room) => void;
  updateRoom: (updates: Partial<Room>) => void;
}}

// Inventory Store Types
type ItemType = "weapon" | "armor" | "consumable" | "quest" | "misc";
type EquipmentSlot = "head" | "chest" | "hands" | "legs" | "feet" | "mainHand" | "offHand" | "ring1" | "ring2" | "amulet";

interface InventoryState {{
  inventory: InventoryItem[];
  equipment: EquipmentSlots;
}}

interface InventoryActions {{
  setInventory: (items: InventoryItem[]) => void;
  addItem: (item: InventoryItem) => void;
  removeItem: (itemId: string) => void;
  updateItem: (itemId: string, updates: Partial<InventoryItem>) => void;
  equipItem: (slot: EquipmentSlot, item: InventoryItem) => void;
  unequipItem: (slot: EquipmentSlot) => void;
}}

interface EquipmentSlots {{
  head: InventoryItem | null;
  chest: InventoryItem | null;
  hands: InventoryItem | null;
  legs: InventoryItem | null;
  feet: InventoryItem | null;
  mainHand: InventoryItem | null;
  offHand: InventoryItem | null;
  ring1: InventoryItem | null;
  ring2: InventoryItem | null;
  amulet: InventoryItem | null;
}}

// Map Store Types
interface MapState {{
  map: MapData;
  visitedRooms: Record<string, true>;  // Not Set<string> — Zustand persist can't serialize Set
}}

interface MapActions {{
  updateMap: (roomInfo: RoomInfo) => void;
  addVisitedRoom: (roomId: string) => void;
  clearMap: () => void;
  setViewport: (center: {{ x: number; y: number }}, zoom: number) => void;
}}

interface MapData {{
  rooms: Record<string, MapRoom>;  // Not Map<string, MapRoom> — Zustand persist can't serialize Map
  currentRoomId: string | null;
  viewportCenter: {{ x: number; y: number }};
  zoom: number;
}}

// Settings Store Types
type ThemeName = "dark" | "light" | "high-contrast";
type PanelType = "map" | "room" | "stats" | "inventory" | "chat" | "quests";

interface DefaultSettings {{
  theme: ThemeName;
  fontSize: 14;
  fontFamily: "Courier New, monospace";
  compactMode: false;
  scrollbackLimit: 10000;
  showTimestamps: true;
  soundEnabled: true;
  notificationsEnabled: true;
  locale: "en";
}}

// Dialogue Store Types
interface DialogueState {{
  activeDialogue: ActiveDialogue | null;
  history: DialogueMessage[];
}}

interface DialogueActions {{
  startDialogue: (npc: DialogueNPC) => void;
  addResponse: (npcId: string, text: string, suggestions: string[]) => void;
  setTyping: (npcId: string, typing: boolean) => void;
  addPlayerMessage: (text: string) => void;
  endDialogue: (reason: "player" | "npc" | "timeout" | "error") => void;
  clearHistory: () => void;
}}

interface ActiveDialogue {{
  npcId: string;
  npcName: string;
  npcTitle: string;
  portrait?: string;
  isTyping: boolean;
  suggestions: string[];
}}

interface DialogueMessage {{
  id: string;
  sender: "player" | "npc";
  senderName: string;
  text: string;
  timestamp: Date;
}}

interface DialogueNPC {{
  npcId: string;
  npcName: string;
  npcTitle: string;
  portrait?: string;
  greeting: string;
}}

Appendix B: CSS Architecture

B.1 Tailwind Configuration

// tailwind.config.js
module.exports = {{
  content: [
    "./index.html",
    "./src/**/*.{{ts,tsx,js,jsx}}",
  ],
  darkMode: ["class", '[data-theme="dark"]'],
  theme: {{
    extend: {{
      colors: {{
        primary: {{
          50: '#eff6ff',
          100: '#dbeafe',
          200: '#bfdbfe',
          300: '#93c5fd',
          400: '#60a5fa',
          500: '#4a9eff',
          600: '#2563eb',
          700: '#1d4ed8',
          800: '#1e40af',
          900: '#1e3a8a',
        }},
        hp: {{
          DEFAULT: '#e53935',
          low: '#ff0000',
        }},
        mp: {{
          DEFAULT: '#2196f3',
        }},
        sp: {{
          DEFAULT: '#ffeb3b',
        }},
        xp: {{
          DEFAULT: '#9c27b0',
        }},
        terminal: {{
          bg: '#1a1a1a',
          text: '#e0e0e0',
          prompt: '#4a9eff',
        }},
      }},
      fontFamily: {{
        terminal: ['Courier New', 'Consolas', 'Monaco', 'monospace'],
        sans: ['Inter', 'system-ui', 'sans-serif'],
      }},
      animation: {{
        'pulse-low': 'pulse 1s ease-in-out infinite',
        'typing': 'typing 1.4s ease-in-out infinite',
      }},
      keyframes: {{
        typing: {{
          '0%, 60%, 100%': {{ transform: 'translateY(0)' }},
          '30%': {{ transform: 'translateY(-8px)' }},
        }},
      }},
    }},
  }},
  plugins: [
    require('@tailwindcss/forms'),
    require('@tailwindcss/typography'),
  ],
}}

B.2 Global Styles

Global CSS uses Tailwind layers: - Base layer: Custom scrollbar styling, selection colors, CSS custom properties for terminal font - Components layer: Button variants (.btn, .btn-primary, .btn-secondary, .btn-ghost), panel styling (.panel, .panel-header, .panel-body), vital bar components


Appendix C: Keyboard Shortcuts

Command Input: Enter (submit), Up/Down (history), Tab (complete), Escape (clear)

Navigation: Ctrl+K (focus input), Page Up/Down (scroll), Home/End (top/bottom)

Panels: Ctrl+M (map), Ctrl+I (inventory), Ctrl+C (chat), Ctrl+S (stats)

Macros: Ctrl+1-9 (quick execution)


Appendix D: Error Handling

Key error handling patterns: - Network errors: Auto-reconnect with exponential backoff + jitter; show ReconnectionOverlay - Auth errors: Clear tokens, redirect to login - Rate limiting: Queue commands, server sends nack with retryAfter - Server errors: User-friendly message, retry option - Invalid commands: Display in terminal with nack reason - GMCP errors: Silent logging, continue with partial data - JSON parse errors: Try/catch, count errors in 10s window, force-reconnect if >5 - Unknown message types: Log warning via validateServerMessage() guard, skip processing - Page visibility: Pause heartbeat timers on hidden, check/reconnect on visible - Stale connection: Show warning indicator, allow one more ping cycle before reconnect


Appendix E: Performance Benchmarks

Key performance targets: - First Contentful Paint: < 1.5s - Time to Interactive: < 3s - ANSI parse (1000 chars): < 5ms - Idle memory: < 50MB - 10K lines buffer: < 100MB