Multi-character scenes
Pattern for several AI characters in a shared scene, each tracking their own perspective. One agent per character, broadcast each event via addMessage, take turns via message-less send. Covers chat rooms, juries, panels, councils, debates, ensemble casts.
Many game scenarios are multiple AI characters sharing a scene: a chat room of friends, a jury deliberating, a panel grading a pitch, a council debating, three NPCs reacting to the player. Each character is its own agent with its own history. When something happens in the scene, every character should perceive it. When it's a character's turn to act, they should respond to what has happened so far — without you having to invent a new user message to prompt them.
This recipe combines three SDK pieces:
- One
Agentper character. agent.addMessageto broadcast each event into every listener's history.agent.sendwith nomessagefield so the chosen actor just responds to their existing history.
Each character's perspective
The trick is that each character's history is symmetric in meaning but asymmetric in role:
- A character's own speech appears in their history as an
assistantmessage (it's what the LLM produced). - The same speech, in every other character's history, appears as a
usermessage tagged with the speaker's name (e.g.Alice: hi everyone).
The system prompt tells each agent who they are; the name prefixes tell them who said what.
Keep the persona timeless. A character's system prompt rides with them for the whole game, so put only what's permanent in it — personality, values, voice, how they treat people. Leave out anything tied to the moment ("tonight she's upset", "you're in the kitchen", what they did last week); a long-running game moves on and the prompt quietly goes stale. The scene's transient situation belongs in a seeded message (see Seeding the scene below), and a character's standing disposition will produce the scene's specifics on its own — write a character as "can't leave things unsaid" and they'll push a quiet moment toward honesty in any scene, without you scripting "tonight they open up".
Code
// types.ts
export interface State {
characters: string[]
nextSpeakerIdx: number
turnCount: number
}// agents/character.ts
import type { AgentConfig } from '@curtain/sdk'
export const characterConfig = (name: string): AgentConfig => ({
systemPrompt: `You are ${name}, a character in an unfolding scene with other characters.
Speak naturally as ${name}. Keep replies to one or two sentences.
Lines from others arrive prefixed with their name (e.g. "Bob: ..."); your own replies
should NOT prefix your name — just say what you say.`,
})// handlers/take-turn.ts — picks the next speaker and lets them respond
import type { Ctx } from '../types.js'
export async function takeTurn(ctx: Ctx) {
const { state, emit, io } = ctx
const { characters, nextSpeakerIdx, turnCount } = state.get()
const speaker = characters[nextSpeakerIdx]
// No `message` — the agent responds to its existing history alone.
// `onText` fires once per text segment with the full segment text (not
// deltas), so emitting from inside it produces one speech event per segment.
// For tool-free characters that's exactly one event per turn.
const { text } = await io.agents[speaker].send(`turn-${turnCount}`, {
onText: segment => emit({ type: 'speech', speaker, text: segment }),
})
// Broadcast the speaker's line to every other character's history. The
// speaker's own history already contains it as their assistant message,
// appended automatically by `send`.
for (const listener of characters) {
if (listener === speaker) continue
io.agents[listener].addMessage({
role: 'user',
content: `${speaker}: ${text}`,
})
}
state.set(draft => {
draft.nextSpeakerIdx = (draft.nextSpeakerIdx + 1) % characters.length
draft.turnCount += 1
})
}Seeding the scene
The very first turn has a problem: the chosen speaker has no history to respond to. Seed every character with a shared scene-setting system message at game start, then pick a starter:
// handlers/game-start.ts
export async function handleGameStart(_event: GameStartInput, ctx: Ctx) {
const { state, io } = ctx
const characters = ['Alice', 'Bob', 'Charlie']
for (const name of characters) {
io.agents[name] = new io.Agent(name, characterConfig(name))
io.agents[name].addMessage({
role: 'system',
content: 'The three of you are sitting around a campfire in the woods. It is late.',
})
}
state.set(draft => {
draft.characters = characters
draft.nextSpeakerIdx = 0
draft.turnCount = 0
})
await takeTurn(ctx) // Alice opens
}Choosing the next speaker
Round-robin is the simplest policy; the pattern works the same with any of these:
- Round-robin —
(idx + 1) % characters.lengthas above. - Random —
io.randomInt('next-speaker', 0, characters.length - 1). - User picks — let the player click a character; their click is the input event that triggers
takeTurn. - Director agent — a hidden agent reads the transcript and picks the next speaker (e.g. via
responseFormat). Useful for dramatic pacing.
Notes
- Broadcasting via
addMessagedoes not call the LLM — it just appends to history. Onlysendcosts a turn. - The speaker's own speech is appended to their history automatically by
send(as the assistant message). You only need to broadcast to the other characters. - If the player speaks into the scene, treat their line exactly like a character's:
addMessage({ role: 'user', content:Player: ${typedLine}})to every character, then calltakeTurnto let the next character respond. There's nosendfor the player — the line came from the human, not an LLM.