The bundle and the save
Your shards are the source. The bundle is what ships.
What export does
Section titled “What export does”storyletengine export (or Publish ▸ Publish Bundle in Storyletter) is the compiler.
It:
- Compiles every expression from source text into a
{ src, ast }envelope, so no runtime ever ships a parser. - Assembles the shards, the project file plus every box folder and deck file, into one JSON document, all collections sorted by id.
- Validates, refusing to write anything on an error. It checks for property references nothing declares, tag references that point nowhere, hands that don’t fill in every group their template asks for, and field values against the box’s card template.
- Computes a content hash over the canonical source shards and embeds it.
- Carries author metadata through by default. A
strippedbuild omits everytitleandpurpose.
The output is a single strict-JSON .storyletsc file at the path the project shard’s
export.bundle names. New projects point it at a storylet-dist/ folder beside the project,
named after it (../storylet-dist/the-hamlet.storyletsc), and that is the default when the
shard names nothing. The project folder is the document; a build output never goes inside it.
Patterpad publishes to ../patter-dist/ in the same way.
What’s in it
Section titled “What’s in it”{ schema: "storylets/bundle@0", content: { project: "proj_salt", // immutable project id version: "0.3.0", // authored project version hash: "a91c...", // over the canonical source shards }, metadata: "full", // "full" | "stripped" settings: { playAdvancesTurns: 1, }, world: { properties: [ /* the @world declarations */ ], registry: { /* owned / foreign split */ }, }, story: { properties: [ /* the @story declarations */ ], }, boxes: [ /* each box, with its tag groups, decks, templates and hands */ ], maps: [ /* only when the project asked: see below */ ], externalScopes: ["patter"], // other engines' scopes the content names, when any}There’s no text a player would read, no localisation and no captions. A card is its condition, its ranking inputs, its redraw policy, its tags, its outcomes, and its box-shaped fields, and an outcome is its condition, its changes, and its own box-shaped fields, which keeps a bundle small.
Author metadata is kept. Titles and purposes ship by default because they make a trace readable: “why did Ambush at the ford get dealt here?” is a question the log can then answer in words.
Maps, when you ask for them
Section titled “Maps, when you ask for them”Maps are off by default, because geometry is authoring data, the engine deals in tag names,
and a shipping build needn’t carry anything it doesn’t use. Turn on export.map in the project
shard (or pass --map to one export) and the bundle gains a maps block, one entry per
spatial tag group:
maps: [{ box: "village", // the owning box, by gameId group: "zone", // the tag group, by gameId zones: [ { tag: "tavern", polygon: [{ x: 0, y: 0 }, { x: 40, y: 0 }, { x: 40, y: 30 }] }, ], backgrounds: [ { file: "assets/village/plan.png", x: 0, y: 0, width: 800, height: 600, opacity: 0.6 }, ], sites: [ // where the placed hands stand { hand: "the-forge", x: 210, y: 340 }, { hand: "the-well", x: 120, y: 80 }, ],}]Everything is named by gameId, the same names peek takes, so a host can match a shape to
the tag it draws. Background pictures are written as files next to the bundle at the path
each entry names, ready for an engine to import.
sites is where each placed hand stands on the map, sorted by hand gameId so the bytes
don’t move when a shard is reordered. A hand nobody has placed has no entry, and a map with
no placed hand has no sites key at all. Which zone a hand belongs to isn’t repeated here,
because the hand’s own binding is what the engine deals from.
The engine never reads any of it. It’s there for a host that wants to draw an in-game
map without building its own export. describeBundle reports whether a bundle carries maps,
so one can’t slip into a build unnoticed.
The staleness check
Section titled “The staleness check”content.hash is a hash over the canonical source shards. storyletengine validate
recomputes it and errors if a committed bundle doesn’t match the shards:
$ storyletengine validate the-hamlet.storyletserror: dist/the-hamlet.storyletsc: bundle is stale (content hash does not match the shards); run: storyletengine exportThat’s what makes committing the bundle safe. The default is to commit it, marked
merge=ours in .gitattributes. You regenerate it, you never hand-merge it, and the hash
means a stale one can’t land without validate saying so. Ignoring the bundle instead is a
choice you can make in .gitignore.
The same triple (project, version, hash) ties a save to the bundle it was made
against.
Versioning
Section titled “Versioning”The schema tag (storylets/bundle@0) versions the format, and runtimes refuse a major
version they don’t speak. Canonical source serialisation is versioned the same way, in each
shard’s own schema tag. A change to how shards serialise is a schema bump even if no field
changed, because the bytes are part of the contract.
The save envelope
Section titled “The save envelope”A running engine snapshots to a storylets/save@2 envelope, which holds what is not a
property: what a shared one-shot spent, then every flow’s own blob, keyed by the flow’s name.
Every id in it is immutable, so renaming things in the project doesn’t break a save.
Property values live in the game’s registry (a ScopeRegistry, one per game, see
Running it with Patter). An engine built without one makes
its own, and then its envelope carries that registry’s values under registry, so one call is
still the whole run. An engine given the game’s registry leaves them out, and the game saves the
registry once, beside every engine’s envelope. The keys are the same on every runtime:
story for the shared @story, storylets/<kind>/<id> for a shared box, deck, hand, or
value bag, and storylets/flow/<flow>/story or storylets/flow/<flow>/<kind>/<id> for a
flow’s own. A bag with no declared properties isn’t registered.
Claims aren’t in it, deliberately. A claim is just “this card is on that hand right now”, so it is read back off the boards rather than stored twice. What a shared one-shot spent is durable, so that does ride the shared half.
{ schema: "storylets/save@2", content: { project: "proj_salt", version: "0.3.0", hash: "a91c..." }, registry: { // only when the engine made its own registry story: { reputation: -1 }, // the shared-flagged properties world: { gold: 120 }, // a self-backed @world "storylets/box/b_enc": { heat: 2 }, "storylets/flow/main/deck/k_docks": { visits: 3 }, // this flow's own copies "storylets/flow/main/hand/h_board": { owner: "elder" }, "storylets/flow/main/value/v_docks": { danger: 3 }, // tag state }, shared: { spent: ["c_pixie"], // shared one-shots taken out of the world }, flows: { "main": { turns: { "b_enc": 12 }, // per-box turn counters, per flow prng: 1199730143, // mulberry32 state, uint32, per flow cooldowns: { "c_ambush": 15 }, // absolute next-eligible turn of that card's box board: { "h_board": ["c_rat_job"] }, // hand contents, in dealt order playLog: [ { card: "rat-job", outcome: "accepted", turn: 11 } ], }, },}A storylets/save@1 envelope, from before the registry held the properties, carried them as
props partitions in its shared half and in each flow. Every runtime still loads one, and its
values move into the registry as it does. saveFlow(id), which parks one flow, still carries
that flow’s props, because a parked flow’s values leave the registry when it closes.
A @world your game binds is never in the envelope. It’s your game’s state (the engine only
borrows it), so your game saves it once, beside the envelope
(why). A self-backed @world is a property the engine’s own
registry stores, so it rides under registry. The .storyletsave FILE on disk is
storylets/savefile@1, which is { schema, engine: <the envelope>, world?: <your values> },
both halves in one file. Storyletter’s Board writes them, every runtime reads and writes
them, and a foreign, malformed, or wrong-project file is refused at the boundary instead of
corrupting a run.
Loading a save against edited content is safe. Orphaned keys drop harmlessly: a deleted card’s cooldown, a deleted hand’s contents, a re-flagged property’s old partition. Newly declared properties get their defaults. A save whose bundle triple doesn’t match is flagged, never guessed at.
Determinism
Section titled “Determinism”The PRNG is mulberry32, bit for bit across every runtime, with its state a plain uint32
in the save. The default seed is 0. There’s one PRNG per flow. random(a, b) draws
advance it, tie shuffles advance it, and the hand-order shuffle in a multi-hand deal
advances it.
So a seeded coverage run reproduces exactly, and the same seed gives the same result in the editor, in CI, and in every engine.
Open source under the MIT licence, made by Ian Thomas.