Quickstart: NPCs that share one world
Same SDK, same coordinator, same ledger as the drama quickstart — which is the point of this document. Nothing about Pouchy World is short-drama-specific; a merchant, a guard and a quest giver standing in one town are the same shape as three characters standing on a dock.
Read world-quickstart-drama.md first for the credential setup and the
authoring calls. This page covers only what a game does differently.
What a game changes
| Drama | Game |
|---|---|
| a scene's beat | a location's tick |
| the audience types | the player acts, and your server reports it |
| one instance per viewer | one instance per SAVE |
| the draft becomes a screenplay | the ledger becomes a save-file audit |
1. The Story Package is your world bible
const pkg = await world.createStoryPackage({
name: 'Frontier Save',
summary: 'A frontier town: trade, patrols, and one missing caravan.',
roles: [
{ storyRoleId: 'merchant', worldRoleId: 'merchant', effectsAllow: ['set_flag'] },
{ storyRoleId: 'guard', worldRoleId: 'guard', effectsAllow: ['increment_flag', 'set_flag'] },
{ storyRoleId: 'quest_giver', worldRoleId: 'quest_giver', effectsAllow: ['complete_objective'] }
],
scenes: [{ sceneId: 'town', title: 'Town', description: 'Dust and awnings.' }],
nodes: [{ nodeId: 'find_caravan', sceneId: 'town', title: 'The caravan',
objective: 'learn what happened to the caravan', prerequisites: [] }],
stateSchema: {
flags: [
{ key: 'stock_fresh', kind: 'boolean', initial: false },
{ key: 'alert_level', kind: 'number', initial: 0 }
]
},
visibility: { factDefault: 'shared' }
});
Note the grants. The guard may raise the alert; the merchant may not. The quest giver may close an objective; nobody else may. A grant is the difference between an NPC that talks about doing something and an NPC that does it — and it lives in the story revision, not in a prompt.
2. One world, one instance per save
const created = await world.createWorld({
name: 'Frontier World',
providerRef: 'game-backend',
runtimeMode: 'coordinated',
storyPackageRef: { packageId: pkg.packageId, revision: pkg.revision, contentHash: pkg.contentHash },
capabilityAllow: { views: [], actions: [], events: ['area.event'] },
roles: [
{ roleId: 'merchant', agentId: 'agent_merchant' },
{ roleId: 'guard', agentId: 'agent_guard', eventSubscriptions: ['area.event'] },
{ roleId: 'quest_giver', agentId: 'agent_quest', eventSubscriptions: ['area.event'] }
]
});
// One session (and so one world instance) per SAVE, not per NPC:
const save = await world.createWorldSession({
environment: created.environmentId,
role: 'merchant', // the role the player is talking to
externalUserId: 'player-9'
});
Two players' saves never share state. That is structural: an instance is pinned to its own world revision, its own story state and its own ledger.
3. Player behaviour becomes an event
Your server owns what "the player did something" means. When it happens, send an event — do not ask an NPC to decide whether it happened.
await world.sendEvent({
name: 'area.event',
environment: created.environmentId,
worldInstance: save.world.instance,
data: { playerId: 'player-9', what: 'caught in the warehouse' }
});
Only the subscribed roles enter that beat: the guard and the quest giver answer, the merchant does not. Role selection is server-side from the pinned definition — nothing in your payload can add a role, and nothing in a model's output can either.
Inside the beat, the guard may propose increment_flag alert_level +2. It
commits once, with everything else that beat decided. On the merchant's NEXT
turn, the merchant's own projection already carries alert_level = 2 — that is
what makes the town feel causally connected rather than three chatbots wearing
hats.
4. Trade and quest state are just flags and objectives
const talk = await world.runTurn({
environmentId: created.environmentId,
worldInstanceId: save.world.instance,
turnId: newTurnId(),
text: 'I ask the merchant for fresh supplies.'
});
// talk.committedStateDiff → what actually changed
// talk.nextOptions → branches the story reached, if any
Read the state whenever your UI needs it:
const { state } = await world.getWorldState(created.environmentId, save.world.instance);
// state.flags.alert_level, state.sceneId, state.completedNodes, state.stateRevision
This is the USER projection: no role's private layer, ever. If your UI needs something an NPC is keeping secret, that is a design question, not an API one.
5. Repeated events, crashed workers, lost responses
- the same
eventIdtwice → the second one runs nothing; - the same
turnIdtwice →duplicate, with the original revisions; - a lost response →
getTurn(turnId); a 404 means it never committed; - a delivery that failed →
deliveryStatus: 'pending', and the durable outbox retries it. The state already moved; you do not need to re-drive the beat.
6. The ledger as a save audit
const report = await world.replayLedgerToEnd(envId, save.world.instance);
if (report.verdict !== 'consistent') {
// A finding for a human. The endpoint never repairs a live save.
}
For a game this is the answer to "did our save state drift from what actually
happened": the ledger is the committed history, and the replay rebuilds the
state from it and compares. ScriptDraft works here too — it reads as a
session log rather than a screenplay, and every line still traces to its turn.
The line this quickstart exists to draw
There is no NPC-specific bypass anywhere in this stack. Same
state model, same coordinator, same effect grants, same ledger, same
WorldTurnResult. If you find yourself wanting a shortcut "because it's just
an NPC", the shortcut you want is a grant in the story package.