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 eventId twice → the second one runs nothing;
  • the same turnId twice → 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.