The shards
A project is made of six 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.
| 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 |
| 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 arrangement layer: where things sit on a canvas or a map, and nothing about what they are |
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; see Version control for the rest.
The arrangement layer
Section titled “The arrangement layer”view.storyletview is the one shard you can ignore. It holds positions (where a card sits
on a deck’s canvas, where a hand’s site sits on a map) and nothing else. Which zone a site
belongs to isn’t recorded here: that’s the hand’s own tag binding, in hands.storylethands.
Delete the file and you lose a layout, never content.
Because of that split, two people arranging the same canvas can only produce a position conflict, never a content conflict.
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: "dist/the-hamlet.storyletsc", metadata: "full", }, settings: { playAdvancesTurns: 1, }, story: { properties: [ { default: "arrival", name: "act", type: "enum", values: [ "arrival", "act-1", "act-2", ], }, ], }, 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, false a copy per flow. Absent means the scope default (@story shared;
box, deck, hand and tag properties per-flow). @world takes no flag: it is the game’s own
state and always shared, and the compiler refuses the flag there.
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.
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", 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. properties is the @box scope. ranking.specificity is
the one per-box ranking toggle.
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. 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: 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; validate and the merge driver both flag
it. 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 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.
{ 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\"", }, 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, not one each. Absent, it takes its deck’s flag, so the usual place to write it is on a deck whose whole pile is scarce; on the card it is 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.tagsmaps group ids to tag ids. An absent group is a wildcard: 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.
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 |
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; a field is data handed to your game.
MIT-licensed open source · Made by Ian Thomas · storylet.studio