Skip to content

Player Frontend

The player frontend is a React-based web client that provides a modern browser interface for playing MAID games. It connects via WebSocket and renders a full MUD experience with terminal output, side panels for game data, and mobile-responsive layout.

Overview

The frontend is served at /play/ by the MAID server and provides:

  • Terminal — xterm.js-based output with full ANSI color support
  • Game panels — Character stats, inventory, room info, map, chat, quest log (stub)
  • NPC dialogue — Dedicated overlay for AI-powered conversations
  • Mobile support — Responsive layout with tab navigation
  • PWA — Installable as a standalone app on mobile/desktop
  • Theming — Dark, light, and high-contrast themes
  • i18n — English and Spanish translations

Building

cd packages/maid-engine/player_frontend

# Install dependencies
npm install

# Build for production (output: dist/)
npm run build

# The MAID server automatically serves the built files at /play/

After building, start the MAID server and navigate to http://localhost:8080/play/.

Development

cd packages/maid-engine/player_frontend

# Start dev server with hot reload
npm run dev

# Run tests
npm run test

# Run tests in watch mode
npm run test:watch

# Preview production build
npm run preview

The dev server proxies WebSocket connections to the running MAID server, so you'll need both running during development.

Architecture

Tech Stack

Layer Technology
Framework React 18
Language TypeScript
Build Vite
Terminal @xterm/xterm
State Zustand
Data fetching @tanstack/react-query
Routing react-router-dom
Styling Tailwind CSS + PostCSS
i18n react-i18next
Sanitization DOMPurify
Testing Vitest + JSDOM

Directory Structure

player_frontend/
├── public/
│   ├── manifest.json          # PWA manifest (scope: /play/)
│   └── sw.js                  # Service worker (no-op, see below)
├── src/
│   ├── main.tsx               # Entry point, i18n init, SW registration
│   ├── App.tsx                # Router + theme application
│   ├── i18n.ts                # i18next configuration
│   ├── pages/
│   │   ├── LoginPage.tsx      # Authentication
│   │   ├── CharacterSelectPage.tsx
│   │   └── GamePage.tsx       # Main game shell
│   ├── components/
│   │   ├── SafeHTML.tsx        # DOMPurify sanitization component
│   │   ├── auth/              # Auth-related components
│   │   ├── dialogue/          # NPC dialogue overlay
│   │   ├── game/              # Terminal output, command input, macro bar
│   │   ├── layouts/           # Layout wrappers
│   │   ├── panels/            # Side panels (stats, inventory, room, etc.)
│   │   └── ui/                # Shared UI primitives
│   ├── stores/                # Zustand state stores
│   ├── hooks/                 # Custom hooks (useGameWebSocket, etc.)
│   ├── lib/                   # Utilities (auth-storage, gmcp-dispatch, sanitize)
│   ├── types/                 # TypeScript types (GMCP, messages, settings)
│   ├── providers/             # React context providers (WebSocket)
│   ├── styles/                # Global CSS + theme variables
│   ├── locales/               # Translation JSON files
│   │   ├── en/translation.json
│   │   └── es/translation.json
│   └── test/                  # Test utilities
├── index.html
├── vite.config.ts             # Base path: /play/
├── tailwind.config.js
└── package.json

Page Flow

LoginPage → CharacterSelectPage → GamePage
    │                                   │
    └── Auth via REST API               └── WebSocket connection
                                             + GMCP subscription

Zustand Stores

The application state is split across eight focused stores:

Store Purpose
connection-store WebSocket status, latency, offline queue (defined but not wired to command input — see note below)
character-store Character name, vitals, status effects
inventory-store Inventory items + equipment slots
room-store Current room name, description, exits, players
map-store Visited rooms, viewport position, zoom level
chat-store Chat channels, message history, unread counts
dialogue-store Active NPC dialogue state and conversation history
settings-store Theme, font, layout, macros, aliases, keybindings, soundEnabled, notificationsEnabled

Settings are persisted to localStorage under the key maid-player-settings.

Send queue architecture

The codebase defines two queue layers, but only one is currently active:

  • connection-store.offlineQueue — Zustand store with send() and addToQueue() methods for buffering player commands. Entries expire after 30 seconds and are capped at 20. However, the active command input path (CommandInputuseGameWebSocket.sendCommand()GameWebSocket.send()) does not use this queue — it is infrastructure for future use.
  • GameWebSocket.queue — Private queue inside the GameWebSocket class (src/lib/game-websocket.ts) that buffers ClientMessage objects when the underlying WebSocket is not in the OPEN state. Flushed automatically on reconnect via onopen. This is the active transport queue.

GMCP Integration

The frontend subscribes to GMCP (Generic MUD Communication Protocol) packages for structured game data. This keeps the terminal clean for narrative text while updating panels with machine-readable data.

Subscribed Packages

// Subscribed on connection
const gmcpPackages = [
  "Core",
  "Char.Vitals",
  "Char.Status",
  "Char.Items.Inv",
  "Room.Info",
  "Room.Players",
  "Comm.Channel",
];

Data Flow

  1. Server sends GMCP messages over the WebSocket alongside terminal output
  2. useGameWebSocket hook receives messages and dispatches by type
  3. gmcp messages are routed to dispatchGMCP(); gmcp_batch messages are routed to dispatchGMCPBatch()
  4. The dispatcher updates the appropriate Zustand store
  5. React components re-render with new data
// Simplified GMCP dispatch flow
function dispatchGMCP(packageName: string, data: unknown) {
  switch (packageName) {
    case "Char.Vitals":
      useCharacterStore.getState().updateVitals(data);
      break;
    case "Room.Info":
      useRoomStore.getState().setRoom(data);
      break;
    case "Char.Items.Inv":
      useInventoryStore.getState().setInventory(data);
      break;
    // ...
  }
}

