Skip to content

Your game's state

The story asks questions about your game. Is it night, does the player have the key, how much gold? @world is where those answers live. It’s the set of properties your game owns and the story’s conditions read, and it’s usually the first thing you wire up after loading a bundle.

Everything on this page is the same in all four runtimes. Only the spelling changes.

@world properties are declared on the project, in Project Settings, with a name, a type, and a default. Declaring is the designer’s half. It tells the editor which names exist, so a condition can be checked as it’s written and a typo is caught when the bundle is published, not in your game.

Your game doesn’t declare anything. It fills in what’s already declared.

A game with more than one editing tool (Storyletter and Patterpad, say) keeps @world in one place instead: game.scopes.json in its shared scopes folder. Storyletter’s World settings then edit that file, and each project keeps a synced copy so it still compiles on its own.

@world.is_night boolean default false
@world.gold number default 0
@world.chapter string default "one" read-only

Four things, and they are the same four in Patter, in the same order, so a game running both engines sees one rule:

  1. Read-only is the story’s promise, declared on the property (writable: false in the shard, the Read-only switch in Storyletter). A condition can still read @world.chapter; an outcome that sets it is a compile error, so a card cannot move the game’s clock by mistake.
  2. The runtime keeps the promise too. If a bundle somehow carries such a write, play() refuses it with '@world.chapter' is read-only and nothing changes. Your resolver’s set is never called for a read-only property.
  3. A resolver with no set makes the whole of @world read-only to the story, whatever the declarations say. That is the game’s policy rather than the story’s promise, and both apply.
  4. Your game is never bound by it, resolver or none. The promise is the story’s, not the game’s. setProperty("world.chapter", ...) writes, whether you bound a resolver or let the engine keep its stand-in bag, and so do the coverage driver and the CLI’s --set, which exist to move exactly these values. The examiner still shows the property as read-only, because that is what the flag means: read by the story, moved by the game. (Until 2026-09-05 the stand-in bag refused the game as well, which locked the game’s own tools out of its own clock. That was a bug in the shared kernel, fixed in scoperegistry 0.6.0 and here.)

There’s no write-only, because a declared property can always be read by the story. If the game holds a value the story shouldn’t see, don’t declare it.

A name that isn’t declared can’t be referenced. The compiler refuses @world.isNight if nothing declares isNight. Property names are lower case (see the format).

@world lives on the engine, and every flow sees the same values. It’s the game’s own state, and the game is one thing however many playthroughs run over it. You can hand the engine a resolver when you build it (new Engine(bundle, { world: { get, set } })), so conditions read your live game state directly. Or hand it nothing, and the engine backs @world itself from the declared defaults, as a property it saves, which is fine for most games. A game running more than one engine registers @world once in its registry instead, and hands the registry to each engine (Running it with Patter).

Before you deal, tell the flow what’s true:

flow.setProperty("world.is_night", true);
flow.setProperty("world.gold", 120);
const dealt = flow.deal("tavern-encounters");

The path form is world.is_night, not @world.is_night. The @ belongs to the expression language a designer writes in; the API takes a plain path. The paths are:

Path Reaches
world.<name> your game’s state
story.<name> the story’s own global state
box.<box>.<name> a box’s properties
deck.<deck>.<name> a deck’s properties
hand.<hand>.<name> a hand’s properties
value.<tag>.<name> a tag’s own properties (see below when two boxes share a tag name)

The owner is named by the name you gave it. A box, deck, hand, or tag is addressed by the same gameId you write in a shard and read on a card. So the Elder’s zone is hand.the-elder.zone, and the docks’ danger is value.docks.danger.

Two boxes may name a tag the same way. Box, deck, and hand names are unique across a project, but a tag’s name only has to be unique within its group, and a group’s within its box, so a harbour box and a cellar box can each have a docks. Where that happens, say which box: value.harbour/docks.danger. The slash sits inside the owner segment, so the address still has its three parts. You can always write the long form, whether or not you need it. The short one is refused where it would name two tags at once, and the refusal tells you both addresses to choose from. A project whose tag names happen to be unique sees none of this.

