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 addMessages, take turns via a 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 agent per character, created once with
io.createAgentwhen the character enters the story and reached by name withio.getAgentafterwards. agent.addMessagesto broadcast each event into every listener's history.agent.sendwith nomessagesso 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 '@omorashi/sdk'
import type { Ctx } from '../types.js'
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.`,
})
// Create a character's agent once, when they enter the story (a second createAgent for the
// name throws); every use after reaches it by name.
export const createCharacter = ({ io }: Ctx, name: string) => io.createAgent(name, characterConfig(name))
export const characterAgent = ({ io }: Ctx, name: string) => io.getAgent(name)// handlers/take-turn.ts — picks the next speaker and lets them respond
import type { Ctx } from '../types.js'
import { characterAgent } from '../agents/character.js'
export async function takeTurn(ctx: Ctx) {
const { state, emit } = ctx
const { characters, nextSpeakerIdx, turnCount } = state.get()
const speaker = characters[nextSpeakerIdx]
// No `messages` — 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 reply = await characterAgent(ctx, 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,
// kept automatically by `send`.
for (const listener of characters) {
if (listener === speaker) continue
characterAgent(ctx, listener).addMessages({ role: 'user', content: `${speaker}: ${reply.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 message at game start, then pick a starter. There is no system role for messages — out-of-fiction direction is a user message with a [System] prefix:
// handlers/game-start.ts
export async function handleGameStart(_event: GameStartInput, ctx: Ctx) {
const { state } = ctx
const characters = ['Alice', 'Bob', 'Charlie']
for (const name of characters) {
createCharacter(ctx, name).addMessages({
role: 'user',
content: '[System] 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
}Characters who arrive mid-story
A cast doesn't have to be fixed at game start. When a new character appears — a stranger walks up to the fire, the story mints a rival — create their agent at that moment, seed it with what they walked into, and add them to the rotation:
export function introduce(name: string, ctx: Ctx) {
const { state } = ctx
createCharacter(ctx, name).addMessages({
role: 'user',
content: `[System] You arrive at a campfire where ${state.get().characters.join(', ')} are talking.`,
})
for (const other of state.get().characters) {
characterAgent(ctx, other).addMessages({ role: 'user', content: `[System] ${name} steps into the firelight.` })
}
state.set(draft => { draft.characters.push(name) })
}Generated personas. When the persona itself is minted at runtime (an LLM writes the
rival), create the agent the same way, once — io.createAgent(name, { systemPrompt: persona })
in place of createCharacter — and from then on the character is just a name in state:
characterAgent (io.getAgent(name)) reaches their agent from any handler without the game
keeping or rebuilding the persona. It throws if no such agent exists, so a typo fails
loudly. (For a structured agent, pass its schema: io.getAgent(name, Schema).)
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 structured agent reads the transcript and picks the next speaker. Useful for dramatic pacing. Give it a fixed
responseFormat(e.g.z.object({ speaker: z.string() })), list the cast in the message and enforce the pick with a.refine()againstcharacters(a refine isn't part of the schema the model is sent, so it doesn't touch the cache) — a schema rebuilt from the current cast would break its prompt cache every time someone joins (seestructured-output).
Notes
- Each character keeps its own conversation history, and the platform compacts each one independently once it outgrows its budget. For a long-running ensemble, give the characters
topicsso what they learn about each other survives that compaction. - Broadcasting via
addMessagesdoes not call the LLM — it just appends to history. Onlysendcosts a turn. - The speaker's own speech is kept in 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:
addMessages({ 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.