GMCP Types

Type definitions for all GMCP data are in src/types/gmcp.ts:

interface GMCPCharVitals {
  hp: number;
  maxhp: number;
  mp?: number;
  maxmp?: number;
  mv?: number;
  maxmv?: number;
}

interface GMCPRoomInfo {
  num: string;
  name: string;
  area: string;
  desc?: string;
  exits: Record<string, string>;
  coord?: { x: number; y: number; z: number };
  npcs?: GMCPEntityInfo[];
  items?: GMCPEntityInfo[];
}

Customization

Theming

Three built-in themes are defined in src/styles/globals.css:

:root[data-theme='dark'] {
  --bg-primary: #1a1a1a;
  --bg-secondary: #252525;
  --bg-tertiary: #2d2d2d;
  --text-primary: #e0e0e0;
  --text-secondary: #a0a0a0;
  --text-muted: #666666;
  --border: #3d3d3d;
  --accent: #4a9eff;
  /* ... */
}

:root[data-theme='light'] {
  --bg-primary: #f7f7f7;
  --bg-secondary: #ffffff;
  --bg-tertiary: #ececec;
  --text-primary: #1c1c1c;
  --text-secondary: #4d4d4d;
  --text-muted: #7a7a7a;
  --border: #d0d0d0;
  --accent: #2463eb;
  /* ... */
}

:root[data-theme='high-contrast'] {
  --bg-primary: #000000;
  --bg-secondary: #111111;
  --bg-tertiary: #1a1a1a;
  --text-primary: #ffffff;
  --text-secondary: #f2f2f2;
  --text-muted: #bdbdbd;
  --border: #ffffff;
  --accent: #00ffff;
  /* ... */
}

Players can set their theme via the settings store (e.g., programmatically or through an API call), and it's applied via document.documentElement.dataset.theme. There is currently no in-game settings UI for theme selection — theme state exists in the store but must be toggled programmatically or via direct store manipulation.

Layout Customization

The settings store supports:

  • Font size and family — Customizable terminal font
  • Compact mode — Stored in settings (not yet consumed by components)
  • Scrollback — Terminal history buffer size

Note

These settings are available at the store and API level. There is currently no visible settings UI panel — customization must be done programmatically or via localStorage.

Macros and Aliases

Players can define:

  • Macros — Clickable buttons that send commands
  • Aliases — Text substitutions (e.g., kkkill kobold)
  • Keybindings — Stored in settings (not yet wired to key event handlers)

These are configured through the settings store and persisted in localStorage. No in-game configuration UI is currently implemented; these fields are stored but not yet fully consumed by the UI.

Adding a New Panel

To add a custom panel:

  1. Create a component in src/components/panels/:

    // src/components/panels/MyPanel.tsx
    import { useMyStore } from "../../stores/my-store";
    
    export function MyPanel() {
      const data = useMyStore((s) => s.data);
      return <div className="panel">{/* render data */}</div>;
    }
    
  2. Add a Zustand store if needed in src/stores/

  3. Handle the relevant GMCP package in the dispatcher
  4. Add the panel to GamePage.tsx layout

Internationalization (i18n)

The frontend uses react-i18next with JSON translation files:

src/locales/
├── en/translation.json    # English (default)
└── es/translation.json    # Spanish

Usage in components:

import { useTranslation } from "react-i18next";

function MyComponent() {
  const { t } = useTranslation();
  return <h1>{t("game.welcome")}</h1>;
}

The language defaults to English (lng: 'en' is hardcoded in the i18next init) and falls back to English for missing keys. There is currently no settings UI for language switching — the language is fixed at initialization time.

Adding a New Language

  1. Create src/locales/<code>/translation.json
  2. Add the import to src/i18n.ts
  3. Register the resource in the i18next config

PWA Support

The frontend is installable as a Progressive Web App:

  • Manifestpublic/manifest.json with start_url: "/play/" and app icons
  • Service Worker — Registered in src/main.tsx; currently only calls skipWaiting() and clients.claim() (no offline caching)
  • Scope — Limited to /play/ path

Players on mobile can "Add to Home Screen" for a native-like experience. Note that without offline caching in the service worker, the app requires a network connection to function.

Deployment

How It's Served

The MAID server's web layer mounts the built frontend at /play/:

  1. Vite builds with base: "/play/" (configured in vite.config.ts), which prefixes all asset URLs with /play/
  2. If player_frontend/dist/ exists, the web server mounts it as a StaticFiles directory at /play/
  3. If the dist directory doesn't exist, a fallback HTML page is served at /play/ instead
  4. The root URL / redirects to /play/

Note

BrowserRouter in main.tsx does not set basename="/play". Client-side routes (/, /characters, /game) are relative to the document root, not to /play/. This works because the server mounts the SPA's index.html at /play/ and Vite's base handles asset paths. If you deploy behind a different prefix, you must update both vite.config.ts (base) and add a matching basename prop to BrowserRouter.

Production Build

# From project root
cd packages/maid-engine/player_frontend
npm install
npm run build

# Start the server — frontend is available at http://localhost:8080/play/
cd ../../..
uv run maid server start

Environment

The frontend doesn't require separate environment variables. It connects to the same server that hosts it, using relative WebSocket URLs.

Sanitization

HTML content rendered in ChatPanel and RoomInfoPanel uses DOMPurify via a SafeHTML component. This prevents XSS attacks from game content or player input that might contain HTML.

import { SafeHTML } from "../SafeHTML";

// Safe to render untrusted content
<SafeHTML html={roomDescription} />

See Also