getProperty reads the same paths, and listProperties() returns every declared property with its path, type, current value and default. That list is what the in-engine examiners are built from.

Write before you deal. A card’s condition is evaluated at the moment you deal or peek, against the state as it stands then. Set @world.is_night after dealing and the hand you already have won’t change; the next deal will.

For the same reason, a dealt card doesn’t carry its outcomes’ availability. Ask outcomes(card, hand) when you’re about to show them and you get the current answer. A card can sit in a hand for many turns while your game moves underneath it.

@world is yours, and the story reads it. An outcome can write it too: “playing this card makes the player poorer” is a normal thing to want. So treat @world as shared, and read it back after a play if your game holds its own copy.

If you’d rather the story never touched a value, keep it in your own code and push it in with setProperty. Nothing forces you to declare state you don’t want written.

Every property is either shared (one value across every flow) or a copy per flow, set on the declaration with a shared flag, never by a different name. The defaults follow the scopes:

Scope Default
@world always shared (the game owns it, not the player)
@story shared
box, deck, hand, and tag properties a copy per flow

A single-flow game never notices any of this. With several flows, the narrow scopes are where personal experience lives (this participant’s danger in the docks), while @story and any property flagged shared is the world every flow moves together. Flows meet only through shared state. There’s no message-passing between them, and no flow can read another’s copies.

Cards can be shared too. The same word on a deck (or a single card) makes the cards themselves scarce across flows rather than the state they read, so there’s one goblin in the whole world, to whoever finds it first. That’s How a deal is decided.

Sharing says whose a value is. durable says how long it lasts. It sits beside shared on the same declarations, and the two are independent:

run-scoped (the default) durable: true
shared world truth for this run: the act, the well opened the installation’s memory: trolls defeated since it opened
per flow this visit: my danger in the docks the player’s pocket: visits, allegiance, what they earned

The engine never reads the flag. It partitions by shared alone, and durability is what whoever runs the engine does at a run boundary: read the declarations, lift the durable values out with getProperty before the world restarts, and write them back with setProperty into the fresh one. Everything that needs is already public.

durable is a compile error on @world, for the reason shared is. @world is the game’s own state, and how long the game keeps it is the game’s business.

A card can be durable too, on the deck or on the card, and it means one thing. Its redraw: never spend survives the run. Only never can (a finite cooldown is an absolute turn of a box’s clock, and the clock resets with the run), so durable on any other redraw is a compile warning. A durable per-flow spend rides in that flow’s cooldowns. A durable shared one rides the engine’s spent set, and both go back with openFlow(id, { restore }) and markTaken.

saveGame() returns the whole run: the shared state once (with anything a shared one-shot has taken out of the world), then every flow’s own state, turn counters, cooldowns, board contents, and random stream position. loadGame(envelope) restores it, rebuilding every flow, so re-take your handles with getFlow, never openFlow, which would replace the restored flow and its dealt hand (see JavaScript, Save and load).

A load is deliberately forgiving about content that has moved underneath a save, which is what lets a save survive an edit, and is also what hides the cost of one. So it tells you what it did. loadGame returns a report of everything it dropped, defaulted, or reset, and previewLoad(envelope) computes the same report without applying anything. saveFlow(id) and openFlow(id, { restore }) do the same for ONE flow, for a playthrough that steps away and comes back.

A @world your game binds is deliberately not in the envelope. It’s your game’s state (the engine only borrows it), so your game saves it, next to the envelope. A self-backed @world is different: nobody else holds those values, so the engine’s own registry stores them and they ride in the save. A game running Patter and the Storylet Engine side by side keeps every property, @world included, in one registry and saves it once, so nothing is written twice. The .storyletsave file format carries a bound world’s values beside the envelope, and play-helpers ships a ready-made container for games with no world state of their own (see JavaScript, Save and load).

Open source under the MIT licence, made by .