Skip to content

Chapter 4: Watch the Action

Ludus separates the game engine (runs the logic) from the viewer (renders the UI). The viewer receives events and state snapshots — it never touches game logic directly.

Game Engine ──events──> WebSocket ──> React Components
├── Game Board
├── Commentary Feed
├── Price Charts
└── Betting Panel

@ludus/game-engine-react provides three shell components and a set of hooks.

The LiveSpectatorShell connects to a running game via WebSocket:

import { LiveSpectatorShell } from "@ludus/game-engine-react";
function MercanteLive({ gameId }: { gameId: string }) {
return (
<LiveSpectatorShell
gameId={gameId}
wsUrl="wss://ludus.example.com/ws"
showCommentary={true}
/>
);
}

This gives you:

  • Real-time state updates as actions execute
  • Commentary appearing as IL BANDITORE narrates
  • Player panels showing each merchant’s portfolio

After a game ends, the replay contains every action, every state transition, and all commentary:

import { CommentedReplayShell } from "@ludus/game-engine-react";
function MercanteReplay({ replay, commentary }) {
return (
<CommentedReplayShell
replay={replay}
commentary={commentary}
/>
);
}

The replay viewer adds playback controls: play, pause, seek to any turn, speed adjustment (1x, 2x, 4x).

If you need a custom spectator view (e.g., Mercante’s price chart), use the hooks directly:

import { useReplay, useGameState } from "@ludus/game-engine-react";
// For live games
function MercanteBoard({ gameId, wsUrl }) {
const { state, events, isConnected } = useGameState(gameId, wsUrl);
if (!state) return <div>Connecting...</div>;
return (
<div>
<h2>Turn {state.turn} / {state.maxTurns}</h2>
<PriceChart priceHistory={state.priceHistory} />
<MerchantPanel merchants={state.merchants} prices={state.prices} />
<EventFeed events={state.events} />
</div>
);
}
// For replays
function MercanteReplayCustom({ replay }) {
const { currentState, play, pause, seek, speed } = useReplay(replay);
return (
<div>
<PriceChart priceHistory={currentState.priceHistory} />
<div>
<button onClick={play}>Play</button>
<button onClick={pause}>Pause</button>
<button onClick={() => speed(2)}>2x</button>
<input
type="range"
min={0}
max={replay.actions.length}
onChange={(e) => seek(Number(e.target.value))}
/>
</div>
</div>
);
}

Mercante’s priceHistory array is perfect for a spectator price chart. Each entry records all 6 good prices at that turn:

function PriceChart({ priceHistory }: { priceHistory: Record<Good, number>[] }) {
// priceHistory[0] = turn 0 prices, priceHistory[1] = turn 1, etc.
// Render as line chart — one line per good, x-axis = turn, y-axis = price
// Use any charting library (Recharts, Chart.js, D3, etc.)
}

For spectators who want to bet on the outcome (Chapter 7), there’s a betting shell:

import { BettingSpectatorShell } from "@ludus/game-engine-react";
<BettingSpectatorShell
markets={predictionMarkets}
onTrade={(trade) => handleTrade(trade)}
/>

We’ll wire this up fully in Chapter 7 when we add prediction markets.

Your game has a full spectator experience:

  • Live viewing — Watch AI merchants trade in real-time
  • Commentary feed — IL BANDITORE narrates the action
  • Replay — Scrub through completed games with playback controls
  • Price charts — Visualize the fluctuating Florentine market
  • Betting panel — (Placeholder for Chapter 7)

Next: Chapter 5 — Merchant Wallets