Playing in your game
Your designers’ cards reach the player through a Storylet Engine runtime. You publish the
project to one .storyletsc bundle, drop it into your engine, and the
runtime loads it and deals from it. Your game asks for a hand, gets back ranked cards, shows
them however it likes, and plays the outcome the player picks.
There’s one runtime per engine, for JavaScript, Unity, Unreal, and Godot. Every one is checked against the same test suite, so the same bundle deals the same cards everywhere, right down to the random draws. All four ship today.
Pick your engine
Section titled “Pick your engine”| Engine | Language | What you get | Getting started |
|---|---|---|---|
| JavaScript / Web | TS/JS | A release zip with the runtime build, the play-helpers build, and a browser drop-in | JavaScript |
| Unity | C# | A package folder for Packages/, with a .storyletsc importer, the Runtime State window, and a demo project |
Unity |
| Unreal | C++ / Blueprint | A source plugin for Plugins/, with a .storyletsc factory, an editor state panel, and a demo project |
Unreal |
| Godot | GDScript | An addon for addons/, with a .storyletsc importer, an in-game state panel, and a demo scene |
Godot |
Every runtime is a zip on GitHub Releases, linked from the download page. Nothing is published to npm, UPM, Fab, or the Godot Asset Library. Each zip carries everything it needs, including its licence, so no package manager is assumed.
The shape of an integration
Section titled “The shape of an integration”It’s the same five steps in all four engines. Learn them once here and each engine page is mostly install notes and the local spelling.
- Load a bundle. Every engine imports
.storyletscfiles as assets. A broken bundle still imports, with the error readable on the asset. - Create an engine over the bundle, with a seed, and open a flow on it by name.
- Deal a hand by name, or peek at a box by tag. You get back ranked card views, each with an id, a gameId, a title and purpose (unless the bundle was stripped), and the card’s fields.
- Read the card’s fields and do whatever your game does with them. Play a scene, run an animation, put text on screen.
- Ask for the outcomes, offer the available ones, and play the one the player picks. State
writes, the cooldown starts, the clock advances. An outcome carries fields of its own when
the box declares them, so the line you show after the press can come with it. A card with
no outcomes at all (a notice, a codex entry, a headline on a screen) is played with none.
Pass
""as the outcome once it has been shown.
Then save and load the whole run through one call.
All of this happens on a flow, one playthrough over the engine’s world. You open one by
name ("main" is the usual name for a single-player game) and every play call lives on it.
One engine can run several flows at once, as parallel personal playthroughs over the same
shared state, which is what an interactive experience with many participants needs. A game
that wants one playthrough opens one flow and never thinks about it again.
Your game’s state
Section titled “Your game’s state”Before you deal, there’s one thing to wire up. @world is the set of properties your game
owns and the story’s conditions read. It works the same way in all four runtimes, so it has
a page of its own. Read it before you pick an engine.
The API, in one table
Section titled “The API, in one table”Names follow each language’s idiom, but the surface is the same everywhere.
On the engine (the world):
| Call | Does |
|---|---|
new Engine(bundle, { seed, log, world }) |
Build the engine; world binds your game’s @world resolver |
openFlow(id, { seed, restore }) |
Open (or replace) a named flow. All play happens on the flow it returns, and restore opens it as it was |
getFlow(id) / flows() / closeFlow(id) |
Find, list, and close flows; a closed flow’s handle refuses every call |
reset() |
Close every flow and reseed shared state |
saveGame() / loadGame(envelope) |
The whole run (shared state plus every flow) in and out. The load returns a report |
saveFlow(id) |
One flow’s state on its own, to park a playthrough that is stepping away |
previewLoad(envelope) / previewFlowRestore(id, save) |
What that load would change, without changing it |
getProperty(path) / setProperty(path, value) |
Shared state and @world only; a per-flow path is refused |
subscribeTrace(handler) |
Every flow’s events, one stream, tagged with the flow id |
log() / clearLog() |
The RUN’s retained log, if you asked for one: every flow’s entries in one order, each naming its flow |
listProperties() / listBags() |
Every shared property, and the shared bags behind them |
sharedClaims() |
How many copies of each card the world’s flows are holding (shared scarcity) |
On a flow (one playthrough):
| Call | Does |
|---|---|
peek(box, criteria, n) |
Look at the top of a box through tag criteria, without dealing anything |
deal(hand) |
Refresh one hand; returns its new contents. A refresh evicts cards no longer eligible and fills empty slots; a card that is still eligible stays dealt, so a newly eligible card waits for an empty slot |
dealMany(hands?) |
Refresh several or all hands, same rule; returns what was dealt, keyed by hand |
board(box?) |
The current contents of every hand, or of one box’s hands |
outcomes(card, hand) |
This card’s outcomes with availability, evaluated against current state, and each one’s fields |
play(card, outcome, hand, { advanceTurns }) |
Apply an outcome, or play a card that has none with "" |
advanceTurns(box, n) |
Advance one box’s clock |
turn(box) |
Read one box’s clock |
listBoxes() |
Every box: id, gameId, title, current turn |
listProperties() |
Every declared property: path, type, value, default, enum values |
getProperty(path) / setProperty(path, value) |
Read and write state by path (the flow’s merged view) |
subscribeTrace(handler) |
Stream every deal, peek, evict, play, write, turn, and diagnostic |
log() / clearLog() |
The retained flow log, if you asked for one |
And beside both, a free function rather than a method on either. describeBundle(bundle)
tells you what a bundle offers (its boxes, hands, decks, and declared properties) without
building an engine or running anything. It’s described with the rest of the
dev tools.
Three things are worth holding on to.
A dealt card doesn’t carry outcome availability. A card can sit on the board for many turns
while the world moves, so ask outcomes() when you’re about to show them and you get the
current answer.
A card with no outcomes is still played. outcomes() answers an empty list for it. Play it
with "" and everything a play does happens except the writes. It counts in the play history,
its cooldown starts, the clock advances, and it leaves its hand. "" on a card that has
outcomes is refused.
You only play cards that are in a hand. play needs the card to be on the board in the hand
you name. Peeking shows you what a box would deal; it doesn’t let you play from it.
Property paths
Section titled “Property paths”getProperty and setProperty address state by path.
world.goldstory.reputationbox.village.heatdeck.arrival.visitshand.the-inn.ownervalue.forest.perillistProperties() returns rows carrying these paths, and the in-engine examiners are built on
those rows. The full list is on Your game’s state.
Determinism
Section titled “Determinism”The random generator is mulberry32, bit for bit in all four languages, and its position rides in the save. Same bundle, same seed, same sequence of calls means the same cards, in every runtime. Compatibility & conformance says how that’s checked.
Shared dev tools
Section titled “Shared dev tools”Every runtime carries the same four development surfaces: a property examiner and editor, a bundle inspector, a state logger, and a Board demo. They’re documented together on Dev tools, because they’re the same everywhere. Every runtime also carries a Live Link client. Connect your running game to Storyletter, and saves reach the run without a restart while the Board shows the game’s deals as they happen.
Where to go next
Section titled “Where to go next”If you just want it running, jump to your engine’s page in the table above. If you’re wiring up your state first, start with Your game’s state. To see the whole loop, open the Board demo that ships with every runtime and press Play. Why it matches everywhere is on Compatibility & conformance.
Open source under the MIT licence, made by Ian Thomas.