The shards
A project is made of eight kinds of file, one extension each. Every one is JSON5 with trailing commas, and every expression is stored as plain source text, never as a syntax tree. Seven of them are yours. The eighth, the installation contract, is written by a venue’s server.
| File | Extension | Holds |
|---|---|---|
| project | <name>.storyletproj |
settings, @world and @story declarations, coverage drivers, export config |
| box | box.storyletbox |
the card template, @box properties, the ranking toggle, whether the box is timed |
| tags | tags.storylettags |
tag groups: their tags and each tag’s properties |
| hands | hands.storylethands |
hand templates and hands |
| deck | <name>.storyletdeck |
the cards, and the deck’s own gate and @deck properties |
| view | view.storyletview |
the canvases: where cards sit on a deck’s node canvas, and nothing about what they are |
| map | map.storyletmap |
the box’s map: where its hands stand in space |
| contract | contracts/<installation>.storyletcontract |
what a venue this project is installed at depends on. Not yours: the server writes it |
How they sit on disk
Section titled “How they sit on disk”A deck is one file and a box is one folder, so two people adding decks to the same box add different files and never meet. That’s most of why everyday edits merge on their own, and Version control has the rest.
The two arrangement shards
Section titled “The two arrangement shards”Positions live apart from content, in two shards of their own, and neither holds anything about what a thing is.
view.storyletview is the one shard you can ignore. It holds where a card sits on a deck’s
node canvas, and the frames drawn round them. Delete the file and you lose a layout, never
content.
map.storyletmap holds where a box’s hands stand on its map. That one is not safe to lose,
because a site leaves the project. It ships in the bundle’s maps block when you export
with the map, and it’s where a screen or a kiosk stands. Which zone a site belongs to isn’t
recorded here either, since that’s the hand’s own tag binding, in hands.storylethands.
Because of that split, two people arranging the same canvas can only produce a position conflict, never a content conflict.
The map lived inside view.storyletview until September 2026. It is still read from there,
for one release. Storyletter moves it the first time you touch the map, and
storyletengine format moves a whole project at once.
The fixed basenames (box, tags, hands) are kept even though the extension already
carries the type, so a box folder reads the same in a file browser and a diff.
The project shard
Section titled “The project shard”One per project, at the root. It holds everything that isn’t specific to a box.
{ schema: "storylets/project@0", project: { id: "proj_village", name: "The Hamlet", version: "0.1.0", }, coverage: { drivers: { "@world.time_of_day": { cadence: "sometimes", kind: "recurring", values: [ "night", ], }, }, }, export: { bundle: "../storylet-dist/the-hamlet.storyletsc", metadata: "full", }, settings: { playAdvancesTurns: 1, }, story: { properties: [ { default: "arrival", name: "act", stages: [ "arrival", "act-1", "act-2", ], type: "quality", }, ], }, templates: {}, world: { properties: [ { default: "day", name: "time_of_day", type: "enum", values: [ "day", "night", ], }, ], registry: {}, },}world declares your game’s state surface and, in registry, who owns each part of
it, whether the storylet engine holds @world itself (when it plays on its own) or your host does. story
declares the story’s own globals.
A declaration anywhere except @world may also carry shared, the sharing axis for
projects that run several flows. true is one value
across every flow, and false is a copy per flow. Absent means the scope default (@story
shared; box, deck, hand, and tag properties per-flow). @world takes no flag, because it is
the game’s own state and always shared, and the compiler refuses the flag there.
Beside it, and independent of it, durable says the value
outlives a run, meaning the
installation’s memory when it’s also shared, and one player’s pocket when it isn’t. The
engine never reads it. Whoever runs the engine lifts and restores durable values at a run
boundary. @world takes no flag here either, and for the same reason.
settings.play is "solo", "shared", or "venue" (absent means "solo"). It sets the
play ladder, which decides how much
of itself Storyletter shows. It is authoring configuration and is never compiled into the
bundle. A project that contains more than its rung shows is a validation warning naming the
rung.
export names where the compiled bundle goes and whether author metadata rides along
(full) or is stripped for size (stripped).
settings.playAdvancesTurns is the default number of turns a play advances its box’s
clock. A host can override it per call.
coverage.drivers configures the coverage harness, keyed by property reference. Only
@world is drivable, because every other scope is written by play itself. A driver’s kind
is initial (rolled once per playthrough) or recurring (re-rolled per turn at its
cadence), and values is the pool it draws from. This block never reaches the bundle.
templates is the configuration bag for templates of play, keyed by template name. The
core validates only what it knows about.
gameScopes (optional, usually absent) names the game’s
shared scopes folder, relative to the
folder holding the project file: gameScopes: "../../shared/game-scopes". You rarely need it.
Every tool finds a game-scopes/ folder on its own by looking in the project folder and then
each folder above it, stopping at the root of your repository, so this is only for a folder
that search wouldn’t reach. A path that doesn’t exist is an error. Storyletter writes it for
you when you share scopes into a folder outside the search. It never reaches the bundle.
patter (optional) names the Patter project
this one is paired with, relative to the folder holding the project file:
patter: "../story/the-hamlet.patter". With it, validate checks each card against the scene
of the same name in that project’s published bundle. A path that doesn’t exist is a warning,
not an error, since a writer may have the cards without the dialogue. It never reaches the bundle.
Where the game shares its scopes and its game.scopes.json declares @world, the project’s
world declarations are a synced copy of those. Storyletter rewrites the copy from the
shared file whenever it saves, so the project still compiles when it’s packed or checked out
on its own. The shared file wins, and validate warns if the two ever differ.
The box shard
Section titled “The box shard”The card shape and the ranking toggle. It’s small and changes rarely. In a team this file is usually owned by the lead, because changing a field’s name or type reshapes every card in the box.
{ schema: "storylets/box@0", box: { fields: [ { default: "", name: "scene", type: "string", }, ], gameId: "village", id: "b_village", outcomeFields: [ { default: "", name: "after", type: "string", }, ], properties: [], purpose: "Every story beat in and around the village.", ranking: { specificity: true, }, title: "Village", },}fields is the card template, what every card in this box carries. Fields are data for
your game (a scene id, an animation reference, a text key). The engine never interprets them
and expressions can’t read them. outcomeFields is the same again for outcomes, what an
outcome in this box may carry, declared the same way, for the line your game shows after a
press without spending a card on it. Leave it out when you have none. properties is the
@box scope. ranking.specificity is the one per-box ranking toggle.
An optional turn makes this a timed box:
turn: { seconds: 60 }, // one turn a minute of the runseconds is a whole number of seconds, one or more. Declaring it says that a turn in this
box is a length of time, so plays in it no longer advance its clock, your game ticks it
instead, and a card’s redraw: 30 reads as thirty minutes. See
Dealing. Leave it out and a turn is a play, which
is the ordinary box.
The tags shard
Section titled “The tags shard”Tag groups and their tags. Tags are declared values, so a typo is a validation error, not a card that never deals. A tag may carry properties of its own.
{ schema: "storylets/tags@0", groups: [ { gameId: "zone", id: "d_zone", purpose: "Where in the world this beat belongs.", tags: [ { gameId: "village", id: "v_village", }, { gameId: "forest", id: "v_forest", properties: [ { default: 0, name: "peril", type: "number", }, ], }, ], }, ],}A group’s name is unique within its box, not project-wide, so two boxes can each declare
a zone group. Tag names are unique within their group, and unique within their box as
well, because a tag’s properties are addressed as value.<box>/<tag>.<name>, which qualifies by box
and no further, so two groups in one box that both name a tag docks leave the second one
with no address of its own. Rename one of them, or pin a distinct gameId on one. Validation
warns about it in this release and refuses it in the next. Ids are unique across the whole
project.
The hands shard
Section titled “The hands shard”Hand templates, and the hands made from them.
{ schema: "storylets/hands@0", hands: [ { chosen: { d_zone: "v_village", }, gameId: "the-inn", id: "h_inn", slots: 2, template: "t_whats_happening", title: "The Inn", }, ], templates: [ { chooses: [ "d_zone", ], gameId: "whats-happening", id: "t_whats_happening", properties: [], purpose: "The main lens: which story beat happens at a place now. One hand per place.", slots: 3, }, ],}A template sets bindings (tags fixed for every hand that uses it), chooses (the tag
groups each hand fills in for itself), one shared condition, a default slots, and the
properties every hand carries. Templates are author-side only, and your game never names one.
A hand is either made from a template (template, plus a chosen entry for every group
the template lists in chooses) or written out in full (a rule object with its own
bindings, condition and slots). It’s one or the other. A hand made from a template can
override only slots; everything else comes from the template.
A hand’s gameId is the name deal is called with from game code, so renaming one is a
breaking change beyond the project’s own borders, which validate and the merge driver both
flag. A hand with no gameId of its own gets one derived from its title.
The scaffolded starter hand shows the written-out form:
{ gameId: "whats-next", id: "h_w7w0n4vm", purpose: "The starter hand: deal it to see what could happen now.", rule: { bindings: {}, slots: "unbounded", }, title: "What's next?",}A hole filled from a property
Section titled “A hole filled from a property”A chosen value is normally a tag id. It can instead be a property reference, and then
the hole moves. The engine resolves the reference each time the hand is asked and binds the
hole to the tag the value names.
{ gameId: "the-elder", id: "h_elder", template: "t_npcs_you_can_talk_to", chosen: { d_zone: "@hand.zone", }, properties: [ { default: "village", name: "zone", shared: true, type: "enum", values: ["village", "forest", "mill"], }, ],}Move the Elder with setProperty("hand.the-elder.zone", "forest") and the next deal follows,
so forest-tagged cards become available at the Elder’s hand, and village-tagged ones leave it. There is
no other verb. A shared: true declaration like the one above makes the move a world fact, so
every flow sees the Elder in the forest; leave the flag off and each flow moves its own copy,
which is how a party gets a “what is around me” hand that follows them about.
The reference may be @hand.<name> (a property this hand or its template declares),
@story.<name>, or @world.<name>. It has to be a string or an enum, because the value has to
be able to name a tag. A value that names no tag in the group leaves the hole unbound, which
is a wildcard rather than an empty hand, and the deal says so on its
trace. A standalone hand does the same thing with a rule binding.
place is the one group this never applies to, because it’s the hand’s own name.
A deck shard
Section titled “A deck shard”One file per deck. It carries the deck’s own identity, its optional gate condition, its
@deck properties, and its cards. It may also carry shared, which makes every card in
the pile scarce across flows unless a card says
otherwise (one of each in the world, rather than one each per participant). It may also carry
durable, which makes every redraw: never card in it
stay played past the end of the run.
{ schema: "storylets/deck@0", deck: { gameId: "arrival", id: "k_arrival", properties: [], purpose: "A newcomer finds their footing.", title: "Arrival", }, cards: [ { condition: "@act == \"arrival\"", fields: { scene: "scn_gate", }, gameId: "arrive-at-the-gate", id: "c_arrive", outcomes: [ { changes: { "@story.act": "\"act-1\"", }, fields: { after: "The gate swings shut behind you.", }, gameId: "step-through", id: "c_arrive_o", title: "Step through the gate", }, ], priority: 10, purpose: "The road ends at a weathered gate; smoke rises from the Inn beyond.", redraw: "never", tags: { d_zone: [ "v_village", ], }, title: "Arrive at the Village Gate", }, ],}Reading a card top to bottom:
conditiongates whether the card is available at all.@actis short for@story.act.fieldsfills in the box’s card template. Here the game readssceneand plays it.priorityis the first ranking key. It can be a number or an expression.redrawis the cooldown policy in this box’s own turns:always,never, or a number.copies(absent here, so 1) is how many hands may hold the card at once, counted within one playthrough.sharedmakes the card scarce across flows, one goblin in the whole world rather than one each. Absent, it takes its deck’s flag, so the usual place to write it’s on a deck whose whole pile is scarce. On the card it’s the override for a single unique card sitting in an ordinary deck.sharedCopiesis then how many hands may hold it anywhere, defaulting tocopies, socopies: 1, sharedCopies: 5is five in the world, one to a customer.durablesays this card’sredraw: neverspend survives the run, for whoever played it, or for everyone when the card is also shared. Absent, it takes its deck’s flag, exactly asshareddoes. On any other redraw it means nothing past the run, and the compiler warns.tagsmaps group ids to tag ids. An absent group is a wildcard, so this card would match any binding of any other group the box declares. Exclusions are written as conditions over@hand, not as negative tags.outcomesare the choices. Each has achangesmap from a fully-qualified@scope.nametarget to an expression, plus an optionalconditionthat gates it. When the box declaresoutcomeFields, an outcome fills them in afieldsmap of its own, exactly as the card fills the card template. Hereafteris the line the game shows once the gate is stepped through. The engine hands it over with the outcome and never reads it. A card may have no outcomes at all, such as a notice, a headline on a screen, or a codex entry, whose whole job is to be shown. Your game plays one with no outcome once it has shown it, so it still counts as played, rests by itsredrawand leaves its hand; nothing is written. Such a card may leave theoutcomeskey out altogether, which reads as an empty list.
Property declarations
Section titled “Property declarations”The same shape is used everywhere state is declared: @world, @story, @box, @deck,
@hand, and on a tag. A tag group can declare properties too, and then every tag in the
group has them. The group says what the property is, and each tag carries only its own
starting value in values. That’s the shape to reach for when “every zone has a haunting
level” is what you mean, and it’s what keeps a zone added later from quietly arriving
without one.
| Field | Notes |
|---|---|
name |
referenced as @scope.name; lower case, unique in its scope |
type |
boolean, number, string, enum, flags or quality (which to use) |
default |
required, so a declared property always has a value |
values |
for enum and flags. On a TAG, values means something else: this tag’s starting values for the properties its group declares |
stages |
for quality: the ladder, in order, lowest first |
writable |
@world only. false makes the property read-only to the story: a condition may read it, an outcome that writes it is a compile error. The game still moves it through its resolver. Default true |
purpose |
author metadata |
Card template fields use the same shape. The difference is what they’re for. A property is state the expressions read and write, and a field is data handed to your game.
The installation contract
Section titled “The installation contract”A project running at a venue (a museum floor, a park, a show) depends on names that live outside it. Stations are bound to particular hands, a scheduler ticks particular timed boxes, a clock drives particular properties, and the crew read particular card fields. Rename one of those and the venue breaks, quietly, after the change has shipped.
So the venue writes down what it depends on, one file per installation, in a contracts/
folder beside the project shard:
{ schema: "storylets/contract@0", by: "Storylet Server 0.1.0", boxes: { street: { turn: 60 }, // the scheduler ticks these }, fields: [ "prompt", // the crew and the bridges read these "cue", ], hands: [ "the-well", // stations are bound to these "the-forge", ], installation: "the-park", properties: [ "world.time_phase", // the clock drives these "story.visits", ], revision: 12,}Everything in it is by gameId, and a property is written the way listProperties() prints
it, with no @. A property may instead be written as { path: "story.visits", type: "number" },
and then a type change is caught as well as a rename. A tag property whose tag name two boxes
share carries the box too (value.harbour/docks.danger), which is the address the engine
takes for it; the short form there would provision the venue against a name its own engine
refuses. A project playing at two venues has two
of these files; two files naming the same installation is an error.
storyletengine validate treats a break as an error: a contracted hand that no longer
exists, a contracted box whose turn is no longer that many seconds, a contracted property that
has gone or changed type, a contracted field no box declares any more. Each one names the
venue, so the message says who cares. storyletengine contract show lists what each
installation depends on. The contract itself never reaches the compiled bundle. The server
does not need its own contract back, it needs the bundle to still honour it.
The server that writes this does not exist yet. Until it does, a project either has no contract at all (which is the normal state, and nothing changes) or one written by hand.
Open source under the MIT licence, made by Ian Thomas.