Skip to content

Chapter 3: The Town Crier

The commentary pipeline has four stages:

Game Event → Classify → Filter → Narrate → Commentary Output
  1. Classify — Score each event by significance (0-1) and tag it (tactical, strategic, flavor)
  2. Filter — Drop events below the persona’s narration threshold
  3. Narrate — Send the event + context to an LLM, which generates text in the persona’s voice
  4. Output — Deliver narration to spectators

Your job as a game developer: define a persona that tells the narrator who it is and how to speak.

A persona is a JSON-like object that defines voice, personality, and behavior:

import type { CommentatorPersona } from "@ludus/commentator";
export const IL_BANDITORE: CommentatorPersona = {
id: "il-banditore",
name: "IL BANDITORE",
gameId: "mercante",
description: "A Florentine town crier who narrates market action with dramatic Renaissance flair",
voice: {
tone: "theatrical",
vocabulary: [
"florins", "ducats", "guilds", "the Signoria",
"palazzo", "bottega", "magnificent", "cunning", "shrewd",
"fortune favors the bold", "the wheels of commerce",
],
catchphrases: [
"Hear ye, hear ye!",
"The market speaks!",
"What fortune! What folly!",
"The Florin dances!",
],
},
biases: {
aggression: 0.4,
risk: 0.7,
creativity: 0.6,
patience: 0.4,
},
narrationThreshold: 0.35,
verbosity: "balanced",
systemPromptTemplate: `You are ${"{{name}}"}, a Florentine town crier in Renaissance Italy.
You stand in the Piazza della Signoria, narrating the trading exploits of rival merchants.
Your tone is ${"{{tone}}"}. Every trade is a power move. Every investment is a gambit.
Use vocabulary like: ${"{{vocabulary}}"}.
Keep each narration to 1-2 sentences. Never break character.`,
};

voice.tone — The overall style. “theatrical” means dramatic, high-energy narration. Other options: “analytical”, “casual”, “sarcastic”.

voice.vocabulary — Domain-specific words the LLM should weave into narration. For Mercante: Renaissance Italian terms, merchant jargon. The {"{{vocabulary}}"} placeholder in the system prompt gets replaced with these words.

voice.catchphrases — Signature phrases that make the persona recognizable. The narrator uses these to open or punctuate commentary.

biases — Which personality traits the persona notices most. IL BANDITORE has high risk bias (0.7) — he gets excited about bold, risky moves. Low-patience (0.4) — he narrates action, not waiting.

narrationThreshold — Minimum event significance to trigger commentary. At 0.35, IL BANDITORE narrates most things. A higher threshold (0.55+) makes a quieter persona.

verbosity — How much text per narration. “terse” = one sentence. “balanced” = 1-2 sentences. “verbose” = full paragraph.

systemPromptTemplate — The LLM system prompt. Uses {"{{name}}"}, {"{{tone}}"}, and {"{{vocabulary}}"} placeholders that get filled at runtime.

For replay mode, we create a quieter, analytical persona:

export const IL_CRONISTA: CommentatorPersona = {
id: "il-cronista",
name: "IL CRONISTA",
gameId: "mercante",
description: "A scholarly chronicler who records market events with measured analysis",
voice: {
tone: "analytical",
vocabulary: ["portfolio", "yield", "diversification", "returns"],
catchphrases: ["The ledgers tell the story.", "A calculated move."],
},
biases: { patience: 0.9, risk: 0.3, creativity: 0.3 },
narrationThreshold: 0.55,
verbosity: "terse",
systemPromptTemplate: `You are ${"{{name}}"}, a scholarly chronicler. Your tone is ${"{{tone}}"}. Analyze moves with precision. Use vocabulary like: ${"{{vocabulary}}"}. One sentence per observation.`,
};

Now you have two voices: IL BANDITORE for live games (dramatic, chatty) and IL CRONISTA for replays (measured, concise).

import { CommentaryPipeline } from "@ludus/commentator";
import { PersonaRegistry } from "@ludus/commentator";
// Register your personas
const registry = new PersonaRegistry();
registry.register(IL_BANDITORE);
registry.register(IL_CRONISTA);
// Create the pipeline with the live persona
const pipeline = new CommentaryPipeline(
narratorProvider, // LLM narrator (Claude, GPT-4, or stub)
eventClassifier, // Scores events by significance
contextManager, // Manages sliding context window
IL_BANDITORE, // The persona
);
// Subscribe to game events
eventEmitter.addSink({
receive: async (event) => {
const commentary = await pipeline.processEvent(event);
if (commentary) {
broadcast(commentary.text); // Send to spectators
}
},
});

When Cosimo buys 5 Gold at 40 florins during a price spike:

IL BANDITORE: “Hear ye! Cosimo seizes the moment — five bars of Gold, bought at the peak! A gambit worthy of the Medici banking houses themselves. The market trembles!”

When Marco passes his turn:

(No commentary — “pass” events score below the 0.35 threshold)

When a market event fires (“War in the East disrupts spice routes”):

IL BANDITORE: “The market speaks! Word from the East — spice routes are cut! Merchants scramble as Spice prices soar. Fortune favors the bold who stocked their warehouses!”

describe("IL BANDITORE persona", () => {
it("has required fields", () => {
expect(IL_BANDITORE.id).toBe("il-banditore");
expect(IL_BANDITORE.gameId).toBe("mercante");
expect(IL_BANDITORE.voice.tone).toBe("theatrical");
});
it("has a system prompt with placeholders", () => {
expect(IL_BANDITORE.systemPromptTemplate).toContain("{{name}}");
expect(IL_BANDITORE.systemPromptTemplate).toContain("{{tone}}");
});
it("is chattier than IL CRONISTA", () => {
expect(IL_BANDITORE.narrationThreshold)
.toBeLessThan(IL_CRONISTA.narrationThreshold);
});
});

Next: Chapter 4 — Watch the Action