Skip to content

Chapter 1: Your First Game

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.

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 Florence
export 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;

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,
};
}

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;
}

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:

  1. Turn advances — The turn counter increments
  2. Market events — A random event may shift prices (20% chance)
  3. 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.

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
}

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`;
}
}

Run the tests:

Terminal window
cd packages/mercante
npx vitest run

You should see:

Test Files 5 passed (5)
Tests 61 passed (61)

Key tests to verify:

  • Initialization — All merchants start with 100 florins, empty inventory
  • ImmutabilityexecuteAction() never mutates the input state
  • Determinism — Same seed + same actions = identical game
  • Rankings — Sorted descending by portfolio value

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