Skip to content

Chapter 7: Place Your Bets

Spectators (and merchants themselves) can bet on game outcomes using LMSR-powered prediction markets. The flow:

Game Created → Markets Created → Markets Opened → Trading
→ Game Starts → Markets Locked → Game Ends → Markets Resolved → Claims Paid

The GameOracle bridges the game engine to the markets — it listens for game_start (locks markets) and game_over (resolves markets) events.

We define three market types that showcase different outcome structures:

“Which merchant will amass the greatest fortune?”

One outcome per player. The most natural bet.

export function createWinnerMarket(
gameId: string,
players: Player[],
liquidityParam: number = 50,
): Omit<Market, "id" | "createdAt"> {
const outcomes = players.map(p => ({
id: `winner-${p.id}`,
label: `${p.name} wins`,
metadata: { playerId: p.id },
}));
return {
gameId,
gameType: "mercante",
template: "mercante-winner",
question: "Which merchant will amass the greatest fortune?",
outcomes,
state: "CREATED",
liquidityParam,
seededLiquidity: liquidityParam * Math.log(players.length),
totalVolume: 0,
shares: new Array(players.length).fill(0),
lockTime: 0,
};
}

“Will Gold exceed 60 florins by game end?”

A simple yes/no bet on price action. Gold is the most volatile good — perfect for binary markets.

export function createGoldPriceMarket(
gameId: string,
threshold: number = 60,
): Omit<Market, "id" | "createdAt"> {
return {
gameId,
gameType: "mercante",
template: "mercante-gold-above",
question: `Will Gold exceed ${threshold} florins by game end?`,
outcomes: [
{ id: "yes", label: `Gold > ${threshold}` },
{ id: "no", label: `Gold <= ${threshold}` },
],
state: "CREATED",
liquidityParam: 30,
seededLiquidity: 30 * Math.log(2),
totalVolume: 0,
shares: [0, 0],
lockTime: 0,
};
}

“What will the winner’s final portfolio value be?”

Four buckets: Under 150, 150-250, 250-400, Over 400 florins.

export function createScoreRangeMarket(gameId: string) {
return {
// ...
outcomes: [
{ id: "under-150", label: "Under 150 florins" },
{ id: "150-250", label: "150-250 florins" },
{ id: "250-400", label: "250-400 florins" },
{ id: "over-400", label: "Over 400 florins" },
],
shares: [0, 0, 0, 0],
};
}

Ludus uses the Logarithmic Market Scoring Rule (LMSR) for automated market making. It’s the same algorithm used by Polymarket and Augur.

Cost function: C(q) = b * ln(sum of e^(qi/b))
Price for outcome i: pi = e^(qi/b) / sum of e^(qj/b)

Where b is the liquidity parameter (higher = more liquid, lower slippage) and q is the share vector.

In practice: The MarketService handles all the math. You just call buy() and sell():

import { MarketService } from "@ludus/prediction-markets";
const service = new MarketService(store);
// Spectator buys 10 shares of "Cosimo wins"
const trade = await service.buy(
marketId,
"spectator-123",
0, // outcome index (Cosimo)
10, // shares
undefined, // no max cost limit
);
console.log(trade.cost); // How many florins it cost
console.log(trade.newPrices); // Updated probabilities for all outcomes
CREATED → OPEN → LOCKED → RESOLVED → SETTLED
| |
+→ VOIDED ←+→ DISPUTED
StateWhat HappensTrading?
CREATEDMarket exists, not yet activeNo
OPENSpectators can buy/sell sharesYes
LOCKEDGame started — no more tradingNo
RESOLVEDGame ended — winning outcome determinedNo
SETTLEDAll claims paid outNo
VOIDEDMarket cancelled (refund all)No
DISPUTEDResolution challengedNo

The GameOracle is an EventSink that bridges game events to market operations:

import { GameOracle } from "@ludus/prediction-markets";
const oracle = new GameOracle(marketService);
// Subscribe to game events
eventEmitter.addSink(oracle);
// When game_start fires:
// → oracle.lockMarketsForGame(gameId)
// → all OPEN markets for this game → LOCKED
// When game_over fires with GameResult:
// → oracle.resolveGame(gameId, gameResult)
// → determines winning outcomes
// → all LOCKED markets → RESOLVED

For Mercante, the oracle resolves markets like this:

  • Winner market: Compares final rankings — top player wins
  • Gold price market: Checks final Gold price vs. threshold
  • Score range market: Maps winner’s score to a bucket
export function resolveScoreRange(winnerScore: number): string {
if (winnerScore < 150) return "under-150";
if (winnerScore < 250) return "150-250";
if (winnerScore < 400) return "250-400";
return "over-400";
}

Mercante also allows merchants themselves to bet (the bet action type). This creates an interesting strategic layer:

  • Betting on yourself is a confidence play — you lose florins now but win double if you’re right
  • Betting on an opponent is a hedge — if they win, your bet softens the loss
  • The prediction market reflects collective intelligence — even the AI agents’ bets are informative

After resolution, winners claim their payouts:

import { ClaimManager } from "@ludus/prediction-markets";
const claims = new ClaimManager(store);
// Check if a user can claim
const canClaim = await claims.canClaim("spectator-123", market);
// Claim winnings
if (canClaim) {
const claim = await claims.claim("spectator-123", market, userShares);
// claim.netPayout: winnings after 3% vig (house fee)
}
describe("Winner Market", () => {
it("creates one outcome per player", () => {
const market = createWinnerMarket("game-1", players);
expect(market.outcomes).toHaveLength(3);
expect(market.outcomes[0].label).toBe("Cosimo wins");
});
});
describe("Score Range Resolution", () => {
it("maps scores to correct buckets", () => {
expect(resolveScoreRange(100)).toBe("under-150");
expect(resolveScoreRange(200)).toBe("150-250");
expect(resolveScoreRange(300)).toBe("250-400");
expect(resolveScoreRange(500)).toBe("over-400");
});
});

Your game now has a complete prediction market layer:

  • Three market types (categorical, binary, scalar)
  • LMSR pricing (automated market maker)
  • GameOracle that bridges events to market resolution
  • In-game betting as a strategic action
  • Claim system with deadline and vig

Spectators can watch the game AND trade on outcomes — the prices update live as the game progresses.


Next: Chapter 8 — Ship It