Chapter 1: Your First Game
The Game Interface
Section titled “The Game Interface”Every Ludus game is a single object that implements Game<TState, TAction>. The engine doesn’t know or care what your game is — it just calls seven methods:
interface Game<TState, TAction> { readonly metadata: GameMetadata; initialize(config, players, rng): TState; getValidActions(state, playerId): TAction[]; executeAction(state, action, rng): TState; isGameOver(state): boolean; getWinner(state): Player | null; getRankings(state): PlayerRanking[]; describeAction(action, state): string;}That’s the entire contract. If you implement these seven methods, the rest of the platform — agents, commentary, spectators, markets — just works.
Step 1: Define Your Types
Section titled “Step 1: Define Your Types”First, define what your game state looks like and what actions players can take.
src/types.ts — The data model for Mercante:
import type { BaseGameState, BaseAction } from "@ludus/game-engine";
// The six tradeable goods in Florenceexport type Good = "silk" | "spice" | "wine" | "wool" | "gold" | "art";
export const ALL_GOODS: Good[] = ["silk", "spice", "wine", "wool", "gold", "art"];
// Base prices and volatility (how much prices fluctuate)export const BASE_PRICES: Record<Good, number> = { silk: 12, spice: 18, wine: 8, wool: 6, gold: 40, art: 30,};
export const VOLATILITY: Record<Good, number> = { silk: 0.15, spice: 0.25, wine: 0.10, wool: 0.08, gold: 0.30, art: 0.35,};Your state extends BaseGameState (which provides turn, activePlayerId, playerOrder, etc.):
export interface MercanteState extends BaseGameState { merchants: Record<string, MerchantState>; prices: Record<Good, number>; priceHistory: Array<Record<Good, number>>; events: MarketEvent[]; currentEvent: MarketEvent | null; swapFeeBps: number; lendingYieldRate: number;}
export interface MerchantState { playerId: string; florins: number; inventory: Record<Good, number>; lending: LendingPosition[]; marketPositions: Map<string, number>; betWinnings: number;}Your actions extend BaseAction:
export interface TradeAction extends BaseAction { type: "trade"; direction: "buy" | "sell"; good: Good; quantity: number;}
export interface SwapAction extends BaseAction { type: "swap"; goodIn: Good; goodOut: Good; amountIn: number;}
// ... InvestAction, WithdrawAction, BetAction, PassAction
export type MercanteAction = | TradeAction | SwapAction | InvestAction | WithdrawAction | BetAction | PassAction;Step 2: Initialize the Game
Section titled “Step 2: Initialize the Game”initialize() creates the starting state. Every merchant gets 100 florins. Prices start at their base values. Player order is randomized using SeededRNG.
initialize(config, players, rng): MercanteState { const order = rng.shuffle(players.map(p => p.id));
const merchants: Record<string, MerchantState> = {}; for (const id of order) { merchants[id] = { playerId: id, florins: 100, inventory: { silk: 0, spice: 0, wine: 0, wool: 0, gold: 0, art: 0 }, lending: [], marketPositions: new Map(), betWinnings: 0, }; }
return { gameId: `merc-${rng.nextInt(1000, 9999)}`, turn: 0, activePlayerId: order[0], playerOrder: order, eliminated: [], maxTurns: config.maxTurns || 15, merchants, prices: { ...BASE_PRICES }, priceHistory: [{ ...BASE_PRICES }], events: [], currentEvent: null, swapFeeBps: 30, lendingYieldRate: 0.02, };}Step 3: Get Valid Actions
Section titled “Step 3: Get Valid Actions”getValidActions() returns every legal move for a player. The engine calls this to tell AI agents what they can do.
Key rules:
- Only the active player gets actions
- Pass is always available
- Buy requires sufficient florins
- Sell requires goods in inventory
- Swap requires goods to exchange
- Invest requires goods to deposit
- Withdraw requires an active lending position
- Bet requires florins (min 5, max configurable)
getValidActions(state, playerId): MercanteAction[] { if (state.turn >= state.maxTurns) return []; if (playerId !== state.activePlayerId) return [];
const merchant = state.merchants[playerId]; const actions: MercanteAction[] = [];
// Pass is always valid actions.push({ type: "pass", playerId });
// Buy goods (if you have florins) for (const good of ALL_GOODS) { const price = state.prices[good]; if (merchant.florins >= price) { const maxQty = Math.floor(merchant.florins / price); for (const qty of [1, 2, 5]) { if (qty <= maxQty) { actions.push({ type: "trade", playerId, direction: "buy", good, quantity: qty }); } } } }
// ... sell, swap, invest, withdraw, bet (same pattern)
return actions;}Step 4: Execute an Action
Section titled “Step 4: Execute an Action”This is the heart of your game. executeAction() takes the current state and an action, and returns the new state.
The Iron Rule: Never mutate the input. Always clone first.
executeAction(state, action, rng): MercanteState { // ALWAYS clone — this is the Iron Rule const next = structuredClone(state);
const merchant = next.merchants[action.playerId];
switch (action.type) { case "trade": { const price = next.prices[action.good]; const cost = price * action.quantity; if (action.direction === "buy") { merchant.florins -= cost; merchant.inventory[action.good] += action.quantity; } else { merchant.inventory[action.good] -= action.quantity; merchant.florins += cost; } break; }
case "swap": { const valueIn = next.prices[action.goodIn] * action.amountIn; const fee = valueIn * (next.swapFeeBps / 10000); const amountOut = Math.floor((valueIn - fee) / next.prices[action.goodOut]); if (amountOut > 0) { merchant.inventory[action.goodIn] -= action.amountIn; merchant.inventory[action.goodOut] += amountOut; } break; }
// ... invest, withdraw, bet, pass }
// Advance turn next.turn++;
// Market event (20% chance) let event = null; if (rng.next() < 0.2) { event = rng.pick(MARKET_EVENTS); next.events.push(event); }
// Update prices with random fluctuation next.prices = calculatePrices(next.prices, rng, event); next.priceHistory.push({ ...next.prices });
// Advance active player (round-robin) const idx = next.playerOrder.indexOf(action.playerId); next.activePlayerId = next.playerOrder[(idx + 1) % next.playerOrder.length];
return next;}Three things happen after every action:
- Turn advances — The turn counter increments
- Market events — A random event may shift prices (20% chance)
- Prices fluctuate — Each good walks randomly within its volatility band
This means the market is alive between moves. Gold might spike while you’re holding silk. The lending pool yield compounds. Timing matters.
Step 5: Game Over and Rankings
Section titled “Step 5: Game Over and Rankings”isGameOver(state): boolean { return state.turn >= state.maxTurns;}
getRankings(state): PlayerRanking[] { // Calculate portfolio value for each merchant: // florins + goods (at current prices) + lending (with yield) + bet winnings // Sort descending by total value}Step 6: Describe Actions
Section titled “Step 6: Describe Actions”describeAction() converts machine actions to human-readable text. This is what the commentary system and replay viewer use.
describeAction(action, state): string { switch (action.type) { case "trade": return `${action.playerId} buys 3 Silk for 36 florins`; case "swap": return `${action.playerId} swaps 5 Spice for Gold`; case "bet": return `${action.playerId} bets 10 florins that marco will win`; case "pass": return `${action.playerId} passes`; }}Testing
Section titled “Testing”Run the tests:
cd packages/mercantenpx vitest runYou should see:
Test Files 5 passed (5) Tests 61 passed (61)Key tests to verify:
- Initialization — All merchants start with 100 florins, empty inventory
- Immutability —
executeAction()never mutates the input state - Determinism — Same seed + same actions = identical game
- Rankings — Sorted descending by portfolio value
What You Have Now
Section titled “What You Have Now”A complete, playable game with:
- 6 tradeable goods with fluctuating prices
- Market events that shift the economy
- DeFi-style swaps with fees
- Lending with yield accrual
- Prediction market betting
- Pure, deterministic logic
The game engine can run this in a loop, record replays, and verify determinism — all from this single Game object.
Next: Chapter 2 — AI Opponents