This is the full developer documentation for Ripple Effect # Ripple Effect documentation > Stories drawn as node graphs, over a folder you own. The documentation is split by what you need to do next. Choose the card that matches. Learn You are new to Ripple Effect. [Your first story](/tutorials/your-first-story/) goes from an empty folder to a playable branch in a few minutes; the [tutorials](/tutorials/) page lists them all. Do You have a task. The how-to guides start with [installing the app on each platform](/how-to/install-the-app/), and go on to [connecting an MCP client](/how-to/connect-an-mcp-client/) such as Claude Code, Claude Desktop, Cursor or any other client, [building a board](/how-to/work-on-the-canvas/) and [playing a campaign](/how-to/play-a-campaign/). Look up You need a fact. Start with [every node](/reference/nodes/); beside it are the [Python reference](/reference/python/), the [MCP tools](/reference/mcp-tools/) an agent can call and the [diagnostics](/reference/diagnostics/) the messages panel shows with a code on them. The Python reference holds every function a script, a passage or the console may call, what it answers and what it refuses. Understand You want to know why it works as it does. Start with [boards, nodes and macros](/explanation/boards-nodes-and-macros/), then [how a story runs](/explanation/how-a-story-runs/). [What Ripple Effect is](/start-here/what-ripple-effect-is/) says what the app is for. # What Ripple Effect is > The shape of the tool in one page, and why boards, passages, choices and an ordinary folder fit together the way they do. Ripple Effect is a desktop application for writing interactive stories as graphs. You lay passages out on a canvas, wire each choice to what it leads to, and press play to walk the result. It is aimed at tabletop prep, game design documents, quest and dialogue trees, and anything else where the interesting part of a document is the paths through it. ![A branching story laid out on a canvas, passages wired to the choices that lead between them](/_astro/board-light.BI1_yn4D_Z1V0sGO.webp) ![A branching story laid out on a canvas, passages wired to the choices that lead between them](/_astro/board-dark.SXOEmDAO_20Hnfl.webp) A board: passages, the choices between them, and a Start. ## Branching, and then computing [Section titled “Branching, and then computing”](#branching-and-then-computing) Where ArcWeave gives you boards, connections and components in a browser, Ripple Effect gives you the same shapes as a native app over a folder on your own disk. The shapes are familiar on purpose: a story that branches is easiest to hold in your head when it looks like what it is, a set of places and the ways between them. Branching alone is not enough for a story that has to remember things, so a visual scripting layer sits around those shapes: loops, conditions, arithmetic, dice, state machines and Python. The point of the layer is that a story can compute as well as branch. A passage can say *the goblin has 3 hit points left* without forking into a passage per hit-point count, because the number is kept in a console variable or a component, not in the shape of the graph. ## The parts, and what each is for [Section titled “The parts, and what each is for”](#the-parts-and-what-each-is-for) A **board** is one thread of story: a chapter, a conversation, an encounter, drawn as cards and the wires between them. A passage is a card and a choice is a wire. There are two ways to join boards together: a *Jump* hands the story to another board, and a *macro* is a stretch of story written once and called from anywhere. [Boards, nodes and macros](/explanation/boards-nodes-and-macros/) explains where those two differ. Every passage is one file per language, so writing in more than one language is part of the shape of a project rather than an add-on, and the app tells you which translations have fallen behind. The reasoning is in [Passages and translations](/explanation/passages-and-translations/). A story also needs things to remember, and the app gives three different homes for them because they are different kinds of fact. Components are the characters, items and places. Console variables are the numbers and flags. State machines are the shapes: a door that is locked, then open. A board reads and changes all three; see [Components and entities](/explanation/components-and-entities/), [Console variables](/explanation/console-variables/) and [State machines](/explanation/state-machines/). Computation can sit in a node on the board, or inside a sentence. [Python in a story](/explanation/python-in-a-story/) covers why both exist. A passage names a soundscape, which is music and sound on a timeline in a file of its own, and it stays in force until another replaces it, while a node or a script plays a sound once and the story carries on. See [Soundscapes and one-shot sounds](/explanation/soundscapes-and-sounds/). ## Playing and shipping use the same engine [Section titled “Playing and shipping use the same engine”](#playing-and-shipping-use-the-same-engine) The play button runs the story from any node, with a trace beside it saying what the story did. It uses the same engine that an exported campaign runs, which is why a playthrough in the editor is evidence about the shipped story rather than an approximation of it; [Why the preview is the shipped story](/explanation/the-preview-is-the-shipped-story/) gives the argument. Export then compiles the project into a campaign that a runtime plays with no editor near it, or copies the project for a translator. These are different things with different purposes, set out in [What an export is](/explanation/what-an-export-is/). A game that embeds the story can be told what happens in it through named host callbacks, described in [Host callbacks](/explanation/host-callbacks/). ## Finding your way around a large story [Section titled “Finding your way around a large story”](#finding-your-way-around-a-large-story) A story of any size becomes hard to hold. A Search section finds, and replaces, a word or a pattern across the whole project, in what you wrote rather than in the JSON underneath. Script navigation and route tracing show how boards hand the story to each other. [Reading a large story back](/explanation/reading-a-story-back/) explains what each view is for. Git is built in, so you can see what changed, side by side, and a conflict is shown rather than decided for you. [History, sharing and conflicts](/explanation/history-and-sharing/) covers how history, sharing and conflicts work. The app is also an MCP server, so Claude Code, Cursor and the rest can read the project, play it and, once you allow it, write into it. What an assistant may do, and how to work with one, is covered in [Working with an AI assistant](/explanation/working-with-an-assistant/). ## Why a project is a folder [Section titled “Why a project is a folder”](#why-a-project-is-a-folder) Underneath all of it, a project is a folder of ordinary files (Markdown, JSON, `.py`) that opens without the app. That choice is what makes the version control honest, since git sees exactly what you wrote, and it is what lets any other tool read it. The cost is that what belongs to the project is held in those files, and in a `.ripple` folder beside them, rather than in a database, so the app has to keep to a format it can explain. [A project is a folder](/explanation/projects-are-folders/) goes through the consequences, and [Project files and the .ripple folder](/reference/project-files/) says exactly what is in it. ## Related [Section titled “Related”](#related) * [Your first story](/tutorials/your-first-story/): from an empty folder to a playable branch, step by step. * [How to install Ripple Effect](/how-to/install-the-app/): Linux, Windows and macOS, and what to do when the installer is stopped. * [The workspace window, menus and palette](/reference/workspace-window/): how the app is laid out. * [Licensing](/about/licensing/): what is yours, what is not permitted. # What the accessibility settings do and do not claim > How the contrast and colour-vision palettes were chosen, why there is no mode for achromatic vision, why there is no screen-reader support, and why cards on a board ignore the system's text size. Ripple Effect offers two accessibility settings of its own, *Contrast* and *Colour vision*, and follows your system’s text size in the chrome. This page explains what each of them is for and, as carefully, where each stops. The aim is that nothing in the app reads as a promise it does not keep. ## Two settings, and the theme beside them [Section titled “Two settings, and the theme beside them”](#two-settings-and-the-theme-beside-them) Both settings belong to the app, not to a project, so they follow you from project to project. They are separate from the theme: you can have dark and firmer at once, or light with a colour-vision palette. A first launch asks about them on its first page, beside a small preview of the app. The rows themselves are listed in [Settings, accessibility and updates controls](/reference/settings/). The house style that these settings modify has its own rule about colour: the accents are a vocabulary, not a palette. A colour in the app means something, such as a node family, a traced route, a mark that version control puts on a file, or a status. That is why accessibility is done by swapping whole vocabularies and not by tinting the screen. ## Contrast: firmer, not different [Section titled “Contrast: firmer, not different”](#contrast-firmer-not-different) Without the setting the app already clears a floor. The whole palette is measured against WCAG 2.1 AA in both themes: the edge of a text field, an outlined button, a chip, a switch or a checkbox is clearly visible, and a file left out of the history is a visible dot. *More* contrast takes body and caption text to the stricter accessibility grade (AAA) and gives every text field, button, chip, switch and checkbox an edge you can find. It deliberately stops there. The colours stay the same and the page does not change shape. The reason is that there are two ways to make something more legible. One is to collapse the surfaces toward white to buy separation, which would be a different design rather than a firmer one. The other is to keep the design and tighten the floors it already clears. The app takes the second: the ink is raised and the surfaces are left alone. The hairlines that divide the page stay decoration. ## Colour vision: three modes for eight kinds [Section titled “Colour vision: three modes for eight kinds”](#colour-vision-three-modes-for-eight-kinds) *Colour vision* is *Off* (the house colours), *Protan*, *Deutan* or *Tritan*. They stand for protanopia and protanomaly, deuteranopia and deuteranomaly, and tritanopia and tritanomaly. That is three modes for six kinds, because an anomaly is a milder case of the dichromacy beside it and is served by the same hues. A mode recolours everything the app uses colour to tell apart: * the node families on a board; * traced routes; * the marks on files that version control puts there; * status colours; * syntax highlighting; * the diff, which stops being red and green. Neutral greys are left alone, because every kind of colour vision agrees about them. That is what lets a mode change the vocabularies and leave the rest of the page as it was. ### How the palettes were chosen [Section titled “How the palettes were chosen”](#how-the-palettes-were-chosen) The rule is that things which share a shape must not share a colour. Palettes are verified by measuring distance after simulating all eight kinds of colour blindness, including the two the app has no mode for, and never by distance on the hue wheel. The house palette is expected to fail the same check; if the defaults were already safe, the modes would be answering a question nobody asked. The simulation is used to verify a palette and is never shown to you. Handing a reader a simulation of their own deficiency would render what they already see, and would throw away whatever separation their vision still gets. ### Why there is no achromatopsia mode [Section titled “Why there is no achromatopsia mode”](#why-there-is-no-achromatopsia-mode) The two achromatic kinds of colour blindness have no mode. This is a decision, not a gap. A traced route set can have eight routes, and eight routes cannot be eight distinguishable greys. A mode that promised it would take as much away as it gave, and a false promise is worse than none. What the palettes owe those readers instead is a spread in lightness, which serves that case as far as a palette can. ## Where a palette is not enough [Section titled “Where a palette is not enough”](#where-a-palette-is-not-enough) A palette helps; it is not sufficient. Three places in the app tell categories apart by hue and nothing else: * the board minimap and the overview’s, where a family is a few-pixel rectangle with no icon or label; * the traced routes; * a soundscape clip too narrow to have its mode written on it. The go and stop marks are different: they carry play and square icons and their labels, so a reader who sees no hue can still tell them apart. A colour-vision mode makes these better without making them readable to everyone. Fixing the first two properly would need the canvas to carry more than a colour for each thing it draws, such as a label, a dash or an index, and that belongs to the node editor package rather than to the app. The settings page does not claim more than the palette can deliver, and this page says so for the same reason. ## No screen-reader support [Section titled “No screen-reader support”](#no-screen-reader-support) The app does not claim screen-reader support: there is none. A canvas of freely positioned node cards, a painted brand and image viewers do not have a screen-reader story worth faking, and a claim that was not true would be worse for the people it was meant for than an honest absence. If that changes, the file tree and the tab strips are where most of the value would be. ## Text size: the chrome follows, the cards do not [Section titled “Text size: the chrome follows, the cards do not”](#text-size-the-chrome-follows-the-cards-do-not) The chrome (menus, dialogs, messages, the side bar) honours your system’s text size, up to double. Boards do not. Cards on a board stay at the size they were drawn at, whatever the desktop’s text size says. The reason is that a card’s height is part of the document. Every port anchor on a card sits at a fraction of the card’s measured height, so a card that grew with one reader’s setting would be written into a board that somebody else opens at their own. The size of a board’s text is therefore decided by zoom, which is the canvas’s text size: zoom is how a board gets bigger, and it changes the view, not what is stored. Outside the canvas the opposite holds. A label that runs out of room ellipsises or wraps and a dialog body scrolls, so a larger system text size changes how much fits and does not clip what is there. There is no in-app size control; the system’s setting is the one that counts. ## Related [Section titled “Related”](#related) * [Settings, accessibility and updates controls](/reference/settings/) lists the rows and their choices. * [How to change the app’s language, contrast and colour vision](/how-to/change-app-language-contrast-and-colour/) applies the settings described here. * [Boards, nodes and macros](/explanation/boards-nodes-and-macros/) explains the families and the canvas that the colour-vision palettes recolour. # Boards, nodes and macros > What a board, a Jump and a macro are, and the reasons behind the rules that shape them. A project holds a story in pieces, and the pieces have a deliberate size. A **board** is one thread of story: a `*.graph.json` file drawn as a canvas of cards and the wires between them. A project has as many boards as it wants, one per chapter, one per conversation or one per encounter, because a board is meant to be read whole and a story is not. A **Jump** hands the story from one board to the next. A **macro** is the second kind of piece, a board that other boards *call* rather than jump to. This page explains why those three exist, and why the rules around them are the way they are. ![A branching story laid out on a canvas, passages wired to the choices that lead between them](/_astro/board-light.BI1_yn4D_Z1V0sGO.webp) ![A branching story laid out on a canvas, passages wired to the choices that lead between them](/_astro/board-dark.SXOEmDAO_20Hnfl.webp) ## Why a board has one Start [Section titled “Why a board has one Start”](#why-a-board-has-one-start) Every board begins with a **Start** node, and it has exactly one. The Create menu will not offer a second, and a board that somehow has two is refused at Play with both named. Where a board begins does not depend on where its cards sit. Script navigation and the merged trace ask the same question the runner does, *which node is the Start?*, so a route drawn between two passages is the route the story takes. ## Why a choice falls back to the story’s language [Section titled “Why a choice falls back to the story’s language”](#why-a-choice-falls-back-to-the-storys-language) A **Narration** node holds a passage and grows one output port per choice. Wire the last one and a new spare appears beneath it. A choice has two things you can give it. Its **name** is the label the card shows, so a branch can be found on a wide board. Its **words** are what the reader is offered, in every language the story is being written in. A wire can carry a caption of its own as well, but that is an annotation for you and no reader sees it. When a choice has nothing in the reader’s language, it falls back to the language the story was written in. An untranslated passage is prose somebody can read past, so it can wait for its translation. An untranslated choice is a button they cannot press, so it cannot. [Passages and translations](/explanation/passages-and-translations/) takes the idea further. A choice can also run out, or be offered only while a condition holds. Both ride on the same choice rather than on extra cards, so “three times, and only while you carry the lantern” is one choice with both. The count is kept for the whole campaign, and a macro counts per call. The steps are in [How to limit a choice](/how-to/limit-a-choice/). A Narration node can also be given named, typed inputs, declared from its *Reads* row, which its passage reads by name. The names belong to the card and not to the passage file, so one passage can be a template read by two cards. ## Why a Jump stores an id [Section titled “Why a Jump stores an id”](#why-a-jump-stores-an-id) Boards are separate files, so a Jump has to point at one. It stores the target board’s catalog id rather than its path, so renaming a board or moving it between folders never breaks a link. The arrow on the card opens the board it points at. A story made of many boards needs a map. How the boards hand the story to each other is drawn for you by **script navigation**, from the left panel’s footer. [Reading a large story back](/explanation/reading-a-story-back/) covers it. ![A map of how the boards hand the story to each other](/_astro/overview-light.D1tBQF-v_Z1a4qXO.webp) ![A map of how the boards hand the story to each other](/_astro/overview-dark.DTmZb2Lk_FIgCs.webp) Cycles are the point, and nothing refuses one. A hub that the story keeps returning to, or a conversation that loops until the reader leaves it, is a wire that goes back on itself. Control flow and data flow are the subject of [How a story runs](/explanation/how-a-story-runs/). ## Why the canvas works the way it does [Section titled “Why the canvas works the way it does”](#why-the-canvas-works-the-way-it-does) Every card is made one way: right-click an empty part of the canvas and pick it from the Create menu. Drop a wire on empty canvas instead and the same menu opens there, already wired to whatever you pick. Every picker of something you made, such as a Jump’s board, a Component’s component or a Narration node’s passage, is searchable and alphabetical. The exception is a Jump’s node picker, which keeps a board’s reading order because that order means something. Every card explains itself. *About this node* on the right-click menu shows what the card is for, what each control does and what each port takes or answers, rendered as Markdown. The [nodes reference](/reference/nodes/) is written from the same text, so the card and the page cannot disagree. Cards can also carry metadata of your own, a list of names, types and values, added with *Edit metadata…* on the right-click menu. Nothing on the board reads it: it is yours, for a status, a reviewer’s note or a mark for a tool of your own. It travels with the node into a compiled campaign and to an agent, and a copied node arrives with its notes. Cards stay at the size they were drawn at whatever your desktop’s text size says, because a card’s size is part of the board you share. Zoom is how a board gets bigger, while the rest of the app grows with the setting, up to double. ## The node set [Section titled “The node set”](#the-node-set) The Create menu groups nodes as Flow, Story, Math, Logic, Lists, Maps, Entities, Value, Chance and the two ends of a macro. The ones a story meets first are **Narration** for a passage, **Jump** for another board, **End** for where a path stops, **If** and **Switch** for branching, **Dice roller** for chance, **Component** for a character or an item, and **Script** for Python. [Nodes](/reference/nodes/) describes each one, and [Node families, slots and state](/reference/node-families/) covers the types a slot offers, defaults, and how lists and maps treat a bad request. ## Why macros are copied [Section titled “Why macros are copied”](#why-macros-are-copied) A **macro** is a stretch of story you write once and call from anywhere: a shop, a rest, a skill check with its own three passages. It is a file of its own, `*.macro.graph.json`, on the same canvas as a board. It is a small board with a signature: a **Macro In** that says what it takes and a **Macro Out** that says what it answers, each with exactly one. [How to reuse a stretch of story with a macro](/how-to/make-a-macro/) has the steps. When the campaign is compiled, a macro is **copied into whatever calls it**. Calling one twice makes two independent copies. A *Once* gate inside a shop therefore fires once per shop rather than once for the whole story, and a choice’s limited-use count in a corridor called from two places has a separate budget for each. A macro cannot call itself. A macro cannot be jumped to and a board cannot be called: a Jump lists boards and a Macro card lists macros. A story can be paused inside a macro and picked up where it left off, and a line in the play window’s trace about something inside a macro takes you to the card that called it. A route traced across boards goes inside the macros it calls, so a shop or a skill check reused in several places appears on the route at each of them. ## Related [Section titled “Related”](#related) * [How to build a board](/how-to/work-on-the-canvas/), which applies the canvas habits described here. * [How to reuse a stretch of story with a macro](/how-to/make-a-macro/), for making and calling one. * [Nodes](/reference/nodes/) and [Node families, slots and state](/reference/node-families/), for every card and its ports. * [Board and navigation rules](/reference/canvas-rules/) and [Settings, accessibility and updates controls](/reference/settings/), for the menus and options stated without the reasoning. # Components and entities > Why a component says what a goblin is, an entity is this goblin, and a story changes entities only where you can point at it on a board. A story keeps meeting the same kind of thing. There are goblins in the first encounter and goblins in the third, a sword that is forged in one chapter and swung in another. Ripple Effect answers with two ideas that are easy to run together: a **component** is what a goblin *is*, and an **entity** is *this* goblin, the one the story has made and a later board can find again by name. ![A component with its typed attributes](/_astro/component-light.SopconNE_1kd7f8.webp) ![A component with its typed attributes](/_astro/component-dark.Tjtnzh37_Z1Cs0cc.webp) ## A goblin and this goblin [Section titled “A goblin and this goblin”](#a-goblin-and-this-goblin) A component is a character, a weapon, a place, a faction: a name, an optional cover picture and an ordered list of typed attributes. It lives in a `*.component.json` file in your project, like any other file you own, and it is the same on every playthrough. Hit points of 7 in the file means a goblin starts with 7. An entity is what exists once the story is under way. It has its own hit points, and when a fight takes some of them away, the file does not change. That separation is the point of having two ideas. Authoring data stays authoring data; what a playthrough does to it stays in the playthrough. There is deliberately no schema behind a component: you add what this story needs. Each attribute has a type, from the set every editor in the app offers, and the editor is a form over exactly that: a name, a cover and a row per attribute. [Project files and the .ripple folder](/reference/project-files/) has the format, and [Components and entities in Python](/reference/python/components-and-entities/) has the type names a script sees. Paths inside a component, a cover or an asset, are measured from the project root. That is a choice made for the folder: you can rename a folder or drag a component into it and every picture still points where it did. A cover picked from outside the project is copied inside first, for the reason [a project is a folder](/explanation/projects-are-folders/): a path that only works on your machine is a hole in the story for everyone else. Because an attribute that points at a file is filled by name, not typed as a path, a picture renamed behind the app’s back shows up as a marked chip instead of a silent gap in an export. ## Why a Component node has two modes [Section titled “Why a Component node has two modes”](#why-a-component-node-has-two-modes) A **Component** node stands for a component file on a board. It has one typed output per attribute, plus one that carries the whole component as a map, and it is in one of two modes. In **Component** mode it is a window onto the file: outputs, and nothing else. It reads the definition and changes nothing. In **Entity** mode the node makes the story a *thing*, keyed by the name written on the node. This is what lets the same file stand behind many independent objects. Two nodes with two names are two swords. A node on a later board that names the same entity is the sword the first board made, and it finds that sword by name, not because anything was wired across. Attribute inputs appear beside the outputs in Entity mode, and this is why the app has no Set node and no Get node for entities. A second node naming the same entity later in the story *is* the setter. A state change should be something you can point at on the canvas, and a node with a name on it, in a place on a board, is that. A node whose name is wired from upstream makes one entity per name it is handed, so a loop that forges a sword a turn forges a sword a turn. ## Many at once, and forgetting [Section titled “Many at once, and forgetting”](#many-at-once-and-forgetting) Four more nodes work on entities, all under the **Entities** category of the node menu beside Component. Each is shaped by a question about what a story needs to do with a crowd. **Spawn** makes many at once from one component, and hands their names on as a list so a script can give each one its own hit points. It makes the same ones every time: returning to a Spawn node finds the goblins it already made rather than six more, which is what lets a paused campaign resume into the encounter it left. The cost is that a second wave needs a different name. Its count and naming options are in the [node reference](/reference/nodes/). **Delete Entity** forgets one by name, because the goblin is dead or the potion is drunk. Deleting a name nothing answers to is not an error: it removes nothing and the story carries on. The component file is untouched, so a node naming it later makes a fresh one. **Entity Exists** answers whether the campaign still has one. Wire it into an If and the story decides for itself what a missing sword means, where a lookup that failed would have decided for it. **Entity Names** answers every entity the story has made, as a list of names in alphabetical order, or only those made from one component. Wire `names` into a For node with a List at node and you can do something to each one, the goblins still standing two passages later for example, without a script. ## Why a passage may read and not write [Section titled “Why a passage may read and not write”](#why-a-passage-may-read-and-not-write) Python can do more than the nodes can, and it can read the same things. `component_get("Goblin")` answers the whole definition, types and all, and `entity_create`, `entity_set`, `entity_rename` and `entity_delete` let a Script node create, set, rename and delete entities. The surface is wider than the boards would suggest, but it keeps one rule from the boards: a passage may read and not write. The reasoning is the one behind having no Set/Get pair: a state change should be something you can point at on the canvas. A script that wants to change an entity does it from a Script node, which is a box on a board, and a passage only reports what is true. [How a story runs](/explanation/how-a-story-runs/) and [Python in a story](/explanation/python-in-a-story/) place that rule among the others. Two details of that surface follow from keeping definition and entity apart. `entity_get` answers an entity’s attributes directly, so its dict has nowhere to record which component the entity came from without colliding with a real attribute label. `entity_of` answers that separately, and the two compose: `component_get(entity_of("Goblin-1"))` is the definition behind an entity. The older `component()` function does the opposite of `component_get` and `entity_get`: an entity of the same name wins over the definition, so it shadows. A question about a definition should not depend on whether an entity has been made, which is why the replacements are two functions, one for each question, and neither shadows. ## Why a playthrough is ephemeral [Section titled “Why a playthrough is ephemeral”](#why-a-playthrough-is-ephemeral) Nothing a playthrough makes is written into your story. Entities live in the campaign window, and a saved game is what carries them past closing it. The component file is never touched by a playthrough. screenshots/entities-panel-dark.png The campaign window with the Entities panel open: entities grouped under the component they came from, a count on each heading, the search line at the top and a bin on each row. The **Entities** panel in the campaign window shows everything the story has made, grouped under the component each came from. Every row has a bin on it, and nothing is asked first, because what you would lose is what this session did to that one sword, and the component file is untouched. Once you have used it, **Start over** is offered. The panel’s controls are in [the campaign window reference](/reference/campaign-window/). ## Related [Section titled “Related”](#related) * [How to use components and entities](/how-to/use-components-and-entities/) applies this on a board. * [How to read and change entities from Python](/how-to/use-entities-from-python/) does the Script node side. * [Components and entities in Python](/reference/python/components-and-entities/) lists the functions. * [Project files and the .ripple folder](/reference/project-files/) has the component file format. * [The campaign window and saved games](/reference/campaign-window/) describes the Entities panel and what a saved game carries. # Console variables > Why a story keeps state in declared, typed variables rather than wires, and what that costs and buys. A story can carry state with no wire at all. A project declares **console variables**: the game-engine idea, at the size a story needs. Each is a typed, named value with a default, set anywhere, read anywhere, across boards and across saves, and every playthrough starts with each one at its default. Three nodes use them: Get variable, Set variable and Variable branch (see [Nodes](/reference/nodes/)). The alternative is to run a wire from wherever a value is decided to wherever it is used. That works on one board and gets worse with every board you add, because a board is meant to be reusable and a wire ties it to its neighbour. A variable inverts the arrangement: a Set variable on one board and a Variable branch on another need no wire between them, because the variable is the wire. The cost is that the connection is no longer visible on the canvas, which is why a variable is declared in one place, with a type, rather than invented at the node that first needs it. The three nodes take the flow rather than being pulled by a wire, so a read after a write in the flow always sees the write. See [How a story runs](/explanation/how-a-story-runs/) for the model behind it. ## A declaration is a name, a type and a default [Section titled “A declaration is a name, a type and a default”](#a-declaration-is-a-name-a-type-and-a-default) Every variable is declared on the project’s Console variables page as a name, a type and a default. The declarations live in `.ripple/cvars.json`, beside the project’s settings, and travel with the project. A node that uses a variable stores the variable’s id, not its name. That is the reason renaming is free: every board that names the variable follows the rename, because none of them ever held the name. The same decision is why a declaration is something you edit in one place rather than something each node carries a copy of. The types are a closed set, the same twenty-four every other editor in the app offers: eight kinds of value (`int`, `float`, `bool`, `string`, `markdown`, `asset`, `componentRef` and `json`), each on its own, in a `list<…>` or in a `map<…>`. Some of the eight are the same kind of value under another purpose. `markdown`, `asset` and `componentRef` are text as far as what you may write into them goes; they say what the variable is *for*. `json` is the open one: it holds a list or an object as it stands, so a `list` is a list of lists if that is what you put in it. No type accepts an empty value. ## Scope is offered, not enforced [Section titled “Scope is offered, not enforced”](#scope-is-offered-not-enforced) A variable can be scoped to boards. The page has a **Boards** column per variable, reading *Everywhere* until you tick some. A Get variable, Set variable or Variable branch node then offers only the variables scoped to its board or to none, and a node already pointed at a variable scoped elsewhere keeps its name and says *Not scoped to this board* under it. That is all a scope does. It decides where a variable is *offered*, not where it can be read: at play time a variable belongs to the campaign, a script names any of them from anywhere, and nothing about a scope reaches a bundle. The word promises more than it delivers, so treat a scope as a promise the author keeps, not one the engine checks. Scope is a change to the file format that an older build refuses rather than reading: it would write the file back without the scopes on its next save. The current version numbers are in [Console variable declarations](/reference/console-variable-declarations/). ## Every write is lossless or refused [Section titled “Every write is lossless or refused”](#every-write-is-lossless-or-refused) Because any node, script or panel can write any variable, the type is defended at every door. One rule is asked by the Set variable node, by Python, by the campaign panel and by the export alike: a value is coerced to the declared type when nothing is lost, and refused otherwise. A whole number goes into an `int`; `3.5` does not. The text `"42"` goes into an `int`; `"forty-two"` does not. `true` and `false` go into a `bool`; `"yes"` and `1` do not. A scalar is never wrapped into a list. Where the refusal lands depends on who is typing. A value that does not fit is refused as you type it on a card. One that arrives on a wire wrong ends the run with a message naming the node: > `gold holds an int, and 3.5 is not one` The full table of conversions is in the [reference](/reference/python/console-variables/#coercion). ## Saves and exports [Section titled “Saves and exports”](#saves-and-exports) A saved game carries every variable. A save from before the project had one holds it at its default; a variable added while a campaign is paused is there when it resumes; one removed is gone. A save records what each variable held and not its type, so an older build refuses a newer save outright rather than restoring a value it cannot tell apart from another. Format numbers are in the reference pages. An export carries `.ripple/cvars.json` beside the project’s settings, so a scaffold opens with every card still pointing at its variable, and a compiled campaign carries the declarations in its own form. ## Related [Section titled “Related”](#related) * [How to declare and scope a console variable](/how-to/declare-console-variables/) * [Console variable declarations](/reference/console-variable-declarations/) * [Console variables in Python](/reference/python/console-variables/) * [The campaign window and saved games](/reference/campaign-window/) * [Components and entities](/explanation/components-and-entities/) # Embedding the engine > Why every binding of the engine is the same small API (a campaign, a session and an ordered list of effects) and what that shape asks of the program that plays a story. A campaign exported from the editor is played by the **engine**: the same one the editor’s Play button runs, published on its own so that other programs can run it too. A game, an app, a page or a tool reaches it through a **binding**, a library in that program’s language. There are four today: JavaScript and TypeScript, C and C++, C#, and Dart. Each spells things the way its language does, but underneath they are one API. Learn it once and all four make sense. The whole design fits in one sentence. **The host never decides anything about the story or the sound: it renders what it is told, tells the engine what the player did, and gives the engine a clock.** The rest of this page follows from that. ## The host drives the story [Section titled “The host drives the story”](#the-host-drives-the-story) The engine never blocks, never sleeps, never reads a clock, never picks its own random seed, never starts a thread and never calls into your code. It is a machine with one crank. Your program turns it with a verb. The engine moves the story as far as it can go without the reader, then stops and says what it is waiting for: a passage to show, a form to fill, a wait to sit out, or an ending. That is the shape every mature narrative runtime a game engine already hosts has, and it was picked for the same reasons. It needs no event loop on either side of a language boundary. It fits a game’s frame loop, a UI framework’s event handlers and a terminal’s read loop equally well. It also compiles for a browser, where there is one thread and nothing may block. Two things follow that matter in practice: * **Time is yours.** A Delay node does not wait. It asks your program to wait, and the story stays where it is until you say the time is up. Use game time if your world can pause. * **Chance is yours.** Every session starts from a seed you choose. The same seed and the same answers play the same story, which is what makes a bug report reproducible and a test deterministic. ## Three things: a campaign, a session, and effects [Section titled “Three things: a campaign, a session, and effects”](#three-things-a-campaign-a-session-and-effects) A **campaign** is the opened bundle. It is immutable and can be shared for the life of your program. It carries the manifest (the languages, the entry boards, whether the text is Markdown or HTML, the table of media files) and two lookups you will call constantly: a node by board and id, for when a trace names one, and a media file by index, for when a passage or the sound names one. A campaign opens from a folder, or from its files held in memory when your program packs its assets its own way. A **session** is one playthrough of a campaign: the reader’s position, the story’s variables and the dice. Make one per playthrough, or per save slot. Its verbs are the only way the story moves. **Effects** are what a verb hands back. ## A verb answers with a list of what happened [Section titled “A verb answers with a list of what happened”](#a-verb-answers-with-a-list-of-what-happened) Every verb returns an **ordered list of effects**: everything the story did between your call and the moment it stopped. The last effect is the **turn**, which says where the story is now: | Turn | What your program does | | ------- | --------------------------------------------------------- | | show | Draw a passage and its choices. | | ask | Show a form and collect typed answers. | | hold | Start a timer, and tell the engine when it fires. | | ended | The story reached an End its author wrote. | | failed | The story stopped on an error, naming the node if it can. | | stopped | The session was stopped or paused. | Before the turn come the facts along the way. The story changed board. The soundscape in force changed. A sound command for your speaker. A console variable, an entity or the story’s memory moved. A named callback the author published fired. With tracing on, there is also one step of the trace per node the story ran through. **The answer is a value, not a set of callbacks**, and that was the decision the rest rests on. A list of plain records crosses every language boundary the same way: a C struct, a WebAssembly record or a JavaScript object. A callback has to be invented again for every language, and with it questions about which thread it runs on and what happens if it throws. So a binding is a dispatch over a list, and your program handles one case per kind of effect and nothing else for the story. Every effect is a copy, so nothing in it points back into the engine. **Order is part of the answer.** A change to the story’s state arrives before the passage it affects, so a status display is right when the dialog box appears. A soundscape stopping arrives before the next one starting. A callback about a passage arrives before the passage. Apply the list in order and you never have to reason about it. ## Answering the turn [Section titled “Answering the turn”](#answering-the-turn) There are four ways to answer a turn: choose a choice, leave the passage without choosing, answer the form, or report that the wait is over. Restart, stop, pause and a change of language are also verbs. **A wrong verb is refused, and nothing moves.** Choosing while a form is showing, or choosing an index that does not exist, is an error your program can catch, and the story is exactly where it was. Most of these are bugs in the host. Two are met in ordinary use and are worth handling: * **A choice that is not open now.** Every wired choice is offered, but one the author limited may be spent or behind a guard that is false. It comes marked unavailable, so draw it greyed and do not offer the press. Pressing it anyway is refused. * **A language the campaign does not have.** Changing the language re-shows the current passage in the new one; an unknown language is refused and nothing changes. **A wait is a request, not a sleep.** If the reader pauses, restarts or stops in the middle of a wait, the report that the time is up is harmless when it finally arrives. ## The player is the loop, written once [Section titled “The player is the loop, written once”](#the-player-is-the-loop-written-once) Dispatching the list, keeping track of the last turn and running the timer is the same code in every host. So each binding ships a **player**: an optional layer over a session that delivers every effect to one handler in order, keeps the current turn, answers a wait itself when your frame loop or event loop finds it due, forwards the sound to your speaker, and keeps the table of named callbacks. Its verbs are the session’s. Most programs use the player. Programs that want every step in their own hands use the session directly, and lose nothing by it. ## Named callbacks are the author’s API to you [Section titled “Named callbacks are the author’s API to you”](#named-callbacks-are-the-authors-api-to-you) An author can publish named points in a story, such as `chapterTwoBegan` or `achievementHit`, each tied to an event and optionally aimed at a board, a node or a variable. A name carries no code. It says that a point exists and what it is called. Your program registers a handler against the name. The engine never sees your table: it hands over a callback effect like any other, and the player looks the name up. Four rules make this safe to build on: * **A callback is a notification.** It returns nothing and cannot change the story. The story plays the same whether anybody is listening or not. * **An unknown name is ordinary.** The two sides are written by different people and nothing can check one against the other, so a miss is counted, not raised. * **A handler that throws does not stop the story.** The error is reported, and the rest of the list is still delivered. * **A handler must not call a verb.** The session is not re-entrant, and a verb from inside a handler is refused. Queue it, and run it once the handler has returned. The name is the only thing joining the author’s declaration to your code, so treat it like a function name somebody else calls: agree it early and rename it deliberately. [Host callbacks](/explanation/host-callbacks/) explains the author’s half. ## Values keep integers and floats apart [Section titled “Values keep integers and floats apart”](#values-keep-integers-and-floats-apart) Everything that is data rather than structure (a console variable, an entity’s attribute, a form’s answer, a script’s result) is one type: JSON, with the difference between an integer and a float kept. The story’s `7` and `7.0` are different values. Integers are 64-bit, and maps keep the order the author wrote them in. Each binding maps this onto its language’s own types. In a language whose numbers are all doubles, the integer side needs a wider type. JavaScript uses `bigint` for it, and Dart compiled to JavaScript rounds an integer beyond 2⁵³. Each binding’s reference says how it spells the type. ## Saving is a string [Section titled “Saving is a string”](#saving-is-a-string) A **checkpoint** is the turn the story is on, written as JSON, without stopping it. A **pause** writes the same save and then ends the session, stopping the sound on the way. Either one exists only while the story is on a passage or a form, because a story mid-wait has no stable place to resume to. Store the save with the seed and the language, and resume a new session from it over the same campaign. A save names the story it came from. Resuming a save whose board or node the campaign no longer has is refused by name. That is the moment to tell the player the save is from an older build of the story. A save carries neither the dice’s position nor the soundscape’s: the same seed gives the same future rolls, and a music bed starts from the top. A host that needs the dice’s exact continuation reads the generator’s state off the old session and sets it on the new one. ## Sound comes in three layers [Section titled “Sound comes in three layers”](#sound-comes-in-three-layers) Sound is where the rule that the host decides nothing pays off most: 1. **The session** says what the story asks for, as audio commands: play a soundscape, stop it with a fade, cut everything, cue a one-shot sound. 2. **The scheduler**, the engine’s mixer, turns those into individual voices: which file, from where in it, how loud, when it fades, when one soundscape hands over to the next. It has no clock, no files and no voices of its own. 3. **The speaker** makes the sounds. It is the only part that touches an audio API, and the only part you write. Between the second and third layers sits the **soundscape driver**, which each binding ships. It ticks the scheduler from your loop, moves a voice’s start on by the time its file took to open, waits out a handover’s fade, and reports a voice’s end and a file’s length back to the scheduler. The speaker contract that is left is small. Opening a voice returns only once it can start at once. Playing a voice that is already playing replaces it. Stopping is for good. Reports may come from your audio thread. Gains are on the editor’s own scale, which is cubic, so a speaker that converts a gain to amplitude sounds the way the author heard it. A program that wants none of that can drive the scheduler by hand, and then takes on the driver’s duties itself. ## When something goes wrong [Section titled “When something goes wrong”](#when-something-goes-wrong) A **story that fails** is not an error: it is the failed turn, with a message and the node, and your program shows it the way it would show an ending. The **errors** are about the program around the story, and they come in a handful of families every binding names: * **The bundle will not open.** A document is missing or malformed, or the campaign was compiled for a newer contract than this engine plays. * **The bundle needs Python and this engine has none.** See [How the engine ships](/explanation/how-the-engine-ships/) for the two variants. * **A verb the story was not asking for**, as above. * **A save that cannot be reunited** with this campaign. * **An invalid argument**, where nothing was done. * **A trap**: the engine itself panicked. **A trap poisons its handle.** The story may be half-moved, so every later call on that campaign or session is refused without running. Recovery is the same in every binding: dispose of it, open the campaign again, and resume from your last checkpoint. That is a reason to checkpoint often, since a checkpoint is cheap. Every message is English and meant for a log. Branch on the error’s code and fields, never on its text. ## One caller at a time [Section titled “One caller at a time”](#one-caller-at-a-time) A campaign or session is used by one caller at a time, and may move between threads. Every verb is synchronous: a passage takes microseconds. The exception is a story that runs Python, where a Script node runs to completion inside the verb, under the deadline the session was given. If that is too long to spend on your UI or render thread, own the session on a worker thread and send the effects back. They are plain values, so they travel. ## How a host proves it is right [Section titled “How a host proves it is right”](#how-a-host-proves-it-is-right) Every engine release carries the **conformance fixtures**: small campaigns, each with the inputs to replay and the trace and callbacks a correct host produces. Every binding plays every fixture through its own API, both through the session and through the player, so a binding that passes is the engine as far as a story can tell. A host built on a binding inherits that and only has to test its own interface. For a quick check against your own campaign, the terminal player in every desktop engine archive, `ripple --auto --seed N`, tells the story a host should tell for that seed. ## What comes next [Section titled “What comes next”](#what-comes-next) These four bindings are the general-purpose layer: everything here, in a language’s own idiom, with no opinion about what your program draws with or plays sound through. **Integrations for particular software, game engines first, are coming soon.** They will be built on these bindings and will fill in what a binding deliberately leaves to you, such as the speaker, the dialog box and the asset pipeline, the way that software expects. ## Related [Section titled “Related”](#related) * [How to play a campaign in your own program](/how-to/play-a-campaign-in-your-program/): the steps, in all four languages. * [How the engine ships](/explanation/how-the-engine-ships/): what a binding puts in your build, the two variants, and why only the web has a ready-made speaker. * [Engine platforms and variants](/reference/engine-targets/): every target, its floor, and which binding ships it. * [JavaScript and TypeScript](/reference/binding-javascript/), [C and C++](/reference/binding-c-cpp/), [C#](/reference/binding-csharp/) and [Dart](/reference/binding-dart/): each binding’s full API. * [Host callbacks reference](/reference/host-callbacks/): the events an author can publish a name for. # History, sharing and conflicts > Why the app keeps a project's history in git, what it leaves out of that history, and why it never settles a conflict or overwrites a file on your behalf. The file formats Ripple Effect writes are plain text, so that a project can be a repository. Git is built into the app. [Version control in the app](/reference/version-control/) shows where the controls are. ## Why git underneath [Section titled “Why git underneath”](#why-git-underneath) The words in the app are its own: *save point*, *storyline*, *back up*, *set aside*. The history under them is an ordinary git repository, and anything that reads one reads this. The vocabulary is not a second format. ## Starting a history [Section titled “Starting a history”](#starting-a-history) Starting a history writes as little into your folder as it can, because the folder was yours before the app arrived. It adds only the entries that are missing to a list of things to leave out, and it does not touch an ignore file you already had. It also keeps line endings consistent for co-authors on other platforms; without that, a co-author on another system checks the project out, their editor writes different line endings, and every prose file reads as changed on both machines for ever. Then it takes a first save point of everything. Files too large to ever take back out again are left out and named, rather than committed and regretted. The one thing of the app’s that is ignored is `.ripple/workspace.json`, because it is per-machine: it records how one person has arranged their window. Everything else in `.ripple/` travels, and should. The registry is what keeps a Jump pointing at the right board after a rename; a copy of the project that arrived without it would have to rebuild its index, and the links between boards would have nothing to point at. The project’s own title, cover and languages are shared facts about the story in the same way. The line between the two is whose fact it is: the story’s, or one machine’s. ## Backing up is a separate decision [Section titled “Backing up is a separate decision”](#backing-up-is-a-separate-decision) You say where the project should be kept, and the app tells you what it is about to do before it does it. [How to keep a history and back up a project](/how-to/keep-a-history-and-back-up/) has the steps. Backing up is deliberately separate from keeping a history. A project can have a history and never leave the machine, and the app does not treat the first as an invitation to the second. Two things are said out loud because nobody would be asked them in time: the repository has to exist on the site before you can back up to it, and *public* really does mean anyone can read your drafts. ## Why a conflict is never settled for you [Section titled “Why a conflict is never settled for you”](#why-a-conflict-is-never-settled-for-you) When two people changed the same passage, there is no neutral answer. Either version might be the one the story needs, and a third sentence, written by hand, might be better than both. Any rule the app applied would be a guess about a story it does not know. So nothing is touched until you say. Both versions are shown side by side and you pick which to keep, or write the sentence yourself. Your files are left exactly as they were while you decide, which means leaving it for later costs nothing: there is no half-merged state to come back to. The same reasoning applies to an assistant connected over MCP. It is refused a document with a conflict pending, because a tool that picked a side would be making your decision. See [Working with an AI assistant](/explanation/working-with-an-assistant/) for how that sits beside the rest of what an assistant may do. ## Undo is undoable [Section titled “Undo is undoable”](#undo-is-undoable) Undoing changes to a file keeps what it removes first. The message offering it back stays in Messages, under the documents, after the notification has gone. Everything you have undone is listed in one place, so the safety net is something you can look at rather than a notification you had four seconds to catch. ## Why autosave never overwrites [Section titled “Why autosave never overwrites”](#why-autosave-never-overwrites) The app saves what you type automatically, about a second after a keystroke. Version control rewrites files: a pull from a co-author, a change of storyline or a discarded edit all replace what is on disk. If those two could collide, the next keystroke in an open editor would silently undo the rewrite, which would look exactly like version control eating your work. That is the rule the whole feature rests on. A file another program changes underneath the app, whether a pull from a co-author or an editor writing the same passage, is noticed rather than overwritten, and autosave never writes over a change it did not make. Version control that could be undone by the editor’s own autosave would be worse than none. ## Related [Section titled “Related”](#related) * [How to keep a history and back up a project](/how-to/keep-a-history-and-back-up/), the steps for starting a history, taking save points and backing up. * [Version control in the app](/reference/version-control/), every control in the Changes section. * [A project is a folder](/explanation/projects-are-folders/), the idea this page builds on. * [Project files and the .ripple folder](/reference/project-files/), what the folder holds and which parts travel. # Host callbacks > Why a story tells the program that plays it what happened by name only, and what that choice does and does not give you. A compiled campaign is meant to be played by something: a game engine, an app, a terminal. A **host callback** is how the story tells that something what has just happened in it, at whatever grain the author chooses. The author declares a **named trigger**: a name, and the occasion that fires it. Whoever embeds the story registers a function against that name. The two halves are written on opposite sides of a boundary and are joined by nothing but the name. That is the whole design, and it is worth holding in mind before anything else about the feature. ## Two people, one name [Section titled “Two people, one name”](#two-people-one-name) The author declares the trigger and a programmer writes the code. Naming `chapterTwoBegan` on the **Host callbacks** page says one thing: there is an extension point in this story, and it is called `chapterTwoBegan`. The page has no code field, and nothing typed there changes what the story does. On the other side, in the game, somebody writes a handler and registers it under that name. When the story reaches the occasion, the engine reports that `chapterTwoBegan` happened and hands over what it knows. The handler runs in their engine, on their schedule. The name is a contract neither tool can check. Renaming a trigger breaks the programmer’s build, and nothing in the editor can warn them, because the breakage lands in a codebase the app has never seen. For the same reason, a callback aimed at a node that is no longer on the board never errors: it fires nothing. The [reference](/reference/host-callbacks/#missing-targets) has what each missing target does. Treat a name the way you would treat the name of a function somebody else calls: agree it early, and rename it deliberately. ## A notification, not a hook [Section titled “A notification, not a hook”](#a-notification-not-a-hook) Because the handler lives outside the story, a callback cannot change the story. It is a notification, not a hook: the story carries on exactly as it would with nobody listening, and a name with nothing registered is not an error. Anything that should change what happens next belongs on the board, as a Script node, a variable or a branch. See [Python in a story](/explanation/python-in-a-story/) for running your own code inside the story. ## Fine grain: watching the cards themselves [Section titled “Fine grain: watching the cards themselves”](#fine-grain-watching-the-cards-themselves) Most events describe something that happens to the story as a whole or to a thing it holds: a passage shown, a choice taken, a variable moved. Three events are finer: `node.entered`, `node.exited` and `node.failed`. They fire on every turn a card takes, whatever kind of card it is. Aimed at a board and a card, they tell the host each time the story runs through it. That is the shape of a quest counter, an analytics event, or a game reacting to a scene it knows by name. The turn of a card has an edge at each end, and where those edges fall is easy to get wrong: * A passage exits once the reader has chosen, not when it appears. Entering fires as the flow arrives, which for a passage is just before it is shown. Exiting fires once the reader has answered and the turn is finished. * Going round twice is two callbacks. A loop that runs a card four times fires four times. * A card inside a macro cannot be picked. A macro is spliced into the board that calls it, so its cards are not on any canvas under their own names. Aim at the card that calls the macro instead. ## Why filtering is on the game’s side by default [Section titled “Why filtering is on the game’s side by default”](#why-filtering-is-on-the-games-side-by-default) Left alone, a callback fires at every occasion of its event: every board entered, everything the story spawns. The notification carries which board, which entity and which value, so the game can filter on its own side. The pane can narrow a callback where it is cheaper to say so there; the [reference](/reference/host-callbacks/) lists what each event can be aimed at. Some events have no sensible unfiltered form. Watching every console variable in a project is almost never what anybody means, so `cvar.changed` requires a variable. A state machine event requires an instance for the same reason. A `cvar.changed` callback can additionally fire only when the variable becomes a given value. The condition is on the transition, not on the state. Setting a score to 100 when it is already 100 fires nothing, while dropping to 50 and climbing back to 100 fires again. A variable at a value is a fact about the present, while arriving at one is something that happened, and a notification is about things that happened. ## Seeing them fire [Section titled “Seeing them fire”](#seeing-them-fire) The editor publishes names but has no game to react to them, so in the play window normally nothing is registered. The **Host callbacks** side panel there lists every fire: the name, the event, whichever payload fields are set, and what became of the lookup. Each fire shows as a name a game would have looked up. It lets an author check that the story fires the occasions they declared before a programmer has written any code. ## What callbacks are not [Section titled “What callbacks are not”](#what-callbacks-are-not) * They are not a way to run your own code inside the story. That is Python. * They are not sound cues. A `soundscape.changed` says which soundscape came into force and nothing finer: not a loop, not a fade, not a marker on a timeline. Those are the mixer’s own business. See [Soundscapes and one-shot sounds](/explanation/soundscapes-and-sounds/). * They are not ordered against the game’s frames. The engine hands over a batch of everything a move led to, the callbacks among it, and the game decides when to run them. A callback arrives before the page it is about is drawn. Past that, when a handler runs is the game’s to schedule. ## Related [Section titled “Related”](#related) * [How to declare, aim and forget a host callback](/how-to/declare-a-host-callback/) * [Host callbacks reference](/reference/host-callbacks/): the events, the aiming fields, what a missing target does and what travels with a project * [What an export is](/explanation/what-an-export-is/) * [How to play a campaign in your own program](/how-to/play-a-campaign-in-your-program/): registering a handler against a name, in each binding’s language # How a story runs > Why a board's control wires are pushed and its data wires only read, why the story only goes forwards, and what that means for branches and for where a story begins. A board looks like a diagram, but the engine reads it as two different things drawn on the same canvas: a path the story walks, and a set of questions the cards ask while they walk it. Most surprises on a board, a value that is a turn out of date or a setting that seems never to have fired, come from taking one wire for the other. This page explains the reasoning, so that the [diagnostics](/reference/diagnostics/) the app raises read as consequences of one model and not as separate rules. ## Two kinds of wire [Section titled “Two kinds of wire”](#two-kinds-of-wire) Every port on a card is one of two kinds, and the canvas draws them apart. **Control** ports carry the story. A card’s way in is called `exec`; its ways out are the choices, the branches, the *first* and *again* of a gate, the `completed` of a node that merely did something. The story is the path from Start along control wires: a node takes its turn, hands the flow on through one of its control outputs, and the next takes its. Two branches that converge on a card run it twice. There is no waiting for both, because a branch a condition never takes would wait for ever. **Data** ports carry values: a number, a name, a list of goblins, the state a machine is in. A data wire is not a step in the story. It is a question the card at its end asks when its turn comes. The canvas draws flow ports as triangles and value ports as dots. Control is **pushed**: a card takes its turn because the card before it handed the flow on. It is one engine, the same whether you press play in the app or ship the campaign, so a story never reads one way in the editor and another in the shipped copy (see [Why the preview is the shipped story](/explanation/the-preview-is-the-shipped-story/)). A data wire is a **read**: when a card takes its turn and reads a data input, it gets what the card at the other end last published. Reading evaluates nothing. The engine could fetch a value on demand from a card with no control ports at all, but every card on this canvas has a way in and a way out, so in practice nothing is pulled. ### Why the values can hang off the story [Section titled “Why the values can hang off the story”](#why-the-values-can-hang-off-the-story) Every card has control ports, so a card the story never reaches would never take a turn, and something downstream might still read from it. Before a story begins, the engine walks the data wires back from everything the Start can reach and finds those cards. They are **primed**: run once ahead of the story, in dependency order, so that whoever reads them finds the value there. That is what lets you wire the story and leave the values hanging off it: a Number into a Set variable, a String into a Component’s name, a Get variable into an Output. None of them needs an `exec` wire. You lay out one correct control path, and every value a node on it reads is worked out before it runs. The price is one rule. **A value that depends on something the story does must be in the flow after it.** A Comparator hanging off a Dice roller is primed before the story starts, before there is a roll to compare, and answers for a total that did not exist yet. Wire the roller’s `completed` into the Comparator and the Comparator’s into the If, and the comparison happens when the roll has. Priming is for values that are true before the story; the flow is for values the story makes. ## The story only goes forwards [Section titled “The story only goes forwards”](#the-story-only-goes-forwards) Reading a value never makes a card run. The story arrives at a card, the card looks at what is wired into it, and what it finds is **whatever that card published the last time it had a turn**: this turn, an earlier passage, the priming before the story began, or nothing at all. Nothing is fetched by going back up the wire and running the card again. That is deliberate, because a value that could run its card backwards would mean that reading a number rolls a die. The consequence is that a value is always in one of three states, and only the first is the one you usually want. * **Computed on the way in.** Its cards are in the flow just before whatever reads them, so every path to that passage runs them. This is the shape to aim for. For several values at once, put a **Do all** in front of the passage, with a chain per value and its `completed` wired on. * **Worked out before the story.** Nothing in the flow reaches it, so it was primed and answers the same on every visit. That is right for a number or a name and wrong for anything the story changes. The app raises `W.GE.05` wherever a primed value’s cards read something the story changes. The cases are a comparison against a die, a variable read, or a gate’s answer, all worked out before there was a roll, before the variable was set, before the gate had been through. A **choice’s guard** is the same case: a `bool` wired into the small input on a choice’s row is worked out in time only if the cards that make it sit in the flow leading to the passage. Hanging off the side, it is worked out once before the story starts and answers the same for ever, and the message names the choice. * **Left over from another path.** Its cards are in the flow, but the way the reader got here this time did not pass them, so the value is an older turn’s. Which visit it came from is not something anything can work out, but the question behind it is one the app can ask. `W.GE.06` asks whether every route to the card that reads the value runs the card that makes it, and says so when one does not. A data wire is a read of what its card last published: if every route runs that card first, what arrives is this visit’s, and if one route skips it, what arrives is whatever it published last, which may be from an earlier visit or nothing at all on the first. `W.GE.06` is worth a look rather than a problem, and a question rather than an accusation, because sometimes the older value is exactly what you meant. A running total reads its own last answer, and a flag set once on the way in is meant to still be set on the way back. So it can be answered per card: tick *I meant that* on its row in the messages panel and the app stops asking about that card. The answer is remembered, and `W.GE.06` is the only code that offers the tick. A board marks the cards a finding concerns, and the messages panel lists them all. The [diagnostics reference](/reference/diagnostics/) has every one of the fourteen coded checks on a board’s shape, including the two above. ### Loops and circles [Section titled “Loops and circles”](#loops-and-circles) Wires that go back on themselves are fine, and the model says why. A control wire back to an earlier card is a loop, which is how a hub or a conversation works. A data wire back into the card that feeds it reads that card’s previous answer, which is how a running total works: it is a value, not a question being asked in a circle. The first time round there has been no previous turn, so it reads nothing at all. `I.GE.11` is the app confirming that it noticed such a wire and that it is allowed, not complaining. What the model cannot give an answer to is a circle of cards that read from each other with none of them having a way in. There would be no value to arrive at: each would be worked out when the next asks for it, and they ask each other. That is `E.GE.14`. Nothing you can draw in the app makes it happen; it exists because a campaign built by something else could. ## Doing several things before a passage [Section titled “Doing several things before a passage”](#doing-several-things-before-a-passage) A **Do all** runs everything wired to it, one branch after another, then carries on from `completed`. The rule underneath is that every branch runs to the end of its wire before the next starts, and a passage does not end a wire. A passage on branch two therefore holds branch three until every passage after it has been read, which looks exactly like a Set that never fired. That is the same forward-only model seen from the other side, and the card’s description says so. The `completed` port is the way not to have to think about it: put the jobs on the branches and the story on `completed`. ## Where the story begins [Section titled “Where the story begins”](#where-the-story-begins) A board begins at its **Start** node, and it has exactly one: a board with none, or with two, is one of the coded checks (`E.GE.01` and `E.GE.02`). Where the Start sits on the canvas does not matter. Script navigation and the merged trace ask the same question the runner does, *which node is the Start?*, so a route drawn between two passages is the route the story actually takes. See [Reading a story back](/explanation/reading-a-story-back/). ## Related [Section titled “Related”](#related) * [Nodes](/reference/nodes/): what each card takes in and hands on. * [Diagnostics](/reference/diagnostics/): every coded check on a board, with its reason. * [How to run several jobs before a passage](/how-to/order-jobs-before-a-passage/): the Do all shape, step by step. * [How to limit a choice](/how-to/limit-a-choice/): wiring a guard so it is worked out in time. * [Board and navigation rules](/reference/canvas-rules/): the rules the canvas and the trace follow. # How the engine ships > What a binding puts into your build, where it comes from, how it is checked, why there are two variants of every build, and why only the web has a ready-made speaker. Adding a binding to your program adds two things: the binding’s own code, in your language and under the MIT licence, and a **build of the engine**, which is where every rule of a story runs. The second is a compiled library or a WebAssembly module that you ship inside your product. This page is about that half: where it comes from, how it gets into your build, which build you get, and what you agree to by shipping it. ## The engine is released on its own [Section titled “The engine is released on its own”](#the-engine-is-released-on-its-own) The engine is not built by the bindings. It is built from its own source, which is not published, and every release is put up as binaries in a public repository, [ripple\_engine\_releases](https://github.com/ConnectingTheDots-DCWK/ripple_engine_releases), under a tag `engine-v`. A release holds: * an archive per platform and per variant (below); * a **manifest** listing every archive with its size and SHA-256 digest, **signed** with Ed25519, and a `SHA256SUMS` file beside it; * the **conformance fixtures**, the small campaigns every binding plays through its own API to prove it is the engine. A published archive is never replaced. A release that turns out wrong is superseded by the next patch rather than edited, so a digest you pinned keeps meaning the same bytes. ## A version number is a contract [Section titled “A version number is a contract”](#a-version-number-is-a-contract) The first two parts of a version, `1.5`, name a **contract**: the campaign format, the formats the editor writes, the engine’s interfaces and the fixtures, frozen together and given a codename (1.5 is *Multiclassing*). The editor, the engine and every binding count their own third part within it. So `@ripplefx/player` 1.5.0, the C# packages 1.5.0 and an engine 1.5.4 all speak the same contract, and a binding’s patch is its own fix, not the engine’s. What that buys you is a short rule. **A binding of contract 1.5 plays any campaign exported by an editor of contract 1.5**, and those of the earlier contracts the engine still reads (1.3 and 1.4, for engine 1.5). A campaign from a newer editor is refused at open, with a sentence saying which version made it and to update. An engine patch within a contract keeps every interface a binding compiled against, which is why a binding can move to a newer engine patch without changing its own API. ## Three ways in: a C library, a component, and Emscripten [Section titled “Three ways in: a C library, a component, and Emscripten”](#three-ways-in-a-c-library-a-component-and-emscripten) The engine is written in Rust and is reached from outside in one of three forms, all built from one description of its interface: * **A C library**, for every native platform. A header, a shared library and a static one, with a list of the system libraries a static link needs. The C, C++, C# and native Dart bindings are all built on it. It is typed rather than a set of JSON calls, so a host does not need a JSON parser to play a story and never has to guess whether `7` is an integer. * **A WebAssembly component**, for browsers and Node. The JavaScript binding and Dart’s web backend transpile it with jco and run it in the page. * **A static library for Emscripten**, for a program that compiles itself to the browser, such as a C++ engine built for the web. It is the C library again, built for the browser, and it needs Emscripten 4.0 or later. Because the C surface and the WebAssembly one are generated from the same description, they cannot disagree. Every binding has the same records in the same order and the same failures, and [the API](/explanation/embedding-the-engine/) is one design rather than four. ## How a binding gets the engine into your build [Section titled “How a binding gets the engine into your build”](#how-a-binding-gets-the-engine-into-your-build) **Every binding pins one engine release**: its version and the digest of every archive it uses, committed in the binding’s repository. The manifest’s signature is checked against the engine’s public key when the pin is written, so a pin names a release the engine’s maintainers signed. After that, **every download is checked against the pinned digest before anything is unpacked**, and a mismatch stops the build. What reaches your build is therefore exactly what was pinned. How the archive arrives depends on what the language’s tooling expects: | Binding | How the engine arrives | | ------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | C and C++ | CMake downloads the pinned archive for the platform you configure for, at configure time, and checks it. You can also point it at an archive you vendored. | | C# | The engine is inside a NuGet package, as NuGet ships native code: one library per runtime identifier, picked by the build. | | Dart | A build hook downloads the archive for the platform you build into a per-user cache, checks it, and bundles the library with your app. The web engine is inside the package. | | JavaScript and TypeScript | The engine is inside the npm package, and a bundler copies it beside your page. The package’s build also checks the manifest’s signature, since thousands of other builds take an npm package on trust. | The web is the one place the engine cannot be downloaded when the page needs it. GitHub’s release downloads send no CORS headers, so a browser may not read them. That is why the web engine is shipped inside the package and served by your own site. From then on, the engine travels with your product like any other native dependency: inside your installer, your app bundle, your APK or your page’s assets. ## Two variants of every build [Section titled “Two variants of every build”](#two-variants-of-every-build) Every release builds every target twice: **`python`**, with the sandboxed Python interpreter that runs Script nodes and snippets in passages, and **`no-python`**, without it. The two have the same API, so switching is a build setting and not a code change. The `no-python` build is about half the size natively. On the web the difference is larger, about 1.36 MB over the wire against about 0.31 MB, because the interpreter is most of the module. A campaign records in its manifest whether it needs Python. The engine treats that as information. **The bindings use it to refuse early**: a `no-python` build given a campaign that needs Python refuses it at open, with an error that names the reason, instead of letting it fail halfway through a story. How each binding picks its variant: | Binding | The setting | | ------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------- | | C and C++ | A CMake option, chosen when you configure. | | C# | Which engine package you reference: the one with Python, or the one without. | | Dart | A user-define on the package in your pubspec for native builds, and a compile-time define on the web. | | JavaScript and TypeScript | Chosen at load by default: the campaign’s manifest says whether it needs Python, and only then is the larger module fetched. A page can insist on either. | The author can guarantee a campaign for the smaller build. Exporting with **Needs no Python** stops the export if any part of the story runs Python, so a campaign that passes is one the `no-python` build will open. [What an export is](/explanation/what-an-export-is/) explains that check, and [How to export for a player without Python](/how-to/export-for-a-player-without-python/) is the steps. ## Where it runs [Section titled “Where it runs”](#where-it-runs) Natively, the engine is built for Windows, macOS and Linux on x64 and arm64, Android on arm64 and x86\_64, and iOS on devices and the arm64 simulator, with nothing 32-bit. Each target has a floor: glibc 2.35 for Linux, macOS 12, iOS 13 and Android API 24. In a browser it runs as the component, or as the Emscripten library for a program built with Emscripten. Not every binding covers every target. .NET’s own browser build, for example, uses an Emscripten older than the engine’s floor, so C# does not run in a browser yet. [Engine platforms and variants](/reference/engine-targets/) has the full table. ## Why only the web has a ready-made speaker [Section titled “Why only the web has a ready-made speaker”](#why-only-the-web-has-a-ready-made-speaker) Every binding ships the soundscape driver, the part that turns the engine’s schedule into voices. None of them makes a sound except one: the JavaScript binding has a second, optional package, **`@ripplefx/soundscape`**, a speaker built on Web Audio. The reason is that the browser is the one place with a single answer. **Every page has Web Audio**, and it is the same API in every current browser, so one speaker serves every page that will ever play a campaign. Making it well is real work: a music bed streams through a media element instead of being decoded whole, which would take about 23 MB a minute; a sound effect is decoded once, so it is never late; every gain goes through Web Audio, because iOS ignores a media element’s own volume; and WebKit stalls a media element that is routed into Web Audio before it starts playing. That work is worth doing once for everyone. The package carries no engine, so it is plain MIT, and a page that would rather play sound its own way leaves it out. Outside a browser there is no such answer. A C or C++ host might use miniaudio, SDL, FMOD, Wwise, XAudio, OpenAL or a game engine’s own mixer. A C# host might use NAudio, OpenAL, FMOD, or Unity’s or Godot’s audio. A Dart app might use flame\_audio, audioplayers, just\_audio or SoLoud. They have nothing in common, each is a dependency bigger than the engine, and which one a game uses is the game’s decision, usually made long before a story arrives. A speaker shipped with the binding would either drag in an audio stack you did not choose or sit unused beside the one you did. So those bindings ship the **speaker contract** instead: a small interface, the same in every language, that you implement over the audio you already have. The driver does everything else. Dart is the edge case, since its web backend runs in a browser. It ships no speaker either, because one package serves native and web from the same API, and a Web Audio speaker would cover only half of its hosts. A Flutter web app writes one against the same contract, with `@ripplefx/soundscape` as the model. ## What you ship it under [Section titled “What you ship it under”](#what-you-ship-it-under) The engine you build into your product is under the **[Runtime Licence](https://legal.ripplefx.app/runtime/)**, not the licence agreement the editor is under. It is free and royalty-free for any product. You redistribute it unmodified in function and as part of your product: signing it, stripping its symbols, wrapping it in a framework or transpiling the component are all fine, but changing what it does is not. Every archive carries a `NOTICE`, and you reproduce it wherever your product lists its third-party software. That notice also tells your users what they may do with the engine, so a product with no terms of its own needs nothing more. A version of the licence stays with the releases published while it was current, so a product you shipped is never re-licensed under you. [Licensing](/about/licensing/) has the terms in full. ## Related [Section titled “Related”](#related) * [Engine platforms and variants](/reference/engine-targets/): every target, its floor, what each archive holds and which binding ships it. * [How to play a campaign in your own program](/how-to/play-a-campaign-in-your-program/): installing a binding and playing a story, in all four languages. * [Embedding the engine](/explanation/embedding-the-engine/): the API every binding shares. * [Licensing](/about/licensing/): the Runtime Licence next to the app’s own terms. # Passages and translations > Why a passage is a folder of Markdown files, one per language, and what follows from that for links, choices, translation freshness and reading time. A passage is Markdown. On a board it is a Narration node; on disk it is a **localised passage**, a folder the app shows as one file and that holds one `.md` per language the project has. * chapters/ * the\_gates.graph.json * reaching\_the\_gates.loc.narr.bdl/ * en-US.md the passage, in the language the story is written in * it-IT.md its translation * touch.json when each was last touched, so a clone can tell which is older ![One passage in two languages, with a mark on the translation that has fallen behind](/_astro/localisation-light.Qtc3whV9_bSm2C.webp) ![One passage in two languages, with a mark on the translation that has fallen behind](/_astro/localisation-dark.edpj7g3H_GhpHH.webp) Most of what is unusual about passages follows from two decisions: a passage in several languages is *one* document, and what is on disk is still ordinary Markdown. ## One document, several files [Section titled “One document, several files”](#one-document-several-files) The app presents the folder as a single document with a chip for each language. Underneath, each language is a plain Markdown file, so a translator handed the folder can work in any editor and a `git diff` of a translation reads as prose. The app writes no special syntax into the text. The exceptions are the two places where a passage may hold Python, an inline `{{ … }}` span and a fenced ` ```ripple ` block, so that a sentence can vary instead of forking into two passages. A passage only *reads*; what that means is the subject of [Python in a story](/explanation/python-in-a-story/). ## Why a link starts with a slash [Section titled “Why a link starts with a slash”](#why-a-link-starts-with-a-slash) The Markdown editor is the same everywhere in the app: a live render, a formatting toolbar and image embeds, local and remote, raster and SVG. A picture picked from outside the project is copied into `.ripple/external/` first, unless **Import outside assets** is switched off in the File menu. In that case it is linked where it is, and Messages says so. The steps are in [How to write a passage](/how-to/write-a-passage/). A link the app writes starts with a slash, as in `![map](/art/map.png)`, and is measured from the project’s root, the way GitHub reads one. A passage is something you move: into another chapter, under another board, beside a renamed folder. A path measured from the passage would have to be rewritten each time. A path measured from the project’s root does not care where the passage sits, so the picture still points where it did. A link written by hand without the slash keeps the meaning it always had: relative to the bundle’s *parent* folder, the way a file standing in the bundle’s place would read it. So `![](../art/map.png)` beside `chapters/the_gates.graph.json` means `art/map.png`. The bundle is a folder, but to the person writing it is a file, and the relative path is resolved as if it were one. Both forms work. ## A passage is a template [Section titled “A passage is a template”](#a-passage-is-a-template) A Narration node can be handed values, and the shape of that arrangement is worth stating. The names a passage reads are declared on the **card**, not in the passage file: a name and a type each, written the way every other card’s ports are. The card grows a port for each name, and whatever is wired into it is what the passage’s Python sees by that name. `You owe {{ gold }} crowns.` reads a number on the `gold` port. A slot with a default and nothing wired uses the default. Putting the names on the card is what lets one passage be read by two cards that fill it differently. The prose is the file, the arguments are the card, and the passage is therefore a template rather than a one-off. A passage opened on its own, in its own tab, has no card to ask, so the names it was given are offered as you type only when you open it from a board. A name the card does not hand over shows as a small warning where it stood, and the rest of the passage renders as usual. A name has to be a name Python can read, because it is a variable the script will use. A board that carries one that is not is reported as [`W.GE.15`](/reference/diagnostics/). The same rule holds for a Script node’s inputs and outputs. The exact rule is in [Passages, choices, languages and reading time](/reference/passages/). ## Choices run out and have conditions [Section titled “Choices run out and have conditions”](#choices-run-out-and-have-conditions) A Narration node’s choices carry two limits besides their wording: how many times a playthrough may take them, and a question that must be true. Both leave the choice on screen when they say no, greyed out and unpressable. That is deliberate: a passage never quietly becomes an empty slide, and the reader and the author can both see that the option exists and is not available. The two limits combine: *three times, and only while you carry the lantern* is one choice with both. The steps are in [How to limit a choice](/how-to/limit-a-choice/). Where the card that works out the answer sits matters, and this is the one place the layout of a board changes behaviour. A card hanging off the side of the board is worked out once, before the story starts, and answers the same for ever. A card in the flow that leads to the passage is worked out when the story gets there. So the cards belong in the flow. A Comparator reading a dice roll follows the same rule. The messages panel names a choice whose answer can never change (see [Diagnostics](/reference/diagnostics/)), because the board looks fine and the story would simply never respond. Why the flow behaves like this is covered in [How a story runs](/explanation/how-a-story-runs/). ## Languages and what changing them does [Section titled “Languages and what changing them does”](#languages-and-what-changing-them-does) The language a story is written in is chosen when the project is made. The starting point comes from your computer’s settings, but the choice is offered rather than applied on your behalf. The languages it is translated into are chosen in Project settings, on the *Languages* page. Switching a translation language on puts an empty translation into every bundle. Switching one off deletes nothing: the files stay, the editor keeps showing them marked retired, and readers stop being offered that language. If you want the words gone, you are asked once, on save, and **Keep them** is the button under your finger; nothing is deleted by clicking through the dialog or dismissing it. Project settings says what changing *Written in* will do. It does nothing to your prose. Every passage gets an empty file for the new language, and everything you have written stays where it is, as a translation. Until you carry it across, your passages play empty. There is no migration for that yet. The steps are in [How to add, switch off or change a project’s languages](/how-to/manage-languages/). ## Why file times cannot be trusted [Section titled “Why file times cannot be trusted”](#why-file-times-cannot-be-trusted) The locale chips along the top of a bundle each stand for one language. A **dot** on a chip means that translation is older than its source, and the Messages panel lists every one that has fallen behind across the whole project. The obvious way to know whether a translation is stale is to compare the files’ modification times, and the app cannot. A `git clone` sets every file’s time to the moment of checkout, so file times cannot say which file is older. The information has to travel with the project, so it lives in `touch.json` in the bundle, recording when each language was last touched. How the file is written follows from where it travels. It uses sorted keys and UTC times, so that saving it twice produces the same bytes and it stays quiet in a diff. A conflict between two translators is three lines a person can resolve rather than a merge nobody wants to read. See [History, sharing and conflicts](/explanation/history-and-sharing/) for the wider picture of working on a project with others. ## Reading time [Section titled “Reading time”](#reading-time) Under every passage is the word count on the left, and on the right a choice of reading pace, slow, average or fast, with the estimate beside it. It keeps up as you type, and the pace you pick is remembered. The estimate counts the language you are actually writing in: fifty-four of them, each with its own reading speed, so a passage in German and the same passage in English come out at similar read times even though the word counts differ. Counting by word fails outright for some scripts. Japanese, Chinese, Thai and Khmer are written without spaces between words, and counting those by the word gives an answer wrong by a factor of ten. They are counted by the character instead. A passage mixing scripts is counted as both, each part by its own rule. The estimate also says how much to trust it. Seventeen of the languages carry figures from reading studies. The rest are worked out from a close relative, and the tooltip on the estimate says which one you are looking at. A number with false precision would be worse than one that admits where it came from. Code does not count as prose. Python snippets and fenced blocks come out before anything is counted, and a link is worth the words you read rather than the length of its URL, since neither is read as text. A passage that mixes languages can be counted as one, and The setting is off by default and switched on per project: paragraphs written in a language other than the passage’s own are found and priced at that language’s speed. It is a project setting because whether your prose mixes languages is a fact about the story, not about a passage or a reader. It is off by default because it reads every paragraph after each pause in typing. Short paragraphs are left alone, since a language guessed from one line is guessed, not known. ## Related [Section titled “Related”](#related) * [How to write a passage](/how-to/write-a-passage/), for the editor, images and the Reads row. * [How to limit a choice](/how-to/limit-a-choice/) and [How to add, switch off or change a project’s languages](/how-to/manage-languages/), for applying what is described here. * [Passages, choices, languages and reading time](/reference/passages/), for the exact controls and defaults. * [Project files and the .ripple folder](/reference/project-files/), for the bundle and `touch.json` on disk. * [How to play and test a campaign](/how-to/play-a-campaign/), for how the player shows a spent choice. # A project is a folder > Why a Ripple Effect project is a view onto an ordinary folder of plain-text files, and what follows from that for saving, sharing and newer versions of the app. A project is a folder you already have, or one the app makes for you. Either way the app is a *view onto it*: the file tree is the directory as it is on disk, and every file it shows is one you could open in another program. Most of what is unusual about how the app behaves comes from taking that sentence seriously. ## A view, not an owner [Section titled “A view, not an owner”](#a-view-not-an-owner) The alternative is the familiar one: a project is a database, or a package the app creates and manages, and the files you care about are inside it. That is easier for the application, because it can assume nobody else touches the contents. It is harder for you. A story kept in an opaque container can only be moved, backed up, searched or reviewed by the program that made it, and it stops being yours to handle the day you want to use another tool. Ripple Effect makes the other trade. You point the app at a directory and it shows you what is there. It writes no scaffold into your story, imposes no layout and claims no ownership. Move a project, rename it, delete it from your file manager: there is nothing else to keep in step. A project that another program has moved or deleted disappears from the home grid, and that is all. A folder that has been a project arrives with its title, description, cover and language already filled in, because those were in the folder all along, and nothing is written. For a plain folder the app writes only `.ripple/project.json`, with the title defaulted to the folder’s name unless you give it another. The cost is that the app cannot assume it is alone, which is why saving and refusing a newer file work the way they do, below. ## Plain text, on purpose [Section titled “Plain text, on purpose”](#plain-text-on-purpose) Every file the app owns is plain text. Boards, components and soundscapes are JSON, namespaced inside `.json` so ordinary tools still read them. A state machine is a diagram in a format that is not ours at all. A passage in several languages is a folder of ordinary Markdown files, and a script is a `.py` file you can open in any editor. The kinds, and what each carries, are in [Project files and the .ripple folder](/reference/project-files/). Two reasons sit behind that choice. The first is that a format a script can read is a format you are not locked into. Attribute types, for example, are written under their wire names, and numbers and booleans are real JSON numbers and booleans rather than strings, because something reading the file should not have to know what the code happens to call them. The second is version control. Prose lives in Markdown, scripts in `.py`, and graphs are JSON with stable ids, so a diff shows what changed and a merge conflict is something a person can read. The app’s per-folder state is written with sorted, relative paths so it stays quiet in `git diff` and travels correctly into a clone. You do not have to bring your own history tool, because version control is built into the app; [History, sharing and conflicts](/explanation/history-and-sharing/) says how that sits on top of a folder that other programs also see. ## What the app keeps, and why it is kept apart [Section titled “What the app keeps, and why it is kept apart”](#what-the-app-keeps-and-why-it-is-kept-apart) The app does keep some things about a project: its title, the index of its files, the layout of the window, the console variables it declares, paused games, export profiles. All of it lives in one folder, `.ripple/`, beside your files, and nothing is written into the story itself. The app’s own bookkeeping has a place of its own so that it never has to impose a convention on yours. The same reasoning decides where a copied-in file goes. A picture or a sound you pick from outside the project, from your Downloads for instance, is copied into `.ripple/external/` rather than into a folder the app invents at the top of your story. Reaching outside the project is a way of saying you do not intend to file the thing, so it goes where the app’s own things go. Copying in is a switch; with it off the file is linked, and a link that leaves the project is said where it is, under the field and in Messages every time the project is opened. Commit `.ripple/`; the reason is the registry. A Jump stores its target’s catalog id rather than its path, and the ids live in the registry, so the registry is what keeps a Jump pointing at the right board after a rename. The registry also indexes every file in the project, a picture, a video, a PDF or a note as well as the files the app makes, as soon as it is in the folder, so something you have just dropped in can be offered in a picker. That does not make it ship: exports and compiled campaigns carry files by what points at them. Nothing under a dotted folder is listed, nor a dotted file, nor anything outside the project folder. Two things in `.ripple/` are about *you* rather than about the story, and those are the ones that should not travel. `workspace.json`, the layout and the open documents, is per machine. Saved games are a fact about your reading rather than about the story. The built-in version control leaves both out for you. ## Saving when you are not alone [Section titled “Saving when you are not alone”](#saving-when-you-are-not-alone) Everything writes as you work, with a short debounce, and closing the window flushes what is left. `Ctrl+S` is there for when you want to be sure. It holds for every kind of document, so there is no “unsaved project” to lose, and the bar over each editor says whether the file is written. Autosave is only safe because the app expects to share the folder. A file that another program changes underneath it is noticed rather than overwritten. That is what makes it reasonable to have a passage open in a second editor, or to pull from a co-author, without the app quietly putting its older copy back. ## A newer file is never half-read [Section titled “A newer file is never half-read”](#a-newer-file-is-never-half-read) The same caution applies when the file was written by a newer Ripple Effect than the one you are running. Files carry a version, and an older copy of the app that meets a newer one does not guess. It says it cannot read the file, tells you to update, and writes nothing to it. This holds for the files you could not get back: the index, the project’s settings, a component, a board and a saved game. Half-reading one and saving it would overwrite your work with a lossy copy, so the app stops instead. A saved game the app cannot read is refused and listed rather than overwritten, and a refusal stops that one file, not the ones beside it. Things that only cache something, such as a bundle’s translation timestamps or the list of recent projects, are different. They rebuild by looking at the folder again, so they are never a reason to stop. The rule is that anything you cannot regenerate is refused, and anything the app can regenerate is regenerated. ## Tips come in slices [Section titled “Tips come in slices”](#tips-come-in-slices) The first time you open each kind of thing, the home screen, a board, a bundle, a component, the campaign window, a short tip appears once, saying what the words for it are. It is not a tour: there is no single path through the app, since you may open a component before you ever open a board, so a tip belongs to the thing in front of you rather than to a sequence. Any tip can be brought back from the command palette, and *Help ▸ Troubleshooting ▸ Show every tip again* re-arms all of them. ## Related [Section titled “Related”](#related) * [Project files and the .ripple folder](/reference/project-files/): every file kind, and what each file in `.ripple/` holds. * [The workspace window, menus and palette](/reference/workspace-window/): the window laid out around the folder. * [How to create or import a project](/how-to/create-or-import-a-project/): making a project or opening a folder you already have. * [How to keep a history and back up a project](/how-to/keep-a-history-and-back-up/): putting the folder under version control. # Python in a story > Why Python in Ripple Effect is a sandboxed subset, why a passage reads but cannot write, and why a read hands back a copy. A story can hold Python in three places: a Script node runs a file of it, a sentence in a passage can hold a snippet of it, and a console beside every script is a place to try a line. The branching, the dice, the variables and the state machines are nodes. This page is about the shape that decision gave the feature: why it is a sandbox, why it is not all of Python, why a passage may look but not touch, and why the same campaign can ship with the interpreter or without it. ![The Python editor, source on the left and an interactive console on the right](/_astro/python-light.BxNHzu3y_1yADCB.webp) ![The Python editor, source on the left and an interactive console on the right](/_astro/python-dark.B9dv_Bte_Z22G5Cs.webp) ## A sandbox, and what that rules out [Section titled “A sandbox, and what that rules out”](#a-sandbox-and-what-that-rules-out) What runs the Python is an interpreter built for running somebody else’s code safely, with no filesystem, no network and a short standard library. The reason is that a story is a file you hand to other people: a campaign you export is played on a reader’s machine or inside somebody else’s product. Taking those abilities away, rather than asking the author to avoid them, is what lets the engine run a stranger’s script. The cost is that it is Python, and not all of Python. There is no `class`, no `import` of the outside world and no `random`. Dicts, `def`, lambdas, f-strings, comprehensions and a handful of modules are there. Snippets are Python-shaped rather than Python-portable: a script written here is not promised to run elsewhere, and a script written elsewhere is not promised to run here. Missing `random` is a deliberate gap with a story-shaped answer. The Dice roller, Random number and Weighted branch nodes are how a story rolls, so a roll is a node and not a call buried in a file. ### Why the API passes dicts [Section titled “Why the API passes dicts”](#why-the-api-passes-dicts) No `class` has two consequences that shape the whole API. A component or an entity crosses into Python as a **dict**, so it is `component_get("Goblin")["HP"]` and never `goblin.hp`. And there is no object syntax to build a nicer one: there is no `import ripple` and nothing to instantiate. Everything a script may call is a **host function**, bound under the name an author types and present without an `import`, answered by the app or by the runtime playing the campaign. ### Why a read is a copy [Section titled “Why a read is a copy”](#why-a-read-is-a-copy) A value crosses from the app to Python through JSON, so what `entity_get`, `component_get` and `cvar_get` answer is a snapshot. `e = entity_get("Mira"); e["HP"] -= 3` is legal, runs, and changes nothing. That is a trap, and it will not be fixed: a live handle would need `__setitem__`, which needs a class, which the interpreter does not have. Writing is always a named act: `entity_set` and `cvar_set` are the only way a script changes the world. ### Why a call is strict [Section titled “Why a call is strict”](#why-a-call-is-strict) Every optional argument is keyword-only, and a wrong call raises instead of answering. The check exists because the interpreter hands an unknown keyword straight through with no error. Without it, a typo in `of=` would behave as though the argument had been left out, and the call would behave as though the argument had been left out. A loud failure at the call is cheaper than a plausible wrong answer. The three functions that predate the rule, `component()`, `components()` and `set_attribute()`, are exempt for the opposite reason: every passage already written calls them, so what they do with a mistake is as frozen as their names. The names are a contract with every script written against them, and they are added to rather than repurposed. The same reasoning makes `entity_create` refuse a name that is already taken instead of returning the existing entity. A script asking for a name it can already see wants to be told, rather than have the mistake surface passages later. The Component and Spawn nodes are get-or-create, because that is what lets a node be re-entered and a paused campaign survive being resumed. Host functions also cannot be called inside `map()`, `filter()` or `sorted(key=…)`; that is a property of the interpreter, and [The sandbox and its limits](/reference/python/sandbox/#host-functions) has the detail. ## Where Python goes, and who may write [Section titled “Where Python goes, and who may write”](#where-python-goes-and-who-may-write) Python appears in five places, which the API calls surfaces: a Script node, a passage, the design-time console, an agent over MCP, and the console during a playthrough. Every function is bound on every surface. What differs is what a call does, and the differences follow one idea: **who is asking decides what may happen**. A Script node reads and writes, because it is a node on the board. A **passage** only reads. Making, changing or deleting an entity, or writing a console variable, is refused from inside prose, because a state change should be something you can point at on the canvas, and a goblin deleted by a sentence has nothing on the board to point at. The refusal renders in place as a `⚠` chip and the story carries on, so a mistake in prose degrades one sentence instead of ending the run. Every refusal is a single line, a `RuntimeError` a script can catch, and the sentences are fixed because scripts already branch on them. Playing a sound is refused from a passage for a different reason, and the difference is the instructive part. A sound changes no state. What it does is happen, and a passage’s snippets run again on every redraw and every change of language, so a bell rung from prose would ring twice for a reader who switched to Italian, with no node on the board to explain why. The rule there is about repetition, not about state. A stinger belongs on a Play sound node before the passage, or in a Script node. The design-time sentence for the console and an agent is different again, so that nobody who hits one goes looking for the rule of the other. Three things are deliberately not in the sound function, and each is a decision. There is no soundscape from Python, because what plays under a passage is set on the passage as a document the whole project can reuse, and a script that could put one in force would be a state change with no node to point at. There is no stopping, waiting or asking what is playing, because a cue is fire and forget by construction, and a function that could be awaited is one an author would await. And there is no path outside the bundle, because every sound a story can play is in the campaign, and a name that matches nothing there is refused in words. The file may be named as the project spells it or as the bundle does, because an export files every asset under `media/` and an author should not have to know that. See [Soundscapes and one-shot sounds](/explanation/soundscapes-and-sounds/) for why a cue and a soundscape are different things. The **design-time console** and an **agent** are refused writes for a plainer reason: there is nothing here to change. A project that is not being played has no entities, and a console variable holds only its default. Reads still work, and an empty answer is an answer: `entity_names()` is `[]` and `entity_get(name)` is `None`. The refusal sentences distinguish the two cases on purpose, because telling somebody a rule forbids what is really a missing playthrough sends them looking for the wrong thing. The **playthrough** console may do everything a Script node may. It exists because *Play from here* hands you a story that starts with nothing made, and the console is how you stand in for the nodes the story did not run. The surfaces are tabulated in the [Python reference](/reference/python/), and [The sandbox and its limits](/reference/python/sandbox/) holds what each allows. ## Slots, types and names [Section titled “Slots, types and names”](#slots-types-and-names) A Script node declares its inputs and outputs as typed rows, and values cross as native Python types, so `total = hp - damage` is arithmetic and not a parse. [Nodes](/reference/nodes/) describes the Script, Output and Input nodes. **A slot gets the type it declared.** An `int` input given `3.7` reads `3`, and a `list` given a list of text arrives as numbers. A value that cannot be read as the declared type becomes that type’s blank rather than stopping the story. The trade is explicit: coercion at the boundary means a script can trust what it receives, and the story can survive a bad value, at the price of a quiet substitution. What a script writes is left as it is. A slot may also carry a default, and the two rules there are asymmetric because the two directions differ. For a script input, a wired value wins over the default. For a script output, the default applies only where the program never set that name at all. Names are the one place where Python’s rules leak into the board. A Script node’s inputs and outputs, and a passage’s inputs, become variables, so they accept only a letter or an underscore followed by letters, digits and underscores, and Python’s own words such as `class` are refused with the reason shown under the field. A board that carries a name Python cannot read reports it instead of silently never filling the slot. The names on an Output or Input node are read by people, so `Your roll` is a fine name there. ## A console that is the same everywhere [Section titled “A console that is the same everywhere”](#a-console-that-is-the-same-everywhere) The console beside every `.py` file and the console during a playthrough are the same tool, with only the permitted writes changing. The [Python reference](/reference/python/) describes the sessions. ## Turning it off [Section titled “Turning it off”](#turning-it-off) A project written for a game, or for a player built without the interpreter, can say so: turn **Python** off under *Capabilities* in the project’s settings. The reason this is a switch on the project rather than something found at export is timing. Every Script node, and every passage with a `{{ … }}` or a `ripple` block, is marked as an error on its card and listed in Messages as you write ([`E.EX.01`](/reference/diagnostics/#eex01) and [`E.EX.02`](/reference/diagnostics/#eex02)), and the campaign will not play or export until there are none. Turning it back on changes nothing about the project, so the switch costs nothing to try. Behind it is the fact that the engine ships in two builds, one with the interpreter and a much smaller one without. Every exported campaign records whether it needs Python. The export dialog’s *Needs no Python* option is the check made at the point of export. See [What an export is](/explanation/what-an-export-is/) for how a campaign is built and [How to build a campaign with no Python](/how-to/turn-python-off/) for the steps. ## Why the app probes instead of listing [Section titled “Why the app probes instead of listing”](#why-the-app-probes-instead-of-listing) The interpreter’s limits change between its releases. A page that listed what is allowed would be correct on the day it was written and wrong later, and a reader would act on the difference. So the app does not describe the sandbox, it **probes** it: the sandbox report behind the `i` button on the console runs a suite of tiny snippets (statements, modules, built-ins, call shapes) in the real interpreter and reports what happened, row by row. It says what the app binds, what `import` can reach and which limits are enforced. An agent can ask for the same text through `python_capabilities`. Where any page and that report disagree, the report is right, and it is the thing to copy into a bug report. The interpreter is [monty](/about/open-source/#monty-python-in-a-sandbox); the open-source page says whose it is. ## Limits are about unattended runs [Section titled “Limits are about unattended runs”](#limits-are-about-unattended-runs) A script that has not finished after **two seconds** is stopped, and the app will not start that script again in the same playthrough. After three distinct scripts have been stopped in one playthrough, no script starts at all. The second rule is the reason for the first one’s shape: it keeps an unattended walk of every branch from paying for the same runaway on each pass. A script that hits this has an infinite loop in it. A script is also held to roughly 64 megabytes and 200 calls deep; runaway recursion raises `RecursionError` and a list that will not fit raises `MemoryError`. ## What a save and an export remember [Section titled “What a save and an export remember”](#what-a-save-and-an-export-remember) Python can read and write console variables, so what a save carries matters to a script’s author. A saved game carries every variable by name. A save from before the project had one holds it at its default; a variable removed from the declarations is dropped on resume; and one retyped so its saved value no longer fits falls back to the default. A save records what each variable held and not its type, so a build older than the one that wrote it would have reset a `json` variable to its default without a word. It refuses the newer save instead, and the fix is to save again from the current build. A compiled campaign carries the declarations (name, type and default), and a runtime starts every playthrough from them. A project copy carries the file itself, `.ripple/cvars.json`. So a script that calls `cvar_get` sees the same starting values wherever the campaign is played, and the saved state is what differs between playthroughs. See [Console variables](/explanation/console-variables/) for what a variable is. ## Related [Section titled “Related”](#related) * [How to run Python in a story](/how-to/script-a-passage/): writing a Script node and a snippet. * [How to build a campaign with no Python](/how-to/turn-python-off/): the project switch and the export check. * [Python reference](/reference/python/): the eighteen functions, the surfaces and the refusal sentences. * [The sandbox and its limits](/reference/python/sandbox/): what the report says and the numbers behind the limits. * [How a story runs](/explanation/how-a-story-runs/): where a Script node sits in the flow. # Reading a large story back > Why script navigation and the merged view exist, and why they draw a story the way a reader meets it rather than the way it is wired. A story with a few hundred passages across a dozen boards cannot be read by looking at it. A board is a good place to write a chapter and a poor place to understand a book: each canvas shows one chapter, and the connections between chapters are spread over many of them. Three questions come up that no single board answers: *how do the chapters connect*, *how can a reader get from here to there*, and *how could a reader have arrived here at all*. Ripple Effect answers them with views of the story rather than with more things to maintain. Script navigation answers the first, and tracing, which ends in the merged view, answers the other two. What they share is a rule about where the truth comes from: they draw what the story does, worked out from the boards, so there is no second drawing to keep in step. ## The map of boards [Section titled “The map of boards”](#the-map-of-boards) ![A map of how the boards hand the story to each other](/_astro/overview-light.D1tBQF-v_Z1a4qXO.webp) ![A map of how the boards hand the story to each other](/_astro/overview-dark.DTmZb2Lk_FIgCs.webp) **Script navigation** draws how a project’s boards hand the story to each other. There is one card per board and one output per way out of it. Every arrow is labelled with the node it leaves by. For a Jump into the middle of a chapter the arrow also says where it lands, as in `Down → The cellar`, so it no longer draws exactly like a jump to the chapter’s beginning. Markers show where a thread begins and ends. Nothing on it can be moved or rewired. The arrangement is worked out for you and is deterministic: the same project always draws the same picture, so the direction of the story stays readable and a screenshot of the map means the same thing each time. Changing how chapters connect happens on the boards, where the wiring is. The map also settles a question that could otherwise be ambiguous. Where a board begins is the same question the runner asks, *which node is the Start?*, so what the map draws is what the story does, not an approximation of it. [How a story runs](/explanation/how-a-story-runs/) explains the rules the runner follows. ## Tracing and its limit [Section titled “Tracing and its limit”](#tracing-and-its-limit) A board can light up the routes between two passages, and the same question can be put between two chapters on the map. The question runs the other way as well: every route that arrives at one passage, from wherever the story begins. Tracing is how you find out whether an ending is reachable without playing your way to it. [How to trace the routes between two passages](/how-to/trace-a-route/) covers using it. A board can only draw what lies on the board. When a route arrives through a Jump, the rest of it is on another canvas, and a single board has nowhere to put it. That limit is why the merged view exists. ## The merged view [Section titled “The merged view”](#the-merged-view) When a route arrives through a Jump the board cannot draw it, so the answer opens a **merged view**: every board on the route on one canvas, with the bookkeeping between two passages traversed rather than drawn. Like script navigation it is read-only; the boards are where things change. ### Drawn as the reader meets it [Section titled “Drawn as the reader meets it”](#drawn-as-the-reader-meets-it) The merged view is not a copy of the wiring. A trace is a question about what a reader goes through, so the picture follows the reader’s experience wherever that differs from the wires. * **A choice that runs a script and returns** to the passage it left is an arrow back to that passage, as a choice wired straight back is. * **A Do all’s branches** lie one after another on the route to `completed`, rather than as a fork the reader picks one way through. The reader does not choose between them; they all happen. So their passages are on the route, and the count of ways in does not count them as alternatives. The same holds for one wire’s end fanned out to several places. * **Macros are opened up.** The route goes inside the macros it calls, and the passages a macro holds are drawn where the call is. A shop or a skill check reused in three places is therefore read on the route in each of them, not as one box that stands for all three. An arrow that passes through an *If*, a *Switch*, a roll or a loop names that node and the way it went, such as `Hit? → true`, beside the words of the choice if there was one. It is clear where the story decided and not the reader. [Boards, nodes and macros](/explanation/boards-nodes-and-macros/) describes those nodes and how a macro is called. ### One kind of trace at a time [Section titled “One kind of trace at a time”](#one-kind-of-trace-at-a-time) A trace on a board and a merged view across boards do not mix. Opening a merged view takes every board’s trace away, and while one is open a board’s two trace buttons wait, and say why, until it is closed. Closing the last merged view hands the boards back. ## What an assistant can ask [Section titled “What an assistant can ask”](#what-an-assistant-can-ask) The same three questions are available to an AI assistant as MCP tools: `trace_between` for the routes between two passages, `trace_into` for every route that arrives at one, and `board_links` for how the boards hand the story to each other. So it can answer *is this ending reachable* without sitting through the story. [Working with an AI assistant](/explanation/working-with-an-assistant/) puts them in context, and the tools themselves are in the [MCP tools reference](/reference/mcp-tools/). ## Related [Section titled “Related”](#related) * [How to trace the routes between two passages](/how-to/trace-a-route/) * [Board and navigation rules](/reference/canvas-rules/) * [MCP tools](/reference/mcp-tools/) # Soundscapes and one-shot sounds > Why a story has two shapes of sound, a timeline file and a single cue, and how the runtime decides what is playing while a host renders it. A story can carry sound in two shapes, and they differ in what they are, not in how loud or long. A **soundscape** is what plays *under* a scene: music, rain on a roof, a bell at the second minute. It is laid out on a timeline, kept in a file of its own, and named by the passage it belongs to. A **sound** is one file played once, now, from a node or from a script, while the story carries straight on. ## Audio belongs to the project, not to a node [Section titled “Audio belongs to the project, not to a node”](#audio-belongs-to-the-project-not-to-a-node) Every audio file in the project folder is in the registry: an `.ogg`, `.mp3`, `.wav`, `.flac`, `.m4a`, `.aac` or `.opus` dropped anywhere in the folder shows up in every picker without a file dialog. This is the same stance the app takes toward the rest of a project, a view onto a folder rather than a structure it imposes. *Import…* is there for a file that lives elsewhere, and copies it to `.ripple/external/` the way a picture picked from outside the project is. An audio file also opens in a tab of its own with play, pause and a scrub bar. ## A soundscape is a file [Section titled “A soundscape is a file”](#a-soundscape-is-a-file) A soundscape is a `*.soundscape.json`: a timeline of clips over one clock. The timeline itself has a length and can loop, which is what a *Loop* clip follows. Keeping it as a file of its own, rather than as fields on a node, is what lets several passages name the same one and lets it be edited as a timeline rather than as a form. A clip names its audio by catalogue id, as a Narration node names the soundscape, so renaming or moving a file never breaks a clip. The file carries integer milliseconds throughout and every default written out, and an earlier build refuses a newer file rather than half-reading it. [Soundscape files](/reference/soundscape-file/) has the keys. ### The picture is the sound [Section titled “The picture is the sound”](#the-picture-is-the-sound) Each clip is drawn as a block with the shape of its sound inside it: the part of the file it plays. That choice matters for how the editor feels. Pulling a ledge moves the picture with the sound, so you trim to a beat by eye rather than by typing a number and listening. The shape appears a moment after the clip does, since reading a file’s shape means decoding it. A clip’s playback mode says how it behaves when the timeline comes round. Auto-loop is the one that ignores the timeline’s end and repeats for as long as the soundscape plays, which suits music. Its rest sets the character: with none, the repeat is gapless, a bed; with one, the clip is a bell every so often on its own rhythm across the whole soundscape. [Soundscape files](/reference/soundscape-file/) defines each mode and the rest. ### A fade is a length, not a moment [Section titled “A fade is a length, not a moment”](#a-fade-is-a-length-not-a-moment) A clip’s fades are drawn on it and you pull them. What the two ramps leave of the clip is the shape you see: a slope up at the start, a slope down at the end, and the quiet part shaded over the waveform. The slopes carry grips you can drag; [How to add sound to a story](/how-to/add-sound-to-a-story/) has the steps. An auto-loop clip shows the same shape on every repeat, because that is where it ramps. The panel’s fields say how long a ramp lasts, not when it begins, which is why a quarter-second fade reads `0.25 s` and not `0:00.250`. Writing it as a length is what keeps the drawing and the number the same thing: the grip and the field edit one value. A clock typed there is still understood. Because an auto-loop ramps at every turn of its clip, rest or no rest, a file that does not loop cleanly can still be used as a bed: the fade covers the seam. A clip whose two ramps together outlast it is allowed, and it sounds the way it looks. It rises and falls without ever reaching full, because the two ramps are taken at whichever is quieter rather than multiplied together. The model clamps nothing; the drawn shape is the rule. The same reasoning applies to the timeline as a whole. Its fade in and fade out are lengths too: the whole soundscape rises when it begins and falls when it is replaced. Its volume is the file’s own, and every clip’s volume is relative to it, so there is no second master to keep in step. A clip past the timeline’s length is legal and shaded, since the length is a field you may lengthen next. What lies past it is cut at play time rather than refused in the editor. ### Lanes are for you [Section titled “Lanes are for you”](#lanes-are-for-you) Lanes can be named, such as *Bed* or *Weather*, and locked. A locked lane takes no clip, so a bed laid out once stays where it was put while everything else is moved around it. These are working aids for the author. Mute and solo are your ears, not the file. Every lane header carries an **M** and an **S**, and a clip can be silenced on its own from its panel; silenced clips are drawn faint and say so. None of it is saved and none of it reaches the story. A soundscape plays every clip it holds, because what a soundscape says is what the story will play. The tab previews through the same thing the campaign plays through, so a change is heard where the clock is: a gain slider is live, and a dragged clip does not restart the bed under it. The whole file is one undo, the toolbar’s, so a ledge pulled on the timeline and a volume typed in the panel share one history. ## What is playing, and when [Section titled “What is playing, and when”](#what-is-playing-and-when) A soundscape is chosen on a **Narration** node, in a row under the choices, and from there it plays **through every passage after it that brings none of its own**. A tavern goes on being a tavern. Naming the same soundscape again on a later passage does not start it over. Two soundscapes never sound at once. The old one finishes its fade before the new one begins its own. The row has two more answers besides a soundscape. *Silence* stops whatever is playing outright. A passage with *None* changes nothing. And a soundscape marked **tied** belongs to its passage: it stops when the story moves on, instead of staying in force. Tied is for the sound that is part of one page, such as a fire that should not follow the reader out of the room. A saved game remembers what was playing, so a load hears the tavern before it sees the page. A tied soundscape comes back only at its own passage. See [How a story runs](/explanation/how-a-story-runs/) for where this sits among a story’s other state. ### The runtime decides, the host renders [Section titled “The runtime decides, the host renders”](#the-runtime-decides-the-host-renders) Which soundscape is in force is the **runtime’s** decision, and the runtime plays nothing. It tells whatever is hosting the story which soundscape is in force, and the host renders it with whatever it has. The play window renders it through the app’s mixer. The command-line player prints a line per change and hears nothing, and that is conforming. This division is why the rule above can be the same everywhere. Every rule about what is in force runs once, in the engine, and is checked by the same fixtures whichever host plays them. A host with no speakers, or a game with its own audio middleware, takes the answer and does the rendering its own way. [What an export is](/explanation/what-an-export-is/) says what a compiled campaign carries for that host. ## A sound, once [Section titled “A sound, once”](#a-sound-once) A one-shot sound is the other half of the split. Nothing waits for it. The **Play sound** node plays one file, once, at a volume and a speed, and its `completed` port fires the moment the sound is cued, not when it ends. A door slams and the next passage is already showing. That follows from the sound being an event and not a state: there is nothing for the story to hold until it finishes. The same call is available from a Script node as `play_sound("sfx/bell.ogg")`, by the project’s spelling of the path or the bundle’s, with `volume` and `speed` by name. There is no way to stop a sound, wait for one or ask what is playing. A function that could be awaited is one an author would await, so a cue is fire and forget by construction. A passage’s snippets are refused it, and that is a rule rather than a circumstance. It is not the rule that keeps a passage from writing an entity: a sound changes no state, it *happens*. Snippets run again on every redraw and every change of language, so a bell rung from prose would ring again for a reader who switched language, with no node on the board to explain why. A sound that belongs there goes on a Play sound node before the passage, or in a Script node. Python cannot put a soundscape in force either. What plays under a passage is set on the passage, as a document the whole project can reuse, and a script that could set one would be a state change with no node on the canvas to point at. ## Hearing it [Section titled “Hearing it”](#hearing-it) The campaign window’s header has a **mute** button, remembered between sessions. It is the reader’s, and it belongs to the campaign: previewing a soundscape in its own tab is never silenced by it, since someone editing a soundscape has asked to hear it. ## Related [Section titled “Related”](#related) * [How to add sound to a story](/how-to/add-sound-to-a-story/): make a soundscape, attach it to a passage, cue a sound. * [Soundscape files](/reference/soundscape-file/): the keys a soundscape carries. * [Sounds in Python](/reference/python/sounds/): the `play_sound` call and its arguments. * [Nodes](/reference/nodes/#play-sound): the Play sound node. # State machines > Why a state machine in Ripple Effect is a mermaid drawing in a file of its own, how it is shared across a story, and why a runtime needs no parser to play it. Some state is a *shape* rather than a value. A door is locked, then unlocked, then open; a quest is offered, then taken, then done; a guard is calm until provoked. You could hold any of these in a [console variable](/explanation/console-variables/) holding a number or a word, and for a single flag that is the right tool. A shape is different: what matters is which moves are allowed from where you stand, and a variable records only where you are. Rules like “you cannot open a locked door” end up scattered across every passage that touches the variable. A state machine puts the rules in one place. In Ripple Effect that place is a drawing. ## A machine is a picture first [Section titled “A machine is a picture first”](#a-machine-is-a-picture-first) A machine is a `*.fsm.mmd` file holding a mermaid `stateDiagram-v2`. Every arrow reads `From --> To : event`, and the start is `[*]`. The same door, written out: ```text stateDiagram-v2 [*] --> Locked Locked --> Unlocked : key Unlocked --> Open : push Open --> Unlocked : pull Unlocked --> Locked : lock ``` The editor shows the text on one side and the picture on the other, redrawn as you type, with every problem on its line. While the text does not parse, it draws the last machine that did, greyed. A picture of the text as typed would be a picture of a machine that does not exist, and blanking the pane on every half-typed arrow would make editing flicker. ## Why mermaid owns the format [Section titled “Why mermaid owns the format”](#why-mermaid-owns-the-format) The format is mermaid’s, not ours. Every other file the app owns carries a `version` of ours; this one does not, because the `stateDiagram-v2` header is the version mermaid gave it. The same file renders on GitHub, in an editor plugin and anywhere else mermaid does, and the full syntax is mermaid’s own [state diagram reference](https://mermaid.js.org/syntax/stateDiagram.html). The app reads a subset of mermaid and refuses the rest, naming the line. [State machine files](/reference/state-machine-file/) lists what is accepted and what is refused. One case is a warning rather than a refusal: an arrow with no event is drawn and kept, but nothing can ever name it, so it can never fire, and the editor says so. ## One reader, so the picture is what plays [Section titled “One reader, so the picture is what plays”](#one-reader-so-the-picture-is-what-plays) A single reader parses the diagram. The editor draws from it, and the compiler uses the same reader to write the transition table a campaign carries. Two readers would be two opinions about what a machine is, and the picture would stop being what the story does. Because there is one, a file that draws is a file that plays. ## A machine on a board [Section titled “A machine on a board”](#a-machine-on-a-board) A machine does nothing until a board drives it. Drop the file on a board and it becomes a **State machine** node; the node takes an event and answers with the state the machine is in afterwards. That makes the machine a participant in the flow of the story, not a separate system running beside it. The node’s two exits, **changed** and **unchanged**, exist because an event that fits nothing is a normal thing to happen. **changed** fires when an arrow fired or the machine was reset; **unchanged** fires otherwise. Pushing a locked door is not a mistake in the story; it is a moment the author may want to narrate. So an event the current state has no arrow for leaves the machine where it is and takes **unchanged**, and what to do about it is a decision for the passages downstream. Wiring the **state** output into a **Switch**, one row per state, is how the story branches on where the machine is. [Adding a machine to a board](/how-to/add-a-state-machine/) walks through the steps. ## Shared instances [Section titled “Shared instances”](#shared-instances) A machine remembers for the whole playthrough, across boards and across saves, in the way the FlipFlop and Once gates do. Starting over resets it. That raises a question: if two nodes name the same machine, do they share it? By default they do. Two nodes anywhere in the story drive the same machine unless one is given an **instance** name; left blank, the instance is the machine’s own name. Two doors want two instances of the door machine, and the instance name is how you say so without copying the file. One quest wants one instance. ## In a saved game and in an export [Section titled “In a saved game and in an export”](#in-a-saved-game-and-in-an-export) A save carries what every machine remembers, so a resumed playthrough finds the door where you left it. A save with no machine data reads as remembering nothing. A compiled campaign carries each machine as a transition table, so a runtime needs no mermaid parser. What a campaign contains is covered in [what an export is](/explanation/what-an-export-is/). ## Related [Section titled “Related”](#related) * [How to add a state machine to a board](/how-to/add-a-state-machine/) applies this on the canvas. * [State machine files](/reference/state-machine-file/) lists what the reader accepts and refuses. * [Nodes](/reference/nodes/) has the State machine node’s ports and fields. * [Console variables](/explanation/console-variables/) covers the other way a story remembers. # Why the preview is the shipped story > Why Play runs the same engine as an exported campaign, and the decisions that follow from it: spent choices, the loop ceiling, refused saves and narration roots. A preview that is a different thing from what ships is a preview that can lie. If the editor ran the story with one implementation and an export ran it with another, the two could disagree. Ripple Effect has only one implementation to disagree with. ## One engine, and it has never seen the canvas [Section titled “One engine, and it has never seen the canvas”](#one-engine-and-it-has-never-seen-the-canvas) **Play** runs the same engine an exported campaign runs: the native engine, which knows nothing about the canvas. The board you draw is compiled into a campaign, and the engine plays the campaign. What you see in the campaign window is therefore the compiled story playing, as a reader of the export would meet it. The rules for how a story moves are the subject of [How a story runs](/explanation/how-a-story-runs/). Play also re-indexes before it compiles. It refreshes what passages link to and where boards jump, which is the same index an export reads. A picture added to a passage since the project was opened is therefore in the playthrough without an export first. Compile warnings, and the engine’s checks of every board, reach **Messages** when you press Play for the same reason: they come from the compiler an export uses, so a problem is found while you are still at the desk. The same logic explains one deliberate asymmetry. The loop ceiling is the app’s and never travels with an exported campaign. It is a guard for the person testing, not a property of the story. ## Showing what has been spent [Section titled “Showing what has been spent”](#showing-what-has-been-spent) A choice with a **Times** limit that has been used up stays on screen, greyed out and unpressable, rather than disappearing. The reason is that a preview exists to tell you what a reader will meet. If spent choices vanished, a passage whose every path was spent would show up as an empty slide, and you could not tell a broken board from a story that had run out. Left visible, the choice says that the path exists and has been used. How to set a limit is in [How to limit a choice](/how-to/limit-a-choice/). The reader’s language follows the same principle. A passage with nothing in the chosen language says so, rather than falling back quietly to another one. A silent fallback would hide the gap and make the preview in one language misleading about the shipped story in another. See [Passages and translations](/explanation/passages-and-translations/). ## A loop that nothing breaks [Section titled “A loop that nothing breaks”](#a-loop-that-nothing-breaks) A loop nothing breaks out of stops the campaign instead of running for ever. By default one loop node may go round 512 times in a board; the campaign then stops and offers you the node that ran away. A For node’s own *Max iterations* still applies first when it is lower. Stopping and naming the culprit is the useful behaviour in a preview: an endless loop is almost always a mistake in the board, and the node that ran away is where the mistake is. The choices of ceiling are listed in [Settings](/reference/settings/). ## A project with Python off [Section titled “A project with Python off”](#a-project-with-python-off) If the project has turned Python off, every Script node and every passage with Python in it is marked as an error and listed in Messages, and the campaign will not play until none remain. The export’s **Needs no Python** option is ticked and locked. None of these is wrong on a board, only for a player that cannot run Python, so turning Python back on lets the same project play and export as before. See [How to build a campaign with no Python](/how-to/turn-python-off/). ## Starting where you are looking [Section titled “Starting where you are looking”](#starting-where-you-are-looking) **Play from here** begins at any node instead of the Start. It starts with nothing made yet, and says so. What a run would have built on the way there depends on dice, on scripts and on choices nobody made, so an invented version would look plausible and be wrong. An empty start is honest. The console in the campaign window is the way to set up whatever the story should have had, and it may do everything a Script node may. The cost is a little setup work; the gain is that the state you test from is one you chose rather than one the tool guessed. See [How to play and test a campaign](/how-to/play-a-campaign/). ## Why a changed story refuses an old save [Section titled “Why a changed story refuses an old save”](#why-a-changed-story-refuses-an-old-save) Pausing writes an automatic save into the project’s `.ripple/saves/`. It records where you are and everything the story has made, including a choice’s **Times** count, so coming back finds a path as spent as it was left; starting over reopens it. The full list is in [The campaign window and saved games](/reference/campaign-window/). A save records a place in a board. A board that has since been rewritten may no longer have that place, and putting you somewhere that no longer means what it meant would be worse than refusing. A saved game from a changed story is therefore listed with the reason beside it and left alone. Pausing and resuming in the same sitting is deliberately different: it hands you back the board you just edited, which is what pausing is for. The two cases differ in intent, which is why one is allowed and the other is not. A save also records what each console variable held, including variables of the `json` type. A build older than the one that wrote a save refuses it rather than quietly putting a `json` variable back to its default, so games have to be saved again after upgrading. Refusing is chosen over guessing here for the same reason as above: a wrong resume looks like a working one. Saves are left out of version control. A playthrough is a fact about your reading rather than about the story, and the automatic one is rewritten every time you pause, so it would be noise in the history. See [History, sharing and conflicts](/explanation/history-and-sharing/). ## Where a story may begin [Section titled “Where a story may begin”](#where-a-story-may-begin) A board has one Start, and only a Start begins a story. Project settings also name **narration roots**, the boards a reader is allowed to start from. Nothing in the app is gated on them, and you can still play any board from any node. They are a statement about the story, not a rule for the editor: an export carries them, and whatever reads the export is told where the author intended a story to begin. ## Related [Section titled “Related”](#related) * [How to play and test a campaign](/how-to/play-a-campaign/) * [How to save and resume a playthrough](/how-to/save-and-resume-a-campaign/) * [The campaign window and saved games](/reference/campaign-window/) * [Settings, accessibility and updates controls](/reference/settings/) * [Diagnostics](/reference/diagnostics/) # Why the installers are unsigned > What code signing is, why Windows and macOS warn about an unsigned installer once, and why Windows 11's Smart App Control blocks it with no override. None of the installers is signed by an authority your machine already trusts. That single fact explains every prompt you meet when you first run the app, and it is worth separating from what the prompts might seem to say. ## What signing is for [Section titled “What signing is for”](#what-signing-is-for) Signing ties an installer to a certificate that a platform already recognises. None of these installers carries one, so Windows and macOS each stop the app once, on first run, and say they do not know it. The warning is about the missing certificate, not about anything the installer does. ## What each platform does [Section titled “What each platform does”](#what-each-platform-does) On Windows the prompt is SmartScreen. On macOS it is Gatekeeper, which reports an unidentified developer or an app it could not verify. Each stops once and then lets you decide. Linux checks nothing of the sort and asks nothing, so the Linux packages install without any question about signing. A purchased certificate would remove the SmartScreen warning on Windows and, with notarisation, the Gatekeeper prompt on macOS. A signed Windows build is a known next step rather than a decision against signing. [How to install Ripple Effect](/how-to/install-the-app/) covers what each platform asks you to do. ## Why Smart App Control is different [Section titled “Why Smart App Control is different”](#why-smart-app-control-is-different) SmartScreen asks a person, and a person can say yes. Smart App Control, a Windows 11 feature, does not ask. It runs only apps that Microsoft’s reputation service already knows or that carry a trusted signature, and an unsigned installer is neither. When it is on, Setup is refused with the message “An Application Control policy has blocked this file” (error 4551). Unlike SmartScreen it has no *Run anyway* and no per-app exception, so the only way to install is to turn the feature off. This catches people who have never heard of it, because of how it is switched on. It is only ever enabled on a clean install of Windows 11, and a reset counts. It starts in an Evaluation mode that blocks nothing while Windows decides whether the feature suits the machine, and then switches itself on, with a notification, or off. A PC upgraded from Windows 10, or one seen running developer tools during evaluation, has it off already, which is why most people never meet it. Caution Turning Smart App Control off is a decision, not a toggle. Microsoft says that recent Windows updates allow it to be turned back on without reinstalling. On older builds, once it is off the only way back is a clean reset of Windows. Weigh that before switching it off for one app. Being refused outright, rather than warned, is the strongest argument for signing the Windows build. The steps to check and turn off Smart App Control are in [Installers and first launch](/reference/installers/). ## Related [Section titled “Related”](#related) * [How to install Ripple Effect](/how-to/install-the-app/) * [Installers and first launch](/reference/installers/) * [Updates, logs and Messages](/explanation/updates-logs-and-privacy/), because an update’s installer raises the same prompts as the first one # Updates, logs and Messages > Why updates are verified and never silent, why the session log has no always-send setting, and why Messages and the log are kept apart. The app does three things on its own account: it keeps itself current, it writes down what it did, and it tells you things about your folder. Each has a different audience, and the design choices below follow from keeping the audiences apart. The page takes them in turn: updates, engine fixes, the news, the session log and what leaves it, reporting a bug, then Messages and files from a newer version. ## An update is something you can refuse [Section titled “An update is something you can refuse”](#an-update-is-something-you-can-refuse) After the first install, the app installs its own updates. Once per launch it asks the website whether a newer release exists. When one does, the notice can do the update: it fetches the installer for the way *this* copy was installed (the `.deb`, the `.rpm`, the Windows Setup or the `.dmg`), checks it against the checksum the release publishes, hands it to Setup, Finder or your package manager, and closes so that can happen. On Windows, Setup offers to reopen the app when it has finished; on macOS and Linux you start it again yourself. Handing the installer to the platform’s own installer window, rather than replacing files from inside the running app, means you get the ordinary window and can still say no to it. A download that does not match its checksum is thrown away. Where the app cannot tell how it arrived, it opens the download page instead. An update never closes the app over unsaved work: a file that changed on disk while you were editing it stops the update, and the app says which one. Previews are the same mechanism with one more choice. A copy following the Preview release channel is offered early builds of the next version as well as every stable release, whichever is newer. The website answers every copy with every channel’s latest release, so the request never says which channel you follow; the choice is made on your machine. Nothing older than the version you are running is ever offered, on either channel. That is why switching back to Stable from a preview never downgrades you. It stops the previews, and the app waits for Stable to catch up, because a project a newer version has saved is exactly what an older one may refuse to open, as the section on files from a newer version explains. [Settings](/reference/settings/#release-channel) has the controls. Installing a downloaded installer is a step the platform may question, because nothing is code-signed yet; [Why the installers are unsigned](/explanation/unsigned-installers/) gives the reasoning, and [Installers and first launch](/reference/installers/) says what each platform asks. ## Engine fixes travel separately [Section titled “Engine fixes travel separately”](#engine-fixes-travel-separately) The part of the app that plays and compiles campaigns can be fixed without a new installer. A fix is fetched in the background, checked against a signature before it is trusted, and used from the next launch. If the app crashes with a fix in place, the fix is set aside automatically. A fix never changes what the app can do. Because it happens without a prompt, it is visible and reversible: the app bar shows a button while a fix arrives and when it is ready, and the fetching can be switched off, or the fixes removed, in Settings. [Settings, accessibility and updates controls](/reference/settings/) lists the controls, and [What the app sends](/about/what-the-app-sends/#engine-fix-check) says what the request carries. ## News without an update [Section titled “News without an update”](#news-without-an-update) Some things worth telling you are not about the editor at all: a new binding for the engine, an integration, a page that moved. They have no place in a release’s notes, and waiting for the next release to say them would be the wrong way round. So the home page has a News button, and the items behind it come from a feed on the website rather than from the app. It is held to the same rules as the update check. It asks once per launch, carries nothing about you, and fetches pictures from our own site and nowhere else. What you have read is a date kept on your machine. It also waits on a licence: version 1.0 of the agreement listed every request the app could make, and the news was not on the list. Version 2.0 allows a request that carries nothing about you, made only to our own site, as a kind, and a copy that accepted 1.0 is asked about it thirty days after being told. [Settings](/reference/settings/#news) has the switch, and [What the app sends](/about/what-the-app-sends/#news) says what the request carries. ## The session log is for a bug report, and you decide what leaves it [Section titled “The session log is for a bug report, and you decide what leaves it”](#the-session-log-is-for-a-bug-report-and-you-decide-what-leaves-it) Ripple Effect keeps a diary of each run. The app never uploads it on its own, and when you want to report a bug you decide what to hand over. One route can carry a copy of it away, and it asks you first. If the app crashes, the next launch shows you the crash report it saved: every file in it, its size, and what each one contains. It is sent only if you answer *Send report*. The session’s logs are among those files. There is no “always send” setting and no “never send” one. Either would turn a decision about one crash into a standing policy made before you had seen what the crash contained. Instead the question is asked once per crash, at the moment you can see what it is about, and whichever way you answer is final for that report. [What the app sends](/about/what-the-app-sends/) goes through it in full. Usage statistics follow the same logic with a different default. They are opt-in, and off until you switch them on. The app asks once, with the complete list in front of the question rather than behind a link, with neither answer preselected, and the answer can be changed afterwards. Switching off destroys the identifier, so nothing already sent can be tied to you again. The fields and the delay are in [What the app sends](/about/what-the-app-sends/#usage-statistics). ### A session is a folder [Section titled “A session is a folder”](#a-session-is-a-folder) What the app records about a run lives in a folder named by a short id: the log, a separate file for each kind of crash, and which build you were on. Every file has one writer. The Rust engine, the part that plays and compiles campaigns, keeps its own `engine.log` in that folder with the same redaction, and a crash in it records the file and line it came from, even in a release build. Two sides writing to one file would need to agree on an order, so each writes its own. When a piece of work crosses from one to the other, a compile for instance, both sides write it down with the same number on it, so the two halves can be read together. A crash writes what happened and closes off the session log in one go, so the file is complete even when the app is not. The rule behind what is recorded is to keep the outline of what happened and not the author’s material: a query string can carry a credential, and an argument can be a passage you wrote. [The session log and reports](/reference/session-log/) lists what is recorded and where the folder is. ### A problem session is never mistaken for a clean one [Section titled “A problem session is never mistaken for a clean one”](#a-problem-session-is-never-mistaken-for-a-clean-one) The log is the evidence a bug report rests on, so the design avoids the ways a log loses it. Nothing is dropped. A message with a line break in it, such as a Python traceback, cannot make the next launch read a problem session as fine and remove it. Warnings and above reach the file at once with the run-up before them, and a very large run continues into a second file rather than growing one nobody can attach to an issue. It does tidy up after itself, on the other side of the same bargain. A session where nothing went wrong is deleted the next time you start the app, so the folder holds the runs worth something and not a file for every time you opened a project. If the app falls over before it can write its own verdict, the next launch reads that file properly rather than guessing. ### Private things are taken out first [Section titled “Private things are taken out first”](#private-things-are-taken-out-first) Redaction happens before anything is written down. Your username and home folder, the location of the project you have open, e-mail addresses, network addresses and anything shaped like a password or a key are replaced. That includes e-mail addresses with accented or non-Latin local parts and account names ending in an accented letter. Times are recorded in UTC rather than your local clock. A list of rules cannot know that a board’s name is private, so a glance before you post is still worth it. ## Reporting a bug starts from what you saw [Section titled “Reporting a bug starts from what you saw”](#reporting-a-bug-starts-from-what-you-saw) A bug report is where the log meets a person, so the app answers the questions it can itself: the version, your operating system and which installer this copy came from arrive already filled in, so a report starts from what you saw rather than from four questions. Links the app opens go through `ripplefx.app`, which sends you on to where that thing lives today, so a link in this version keeps working if the place moves. The steps are in [How to report a bug](/how-to/report-a-bug/). ## Messages are about your folder, the log is about the app [Section titled “Messages are about your folder, the log is about the app”](#messages-are-about-your-folder-the-log-is-about-the-app) The **Messages** panel is a different thing from the log. Messages is what the app says to *you* about *your folder*: a translation that has fallen behind, a picture a passage points at that is not there, a board with no way in, a file written by a newer version. It is scoped to the open project and cleared when another one opens. The log is what the app says about *itself*, for whoever reads a bug report. The two have different readers, so each holds what its reader needs. [Diagnostics](/reference/diagnostics/) covers how Messages are grouped and what the codes mean. ### Files from a newer version are refused, not guessed at [Section titled “Files from a newer version are refused, not guessed at”](#files-from-a-newer-version-are-refused-not-guessed-at) A project file written by a newer Ripple Effect is never half-read. An older copy of the app says it cannot read the file, tells you to update, and writes nothing to it. Each of these files is something you could not get back if a copy that misunderstood it wrote it out again. Things that only cache something, such as a bundle’s translation timestamps or the list of recent projects, rebuild by looking at the folder again, so those are never a reason to stop. [Project files and the .ripple folder](/reference/project-files/) lists the file kinds. ## Related [Section titled “Related”](#related) * [The session log and reports](/reference/session-log/) * [Settings, accessibility and updates controls](/reference/settings/) * [What the app sends](/about/what-the-app-sends/) * [How to report a bug](/how-to/report-a-bug/) * [Diagnostics](/reference/diagnostics/) # What an export is > Why a campaign is compiled rather than copied, why media is a switch, why there are two engine builds, and why HTML is shaped by a profile in the project. Export makes one of two things, and the dialog asks which. They look alike from the outside, since both write a folder, but they answer different questions. A project copy answers “how do I hand this story to someone who will keep editing it?”. A campaign answers “how do I hand this story to something that will only play it?”. ## A project copy is the project, resolved [Section titled “A project copy is the project, resolved”](#a-project-copy-is-the-project-resolved) A project copy is a folder that opens in the editor, with `.ripple/` included. It exists for a translator, or for keeping. Because a project is a view onto a folder (see [A project is a folder](/explanation/projects-are-folders/)), a copy is the nearest thing to the project itself, with one change: a picture the prose points at from outside the project is brought in, landing in `media/external/`, so the copy does not depend on anything on your machine. The decision is what to do with media. A story’s music is the heaviest thing in the folder and the one most likely to be somebody else’s to license. Sending a translator the story while keeping the score is therefore the ordinary case, not an edge case, and that is why **Include media** is three switches, one each for images, videos and audio, rather than one. When a kind is switched off its files are listed and left behind, but the passages, boards and soundscapes still point where they would have been. The copy stays structurally whole, and the media can be put back later without anything having to be rewritten. ## A campaign is compiled, not rearranged [Section titled “A campaign is compiled, not rearranged”](#a-campaign-is-compiled-not-rearranged) A campaign is the story compiled down to a bundle that a runtime plays with no editor anywhere near it. It is not a tidied copy of the project, and the difference is the reason it exists. An editor and a player want opposite things from a file. The editor wants everything it needs to let you change your mind: references by name, several languages side by side, macros you can open, boards laid out for reading. A player wants decisions already made. So compiling resolves every reference ahead of time, settles every board’s evaluation order, flattens each passage’s languages into one file and throws away what only an editor cares about. A runtime then never has to know what a macro is, what Markdown is or how a board was drawn, and that is what lets a terminal player, a game or any other host play the same story the editor does. The bundle format is a published contract and the engine that plays it is written in Rust, with no editor in it; a command-line player built from it plays a campaign with neither Flutter nor Dart installed, which is how that claim is checked. Most of what a compiled campaign gets right follows from that one idea. * **It carries what the project has, and what the story uses.** A language you switched off stays in your project and does not ship. A picture a passage or component points at but the project does not hold is left out rather than travelling as a name nothing can open, and a picture nobody embedded, or a sound no soundscape plays, is left out because the folder happened to hold it. Each omission is named in Messages, so what the export produces agrees with what it said. * **A component’s files travel with it.** An attribute holding a list of files, however it was written, has its files carried along with rewritten paths, so the campaign never names a picture that is not in it. * **Macros dissolve.** Nothing of a macro reaches the bundle: each call is spliced into the board that made it, so two calls are two copies. See [Boards, nodes and macros](/explanation/boards-nodes-and-macros/). * **Python is lifted out of the prose.** Each passage’s snippets go into a table of their own, with a marker left where each one was, so a program playing the campaign puts the answers in by number and never needs to understand Markdown. That separation is also what makes it possible to deliver prose in another format at all, as below. * **Slots are read as the type they declared.** A value that cannot be read as its slot’s type becomes that type’s blank rather than stopping the story. The trade is deliberate: a story that keeps going with a blank is easier to recover than one that halts on a bad value. * **A default is settled before shipping, or carried if someone must answer it.** Most defaults become ordinary values on the board when the campaign is compiled; the two that a reader or a program answers, a form field’s and a script output’s, are carried in the campaign. The examples and the full list of sites are in [Export options and bundle contents](/reference/export-options/#what-a-bundle-holds). * **Sound is a table, and the runtime plays none of it.** A Narration node names its soundscape by index into `soundscapes/`. The runtime decides which one is in force and tells its host, which renders it with whatever it has. The command-line player prints a line per change and hears nothing, and that is conforming. See [Soundscapes and one-shot sounds](/explanation/soundscapes-and-sounds/). * **A node’s metadata travels with it**, under `metadata` on the node. The runtime hands it to the host on the node’s turn and never looks inside, so the meaning is yours and the host’s. A campaign carries a format number, and a runtime refuses one newer than it implements, naming both numbers rather than half-playing it. Refusing is safer than guessing: a story that plays slightly wrong is harder to diagnose than one that does not start. A saved game is unaffected. The files in a bundle and the options that shape it are listed in [Export options and bundle contents](/reference/export-options/); the reason the export is the compiler, and why the preview matches it, is in [Why the preview is the shipped story](/explanation/the-preview-is-the-shipped-story/). ## Two engine builds, and a promise you can check [Section titled “Two engine builds, and a promise you can check”](#two-engine-builds-and-a-promise-you-can-check) The engine that plays a campaign comes in two builds: one with the Python interpreter in it, and a much smaller one without. A game or player built on the smaller one can play any campaign, but a Script node stops the story where it stands and a passage shows a warning where its Python was. The cost of dropping the interpreter is therefore not that campaigns refuse to load, but that parts of them quietly cannot run. **Needs no Python** turns that into a promise checked before anybody plays. With it ticked, the export looks for every Script node and every passage with a `ripple` code block or an inline `{{ … }}` in any of its languages, and stops if it finds one. Stopping rather than stripping is the point: removing the Python for you would deliver a different story from the one you tested. The findings, their codes and the retry loop are in [How to export for a player without Python](/how-to/export-for-a-player-without-python/) and [Diagnostics](/reference/diagnostics/). If the whole project is meant for such a player, say so once instead, by turning **Python** off under *Capabilities* in the project’s settings. Every card that runs Python is then marked as an error on its board as you write, Play refuses to start while any is left, and the export’s box is ticked for you and cannot be unticked. The check moves from the end of the work to the moment of writing it. Separately, every campaign records in its manifest whether it needs Python, under `needs`. That is information, not a gate: a player without the interpreter still plays the campaign, and a host that would rather refuse one reads the flag to do it. The engine’s own bindings do: their builds without Python refuse such a campaign when it is opened, as [How the engine ships](/explanation/how-the-engine-ships/#two-variants-of-every-build) explains. See [Python in a story](/explanation/python-in-a-story/) for what the interpreter is for, and [Diagnostics](/reference/diagnostics/) for the codes. ## Why HTML is shaped by a profile in the project [Section titled “Why HTML is shaped by a profile in the project”](#why-html-is-shaped-by-a-profile-in-the-project) A campaign can have every passage written out as an HTML fragment per language, ready to drop into a page. The editor and the play view stay Markdown; HTML is for whatever reads the campaign next. The export dialog is split to say so: the controls above the line decide what *travels*, and the ones below decide what the prose *becomes*. The difficulty is that HTML only makes sense inside a page, and the page already has a stylesheet. The export cannot know your class names, and guessing a set would force you to rewrite your CSS around the tool. So an **export profile** names the class each kind of element gets: a paragraph, a heading, emphasis, a link, a table cell, a Python snippet can each carry a class of your choosing. Nothing assigned means no attribute at all, so the fragments carry no styling opinions of their own. A profile is a file in the project, `.ripple/export/.profile.json`, rather than a setting of the dialog or of the app. That placement is the argument. A build that must reproduce last month’s output needs the classes that produced it, and a file committed with the story gives it that; a preference stored on one machine would not. One profile serves as many exports as you like, so a profile is not tied to one export. ## Stripping media and links [Section titled “Stripping media and links”](#stripping-media-and-links) **Strip media** and **Strip links** work on a project copy as well as a campaign, and they answer a different question from **Include media**. Whether a file ships and whether the prose points at it are separate: stripping changes the prose and leaves what travels alone. The rules for each switch are in [Export options and bundle contents](/reference/export-options/#dialog-controls). ## Related [Section titled “Related”](#related) * [How to export a project copy or a campaign](/how-to/export-a-project-or-campaign/) * [How to export for a player without Python](/how-to/export-for-a-player-without-python/) * [Export options and bundle contents](/reference/export-options/) * [Diagnostics](/reference/diagnostics/) # Working with an AI assistant > Why Ripple Effect is an MCP server, why its tools are the app's own, and why an assistant's writes go through the same sessions as yours. Ripple Effect is an **MCP server**, so Claude Code, Claude Desktop, Cursor and anything else that speaks the protocol can work in the folder alongside you instead of being pasted things. The design rests on four decisions: the channel is private, the tools are the app’s rather than the bridge’s, a write is the same act whoever makes it, and the log records that something happened without recording what. ## Why a server at all [Section titled “Why a server at all”](#why-a-server-at-all) A project is ordinary files, so an assistant with the folder open can already read most of it. What the files cannot answer on their own is what leads to what across boards, which translations have fallen behind, what the embedded Python can actually do, and what the story does when it is played. Those answers live in the running app, and the server is how an assistant asks for them. ## A private channel, off by default [Section titled “A private channel, off by default”](#a-private-channel-off-by-default) The server is a private local channel that only your own account can open. It is never a network port. Connecting a client is a copy-and-paste, which [How to connect an AI assistant](/how-to/connect-an-mcp-client/) walks through. Access is off until you turn it on, in two steps, on the **Agents** page of the Settings dialog. **Let agents reach this app** makes the server listen: an assistant can then read the project and play it. **Let agents change files** is subordinate to the first and off independently. Both default to off. The split follows the difference in consequence. Reading is useful on its own and cannot touch a thing, so it is the first thing you can grant. Writing changes your story, so it is a separate decision, and a project can be shared with an assistant for review, questions and play-testing without ever offering it the chance to edit. ## Why the tools are the app’s, not the bridge’s [Section titled “Why the tools are the app’s, not the bridge’s”](#why-the-tools-are-the-apps-not-the-bridges) The program a client starts is a thin bridge. It does not carry a list of tools; it asks the running app what it offers the moment a client starts it, and the app answers for this build and this project. The node types that adding a node accepts are an enumeration read off the editor’s own palette, with the card names beside them. Every `locale` argument names the languages the project is written in, and `cvar` names the variables it declares. A write tool says in its description when the edit switch is off, and every description ends with a link to its own page. The reason is drift. A list written into the bridge describes the app that existed when it was written; a list read from the app describes the one you are running. The same reasoning is behind the `node_types` tool, which lists every kind of node this build has and, asked about one, describes it in full: the fields to fill in and the ports to wire. It reads the palette at the moment it is asked. The client is told the moment any of that changes: a project opened, a switch thrown, a language or a variable declared. An update to the app is therefore seen by a client that was already running, and a client with the bridge configured shows up on the Agents page as soon as agent access is on, rather than at its first call. A client whose bridge predates 1.4 needs a restart once. ## Reading: the shape of the project, on demand [Section titled “Reading: the shape of the project, on demand”](#reading-the-shape-of-the-project-on-demand) What an assistant can read is chosen to answer the questions a long story raises: the shape of the whole project in about twenty lines, then any board as a compact digest, and beyond single documents what leads to what, every route between two passages, and what is stale or wrong. [MCP tools](/reference/mcp-tools/) lists them. This is the same argument as [Reading a large story back](/explanation/reading-a-story-back/): a project too large to hold in view is better read as digests and answers than as every file, and an assistant is in the same position as you are. A node’s metadata reaches it too, as a `meta` line under the node in a board read; adding or changing a node takes a `metadata` object that replaces what the node had. ## Playing: the shipped story, not a simulation [Section titled “Playing: the shipped story, not a simulation”](#playing-the-shipped-story-not-a-simulation) An assistant plays with the same runner the Play button uses, with the same Python and the same component values, for the reasons given in [Why the preview is the shipped story](/explanation/the-preview-is-the-shipped-story/). It starts a campaign, makes choices and reads the state. Because it is a program rather than a person, it can also walk **every** branch and report what nobody has time to sit through: which endings are reachable, which passages nothing leads to, where a path runs out of wire, and which jumps are broken. A playthrough writes nothing, and it plays the boards as they are on disk rather than what is half-edited in a tab, so a walk reports on what would be saved and exported. It reads the same trace the play window shows, line by line with the board and node each line came from, and continues from where it last read. An Input node reaches it as a slide of its own listing the fields it asks for, and it fills the form in with a value per name; an unattended walk goes through one with every field blank. ## Writing: through the sessions, once you allow it [Section titled “Writing: through the sessions, once you allow it”](#writing-through-the-sessions-once-you-allow-it) With the second switch on, an assistant can write: passages and their translations, nodes and wires, components, scripts, soundscapes, and the files themselves. [MCP tools](/reference/mcp-tools/) lists every one. The important property is that none of these is a separate path to the disk. Every write goes through the app’s own sessions, the same ones your typing goes through. That has three consequences you would otherwise have to build separately. Adding a passage is a single undo. Autosave sees it, so it is not lost or overwritten. Version control lists it, so it appears in the changes like anything else. A second route that wrote files directly would need its own answer to each of those, and the app would have two ways of being wrong. Everything changed this session is listed on the Agents page, and every change is posted to Messages as it happens. ### What a write is refused [Section titled “What a write is refused”](#what-a-write-is-refused) A refusal is either a decision that belongs to you or a guard that keeps the project consistent. A document with two versions pending is a conflict, and picking a side would make your decision for you; the registry and catalogue ids are never written directly, so a rename cannot leave a Jump pointing at nothing. Deleting a file cannot be undone, so it is guarded rather than prompted: nothing outside the project, nothing in `.ripple/` and nothing with unsaved edits is deleted. [What an assistant may read, play and write](/reference/mcp-permissions/) is the reference for the full list. ## Knowing who is connected [Section titled “Knowing who is connected”](#knowing-who-is-connected) The app bar keeps only what has news, and the agents mark is one of those things. While a client is connected its mark appears there (Claude’s, OpenAI’s, Gemini’s, or a plain robot for everything else), and pressing it opens the Agents page. If the local connection could not be opened, a plug in the error colour takes its place, with *Agents cannot reach this app* on the tooltip. With the switch off, or on and nobody there, the bar shows nothing. ## What the app writes down, and what it does not [Section titled “What the app writes down, and what it does not”](#what-the-app-writes-down-and-what-it-does-not) Every tool call, resource read and prompt a client makes leaves a line in the session log: which client, which tool, which argument *names* it passed, how long it took and whether it worked. Argument contents are never recorded. A path would be redacted, but a passage you wrote would not be, and a denylist of what to scrub is right only until the day it misses a case. The log can say that an assistant wrote to a board but not what it wrote; every change is also posted to Messages. See [Updates, logs and Messages](/explanation/updates-logs-and-privacy/). ## Prompts and the documentation [Section titled “Prompts and the documentation”](#prompts-and-the-documentation) Two prompts, for writing a passage and for editing a board, can be handed to an assistant with the passage or board already in them. They exist because they carry the two things an assistant gets wrong first: that Python in prose is monty rather than CPython, so an attribute is `component("X")["attr"]`, and that a node id is numbered across a whole board rather than per type. This site also publishes its documentation as plain text for assistants; [MCP tools](/reference/mcp-tools/) names the files. ## Related [Section titled “Related”](#related) * [How to connect an AI assistant (MCP client)](/how-to/connect-an-mcp-client/): the copy-and-paste that connects a client. * [MCP tools](/reference/mcp-tools/): every tool and argument. * [What an assistant may read, play and write](/reference/mcp-permissions/): the permissions in full. * [How to play and test a campaign](/how-to/play-a-campaign/): the runner an assistant shares with you. # How to add a state machine to a board > Create a state machine file, put it on a board as a State machine node, and branch the story on the state it answers. Use a state machine for a door that is locked, unlocked or open, or a quest that is offered, taken or done. ## Create the machine [Section titled “Create the machine”](#create-the-machine) 1. In the file tree, right-click and choose **New state machine**. The file is `*.fsm.mmd` and holds a mermaid `stateDiagram-v2`. 2. Write the machine in the editor. Every arrow is `From --> To : event`, and `[*] --> S` names the start: ```text stateDiagram-v2 [*] --> Locked Locked --> Unlocked : key Unlocked --> Open : push Open --> Unlocked : pull Unlocked --> Locked : lock ``` 3. Fix anything the editor reports against its line. [State machine files](/reference/state-machine-file/) lists what the editor warns about. ## Put it on a board [Section titled “Put it on a board”](#put-it-on-a-board) 1. Drag the `.fsm.mmd` file from the file tree onto the board. It becomes a **State machine** node pointing at the file. Alternatively, add a State machine node and pick the machine from the list on its card. 2. Wire or type the **Event** the node receives. Leave it blank to read the state without moving the machine. Use `[*]` as the event to reset the machine to its start. 3. If two nodes in the story must drive separate copies of one machine, give each its own **Instance** name. Give a single quest none: left blank, the instance is the machine’s own name, so every node without one drives the same machine. ## Branch on the state [Section titled “Branch on the state”](#branch-on-the-state) 1. Add a **Switch** node and wire the State machine node’s **state** output into it. 2. In the Switch’s rows, type one state name per row. Each row has its own output, taken when the state matches that name. 3. Wire the State machine node’s flow out to the rest of the board. It leaves by **changed** when an arrow fired or the machine was reset, and by **unchanged** otherwise. An event the current state has no arrow for is not an error. The machine stays put and the flow takes **unchanged**, so decide downstream what to do about it. ## What is kept [Section titled “What is kept”](#what-is-kept) A machine remembers its state for the whole playthrough, across boards and saves. Starting over resets it. ## Related [Section titled “Related”](#related) * [State machines](/explanation/state-machines/): why a state machine is a drawing and how it differs from a console variable. * [State machine files](/reference/state-machine-file/): the mermaid syntax the app reads and what it refuses. * [Nodes](/reference/nodes/): the State machine and Switch nodes. * [How to play and test a campaign](/how-to/play-a-campaign/): check the machine moves as you intend. # How to add sound to a story > Bring audio into a project, lay it out as a soundscape, name it on a Narration node, and cue one-off sounds. ## Bring audio into the project [Section titled “Bring audio into the project”](#bring-audio-into-the-project) Drop an `.ogg`, `.mp3`, `.wav`, `.flac`, `.m4a`, `.aac` or `.opus` anywhere in the folder and it appears in every picker. Or use *Import…* for a file from outside; it is copied to `.ripple/external/`. An audio file opens in a tab of its own with play, pause and a scrub bar, so you can check it before you use it. ## Make a soundscape and lay out clips [Section titled “Make a soundscape and lay out clips”](#make-a-soundscape-and-lay-out-clips) 1. Choose **+** in the file tree and make a new soundscape, a `*.soundscape.json` file. Open it in a tab. 2. Add a clip with *Add clip*, or double-click empty canvas. Both offer the project’s audio, searchable by name or by folder, each row showing where the file is. Choose *Import a file…* if the audio is not in the project yet. You can also drag a sound from the file tree onto the timeline and let go where you want it. 3. Drag a block to move it. Pull either edge to trim it. The shape of the sound is drawn inside the block, showing the part of the file it plays, so you can trim to a beat by eye. The shape appears a moment after the clip, because the file is decoded to draw it. 4. Select a clip to edit it in the panel on the right. Set **Playback** to *Once*, *Loop* or *Auto-loop*. Use *Auto-loop* for music. Turn on **Rest** on an auto-loop clip to leave a silence between plays, for a bell every so often. Leave it off for a gapless bed. 5. Shape the clip’s ends. Drag the grip at the top of a fade slope inward to lengthen the ramp, or back into the corner to remove it. On an auto-loop clip the fades apply at every repeat, so a fade can hide the seam of a file that does not loop cleanly. For a ramp measured to the millisecond, type a length such as `0.25 s` in **Fade in** or **Fade out**. A clock typed there is also understood. 6. Open the gear in the toolbar to set the timeline’s own properties: **Length**, **Loop timeline**, **Fade in**, **Fade out**, **Volume** and **Tied to the passage**. Every field a clip and a soundscape carries is in [Soundscape files](/reference/soundscape-file/). A clip names its audio by catalog id, so renaming or moving a file does not break it. ### Name and lock lanes [Section titled “Name and lock lanes”](#name-and-lock-lanes) * Double-click a lane header to name it, for example *Bed* or *Weather*. * Right-click a lane header to name it or to lock it. A locked lane takes no clip, so a bed laid out once stays put while you move everything else around it. * Right-click anywhere else for what the pointer is on: duplicate or delete a clip, add one here, select everything on a lane, fit the pane, set the row height, undo and redo. ### Preview the soundscape [Section titled “Preview the soundscape”](#preview-the-soundscape) Press Space to play and pause, and Shift+Space to stop. The ruler, or any empty canvas, is the scrub bar. The preview plays through the same mixer the campaign uses, so a gain slider is heard live and a dragged clip does not restart the bed under it. To audition without the whole mix, use the **M** and **S** on a lane header, or silence a single clip from its panel. Silenced clips are drawn faint. None of this is saved and none of it reaches the story: a soundscape plays every clip it holds. The whole file is one undo history, the toolbar’s. An edge you trim on the timeline and a volume typed in the panel are the same history. Note A clip past the timeline’s length is allowed and shaded. What lies past the length is cut at play time rather than refused in the editor. ## Play a soundscape from a passage [Section titled “Play a soundscape from a passage”](#play-a-soundscape-from-a-passage) 1. On the **Narration** node, find the soundscape row under the choices. 2. Pick the soundscape. It plays from that passage on, through every passage after it that brings none of its own. 3. To change the music, pick another soundscape on a later passage. Two soundscapes never overlap. Naming the same one again does not start it over. 4. To stop everything outright, pick *Silence — stops what is playing* on a later passage. A passage with *None* changes nothing. 5. To make a soundscape belong to one passage, turn on **Tied to the passage** in its properties. A tied soundscape stops when the story moves on. A saved game remembers what was playing. A load plays the soundscape before the page shows, and a tied soundscape comes back only at its own passage. ## Cue a sound once [Section titled “Cue a sound once”](#cue-a-sound-once) Use a one-off sound when the story should carry straight on. `completed` fires the moment the sound is cued, so nothing waits for it. * **From a node.** Add a **Play sound** node and pick one file, a volume and a speed. See [Nodes](/reference/nodes/#play-sound). * **From Python.** In a Script node, call `play_sound("sfx/bell.ogg")`, with the path as the project spells it or as the bundle spells it. Pass `volume` and `speed` by name. See [Sounds](/reference/python/sounds/). A passage’s snippets cannot cue a sound. To sound a stinger when a passage is reached, put a **Play sound** node before the passage, or call `play_sound` from a Script node before it. ## Hear it in the campaign [Section titled “Hear it in the campaign”](#hear-it-in-the-campaign) The campaign window’s header has a **mute** button. It is the reader’s, it belongs to the campaign, and it is remembered between sessions. Previewing a soundscape in its own tab is never silenced by it. A soundscape’s volume is the file’s, and every clip’s volume is relative to it, so there is no second master to keep in step. ## Related [Section titled “Related”](#related) * [Soundscapes and one-shot sounds](/explanation/soundscapes-and-sounds/) for how the runtime decides what is playing. * [Soundscape files](/reference/soundscape-file/) for every clip and timeline property, and how the file names its audio. * [Sounds](/reference/python/sounds/) for `play_sound` in Python. * [Nodes](/reference/nodes/#play-sound) for the Play sound node. * [How to play and test a campaign](/how-to/play-a-campaign/) to hear the result. # How to arrange the workspace > Split, tab and resize documents, fold the side bar, show or hide Messages, enter Zen Mode and bring the tips back. ## Put two documents side by side [Section titled “Put two documents side by side”](#put-two-documents-side-by-side) * To put a board beside the passage it links to, drag its tab out of the strip and drop it beside the other document. * To tab a document into a group instead, drop it on that group’s strip. * To resize, drag a divider. Your layout is remembered per project in `.ripple/workspace.json`. ## Give the documents the whole width [Section titled “Give the documents the whole width”](#give-the-documents-the-whole-width) ![The project as a folder: the file tree beside an open passage](/_astro/files-light.p9mrFCWm_Z2icsSu.webp) ![The project as a folder: the file tree beside an open passage](/_astro/files-dark.l_awLRhE_1dveJP.webp) Click the activity bar icon of the section already showing — **Files**, **Changes** or **Search** — to fold the side bar away. Click the icon of any section to pick what the side bar shows again. ## Close and reopen Messages [Section titled “Close and reopen Messages”](#close-and-reopen-messages) * Close Messages with its cross. * Bring it back, under the documents, from the Messages button at the bottom of the activity bar, which carries the count, from the **View** menu, or with *Toggle messages* in the command palette (`Ctrl+Shift+P`). To change how Messages reads: * To see everything broken first, switch the grouping from what a message is about to how loud it is. * To fold a group, click the count on its heading. * To hide a kind of message, turn off its chip: problems, cautions or records. The grouping and the chips are remembered. * To silence a deliberate choice the check cannot judge, tick *I meant that* on its row. It stays said. * To read about a message from the engine, click its code to open its page in [Diagnostics](/reference/diagnostics/). To find the cards a message names, click the message. ## Give the window to one document [Section titled “Give the window to one document”](#give-the-window-to-one-document) Press `F11` (`Ctrl+Cmd+F` on a Mac), or choose *View ▸ Zen Mode*. The app bar, side bar, Messages and tabs go. The same keys bring them back. * A banner at the top names the document on the way in and whenever it changes. When the pointer reaches the top edge it carries an *Exit Zen Mode* button. * `Ctrl+Tab` and `Ctrl+Shift+Tab` move between documents — every open one, whichever group it is in. Outside Zen Mode the same keys move along the tabs of the group you are in. ## Bring the tips back [Section titled “Bring the tips back”](#bring-the-tips-back) Each tip appears once, the first time you open its kind of thing. To see one again, run *Show the tip for…* from the command palette. To re-arm all of them, choose *Help ▸ Troubleshooting ▸ Show every tip again*. ## Related [Section titled “Related”](#related) * [The workspace window, menus and palette](/reference/workspace-window/) — what each part of the window holds. * [Project files and the .ripple folder](/reference/project-files/) — what `workspace.json` records. * [A project is a folder](/explanation/projects-are-folders/) — why the layout is remembered per project and per machine. * [Updates, logs and Messages](/explanation/updates-logs-and-privacy/) — what lands in Messages and why. # How to change the app's language, contrast and colour vision > Switch the interface language, raise the contrast and recolour the app for a kind of colour blindness from Settings. *Language*, *Contrast* and *Colour vision* are settings of the app, not of a project, so they follow you from project to project. Each is a choice among a few named options and takes effect when you press it; there is no Apply. Note Contrast and colour vision are also on the first page of a first launch, beside a small preview that changes as you choose. ## Open the settings [Section titled “Open the settings”](#open-the-settings) 1. Press the gear in the app bar. The Settings dialog opens. 2. On the *General* page, find the row you want: *Language*, *Contrast* or *Colour vision*. [Settings, accessibility and updates controls](/reference/settings/) shows where each row sits. ## Change the language [Section titled “Change the language”](#change-the-language) 1. In *Language*, press the language you want. Each language is listed under its own name, so you can find yours even when the app is showing one you cannot read. 2. To follow the operating system instead, press *System*, which the app shows followed by the language it currently resolves to, in brackets. Every menu, dialog, message and tip changes. A project’s own text and languages are untouched; to change those, see [How to add, switch off or change a project’s languages](/how-to/manage-languages/). ## Raise the contrast [Section titled “Raise the contrast”](#raise-the-contrast) In *Contrast*, press *More*. Press *Standard* to go back. Text becomes firmer and controls gain edges you can find; the colours and layout stay the same. [Settings](/reference/settings/#contrast) has the full list. Contrast is separate from the theme, so you can have dark and firmer at once. ## Recolour the app for a kind of colour blindness [Section titled “Recolour the app for a kind of colour blindness”](#recolour-the-app-for-a-kind-of-colour-blindness) In *Colour vision*, press the mode that matches yours: * *Protan*: protanopia and protanomaly. * *Deutan*: deuteranopia and deuteranomaly. * *Tritan*: tritanopia and tritanomaly. Press *Off* to return to the house colours. The app’s category colours change to a palette you can tell apart; [Settings](/reference/settings/#colour-vision) lists what each mode recolours. A few places still tell categories apart by hue alone; see [What the accessibility settings do and do not claim](/explanation/accessibility-limits/). ## Change the text size [Section titled “Change the text size”](#change-the-text-size) Text size is set in your operating system, not in the app. For why the cards on a board do not follow it, see [Text size: the chrome follows, the cards do not](/explanation/accessibility-limits/#text-size-the-chrome-follows-the-cards-do-not). ## Related [Section titled “Related”](#related) * [Settings, accessibility and updates controls](/reference/settings/): every option on the dialog. * [What the accessibility settings do and do not claim](/explanation/accessibility-limits/): the limits of the colour-vision palettes. * [How to add, switch off or change a project’s languages](/how-to/manage-languages/): the languages of the project’s own text. # How to check spelling and grammar > Read the underlines in a passage or a note, take a suggestion, teach the project's dictionary a word, edit the dictionary, and switch checking off. Passages in an English language and Markdown notes are checked as you type, by [Harper](https://writewithharper.com/). A passage in another language is not checked, and neither is the Python in a passage. The checking happens on your machine: nothing you write is sent anywhere to be checked. The colour of an underline says what it found: red for spelling, amber for grammar, blue for style. ## Take a suggestion [Section titled “Take a suggestion”](#take-a-suggestion) 1. Right-click the underlined words, or put the caret in them and press **Ctrl+.** (**⌘.** on a Mac). 2. Pick a suggestion. It replaces the underlined words. The line at the top of the menu is the checker’s explanation, in English as the checker wrote it. **Ignore** stops underlining that one until the project is closed. ## Teach the project a word [Section titled “Teach the project a word”](#teach-the-project-a-word) On a spelling underline, choose **Add to dictionary** in the same menu. The word stops being underlined everywhere in the project. Names the project already has are words without being added: its files’ names, its console variables, its host callbacks and its state machines’ states. ## See or change the dictionary [Section titled “See or change the dictionary”](#see-or-change-the-dictionary) 1. Open Project settings from the bottom of the activity bar and pick *Writing*. 2. Under *Dictionary*, type a word in *Add a word* and press Enter or choose **Add**. One word at a time: a phrase with a space is refused. 3. Use *Find a word* to search the list, and the bin beside a word to take it out. 4. Save. Cancel puts every word back. The dictionary is saved with the project, so everyone who opens it shares it. A dictionary saved by a newer version of the app is not shown or changed: update the app to edit it. ## Switch checking off [Section titled “Switch checking off”](#switch-checking-off) 1. Press the gear in the app bar. The Settings dialog opens. 2. On the *General* page, set *Writing* to *Off*. The command palette’s **Toggle spelling and grammar** does the same. The words in a project’s dictionary are kept. ## Related [Section titled “Related”](#related) * [How to write a style rule](/how-to/write-a-style-rule/) * [Settings, accessibility and updates controls](/reference/settings/) * [Project files](/reference/project-files/) # How to connect an AI assistant (MCP client) > Switch agent access on, copy the configuration and paste it into Claude Code, Claude Desktop, Cursor or another MCP client. Use this to point an MCP client — Claude Code, Claude Desktop, Cursor or anything else that runs local servers — at the project you have open in Ripple Effect. 1. **Switch agent access on.** Open the gear in the app bar, which is on every screen, and pick the **Agents** page; the command palette’s Agents command opens the same page. Switch on **Let agents reach this app**. An assistant can now read the project and play it, but change nothing. Leave **Let agents change files** off until you want an assistant to change files. It only does anything while the first switch is on, and reading is useful on its own. With it off, a tool that writes answers `refused`. Both switches are for the app rather than for one project: the app serves whichever project is open. 2. **Copy the configuration.** Press **Copy client configuration** on the page. The clipboard receives the block most clients read, already pointing at the right place on your machine: ```json { "mcpServers": { "ripple_effect": { "command": "/opt/ripple_effect/ripple_effect_mcp", "args": [] } } } ``` The path is a small program installed beside the app that the client starts and that talks to the app for it. Copy it from the page rather than typing it; the app installs to a different place on each platform. 3. **Paste it where your client reads it.** * Claude Code One command, with the path from the page: ```sh claude mcp add ripple_effect -- /opt/ripple_effect/ripple_effect_mcp ``` Quote the path on Windows, where it contains a space. `claude mcp list` shows it registered; `/mcp` inside a session shows whether it connected. * Claude Desktop Settings → Developer → **Edit Config** opens `claude_desktop_config.json`; paste the block in. If the file already has an `mcpServers` object, add the `ripple_effect` entry inside it rather than pasting a second object. Restart Claude Desktop and the tools appear. Claude Desktop has no Linux build. * Cursor Paste the block into `.cursor/mcp.json` in the project you are working from, or into `~/.cursor/mcp.json` for every project. Cursor lists it under Settings → MCP, where it can be switched on and off. * Anything else Every MCP client that runs local servers takes a command and an argument list. The command is the path in the block and the argument list is empty. Most read the `mcpServers` key exactly as above; check your client’s own documentation for which file it wants. 4. **Ask it something.** A client with the bridge configured appears in the page’s **Connected** list as soon as access is on; it does not wait for its first request. The app bar shows nothing for agents until there is news. While a client is connected its mark appears there, with its name and version on the tooltip. If the app could not open its local connection, the mark appears in the error colour instead. Either one opens the Agents page. A good first request is `project_outline` — the shape of the whole story in about twenty lines — or ask *what is this project about?* and let the assistant find that tool itself. ## If it says the app is not running [Section titled “If it says the app is not running”](#if-it-says-the-app-is-not-running) While the app is closed or agent access is off, every tool call answers: ```text ripple_effect is not running, or agent access is switched off. Open the project in ripple_effect and switch on "Let agents reach this app" under Settings → Agents. ``` Open the project and switch access on in the Agents page of Settings. The client does not need restarting. The assistant sees the full tool list either way, so it can tell you what it would be able to do before the app is up. ## Find the program by hand [Section titled “Find the program by hand”](#find-the-program-by-hand) If you need the path without opening the app, it is: | Platform | Path | | ------------- | ------------------------------------------------------------------ | | Linux, `.deb` | `/opt/ripple_effect/ripple_effect_mcp` | | Linux, `.rpm` | `/usr/share/ripple-effect/ripple_effect_mcp` | | Windows | `%LOCALAPPDATA%\Programs\Ripple Effect\ripple_effect_mcp.exe` | | macOS | `/Applications/Ripple Effect.app/Contents/MacOS/ripple_effect_mcp` | It is deliberately not on your `PATH`: the app installs to three different places on three platforms, and no one command line is true on all of them. The connection between it and the app is a private local channel that only your own account can open, never a network port. A build run from source has no such program beside it; `melos run editor:mcp:bridge` makes one and prints where it put it. ## Related [Section titled “Related”](#related) * [Working with an AI assistant](/explanation/working-with-an-assistant/) — what agent access buys you and why it is off by default. * [What an assistant may read, play and write](/reference/mcp-permissions/) — what each switch grants. * [MCP tools](/reference/mcp-tools/) — every tool, with what it takes. # How to create or import a project > Make a new project, copy an example project, or open a folder you already have from the home screen. The home screen is a grid of the projects the app knows about. It has two buttons: **New project** and **Import a folder**. ## Create a blank project [Section titled “Create a blank project”](#create-a-blank-project) 1. On the home screen, choose **New project**. 2. Leave the dialog’s left rail on **Blank** (*An empty folder*). 3. Enter a **Title**. Add a **Description** and a **Cover** if you want them; a colour is generated for the cover otherwise. 4. Choose where the new folder goes. 5. Under **Written in**, check the language. The starting point comes from your computer’s settings, but the choice is yours. Your passages start in this language; the ones you translate into are chosen later, in project settings. 6. Choose **Create**. The app creates the folder and one file in it, `.ripple/project.json`, and nothing else. If a folder with that name already exists at the location you chose, the dialog refuses and tells you to use **Import a folder** instead. ## Start from an example project [Section titled “Start from an example project”](#start-from-an-example-project) 1. Choose **New project**. 2. In the dialog’s left rail, choose **Templates** (*A copy of an example*). 3. Pick an example. This build carries *The City Of Elsenmar*. 4. Choose where the copy goes, then choose **Create**. The copy is yours to change, and you can make another later. ## Import a folder you already have [Section titled “Import a folder you already have”](#import-a-folder-you-already-have) 1. Choose **Import a folder**. 2. Pick the directory. The **Import a project** dialog opens. 3. Check what the dialog shows, then choose **Import**. * If the folder was a project before, check the prefilled fields and choose **Import**. Its title, description, cover and language arrive already filled in, and nothing is written into it. To change any of them, open the project and use project settings. * If it was not, name it and choose **Import**. The language is guessed from your computer and written down, because nothing in the folder says yet. The only thing written into it is a small `.ripple/` folder holding the project’s settings; your files are shown as they are. * If the cover the folder names is missing, a colour is generated instead. * If the folder’s settings were written by a newer version of the app, they are left alone rather than read and written back over. The folder is still imported, and opening it says the same thing. Update the app to read them. ## When the folder is already a project [Section titled “When the folder is already a project”](#when-the-folder-is-already-a-project) If the folder you choose, for a new project or an import, is already used by another project on the home screen, the app says so and does not add a second. See [A project is a folder](/explanation/projects-are-folders/) for why. A project that another program has moved or deleted disappears from the grid; there is nothing else to keep in step. ## Related [Section titled “Related”](#related) * [A project is a folder](/explanation/projects-are-folders/) for why the app writes so little into your folder. * [Project files and the .ripple folder](/reference/project-files/) for what `.ripple/project.json` holds. * [The workspace window, menus and palette](/reference/workspace-window/) for the dialog’s fields. * [How to add, switch off or change a project’s languages](/how-to/manage-languages/) to add or change languages after the project exists. # How to declare, aim and forget a host callback > Name a trigger for a game that embeds the story, choose its event, aim it, forget it, and watch it fire in the play window. You declare the trigger here; the programmer who embeds the story writes the handler and registers it under the same name. Nothing you type in the pane changes what the story does. A name is a published API The name is the only thing joining your declaration to their code. Renaming it breaks their build, and nothing in the editor can warn them. Agree the names early and treat a rename like renaming a function somebody calls. ## Declare a callback [Section titled “Declare a callback”](#declare-a-callback) 1. Open **Host callbacks** from the activity bar, or from the command palette. 2. Type a name in the name field and add it. Write it like an identifier, because a programmer will type it: `chapterTwoBegan`, `achievementHit`, `guardWentAlert`. It must be unique in the project and cannot hold a line break or a control character. 3. Select the callback in the list, and pick its **Event** (the picker has a search field, **Find an event**). The pane offers only the fields that event can use. The programmer writes the event exactly as the picker spells it. The full list, with when each fires, is under [Events](/reference/host-callbacks/#events) in the reference. ## Aim a callback [Section titled “Aim a callback”](#aim-a-callback) Left alone, a callback fires at every occasion of its event. To narrow it, use the fields under **What it watches**. * **Board**, then optionally **Node** on it. `passage.shown` can then mean one passage rather than every passage. The node picker reads **Pick a board first** until a board is chosen. * **Console variable**. `cvar.changed` cannot do without it. * **Component**, for the entity events, and **Attribute** for `entity.field.changed`. The attribute is required and the component is not: with no component picked, `hp` watches anything the story made that has an `hp`. * **State machine**, which `machine.state.entered` cannot do without, and optionally the **State** to wait for. ### Fire only when a variable reaches a value [Section titled “Fire only when a variable reaches a value”](#fire-only-when-a-variable-reaches-a-value) On a `cvar.changed` callback, tick **Only when it becomes a particular value** and enter the **Value**. The field takes the variable’s own type, so you cannot enter a value it could never hold. The condition fires when the variable becomes that value, not while it holds it. ### Watch individual cards [Section titled “Watch individual cards”](#watch-individual-cards) To be told each time the story runs through a card, aim `node.entered`, `node.exited` or `node.failed` at a board and a card. A card inside a macro cannot be picked, because a macro is spliced into the board that calls it. Aim at the card that calls the macro, which is what the picker offers. ## Forget a callback [Section titled “Forget a callback”](#forget-a-callback) 1. Select the callback and choose **Forget it**. 2. Confirm in the dialog, or choose **Keep it** to cancel. A game listening for that name never hears it again, and nothing in the project says why. ## See callbacks fire [Section titled “See callbacks fire”](#see-callbacks-fire) Open the **Host callbacks** side panel in the play window. Each row lists the name, the event, the payload fields that are set, and what became of the lookup. The editor publishes the names but has no game to react to them, so normally nothing is registered and each row ends with “nothing registered”. That is not an error: the story plays as it would have. “The handler failed” marks a registered handler that threw; the story plays on. ## Fix a callback aimed at something that is gone [Section titled “Fix a callback aimed at something that is gone”](#fix-a-callback-aimed-at-something-that-is-gone) A callback aimed at a board, variable or component the project no longer has is left out of the compiled campaign with a warning, and the field in the pane says so. The declaration stays in the file, so restoring the board from version control brings the callback back. Aim it at something that exists, or forget it. A node is the exception. The build does not check that a node id is still on its board, so a callback naming a missing node travels intact and never fires. The pane says so while the board is open, and the board carries the warning `W.GE.16`. Aim it at a node that is there, or clear the node to watch the whole board. If the pane is read-only and a banner says the callbacks were written by a newer version, update the app to edit them. Nothing is written to the file meanwhile. ## Related [Section titled “Related”](#related) * [Host callbacks](/explanation/host-callbacks/): why the trigger and the handler are written on opposite sides. * [Host callbacks reference](/reference/host-callbacks/): the events, their payloads and `.ripple/callbacks.json`. * [How to play and test a campaign](/how-to/play-a-campaign/): the play window the callback panel sits in. * [Diagnostics](/reference/diagnostics/): `W.GE.16`. # How to declare and scope a console variable > Add a typed, named variable to the project, limit the boards that offer it, and re-save older games. Declare a console variable when a value has to be read or written from more than one board, or from Python, with no wire between them. ## Declare a variable [Section titled “Declare a variable”](#declare-a-variable) 1. Open the **Console variables** page from the activity bar’s project buttons or from the command palette. 2. In the **Add a variable** row, give it a name. The greyed example is `gate_open`; lower case with underscores suits a script that names it. 3. Pick its type and set its default. The twenty-four types are listed in [Console variable declarations](/reference/console-variable-declarations/). 4. For `asset` and `componentRef`, on their own, in a list or in a map, type a few letters of a name and pick from the project’s files; the full path shows beside each. What you pick sits as a chip, and hovering shows the path, which is what the variable stores. A chip for a file that is outside the project or no longer there is marked. The declaration appears under **Declared**. You can rename it at any time: a node stores the variable’s id, so every board that uses it follows. A script that names the old name does not, and is refused when the campaign plays. To remove one, use **Forget this variable** and confirm. Every node that names it then says the variable is gone, a script that names it is refused at play time, and there is no undo. ## Limit a variable to some boards [Section titled “Limit a variable to some boards”](#limit-a-variable-to-some-boards) 1. On the variable’s row, open the **Boards** column. It is a searchable menu of the project’s boards with a box beside each, and reads **Everywhere** until you tick some. 2. Tick the boards that should offer the variable. A Get variable, Set variable or Variable branch node on a board then offers the variables scoped to that board or to none. A node already pointed at a variable scoped elsewhere keeps its name and shows **Not scoped to this board** under it. If a board a scope names is deleted, the row says how many are gone. A scope only filters what a node offers; a script can still name the variable from anywhere. Caution Ticking a board moves `.ripple/cvars.json` to version 2, which an earlier build refuses rather than reading and writing back without the scopes. A project that never ticks one still moves, because the version belongs to the file and not to the variable. ## Use the variable on a board [Section titled “Use the variable on a board”](#use-the-variable-on-a-board) Add a Get variable, Set variable or Variable branch node and pick the variable by name on the card. The three nodes’ ports and behaviour are in [Nodes](/reference/nodes/). A value that does not fit the declared type is refused as you type it on a card. A value arriving on a wire that does not fit ends the run with a message naming the node. The rule is in [Console variables](/reference/python/console-variables/#coercion). ## Change a value while testing [Section titled “Change a value while testing”](#change-a-value-while-testing) In the campaign window, open the panel beside the entities and the console. It shows what every variable holds as the story runs, and lets you change one by hand. **Start over** is then offered, as it is after deleting an entity. ## Re-save older games [Section titled “Re-save older games”](#re-save-older-games) A save made before 1.5 has to be replaced: the campaign has to be recompiled, and a save is now format 5. 1. Recompile the campaign. 2. Make a fresh save of any game started before the change. What a save holds is in [The campaign window and saved games](/reference/campaign-window/). ## Have an assistant point a node at a variable [Section titled “Have an assistant point a node at a variable”](#have-an-assistant-point-a-node-at-a-variable) To have an assistant do this, see [How to connect an AI assistant (MCP client)](/how-to/connect-an-mcp-client/). ## Related [Section titled “Related”](#related) * [Console variables](/explanation/console-variables/): what a variable is and why a scope only filters the offer. * [Console variable declarations](/reference/console-variable-declarations/): the types, the file and its versions. * [Console variables from Python](/reference/python/console-variables/): the functions and the coercion table. * [The campaign window and saved games](/reference/campaign-window/): what a save holds. # How to export a project copy or a campaign > Choose between a project copy and a compiled campaign, set Include media, strip media and links, and write passages as HTML against a profile. ## Open the export dialog [Section titled “Open the export dialog”](#open-the-export-dialog) Choose *Export project* in the project panel, or *Export project…* from the command palette. The dialog’s format switch asks for one of two things: * **Project copy**, a folder that opens as a project again. Use it to hand a story to a translator or to keep a copy. It includes `.ripple/`. * **Campaign**, a compiled campaign ready to be played by the runtime. Nothing in it opens in the editor. Use it for a game or player. Warnings, such as media left out, appear in Messages. A picture the prose points at from outside the project lands in `media/external/` in a project copy. ## Export a project copy [Section titled “Export a project copy”](#export-a-project-copy) 1. Choose **Project copy**. 2. Under **Include media**, switch on the kinds that should travel: **Images**, **Videos**, **Audio**. Leave a kind off to send a translator the story and keep the score. With a kind off, its files are listed and left behind. The passages, boards and soundscapes still point where the files would have been, so you can put them back later. 3. Switch on **Zip files** to write one archive instead of a folder. 4. Optionally strip media or links from the prose. See [Strip media and links](#strip-media-and-links). 5. Choose **Export**, then pick the destination in the system dialog (*Export here*, or *Zip archive* when zipping). ## Export a campaign [Section titled “Export a campaign”](#export-a-campaign) 1. Choose **Campaign**. 2. Set **Include media** and **Zip files** as for a project copy. 3. If a game or player built without the Python interpreter will play it, tick **Needs no Python** after following [How to export for a player without Python](/how-to/export-for-a-player-without-python/). 4. Under **Output passages as**, keep Markdown, or choose HTML (see below). 5. Choose **Export**, then pick the destination. Media left out of a campaign is named in Messages. What a campaign contains, folder by folder, is in [Export options and bundle contents](/reference/export-options/). ## Write passages as HTML [Section titled “Write passages as HTML”](#write-passages-as-html) Campaign only. The controls above the line in the dialog decide what travels; the ones below decide what the prose becomes. The editor and the play view stay Markdown. 1. Beside **Output passages as**, choose HTML. Every passage is written out as an HTML fragment per language, ready to drop into a page. 2. Open the **Profile** drop-down. With no profile every element goes out bare, a plain `

` or `` (*None — plain elements*). Choose *Find a profile* to pick an existing one, or *New profile* to make one. 3. In the profile, give each kind of element a class. The kinds are listed on the left (*In the passage*), with the Markdown each stands for on a tooltip, and a **CSS class** field sits on the right. A paragraph, a heading, emphasis, a link, a table cell and a Python snippet can each carry a class. Leave a field empty for no attribute at all. 4. Choose **Export**, then pick the destination. The profile is saved as you type, to `.ripple/export/.profile.json`. A name is a file name: letters, digits, spaces and dashes. Commit the file with the story so a build reproduces it. One profile serves as many exports as you like. *Delete profile* removes only that file. ## Strip media and links [Section titled “Strip media and links”](#strip-media-and-links) Two switches under **The passages**; both work on a project copy as well as a campaign. * **Strip media** takes embedded pictures, videos and audio out of the prose, for a reader that cannot show them. It has one child switch per kind. * **Strip links** unwraps links to documents and the web to their words. A link to a picture is a picture, not a link, and answers to **Strip media**. Neither switch changes what travels beside the passages; that is still **Include media**. A strip for a kind that is not in the export is disabled, since there is nothing to strip, and returns as it was when the kind does. ## Related [Section titled “Related”](#related) * [What an export is](/explanation/what-an-export-is/): why a campaign is compiled rather than copied. * [Export options and bundle contents](/reference/export-options/): every switch and what a bundle holds. * [How to export for a player without Python](/how-to/export-for-a-player-without-python/) * [Diagnostics](/reference/diagnostics/): the export’s codes in Messages. # How to export for a player without Python > Export a campaign that a game or player built without the Python interpreter can play, and fix what stops the export. Use this when the game or player that will run the campaign is built without the Python interpreter. [What an export is](/explanation/what-an-export-is/) explains the two engine builds. ## Export with Needs no Python ticked [Section titled “Export with Needs no Python ticked”](#export-with-needs-no-python-ticked) 1. Open the Export dialog as described in [How to export a project copy or a campaign](/how-to/export-a-project-or-campaign/). 2. Under *Campaign*, tick **Needs no Python**. 3. Export. The export looks for every Script node, and for every passage with a `ripple` code block or an inline `{{ … }}` in any of its languages, and stops if it finds one. 4. If it stopped, open Messages. Each place that would run Python is listed and opens its card. The dialog stays open, so you can fix the cards or untick the box and try again. 5. Export again. A campaign that passes carries no scripts at all. ## Fix what stops the export [Section titled “Fix what stops the export”](#fix-what-stops-the-export) Messages lists two codes. Any one of them stops the export. * [`E.EX.01`](/reference/diagnostics/#eex01), a Script node. Move what the script does onto cards that do not need Python, such as a console variable set and read by its own nodes, or a branch on a value. A Script node inside a macro is reported on the card that calls the macro when you export, and on the macro’s own board while you edit. * [`E.EX.02`](/reference/diagnostics/#eex02), a passage that runs Python. Replace each `ripple` block or inline `{{ … }}` with the text it would have produced, or split the passage so that a choice or a branch picks between versions of it. If the player does have the interpreter, untick **Needs no Python** instead. ## Turn Python off for the whole project [Section titled “Turn Python off for the whole project”](#turn-python-off-for-the-whole-project) To have the app flag these cards as you write, instead of ticking the box on every export, turn **Python** off under *Capabilities* in the project’s settings; [How to build a campaign with no Python](/how-to/turn-python-off/) has the steps. The **Needs no Python** box in the Export dialog is then ticked for you and cannot be unticked. ## Related [Section titled “Related”](#related) * [What an export is](/explanation/what-an-export-is/), for the two engine builds and why the export checks for scripts. * [Diagnostics](/reference/diagnostics/), for `E.EX.01` and `E.EX.02` in full. * [Export options and bundle contents](/reference/export-options/), for the manifest’s `needs` field and the rest of the Campaign options. * [How to build a campaign with no Python](/how-to/turn-python-off/), for the project-level switch. # How to find and replace across a project > Search every board, component, passage and variable in a project for a word or pattern, and replace it in one result, one file or everywhere, with a preview. ## Find a word or pattern [Section titled “Find a word or pattern”](#find-a-word-or-pattern) 1. Open the **Search** section of the side bar with `Ctrl+Shift+F`, or run *Find in project* from the command palette. 2. Type in the **Search the project** field. The search starts 300 ms after your last keystroke, and a newer search replaces one still running. 3. Set the options you need with the three toggles beside the field: **Match case**, **Match whole word** and **Use regular expression**. 4. To narrow the search, open **Files and categories**: * **Files to include** and **Files to exclude** take glob patterns separated by commas, such as `chapters/, *.md`. * The category switches are **Boards & components**, **Narration**, **Console variables** and **Other text**, which covers notes, scripts and state machines. 5. Read the results, grouped by file. The status line gives the totals, for example *3 results in 2 files*. Click a result to open the document at the match. **Clear** empties the field and the results, and **Collapse all** folds every file. Each result says where it is, for example *Choice text (Italian) in “At the gate”*, *Field “Backstory”* or *Line 12, English*. Ids and references never match. A result tagged **name** is something else that refers to it by name: a variable’s name, a state or event of a state machine, an instance, a slot or a metadata key. It is found and marked, and Replace leaves it alone. If the status line says *This is not a valid pattern*, correct the regular expression. If it says *Stopped at N results. Narrow the search to see the rest.*, add include or exclude patterns or switch categories off. A line under the results, *N files could not be read*, lists files the search could not read; hover it to see which. ## Replace what you found [Section titled “Replace what you found”](#replace-what-you-found) 1. Type the new text in **Replace with**, under the search field. Each result now shows what it would become, struck through and followed by the replacement. An empty replacement deletes the match. With a regular expression, `$1` and the other capture groups can be used in the replacement. 2. Hover a result or a file to see its actions. To check the effect on a file, choose **Preview the replacement** on the file. It opens as a tab showing **Now** and **After replacing**. For a board, a component or the console variables it shows only the strings that change, each under its location. The preview follows the replacement field as you retype it, and is not saved with your layout. 3. Use **Dismiss** on a result or a file to remove what you do not want replaced from the list. 4. Replace at the scale you need: * **Replace** on a result, to change that match only. * **Replace in this file** on a file’s row. * **Replace all**, which asks first. The dialog gives the counts of matches and files and how many names are left alone; confirm with **Replace**. 5. Read the status line. After any replace the search runs again, so the list is what is left. Replacements are written through the open editors, so you undo them there. A board’s replacements, however many, are one undo step. ## When a replacement is not applied [Section titled “When a replacement is not applied”](#when-a-replacement-is-not-applied) The status line says so in two cases: * **The text changed since the search.** The result is skipped and counted: *1 was skipped: its text changed since the search*. Search again. * **The file changed on disk under unsaved edits.** It is not overwritten: *1 file changed on disk and was not saved; the replacement is in its open editor*. The replacement is in that editor, unsaved. ## Related [Section titled “Related”](#related) * [The workspace window, menus and palette](/reference/workspace-window/) * [Passages, choices, languages and reading time](/reference/passages/) * [Console variables](/explanation/console-variables/) * [A project is a folder](/explanation/projects-are-folders/) # How to install Ripple Effect > Install Ripple Effect on Windows, macOS or Linux, get past the first-run warning, and turn off Smart App Control if Windows blocks the installer. Download the installer for your platform from the [download page](https://ripplefx.app/download), which always points at the latest release, then follow the section for your platform. None of the installers is code-signed, so Windows and macOS each stop the app once on first run; Linux asks nothing. The warning is about the missing certificate, not about what the installer does. * Windows Needs Windows 10 or 11. 1. Run the Setup `.exe`. 2. When SmartScreen shows **Windows protected your PC**, choose **More info**, then **Run anyway**. 3. If you see **An Application Control policy has blocked this file** instead, that is not SmartScreen. Follow [Turn off Smart App Control](#turn-off-smart-app-control), then run Setup again. Setup installs into `%LOCALAPPDATA%\Programs\Ripple Effect`, for your account only, and never asks for an administrator. To upgrade by hand, run a later installer over it. * macOS Needs macOS 12 Monterey or newer. The `.dmg` is one universal build for Apple silicon and Intel. 1. Open the `.dmg` and drag the app to Applications. 2. Open the app. Gatekeeper says it cannot be opened because it is from an unidentified developer, or that it could not verify it. 3. Either right-click the app and choose **Open**, which adds an Open button to the dialog, or clear the quarantine mark from a terminal: ```sh xattr -dr com.apple.quarantine "/Applications/Ripple Effect.app" ``` You do this once; the app opens normally afterwards. * Linux Needs glibc 2.38 or newer: Ubuntu 24.04+, Debian 13+ or Fedora 39+. Install the package for your distribution: ```sh sudo apt install ./ripple_effect--linux.deb sudo dnf install ./ripple_effect--linux.rpm ``` The `.deb` installs to `/opt/ripple_effect` and the `.rpm` to `/usr/share/ripple-effect`. Both put `ripple_effect` on your `PATH`. The `.deb` declares ALSA, PulseAudio and PipeWire as dependencies for sound. If the package manager refuses it with a complaint about `libc6` or `GLIBC_2.38`, your distribution is older than the floor above. After the first install the app updates itself. See [Updates, logs and Messages](/explanation/updates-logs-and-privacy/). ## Turn off Smart App Control [Section titled “Turn off Smart App Control”](#turn-off-smart-app-control) Windows 11 only. Setup refusing with `An Application Control policy has blocked this file` (error 4551) means Smart App Control is on. It has no *Run anyway* and no per-app exception, so the only way to install is to turn it off. 1. Open **Settings → Privacy & security → Windows Security → App & browser control → Smart App Control settings**. 2. Choose **Off**. 3. Run Setup again. Caution Turning Smart App Control off is a decision, not a toggle. Microsoft says recent Windows updates allow it to be turned back on without reinstalling. On older builds, once it is off the only way back is a clean reset of Windows. If the setting already reads **Off**, the block has another cause. For the background, see [Installers and first launch](/reference/installers/#smart-app-control). ## Related [Section titled “Related”](#related) * [Installers and first launch](/reference/installers/) — the platform table, the first-launch pages and the error text. * [Why the installers are unsigned](/explanation/unsigned-installers/) * [Updates, logs and Messages](/explanation/updates-logs-and-privacy/) # How to keep a history and back up a project > Start a history, make save points, branch a storyline, back the project up, resolve a conflict and recover an undone change. The history is built into the app and lives behind **Changes** in the side bar, the second icon on the activity bar beside Files. ## Start a history [Section titled “Start a history”](#start-a-history) Every file in a project is plain text, so the folder can take a history at any point. Starting one needs a name and an email address and nothing else. 1. Open **Changes** in the side bar. A project with no history shows *This project has no history yet.* 2. Choose **Let us help you set this up**. 3. Check the name and email address. If git is already set up on the computer they are filled in from `~/.gitconfig`; otherwise enter your own. They are written into this project only. 4. Choose **Start keeping a history**. This starts the history in the project folder, writes a short list of things to leave out and takes a first save point of everything. An ignore file you already had is not touched, and only what is missing is added. Line endings are kept consistent for co-authors on other platforms. Files too large to ever take back out again are left out and named. `.ripple/workspace.json` is the one file of the app’s that is ignored, because it is per-machine. Commit the rest of `.ripple/`. [Project files and the .ripple folder](/reference/project-files/) says what each file is for. ## Make a save point [Section titled “Make a save point”](#make-a-save-point) 1. Open **Changes**. Files are listed in two groups: **Ready to save** and **Not yet included**. Click a file to see it side by side against the version before it. 2. Move files into **Ready to save** with **Mark ready (stage)**, or all of a group at once with **Mark all of these ready (stage)**. **Take back out (unstage)** reverses it. 3. In the box at the foot of the section, write a line under **What changed?**. Choose **Add more detail** if you want a longer note. 4. Choose **Save**, which reads *Save N files*. Coloured dots mark changed files in the file tree, and the folders holding them, so you can follow a changed chapter down without opening anything. Right-click a file in the tree to see what changed in it without going through the list. The list keeps up on its own as files change anywhere in the project. If the folder is not being watched, choose **Look again**. To take the last save point back, use **Take it back** in **History**. Every file stays where it is and goes back to being marked ready. **History**, beside Changes, lists what was saved, by whom and how long ago. ## Branch a storyline [Section titled “Branch a storyline”](#branch-a-storyline) A storyline is a branch. Use the storyline commands in the command palette. 1. Choose **New storyline…**, name it, and choose **Start it**. It starts at the current save point and you move onto it. 2. To move to another, choose **Move to another storyline…**. If you have unsaved work, the app asks first. Choose **Set them aside and move** to put the work aside and bring it back later, or cancel and save it first. 3. To rename one, choose **Rename this one…**. To remove one, choose **Delete one…** and pick which. A deleted storyline is kept and can be put back with **Put back**. ## Set work aside [Section titled “Set work aside”](#set-work-aside) Use this to park half-finished work and return to it later. 1. Open the set-aside view in **Changes** (its tooltip reads *Stashes, and what has been undone*). 2. Choose **Put what is here out of the way** and name what you are half-way through. The project goes back to its last save point. 3. To return to it, choose **Bring back**. The entry stays in the list until you throw it away. Caution **Throw it away** is the one action here that cannot be undone. ## Back the project up [Section titled “Back the project up”](#back-the-project-up) Do this to keep a copy of the project somewhere else. 1. Choose **Back this up somewhere**. 2. Under **Address**, give a web address or the full path to a folder. A shared or synced folder needs no account. 3. For a web address, make the repository on the site first. The app does not create it. On GitHub that is New repository, and the address is under the Code button, on the HTTPS tab. Use the HTTPS address, not SSH. 4. Check whether the repository is public or private. A public repository means anyone can read your story, including every draft you save. Choose private unless you mean otherwise. It can be changed later on the site. 5. Choose **Use this**. A web address asks you to sign in once, with a personal access token you make on that site. Enter the username and the token, then choose **Sign in**. The token is kept on this computer and shared by every project you back up there. If the computer has no password store it is kept for this session only and you are asked again next time. 6. Use the button that now sits in the Changes section. It tells you what it is about to do before it does it: *Check for changes*, then for example *Get 3 changes*, then *Back up 2 save points*, or *Back this storyline up* the first time. Backing up to a repository that does not exist yet comes back looking like a wrong password. Under **More**, use **Check without taking anything** to look without getting changes, **Change where it is backed up…** to change the address, and the item that reads “Sign in to” followed by the site’s host name and “again…” to replace the token. ## Resolve a conflict [Section titled “Resolve a conflict”](#resolve-a-conflict) When two people changed the same passage, the file is marked **Two versions disagree**. 1. Open the file. Both versions are shown side by side. 2. Choose **Keep mine** or **Take theirs**, or choose **Write it myself…** and write the text under **What it should say**. Where the other side deleted the file, choose **Keep it deleted** or **Let theirs delete it**. 3. Repeat for each file. A file that is not text has no readable difference to show: keep one or the other. Nothing is touched until you pick, and your files are left as they were while you decide, so leaving it for later costs nothing. Choose **Leave it for now** to defer. An assistant connected over MCP is refused a document with a conflict pending. ## Recover an undone change [Section titled “Recover an undone change”](#recover-an-undone-change) 1. To undo changes to a file, choose **Undo these changes** on it and confirm with **Undo them**. What is thrown away is kept first. 2. To get it back, choose **Put back** in the message that offers it. That message stays in Messages, under the documents, after the notification has gone. 3. Later, open the set-aside view. **Recently undone** lists everything you have undone. The undone list is kept since the project was opened and starts again when you close the app. What each held is still in the history afterwards. ## Related [Section titled “Related”](#related) * [Version control in the app](/reference/version-control/) for every control and what it does. * [History, sharing and conflicts](/explanation/history-and-sharing/) for why the app shares the folder and never lets autosave write over a change. * [A project is a folder](/explanation/projects-are-folders/) for what travels with the folder. * [Project files and the .ripple folder](/reference/project-files/) for what lives in `.ripple/` and which files are ignored. # How to limit a choice > Cap how often a choice can be taken or offer it only while a condition holds. A choice that is used up, or whose condition is false, stays on screen greyed out and unpressable, so a passage never becomes an empty slide. ## Limit how often a choice can be taken [Section titled “Limit how often a choice can be taken”](#limit-how-often-a-choice-can-be-taken) 1. Open the choice on a Narration node. 2. Fill the *Times* box beside its name with the number of times one playthrough can take it. Leave it blank for as often as the reader likes. Once the choice is used up it stays on screen, greyed out. The card shows the ceiling as `×3` at the end of the choice’s row. ## Offer a choice only while a condition holds [Section titled “Offer a choice only while a condition holds”](#offer-a-choice-only-while-a-condition-holds) 1. Find the small input on the choice’s row. Every wired choice has one. 2. Wire a `bool` into it. The choice is offered only while that value is true. Times and the condition combine on one choice: *three times, and only while you carry the lantern* is a single choice with both set. ## Place the deciding cards in the flow [Section titled “Place the deciding cards in the flow”](#place-the-deciding-cards-in-the-flow) A condition is only worked out in time if the cards that decide it sit in the flow leading to the passage. That holds for any card whose answer depends on something the story changes, such as a Comparator reading a dice roll. 1. Put the cards that work out the answer in the flow that leads to the passage. For several, use a Do all with a chain on each branch. 2. Wire the Do all’s `completed` port into the passage. 3. Play the campaign and check the messages panel. A card that is not in the flow is reported there, with the choice named. Caution A card hanging off the side of the board is worked out once, before the story starts, and gives the same answer for ever. The messages panel says so and names the choice. ## Related [Section titled “Related”](#related) * [Diagnostics](/reference/diagnostics/) for the message that names a choice decided before the story starts. * [Nodes](/reference/nodes/) for the cards you wire into a choice. * [Passages, choices, languages and reading time](/reference/passages/) for what a choice carries. * [How a story runs](/explanation/how-a-story-runs/) for why the flow decides when a card is worked out. * [The campaign window and saved games](/reference/campaign-window/) for how the player shows a spent choice. # How to reuse a stretch of story with a macro > Write a stretch of story once as a macro board, then call it from any board with a Macro card. Use a macro for a stretch you want to call from several places: a shop, a rest, a skill check with its own three passages. ## Make the macro [Section titled “Make the macro”](#make-the-macro) 1. In the file tree, press **+** beside a board and choose **Macro**. The file is `*.macro.graph.json` and opens on the same canvas as a board, with a **Macro In** and a **Macro Out** node where a board has its Start and its Ends. 2. Double-click **Macro In** to say what the macro takes: a name and a type per row, as on an Input node. Each name becomes an output carrying what the calling board wires in or types on the card. A take can carry a default, which is used when the calling card wires nothing and types nothing. 3. Double-click **Macro Out** to say what the macro answers, the same way. Each name becomes an input. Its way in takes any number of wires, so every branch inside the macro can finish at the same way out. 4. Build the stretch between the two with the ordinary node set. A macro has exactly one Macro In and one Macro Out. ## Call it from a board [Section titled “Call it from a board”](#call-it-from-a-board) 1. Drag the macro file from the file tree onto the board. A **Macro** card appears with a port per name, and a field beside every one the macro takes. 2. For each take, wire it or type it in the field. If you do both, the wire wins. 3. Wire the card’s outputs on to the rest of the board. 4. To run the macro by itself, with nothing wired in, press play on its own tab; see [How to play and test a campaign](/how-to/play-a-campaign/). ## What to expect [Section titled “What to expect”](#what-to-expect) * Two calls are two independent copies, so a *Once* gate inside a shop fires once per shop, not once for the whole story. * A choice’s *Times* count inside a macro is kept per call: a corridor called from two places has a separate budget for each. See [How to limit a choice](/how-to/limit-a-choice/). * A macro cannot call itself. * A macro cannot be jumped to and a board cannot be called: a Jump lists boards, and a Macro card lists macros. * A story can be paused inside a macro and picked up where it left off. ## Related [Section titled “Related”](#related) * [Boards, nodes and macros](/explanation/boards-nodes-and-macros/), for how a board and a macro differ. * [Nodes](/reference/nodes/), for the Macro, Macro In and Macro Out cards. * [How to play and test a campaign](/how-to/play-a-campaign/) * [How to trace the routes between two passages](/how-to/trace-a-route/), where a route goes inside the macros it calls. * [How to limit a choice](/how-to/limit-a-choice/) # How to add, switch off or change a project's languages > Switch translation languages on and off, change the language a story is written in, and find the translations that have fallen behind. The language a story is written in is chosen when the project is made, as [How to create or import a project](/how-to/create-or-import-a-project/) describes. Everything after that is in Project settings, which you open from the bottom of the activity bar. ## Add a translation language [Section titled “Add a translation language”](#add-a-translation-language) 1. Open Project settings from the bottom of the activity bar. It is a set of pages down a rail: *General*, *Languages*, *Writing*, *Narration roots* and *Capabilities*. 2. Pick *Languages*. 3. Tick the language. Use *Find a language* to search the list. 4. Save. An empty translation is put into every passage bundle. The app’s own language is a separate setting, in the app’s settings. ## Switch a translation language off [Section titled “Switch a translation language off”](#switch-a-translation-language-off) 1. Open Project settings and pick *Languages*. 2. Untick the language. 3. Save. If anything is written in it, you are asked once what to do with the words. Unticking deletes nothing. The files stay, the editor keeps showing them marked retired, and readers stop being offered that language. To remove the words, answer **Delete them**. That deletes the language’s file from every passage bundle and the wording of any choice written in it on your boards, from disk; version control is the only way back. **Keep them** is the default button: clicking through the dialog or dismissing it deletes nothing. ## Change the language a story is written in [Section titled “Change the language a story is written in”](#change-the-language-a-story-is-written-in) 1. Open Project settings and pick *Languages*. 2. Change the *Written in* dropdown. A note under it says what the change will do. 3. Save. Nothing is moved. Every passage gets an empty file for the new language, and everything you have written stays in the old language’s file as a translation. Until you carry the words across by hand, your passages play empty and the Messages panel calls the project untranslated. There is no migration for this. ## Find translations that have fallen behind [Section titled “Find translations that have fallen behind”](#find-translations-that-have-fallen-behind) 1. Open a passage bundle. The locale chips along its top each stand for one language. 2. Look for a **dot** on a chip: that translation is older than its source. [Passages and translations](/explanation/passages-and-translations/) explains how that is told. 3. For the whole project, open the Messages panel (View menu, or the messages button at the bottom of the activity bar). It lists every translation that has fallen behind, for example “3 translations have fallen behind”. Editing a passage and its translation together does not leave the translation behind. The check cannot see choice wording. ## Related [Section titled “Related”](#related) * [Passages and translations](/explanation/passages-and-translations/) * [Passages, choices, languages and reading time](/reference/passages/) * [Project files and the .ripple folder](/reference/project-files/) * [How to write a passage](/how-to/write-a-passage/) # How to run several jobs before a passage > Put the jobs on the branches of a Do all and the story on its completed port, so every value is computed before the passage that reads it. Use a **Do all** when more than one card has to run before a passage — setting several values, say — and the order has to be certain. 1. Put a **Do all** in front of the passage. 2. Wire the first job’s chain to the first branch. Wire the last branch and another appears beneath it, so the card grows as many as you need. For several values, give each value its own chain on its own branch. Branches run from top to bottom. 3. Wire the Do all’s `completed` port to the passage. The jobs go on the branches and the story goes on `completed`. A Do all carries on from `completed` once every branch has run. A branch runs to the end of its wire before the next one starts, and a passage does not end a wire, so a passage on a branch holds the later branches back until everything after it has been read. That looks exactly like a Set that never fired. See [How a story runs](/explanation/how-a-story-runs/). ## If the app flags a value [Section titled “If the app flags a value”](#if-the-app-flags-a-value) A read never runs a card, so a value is only safe when its cards sit in the flow just before whatever reads it. The app reports two cases. A card with a finding wears a halo in the colour of its severity and a mark at the end of its header whose tooltip names the check; the full records are in the messages panel. * [`W.GE.05`](/reference/diagnostics/#wge05), a value worked out before the story that depends on something the story changes: put the cards in the flow that leads to the reader, using a Do all as above. Wire a producing card’s `completed` into the card that reads it, as in a roller’s `completed` into a Comparator and the Comparator’s into an If. Priming suits a number or a name, not anything the story changes. * [`W.GE.06`](/reference/diagnostics/#wge06), a route that skips the card whose value is read: make every route run the card, or tick *I meant that* on its row where the leftover is intended, as with a running total or a flag set once on the way in. * A `bool` wired into a choice’s row that raises `W.GE.05`: use a Do all with a chain per branch and `completed` into the passage. See [How to limit a choice](/how-to/limit-a-choice/). ## Related [Section titled “Related”](#related) * [How a story runs](/explanation/how-a-story-runs/) for why the flow only goes forwards and why a read never runs a card. * [How to limit a choice](/how-to/limit-a-choice/) for guards that need a value computed in time. * [Diagnostics](/reference/diagnostics/) for every coded check and its meaning. * [Nodes](/reference/nodes/) for the Do all card and its ports. * [How to build a board](/how-to/work-on-the-canvas/) for wiring cards. # How to play and test a campaign > Press Play, read a story in a chosen language, handle warnings and runaway loops, start from any node, and inspect a playthrough. ## Play a board [Section titled “Play a board”](#play-a-board) 1. Open the board and press **Play** at its bottom right. The campaign window opens and runs the story from that board’s Start, following Jumps from board to board until a path reaches an End. ![A story being played: rendered prose above a list of choices](/_astro/play-light.CcR2CsXk_2A80B.webp) ![A story being played: rendered prose above a list of choices](/_astro/play-dark.Dd_vqrUr_ZaeXi0.webp) 2. Read the passage. It is rendered as Markdown with its Python already run, and the choices leading out of it are the tiles beneath. Press a tile to follow it. 3. Press **Start over** when you want to run again. It is available from the first passage to an ending, and an ending also offers *Play again*. Play re-indexes the project before it compiles, so the playthrough is current without exporting first. [Why the preview is the shipped story](/explanation/the-preview-is-the-shipped-story/) explains why. ## Read in another language [Section titled “Read in another language”](#read-in-another-language) Pick the language from the campaign window’s header. You can change it mid-passage. A passage with nothing in the chosen language says so instead of falling back to another. ## Answer an Input node’s form [Section titled “Answer an Input node’s form”](#answer-an-input-nodes-form) When the story reaches an **Input** node, fill in the form and press Enter in a field to continue. The fields are listed in [The campaign window and saved games](/reference/campaign-window/). ## Deal with warnings [Section titled “Deal with warnings”](#deal-with-warnings) Compile warnings, such as a roll that cannot vary or a macro missing one of its ends, appear in **Messages** when you press Play, not only when you export. The engine’s board checks appear there too: Play checks every board, and a finding names the cards it concerns. 1. Open **Messages** and read the finding. Look the code up in the [diagnostics reference](/reference/diagnostics/). 2. Fix the board, or, for `W.GE.06`, choose **I meant that** on the finding to acknowledge it for that card. If the project has turned **Python** off (project settings, *Capabilities*), every Script node and every passage with Python in it is marked as an error and listed in Messages. The campaign will not play until none remain. ## Raise the loop ceiling [Section titled “Raise the loop ceiling”](#raise-the-loop-ceiling) A loop nothing breaks out of stops the campaign and offers you the node that ran away. By default one loop node may go round 512 times in a board. 1. Open Settings and find **Playing**. 2. Choose 128, 512, 2,048, 8,192, or None for no ceiling. A For node’s own *Max iterations* still applies first when it is lower. ## Play from a node [Section titled “Play from a node”](#play-from-a-node) 1. Right-click any node on a board. 2. Choose **Play from here**. The campaign begins at that node instead of the Start. 3. Set up whatever the story should already have made, using the console in the campaign window. It may do everything a Script node may. The run starts with nothing made yet, and the window says so. Only a Start begins a story on its own; Play from here is how you begin anywhere else. ## Inspect and change a playthrough [Section titled “Inspect and change a playthrough”](#inspect-and-change-a-playthrough) Open a panel from the narrow strip down the window’s right edge: Entities, Variables, Console or Trace. What each shows is in [The campaign window and saved games](/reference/campaign-window/). * To change a value by hand, open **Variables** and edit it. * To read the story’s own steps, open **Trace**. Use its toggle to show everything underneath. * To keep the plain lines, press **Copy** in **Trace**. ### Find the node behind a trace line [Section titled “Find the node behind a trace line”](#find-the-node-behind-a-trace-line) 1. Tap a line in **Trace**. The story pauses, the window closes, and the board opens with that node selected and centred. 2. Press Resume to bring the story back. A line about something inside a macro takes you to the card that called it, since that is what is on the board. ## Change the window [Section titled “Change the window”](#change-the-window) A button in the header takes the window to the app’s edges and back; Escape also brings it back. The **mute** button beside it is remembered between sessions. ## Related [Section titled “Related”](#related) * [How to save and resume a playthrough](/how-to/save-and-resume-a-campaign/) * [How to trace the routes between two passages](/how-to/trace-a-route/) * [Why the preview is the shipped story](/explanation/the-preview-is-the-shipped-story/) * [The campaign window and saved games](/reference/campaign-window/) * [Diagnostics](/reference/diagnostics/) # How to play a campaign in your own program > Export a campaign, add the engine's binding for your language, and play the story with its choices, forms, waits, callbacks, sound and saves. These steps take a story from the editor into a program of yours: a game, an app, a page or a tool. Each step has a tab for each binding, and choosing a language once switches every tab on the page. What each call does, and every option it takes, is in the binding’s reference: [JavaScript and TypeScript](/reference/binding-javascript/), [C and C++](/reference/binding-c-cpp/), [C#](/reference/binding-csharp/), [Dart](/reference/binding-dart/). 1. **Export the story as a campaign.** In the editor, choose *Export project* in the project panel, choose **Campaign**, and export to a folder. The folder, with `campaign.json` at its root, is what your program opens. If your program will use the engine without Python, tick **Needs no Python** first. See [How to export a project copy or a campaign](/how-to/export-a-project-or-campaign/). 2. **Add the binding.** * JavaScript ```sh npm install @ripplefx/player @ripplefx/soundscape ``` Serve the campaign folder beside your page, and serve `.wasm` files as `application/wasm`. Your bundler copies the engine’s files from the package. * C++ ```cmake include(FetchContent) FetchContent_Declare( ripple_effect GIT_REPOSITORY https://github.com/ConnectingTheDots-DCWK/ripple_effect_c_cpp_integration GIT_TAG v1.5.2) FetchContent_MakeAvailable(ripple_effect) target_link_libraries(my_game PRIVATE Ripple::cpp) ``` Configuring downloads and checks the engine for your platform. Link `Ripple::engine` instead for C alone. * C# ```sh dotnet add package RippleFx.Engine dotnet add package RippleFx.Engine.Native ``` Reference exactly one engine package: `RippleFx.Engine.Native`, or `RippleFx.Engine.Native.NoPython` for the engine without Python. * Dart ```sh dart pub add ripplefx_engine # or: flutter pub add ripplefx_engine ``` The package’s build hook downloads, checks and bundles the engine for the platform you build. 3. **Choose the variant**, if the campaign needs no Python and you want the smaller engine. * JavaScript Nothing to do: `Campaign.load` reads the campaign’s manifest and loads the engine with Python only when the campaign needs it. Pass `python: false` to refuse one that does. * C++ ```sh cmake -B build -DRIPPLE_ENGINE_PYTHON=OFF ``` * C# Reference `RippleFx.Engine.Native.NoPython` instead of `RippleFx.Engine.Native`. * Dart In your pubspec, and on the web `--dart-define=RIPPLEFX_PYTHON=false`: ```yaml hooks: user_defines: ripplefx_engine: python: false ``` The engine without Python refuses a campaign that needs it when the campaign is opened. 4. **Open the campaign, make a session, and wrap it in a player.** Choose the seed yourself: the same seed and the same answers play the same story. * JavaScript ```ts import { Campaign, Player } from '@ripplefx/player'; const campaign = await Campaign.load('/campaigns/the-city-of-elsenmar/'); const player = new Player(campaign.session({ seed: BigInt(Date.now()) }), { onEffect: draw, // step 5 onError: (error) => console.error(error), }); ``` * C++ ```cpp #include const ripple::Campaign campaign = ripple::Campaign::open_dir("assets/story"); ripple::SessionConfig config; config.board = campaign.manifest().entries.front(); config.seed = seed; ripple::PlayerHandlers handlers; handlers.on_effect = [&](const ripple::Effect& effect) { draw(effect); }; // step 5 handlers.on_error = [](const ripple::Failure& failure) { log(failure.message); }; ripple::Player player(ripple::Session::create(campaign, config), handlers); ``` * C# ```csharp using RippleFx.Engine; using var campaign = Campaign.OpenDirectory("Assets/Story"); var config = new SessionConfig { Board = campaign.Manifest.Entries[0], Seed = seed }; using var player = new Player(Session.Create(campaign, config), new PlayerHandlers { OnEffect = Draw, // step 5 OnError = error => Log(error.Message), }); ``` * Dart ```dart import 'package:ripplefx_engine/ripplefx_engine.dart'; final campaign = await Campaign.openDirectory('assets/story'); final player = Player( Session.create( campaign, SessionConfig(board: campaign.manifest.entries.first, seed: seed), ), onEffect: draw, // step 5 onError: (error, stack) => log('$error'), ); ``` A program that packs its assets its own way opens the campaign from its files in memory instead: `Campaign.fromFiles`, `Campaign::open_files`, `Campaign.OpenFiles` or `Campaign.openFiles`. 5. **Draw the turn.** Every effect reaches your handler in order. The ones that need drawing are a passage (`show`), a form (`ask`) and an ending (`ended`, `failed`, `stopped`). Draw a choice that is not available greyed, and do not let it be pressed. * JavaScript ```ts function draw(effect: Effect) { switch (effect.kind) { case 'show': showPassage(effect.passage); break; // passage.text, passage.choices[i].available case 'ask': showForm(effect.prompt); break; case 'ended': showEnding(effect.title); break; case 'failed': showProblem(effect.message); break; } } ``` * C++ ```cpp void draw(const ripple::Effect& effect) { if (const auto* shown = std::get_if(&effect)) show_passage(shown->passage); else if (const auto* asked = std::get_if(&effect)) show_form(asked->prompt); else if (const auto* ended = std::get_if(&effect)) show_ending(ended->title); else if (const auto* failed = std::get_if(&effect)) show_problem(failed->message); } ``` * C# ```csharp void Draw(Effect effect) { switch (effect) { case Effect.Show s: ShowPassage(s.Passage); break; // Passage.Choices[i].Available case Effect.Ask a: ShowForm(a.Prompt); break; case Effect.Ended e: ShowEnding(e.Title); break; case Effect.Failed f: ShowProblem(f.Message); break; } } ``` * Dart ```dart void draw(Effect effect) { switch (effect) { case Show(:final passage): showPassage(passage); // passage.choices[i].available case Ask(:final prompt): showForm(prompt); case Ended(:final title): showEnding(title); case Failed(:final message): showProblem(message); default: } } ``` A passage’s text is Markdown, or HTML when the campaign was exported that way, as the manifest’s text format says. Render it with a library of your choice. 6. **Start the story, answer the reader, and give the player your clock.** Call the verb for what the reader did. Outside JavaScript, call the player’s `update` from your frame or event loop: it answers a wait once its time has come. * JavaScript ```ts player.start(); choiceButton.onclick = () => player.choose(index); leaveButton.onclick = () => player.choose(); // the reader leaves the passage form.onsubmit = () => player.answer({ name: 'Ada', age: 31n }); // an Int is a bigint // The player waits out a hold by itself. ``` * C++ ```cpp player.start(); player.choose(index); // or player.choose(ripple::leave) player.answer(answers); // a ripple::Value::Map, or std::nullopt to leave the form // every frame: player.update(); ``` * C# ```csharp player.Start(); player.Choose(index); // or player.Leave() player.Answer(answers); // field name to Value; null leaves the form // every frame: player.Update(); ``` * Dart ```dart player.start(); player.choose(index); // or player.leave() player.answer({'name': TextValue('Ada')}); // null leaves the form // every frame: player.update(); ``` A verb the story was not asking for is refused and nothing moves. Catch the refusal for a spent choice and for an unknown language, which are the two met in ordinary use. 7. **Register the author’s callbacks.** Agree the names with the author. A handler must not call a verb, so queue one for after it returns. * JavaScript ```ts player.onCallback('chapterTwoBegan', (fired) => game.unlock('chapter-2')); player.onCallback('quitToTitle', () => queueMicrotask(() => player.stop())); ``` * C++ ```cpp player.on_callback("chapterTwoBegan", [&](const ripple::CallbackEvent&) { game.unlock("chapter-2"); }); player.on_callback("quitToTitle", [&](const ripple::CallbackEvent&) { later.push_back([&] { player.stop(); }); }); ``` * C# ```csharp player.OnCallback("chapterTwoBegan", fired => game.Unlock("chapter-2")); player.OnCallback("quitToTitle", fired => later.Enqueue(() => player.Stop())); ``` * Dart ```dart player.onCallback('chapterTwoBegan', (fired) => game.unlock('chapter-2')); player.onCallback('quitToTitle', (fired) => scheduleMicrotask(player.stop)); // dart:async ``` 8. **Give the story a speaker.** Make a soundscape driver over the campaign and a speaker, pass the driver to the player as its audio, and tick it with the player. In a browser, `@ripplefx/soundscape` is the speaker; elsewhere you implement the binding’s speaker interface over your own audio API, as its reference describes. * JavaScript ```ts import { Player, SoundscapeDriver } from '@ripplefx/player'; import { WebAudioSpeaker } from '@ripplefx/soundscape'; const speaker = new WebAudioSpeaker(); const audio = new SoundscapeDriver(campaign, speaker); const player = new Player(campaign.session({ seed }), { onEffect: draw, audio }); begin.onclick = async () => { await speaker.unlock(); player.start(); }; // sound needs a click first ``` * C++ ```cpp MySpeaker speaker; // implements ripple::Speaker auto audio = ripple::SoundscapeDriver::create(campaign, speaker); handlers.audio = audio.get(); // every frame: audio->update(); player.update(); ``` * C# ```csharp using var audio = new SoundscapeDriver(campaign, new MySpeaker()); // implements ISpeaker // in PlayerHandlers: Audio = audio, // every frame: audio.Update(); player.Update(); ``` * Dart ```dart final audio = SoundscapeDriver(campaign, MySpeaker()); // implements Speaker // in Player(...): audio: audio, // every frame: audio.update(); player.update(); ``` 9. **Save, and resume.** Checkpoint while the story is on a passage or a form, and keep the save with the seed. To resume, open a session from the save over the same campaign and start it: it shows the saved turn again. * JavaScript ```ts const save = player.session.checkpoint(); // undefined except on a passage or a form const resumed = Session.resume(campaign, save, { seed }); ``` * C++ ```cpp std::optional save = player.session().checkpoint(); ripple::Session resumed = ripple::Session::resume(campaign, *save, seed, options); ``` * C# ```csharp string? save = player.Session.Checkpoint(); var resumed = Session.Resume(campaign, save, seed, options); ``` * Dart ```dart final save = player.session.checkpoint(); final resumed = Session.resume(campaign, save!, seed: seed); ``` A save from a campaign that has since changed too much is refused when it is resumed. Tell the player the save is from an older build of the story. If the engine reports a **trap**, the campaign is unusable from then on: dispose of it, open it again, and resume from the last checkpoint. 10. **Ship the engine’s notice.** Reproduce the engine’s `NOTICE` wherever your product lists its third-party software. Each binding names the file: `ENGINE-NOTICE` in the npm package, `share/ripple_effect/ENGINE-NOTICE` in a CMake install, `ENGINE-NOTICE.txt` in the C# engine packages, and the archive’s `NOTICE` for Dart. ## Related [Section titled “Related”](#related) * [Embedding the engine](/explanation/embedding-the-engine/): why the API is a session answering with a list of effects. * [How the engine ships](/explanation/how-the-engine-ships/): what the binding puts in your build, and the two variants. * [Engine platforms and variants](/reference/engine-targets/): the platforms each binding ships for. * [Host callbacks](/explanation/host-callbacks/): the author’s side of a named callback. # How to report a bug > Open the pre-filled bug form from the Support page and attach the session's log and reports as a zip. 1. Open **Settings ▸ Support**, or choose *Support and feedback* in the Help menu. 2. Choose **Report a bug**. The bug form opens in the public support repository, [`ripple_effect_support`](https://github.com/ConnectingTheDots-DCWK/ripple_effect_support), with the version, your operating system and which installer this copy came from already answered, along with the technical details. 3. Describe what you saw in the form. 4. Under *What to attach*, choose **Save this session as a zip**. The log and reports of the session are packed into one file, and the folder it was written to opens. The row names the session by its id, so a support thread can be about the right run. 5. Attach the zip to the issue before you submit it. Caution The app removes private details from the log before it is written, but it cannot know that a board’s name is private. Read the zip’s contents before you post it. Nothing uploads these files; you decide what to attach. The one exception is a crash report, which the app asks about first and sends only if you say yes. [The session log and reports](/reference/session-log/) lists what is removed. ## Other ways to give details [Section titled “Other ways to give details”](#other-ways-to-give-details) * **Help ▸ Troubleshooting** copies the package details (which build this is and how it was installed) and the device details (the operating system), and opens this session’s folder. *Show every tip again* is in the Help menu itself. The Troubleshooting items and *Show every tip again* are in the command palette too; see [the workspace window, menus and palette](/reference/workspace-window/). * **Ask a question** opens GitHub Discussions, **Request a feature** opens a new issue tagged as an enhancement, and **Browse open issues** lists the known problems, so you can check whether yours is already there. ## Related [Section titled “Related”](#related) * [The workspace window, menus and palette](/reference/workspace-window/) * [Settings, accessibility and updates controls](/reference/settings/) * [The session log and reports](/reference/session-log/) * [Updates, logs and Messages](/explanation/updates-logs-and-privacy/) # How to save and resume a playthrough > Save a playthrough under a name, pause and resume it, and open a saved game from the bookmark or the command palette. ## Save a playthrough under a name [Section titled “Save a playthrough under a name”](#save-a-playthrough-under-a-name) Saving does not end the playthrough, and you can save as many times as you like. 1. In the campaign window, select the bookmark in the header. 2. Type a name in the **Save this playthrough** dialog. **Save** stays unavailable until the name is not empty. 3. Select **Save**. The app reports *Saved as “name”.* and the playthrough carries on. ## Put a playthrough down and pick it up again [Section titled “Put a playthrough down and pick it up again”](#put-a-playthrough-down-and-pick-it-up-again) Select **Pause** in the campaign window to put a playthrough down. Pausing writes an automatic save into the project’s `.ripple/saves/`, listed as **Where you left off** in the same list as your named saves and rewritten each time you pause. [How to play and test a campaign](/how-to/play-a-campaign/) covers pausing and resuming. Pausing and resuming in the same sitting hands you back the board you just edited. A save opened later is checked against the story as it is then, which is why one can be refused. ## Open a saved game [Section titled “Open a saved game”](#open-a-saved-game) 1. Select the bookmark beside Play, or run **Open a saved game…** from the command palette. 2. Pick a save. The list shows the passage each is waiting at and how long ago it was written. 3. If a playthrough is running, the app asks **Open this saved game?** Opening a save throws the running campaign away, so save that playthrough first if you want it back. 4. Select **Open**. A save records your place and everything the story has made. [The campaign window and saved games](/reference/campaign-window/) lists what it holds, and [How a story runs](/explanation/how-a-story-runs/) explains how choice counts and macros behave. ## When a saved game is refused [Section titled “When a saved game is refused”](#when-a-saved-game-is-refused) A save records a place in a board. If the story has been rewritten since and that place no longer exists, the save is listed with the reason beside it and left alone. Open another save, or start a new playthrough and save that. A build older than yours refuses a newer save rather than put a `json` variable back to its default. Update the app to open it. Saves are left out of version control. ## Related [Section titled “Related”](#related) * [How to play and test a campaign](/how-to/play-a-campaign/) * [The campaign window and saved games](/reference/campaign-window/) * [How a story runs](/explanation/how-a-story-runs/) * [Project files and the .ripple folder](/reference/project-files/) # How to run Python in a story > Compute with a Script node, show the answer with an Output node, vary a passage with {{ }} and ripple blocks, and check what the sandbox allows. ## Compute a value with a Script node [Section titled “Compute a value with a Script node”](#compute-a-value-with-a-script-node) A Script node references a `.py` file in the project, so one script can serve many nodes. 1. Add a Script node and reference a `.py` file. 2. Press **Inputs** or **Outputs** in its editor, as you need them. Each opens the same ports dialog every other node uses. Declare a name and a type per row; the node grows a port per name. 3. Wire the inputs. Values cross as native Python types, so `total = hp - damage` is arithmetic, not a parse. 4. Assign each output name in the script. Whatever the script assigns to an output name is published on that port. Choose names Python can read. A Script node’s inputs and outputs, and a passage’s inputs, accept only a letter or an underscore followed by letters, digits and underscores. Python’s own words, such as `class`, are refused, with the reason shown under the field. A board that carries a name Python cannot read reports it. ### Choose slot types and defaults [Section titled “Choose slot types and defaults”](#choose-slot-types-and-defaults) Give each row the type of the value it carries, and a default if the slot may be left unfilled. For a script input, a wired value wins over the default; for an output, the default applies only where the script never set that name. The type list and the coercion rules are in [Node families, slots and state](/reference/node-families/). ## Show the answer with an Output node [Section titled “Show the answer with an Output node”](#show-the-answer-with-an-output-node) A Script node presents nothing; an Output node shows its inputs to the reader as a slide with one way on. 1. Add an Output node and wire the Script node’s outputs into it. 2. Double-click the card and declare a name and a type per row. These names are read by people, so `Your roll` is a fine name here, unlike a Script node’s. A slot may carry a default. To take a name or a number from the reader instead, use an Input node. It asks the reader for values and hands what was typed on as ports, so a Script node can use them. On an Input node the default is filled in for the reader to accept or type over. ## Vary a passage with Python [Section titled “Vary a passage with Python”](#vary-a-passage-with-python) A passage has two places for Python: an inline `{{ … }}` span and a fenced ` ```ripple ` block. Both are replaced by what they evaluate to when the passage is presented, so a sentence can vary instead of forking into two passages. ````markdown The goblin has {{ entity_get("Goblin-1")["HP"] }} hit points left. ```ripple names = entity_names(of="Goblin") f"{len(names)} goblins are still standing." ``` ```` A passage only reads. Making, changing or deleting an entity, or writing a console variable, is refused from inside prose. Do those in a Script node, where the change can be pointed at on the canvas. The refusal renders in place as a `⚠` chip and the story carries on. ## Try a line in the console [Section titled “Try a line in the console”](#try-a-line-in-the-console) Open any `.py` file. Its console is a live session, one per project, so `x` survives between lines. * **Enter** runs what is there; **Shift+Enter** starts a new line. The prompt grows to three lines as you type. * The arrow keys walk through what you have already run. During a playthrough the same console sits beside the Entities and Variables panels, over the story that is running. There you can run statements that make or change entities, as a Script node can. ## Work within the sandbox [Section titled “Work within the sandbox”](#work-within-the-sandbox) The interpreter runs a subset of Python; the limits are in [The sandbox and its limits](/reference/python/sandbox/). To roll dice, use the Dice roller, Random number and Weighted branch nodes. To keep entities straight in prose and scripts, use these habits. * **Read components and entities as dicts** (see [How to read and change entities from Python](/how-to/use-entities-from-python/)). Write `component_get("Goblin")["HP"]`, never `goblin.hp`. * **Write with `entity_set`.** A read hands back a copy: `e = entity_get("Mira"); e["HP"] -= 3` runs and changes nothing. * **Keep host functions out of `map()`, `filter()` and `sorted(key=…)`.** Everything the app binds fails there. Inside a `def` or a comprehension it works, so build the pairs first: ```python pairs = [(entity_get(n)["HP"], n) for n in entity_names()] weakest = sorted(pairs)[0][1] ``` ### Check what the sandbox allows [Section titled “Check what the sandbox allows”](#check-what-the-sandbox-allows) The list above is not the authority; the app is. The interpreter’s limits change between its releases, so the app probes them. 1. Press the **i** button on the console, or run the command palette’s *What this Python can and cannot do*. 2. Read the report. It runs a suite of tiny snippets (statements, modules, built-ins, call shapes) in the real interpreter and reports what happened, row by row. It says what the app binds, what `import` can reach, and which limits are enforced. 3. Press **Copy report** to paste it into a bug report. An agent can ask for the same text through `python_capabilities`. Where this page and the report disagree, the report is right. ## Related [Section titled “Related”](#related) * [Python in a story](/explanation/python-in-a-story/): why Python sits where it does and what the sandbox is for. * [Python reference](/reference/python/): every function the app binds, with what it answers and refuses. * [The sandbox and its limits](/reference/python/sandbox/): the limits the interpreter enforces. * [Node families, slots and state](/reference/node-families/): slot types, coercion and defaults. To leave Python out of a campaign, see [How to build a campaign with no Python](/how-to/turn-python-off/). # How to trace the routes between two passages > Open Script navigation, pick two passages or two chapters, confirm, and read every route between them, or ask what leads to one passage. Use a trace to answer *how can a reader get from here to there* or *how could a reader have arrived here at all*, without playing the story. ## See how the boards connect [Section titled “See how the boards connect”](#see-how-the-boards-connect) 1. Press **Script navigation**, the first button in the left panel’s footer. The overview opens: one card per board, one output per way out of it, every arrow labelled with the node it leaves by. A Jump into the middle of a chapter also names where it lands, such as `Down → The cellar`. Markers show where a thread begins and ends. Nothing on the overview can be moved or rewired, and the same project lays out identically each time. ![A map of how the boards hand the story to each other](/_astro/overview-light.D1tBQF-v_Z1a4qXO.webp) ![A map of how the boards hand the story to each other](/_astro/overview-dark.DTmZb2Lk_FIgCs.webp) ## Trace every route between two passages [Section titled “Trace every route between two passages”](#trace-every-route-between-two-passages) 1. Open the board and press **Trace the routes between two passages** on its quickbar. The quickbar then offers a tick and a cross in place of the toggle. 2. Click the first passage, then the second. Only Start, Narration, Jump and End nodes can be picked; a click on any other node does nothing. The status beside the buttons reads *Pick two passages*, then *Pick one more*, then *Ready*. 3. Press the tick, **Trace every route between them**. Every route lights up in colour and the rest of the board goes under a scrim. The status beside the buttons gives the count, such as *2 routes*, or *No route between them*, or *Loops — countless routes* when the story can go round a loop any number of times. 4. Read the colours. Each linear run has its own hue, so the place where routes part is where the colour changes. You can pick either passage first. The app tries both directions and draws the one that has a route; if both do, the order you picked decides. 5. Press the cross, **Take the trace away**, when you are done. Editing the board also drops the trace. ![Every route between two passages picked out in colour while the rest of the board dims](/_astro/trace-light.DA4waBD6_Z1sd2fN.webp) ![Every route between two passages picked out in colour while the rest of the board dims](/_astro/trace-dark.W1DQuwA7_Z2qPgmR.webp) Confirming a trace ends the picking: the board keeps its routes with only the cross beside them, and the next click is an ordinary click. Two boards can each hold a trace at once. To abandon the picking before you confirm, press the cross, **Stop tracing**. ## Trace between two chapters [Section titled “Trace between two chapters”](#trace-between-two-chapters) On the boards overview, use the same gesture to pick two boards instead of two passages: **Trace the routes between two boards**, click each, then the tick. ## Ask what leads to one passage [Section titled “Ask what leads to one passage”](#ask-what-leads-to-one-passage) 1. Select one passage on a board. With nothing selected the button reads *Select one to see everything that leads to it*. 2. Press the quickbar button beside the trace toggle, or run **Trace what leads here** from the command palette. The answer is every route that arrives at the passage, from wherever the story begins. You pick no second end, and a story often has several beginnings. The status reads a count such as *3 ways here*, *Nothing leads here* or *Loops — countless ways here*. If every route stays on its own board, the board draws it and the cross takes it away. If one arrives through a Jump, the board cannot draw it and the answer opens as a merged view. ## Trace across boards in the merged view [Section titled “Trace across boards in the merged view”](#trace-across-boards-in-the-merged-view) 1. Pick a passage on one board. 2. Open another board and pick a second passage there. 3. Press the tick, now reading **Open both boards in one view and trace between them**. The merged view opens in its own tab, titled with the two boards’ names joined by ↔, or `Into ` for a what-leads-here answer. 4. Press the reload button on the bar to read every board from disk again after you have edited one. In the merged view: * Every board on the route is on one canvas, and a passage is shown as its prose. Hover a passage for its name, and click it to open its bundle in the viewer panel. * Wherever the story passed through an *If*, a *Switch*, a roll or a loop, the arrow names that node and the way it went, such as `Hit? → true`, beside the words of the choice if there was one. * If the header says boards are folded, press **Expand N more chapters**. A board over the view’s size budget appears as one card, and the route still passes through it. * If a chapter has since been deleted, the view says *One of those chapters is no longer in this project.* If nothing reaches the board you asked about, it says so, as *Nothing in this project reaches* followed by the board’s name. The merged view is read-only. Make changes on the boards themselves. A trace on a board and a merged view do not mix: opening a merged view takes every board’s trace away, and while one is open the board’s two trace buttons are disabled with the tooltip *Close the merged trace first — a board cannot trace while one is open*. Closing the last merged view hands the boards back. For how Do-all branches, macros and choices that run a script are drawn on a route, see [Board and navigation rules](/reference/canvas-rules/#merged-view). ## Ask an assistant instead [Section titled “Ask an assistant instead”](#ask-an-assistant-instead) An MCP client can ask the same three questions with `trace_between`, `trace_into` and `board_links`, for example to check that an ending is reachable. ## Related [Section titled “Related”](#related) * [Reading a large story back](/explanation/reading-a-story-back/) * [Board and navigation rules](/reference/canvas-rules/#traces) * [MCP tools](/reference/mcp-tools/) # How to build a campaign with no Python > Turn Python off in a project's settings so the app flags every card that needs the interpreter, and export a campaign for a player built without it. Use this when the campaign is for a game or a player built without the Python interpreter, and you want the app to tell you everything that would need it. 1. Open the project’s settings and go to the **Capabilities** page. 2. Turn **Python** off. 3. Open **Messages**. Each Script node and each passage with a `{{ … }}` or a `ripple` block is listed there and marked as an error on its card: * [`E.EX.01`](/reference/diagnostics/#eex01) is a Script node. * [`E.EX.02`](/reference/diagnostics/#eex02) is a passage that runs Python, in any of its languages. 4. Replace each one with cards that do not need Python, such as a console variable set and read by its own nodes, or a branch on a value. Until none are left, the campaign will not play or export. 5. [Export the campaign](/how-to/export-for-a-player-without-python/). **Needs no Python** is ticked in the export dialog and cannot be unticked while the project has Python off. To go back, turn **Python** on again. Nothing about the project has changed, and it plays and exports as before. Note What a player built without Python does with a campaign that still has Python in it is covered in [the export guide](/how-to/export-for-a-player-without-python/). ## Related [Section titled “Related”](#related) * [How to export for a player without Python](/how-to/export-for-a-player-without-python/) * [How to run Python in a story](/how-to/script-a-passage/) * [Python in a story](/explanation/python-in-a-story/) * [Diagnostics](/reference/diagnostics/) # How to use components and entities > Make a component, give it typed attributes, put it on a board as a component or an entity, and spawn, delete and list the entities a story has made. Use this to make a component and put it, or entities made from it, on a board. For what the two are, see [Components and entities](/explanation/components-and-entities/). ## Make a component [Section titled “Make a component”](#make-a-component) 1. In the file tree, create a **New component**. It is saved as a `*.component.json` file. 2. Set a name and, if you want one, a cover picture. 3. Add a row per attribute. Give each a label, a type and a value. Drag rows to reorder them. 4. Choose each type from the attribute’s type menu. Each can stand on its own, as a list or as a map from names. A `markdown` attribute keeps its six-line editor when you change its type from the menu. Add only the attributes this story needs. The types and how the file stores them are in [Project files and the .ripple folder](/reference/project-files/). ### Point an attribute at a file [Section titled “Point an attribute at a file”](#point-an-attribute-at-a-file) An `asset` or `componentRef` attribute, or the items of a list of them, is filled by name. 1. Type in the slot. The project’s files are offered, with the full path beside each. 2. Pick one. It sits in the slot as a chip with its name on it; hover to see the path. A chip is marked when its file is outside the project or missing; fix or replace the file. Dotted folders and files, and anything outside the project, are not offered. ### Use a cover or file from outside the project [Section titled “Use a cover or file from outside the project”](#use-a-cover-or-file-from-outside-the-project) A cover picked from outside the project is copied into `.ripple/external/`, so the file never records a path that works on one machine only. Copying in is a switch: run **Toggle importing outside assets** from the command palette. With it off, the file is linked instead. A cover that is still outside the project shows a caution under the field with **Copy into project** beside it. Messages names every such link each time the project is opened. ## Put a component on a board [Section titled “Put a component on a board”](#put-a-component-on-a-board) 1. Drag the `*.component.json` from the file tree onto a board. It becomes a **Component** node with one typed output per attribute, plus one that carries the whole component as a map. 2. Choose the node’s mode. The node says which one it is in. * **Component** is a window onto the file: outputs and nothing else. * **Entity** makes the story a thing, keyed by the name on the node. Attribute inputs appear beside the outputs. To make two independent swords, use two Entity nodes with two names. A node on a later board that names the same entity is the sword the first board made. To change an entity later, put another node naming it later in the story. There is no Set/Get pair; that node is the setter. To make one entity per name, wire the node’s name from upstream. Wire the name from upstream, for example from a For node, to make one entity per name it is handed. ## Make several entities at once [Section titled “Make several entities at once”](#make-several-entities-at-once) 1. Add a **Spawn** node from the **Entities** category of the node menu. 2. Point it at a component and say how many. 3. Choose how they are told apart: numbers or letters, and what goes between them. “Goblin” with numbers and a dash makes Goblin-1, Goblin-2 and so on. 4. Wire the names it hands on, as a list, into whatever gives each one its own values, for example a Python script that sets each one’s hit points. See [How to read and change entities from Python](/how-to/use-entities-from-python/). Spawn makes the same entities every time. Coming back to the node finds the goblins it already made rather than six more, so a paused campaign resumes into the encounter it left. For a second wave, use a different name. ## Remove an entity [Section titled “Remove an entity”](#remove-an-entity) Add a **Delete Entity** node and give it the name. The entity is forgotten; the component file is untouched, so a node naming it later makes a fresh one. Deleting a name nothing answers to is not an error. It removes nothing and the story carries on. ## Branch on whether an entity exists [Section titled “Branch on whether an entity exists”](#branch-on-whether-an-entity-exists) Add an **Entity Exists** node and wire its answer into an If node. ## Do something to every entity [Section titled “Do something to every entity”](#do-something-to-every-entity) 1. Add an **Entity Names** node. It answers the names of every entity the story has made, alphabetically. To limit it to one component, choose that component. 2. Wire its `names` output into a For node with a List at node. 3. Put the work to do for each one inside the loop. For example, to reach the goblins still standing two passages later, put the work inside the loop. ## Related [Section titled “Related”](#related) * [Components and entities](/explanation/components-and-entities/) for what a component and an entity are * [Project files and the .ripple folder](/reference/project-files/) for the component file format * [Nodes](/reference/nodes/) for the Component, Spawn, Delete Entity, Entity Exists and Entity Names nodes * [Components and entities in Python](/reference/python/components-and-entities/) for the calls a script uses * [The campaign window and saved games](/reference/campaign-window/) for the Entities panel * [How to read and change entities from Python](/how-to/use-entities-from-python/) as a see-also # How to read and change entities from Python > Find, damage, delete, create and inspect entities from a Script node, using the component and entity functions. Five recipes over the entities a playthrough has made from a component. Each assumes a component called `Goblin` with an `HP` attribute, and a script that runs in a Script node or the playthrough console. Reading works on every [surface](/reference/python/#surfaces); `entity_set`, `entity_create`, `entity_delete` and `entity_rename` work only in a Script node and the playthrough console, and are refused elsewhere. `damage` stands for a number the script already has. ## Find the weakest goblin [Section titled “Find the weakest goblin”](#find-the-weakest-goblin) 1. Collect each goblin’s hit points with its name, using `entity_names(of="Goblin")`. 2. Sort the pairs and take the name from the first, guarding against there being none. ```python pairs = [(entity_get(n)["HP"], n) for n in entity_names(of="Goblin")] weakest = sorted(pairs)[0][1] if pairs else None ``` `weakest` is a name, or `None` when the story has made no goblins. To test whether an entity exists, use `entity_get(name) is None`: an entity with no attributes answers `{}`, which is falsy too. ## Damage an entity and delete it when it drops [Section titled “Damage an entity and delete it when it drops”](#damage-an-entity-and-delete-it-when-it-drops) 1. Read its `HP` and subtract the damage. 2. Write the new value back with `entity_set` while it is above zero. 3. Call `entity_delete` when it is not. ```python if weakest is not None: hp = entity_get(weakest)["HP"] - damage if hp > 0: entity_set(weakest, "HP", hp) else: entity_delete(weakest) ``` Both `entity_set` and `entity_delete` raise when nothing answers to the name. Deleting leaves no tombstone: a Component node that still names the entity makes a fresh one from the file if it runs again. Renaming has the same effect, so avoid `entity_rename` on an entity a Component node names: after `entity_rename("Goblin-7", "Steve")` the node finds nothing under `Goblin-7` and makes a fresh goblin from the file. ## Create an entity only if the story has not already [Section titled “Create an entity only if the story has not already”](#create-an-entity-only-if-the-story-has-not-already) `entity_create` is never get-or-create: it raises when the name is taken, so catch the error when you do not mind. ```python try: entity_create("Boss", of="Goblin") except RuntimeError: pass ``` The `except` clause also swallows the error raised when `of` names no component, which is a typo rather than an empty filter, so check the spelling of `of` first. The call answers the new entity’s attributes, so `entity_create("Goblin-7", of="Goblin")["HP"]` reads the starting hit points in one line. ## Collect a component’s values into one dict [Section titled “Collect a component’s values into one dict”](#collect-a-components-values-into-one-dict) 1. Call `component_get("Goblin")`, which answers the definition as its file describes it, or `None`. 2. For each entry in `attributes`, take `items` when it has one (the two lists of paths, `list` and `list`), and `value` otherwise. ```python fields = {} for attr in component_get("Goblin")["attributes"]: if "items" in attr: fields[attr["label"]] = attr["items"] else: fields[attr["label"]] = attr["value"] ``` Each attribute also carries its `type`. Switch on it with a default branch, because the set is open. ## Find the definition behind an entity [Section titled “Find the definition behind an entity”](#find-the-definition-behind-an-entity) `entity_of` answers the component an entity was made from, and `component_get` takes that name: ```python definition = component_get(entity_of("Goblin-1")) ``` ## Related [Section titled “Related”](#related) * [Components and entities](/reference/python/components-and-entities/) lists every function, what it answers and what it refuses. * [Components and entities, explained](/explanation/components-and-entities/) covers what a component and an entity are. * [How to run Python in a story](/how-to/script-a-passage/) covers the Script node these recipes run in. * [The sandbox and its limits](/reference/python/sandbox/) lists what a script may call. # How to build a board > Build a board by creating cards, adding its Start, naming choices, and wiring the story and the values it reads. ![A branching story laid out on a canvas, passages wired to the choices that lead between them](/_astro/board-light.BI1_yn4D_Z1V0sGO.webp) ![A branching story laid out on a canvas, passages wired to the choices that lead between them](/_astro/board-dark.SXOEmDAO_20Hnfl.webp) ## Add a node [Section titled “Add a node”](#add-a-node) Right-click an empty part of the canvas and pick the card from the Create menu — a passage, a Jump, a Script, a loop, a component. To create a card already wired, drag a wire from a port and drop it on empty canvas instead. The same menu opens there, and the card you pick arrives wired to the port you dragged from. Undo and Redo are in the Edit menu and on their keystrokes. **View ▸ Shortcuts card** brings the shortcuts card back. In any picker of something you made — a Jump’s board, a Component’s component, a Script’s `.py`, a Narration node’s passage or soundscape, a State machine’s diagram, a variable on a `cvar` node — the search field opens focused. Type to narrow the list by name or by path, which is shown under each name. Pickers are alphabetical; a Jump’s node picker keeps the board’s reading order. A Jump card has an arrow that opens the board it points at. To see what a card is for, right-click it and choose *About this node*. It lists what each control does and what each port takes or answers, and links to the docs page. ## Add the Start [Section titled “Add the Start”](#add-the-start) A board has exactly one **Start**. Right-click an empty part of the canvas and choose **Start** from the Create menu; the menu does not offer a second. A board with two is refused at Play, with both named. ## Name a choice and give it words [Section titled “Name a choice and give it words”](#name-a-choice-and-give-it-words) A Narration node grows one output port per choice. Wire the last one and a new spare appears beneath it. 1. Click a choice’s row on the card. 2. Enter a **name**. The card shows it as the choice’s label, so a branch can be found on a wide board. 3. Enter its **words** — what the reader is offered — in each language the story is written in. A choice with nothing in the reader’s language falls back to the language the story was written in. To limit how often a choice can be taken, or to offer it only while a condition holds, see [How to limit a choice](/how-to/limit-a-choice/). ## Attach your own notes to a card [Section titled “Attach your own notes to a card”](#attach-your-own-notes-to-a-card) 1. Right-click the card and choose *Edit metadata…*. 2. Add entries, each a name, a type and a value. A small tag appears on the card’s header while the card carries any. Nothing on the board reads metadata; it is yours, for a status, a reviewer’s note or a mark for a tool of your own. It travels with the node into a compiled campaign and to an AI assistant, and a copied node arrives with its notes. ## Draw the wires with the right kind of port [Section titled “Draw the wires with the right kind of port”](#draw-the-wires-with-the-right-kind-of-port) Wire a triangle to a triangle and a dot to a dot: control ports are triangles pointing the way the flow runs and carry the story, data ports are dots and carry a value. Rest the pointer on a value port to see what it takes — `number`, `list`, or `anything`. ## Wire the story and leave the values hanging off it [Section titled “Wire the story and leave the values hanging off it”](#wire-the-story-and-leave-the-values-hanging-off-it) 1. Wire one control path from Start through the cards the story takes: `exec` in, a choice, branch or `completed` out. 2. Wire each value into the card that reads it — a Number into a Set variable, a String into a Component’s name, a Get variable into an Output. None of these needs an `exec` wire. Put any card whose value depends on something the story does in the flow after it, as below. [How a story runs](/explanation/how-a-story-runs/) explains why. ## Make a value depend on something the story does [Section titled “Make a value depend on something the story does”](#make-a-value-depend-on-something-the-story-does) A card whose value depends on something the story does must be in the flow after it. A Comparator hanging off a Dice roller, say, is worked out before there is a roll to compare. 1. Wire the roller’s `completed` into the Comparator. 2. Wire the Comparator’s `completed` into the If. For several values at once, use a **Do all**: 1. Put a **Do all** in front of the passage and one chain of cards per value on its branches. 2. Wire the Do all’s `completed` port to the passage. A branch runs to the end of its wire before the next starts, and a passage does not end a wire, so a passage on one branch holds the later branches back until it has been read. That looks like a Set that never fired; keeping the story on `completed` avoids it. See [How to run several jobs before a passage](/how-to/order-jobs-before-a-passage/). If the app marks a card with [`W.GE.05`](/reference/diagnostics/#wge05) or [`W.GE.06`](/reference/diagnostics/#wge06), a value is being read from before the story or from another path. `W.GE.06` can be answered per card with *I meant that*; see the [diagnostics reference](/reference/diagnostics/). ## Draw wires as right angles [Section titled “Draw wires as right angles”](#draw-wires-as-right-angles) Wires are curved by default. In **Settings ▸ Links**, choose **Right-angled** to draw every wire on every canvas as straight legs with rounded corners. A handle on a wire is then a corner, and lines up with its neighbours as you drag it. ## Related [Section titled “Related”](#related) * [Boards, nodes and macros](/explanation/boards-nodes-and-macros/) — what a board is and why the canvas draws two kinds of wire. * [How a story runs](/explanation/how-a-story-runs/) — two kinds of wire, and why the story only goes forwards. * [Board and navigation rules](/reference/canvas-rules/) — the rules the canvas enforces. * [Nodes](/reference/nodes/) — every node, what it takes and what it answers. * [Diagnostics](/reference/diagnostics/) — the coded checks on a board’s shape. # How to write a passage > Write a Narration node's Markdown, add pictures, and hand the passage values to read with {{ name }}. ## Write the text [Section titled “Write the text”](#write-the-text) 1. Double-click a Narration node to open its editor. 2. Write the passage. To make a sentence vary instead of forking into two passages, put Python in an inline `{{ … }}` span or a fenced ` ```ripple ` block. See [How to run Python in a story](/how-to/script-a-passage/). ## Add a picture [Section titled “Add a picture”](#add-a-picture) * **Drop a file on the editor.** The link writes itself. * **Click Image**, beside Link. Type a few letters to pick a project file by name, or give a URL or a path for anything outside the folder. A picture picked from outside the project is copied into `.ripple/external/` first. If **Import outside assets** is switched off in the File menu, it is linked instead and Messages says so. The alt text and prompt behaviour are in [Passages, choices, languages and reading time](/reference/passages/#writing). ### Write a link by hand [Section titled “Write a link by hand”](#write-a-link-by-hand) Start the path with a slash to measure it from the project root, as in `![map](/art/map.png)`. Without a slash it is read from the bundle’s parent folder. The rules and an example are under [Link spellings](/reference/passages/#link-spellings). ## Hand a passage values to read [Section titled “Hand a passage values to read”](#hand-a-passage-values-to-read) 1. Click the **Reads** row at the bottom of the card, beside the soundscape the node brings. It reads “Reads nothing — click to declare” until a name is declared, then “Reads — N”. 2. In the dialog, declare each name with a type, written the way every other card’s ports are. A slot may carry a default; the types and defaults are in [Node families, slots and state](/reference/node-families/#slots). 3. Wire a value into the port the card now has for each name. 4. Use the name in the passage: `You owe {{ gold }} crowns.` with a number on the `gold` port. A slot with a default and nothing wired uses the default. Declare the names on each card that shows the passage: they belong to the card, not to the passage file (see [Passages and translations](/explanation/passages-and-translations/)). If a name is not wired, a small warning shows where it stood and the rest of the passage renders as usual. Names are offered as you type only when the passage is opened from a board. ### Name a slot [Section titled “Name a slot”](#name-a-slot) Use letters, digits and underscores, and do not start with a digit. If the dialog refuses a name, the reason is shown under the field. The full rule is under [Name rule](/reference/passages/#name-rule). If Messages reports [`W.GE.15`](/reference/diagnostics/#wge15), rename the slot. ## Related [Section titled “Related”](#related) * [Passages and translations](/explanation/passages-and-translations/): why a passage is a folder of Markdown files, one per language. * [Passages, choices, languages and reading time](/reference/passages/): link and name rules, the reading-time figures. # How to write a style rule > Teach the checker your project's house style in Weir — name a rule, say what it catches and what it suggests, test it, and fix one that does not compile. A style rule is a check of your own: a phrase to look for, what to say about it, and what to suggest instead. It is written in Weir, the rule language of Harper, the spelling checker, and it applies to every English passage and note in the project. What it catches is underlined like any other finding, and its suggestion is in the menu. ## Write a rule [Section titled “Write a rule”](#write-a-rule) 1. Open Project settings from the bottom of the activity bar and pick *Writing*. 2. Under *Style rules*, type a name in the field and choose **New rule**. A name is letters, digits, `_` and `-`, starting with a letter or a digit, and no two rules share one, whatever their case. 3. The editor opens on an example rule that already works. Change it to say what you mean; [What a rule says](#what-a-rule-says) is below. 4. Read the line under the editor. *Compiles, and all 2 tests pass.* means the rule works and does what its tests say. 5. Choose **Save**, then Save the settings. ## What a rule says [Section titled “What a rule says”](#what-a-rule-says) ```weir expr main [(magic user), (magic-user)] let message "This setting calls them spellwrights." let description "Suggests the setting's own word." let kind "Style" let becomes "spellwright" test "A magic user knocked at the gate." "A spellwright knocked at the gate." allows "The spellwright knocked at the gate." ``` * `expr main` is what to look for. Words in a row go in `( )`, alternatives in `[ ]` separated by commas. Inside `[ ]`, put a hyphenated word in brackets of its own: `(magic-user)`. * `message` is what the menu says over the underline. * `description` says what the rule is for. * `becomes` is the suggestion. * `kind` sets the colour of the underline: `Style`, or no `kind` at all, is blue; `Grammar`, `Usage` or `WordChoice` is amber; `Spelling` is red. * `test "before" "after"` checks the rule turns the first sentence into the second. `allows "sentence"` checks it leaves one alone. The rest of the language, from parts of speech to exceptions, is on Harper’s [Weir](https://writewithharper.com/docs/weir) page. **Weir reference** in the editor opens it. ## Fix a rule that does not work [Section titled “Fix a rule that does not work”](#fix-a-rule-that-does-not-work) * **A test fails.** The line under the editor lists each failing test, with what it expected and what it got. * **The rule does not compile.** The line under the editor says why, in the checker’s English, and **Save** becomes **Save anyway**. A rule saved like that is marked in red on the *Writing* page and listed in Messages until it compiles; the other rules go on working. A rule is a file, `.ripple/style/.weir`, and can be edited in any text editor as well. The app picks up a change made there. ## Delete a rule [Section titled “Delete a rule”](#delete-a-rule) Choose the bin beside the rule on the *Writing* page, then Save. ## Related [Section titled “Related”](#related) * [How to check spelling and grammar](/how-to/check-spelling-and-grammar/) * [Project files](/reference/project-files/) # C and C++ binding > The C and C++ binding of the Ripple Effect engine: the CMake project, its options and targets, the C++17 layer's every type and member, and the C ABI's conventions and functions. The C and C++ binding is a CMake project that fetches the pinned engine release for the target you are building, refuses it unless its SHA-256 is the pinned one, and adds `ripple/ripple.hpp`, a header-only C++17 layer over the engine’s C ABI. A C host uses the C header, `include/ripple/ripple.h` inside the engine archive, directly. Version 1.5.x binds Ripple Effect contract 1.5; the `PATCH` is the binding’s own. The source is public at [ConnectingTheDots-DCWK/ripple\_effect\_c\_cpp\_integration](https://github.com/ConnectingTheDots-DCWK/ripple_effect_c_cpp_integration). The binding is MIT. The engine library and `ripple.h` are under the [Ripple Effect Runtime Licence](https://legal.ripplefx.app/runtime/), whose one condition is that the engine archive’s `NOTICE` is reproduced wherever your product lists the third-party software it contains. | Release | Engine pinned | Added | | ------- | ------------- | ---------------------------------------------------------------------------------------- | | 1.5.0 | 1.5.2 | The C ABI, the C++17 layer, `Player`, `SoundscapeDriver`, CMake that fetches the engine. | | 1.5.1 | 1.5.3 | The web, through Emscripten. | | 1.5.2 | 1.5.3 | iOS, device and simulator. | ## Packages and installing [Section titled “Packages and installing”](#packages-and-installing) No package manager carries the binding. Take it one of three ways. ### FetchContent [Section titled “FetchContent”](#fetchcontent) ```cmake include(FetchContent) FetchContent_Declare( ripple_effect GIT_REPOSITORY https://github.com/ConnectingTheDots-DCWK/ripple_effect_c_cpp_integration GIT_TAG v1.5.2) FetchContent_MakeAvailable(ripple_effect) target_link_libraries(my_game PRIVATE Ripple::cpp) ``` Or the release’s source tarball, pinned by the digest in that release’s `SHA256SUMS`: ```cmake FetchContent_Declare( ripple_effect URL https://github.com/ConnectingTheDots-DCWK/ripple_effect_c_cpp_integration/releases/download/v1.5.2/ripple_effect_c_cpp_integration-1.5.2.tar.gz URL_HASH SHA256=) ``` ### A submodule [Section titled “A submodule”](#a-submodule) ```sh git submodule add https://github.com/ConnectingTheDots-DCWK/ripple_effect_c_cpp_integration third_party/ripple_effect git -C third_party/ripple_effect checkout v1.5.2 ``` ```cmake add_subdirectory(third_party/ripple_effect) target_link_libraries(my_game PRIVATE Ripple::cpp) ripple_effect_copy_runtime(my_game) # the DLL beside the .exe, on Windows ``` As a subproject it builds no tests and no examples and downloads nothing but the engine. ### find\_package, after an install [Section titled “find\_package, after an install”](#find_package-after-an-install) ```sh cmake --preset linux-clang && cmake --build --preset linux-clang cmake --install out/build/linux-clang --prefix /opt/ripple_effect ``` ```cmake find_package(RippleEffect 1.5 REQUIRED) # CMAKE_PREFIX_PATH=/opt/ripple_effect target_link_libraries(my_game PRIVATE Ripple::cpp) ``` The package version is **SameMinorVersion**: a request for 1.5 takes any 1.5.x and nothing else. The installed tree: | Path | Holds | | ------------------------------------------------------ | ---------------------------------------------------------------------- | | `include/ripple/ripple.h`, `include/ripple/ripple.hpp` | The C header and the C++ layer. | | `lib/` | The library; on Windows the shared library’s import library. | | `bin/` | The Windows DLL. | | `lib/cmake/RippleEffect/` | The package config, and `native-static-libs.txt` for a static install. | | `share/ripple_effect/ENGINE-NOTICE` | The engine’s `NOTICE`. | The package config also sets `RippleEffect_ENGINE_VERSION` and `RippleEffect_ENGINE_TRIPLE`. ### Targets [Section titled “Targets”](#targets) | Target | What it is | | ---------------- | ------------------------------------------------------------------------------------------------------------------------------------------ | | `Ripple::engine` | The C library, shared or static, with `ripple/ripple.h` on its include path. A static link carries the system libraries the archive lists. | | `Ripple::cpp` | `Ripple::engine`, and `ripple/ripple.hpp` over it. Requires C++17. | ### Options and cache variables [Section titled “Options and cache variables”](#options-and-cache-variables) | Name | Default | What it does | | --------------------------- | ------------------------------------ | -------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `RIPPLE_ENGINE_PYTHON` | `ON` | The engine with the Python interpreter. `OFF` links the `no-python` engine. | | `RIPPLE_ENGINE_SHARED` | `ON` | The shared library. `OFF` links the static one and the system libraries its archive lists in `lib/native-static-libs.txt`. Forced `OFF` on iOS and under Emscripten. | | `RIPPLE_ENGINE_TARGET` | empty: worked out from the toolchain | The engine’s triple, for a toolchain the configure cannot name. | | `RIPPLE_ENGINE_DIR` | empty | An unpacked `ripple-engine-c` archive, for an offline, vendored or separately audited build. Nothing is downloaded. | | `RIPPLE_ENGINE_CACHE` | `/_ripple_engine` | Where downloaded archives are kept, to share them between build trees. | | `RIPPLE_ENGINE_TREE` | empty | A checkout of the engine’s own source, built with cargo. For the engine’s developers: the result is marked UNVERIFIED and a release of the binding refuses it. | | `RIPPLE_BUILD_TESTS` | `ON` when top-level | This repository’s tests. | | `RIPPLE_BUILD_EXAMPLES` | `ON` when top-level | The two example players. | | `RIPPLE_INSTALL` | `ON` when top-level | The install rules and the package config. | | `RIPPLE_WARNINGS_AS_ERRORS` | `OFF` | Warnings as errors, for this repository’s own targets. | | `RIPPLE_SANITIZE` | empty | `address` or `thread`, for this repository’s own targets. | After the configure, `RIPPLE_ENGINE_VERSION`, `RIPPLE_ENGINE_TRIPLE`, `RIPPLE_ENGINE_ROOT` (where the archive was unpacked) and `RIPPLE_ENGINE_UNVERIFIED` are set, and the configure prints the binding’s version, the engine’s, the triple, the variant and the linkage. | CMake function | What it does | | -------------------------------------- | ----------------------------------------------------------------------------------------------------------------- | | `ripple_effect_copy_runtime()` | Copies the engine’s DLL beside `` after it links. Does nothing off Windows and nothing for a static link. | ### Where the engine comes from [Section titled “Where the engine comes from”](#where-the-engine-comes-from) The pinned release is `engine-v` of the public [ripple\_engine\_releases](https://github.com/ConnectingTheDots-DCWK/ripple_engine_releases), whose signed manifest names every archive. The binding’s `engine.json` holds the version, the signing key’s id and every archive’s digest. A download is tried from `https://ripplefx.app/go/engine//` first and the GitHub release second; an archive whose digest is not the pinned one is refused. An archive is `ripple-engine-c-[-no-python]-.tar.gz` and holds: | Path | Holds | | ---------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | | `include/ripple/ripple.h` | The C header. The binding never copies it, so the header you compile against is always the library’s own. | | `lib/` | `libripple_effect_engine.so`, `.dylib` or `.a`; on Windows `ripple_effect_engine.dll`, its import library `ripple_effect_engine.dll.lib`, and the static `ripple_effect_engine.lib`. | | `lib/native-static-libs.txt` | The system libraries a static link needs. | | `NOTICE` | The notice the Runtime Licence asks you to reproduce. | ### Requirements [Section titled “Requirements”](#requirements) | | | | ------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | | CMake | 3.24 or later. The presets file is version 5. | | C | `ripple.h` is C99. | | C++ | `ripple.hpp` is C++17. | | Presets | `linux-gcc`, `linux-clang`, `linux-asan`, `linux-static`, `linux-no-python`, `macos-clang`, `windows-msvc` (from a developer prompt), `android-arm64`, `ios-arm64`, `ios-simulator`, `emscripten`, `emscripten-no-python`. Each builds under `out/build/`. | ### Under Emscripten [Section titled “Under Emscripten”](#under-emscripten) Configure with Emscripten’s toolchain file, through `emcmake cmake` or the `emscripten` preset; the `wasm32-unknown-emscripten` archive is fetched and linked statically. The engine asks for no exception flag. `ripple.hpp` reports a refusal by throwing, so a C++ host that catches `ripple::Error` compiles and links with `-fwasm-exceptions`, adding `-sSUPPORT_LONGJMP=wasm` for Emscripten 4.0.10, as `examples/cpp_player` does; one Emscripten link takes one exception model. Without exceptions, define `RIPPLE_HPP_NO_EXCEPTIONS`. ## Choosing the variant [Section titled “Choosing the variant”](#choosing-the-variant) The variant is a configure-time choice: `RIPPLE_ENGINE_PYTHON=ON` links the engine with the Python interpreter and `OFF` links the `no-python` engine, about half the size. * **Opening.** The `no-python` engine refuses a campaign that needs Python when it is opened, with `RIPPLE_ERROR_NEEDS_PYTHON` (`ripple::NeedsPython`). A campaign exported with **Needs no Python** is checked for exactly that host. * **Asking.** `ripple::engine_has_python()` (`ripple_engine_has_python()`) answers which library is loaded; `Session::python_available()` answers for a session. A manifest’s `needs` lists `python` when the campaign runs scripts. * **The console.** `Session::console_feed` on the `no-python` engine answers a failure of kind `RIPPLE_PYTHON_FAILURE_KIND_UNAVAILABLE`. ## Platforms [Section titled “Platforms”](#platforms) | Platform | Architectures | Floor | Notes | | --------------------------- | ----------------------------- | -------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | Linux | x86\_64, aarch64 | glibc 2.35 | The soname is `libripple_effect_engine.so`. Ship it beside your binary with an `$ORIGIN` RPATH, or link statically. | | macOS | x86\_64, arm64 | 12.0 | One architecture per configure; the release has no universal library, so build twice and `lipo`. The install name is `@rpath/libripple_effect_engine.dylib`. | | Windows | x64, arm64 | | MSVC’s ABI only; MinGW is refused. The shared engine is a DLL with an import library. The static library is built against the dynamic CRT (`/MD`). | | Android | arm64-v8a, x86\_64 | API 24 | With the NDK’s toolchain file. The `.so` goes in your APK’s `jniLibs`; the static library links into your own native library. | | iOS | arm64 device, arm64 simulator | 13.0 | Static only. `CMAKE_SYSTEM_NAME=iOS` fetches `aarch64-apple-ios`; adding `CMAKE_OSX_SYSROOT=iphonesimulator` fetches `aarch64-apple-ios-sim`. A simulator runtime’s own minimum may raise the deployment target. | | The web, through Emscripten | wasm32 | Emscripten 4.0 | Static only. From engine 1.5.3. | On Emscripten: * **Emscripten 4.0 or later.** Each release is linked and played with 4.0.10, 4.0.19 and the newest it was built with. Before 4.0 the optimiser rejects a feature the engine’s objects carry, and 3.1.38 links and then cannot open a bundle’s folder, with nothing reporting it. * **Unity** 6000.5 and later bundle Emscripten 4.0.19 and are supported; 6000.0 to 6000.4 bundle 3.1.38 and are not. The archive’s `.a` is the plugin. * **.NET in the browser is not supported**: its `wasm-tools` workload links with an Emscripten below the floor. * **Single-threaded.** A program linked with `-pthread` refuses the library. * **A panic aborts the module** rather than unwinding. A trap is not recoverable in place: the page reopens and resumes from its last checkpoint. * **Open a campaign from memory.** A page has no folder; fetch the bundle’s files and pass them to `Campaign::open_files` (`ripple_campaign_open_memory`). A folder works under `-sNODERAWFS` or in Emscripten’s virtual filesystem. * **Size.** A linked program with the interpreter is about 5.4 MB of WebAssembly, 2.0 MB gzipped; without it, about 0.8 MB and 0.3 MB. The full matrix of what each engine release ships is in [Engine targets](/reference/engine-targets/). ## The engine [Section titled “The engine”](#the-engine) | C++ | C | Answers | | -------------------------------------------------- | ------------------------------------------------ | ------------------------------------------------------------ | | `ripple::engine_version()` | `ripple_engine_version()` | The loaded library’s version, not the one built against. | | `ripple::engine_contract()` | `ripple_engine_contract()` | The contract the loaded library binds, `1.5`. | | `ripple::engine_has_python()` | `ripple_engine_has_python()` | False for the `no-python` library. | | | `ripple_bundle_format()` | The bundle format the engine writes, and reads up to. | | | `ripple_save_format()` | The save format the engine writes. | | `ripple::contract_major`, `ripple::contract_minor` | `RIPPLE_CONTRACT_MAJOR`, `RIPPLE_CONTRACT_MINOR` | The contract the header binds, `1` and `5`, at compile time. | The C++ functions return `std::string_view` over strings the library owns; the C ones return NUL-terminated `const char *`. An engine of the same `MAJOR.MINOR` keeps every function, struct and constant of the header. `ripple.hpp` fails to compile with `#error` against a `ripple.h` of another contract. | Constant | Value | Meaning | | -------------------------------- | ------------ | -------------------------------------------------------------------------------------- | | `RIPPLE_CHOICE_NONE` | `4294967295` | The choice index that means the reader is leaving the passage; `ripple::leave` in C++. | | `RIPPLE_MIXER_DEFAULT_MAX_CUES` | `8` | Cues that may sound at once; the oldest gives way. | | `RIPPLE_MIXER_DEFAULT_GAIN_STEP` | `0.004` | The smallest change of gain worth a `gain` command. | ## Campaign [Section titled “Campaign”](#campaign) `ripple::Campaign` is a bundle, opened: a move-only handle shared by every session made from it. It may be destroyed before its sessions, which go on working. | Member | Answers | Notes | | -------------------------- | ------------------------------------------ | ----------------------------------------------------------------------------------------------------------------------- | | `static open_dir(folder)` | `Campaign` | The folder that holds `campaign.json`. Files are read as the story asks for them, never from outside the folder. | | `static open_files(files)` | `Campaign` | A bundle in memory: a `std::vector`, each a `path` inside the bundle and its `bytes`. The bytes are copied. | | `handle()` | `RippleCampaign*` | The C handle, for a call the layer does not wrap. | | `folder()` | `const std::optional&` | The folder it was opened from; none for a bundle in memory. | | `manifest()` | `Manifest` | | | `board(index)` | `Board` | By index into the manifest’s boards. | | `soundscape(index)` | `Soundscape` | By index into the manifest’s soundscapes. | | `board_with_id(id)` | `std::optional` | The index of the board with that catalog id. | | `node(board, id)` | `std::optional` | A node of a board, by its id. | | `validate()` | `std::vector` | Everything wrong with the bundle as a whole; empty when it is sound. Each has a `message` and a `location`. | | `media_path(index)` | `std::optional` | A medium’s path inside the bundle; never absolute and never leaving it. | | `media_file(index)` | `std::optional` | The folder joined with the path, for a campaign opened from a folder; none otherwise. Never fails. | | `media_bytes(index)` | `std::optional>` | A medium’s bytes, read whole; none when the bundle does not hold it. A long track is better streamed from its path. | ### Manifest [Section titled “Manifest”](#manifest) `campaign.json`, table by table. | Field | Type | Meaning | | --------------------------------------------------------------------------------- | ---------------------------- | ------------------------------------------------------------------------------------------------------------------------------- | | `format` | `std::int64_t` | The bundle format. | | `generator` | `std::optional` | Who compiled it. | | `text_format` | `std::string` | `markdown` or `html`: what a passage’s text is. | | `project` | `Project` | `title`, `description`, `default_locale`, `locales`, and `cover`, the cover picture’s path inside the bundle when there is one. | | `entries` | `std::vector` | The entry boards, by index into `boards`. | | `boards`, `passages`, `components`, `scripts`, `media`, `machines`, `soundscapes` | `std::vector` | One row each: `id`, `name`, and `path`, the document’s path inside the bundle (for a medium, the file’s). | | `cvars` | `std::vector` | The console variables declared: `name`, `type`, `default_value`. | | `needs` | `std::vector` | What a host must have to play it: `python`, when it runs scripts. | ### Board, Node, Port [Section titled “Board, Node, Port”](#board-node-port) | Type | Fields | | ------- | ----------------------------------------------------------------------------------------------- | | `Board` | `id`, `name`, `start` (`std::int64_t`), `prime` (`std::vector`), `nodes`. | | `Node` | `id`, `type`, `title`, `inputs`, `outputs` (`std::vector`), `data`, `metadata` (`Value`). | | `Port` | `id`, `kind` (`PortKind::control` or `PortKind::data`), `edges`. | | `Edge` | `node`, `port`: one end of a wire, by index on its board. | ### Soundscape and Track [Section titled “Soundscape and Track”](#soundscape-and-track) A soundscape is a timeline of clips a passage plays. Times are milliseconds. | `Soundscape` field | Type | | ------------------------------- | ----------------------- | | `id`, `name` | `std::string` | | `length`, `fade_in`, `fade_out` | `std::int64_t` | | `looping`, `tied` | `bool` | | `volume` | `double`, default `1.0` | | `tracks` | `std::vector` | | `Track` field | Type | Meaning | | --------------------------------- | ----------------------------- | ------------------------------------------------------------ | | `media` | `std::int64_t` | The medium, by index into the manifest’s media. | | `lane`, `start`, `clip_start` | `std::int64_t` | | | `clip_end` | `std::optional` | | | `volume`, `speed` | `double`, default `1.0` | | | `fade_in`, `fade_out`, `interval` | `std::int64_t` | | | `mode` | `TrackMode` | `once`, `looping` or `automatic` (`RIPPLE_TRACK_MODE_AUTO`). | ## Session [Section titled “Session”](#session) `ripple::Session` is one story being played, a move-only handle. Every verb answers the effects it led to, in the order they are applied, the Turn last. Destroyed with its object. ### Creating and resuming [Section titled “Creating and resuming”](#creating-and-resuming) | Member | Answers | Notes | | ----------------------------------------------------------------- | ---------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `static create(campaign, config)` | `Session` | Before its first turn: call `start()` next. | | `static resume(campaign, save, seed, options = SessionOptions())` | `Session` | A save, as `pause()` or `checkpoint()` answered it, over the same campaign. Call `start()` next; it answers the turn the save was on. Refused with `ResumeError` when the save names a board or node the campaign no longer has. | | `handle()` | `RippleSession*` | The C handle. | `SessionConfig`, where a new session begins: | Field | Type | Default | Meaning | | ------------ | ---------------------------- | ------------ | ------------------------------------------------------------------------------ | | `board` | `std::uint32_t` | `0` | The board, by index into the manifest’s boards; usually `manifest.entries[0]`. | | `entry_node` | `std::optional` | none | A node to begin at instead of the board’s start. | | `locale` | `std::optional` | none | The language to read in; none for the default. | | `seed` | `std::uint64_t` | `0` | The dice: the same seed rolls the same story. | | `digest` | `std::optional` | none | The bundle digest a save made from this session is stamped with. | | `options` | `SessionOptions` | the engine’s | | `SessionOptions`, the engine’s budgets and policies. A default-constructed `SessionOptions` holds the engine’s own values, read from `ripple_session_options_default`. | Field | Type | Default | Meaning | | ------------------------ | --------------- | ------------ | --------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `step_budget` | `std::uint32_t` | the engine’s | Steps a run may take before it is stopped; `0` stops on the first. | | `silent_jump_limit` | `std::uint32_t` | the engine’s | Silent jumps between boards before the story is stopped. | | `loop_limit` | `std::uint32_t` | the engine’s | Times a loop may go round; `0` is no ceiling. | | `script_deadline_ms` | `std::uint64_t` | the engine’s | How long a script may run. | | `script_memory_bytes` | `std::uint64_t` | the engine’s | How much memory a script may take. | | `script_recursion_depth` | `std::uint32_t` | the engine’s | How deep a script may recurse. | | `untranslated` | `Untranslated` | `report` | A passage missing in the reader’s language: `report` gives empty text and names the language; `fall_back` gives the default language’s text and still names it. | | `trace` | `bool` | `false` | Whether verbs answer `Trace` effects. | | `trace_payloads` | `bool` | `false` | Whether a traced value carries its payload. | `raw()` answers the options as a `RippleSessionOptions`. ### Verbs [Section titled “Verbs”](#verbs) | Verb | Answers | Notes | | ------------------------- | ---------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `start()` | `Effects` | The story’s first turn; on a resumed session, the turn it was saved on. A verb before it is refused with `nothing_asked`. | | `choose(index)` | `Effects` | Answers a `Show`: the choice by index into its `choices`, or `ripple::leave` for the reader leaving the passage. An index past the offered ones is `no_such_choice`; a choice whose `available` is false is `choice_spent`. | | `answer(values)` | `Effects` | Answers an `Ask`: a `Value::Map` of the form’s fields by name, or `std::nullopt` for the reader leaving it. | | `elapsed()` | `Effects` | Answers a `Hold`, once the host’s clock says it is over. | | `set_locale(locale)` | `Effects` | The turn the story is on, answered again in the new language. A language the campaign is not readable in is `unknown_locale`. | | `restart()` | `Effects` | The story over from its first turn, the same seed rolling again. | | `stop()` | `Effects` | Stops the story for good; a verb after it is `stopped`. | | `pause()` | `Paused` | `save`, the save as JSON text (none when there was nothing to save), and `effects`, what pausing did. Pausing stops the story; resuming is a new session from the save. | | `checkpoint()` | `std::optional` | A save of the turn the story is on, without stopping it; none unless the story is on a passage or a form. | | `console_feed(code)` | `ConsoleResult` | A line of Python run against the story, as the editor’s console runs it: `result` and `effects`. | | `write_cvar(name, value)` | `CvarChange` | Writes a console variable, coerced to its type. | | `reset_cvar(name)` | `CvarChange` | Puts a console variable back to its default. | | `reset_all_cvars()` | `Effects` | Puts every console variable back to its default. | | `remove_entity(key)` | `EntityRemoval` | `removed`, and `effects`. | The answers that carry effects beside a result: | Type | Fields | | --------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `CvarChange` | `outcome` (`CvarWrite::done`, `unknown` for a variable the campaign does not declare, `refused` for a value its type does not take), `refusal` (why), `effects`. | | `EntityRemoval` | `removed`, `effects`. | | `ConsoleResult` | `result` (`PythonResult`), `effects`. | | `PythonResult` | `value`, `output` (what it printed), `error` (`std::optional`, none when it finished). | | `PythonFailure` | `kind` (a `RipplePythonFailureKind`: `PYTHON`, `TIMEOUT`, `REFUSED`, `CLOSED`, `HOST_ERROR`, `UNAVAILABLE`), `exception` (the exception’s type, for `PYTHON`), `message`, `line`, `traceback`. | ### Readers [Section titled “Readers”](#readers) | Member | Answers | Notes | | ---------------------------------- | ---------------------------- | ------------------------------------------------------------------------------------------------------------------------- | | `board()` | `std::uint32_t` | The board the story is on, by index into the manifest’s boards. | | `locale()` | `std::string` | The language the story reads in. | | `default_locale()` | `std::string` | The campaign’s default language. | | `ambience()` | `std::optional` | The soundscape in force; none for silence. | | `entities()` | `std::vector` | Every entity the story holds. | | `memory()` | `Memory` | What the story remembers. | | `cvars()` | `std::vector` | Every console variable, with its value. | | `guard()` | `GuardReport` | What the script guard has had to do so far: `abandoned` (the nodes whose scripts were abandoned), `refused`, `exhausted`. | | `python_available()` | `bool` | False on the `no-python` engine. | | `describe_cvar_write(name, value)` | `std::optional` | Why a console-variable write would be refused; none when it would be taken. | | `set_trace(enabled)` | `void` | Turns `Trace` effects on or off. | | `set_trace_payloads(enabled)` | `void` | Turns a traced value’s payload on or off. | ### The generator [Section titled “The generator”](#the-generator) A save carries no generator state and no soundscape position: the same seed gives the same future rolls, and a soundscape starts from the top. To carry the dice’s exact continuation across a resume, read the old session’s `generator()` and `set_generator` it on the new one. | Member | Answers | | ---------------------- | ------------------------------------------------ | | `generator()` | `Generator`, two `std::uint32_t`: `lo` and `hi`. | | `set_generator(state)` | `void` | ## Effects [Section titled “Effects”](#effects) `ripple::Effect` is a `std::variant` of twelve alternatives, in the engine’s order; `ripple::Effects` is a `std::vector`. Apply a batch in order. The last effect of a batch is its Turn. Each effect owns its strings: the C batch is copied and freed before the verb returns. | Alternative | Turn | Fields | Meaning | | ----------------- | ---- | ---------------------------------------------------------------- | ----------------------------------------------------------------------------------------- | | `Trace` | | `event` (`RunEvent`) | An event of the run, when tracing is on. | | `Callback` | | `event` (`CallbackEvent`) | A named callback the author declared. | | `Jumped` | | `board` (`std::uint32_t`), `node` (`std::optional`) | The story moved to another board. | | `AmbienceChanged` | | `ambience` (`std::optional`) | The soundscape now in force; none for silence. | | `Audio` | | `command` (`AudioCommand`) | What the speaker should do, for the mixer. | | `Changed` | | `entities`, `memory`, `cvars`, each optional | Which stores moved; a store that did not is absent. | | `Show` | yes | `passage` (`Passage`) | A passage. Answer with `choose`. | | `Ask` | yes | `prompt` (`Prompt`) | A form. Answer with `answer`. | | `Hold` | yes | `ms` (`std::uint64_t`) | A wait. Call `elapsed` once your clock says it is over. | | `Ended` | yes | `title` (`std::optional`), `ran_out` (`bool`) | The story is over. `ran_out` is true when it ran out of road rather than reaching an End. | | `Failed` | yes | `message`, `node` (`std::optional`) | The story could not go on. | | `Stopped` | yes | none | The session stopped. | | Function | Answers | | ----------------- | ----------------------------------------------------------------------- | | `is_turn(effect)` | Whether the effect is a Turn. | | `is_over(effect)` | Whether a Turn is the end of the story: `Ended`, `Failed` or `Stopped`. | ### Records [Section titled “Records”](#records) **`Passage`**, a passage the reader is on: | Field | Type | Meaning | | --------------- | ---------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | | `node`, `title` | `std::string` | | | `text` | `std::string` | Markdown, or HTML when the manifest’s `text_format` says so. | | `choices` | `std::vector` | Each: `port` (the control output the flow leaves by), `text`, and `available`, false for a choice the author limited and the reader used up. A spent choice is still offered so the reader can see it. | | `sources` | `std::optional>` | The text in every language, each a `locale` and a `text`, when the compiler kept them. | | `snippets` | `std::vector` | Python in the prose: `code`, `is_inline`. | | `media` | `std::vector` | Every link in the text that names a medium: `link` as the author wrote it, and `media`, by index into the manifest’s media. | | `untranslated` | `std::optional` | The language the text is missing in, when it is. | | `soundscape` | `std::optional` | The soundscape the passage names. | | `silence` | `bool` | True when the passage asks for silence. | **`Prompt`**, a form the story asks the reader to fill: `node`, `title`, and `fields`, each a `PromptField` with `name`, `type` and `default_value` (`Value`). **`Ambience`**, the soundscape in force: `soundscape`, `board` (`std::uint32_t`), and `node`, the node that set it. **`AudioCommand`**, what the session asks the speaker: | Field | Type | Meaning | | ----------------- | ---------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------- | | `kind` | `AudioKind` | `play` a soundscape, fading in over `fade_ms`; `stop` what is sounding, fading out over `fade_ms`; `cut` everything now, cues included; `cue` one sound. | | `soundscape` | `std::optional` | `play`: what to play. | | `fade_ms` | `std::uint64_t` | `play`: the fade-in. `stop`: the fade-out. | | `media` | `std::uint32_t` | `cue`: the medium, by index into the manifest’s media. | | `volume`, `speed` | `double`, default `1.0` | `cue`. | | `node` | `std::optional` | `cue`: the node that asked for it, when one did. | **`CallbackEvent`**, a named callback fired. Which fields are set depends on `event`. | Field | Type | Meaning | | -------------- | ------------------------------ | -------------------------------------------------------------- | | `callback` | `std::string` | The name the author gave it, which a handler is registered by. | | `event` | `std::uint32_t` | A `RippleCallbackKind`. | | `board` | `std::optional` | | | `node`, `name` | `std::optional` | | | `from`, `to` | `std::optional` | | | `index` | `std::optional` | | | `event_name()` | `std::string_view` | The event in the author’s vocabulary, such as `cvar.changed`. | **`Entity`**: `key`, `component` (`std::optional`), `label`, and `values`, a map of its fields. **`Memory`**: `nodes`, a map node by node, and `machines`, a `std::vector` of (machine, state) pairs. **`CvarSnapshot`**: `name`, `type`, `default_value`, `value`. **`RunEvent`**, one event of the run’s trace, read by `kind`; a field another kind does not use is empty. | Field | Set by | Meaning | | ----------------------------------- | ------------------------------------------ | -------------------------------------------------------------------------------------------------------------------------------------------------------- | | `kind` | all | `RunEventKind`: `run_started`, `node_started`, `node_finished`, `memo_hit`, `diagnostic`, `log`, `run_finished`. | | `run`, `board`, `sequence` | all | Where in a run the event happened. | | `node` | node started, node finished, memo hit, log | | | `step` | node started, node finished, log | | | `entry` | run started | The node the run entered by. | | `primed` | run started | The nodes primed before it. | | `entered_via` | node started | | | `pulled` | node started | Whether it ran because a data edge pulled it. | | `inputs` | node started | `std::vector`. | | `outcome` | node finished | A `RippleTurnOutcome`: `DONE` or `FAILED`. | | `outputs` | node finished | `std::vector`. | | `flowed` | node finished | The control ports the flow left by. | | `error` | node finished, run finished | | | `diagnostic` | diagnostic | `RunDiagnostic`: `issue` (a `RippleRunIssue`: `AMBIGUOUS_FLOW`, `MULTIPLE_INPUTS`, `PRODUCER_NOT_RUN`, `REENTERED`), `message`, `node`, `port`, `wires`. | | `level` | log | A `RippleLogLevel`: `DEBUG`, `INFO`, `WARNING`, `ERROR`. | | `message` | log | | | `data` | log | `std::optional`; none when the node logged no data. | | `steps`, `cancelled`, `failed_node` | run finished | | **`TraceValue`**, a value on a wire as the trace saw it: `from_node`, `from_port`, `to_node`, `to_port`, `payload` (`PayloadKind::present` when `value` is what crossed, `withheld` when a value crossed and payloads are not traced, `absent` when nothing crossed), and `value`, set when `payload` is `present`. ## Values [Section titled “Values”](#values) `ripple::Value` is a story’s value. An Int and a Float never become each other, and a map keeps the order it was written in. | Story type | `Value` holds | C `RippleValueKind` | C field | | ---------- | ------------------------------------------------------------ | --------------------------- | -------------------------------------------- | | null | `std::nullptr_t` | `RIPPLE_VALUE_KIND_NULL` | | | Bool | `bool` | `RIPPLE_VALUE_KIND_BOOLEAN` | `data.boolean` | | Int | `std::int64_t` | `RIPPLE_VALUE_KIND_INTEGER` | `data.integer` | | Float | `double` | `RIPPLE_VALUE_KIND_REAL` | `data.real` | | text | `std::string` | `RIPPLE_VALUE_KIND_TEXT` | `data.text` | | list | `Value::List`, a `std::vector` | `RIPPLE_VALUE_KIND_ITEMS` | `data.items`: child indices | | map | `Value::Map`, a `std::vector>` | `RIPPLE_VALUE_KIND_ENTRIES` | `data.entries`: a key and a child index each | | Member | Answers | | ---------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | Constructors | From `nullptr`, `bool`, any integral type (stored as `std::int64_t`), `double`, `std::string`, `const char*`, `std::string_view`, `List`, `Map`. A default `Value` is null. | | `is_null()`, `is_bool()`, `is_int()`, `is_float()`, `is_text()`, `is_list()`, `is_map()` | Which it holds. | | `get_if()` | A pointer to the held `T`, or `nullptr`. | | `storage()` | The underlying `std::variant`. | | `find(key)` | A map’s value under `key`, or `nullptr`, also for anything that is not a map. | | `ripple::as_text(value)` | The value as text, the way a passage would print it. | ### The arena in C [Section titled “The arena in C”](#the-arena-in-c) A C value is a `RippleValue`: `nodes`, a flat array of `RippleValueNode`, and `count`. The root is node 0. Each node is a `uint32_t kind` and a `union data` read by it; a list’s or a map’s children are indices into the same array. An empty arena is null. A child index that is not after its parent, or is past the end, reads as null, so an arena cannot loop. A host builds one arena per value it hands in; `RippleFields` is a list of `RippleField`, each a `key` and a `value`, for an answer’s fields. ## Player [Section titled “Player”](#player) `ripple::Player` is the loop over a session, written once. It delivers every effect in order, keeps the last Turn, answers a `Hold` from its clock when `update()` finds it due, applies `Audio` effects to a `SoundscapeDriver`, and dispatches the author’s named callbacks. ```cpp Player(Session session, PlayerHandlers handlers, Clock now = {}); ``` The player owns the session. With no clock it reads `ripple::steady_clock()`. `PlayerHandlers`: | Field | Type | Meaning | | ----------- | ------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `on_effect` | `std::function` | Every effect, in the order the engine produced it. | | `on_error` | `std::function` | A failure of a verb the player made by itself, the `elapsed` after a hold, or a handler’s exception. Delivery goes on either way. A handler’s exception that is not a `ripple::Error` arrives as `Code::invalid_argument` with its `what()`. | | `audio` | `SoundscapeDriver*` | Where the story’s sound goes; without one, nothing is heard. | | Member | Answers | Notes | | ----------------------------------------------------------------------------------------- | ------------------------------ | ---------------------------------------------------------------------------------------------------------------------------------- | | `start()`, `choose(index)`, `answer(values)`, `set_locale(locale)`, `restart()`, `stop()` | `void` | The session’s verbs, their effects delivered through the handlers. Each clears a pending hold. | | `pause()` | `std::optional` | Puts the story down and answers its save: none, and nothing stopped, unless the story is on a passage or a form. | | `update()` | `void` | Call from your loop. Answers a hold whose time has come. With exceptions and no `on_error`, a failure of that `elapsed` is thrown. | | `turn()` | `const std::optional&` | The last Turn delivered. | | `hold_due()` | `std::optional` | When the pending hold is due, by the player’s clock. | | `session()` | `Session&` | The session, for what the player does not wrap. A verb called on it goes round the player. | | `on_callback(name, handler)` | `void` | Calls `handler` whenever the callback named `name` fires, replacing any handler under that name. | | `remove_callback(name)` | `void` | | | `on_any_callback(handler)` | `void` | Calls `handler` for every callback that fires, registered or not. | | `clear_callbacks()` | `void` | Removes both kinds. | | `callback_misses()` | `std::uint64_t` | How many callbacks fired with nothing registered under their name. | | `dispose()` | `void` | Delivers nothing more. A verb after it does nothing. | For each effect of a batch, in order, the player records it if it is a Turn, applies it to `audio` if it is `Audio`, dispatches it if it is `Callback`, then calls `on_effect`. A verb called on the player from inside a handler is refused with `Code::reentrant` (`ripple::ReentrantVerb`), because the rest of the batch would arrive after the new one: queue it and call it once the handler returns. `Clock` is `std::function`, milliseconds, monotonic. `ripple::steady_clock()` answers one over `std::chrono::steady_clock`, starting at zero. ## Named callbacks [Section titled “Named callbacks”](#named-callbacks) The author declares a callback’s name and event; the engine emits a `Callback` effect when it fires; the host registers a handler by name. The engine never sees the handler table, and a callback cannot change the story. | Rule | | | ------------------------ | ----------------------------------------------------------------------------------------------------------------------------------------------------------------- | | Registering | `Player::on_callback(name, handler)`; a `CallbackHandler` is `std::function`. A C host matches `RippleCallbackEvent.callback` itself. | | A name nobody registered | Ordinary, not a failure. `Player` counts it in `callback_misses()`. | | A handler that throws | Absorbed: handed to `on_error`, and the rest of the batch still delivered. | | A verb from a handler | Refused with `ReentrantVerb`; queue it. | | The event’s name | `CallbackEvent::event_name()`, or `ripple_callback_kind_name(kind)` in C, which answers NULL for a value that is not a `RippleCallbackKind`. | | `RippleCallbackKind` | Value | Name | | -------------------------------------------- | ----- | ----------------------- | | `RIPPLE_CALLBACK_KIND_STORY_STARTED` | 0 | `story.started` | | `RIPPLE_CALLBACK_KIND_BOARD_ENTERED` | 1 | `board.entered` | | `RIPPLE_CALLBACK_KIND_SOUNDSCAPE_CHANGED` | 2 | `soundscape.changed` | | `RIPPLE_CALLBACK_KIND_PASSAGE_SHOWN` | 3 | `passage.shown` | | `RIPPLE_CALLBACK_KIND_CHOICE_TAKEN` | 4 | `choice.taken` | | `RIPPLE_CALLBACK_KIND_DELAY_STARTED` | 5 | `delay.started` | | `RIPPLE_CALLBACK_KIND_DELAY_ELAPSED` | 6 | `delay.elapsed` | | `RIPPLE_CALLBACK_KIND_STORY_ENDED` | 7 | `story.ended` | | `RIPPLE_CALLBACK_KIND_STORY_FAILED` | 8 | `story.failed` | | `RIPPLE_CALLBACK_KIND_CVAR_CHANGED` | 9 | `cvar.changed` | | `RIPPLE_CALLBACK_KIND_ENTITY_SPAWNED` | 10 | `entity.spawned` | | `RIPPLE_CALLBACK_KIND_ENTITY_REMOVED` | 11 | `entity.removed` | | `RIPPLE_CALLBACK_KIND_ENTITY_RENAMED` | 12 | `entity.renamed` | | `RIPPLE_CALLBACK_KIND_ENTITY_FIELD_CHANGED` | 13 | `entity.field.changed` | | `RIPPLE_CALLBACK_KIND_MACHINE_STATE_ENTERED` | 14 | `machine.state.entered` | | `RIPPLE_CALLBACK_KIND_NODE_ENTERED` | 15 | `node.entered` | | `RIPPLE_CALLBACK_KIND_NODE_EXITED` | 16 | `node.exited` | | `RIPPLE_CALLBACK_KIND_NODE_FAILED` | 17 | `node.failed` | What each event fires on and what it may be aimed at is in [Host callbacks](/reference/host-callbacks/). ## Sound [Section titled “Sound”](#sound) No speaker ships. The session says what the story asks for as `Audio` effects; the engine’s scheduler, `ripple::Mixer`, decides every voice from them; a speaker you write makes the sounds. `ripple::SoundscapeDriver` drives the scheduler for a speaker, and is the host’s half of it. The binding’s [speaker contract](https://github.com/ConnectingTheDots-DCWK/ripple_effect_c_cpp_integration/blob/main/docs/speaker.md) has mapping notes for miniaudio, SDL3 and FMOD Core. ```cpp MySpeaker speaker; auto audio = ripple::SoundscapeDriver::create(campaign, speaker); ripple::PlayerHandlers handlers; handlers.audio = audio.get(); ripple::Player player(ripple::Session::create(campaign, config), handlers); // every frame: audio->update(); player.update(); ``` ### SoundscapeDriver [Section titled “SoundscapeDriver”](#soundscapedriver) | Member | Answers | Notes | | ------------------------------------------------ | ----------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------ | | `static create(campaign, speaker, options = {})` | `std::unique_ptr` | Keeps references to `campaign` and `speaker`, which must outlive it. Not copyable. | | `apply(command)` | `void` | Obeys one `AudioCommand`. `Player` calls it for every `Audio` effect. A cue whose medium has no file is ignored. | | `update()` | `void` | Call from your loop: hears what the voices reported and moves the timeline on. The editor’s pace is every 25 ms; more often loses nothing. | | `set_muted(muted)` | `void` | Mutes or unmutes at once: every gain written as zero, the rest remembered. | | `muted()` | `bool` | | | `pause()`, `resume()` | `void` | Holds every voice and the timeline with them, not the story. | | `playing()` | `std::optional` | The id of the soundscape in force, sounding or waiting its turn. | | `idle()` | `bool` | Whether nothing sounds, waits or cues. | | `voices()` | `std::size_t` | How many voices the speaker holds open now. | | destructor | | Stops every voice. | A failure from the mixer stops the sound rather than the story: the driver does not report it. `SoundscapeOptions`: | Field | Type | Default | | ----------- | --------------- | -------------------------------- | | `max_cues` | `std::uint32_t` | `RIPPLE_MIXER_DEFAULT_MAX_CUES` | | `gain_step` | `double` | `RIPPLE_MIXER_DEFAULT_GAIN_STEP` | | `muted` | `bool` | `false` | | `now` | `Clock` | `ripple::steady_clock()` | What the driver does, so that a speaker does not: the clock and the tick; the scheduler’s commands, in order; a late voice, whose start moves on by the time `open` took times `rate`, a looping clip wrapping inside its loop and a clip already over reported ended; voices reported ended or measured, told to the scheduler; tracks with no file, told as silence; mute; and the handover between soundscapes, which never overlap: the one leaving fades out over its own `fade_out` before the next begins. ### Speaker and Voice [Section titled “Speaker and Voice”](#speaker-and-voice) ```cpp class Speaker { public: virtual std::unique_ptr open(const VoiceSource& source, VoiceRole role, VoiceEvents events) = 0; virtual bool resolves(const VoiceSource& source) { return true; } }; class Voice { public: virtual void play(const VoicePlay& request) = 0; virtual void set_gain(double gain) = 0; virtual void pause() = 0; virtual void resume() = 0; virtual void stop() = 0; }; ``` | Member | Obligation | | ------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `Speaker::open(source, role, events)` | Returns once the voice can begin at once: the file opened, or enough of it decoded. Returns `nullptr` when the file cannot be played, which is heard as silence and never retried. Keep `events` with the voice. `role` is a hint: `VoiceRole::track` is usually long and better streamed, `VoiceRole::cue` short and better decoded whole and started precisely. | | `Speaker::resolves(source)` | Asked before a soundscape plays: a track your speaker cannot play is told to the scheduler as silence up front. True unless overridden. | | `Voice::play(request)` | Begins at once, replacing whatever the voice was playing. May be called again on the same voice, for a gapless bed whose file ended and comes round; follow on as closely as your API allows. | | `Voice::set_gain(gain)` | A new gain on the same cubic scale. The scheduler writes one each tick a fade moves; smooth between them over a few milliseconds. | | `Voice::pause()`, `Voice::resume()` | Hold the voice exactly where it is, and let it go again. The driver pauses every voice together. | | `Voice::stop()` | Ends the voice for good and lets its resources go. No `ended` follows. | `VoicePlay`, what a voice is to play: | Field | Type | Meaning | | -------------- | ----------------------- | ------------------------------------------------------------------------ | | `from_ms` | `double` | Where in the file to begin. | | `to_ms` | `std::optional` | Where to stop; the file’s end when absent. | | `gain` | `double` | 0 to 1, on the engine’s cubic scale. | | `rate` | `double` | Playback speed; 1 is as recorded. | | `looping` | `bool` | Whether reaching `to_ms` comes round again. | | `loop_from_ms` | `double` | Where each turn of a loop comes round to: `loop_from_ms`, not `from_ms`. | **The gain scale is cubic.** A speaker that writes amplitude writes `gain * gain * gain`. `VoiceSource`: `media` (by index into the manifest’s media), `path` (inside the bundle), and `file` (on disk, for a campaign opened from a folder). `VoiceEvents`, what a voice reports back. Both calls are thread-safe and may come from an audio thread; the driver hears them on its next `update()`. A call from a voice that was stopped meanwhile is ignored. | Member | When | | -------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------ | | `ended()` | Once per play, when a non-looping voice reaches the end of what it was given. Never after `stop()`, never for a looping voice. | | `duration(ms)` | Once, when the file’s length is known. A track played to its file’s end has no end the scheduler can plan a fade-out or a gapless turn for until then. | ### Mixer [Section titled “Mixer”](#mixer) `ripple::Mixer` is the engine’s soundscape scheduler, raw: commands in, `VoiceCommands` out, the host’s clock on every verb, and no clock, file or voice of its own. A host with a `Speaker` uses the driver instead; a host that drives the mixer itself owes it everything the driver does. | Member | Answers | Notes | | ----------------------------------------------------------------------------------------------------- | ---------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `static create(max_cues = RIPPLE_MIXER_DEFAULT_MAX_CUES, gain_step = RIPPLE_MIXER_DEFAULT_GAIN_STEP)` | `Mixer` | At most `max_cues` cues at once; a gain written only when it moved by more than `gain_step`. | | `handle()` | `RippleMixer*` | | | `play(now_ms, soundscape, unresolved = {}, at_ms = 0, muted = false)` | `VoiceCommands` | An audio `play`, `at_ms` into the timeline, handing over from whatever is sounding. `unresolved` names the tracks you have no file for, by index: heard as silence, never retried. | | `stop(now_ms)` | `VoiceCommands` | An audio `stop`: fades out what is sounding. | | `cut(now_ms)` | `VoiceCommands` | An audio `cut`: silences everything at once, cues included. | | `cue(now_ms, media, volume, speed)` | `VoiceCommands` | An audio `cue`. | | `pause(now_ms)`, `resume(now_ms)` | `VoiceCommands` | | | `tick(now_ms)` | `VoiceCommands` | Moves the timeline on: what is due opens, ramps move, what is done stops. | | `voice_ended(now_ms, voice)` | `VoiceCommands` | The speaker’s report that a voice reached its end. | | `duration_known(voice, ms)` | `void` | The speaker’s report of a file’s length. | | `set_muted(muted)` | `void` | The timeline runs on, every gain written as 0. | | `playing_id()` | `std::optional` | The soundscape playing. | | `finishing_id()` | `std::optional` | The soundscape fading out under the one playing. | | `state()` | `std::optional` | The playing soundscape’s phase. | | `track_state(track)` | `Phase` | A track’s phase, by index into the playing soundscape’s tracks. | | `position_ms()` | `std::uint64_t` | How far into its timeline the playing soundscape is. | | `pass()` | `std::uint32_t` | How many times it has gone round. | | `ran_out()` | `bool` | Whether it has played to its end and will not loop. | | `is_paused()`, `muted()` | `bool` | | | `is_idle()` | `bool` | Whether nothing is sounding or due: when a host may stop ticking. | `Phase` is `queued`, `starting`, `playing`, `finishing` or `finished`. `VoiceCommand`, read by `kind`; a field another kind does not use is zero: | Field | Type | Meaning | | ------------------------- | ------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------ | | `kind` | `VoiceKind` | `open` a voice on `media` and play it; `gain`, a new gain; `stop` the voice for good, which reports no end; `pause` every voice; `resume` every voice. | | `voice` | `std::uint64_t` | `open`, `gain`, `stop`: the voice. | | `media` | `std::uint32_t` | `open`: by index into the manifest’s media. | | `from_ms` | `std::uint64_t` | `open`. | | `to_ms` | `std::optional` | `open`: none for the file’s end. | | `gain` | `double` | `open`, `gain`: on the cubic scale. | | `rate` | `double` | `open`. | | `looping`, `loop_from_ms` | `bool`, `std::uint64_t` | `open`. | | `role` | `VoiceRole` | `open`: `track` or `cue`. | | `track` | `std::uint32_t` | `open`, as a track: its index in the soundscape. | ## Errors [Section titled “Errors”](#errors) With exceptions, every call that can fail throws a class derived from `ripple::Error`, a `std::runtime_error` whose `failure()` answers the `Failure` and `code()` its `Code`. With `RIPPLE_HPP_NO_EXCEPTIONS` defined, the same calls answer `ripple::Result`. `ripple::Returns` is `T` with exceptions and `Result` without, so code that reads results through it compiles both ways. | `RippleResult` | Value | `ripple::Code` | C++ class | Met | | ------------------------------- | ----- | ------------------ | ----------------- | --------------------------------------------------------------------------------------------------------- | | `RIPPLE_SUCCESS` | 0 | `success` | | | | `RIPPLE_ERROR_BUNDLE_FORMAT` | 1 | `bundle_format` | `BundleError` | In use: a document of the bundle is malformed or not there. | | `RIPPLE_ERROR_BUNDLE_VERSION` | 2 | `bundle_version` | `BundleError` | In use: a document is from a version this engine cannot read. | | `RIPPLE_ERROR_NEEDS_PYTHON` | 3 | `needs_python` | `NeedsPython` | In use: the bundle needs Python and this is the `no-python` library. | | `RIPPLE_ERROR_WRONG_TURN` | 10 | `wrong_turn` | `HostError` | A host bug: the verb does not answer the turn the story is on. | | `RIPPLE_ERROR_NO_SUCH_CHOICE` | 11 | `no_such_choice` | `HostError` | A host bug: a choice past the offered ones. | | `RIPPLE_ERROR_CHOICE_SPENT` | 12 | `choice_spent` | `HostError` | A host bug: a choice whose `available` is false. | | `RIPPLE_ERROR_STOPPED` | 13 | `stopped` | `HostError` | A host bug: the session has stopped. | | `RIPPLE_ERROR_NOTHING_ASKED` | 14 | `nothing_asked` | `HostError` | A host bug: `start` was not called. | | `RIPPLE_ERROR_UNKNOWN_LOCALE` | 15 | `unknown_locale` | `HostError` | A language the campaign is not readable in. | | `RIPPLE_ERROR_BOARD_GONE` | 20 | `board_gone` | `ResumeError` | In use: a save names a board the campaign no longer has. | | `RIPPLE_ERROR_NODE_GONE` | 21 | `node_gone` | `ResumeError` | In use: a save names a node the board no longer has. | | `RIPPLE_ERROR_INVALID_ARGUMENT` | -1 | `invalid_argument` | `InvalidArgument` | A host bug: a NULL handle or pointer, a string that is not UTF-8, a value out of range. Nothing was done. | | `RIPPLE_ERROR_TRAP` | -2 | `trap` | `EngineTrap` | The engine panicked, now or earlier on this handle. | | | -100 | `reentrant` | `ReentrantVerb` | A host bug, C++ only: a `Player` verb from inside a handler. | `Failure` carries everything the engine said. Which fields mean something depends on `code`; the rest are zero or empty. | Field | Set for | | ------------------------------ | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `code`, `message` | Always. `message` is the engine’s sentence, for a log or a developer. | | `location` | `bundle_format`: where in the document, when known. | | `found`, `supported`, `oldest` | `bundle_version`: the version the document carries, the newest this engine reads, and the oldest when there is a floor. | | `asked`, `answered` | `wrong_turn`: the turn the story is on (a `RippleTurnKind`: `PRESENT`, `ASK`, `WAIT`, `ENDED`, `FAILED`, `STOPPED`) and the verb called (a `RippleVerb`: `CHOOSE`, `ANSWER`, `ELAPSED`). | | `offered`, `chosen` | `no_such_choice`: how many choices were offered, and the index taken. | | `subject` | `choice_spent` (the port), `unknown_locale` (the locale), `board_gone` and `node_gone` (the board). | | `node` | `node_gone`. | `Result`: `ok()` and `explicit operator bool`, `value()`, `operator->`, `operator*`, and `failure()`. `Result` has `ok()`, `operator bool` and `failure()`, whose code is `success` when nothing failed. **A trap poisons the handle.** After `RIPPLE_ERROR_TRAP` from any call, destroy that handle, open again and resume from the last checkpoint. Under Emscripten a panic aborts the module instead, and the page reopens and resumes. ## Threading and lifetime [Section titled “Threading and lifetime”](#threading-and-lifetime) * **One caller at a time per handle.** A `Campaign`, `Session` or `Mixer` may move between threads; to run the engine off your main thread, move the handle there. * **The engine starts no thread, never sleeps and never reads a clock.** A `Hold` is answered by your clock, through `elapsed()` or `Player::update()`. * **`Player` and `SoundscapeDriver` own no clock and no thread.** They read their `Clock` when you call `update()`; your loop is the timer. Their calls come from one thread; `VoiceEvents` may be called from any. * **RAII.** `Campaign`, `Session` and `Mixer` are move-only and destroy their C handle with them. A campaign may be destroyed before its sessions. A `Player` owns its `Session`. A `SoundscapeDriver` holds references to its `Campaign` and `Speaker`, so both must outlive it. * **Nothing in C++ points into the engine.** Every answer is copied out of the C result, which is freed before the call returns; the cost is a copy per verb. * **In C, a batch’s pointers die with it.** Everything a call writes through an out-pointer is valid until it is given to `ripple_free`. ## The C layer [Section titled “The C layer”](#the-c-layer) `ripple.h` is C99 and keeps the same conventions in every function: * **Results.** A call that can fail returns a `RippleResult` and takes a last `const RippleError **error`, which may be NULL. On failure `*error` describes it; give it back with `ripple_free`. * **Out-pointers.** Everything written through an out-pointer is the engine’s and goes back through `ripple_free`. NULL, a pointer the engine never gave and one already freed are all ignored. * **A leak check.** `ripple_outstanding()` answers how many objects the engine has handed out and not had back: zero once everything has been freed. * **Handles.** `RippleCampaign`, `RippleSession` and `RippleMixer` are opaque and go back through their own `ripple_*_destroy`, which ignores NULL. * **Tagged unions.** A `uint32_t kind` and a `union data` read by it: an effect, a value node, a voice command. * **Strings.** A `RippleStr` is `{ptr, len}`, UTF-8. `ptr` is NULL for an absent optional string; a present empty one has a non-NULL `ptr` and `len` 0. From the engine it is NUL-terminated past `len`; from a host it need not be. * **Optionals.** `RippleOptionalU32`, `RippleOptionalU64` and `RippleOptionalI64` are `{present, value}`. * **Lists.** A pointer and a count, either as a struct (`RippleEffects`, `RippleStrList`, `RippleEntityList`…) or as a field pair (`choices`, `choice_count`). * **Values.** The arena under *Values*. * **Options.** Start from `ripple_session_options_default` and change what you mean to. ### Functions [Section titled “Functions”](#functions) **The engine and its formats** | Function | Does | | ------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `ripple_engine_version` | The library’s version, a string it owns. | | `ripple_engine_contract` | The contract the library binds, a string it owns. | | `ripple_engine_has_python` | False for the `no-python` library. | | `ripple_bundle_format` | The bundle format the engine writes, and reads up to. | | `ripple_save_format` | The save format the engine writes. | | `ripple_save_check` | Reads a stored save and answers it as this engine would write it, refusing one that is malformed or from a newer engine before a session is asked to resume it. No C++ wrapper. | | `ripple_callback_kind_name` | A `RippleCallbackKind`’s name, such as `cvar.changed`; NULL for any other value. | | `ripple_cvar_types` | The console variable types a campaign may declare, by wire name. No C++ wrapper. | | `ripple_cvar_coerce` | A value coerced to a console variable’s type, as a write would take it; `*out` NULL when the type refuses it. No C++ wrapper. | | `ripple_cvar_describe_refusal` | The sentence a refused console-variable write would be refused with. No C++ wrapper. | | `ripple_value_as_text` | A value as text, the way a passage would print it. | | `ripple_value_as_truth` | Whether a value is true, by the story’s rules of truth. No C++ wrapper. | | `ripple_value_deep_equals` | Whether two values are equal by the story’s rules: a map by membership, an Int and a Float by number. No C++ wrapper. | **Campaign** | Function | Does | | ------------------------------- | -------------------------------------------------------------------------------------------- | | `ripple_campaign_open_dir` | Opens the bundle in a folder. | | `ripple_campaign_open_memory` | Opens a bundle from an array of `RippleFile` (`path`, `bytes`, `len`); the bytes are copied. | | `ripple_campaign_destroy` | Destroys a campaign; its sessions go on working. | | `ripple_campaign_manifest` | The manifest. | | `ripple_campaign_board` | A board, by index. | | `ripple_campaign_soundscape` | A soundscape, by index. | | `ripple_campaign_board_with_id` | The index of a board by catalog id, as a `RippleOptionalU32`. | | `ripple_campaign_node` | A node of a board by id; `*out` NULL when there is none. | | `ripple_campaign_validate` | Everything wrong with the bundle as a whole. | | `ripple_campaign_media_path` | A medium’s path inside the bundle; `*out` NULL when there is no such medium. | | `ripple_campaign_has_media` | Whether the bundle holds the medium’s file. No C++ wrapper. | | `ripple_campaign_media_bytes` | A medium’s bytes, read whole; `*out` NULL when the bundle does not hold it. | **Session** | Function | Does | | ------------------------------------ | -------------------------------------------------------------------------- | | `ripple_session_options_default` | Fills a `RippleSessionOptions` with the engine’s own budgets and policies. | | `ripple_session_create` | A new session from a `RippleSessionConfig`. | | `ripple_session_resume` | A session from a save, a seed and options. | | `ripple_session_destroy` | Destroys a session. | | `ripple_session_start` | The first turn, or the saved one. | | `ripple_session_choose` | Answers a passage; `RIPPLE_CHOICE_NONE` for leaving. | | `ripple_session_answer` | Answers a form with `RippleFields`; NULL for leaving. | | `ripple_session_elapsed` | Answers a wait. | | `ripple_session_set_locale` | Changes the language; the turn is answered again. | | `ripple_session_restart` | Starts over from the first turn. | | `ripple_session_pause` | The save and what pausing did, as a `RipplePaused`. | | `ripple_session_stop` | Stops the story for good. | | `ripple_session_checkpoint` | A save without stopping; `*out` NULL when there is nothing to save yet. | | `ripple_session_console_feed` | Runs a line of Python, as a `RippleConsoleResult`. | | `ripple_session_write_cvar` | Writes a console variable, as a `RippleCvarChange`. | | `ripple_session_reset_cvar` | Puts one console variable back to its default. | | `ripple_session_reset_all_cvars` | Puts every console variable back. | | `ripple_session_remove_entity` | Removes an entity, as a `RippleEntityRemoval`. | | `ripple_session_board` | The board the story is on. | | `ripple_session_locale` | The language it reads in. | | `ripple_session_default_locale` | The campaign’s default language. | | `ripple_session_ambience` | The soundscape in force; `*out` NULL for silence. | | `ripple_session_entities` | Every entity. | | `ripple_session_memory` | What the story remembers. | | `ripple_session_cvars` | Every console variable with its value. | | `ripple_session_guard` | What the script guard has had to do. | | `ripple_session_python_available` | Whether the session can run scripts. | | `ripple_session_describe_cvar_write` | Why a write would be refused; `*out` NULL when it would be taken. | | `ripple_session_set_trace` | Turns `trace` effects on or off. | | `ripple_session_set_trace_payloads` | Turns traced payloads on or off. | | `ripple_session_generator` | The dice’s state. | | `ripple_session_set_generator` | Sets the dice’s state. | **Mixer** | Function | Does | | ----------------------------- | -------------------------------------------------------------------- | | `ripple_mixer_create` | A mixer with `max_cues` and `gain_step`. | | `ripple_mixer_destroy` | Destroys a mixer; your voices are yours to stop. | | `ripple_mixer_play` | Plays a soundscape, with the unresolved tracks and `at_ms`. | | `ripple_mixer_stop` | Fades out what is sounding. | | `ripple_mixer_cut` | Silences everything at once. | | `ripple_mixer_cue` | Plays one cue. | | `ripple_mixer_pause` | Holds everything where it is. | | `ripple_mixer_resume` | Lets everything go on. | | `ripple_mixer_tick` | Moves the timeline on to `now_ms`. | | `ripple_mixer_voice_ended` | A voice reached its end. | | `ripple_mixer_duration_known` | A file’s length is known. | | `ripple_mixer_set_muted` | Mutes or unmutes. | | `ripple_mixer_playing_id` | The soundscape playing; `*out` NULL when none is. | | `ripple_mixer_finishing_id` | The soundscape fading out under it. | | `ripple_mixer_state` | The playing soundscape’s `RipplePhase`, absent when none is playing. | | `ripple_mixer_track_state` | A track’s `RipplePhase`. | | `ripple_mixer_position_ms` | How far into its timeline it is. | | `ripple_mixer_pass` | How many times it has gone round. | | `ripple_mixer_ran_out` | Whether it played to its end and will not loop. | | `ripple_mixer_is_paused` | Whether the mixer is paused. | | `ripple_mixer_muted` | Whether it is muted. | | `ripple_mixer_is_idle` | Whether nothing is sounding or due. | **Memory** | Function | Does | | -------------------- | -------------------------------------------------------------------------------------------------------------------- | | `ripple_free` | Gives back anything the engine handed out: a batch, a manifest, a string, an error. Forgives NULL and a double free. | | `ripple_outstanding` | How many objects are handed out and not given back. | A host driving the mixer from C calls `ripple_mixer_play` with the `RippleSoundscape` an audio effect carries, `ripple_mixer_tick` from its loop while `ripple_mixer_is_idle` says something is sounding, and `ripple_mixer_voice_ended` from its speaker, and carries out every `RippleVoiceCommands` with its own audio API, owing the scheduler the duties listed under *SoundscapeDriver*. ### A turn loop in C [Section titled “A turn loop in C”](#a-turn-loop-in-c) ```c #include #include #include uint32_t pick(const RipplePassage *passage); /* yours */ void wait_ms(uint64_t ms); /* yours */ static RippleStr str(const char *text) { RippleStr s; s.ptr = text; s.len = strlen(text); return s; } static int report(RippleResult code, const RippleError *error) { fprintf(stderr, "ripple (%d): %.*s\n", (int)code, error ? (int)error->message.len : 0, error ? error->message.ptr : ""); ripple_free(error); return 1; } int play(const char *folder, uint64_t seed) { RippleCampaign *campaign = NULL; RippleSession *session = NULL; const RippleManifest *manifest = NULL; const RippleEffects *batch = NULL; const RippleError *error = NULL; RippleSessionConfig config; RippleResult code; code = ripple_campaign_open_dir(str(folder), &campaign, &error); if (code != RIPPLE_SUCCESS) return report(code, error); code = ripple_campaign_manifest(campaign, &manifest, &error); if (code != RIPPLE_SUCCESS) { ripple_campaign_destroy(campaign); return report(code, error); } memset(&config, 0, sizeof config); /* absent strings are {NULL, 0} */ config.board = manifest->entry_count > 0 ? manifest->entries[0] : 0; config.seed = seed; ripple_session_options_default(&config.options); ripple_free(manifest); code = ripple_session_create(campaign, &config, &session, &error); if (code == RIPPLE_SUCCESS) code = ripple_session_start(session, &batch, &error); while (code == RIPPLE_SUCCESS && batch != NULL && batch->count > 0) { const RippleEffect *turn = &batch->items[batch->count - 1]; /* the Turn is last */ const RippleEffects *next = NULL; size_t i; for (i = 0; i + 1 < batch->count; i++) { const RippleEffect *effect = &batch->items[i]; if (effect->kind == RIPPLE_EFFECT_KIND_AUDIO) { /* effect->data.audio, to ripple_mixer_* */ } else if (effect->kind == RIPPLE_EFFECT_KIND_CALLBACK) { /* effect->data.callback->callback, by name */ } } if (turn->kind == RIPPLE_EFFECT_KIND_SHOW) { code = ripple_session_choose(session, pick(turn->data.show), &next, &error); } else if (turn->kind == RIPPLE_EFFECT_KIND_HOLD) { wait_ms(turn->data.hold_ms); code = ripple_session_elapsed(session, &next, &error); } else if (turn->kind == RIPPLE_EFFECT_KIND_ASK) { code = ripple_session_answer(session, NULL, &next, &error); /* the reader leaves */ } ripple_free(batch); /* every pointer into the batch dies here */ batch = next; /* NULL after ended, failed or stopped */ } ripple_session_destroy(session); ripple_campaign_destroy(campaign); return code == RIPPLE_SUCCESS ? 0 : report(code, error); } ``` ## A complete host [Section titled “A complete host”](#a-complete-host) A terminal host in C++17: a speaker that writes down what it would play, the driver and the player, a choice that skips spent options, a form filled from its defaults, holds answered from `update()`, named callbacks with a verb queued from a handler, a checkpoint at every passage, and a trap recovered by reopening and resuming. ```cpp #include #include #include #include #include #include #include #include #include #include namespace { // A voice that prints what it is told. Yours opens the file with your audio API, // and calls events_.ended() and events_.duration(ms) from its own thread. class PrintedVoice : public ripple::Voice { public: PrintedVoice(std::string file, ripple::VoiceEvents events) : file_(std::move(file)), events_(std::move(events)) {} void play(const ripple::VoicePlay& r) override { const double amplitude = r.gain * r.gain * r.gain; // the gain scale is cubic std::printf("[sound] %s from %.0f ms, amplitude %.3f%s\n", file_.c_str(), r.from_ms, amplitude, r.looping ? ", looping" : ""); } void set_gain(double gain) override { (void)gain; } // smooth it over a few ms void pause() override {} void resume() override {} void stop() override { std::printf("[sound] stop %s\n", file_.c_str()); } private: std::string file_; ripple::VoiceEvents events_; }; class PrintedSpeaker : public ripple::Speaker { public: std::unique_ptr open(const ripple::VoiceSource& source, ripple::VoiceRole, ripple::VoiceEvents events) override { return std::make_unique(source.file.value_or(source.path), std::move(events)); } }; std::optional pick(const ripple::Passage& passage) { std::cout << "\n== " << passage.title << " ==\n" << passage.text << "\n\n"; if (passage.choices.empty()) return ripple::leave; for (std::size_t i = 0; i < passage.choices.size(); ++i) std::cout << " " << i + 1 << ". " << passage.choices[i].text << (passage.choices[i].available ? "" : " (spent)") << "\n"; for (std::string line; std::cout << "> " && std::getline(std::cin, line);) { const std::size_t n = std::strtoul(line.c_str(), nullptr, 10); // A spent choice is shown but never taken: choosing it is choice_spent. if (n >= 1 && n <= passage.choices.size() && passage.choices[n - 1].available) return static_cast(n - 1); } return ripple::leave; } std::optional fill(const ripple::Prompt& prompt) { std::cout << "\n(" << prompt.title << ")\n"; ripple::Value::Map answers; for (const ripple::PromptField& field : prompt.fields) { if (field.default_value.is_text()) { std::string line; std::cout << field.name << "> "; std::getline(std::cin, line); answers.emplace_back(field.name, ripple::Value(line)); } else { answers.emplace_back(field.name, field.default_value); } } return answers; // std::nullopt would leave the form instead } // A save, and the dice where they were when it was made. struct Saved { std::string save; ripple::Generator dice; }; // Plays from `last`, or from the start; keeps `last` up to date at every passage. void play(const std::string& folder, std::optional& last) { const ripple::Campaign campaign = ripple::Campaign::open_dir(folder); const ripple::Manifest manifest = campaign.manifest(); PrintedSpeaker speaker; auto audio = ripple::SoundscapeDriver::create(campaign, speaker); ripple::SessionOptions options; options.untranslated = ripple::Untranslated::fall_back; const std::uint64_t seed = 20261019; std::optional session; if (last) { session.emplace(ripple::Session::resume(campaign, last->save, seed, options)); session->set_generator(last->dice); } else { ripple::SessionConfig config; config.board = manifest.entries.empty() ? 0 : manifest.entries.front(); config.seed = seed; config.options = options; session.emplace(ripple::Session::create(campaign, config)); } std::optional broke; ripple::PlayerHandlers handlers; handlers.audio = audio.get(); handlers.on_error = [&](const ripple::Failure& failure) { std::cerr << "ripple: " << failure.message << "\n"; if (failure.code == ripple::Code::trap) broke = failure; }; ripple::Player player(std::move(*session), handlers); // A handler may not call a verb; it queues one for after it returns. std::deque> later; player.on_callback("achievementHit", [](const ripple::CallbackEvent& fired) { std::cout << "[achievement] " << fired.name.value_or(fired.callback) << "\n"; }); player.on_callback("returnToMenu", [&](const ripple::CallbackEvent&) { later.push_back([&] { player.stop(); }); }); player.on_any_callback([](const ripple::CallbackEvent& fired) { std::cout << "[callback] " << fired.callback << " (" << fired.event_name() << ")\n"; }); player.start(); // on a resumed session, the saved passage again for (;;) { audio->update(); player.update(); // answers a Hold once its time has come if (broke) throw ripple::EngineTrap(*broke); while (!later.empty()) { auto run = std::move(later.front()); later.pop_front(); run(); } const std::optional& turn = player.turn(); if (!turn || ripple::is_over(*turn)) break; if (const auto* shown = std::get_if(&*turn)) { if (auto save = player.session().checkpoint()) last = Saved{*save, player.session().generator()}; player.choose(pick(shown->passage)); } else if (const auto* asked = std::get_if(&*turn)) { player.answer(fill(asked->prompt)); } else { std::this_thread::sleep_for(std::chrono::milliseconds(25)); // a Hold } } if (const auto& turn = player.turn()) { if (const auto* ended = std::get_if(&*turn)) std::cout << "\nThe end" << (ended->title ? ": " + *ended->title : "") << "\n"; else if (const auto* failed = std::get_if(&*turn)) std::cout << "\nThe story could not go on: " << failed->message << "\n"; } std::cout << player.callback_misses() << " callbacks had no handler\n"; } } // namespace int main(int argc, char** argv) { if (argc < 2) { std::cerr << "usage: " << argv[0] << " \n"; return 2; } std::cout << "engine " << ripple::engine_version() << ", contract " << ripple::engine_contract() << "\n"; std::optional last; for (int attempt = 0; attempt < 3; ++attempt) { try { play(argv[1], last); return 0; } catch (const ripple::EngineTrap& trap) { // The handle is poisoned: everything was destroyed with play(); open again. std::cerr << "engine trap, resuming from the last passage: " << trap.what() << "\n"; if (!last) return 1; } catch (const ripple::ResumeError&) { std::cerr << "This save is from an older build of the story.\n"; last.reset(); } catch (const ripple::NeedsPython&) { std::cerr << "This campaign needs the engine with Python.\n"; return 1; } catch (const ripple::Error& error) { std::cerr << "ripple: " << error.what() << "\n"; return 1; } } return 1; } ``` ## Related [Section titled “Related”](#related) * [Embedding the engine](/explanation/embedding-the-engine/): what a host is responsible for, and why the engine keeps no clock and plays no sound. * [How the engine ships](/explanation/how-the-engine-ships/): engine releases, contracts and the two variants. * [Play a campaign in your program](/how-to/play-a-campaign-in-your-program/): the steps from an exported campaign to a running host. * [Engine targets](/reference/engine-targets/): every target an engine release ships, with its floor. * [Host callbacks](/explanation/host-callbacks/): why a callback is a name the author publishes and the host answers. # C# binding > The C# binding of the Ripple Effect engine: its NuGet packages, the variants and platforms, and every public type: Campaign, Session, the effects, Value, Player, the speaker contract and the exceptions. The C# binding is three NuGet packages: `RippleFx.Engine`, the managed bindings, and one of two engine packages, `RippleFx.Engine.Native` or `RippleFx.Engine.Native.NoPython`. Version 1.5.x binds Ripple Effect contract 1.5. `MAJOR.MINOR` is the engine contract, and the `PATCH` is the binding’s own. Each binding release pins one engine release, `engine-v` of the public [ripple\_engine\_releases](https://github.com/ConnectingTheDots-DCWK/ripple_engine_releases) repository, and the engine packages carry exactly the libraries of that release’s signed archives. Binding 1.5.0 pins engine 1.5.3. The source is at [ConnectingTheDots-DCWK/ripple\_effect\_csharp\_integration](https://github.com/ConnectingTheDots-DCWK/ripple_effect_csharp_integration). The bindings are MIT. The engine binary the native packages carry is under the [Ripple Effect Runtime Licence](https://legal.ripplefx.app/runtime/), and its notice must be reproduced wherever your product lists the third-party software it contains. Everything below is in the `RippleFx.Engine` namespace. Times are milliseconds throughout. ## Packages and installing [Section titled “Packages and installing”](#packages-and-installing) | Package | What it is | Targets | | --------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | ------------------------------------------------------ | | `RippleFx.Engine` | The managed bindings: `Campaign`, `Session`, `Effect`, `Value`, the records, the exceptions, `Mixer`, `ISpeaker` and `IVoice`, `SoundscapeDriver`, `Player`. No dependencies. Carries no engine. | `netstandard2.1`, `net8.0`; compiled as C# 9 | | `RippleFx.Engine.Native` | The engine with its Python interpreter, as NuGet ships native code: `runtimes//native/`. | every runtime identifier under [Platforms](#platforms) | | `RippleFx.Engine.Native.NoPython` | The engine without Python: about half the size. | the same | ```sh dotnet add package RippleFx.Engine dotnet add package RippleFx.Engine.Native # with Python dotnet add package RippleFx.Engine.Native.NoPython # or without ``` * **Reference exactly one engine package.** A project that references both fails to build, with an error naming both. A project that references neither builds, and the first call that needs the engine throws `EngineNotFoundException` naming the two packages. * **The engine packages carry `ENGINE-NOTICE.txt` and `LICENSE-ENGINE.txt`** at their root. `ENGINE-NOTICE.txt` is the notice to reproduce. * **`netstandard2.1` and C# 9** are what Unity compiles and loads. * **Ahead of time.** Every signature in `RippleFx.Engine.Interop` is blittable and nothing calls back into managed code, so no marshalling stub is generated at run time. The assembly is marked AOT-compatible for `net8.0` (NativeAOT), and the same holds under IL2CPP. * **A self-contained or single-file publish** takes the engine library with it, as it does any NuGet native asset. * **`RippleFx.Engine.Interop`** is the engine’s C ABI as P/Invoke, generated from the engine’s `ripple.h` with the C names kept: the static class `Native` and the C structs and enums. Use it, with `Campaign.DangerousHandle` and `Session.DangerousHandle`, for a call the bindings do not wrap. ## Choosing the variant [Section titled “Choosing the variant”](#choosing-the-variant) The variant is chosen by which engine package you reference. The managed package names neither. | | `RippleFx.Engine.Native` | `RippleFx.Engine.Native.NoPython` | | --------------------------------------------------- | ------------------------ | --------------------------------------------- | | Runs Script nodes and Python snippets | yes | no | | A campaign whose `Manifest.Needs` contains `python` | plays | refused at open with `NeedsPythonException` | | `RippleEngine.HasPython`, `Session.PythonAvailable` | `true` | `false` | | `Session.ConsoleFeed` | runs the line | answers a `PythonFailure` of kind unavailable | The editor’s **Needs no Python** export is checked by the compiler for exactly the `NoPython` host. ## Platforms [Section titled “Platforms”](#platforms) | Runtime identifiers | Library | Floor and notes | | --------------------------------- | ----------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | | `win-x64`, `win-arm64` | `ripple_effect_engine.dll` | Built against the dynamic CRT. | | `osx-x64`, `osx-arm64` | `libripple_effect_engine.dylib` | macOS 12.0 and up. One library per architecture; a universal app takes both identifiers. | | `linux-x64`, `linux-arm64` | shared library | glibc 2.35 and up. Not musl: Alpine has no engine build. | | `android-arm64`, `android-x64` | shared library | API 24 and up. .NET for Android packs it under `lib/arm64-v8a/` and `lib/x86_64/`. | | `ios-arm64`, `iossimulator-arm64` | `libripple_effect_engine.a`, static | iOS 13.0 and up; a simulator runtime’s own minimum may raise the deployment target. The package’s build targets link the archive into your app and keep and export every C entry point, so the same P/Invokes resolve through the main image. Build one runtime identifier at a time with `-r`, and do not list both in `RuntimeIdentifiers`: a list naming a simulator makes a device build target the simulator. | **Not supported: .NET in the browser** (`browser-wasm`). The engine’s web build for native linking needs Emscripten 4.0 or later, and .NET’s `wasm-tools` workload links with its own older Emscripten (3.1.34 in .NET 8, 3.1.56 in .NET 10), whose optimiser refuses the engine’s objects. The full matrix of engine builds is [Engine targets](/reference/engine-targets/). ## The engine [Section titled “The engine”](#the-engine) `RippleEngine` is a static class describing the library this process loaded. | Member | Type | What it is | | ---------------- | -------------- | ----------------------------------------------------------------------------------------------------------------------------------------------- | | `BoundContract` | `const string` | The contract these bindings bind: `"1.5"`. | | `Version` | `string` | The loaded library’s version, such as `1.5.3`: the one loaded, not the one built against. | | `Contract` | `string` | The contract the loaded library binds. | | `HasPython` | `bool` | Whether the loaded library runs scripts: `false` for the `NoPython` one. | | `Outstanding` | `long` | How many things the engine handed out and has not had back. Zero whenever no call is in flight. | | `EnsureLoaded()` | `void` | Loads the library and checks its contract against `BoundContract`, once. Every handle’s constructor calls it. Throws `EngineNotFoundException`. | ## Campaign [Section titled “Campaign”](#campaign) A `Campaign` is an opened bundle: immutable, shared by every session made from it, and `IDisposable`. It may be disposed before its sessions, and they go on working. ### Opening [Section titled “Opening”](#opening) | Factory | Opens | Notes | | ----------------------------------------------------- | ------------------------------------------------------------------------- | --------------------------------------------------------------------------------------- | | `Campaign.OpenDirectory(string folder)` | The bundle in the folder that holds `campaign.json`. | Files are read as the story asks for them, never from outside the folder. | | `Campaign.OpenFiles(IReadOnlyList files)` | A bundle held in memory: every file of it, by its path inside the bundle. | The bytes are copied. For a packed asset system, a phone’s bundled assets or a console. | `BundleFile(string Path, byte[] Bytes)` is one file of a bundle in memory. Both factories throw `BundleException` for a malformed bundle or one from a newer engine, and `NeedsPythonException` on the `NoPython` engine for a bundle that needs Python. ### Members [Section titled “Members”](#members) | Member | Answers | Notes | | ----------------------------- | ------------------------------ | ----------------------------------------------------------------------------------------------- | | `Manifest` | `Manifest` | `campaign.json`, read afresh on every call: keep it rather than ask again. | | `Board(uint index)` | `Board` | A board, by index into `Manifest.Boards`. | | `Soundscape(uint index)` | `Soundscape` | A soundscape, by index into `Manifest.Soundscapes`. | | `BoardWithId(string id)` | `uint?` | A board’s index by its id; null when there is no such board. | | `Node(uint board, string id)` | `Node?` | A node of a board, for when a trace names one; null when there is none. | | `Validate()` | `IReadOnlyList` | Everything wrong with the bundle the engine can see without playing it; empty when it is sound. | | `MediaPath(uint index)` | `string?` | A medium’s path inside the bundle; null when there is no such medium. | | `MediaFile(uint index)` | `string?` | The folder joined with the medium’s path, for a campaign opened from a folder; null otherwise. | | `MediaBytes(uint index)` | `byte[]?` | A medium’s bytes; null when there is no such medium. | | `Folder` | `string?` | The folder it was opened from; null for a bundle in memory. | | `DangerousHandle` | `IntPtr` | The engine’s handle. Good while the campaign is neither disposed nor collected. | | `Dispose()` | | Releases the handle. | `BundleProblem(string Message, string Location)` is one finding of `Validate()`. ### Manifest [Section titled “Manifest”](#manifest) | Field | Type | What it is | | --------------------------------------------------------------------------------- | ----------------------- | -------------------------------------------------------------------- | | `Format` | `long` | The bundle format. | | `Generator` | `string?` | Who compiled it; null when it does not say. | | `TextFormat` | `string` | `markdown` or `html`: what a passage’s text is written in. | | `Project` | `Project` | Title, description and languages. | | `Entries` | `IReadOnlyList` | The boards a story may begin on, by index into `Boards`. | | `Boards`, `Passages`, `Components`, `Scripts`, `Media`, `Machines`, `Soundscapes` | `IReadOnlyList` | The manifest’s tables. | | `Cvars` | `IReadOnlyList` | The console variables the campaign declares. | | `Needs` | `IReadOnlyList` | What the campaign needs of its host: `python`, when it runs scripts. | | Record | Fields | | --------- | -------------------------------------------------------------------------------------------------------------------------------------------------------- | | `Project` | `Title`, `Description`, `DefaultLocale` (`string`); `Locales` (`IReadOnlyList`); `Cover` (`string?`, the cover picture’s path inside the bundle) | | `Entry` | `Id`, `Name`, `Path` (`string`): one row of a table | | `Cvar` | `Name`, `Type` (`string`); `Default` (`Value`) | ### Boards, nodes and soundscapes [Section titled “Boards, nodes and soundscapes”](#boards-nodes-and-soundscapes) | Record | Fields | | ------------ | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `Board` | `Id`, `Name` (`string`); `Start` (`long`); `Prime` (`IReadOnlyList`); `Nodes` (`IReadOnlyList`) | | `Node` | `Id`, `Type`, `Title` (`string`); `Inputs`, `Outputs` (`IReadOnlyList`); `Data`, `Metadata` (`Value`) | | `Port` | `Id` (`string`); `Kind` (`PortKind`: `Control`, `Data`); `Edges` (`IReadOnlyList`) | | `Edge` | `Node`, `Port` (`long`) | | `Soundscape` | `Id`, `Name` (`string`); `Length`, `FadeIn`, `FadeOut` (`long`); `Looping`, `Tied` (`bool`); `Volume` (`double`, default 1.0); `Tracks` (`IReadOnlyList`) | | `Track` | `Media` (`long`, by index into the manifest’s media); `Lane`, `Start`, `ClipStart`, `FadeIn`, `FadeOut`, `Interval` (`long`); `ClipEnd` (`long?`); `Volume`, `Speed` (`double`, default 1.0); `Mode` (`TrackMode`: `Once`, `Looping`, `Auto`) | ## Session [Section titled “Session”](#session) A `Session` is one story being played: `IDisposable`, used by one caller at a time. Every verb answers `IReadOnlyList`, the effects it led to in the order they are applied, with the Turn last. ### Creating and resuming [Section titled “Creating and resuming”](#creating-and-resuming) | Factory | Notes | | -------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `Session.Create(Campaign campaign, SessionConfig config)` | A new session before its first turn. Call `Start()` next. | | `Session.Resume(Campaign campaign, string save, ulong seed, SessionOptions? options = null)` | A session picking up a save from `Pause()` or `Checkpoint()` over the same campaign. Call `Start()` next; it answers the turn the save was on. Throws `ResumeException` when the save names a board or node the campaign no longer has. | ### SessionConfig [Section titled “SessionConfig”](#sessionconfig) | Field | Type | Default | What it is | | ----------- | ----------------- | ------- | --------------------------------------------------------------------------------------------------- | | `Board` | `uint` | `0` | The board, by index into the manifest’s boards: usually `Manifest.Entries[0]`. | | `EntryNode` | `string?` | `null` | A node to begin at instead of the board’s start. | | `Locale` | `string?` | `null` | The language to read in; null for the default. A locale the campaign is not readable in is refused. | | `Seed` | `ulong` | `0` | The dice’s seed: the same seed and the same moves play the same story. | | `Digest` | `string?` | `null` | The bundle digest a save made from this session is stamped with; null for none. | | `Options` | `SessionOptions?` | `null` | Null means `new SessionOptions()`. | ### SessionOptions [Section titled “SessionOptions”](#sessionoptions) `new SessionOptions()` reads the engine’s own budgets from the loaded library. Set only what you mean to change. | Field | Type | Default | What it is | | ---------------------- | -------------- | ------------ | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `StepBudget` | `uint` | the engine’s | Steps a run may take before it is stopped; 0 stops on the first. | | `SilentJumpLimit` | `uint` | the engine’s | Silent jumps between boards before the story is stopped. | | `LoopLimit` | `uint` | the engine’s | Times a loop may go round; 0 is no ceiling. | | `ScriptDeadlineMs` | `ulong` | the engine’s | How long a script may run before it is stopped. Ignored without Python. | | `ScriptMemoryBytes` | `ulong` | the engine’s | How much memory a script may use. Ignored without Python. | | `ScriptRecursionDepth` | `uint` | the engine’s | How deep a script may recurse. Ignored without Python. | | `Untranslated` | `Untranslated` | `Report` | `Report`: a passage with no text in the locale has empty text and names the locale in `Passage.Untranslated`. `FallBack`: it shows the default locale’s text and still names it. | | `Trace` | `bool` | `false` | Whether every verb’s batch carries the run’s trace as `Effect.Trace`. | | `TracePayloads` | `bool` | `false` | Whether the trace carries the values that flowed, not only that they did. | ### Verbs [Section titled “Verbs”](#verbs) | Verb | Answers | Notes | | ---------------------------------------------------------- | --------------- | --------------------------------------------------------------------------------------------------------------------- | | `Start()` | effects | The first turn, or the turn a resumed save was on. | | `Choose(int index)` | effects | Takes the choice at `index` among those the passage offers. A negative index throws `ArgumentOutOfRangeException`. | | `Leave()` | effects | The reader leaves the passage without choosing. | | `Answer(IEnumerable>? fields)` | effects | Answers the form showing, field by field in the order given. `null` is the reader leaving the form. | | `Elapsed()` | effects | Answers a `Hold` once your clock says it is over. | | `SetLocale(string locale)` | effects | Changes the language; the turn the story is on is answered again in it. | | `Restart()` | effects | Starts the story over from its first turn. | | `Stop()` | effects | Stops the story for good. | | `Pause()` | `Paused` | Puts the story down: its save and what pausing did. Pausing stops the story; resuming is a new session from the save. | | `Checkpoint()` | `string?` | A save of the turn the story is on, without stopping it; null when there is nothing to save. | | `ConsoleFeed(string code)` | `ConsoleResult` | Runs a line of Python in the story’s interpreter, as the editor’s console does. | | `WriteCvar(string name, Value value)` | `CvarChange` | Writes a console variable. | | `ResetCvar(string name)` | `CvarChange` | Puts a console variable back to its default. | | `ResetAllCvars()` | effects | Puts every console variable back to its default. | | `RemoveEntity(string key)` | `EntityRemoval` | Removes an entity from the story. | | Result record | Fields | | --------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `Paused` | `Save` (`string?`, JSON; null when there was nothing to save); `Effects` | | `CvarChange` | `Outcome` (`CvarWrite`: `Done`, `Unknown`, `Refused`); `Refusal` (`string?`, the sentence when refused); `Effects` | | `EntityRemoval` | `Removed` (`bool`); `Effects` | | `ConsoleResult` | `Result` (`PythonResult`); `Effects` | | `PythonResult` | `Value` (`Value`); `Output` (`string`); `Error` (`PythonFailure?`) | | `PythonFailure` | `Kind` (`uint`: 0 Python, 1 timeout, 2 refused, 3 closed, 4 host error, 5 unavailable); `Exception` (`string?`, the exception’s type for kind 0); `Message` (`string`); `Line` (`uint?`); `Traceback` (`string?`) | Every `Effects` field is an `IReadOnlyList` to dispatch like any verb’s answer. ### Readers [Section titled “Readers”](#readers) | Member | Type | What it is | | --------------------------------------------- | ----------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `Board` | `uint` | The board the story is on, by index into the manifest’s boards. | | `Locale` | `string` | The language the story reads in. | | `DefaultLocale` | `string` | The campaign’s default language. | | `Ambience` | `Ambience?` | The soundscape in force; null for silence. | | `Entities` | `IReadOnlyList` | What the story has made. | | `Memory` | `Memory` | The story’s own store. | | `Cvars` | `IReadOnlyList` | Every console variable and its value. | | `Guard` | `GuardReport` | What the script guard has had to do so far: `Abandoned` (`IReadOnlyList`, the nodes whose scripts were abandoned), `Refused` (`uint`), `Exhausted` (`bool`). | | `PythonAvailable` | `bool` | Whether this session can run scripts: `false` on the `NoPython` engine. | | `DescribeCvarWrite(string name, Value value)` | `string?` | Why the write would be refused; null when it would not. Writes nothing. | | `SetTrace(bool enabled)` | `void` | Turns `Effect.Trace` on or off. | | `SetTracePayloads(bool enabled)` | `void` | Turns traced payloads on or off. | | `DangerousHandle` | `IntPtr` | The engine’s handle, for a call the bindings do not wrap. | ### Saving and the generator [Section titled “Saving and the generator”](#saving-and-the-generator) * A save is JSON text. Keep it with the seed and the locale. * A save carries no generator state and no soundscape position: the same seed gives the same future rolls, and a soundscape starts from the top on resume. * `Generator` (get and set) is the dice’s state, `Generator(uint Lo, uint Hi)`. Read it before saving and set it on the resumed session for the dice to run on exactly. ## Effects [Section titled “Effects”](#effects) `Effect` is an abstract record with a closed set of nested sealed records, so `Effect.Trace` never collides with `System.Diagnostics.Trace`. Every effect is a copy: nothing in it points into the engine, and it outlives the session and the campaign. | Member | What it is | | -------- | ----------------------------------------------------------------------------------------------- | | `IsTurn` | `true` for a Turn: where the story now is. Exactly one closes every batch that moved the story. | | `IsOver` | `true` for `Ended`, `Failed` and `Stopped`. | ### Turns [Section titled “Turns”](#turns) | Effect | Fields | Answered with | | ---------------- | ------------------------------------------------------------------------------------------------------- | ------------------------------------------------ | | `Effect.Show` | `Passage` (`Passage`) | `Choose` or `Leave` | | `Effect.Ask` | `Prompt` (`Prompt`) | `Answer` | | `Effect.Hold` | `Ms` (`ulong`) | `Elapsed`, once your clock says `Ms` have passed | | `Effect.Ended` | `Title` (`string?`); `RanOut` (`bool`, true when the story ran out of road rather than reaching an End) | nothing; `IsOver` | | `Effect.Failed` | `Message` (`string`); `Node` (`string?`) | nothing; `IsOver` | | `Effect.Stopped` | none | nothing; `IsOver` | ### Along the way [Section titled “Along the way”](#along-the-way) | Effect | Fields | What it is | | ------------------------ | ----------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------- | | `Effect.Jumped` | `Board` (`uint`); `Node` (`string?`) | The story moved to another board, by index into the manifest’s boards. | | `Effect.AmbienceChanged` | `Ambience` (`Ambience?`) | The soundscape now in force; null for silence. A fact for a HUD or a save screen, not a command for the speaker. | | `Effect.Audio` | `Command` (`AudioCommand`) | A command for the sound: give it to `SoundscapeDriver.Apply`. | | `Effect.Changed` | `Entities` (`IReadOnlyList?`); `Memory` (`Memory?`); `Cvars` (`IReadOnlyList?`) | Which stores moved; a store that did not is null. The first `Changed` after `Start` names all three. | | `Effect.Callback` | `Event` (`CallbackEvent`) | A named callback the author declared, fired. | | `Effect.Trace` | `Event` (`RunEvent`) | One event of the run’s trace, when the session traces. | ### Records [Section titled “Records”](#records) | Record | Fields | | --------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `Passage` | `Node`, `Title` (`string`); `Text` (`string`, Markdown, or HTML when `Manifest.TextFormat` says so); `Choices` (`IReadOnlyList`); `Sources` (`IReadOnlyList?`, the text in every language when the compiler kept them); `Snippets` (`IReadOnlyList`); `Media` (`IReadOnlyList`); `Untranslated` (`string?`, the language the text is missing in); `Soundscape` (`Soundscape?`, the one the passage names); `Silence` (`bool`, true when the passage asks for silence) | | `Choice` | `Port` (`string`, the control output the flow leaves by); `Text` (`string`); `Available` (`bool`, false for a choice not open now; still shown, and choosing it throws `HostException` with `Code.ChoiceSpent`) | | `LocaleText` | `Locale`, `Text` (`string`) | | `Snippet` | `Code` (`string`); `IsInline` (`bool`) | | `MediaLink` | `Link` (`string`, the name the author linked it under); `Media` (`uint`, by index into the manifest’s media) | | `Prompt` | `Node`, `Title` (`string`); `Fields` (`IReadOnlyList`) | | `PromptField` | `Name`, `Type` (`string`: `number`, `bool`, `string`, `numberList`, `boolList`, `stringList` or `json`); `Default` (`Value`). Answer with the matching type. | | `Ambience` | `Soundscape` (`Soundscape`); `Board` (`uint`); `Node` (`string`, the node that set it) | | `Entity` | `Key`, `Label` (`string`); `Component` (`string?`); `Values` (`Value`, a map, field by field) | | `Memory` | `Nodes` (`Value`, a map, node by node); `Machines` (`IReadOnlyList>`, each state machine and the state it is in) | | `CvarSnapshot` | `Name`, `Type` (`string`); `Default`, `Value` (`Value`) | | `CallbackEvent` | `Callback` (`string`, the name a handler is registered by); `Event` (`uint`, a `RippleCallbackKind`); `EventName` (`string`, such as `cvar.changed`); `Board` (`uint?`); `Node`, `Name` (`string?`); `From`, `To` (`Value?`); `Index` (`uint?`). Which optional fields are set depends on the event. | | `AudioCommand` | `Kind` (`AudioKind`: `Play`, `Stop`, `Cut`, `Cue`); `Soundscape` (`Soundscape?`, Play: what to play); `FadeMs` (`ulong`, Play: the fade-in, Stop: the fade-out); `Media` (`uint`, Cue: the medium); `Volume`, `Speed` (`double`, default 1.0); `Node` (`string?`, Cue: the node that asked for it) | | `RunEvent` | `Kind` (`RunEventKind`: `RunStarted`, `NodeStarted`, `NodeFinished`, `MemoHit`, `Diagnostic`, `Log`, `RunFinished`); `Run`, `Board`, `Sequence`, `Step`, `Steps` (`uint`); `Node`, `Entry`, `Message` (`string`); `Primed`, `Flowed` (`IReadOnlyList`); `EnteredVia`, `Error`, `FailedNode` (`string?`); `Pulled`, `Cancelled` (`bool`); `Inputs`, `Outputs` (`IReadOnlyList`); `Outcome` (`uint`: 0 done, 1 failed); `Diagnostic` (`RunDiagnostic?`); `Level` (`uint`: 0 debug, 1 info, 2 warning, 3 error); `Data` (`Value?`). Read the fields by `Kind`. | | `TraceValue` | `FromNode`, `FromPort` (`string`); `ToNode`, `ToPort` (`string?`); `Payload` (`PayloadKind`: `Present`, `Withheld`, `Absent`); `Value` (`Value`, set when `Payload` is `Present`) | | `RunDiagnostic` | `Issue` (`uint`: 0 ambiguous flow, 1 multiple inputs, 2 producer not run, 3 re-entered); `Message` (`string`); `Node`, `Port` (`string?`); `Wires` (`uint?`) | The `uint` kinds match the enums of the same name in `RippleFx.Engine.Interop`, such as `RippleCallbackKind` and `RippleRunIssue`. ## Values [Section titled “Values”](#values) `Value` is a sealed, immutable class: a story’s value. `ValueKind` is `Null`, `Bool`, `Int`, `Float`, `Text`, `List` or `Map`. | C# | Story value | Make one | Read it | | -------------------------------------------- | ----------- | ------------------------------------------------------------------------------------------------------ | ---------------------- | | none | null | `Value.Null` | `IsNull` | | `bool` | Bool | `Value.Bool(b)`, or implicit from `bool` | `AsBool()` | | `long` | Int | `Value.Int(n)`, or implicit from `int` or `long` | `AsInt()` | | `double` | Float | `Value.Float(x)`, or implicit from `double` | `AsFloat()` | | `string` | Text | `Value.Text(s)`, or implicit from `string` (`null` becomes `Value.Null`) | `AsText()` | | `IReadOnlyList` | List | `Value.List(IEnumerable)`, `Value.List(params Value[])` | `AsList()` | | `IReadOnlyList>` | Map | `Value.Map(IEnumerable>)`, `Value.Map(params (string Key, Value Value)[])` | `AsMap()`, `Find(key)` | * **An Int and a Float never become each other.** `7` and `7.0` are different values, and `ToString()` prints them differently. * **A map keeps the order it was written in**, and its equality includes that order. A map key is never null. * **A null item or map value becomes `Value.Null`.** * `AsBool()`, `AsInt()` and the rest throw `InvalidOperationException` for a value of another kind. * `Find(string key)` answers a map’s value under the key, or null for a missing key or for a value that is not a map. * `Equals` and `==` compare what is held; a value implements `IEquatable`. * `ToString()` is JSON-like text for a log or a debugger. `ToStoryText()` asks the engine for the text a passage would print. ## Player [Section titled “Player”](#player) `Player` is the loop over a session, written once: `IDisposable`, owning no clock and no thread. ```csharp public Player(Session session, PlayerHandlers handlers, Func? now = null) ``` The player owns `session` from then on and disposes it. `now` is the clock in milliseconds; `Clocks.Steady()` when null. ### PlayerHandlers [Section titled “PlayerHandlers”](#playerhandlers) | Field | Type | What it is | | ---------- | -------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `OnEffect` | `Action?` | Every effect, in the order the engine produced it. | | `OnError` | `Action?` | The failure of a verb the player made by itself (the `Elapsed` after a hold), or an exception from a handler. Delivery goes on either way. Without one, a self-made verb’s failure is thrown from `Update()` and a handler’s is lost. | | `Audio` | `SoundscapeDriver?` | Where the story’s sound goes: the player applies every `Effect.Audio` to it. Without one, nothing is heard. | ### Members [Section titled “Members”](#members-1) | Member | What it does | | ------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------ | | `Start()`, `Choose(int)`, `Leave()`, `Answer(fields)`, `SetLocale(string)`, `Restart()`, `Stop()` | The session’s verbs. The player delivers the batch they answer instead of returning it. After `Dispose` they do nothing. | | `Pause()` | Puts the story down and answers its save, `string?`. Null, and nothing stopped, unless the story is on a passage or a prompt. | | `Update()` | Call it from your loop. Answers a hold whose time has come with `Elapsed()`. | | `Turn` | `Effect?`: the last Turn delivered; null before `Start()`. | | `HoldDue` | `ulong?`: when the pending hold is due by the player’s clock; null when none is. | | `Session` | The session underneath, for what the player does not wrap. A verb called on it goes round the player and its batch is not delivered. | | `OnCallback`, `RemoveCallback`, `OnAnyCallback`, `ClearCallbacks`, `CallbackMisses` | The callback table: [Named callbacks](#named-callbacks). | | `Dispose()` | Delivers nothing more, moves the story no further, and disposes the session. | ### Delivering a batch [Section titled “Delivering a batch”](#delivering-a-batch) For each effect in order, the player: 1. records it as `Turn` when it is a Turn; 2. applies an `Effect.Audio` to `Audio`; 3. dispatches an `Effect.Callback` through the callback table; 4. calls `OnEffect`. When the batch ends with `Effect.Hold`, `HoldDue` is set to the clock plus `Ms`, and the next `Update()` at or after it calls `Elapsed()`. Any verb clears a pending hold. **A verb called from inside a handler** throws `ReentrantVerbException` (`Code.Reentrant`): the rest of the batch would arrive after the new one. Queue the verb and call it after the handler returns. ## Named callbacks [Section titled “Named callbacks”](#named-callbacks) The author declares a callback’s name and event; you register a handler against the name on the `Player`. The engine never sees the table. | Member | What it does | | -------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------- | | `OnCallback(string name, Action handler)` | Calls `handler` whenever the callback named `name` fires, replacing any handler already under that name. Names are compared ordinally. | | `RemoveCallback(string name)` | Removes the handler under `name`. | | `OnAnyCallback(Action handler)` | Calls `handler` for every callback that fires, registered or not. Adds to any already there. | | `ClearCallbacks()` | Removes every named and every catch-all handler. | | `CallbackMisses` | `long`: how many callbacks fired with nothing registered under their name. | * **A handler is read-only.** It cannot change what the story does; the story plays on identically with nobody listening. * **A miss is ordinary**, not a failure: it is counted in `CallbackMisses` and delivery goes on. * **A handler that throws** is absorbed: the exception goes to `OnError`, and the rest of the batch is still delivered. * **A handler runs inside the verb**, on the thread that called it, and cannot call a verb (see [Player](#player)). * **A name is the only join** between the author’s declaration and your code. A rename on the author’s side breaks the match without any diagnostic. A host not using `Player` reads `Effect.Callback` from the batch and looks up `Event.Callback` in a table of its own. ## Sound [Section titled “Sound”](#sound) No speaker ships. The session says what the story asks for as `Effect.Audio`; the engine’s scheduler (`Mixer`) decides every voice; `SoundscapeDriver` drives the scheduler; your `ISpeaker` makes the sound. The contract is [`docs/speaker.md`](https://github.com/ConnectingTheDots-DCWK/ripple_effect_csharp_integration/blob/main/docs/speaker.md). ### AudioCommand [Section titled “AudioCommand”](#audiocommand) | `Kind` | Fields used | What it asks | | ------ | ---------------------------------- | --------------------------------------- | | `Play` | `Soundscape`, `FadeMs` (fade-in) | Play a soundscape. | | `Stop` | `FadeMs` (fade-out) | Stop the soundscape. | | `Cut` | none | Stop everything at once, cues included. | | `Cue` | `Media`, `Volume`, `Speed`, `Node` | Play a sound once. | ### SoundscapeDriver [Section titled “SoundscapeDriver”](#soundscapedriver) ```csharp public SoundscapeDriver(Campaign campaign, ISpeaker speaker, SoundscapeOptions? options = null) ``` | `SoundscapeOptions` field | Type | Default | What it is | | ------------------------- | -------------- | ------- | ------------------------------------------------------- | | `MaxCues` | `uint` | `8` | How many cues may sound at once; the oldest gives way. | | `GainStep` | `double` | `0.004` | The smallest change of gain worth a gain command. | | `Muted` | `bool` | `false` | Start muted. | | `Now` | `Func?` | `null` | The clock in milliseconds; `Clocks.Steady()` when null. | | Member | What it does | | ----------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------- | | `Apply(AudioCommand command)` | Obeys one of the session’s audio commands. A `Play` with no soundscape, and a `Cue` whose medium has no path, are ignored. | | `Update()` | Call it from your loop: hears what the voices reported and moves the timeline on. The editor ticks every 25 ms, and more often loses nothing. | | `Pause()`, `Resume()` | Holds every voice and the timeline with them, and lets them go. Does not pause the story. | | `Muted` | `bool`, get and set: mutes or unmutes at once. The scheduler writes every gain as zero and remembers the rest. | | `Playing` | `string?`: the soundscape in force, by id, sounding or waiting its turn. | | `Idle` | `bool`: whether nothing sounds, waits or cues. | | `Voices` | `int`: how many voices the speaker holds open now. | | `Dispose()` | Stops every voice the speaker holds, and the scheduler with them. | The driver does what the scheduler asks of a host, so a speaker must not: the tick, a late voice (the time `Open` took, times `Rate`, is added to `FromMs`, and a looping clip wraps inside its loop; a clip already over is reported ended), reporting voices ended or measured, telling the scheduler about tracks with no file, mute, and the handover between soundscapes. A failure inside the scheduler stops the sound rather than the story: the driver swallows it. The driver reads media paths from its campaign, so keep the campaign undisposed while the driver is in use. ### ISpeaker and IVoice [Section titled “ISpeaker and IVoice”](#ispeaker-and-ivoice) ```csharp public interface ISpeaker { IVoice? Open(VoiceSource source, VoiceRole role, VoiceEvents events); bool Resolves(VoiceSource source); } public interface IVoice { void Play(VoicePlay request); void SetGain(double gain); void Pause(); void Resume(); void Stop(); } ``` They are interfaces, so a `MonoBehaviour` or a Godot `Node` can be the speaker. | Member | Obligation | | --------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | | `ISpeaker.Open` | Return once the voice can begin at once: the file opened, or enough of it decoded. Return null when the file cannot be played; that is heard as silence and never retried. Keep `events` with the voice. `role` is a hint: a `Track` may be minutes long and is usually better streamed, a `Cue` is short and must start on time. For a campaign opened from memory `source.File` is null: read `Campaign.MediaBytes(source.Media)`. | | `ISpeaker.Resolves` | Asked before a soundscape plays. Answer false for a medium the speaker cannot play, so the track is told to the scheduler as silence up front. Answer true when unsure. Required; there is no default. | | `IVoice.Play` | Begin at once, replacing whatever the voice was playing. May be called again on the same voice for a gapless bed whose file ended. Play from `FromMs` to `ToMs`, or to the file’s end. When `Looping`, reaching `ToMs` comes round to `LoopFromMs`, not `FromMs`. | | `IVoice.SetGain` | Write a new gain. The scheduler writes one every tick a fade moves, so smooth between them over a few milliseconds. | | `IVoice.Pause`, `IVoice.Resume` | Hold the voice exactly where it is, and let it go again. | | `IVoice.Stop` | End the voice for good and release its resources. No `Ended` follows. | | `VoiceEvents.Ended()` | Call once per play, when a non-looping voice reaches the end of what it was given. Never after `Stop()`, never for a looping voice. | | `VoiceEvents.Duration(double ms)` | Call once, when the file’s length is known. | Both `VoiceEvents` calls are thread-safe and may come from an audio thread; the driver hears them on its next `Update()`. A call from a voice that was stopped, or after the driver is gone, is ignored. **The gain scale is cubic.** A speaker writing amplitude writes `gain * gain * gain`. | Record or enum | Fields | | ----------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `VoiceSource` | `Media` (`uint`, by index into the manifest’s media); `Path` (`string`, inside the bundle); `File` (`string?`, on disk, for a campaign opened from a folder) | | `VoicePlay` | `FromMs` (`double`); `ToMs` (`double?`, the file’s end when null); `Gain` (`double`, 0 to 1 on the cubic scale); `Rate` (`double`, 1 is as recorded); `Looping` (`bool`); `LoopFromMs` (`double`) | | `VoiceRole` | `Track`, `Cue` | | `Clocks.Steady()` | `Func`: milliseconds since the clock was made, monotonic, from `Stopwatch` | ### Mixer [Section titled “Mixer”](#mixer) `Mixer` is the engine’s scheduler, raw, for a host not using the driver: commands in, `IReadOnlyList` out, your clock on every verb. A host that uses it directly owes the driver’s duties above by hand. | Member | What it does | | ----------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------ | | `Mixer.Create(uint maxCues = 8, double gainStep = 0.004)` | A new scheduler. | | `Play(ulong nowMs, Soundscape scape, IReadOnlyList? unresolved = null, ulong atMs = 0, bool muted = false)` | Plays a soundscape from `atMs` into it. Tracks listed in `unresolved`, by index, are heard as silence. | | `Stop(nowMs)`, `Cut(nowMs)`, `Pause(nowMs)`, `Resume(nowMs)`, `Tick(nowMs)` | The scheduler’s verbs. | | `Cue(ulong nowMs, uint media, double volume, double speed)` | Plays a sound once. | | `VoiceEnded(ulong nowMs, ulong voice)` | A voice reached its end. | | `DurationKnown(ulong voice, ulong ms)` | A voice’s file length is known. | | `Muted` | `bool`, get and set. | | `PlayingId`, `FinishingId` | `string?`: the soundscape in force, and the one fading out, by id. | | `State` | `Phase?`; `TrackState(uint track)` answers `Phase`. `Phase` is `Queued`, `Starting`, `Playing`, `Finishing`, `Finished`. | | `PositionMs` (`ulong`), `Pass` (`uint`), `RanOut`, `IsPaused`, `IsIdle` (`bool`) | Where the scheduler is. `IsIdle` is when a tick may rest. | | `Dispose()` | Releases the scheduler. | `VoiceCommand` fields: `Kind` (`VoiceKind`: `Open`, `Gain`, `Stop`, `Pause`, `Resume`); `Voice` (`ulong`); `Media`, `Track` (`uint`); `FromMs`, `LoopFromMs` (`ulong`); `ToMs` (`ulong?`); `Gain` (`double`); `Rate` (`double`, default 1.0); `Looping` (`bool`); `Role` (`VoiceRole`). ## Errors [Section titled “Errors”](#errors) Every failure the engine reports is a `RippleException` subclass. `Failure` carries what the engine said, and `Code` is `Failure.Code`. A verb refused with a `HostException` moved nothing. | Exception | `Code` values | Meaning | Met in | | -------------------------- | -------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------- | | `BundleException` | `BundleFormat` (1), `BundleVersion` (2) | A document of the bundle is malformed or missing, or is from a version this engine cannot read. | Normal use: opening a bad or newer bundle. | | `NeedsPythonException` | `NeedsPython` (3) | The bundle needs Python, and this is the `NoPython` engine. | Normal use: opening. | | `HostException` | `WrongTurn` (10), `NoSuchChoice` (11), `ChoiceSpent` (12), `Stopped` (13), `NothingAsked` (14), `UnknownLocale` (15) | A verb that does not answer the turn, a choice past those offered, a choice used up or closed, a session that has stopped, a verb before `Start()`, a language the campaign is not readable in. | A host bug, except `ChoiceSpent` when you offer an unavailable choice and `UnknownLocale` from user input. | | `ResumeException` | `BoardGone` (20), `NodeGone` (21) | A save names a board or node the campaign no longer has. | Normal use: a save from an older build of the story. | | `InvalidArgumentException` | `InvalidArgument` (-1) | A malformed argument: a null handle, text that is not UTF-8, a value out of range. Also any code the binding does not know. | A bug. | | `EngineTrapException` | `Trap` (-2) | The engine panicked, now or earlier on this handle. | A bug in the engine. | | `ReentrantVerbException` | `Reentrant` (-100) | A `Player` verb called while it was delivering a batch. The binding’s own code. | A host bug. | | `EngineNotFoundException` | none; derives from `Exception` | The engine library could not be loaded for this platform, or binds another contract. | Packaging: reference exactly one engine package of the same `MAJOR.MINOR`. | `Code.Success` (0) is never thrown. | `Failure` field | Type | Set for | | -------------------- | ---------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | | `Code`, `Message` | `Code`, `string` | every failure | | `Location` | `string?` | `BundleFormat`: the document at fault | | `Found`, `Supported` | `long` | `BundleVersion`: the bundle’s format, and the newest this engine reads | | `Oldest` | `long?` | `BundleVersion`: the oldest, when there is a floor | | `Asked`, `Answered` | `uint` | `WrongTurn`: what the story was asking (`RippleTurnKind`: 0 present, 1 ask, 2 wait, 3 ended, 4 failed, 5 stopped) and the verb that answered (`RippleVerb`: 0 choose, 1 answer, 2 elapsed) | | `Offered`, `Chosen` | `uint` | `NoSuchChoice`: how many choices were offered, and the index taken | | `Subject` | `string` | the choice, locale, board or node a host or resume error is about | | `Node` | `string` | `NodeGone`: the node | Other exceptions the bindings throw: * `ObjectDisposedException` from any member of a disposed `Campaign`, `Session` or `Mixer`. * `ArgumentOutOfRangeException` from `Choose` with a negative index; leave with `Leave()`. * `InvalidOperationException` from `Value.AsBool()` and the other readers for a value of another kind. **A trap poisons the handle.** After `EngineTrapException`, dispose the session (and the campaign, if the trap came from it), open again, and resume from the last checkpoint. Every later call on a poisoned handle throws `EngineTrapException` again. ## Threading and lifetime [Section titled “Threading and lifetime”](#threading-and-lifetime) * **One caller at a time per handle.** A `Campaign`, `Session` or `Mixer` is not thread-safe. A handle may move between threads. * **The engine starts no thread, never sleeps and never reads a clock.** A `Hold` is answered by your clock. `Player` and `SoundscapeDriver` read the clock they were given and own no thread: your game loop is the timer. * **`VoiceEvents` is the one thing callable from any thread.** * **Every verb is synchronous.** A script runs inside the verb that reached it, for up to `ScriptDeadlineMs`. If that cannot be spent on your main thread, own the session on a worker and marshal the effects back: they are values. * **Everything a verb answers is a copy.** Effects, passages and values are C# records that outlive the session and the campaign. The engine’s batch is freed before the verb returns, and `RippleEngine.Outstanding` is zero whenever no call is in flight. * **`Campaign`, `Session`, `Mixer`, `SoundscapeDriver` and `Player` are `IDisposable`.** A forgotten `Dispose` is collected by the finalizer rather than leaked. A disposed handle throws `ObjectDisposedException` rather than reaching the engine. * **A session outlives its campaign:** dispose the campaign first and the session goes on working. * **`Player` owns its session** and disposes it. It does not own its `SoundscapeDriver`. ## A complete host [Section titled “A complete host”](#a-complete-host) A terminal host over a bundle folder: two sittings with a save between them, a speaker that prints what it would play, the author’s callbacks, and recovery from a trap. ```csharp using System; using System.Collections.Generic; using System.Globalization; using System.Threading; using RippleFx.Engine; public static class Host { static readonly ulong Seed = (ulong)DateTime.UtcNow.Ticks; static readonly SessionOptions Options = new SessionOptions { Untranslated = Untranslated.FallBack }; static readonly Queue Later = new Queue(); // verbs a handler asked for static string? autosave; // the last Checkpoint(), kept with Seed public static int Main(string[] args) { var folder = args[0]; try { Console.WriteLine($"engine {RippleEngine.Version}, contract {RippleEngine.Contract}, Python {RippleEngine.HasPython}"); using var campaign = Campaign.OpenDirectory(folder); var manifest = campaign.Manifest; // read once and kept foreach (var problem in campaign.Validate()) Console.Error.WriteLine($"{problem.Location}: {problem.Message}"); var config = new SessionConfig { Board = manifest.Entries[0], Locale = manifest.Project.DefaultLocale, Seed = Seed, Options = Options, }; using var audio = new SoundscapeDriver(campaign, new LoggingSpeaker(campaign)); // The first sitting: three passages, then the story is put down. string? save; using (var player = NewPlayer(Session.Create(campaign, config), audio)) { player.Start(); save = Play(player, audio, passages: 3) ? player.Pause() : null; } // The second sitting: a new session from the save, with the same seed. if (save != null) { using var player = NewPlayer(Session.Resume(campaign, save, Seed, Options), audio); player.Start(); // re-shows the turn the save was on Play(player, audio, passages: int.MaxValue); } return 0; } catch (EngineNotFoundException error) { Console.Error.WriteLine(error.Message); // names the packages to reference return 3; } catch (NeedsPythonException) { Console.Error.WriteLine("This campaign runs Python: reference RippleFx.Engine.Native."); return 2; } catch (BundleException error) { Console.Error.WriteLine($"{error.Failure.Location}: {error.Message}"); return 2; } catch (ResumeException) { Console.Error.WriteLine("This save is from an older build of the story."); return 2; } catch (EngineTrapException) when (autosave != null) { // The handles that saw the trap are poisoned: open again and resume from the checkpoint. using var campaign = Campaign.OpenDirectory(folder); using var audio = new SoundscapeDriver(campaign, new LoggingSpeaker(campaign)); using var player = NewPlayer(Session.Resume(campaign, autosave!, Seed, Options), audio); player.Start(); Play(player, audio, passages: int.MaxValue); return 0; } } static Player NewPlayer(Session session, SoundscapeDriver audio) { var player = new Player(session, new PlayerHandlers { Audio = audio, // every Effect.Audio goes to the driver OnError = error => Console.Error.WriteLine($"host: {error.Message}"), OnEffect = effect => { if (effect is Effect.Changed { Cvars: { } cvars }) foreach (var cvar in cvars) Console.WriteLine($" [{cvar.Name} = {cvar.Value}]"); }, }); player.OnCallback("chapterTwoBegan", fired => Console.WriteLine($" [chapter two, board {fired.Board}]")); // A verb inside a handler throws ReentrantVerbException: queue it for after delivery. player.OnCallback("quitToTitle", fired => Later.Enqueue(() => player.Stop())); player.OnAnyCallback(fired => Console.WriteLine($" [{fired.Callback}: {fired.EventName}]")); return player; } // Plays until the story is over (false) or `passages` passages have been answered (true). static bool Play(Player player, SoundscapeDriver audio, int passages) { var answered = 0; while (true) { audio.Update(); // the sound's tick player.Update(); // answers a Hold that has come due while (Later.Count > 0) Later.Dequeue()(); switch (player.Turn) { case null: case Effect { IsOver: true }: Report(player.Turn); return false; case Effect.Hold: Thread.Sleep(25); // a frame; the engine never sleeps break; case Effect.Show shown: if (answered++ == passages) return true; autosave = player.Session.Checkpoint() ?? autosave; Choose(player, shown.Passage); break; case Effect.Ask asked: player.Answer(Fill(asked.Prompt)); // null leaves the form break; } } } static void Choose(Player player, Passage passage) { Console.WriteLine($"\n== {passage.Title} ==\n{passage.Text}"); // Markdown or HTML, by Manifest.TextFormat if (passage.Untranslated != null) Console.WriteLine($"(not translated into {passage.Untranslated})"); if (passage.Choices.Count == 0) { player.Leave(); // a dead end: leaving is the only answer return; } for (var i = 0; i < passage.Choices.Count; i++) { var choice = passage.Choices[i]; Console.WriteLine($" {i + 1}. {choice.Text}{(choice.Available ? "" : " (unavailable)")}"); } while (true) { Console.Write("> "); var line = Console.ReadLine(); if (line == null) { player.Leave(); // the reader leaves the passage return; } if (!int.TryParse(line, NumberStyles.Integer, CultureInfo.InvariantCulture, out var picked) || picked < 1 || picked > passage.Choices.Count) continue; try { player.Choose(picked - 1); return; } catch (HostException error) when (error.Code == Code.ChoiceSpent) { Console.WriteLine("That way is closed."); // nothing moved: ask again } } } static IEnumerable>? Fill(Prompt prompt) { Console.WriteLine($"\n-- {prompt.Title} --"); var answers = new List>(); foreach (var field in prompt.Fields) { Console.Write($"{field.Name} [{field.Default}]: "); var line = Console.ReadLine(); if (line == null) return null; // the reader leaves the form var value = line.Length == 0 ? field.Default : field.Type switch { "number" => long.TryParse(line, NumberStyles.Integer, CultureInfo.InvariantCulture, out var whole) ? Value.Int(whole) : Value.Float(double.Parse(line, CultureInfo.InvariantCulture)), "bool" => Value.Bool(line == "y"), _ => Value.Text(line), // the list types and json need parsing of their own }; answers.Add(new KeyValuePair(field.Name, value)); } return answers; } static void Report(Effect? turn) { switch (turn) { case Effect.Ended ended: Console.WriteLine(ended.RanOut ? "\n(the story ran out)" : $"\n{ended.Title ?? "The end"}"); break; case Effect.Failed failed: Console.WriteLine($"\nThe story could not go on at {failed.Node}: {failed.Message}"); break; case Effect.Stopped: Console.WriteLine("\n(stopped)"); break; } } } // Prints what it would play. A real speaker opens the file in your audio API. sealed class LoggingSpeaker : ISpeaker { private readonly Campaign campaign; public LoggingSpeaker(Campaign campaign) => this.campaign = campaign; public bool Resolves(VoiceSource source) => true; // true when unsure public IVoice? Open(VoiceSource source, VoiceRole role, VoiceEvents events) { // A campaign opened from memory has no File: the bytes come from the campaign. var bytes = source.File == null ? campaign.MediaBytes(source.Media) : null; if (source.File == null && bytes == null) return null; // silence, never retried return new LoggingVoice(source.Path, role); // a real voice keeps `events` } } sealed class LoggingVoice : IVoice { private readonly string path; private readonly VoiceRole role; public LoggingVoice(string path, VoiceRole role) { this.path = path; this.role = role; } public void Play(VoicePlay request) => Console.WriteLine($" [{role} {path}] from {request.FromMs:F0} ms, amplitude {Math.Pow(request.Gain, 3):F2}" + (request.Looping ? $", looping back to {request.LoopFromMs:F0} ms" : "")); public void SetGain(double gain) { } // amplitude is gain³, smoothed over a few ms public void Pause() { } public void Resume() { } public void Stop() => Console.WriteLine($" [{role} {path}] stop"); // for good: no Ended after this // A real voice calls events.Ended() from its audio thread at the end of a non-looping play, // and events.Duration(ms) once the file's length is known. } ``` The repository’s [`examples/ConsolePlayer`](https://github.com/ConnectingTheDots-DCWK/ripple_effect_csharp_integration/tree/main/examples/ConsolePlayer) is a runnable terminal player over any bundle folder. ## Related [Section titled “Related”](#related) * [Embedding the engine](/explanation/embedding-the-engine/): what a host is responsible for, and what the engine does itself. * [How the engine ships](/explanation/how-the-engine-ships/): engine releases, contracts and the two variants. * [Play a campaign in your program](/how-to/play-a-campaign-in-your-program/): the steps from an exported bundle to a running story. * [Host callbacks](/explanation/host-callbacks/): why the author declares a callback and the host registers it. * [Export a project or campaign](/how-to/export-a-project-or-campaign/): making the bundle this binding opens. # Dart binding > The ripplefx_engine package for Dart, Flutter and Flame, native and web — installing it, choosing the variant, and every class, member, effect, record and exception it exports. `ripplefx_engine` on [pub.dev](https://pub.dev/packages/ripplefx_engine), published by `connecting-the-dots.dev`, plays a compiled campaign from Dart: a command-line program, a server, a Flutter app or a [Flame](https://flame-engine.org) game. Natively it runs over `dart:ffi`, and on the web over `dart:js_interop`, with one API for both. Version 1.5.x binds Ripple Effect contract 1.5. The `PATCH` is the binding’s own, and each version pins one engine release (1.5.0 pins engine 1.5.4). The source is public at [ConnectingTheDots-DCWK/ripple\_effect\_dart\_integration](https://github.com/ConnectingTheDots-DCWK/ripple_effect_dart_integration). The package is MIT. The engine it loads is under the [Ripple Effect Runtime Licence](https://legal.ripplefx.app/runtime/), and its `NOTICE` must be reproduced wherever your product lists the third-party software it contains. Everything below is exported from one library: ```dart import 'package:ripplefx_engine/ripplefx_engine.dart'; ``` ## Packages and installing [Section titled “Packages and installing”](#packages-and-installing) ```sh dart pub add ripplefx_engine # or: flutter pub add ripplefx_engine ``` | | | | ------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | SDK floor | Dart 3.10 or Flutter 3.38, the first releases that run build hooks without an experiment flag. | | Dependencies | `archive`, `code_assets`, `crypto`, `ffi`, `hooks`, `web`. Nothing else to install. | | Native engine | Not in the package. The build hook brings it (below). | | Web engine | In the package, under `lib/src/web/engine/`: the component’s JavaScript glue and `.wasm` files for both variants, about 12 MB in all. A page downloads only the variant it loads. | ### The build hook [Section titled “The build hook”](#the-build-hook) On a native platform, every build that runs hooks — `dart run`, `dart test`, `dart build cli`, any `flutter run` or `flutter build` — runs the package’s `hook/build.dart`, which: 1. works out the engine target for the platform being built; 2. downloads that target’s C archive of the pinned engine release, from `ripplefx.app/go/engine/…` and then from the [engine releases](https://github.com/ConnectingTheDots-DCWK/ripple_engine_releases/releases), into the hook’s shared output directory; 3. checks its SHA-256 against the digest `engine.json` pins (taken from the release’s signed manifest) before anything is unpacked; 4. hands the shared library to the SDK, which bundles it beside your program. The download happens once per project and platform; later builds reuse it. A program built without the SDK’s hooks, such as a plain `dart compile exe`, bundles no library, and the first call that needs the engine throws `EngineNotFoundException`. A target with no engine build — 32-bit Android, a 32-bit desktop — gets no library and still builds; on such a device the first call throws `EngineNotFoundException`. ### User-defines [Section titled “User-defines”](#user-defines) The hook reads user-defines from **your** pubspec, the one at the root of your workspace. It sees no environment variables, proxy settings included. ```yaml hooks: user_defines: ripplefx_engine: python: false # the engine without Python engine_dir: third_party/ripple-engine/lib # a library of your own engine_tree: ../ripple_effect # an engine checkout: cargo builds it ``` | User-define | Value | Effect | | ------------- | --------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | | `python` | `true` (default) or `false` | Picks the variant. Any other value fails the build. | | `engine_dir` | a directory | Uses the library in it as it is, with nothing checked: `libripple_effect_engine.so`, `libripple_effect_engine.dylib` or `ripple_effect_engine.dll`. Download `ripple-engine-c-[-no-python]-.tar.gz` from the engine releases and point at its `lib/`. For a build with no network, behind a proxy, or a vendored library. | | `engine_tree` | an engine checkout | Builds the engine with `cargo`, desktop targets only, and prints that the engine is UNVERIFIED. A phone build fails and names `engine_dir` instead. | A relative path is relative to the pubspec that names it. `engine_dir` wins over `engine_tree`, and either replaces the download. ## Choosing the variant [Section titled “Choosing the variant”](#choosing-the-variant) Every engine release is two builds: **with Python**, the default, and **without**, about half the size, which plays every campaign exported with *Needs no Python*. | Platform | How to choose the engine without Python | | -------- | ----------------------------------------------------------------------------------------------------------------------------------------------------- | | Native | `python: false` under `hooks: user_defines: ripplefx_engine:` in your pubspec. | | Web | `--dart-define=RIPPLEFX_PYTHON=false` on the build or run command. No hook runs for the web. Without the define, a page loads the engine with Python. | A campaign whose manifest `needs` names `python`, opened on the engine without Python, is refused at open with `NeedsPythonException`, on both backends. `RippleEngine.hasPython` and `Session.pythonAvailable` report which engine is loaded. ## Platforms [Section titled “Platforms”](#platforms) | Platform | Architectures | Floor | Notes | | -------- | --------------------------------------- | ---------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | Windows | x64, arm64 | | `ripple_effect_engine.dll`, built against the dynamic CRT. | | macOS | arm64, x64 | 12.0 | One library per architecture; a universal Flutter app takes both, wrapped in a framework with an `@rpath` install name. No entitlement needed. A plugin pod that keeps an older deployment target is pinned up in a `post_install` hook, as the example’s `macos/Podfile` shows. | | Linux | x64, arm64 | glibc 2.35 | No build for musl (Alpine). | | Android | arm64, x86\_64 | API 24 | Packed under `lib/arm64-v8a/` and `lib/x86_64/`. No 32-bit build: an APK that includes `armeabi-v7a` still builds, and throws `EngineNotFoundException` on a 32-bit device. `--target-platform android-arm64,android-x64` leaves that ABI out. | | iOS | device (arm64), Apple-silicon simulator | 13.0 | Wrapped in a framework with an `@rpath` install name. No Intel simulator build. No entitlement needed. Xcode 15 and later require a simulator deployment target of 15.0 or more; set `IPHONEOS_DEPLOYMENT_TARGET` and pin older plugin pods in a `post_install` hook, as the example’s `ios/Podfile` shows. | | Web | dart2js, dart2wasm | | Flutter web (`flutter build web`, with or without `--wasm`) or any other Dart web app. | The engine’s own target list is in [Engine targets](/reference/engine-targets/). ### What differs on the web [Section titled “What differs on the web”](#what-differs-on-the-web) | | Native | Web | | -------------------------- | ----------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | Backend | `dart:ffi` over the C ABI | `dart:js_interop` over the engine’s WebAssembly component | | Engine files | bundled by the build hook | Flutter web: `assets/packages/ripplefx_engine/…`, declared `platforms: [web]`, so no other build carries them. Other web builds: served from `packages/ripplefx_engine/src/web/engine/`. | | `RippleEngine.load(base:)` | answers at once | fetches, compiles and instantiates the engine. `base` names another place the files are served from, such as a CDN: a URL holding `python/`, `no-python/`, `wasi.js` and `bridge.js`. Copy the package’s `lib/src/web/engine/` folder there and call `load(base:)` before anything else. | | Engine-level calls | work at once | `RippleEngine.version`, `contract`, `hasPython`, a `Mixer` made on its own and `Value.toStoryText` throw `EngineNotFoundException` until `load()` has been awaited. Opening a campaign awaits it. | | `Campaign.openDirectory` | opens a folder | throws `UnsupportedError`. Use `Campaign.openFiles`. | | `Campaign.mediaFile` | the file on disk, for a campaign opened from a folder | always `null` | | A trap | poisons the handle it happened on | each campaign runs in an engine instance of its own, so a trap poisons that campaign and its sessions only | | `RippleEngine.outstanding` | the engine’s count | always `0` | | A 64-bit Int | exact | exact under dart2wasm. Under dart2js an Int beyond ±2^53 rounds to the nearest double, and `Value.from(7.0)` is an Int; build a Float as `FloatValue(7.0)`. A story that counts past ±2^53 needs `--wasm`. | | Content Security Policy | | must allow `script-src 'wasm-unsafe-eval'` and the engine’s files from wherever they are served | ## The engine [Section titled “The engine”](#the-engine) `RippleEngine` is a class of static members. | Member | Type | Meaning | | ------------------- | ---------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `boundContract` | `String` (const) | The contract these bindings bind, `'1.5'`. | | `load({Uri? base})` | `Future` | Loads the engine and checks it binds `boundContract`. A no-op natively; on the web the one await before the engine-level getters work. `base` is web-only. Throws `EngineNotFoundException`. | | `ensureLoaded()` | `void` | Natively, loads the library and checks its contract, once; every handle’s constructor calls it. On the web, checks that `load()` has been awaited. Throws `EngineNotFoundException`. | | `version` | `String` | The loaded engine’s version, such as `1.5.4`. | | `contract` | `String` | The contract the loaded engine binds, such as `1.5`. | | `hasPython` | `bool` | `false` for the engine without Python. | | `outstanding` | `int` | How many things the engine handed out and has not had back. Zero whenever no call is in flight. Always `0` on the web. | ## Campaign [Section titled “Campaign”](#campaign) A bundle, opened. Immutable and shared by every session made from it. It may be disposed before its sessions, which go on working. ### Opening [Section titled “Opening”](#opening) | Factory | Answers | Notes | | ------------------------------------------------ | ------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------ | | `Campaign.openDirectory(String folder)` | `Future` | The folder that holds `campaign.json`. Files are read as the story asks for them, never from outside the folder. Native only; `UnsupportedError` on the web. | | `Campaign.openFiles(Iterable files)` | `Future` | Every file of the bundle, by its path inside it. The bytes are copied. For a Flutter app’s assets, a packed archive, or the web. | `BundleFile(String path, Uint8List bytes)` is one file held in memory, by its path inside the bundle (`campaign.json`, `boards/…`). Opening throws `BundleException` for a malformed, missing or newer bundle, and `NeedsPythonException` on the engine without Python. ### Members [Section titled “Members”](#members) | Member | Type | Meaning | | ---------------------------- | --------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------- | | `manifest` | `Manifest` | `campaign.json`, read afresh on every call. Keep it rather than ask again. | | `board(int index)` | `Board` | A board, by index into `manifest.boards`. | | `soundscape(int index)` | `Soundscape` | A soundscape, by index into `manifest.soundscapes`. | | `boardWithId(String id)` | `int?` | A board’s index by its id; `null` when there is none. | | `node(int board, String id)` | `Node?` | A node of a board, for when a trace names one; `null` when there is none. | | `validate()` | `List` | Everything wrong with the bundle the engine can see without playing it. Each `BundleProblem` has `message` and `location`. | | `mediaPath(int index)` | `String?` | A medium’s path inside the bundle; `null` when there is no such medium. | | `mediaFile(int index)` | `String?` | A medium’s file on disk, for a campaign opened from a folder; `null` otherwise, and always on the web. | | `mediaBytes(int index)` | `Uint8List?` | A medium’s bytes; `null` when there is no such medium (on the web, also when its bytes were not among the files opened). | | `folder` | `String?` | The folder it was opened from; `null` for a bundle in memory, and always on the web. | | `handle` | `Pointer` natively, `JSObject` on the web | The engine’s handle, for a call the binding does not wrap. Good while the campaign is neither disposed nor collected. | | `dispose()` | `void` | Frees the campaign now. Anything else on it then throws `StateError`. A second call does nothing. | ### Manifest [Section titled “Manifest”](#manifest) | Field | Type | Meaning | | --------------------------------------------------------------------------------- | -------------- | ---------------------------------------------------------------------------------------------------------- | | `format` | `int` | The bundle format. | | `generator` | `String?` | What compiled the bundle. | | `textFormat` | `String` | `markdown` or `html`: what every passage’s text is written in. | | `project` | `Project` | `title`, `description`, `defaultLocale`, `locales` (`List`), `cover` (`String?`). | | `entries` | `List` | The boards a story may begin on, by index into `boards`. Usually `entries.first` is `SessionConfig.board`. | | `boards`, `passages`, `components`, `scripts`, `media`, `machines`, `soundscapes` | `List` | The bundle’s tables. An `Entry` is `id`, `name`, `path`. | | `cvars` | `List` | The console variables the campaign declares: `name`, `type` (`String`), `defaultValue` (`Value`). | | `needs` | `List` | What the campaign needs of its host: `python` when it runs scripts. | ### Board and Node [Section titled “Board and Node”](#board-and-node) | Record | Fields | | ------- | --------------------------------------------------------------------------------------------- | | `Board` | `id`, `name`, `start` (`int`), `prime` (`List`), `nodes` (`List`) | | `Node` | `id`, `type`, `title`, `inputs` and `outputs` (`List`), `data` and `metadata` (`Value`) | | `Port` | `id`, `kind` (`PortKind.control` or `PortKind.data`), `edges` (`List`) | | `Edge` | `node` (`int`), `port` (`int`) | ## Session [Section titled “Session”](#session) One story being played. Every verb answers the effects it led to, in the order they are applied, the Turn last. ### Creating and resuming [Section titled “Creating and resuming”](#creating-and-resuming) | Factory | Notes | | ----------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `Session.create(Campaign campaign, SessionConfig config)` | A new session, before its first turn. Call `start()` next. | | `Session.resume(Campaign campaign, String save, {int seed = 0, SessionOptions? options})` | A session picking up a save — the JSON text `pause()` or `checkpoint()` answered — over the same campaign. Call `start()` next; it answers the turn the save was on. A save whose board or node the campaign no longer has throws `ResumeException`. | ### SessionConfig [Section titled “SessionConfig”](#sessionconfig) | Field | Type | Default | Meaning | | ----------- | ----------------- | -------- | ------------------------------------------------------------------------------------------------ | | `board` | `int` | required | The board, by index into `manifest.boards`; usually `manifest.entries.first`. | | `entryNode` | `String?` | `null` | A node to begin at instead of the board’s start. | | `locale` | `String?` | `null` | The language to read in; `null` for the default. A locale the campaign does not have is refused. | | `seed` | `int` | `0` | The dice: the same seed and the same moves play the same story. | | `digest` | `String?` | `null` | The bundle digest a save made from this session is stamped with. | | `options` | `SessionOptions?` | `null` | `null` is `SessionOptions()`. | ### SessionOptions [Section titled “SessionOptions”](#sessionoptions) `const SessionOptions()` is the engine’s own budgets and policies. Change one with `copyWith(...)`, which takes every field as an optional named argument. | Field | Type | Default | Meaning | | ---------------------- | -------------- | --------------------- | --------------------------------------------------------------------------------------------------------------------------------- | | `stepBudget` | `int` | `1048576` | Steps a run may take before it is stopped; `0` stops on the first. | | `silentJumpLimit` | `int` | `64` | Silent jumps between boards before the story is stopped. | | `loopLimit` | `int` | `512` | Times a loop may go round; `0` is no ceiling. | | `scriptDeadlineMs` | `int` | `2000` | How long a script may run. | | `scriptMemoryBytes` | `int` | `67108864` | How much memory a script may use. | | `scriptRecursionDepth` | `int` | `200` | How deep a script may recurse. | | `untranslated` | `Untranslated` | `Untranslated.report` | `report`: a passage with no text in the locale says so in `Passage.untranslated`. `fallBack`: it shows the default locale’s text. | | `trace` | `bool` | `false` | Whether every batch carries the run’s trace, as `Trace` effects. | | `tracePayloads` | `bool` | `false` | Whether the trace carries the values that flowed, not only that they did. | ### Verbs [Section titled “Verbs”](#verbs) | Verb | Answers | Answers which Turn | Notes | | ------------------------------------- | --------------- | ------------------ | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `start()` | `List` | any | Steps the story to its first turn, or, resumed, back to the turn it was saved on. | | `choose(int index)` | `List` | `Show` | The choice at `index` among `passage.choices`. A negative index throws `RangeError`. An unavailable choice throws `HostException` (`Code.choiceSpent`); an index past the end, `Code.noSuchChoice`. | | `leave()` | `List` | `Show` | The reader leaves the passage without choosing. | | `answer(Map? fields)` | `List` | `Ask` | The form’s fields by name, in the order given; `null` leaves the form. | | `elapsed()` | `List` | `Hold` | Answers a wait once your clock says it is over. | | `setLocale(String locale)` | `List` | any | Changes the language; the turn the story is on is answered again in it. | | `restart()` | `List` | any | Starts the story over from its first turn. | | `stop()` | `List` | any | Stops the story for good. | | `pause()` | `Paused` | any | Puts the story down: `save` (`String?`, `null` when there is nothing to save) and `effects`. Pausing stops the story; resuming is a new session from the save. | | `checkpoint()` | `String?` | | A save of the turn the story is on, without stopping it; `null` when there is nothing to save. | | `writeCvar(String name, Value value)` | `CvarChange` | | Writes a console variable, coerced to its type. | | `resetCvar(String name)` | `CvarChange` | | Puts a console variable back to its default. | | `resetAllCvars()` | `List` | | Puts every console variable back to its default. | | `removeEntity(String key)` | `EntityRemoval` | | Removes an entity from the story: `removed` (`bool`) and `effects`. | | `consoleFeed(String code)` | `ConsoleResult` | | Runs a line of Python in the story’s interpreter, as the editor’s console does: `result` (`PythonResult`) and `effects`. The engine without Python answers that it is unavailable. | A verb the story was not asking for throws `HostException` and moves nothing. `CvarChange` carries `outcome` (`CvarWrite.done`, `unknown` or `refused`), `refusal` (the sentence, when refused) and `effects`. ### Readers [Section titled “Readers”](#readers) | Reader | Type | Meaning | | --------------------------------------------- | -------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------ | | `board` | `int` | The board the story is on, by index into `manifest.boards`. | | `locale` | `String` | The language the story reads in. | | `defaultLocale` | `String` | The campaign’s default language. | | `ambience` | `Ambience?` | The soundscape in force; `null` for silence. | | `entities` | `List` | Every entity the story holds. | | `memory` | `Memory` | What the story remembers. | | `cvars` | `List` | Every console variable, with its value. | | `guard` | `GuardReport` | What the script guard has had to do so far: `abandoned` (the nodes whose scripts were abandoned), `refused` (`int`), `exhausted` (`bool`). | | `pythonAvailable` | `bool` | Whether this session can run scripts; `false` on the engine without Python. | | `describeCvarWrite(String name, Value value)` | `String?` | Why writing `value` would be refused; `null` when it would be taken. Writes nothing. | | `generator` | `Generator` | The dice’s state (`lo`, `hi`). Settable. | | `handle` | `Pointer` natively, `JSObject` on the web | The engine’s handle, for a call the binding does not wrap. | | Setter | Meaning | | -------------------------------- | -------------------------------------------------------------------- | | `setTrace(bool enabled)` | Turns `Trace` effects on or off. | | `setTracePayloads(bool enabled)` | Turns a traced value’s payload on or off. | | `generator = Generator(lo, hi)` | Sets the dice’s state. | | `dispose()` | Frees the session now. Anything else on it then throws `StateError`. | ### Saving [Section titled “Saving”](#saving) A save is JSON text, from `checkpoint()` (the story goes on) or `pause()` (the story stops). It carries no dice state and no soundscape position: the same seed gives the same future rolls, and a soundscape starts from the top. To keep the dice’s exact continuation, read `generator` from the old session and set it on the resumed one. ```dart final save = session.checkpoint(); // null unless on a passage or a form final dice = session.generator; final resumed = Session.resume(campaign, save!, seed: seed); resumed.generator = dice; final batch = resumed.start(); // the passage again ``` ## Effects [Section titled “Effects”](#effects) `Effect` is a sealed class. `Turn` and `Over` are sealed middles, so a `switch` over `batch.last` is exhaustive over the six Turns. | Effect | Fields | `isTurn` | `isOver` | Answered with | | ----------------- | ------------------------------------------------------------------------------------------------------------------------ | -------- | -------- | ----------------------------------- | | `Show` | `passage` (`Passage`) | yes | | `choose` or `leave` | | `Ask` | `prompt` (`Prompt`) | yes | | `answer` | | `Hold` | `ms` (`int`) | yes | | `elapsed`, once your clock says so | | `Ended` | `title` (`String?`), `ranOut` (`bool`: the story ran out of road rather than reaching an End) | yes | yes | | | `Failed` | `message` (`String`), `node` (`String?`) | yes | yes | | | `Stopped` | | yes | yes | | | `Trace` | `event` (`RunEvent`) | | | | | `Callback` | `event` (`CallbackEvent`) | | | | | `Jumped` | `board` (`int`, by index into `manifest.boards`), `node` (`String?`) | | | | | `AmbienceChanged` | `ambience` (`Ambience?`; `null` for silence) | | | | | `Audio` | `command` (`AudioCommand`) | | | give it to `SoundscapeDriver.apply` | | `Changed` | `entities` (`List?`), `memory` (`Memory?`), `cvars` (`List?`); a store that did not move is `null` | | | | `Ended`, `Failed` and `Stopped` extend `Over`, which extends `Turn`. ### Records [Section titled “Records”](#records) Every record is a copy: it outlives the verb that made it, the session and the campaign. Times are milliseconds throughout. | Record | Fields | | --------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `Passage` | `node`, `title`, `text` (Markdown, or HTML when `manifest.textFormat` says so), `choices` (`List`), `sources` (`List?`), `snippets` (`List`), `media` (`List`), `untranslated` (`String?`: the locale this passage fell back from), `soundscape` (`Soundscape?`), `silence` (`bool`) | | `Choice` | `port`, `text`, `available` (`false` for a choice that is not open now; draw it greyed) | | `LocaleText` | `locale`, `text` | | `Snippet` | `code`, `isInline` | | `MediaLink` | `link` (the name in the prose), `media` (index into `manifest.media`) | | `Prompt` | `node`, `title`, `fields` (`List`) | | `PromptField` | `name`, `type` (`String`), `defaultValue` (`Value`) | | `Ambience` | `soundscape` (`Soundscape`), `board` (`int`), `node` (`String`): the soundscape in force and the node that set it | | `Entity` | `key`, `component` (`String?`), `label`, `values` (a map `Value`, field by field) | | `Memory` | `nodes` (a map `Value`, node by node), `machines` (`List<(String machine, String state)>`, each state machine and its state, in order) | | `CvarSnapshot` | `name`, `type`, `defaultValue`, `value` | | `CallbackEvent` | `callback` (the name the author gave it), `event` (`int`), `eventName` (the event, such as `cvar.changed`), `board` (`int?`), `node`, `name` (`String?`), `from`, `to` (`Value?`), `index` (`int?`) | | `AudioCommand` | `kind` (`AudioKind`), `soundscape` (`Soundscape?`, for `play`), `fadeMs` (`play`: the fade-in; `stop`: the fade-out), `media` (`cue`: index into `manifest.media`), `volume`, `speed`, `node` (`String?`) | | `Soundscape` | `id`, `name`, `length`, `looping`, `fadeIn`, `fadeOut`, `volume`, `tied`, `tracks` (`List`) | | `Track` | `media`, `lane`, `start`, `clipStart`, `clipEnd` (`int?`), `volume`, `speed`, `fadeIn`, `fadeOut`, `mode` (`TrackMode.once`, `looping` or `auto`), `interval` | | `RunEvent` | `kind` (`RunEventKind`), `run`, `board`, `sequence`, `node`, `step`, `entry`, `primed` (`List`), `enteredVia` (`String?`), `pulled`, `inputs` and `outputs` (`List`), `outcome` (`int`), `flowed` (`List`), `error` (`String?`), `diagnostic` (`RunDiagnostic?`), `level` (`int`), `message`, `data` (`Value?`), `steps`, `cancelled`, `failedNode` (`String?`). Read the fields by `kind`. | | `TraceValue` | `fromNode`, `fromPort`, `toNode`, `toPort` (`String?`), `payload` (`PayloadKind.present`, `withheld` or `absent`), `value` (set when `present`) | | `RunDiagnostic` | `issue` (`int`), `message`, `node`, `port` (`String?`), `wires` (`int?`) | | `PythonResult` | `value` (`Value`), `output` (`String`), `error` (`PythonFailure?`) | | `PythonFailure` | `kind` (`int`), `exception` (`String?`), `message`, `line` (`int?`), `traceback` (`String?`) | | Enum | Values | | -------------- | ------------------------------------------------------------------------------------------ | | `AudioKind` | `play`, `stop`, `cut`, `cue` | | `RunEventKind` | `runStarted`, `nodeStarted`, `nodeFinished`, `memoHit`, `diagnostic`, `log`, `runFinished` | | `Untranslated` | `report`, `fallBack` | | `CvarWrite` | `done`, `unknown`, `refused` | ## Values [Section titled “Values”](#values) `Value` is a sealed class with one subclass per kind. An Int and a Float never become each other, and a map keeps the order it was written in. Values are immutable and equal when they hold the same thing, a map’s order included. | Class | Field | Dart type | Notes | | ------------ | --------- | -------------------- | --------------------------------------------------------------------------------------- | | `NullValue` | | | Also `Value.nil`. | | `BoolValue` | `value` | `bool` | | | `IntValue` | `value` | `int` | 64 bits; see the dart2js row under [What differs on the web](#what-differs-on-the-web). | | `FloatValue` | `value` | `double` | `FloatValue(7)` is `7.0`. `NaN` equals `NaN`. | | `TextValue` | `value` | `String` | | | `ListValue` | `items` | `List` | Unmodifiable. | | `MapValue` | `entries` | `Map` | Unmodifiable, in written order. | | Member | Meaning | | -------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------ | | `Value.from(Object? dart)` | `null`, `bool`, `int` (an Int), `double` (a Float), `String`, `List`, `Map` with `String` keys, or a `Value` as it is. Anything else throws `ArgumentError`. | | `isNull` | Whether this is `NullValue`. | | `operator [](String key)` | A map’s value under `key`, or `null` (also for anything that is not a map). | | `toStoryText()` | The value as a passage prints it, asked of the engine. On the web, needs `RippleEngine.load()` first. | | `toString()` | JSON-like text for a log: `7` and `7.0` differ. | ```dart switch (value) { case IntValue(:final value): print('a whole number, $value'); case TextValue(:final value): print('text, $value'); default: print(value); } ``` ## Player [Section titled “Player”](#player) The loop over a session, written once. It delivers every effect in order, keeps the last Turn, answers a `Hold` from its clock when `update()` finds it due, applies `Audio` effects to a driver, and dispatches named callbacks. ```dart Player( Session session, { void Function(Effect effect)? onEffect, void Function(Object error, StackTrace stack)? onError, SoundscapeDriver? audio, Clock? now, }) ``` | Parameter | Meaning | | ---------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `session` | The session to play. The player owns it from here and disposes it. | | `onEffect` | Hears every effect, in the order the engine produced it. | | `onError` | Hears the failure of a verb the player made by itself (the `elapsed` after a hold) and any handler’s exception; delivery goes on either way. Without it, a self-made verb’s failure is thrown from `update()`, and a handler’s exception is lost. | | `audio` | Where `Audio` effects go. Without one, nothing is heard. | | `now` | The clock, in milliseconds. `Clocks.steady()` when omitted. | | Member | Type | Meaning | | ---------------------------------------------------------------------------------------------------------------------------------- | --------- | ------------------------------------------------------------------------------------------------------------------------------------------------- | | `session` | `Session` | The session underneath. A verb called on it goes round the player. | | `turn` | `Turn?` | The last Turn delivered. | | `holdDue` | `int?` | When the pending hold is due, by the player’s clock; `null` when none is. | | `callbackMisses` | `int` | How many callbacks fired with nothing registered under their name. Not a failure. | | `start()`, `choose(int index)`, `leave()`, `answer(Map? fields)`, `setLocale(String locale)`, `restart()`, `stop()` | `void` | The session’s verbs; the effects go to the handlers. After `dispose()` they do nothing. | | `pause()` | `String?` | Puts the story down and answers its save — `null`, with nothing stopped, unless the story is on a passage or a prompt. Its effects are delivered. | | `update()` | `void` | Call from your loop (Flame’s `update(dt)`): answers a hold whose time has come. | | `onCallback(String name, handler)` | `void` | Calls `handler` whenever the callback named `name` fires, replacing any handler under that name. | | `removeCallback(String name)` | `void` | Removes the handler under `name`. | | `onAnyCallback(handler)` | `void` | Calls `handler` for every callback that fires, registered or not. | | `clearCallbacks()` | `void` | Removes every handler. | | `dispose()` | `void` | Delivers nothing more, moves the story no further, and disposes the session. | A player verb called from inside a handler throws `ReentrantVerbException`: the rest of the batch would arrive after the new one. Call the verb after the handler returns. ## Named callbacks [Section titled “Named callbacks”](#named-callbacks) A callback the author declared arrives as a `Callback` effect carrying a `CallbackEvent`, in its place in the batch. With `Player`, register handlers by the author’s name: ```dart player ..onCallback('chapterTwoBegan', (event) => unlock('chapter-two')) ..onAnyCallback((event) => log('${event.eventName} ${event.callback}')); ``` | `CallbackEvent` field | Meaning | | ----------------------------- | -------------------------------------------------------------- | | `callback` | The name the author gave it, which a handler is registered by. | | `eventName` | The event in the author’s vocabulary, such as `cvar.changed`. | | `event` | The same event as the engine’s number. | | `board`, `node` | Where it happened, when the event has a place. | | `name`, `from`, `to`, `index` | What changed, when the event carries it. | A callback with nothing registered under its name counts in `Player.callbackMisses`. A handler’s exception goes to `onError`, and delivery goes on. Without `Player`, match `Callback(:final event)` in your own dispatch. The events and their targets are in [Host callbacks](/reference/host-callbacks/). ## Sound [Section titled “Sound”](#sound) The package ships no speaker. The session says what the story asks for as `Audio` effects; the engine’s scheduler (`Mixer`) decides every voice; `SoundscapeDriver` drives the scheduler; a `Speaker` of yours makes the sound. The full contract is [`doc/speaker.md`](https://github.com/ConnectingTheDots-DCWK/ripple_effect_dart_integration/blob/main/doc/speaker.md). ```dart final audio = SoundscapeDriver(campaign, MySpeaker()); final player = Player(Session.create(campaign, config), audio: audio); // every frame: audio.update(); player.update(); ``` ### SoundscapeDriver [Section titled “SoundscapeDriver”](#soundscapedriver) ```dart SoundscapeDriver( Campaign campaign, Speaker speaker, { int maxCues = 8, double gainStep = 0.004, bool muted = false, Clock? now, }) ``` | Option | Default | Meaning | | ---------- | ----------------- | ------------------------------------------------------- | | `maxCues` | `8` | At most this many cues at once. | | `gainStep` | `0.004` | A gain is written only when it moved by more than this. | | `muted` | `false` | Starts muted. | | `now` | `Clocks.steady()` | The clock, in milliseconds. | | Member | Type | Meaning | | ----------------------------- | --------- | ---------------------------------------------------------------------------------------------------------------------- | | `apply(AudioCommand command)` | `void` | Obeys one of the session’s audio commands. A `cue` whose medium has no path is ignored. | | `update()` | `void` | Hears what the voices reported and moves the timeline on. Every 25 ms or so is the editor’s pace; every frame is fine. | | `pause()`, `resume()` | `void` | Holds every voice, and the timeline with them — not the story — and lets them go on. | | `muted` | `bool` | Get or set. Mutes at once; the scheduler writes every gain as zero and remembers the rest. | | `playing` | `String?` | The soundscape in force, by id: sounding, or waiting its turn. | | `idle` | `bool` | Whether nothing sounds, waits or cues. | | `voices` | `int` | How many voices the speaker holds open now. | | `dispose()` | `void` | Stops every voice the speaker holds, and the scheduler. | The driver adds how late a voice came to `fromMs` (times `rate`), wraps a late loop inside its loop, reports a clip already over as ended, tells the scheduler about voices that ended or were measured, and plays tracks with no file as silence. A failure from the scheduler stops the sound, not the story. It owns no timer: your loop is the timer. `Clock` is `int Function()`, milliseconds and monotonic. `Clocks.steady()` is one over a `Stopwatch`. ### Speaker and Voice [Section titled “Speaker and Voice”](#speaker-and-voice) ```dart abstract interface class Speaker { Voice? open(VoiceSource source, VoiceRole role, VoiceEvents events); bool resolves(VoiceSource source); } abstract interface class Voice { void play(VoicePlay request); void setGain(double gain); void pause(); void resume(); void stop(); } ``` | Member | Obligation | | ------------------------------------ | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `Speaker.open(source, role, events)` | Return a voice that can begin at once, or `null` when the file cannot be played; `null` is heard as silence and never retried. Synchronous: over an asynchronous audio API, create the player here and start it when ready. Keep `events` with the voice. | | `Speaker.resolves(source)` | Whether the speaker can play the medium at all, asked before a soundscape plays; a track it cannot is silence up front. Answer `true` when unsure. | | `Voice.play(request)` | Begin at once, replacing whatever the voice was playing. May be called again on the same voice, for a gapless bed coming round. Play from `fromMs` to `toMs`, or to the file’s end; when `looping`, reaching `toMs` comes round to `loopFromMs`, not `fromMs`. | | `Voice.setGain(gain)` | Write a new gain, smoothed over a few milliseconds where the API can. | | `Voice.pause()`, `Voice.resume()` | Hold the voice exactly where it is, and let it go again. | | `Voice.stop()` | End the voice for good and let its resources go. No `ended()` follows. | | `VoiceEvents.ended()` | Call once per play when a non-looping voice reaches the end of what it was given. Never after `stop()`, never for a looping voice. | | `VoiceEvents.duration(double ms)` | Call once, when the file’s length is known. | Either `VoiceEvents` call may come at any time, from any callback; the driver hears it on its next `update()`. A call from a stopped voice, or after the driver is disposed, is ignored. | Type | Fields | | ------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `VoiceSource` | `media` (index into `manifest.media`), `path` (inside the bundle), `file` (`String?`: on disk, for a campaign opened from a folder). For a campaign opened from its files, read bytes with `campaign.mediaBytes(source.media)` or from your assets by `source.path`. | | `VoicePlay` | `fromMs`, `toMs` (`double?`; the file’s end when `null`), `gain` (0 to 1), `rate` (1 is as recorded), `looping`, `loopFromMs` | | `VoiceRole` | `track` (may be minutes long; usually better streamed) or `cue` (short, must start on time; usually better decoded whole) | **The gain scale is cubic.** A speaker that writes amplitude writes `gain * gain * gain`. On the web a speaker is usually Web Audio. The JavaScript binding’s `@ripplefx/soundscape` has a whole `WebAudioSpeaker` to model one on, and the Flame example’s flame\_audio speaker runs in the browser as it is. ### Mixer [Section titled “Mixer”](#mixer) The engine’s scheduler, raw, for a host that does not use the driver: commands in, `List` out, your clock on every verb. On the web a `Mixer` made on its own needs `RippleEngine.load()` first. `Mixer({int maxCues = 8, double gainStep = 0.004})` | Member | Type | Meaning | | -------------------------------------------------------------------------------------------------------- | -------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------- | | `play(int nowMs, Soundscape scape, {List unresolved = const [], int atMs = 0, bool muted = false})` | `List` | Plays a soundscape `atMs` into its timeline, handing over from whatever is sounding. Tracks in `unresolved`, by index, are silence and never retried. | | `stop(int nowMs)` | `List` | Stops what is sounding, fading it out. | | `cut(int nowMs)` | `List` | Silences everything at once, cues included. | | `cue(int nowMs, int media, double volume, double speed)` | `List` | Plays one cue. | | `pause(int nowMs)`, `resume(int nowMs)` | `List` | Holds everything where it is, and lets it go on. | | `tick(int nowMs)` | `List` | Moves the timeline on: what is due opens, ramps move, what is done stops. | | `voiceEnded(int nowMs, int voice)` | `List` | The speaker’s report that a voice reached its end. | | `durationKnown(int voice, int ms)` | `void` | The speaker’s report of a file’s length. | | `muted` | `bool` | Get or set. The timeline runs on, every gain written as 0. | | `playingId`, `finishingId` | `String?` | The soundscape playing, and the one fading out under it. | | `state` | `Phase?` | The playing soundscape’s phase; `null` when none is playing. | | `trackState(int track)` | `Phase` | A track’s phase, by index into the playing soundscape’s tracks. | | `positionMs` | `int` | How far into its timeline the playing soundscape is. | | `pass` | `int` | How many times the playing soundscape has gone round. | | `ranOut` | `bool` | Whether it has played to its end and will not loop. | | `isPaused`, `isIdle` | `bool` | Paused; nothing sounding or due. | | `dispose()` | `void` | Frees the mixer. | | `VoiceCommand` field | Meaning | | ---------------------------------------------------------- | ----------------------------------------------------------- | | `kind` | `VoiceKind.open`, `gain`, `stop`, `pause` or `resume` | | `voice` | The voice’s id. | | `media`, `role`, `track` | For `open`: the medium, `VoiceRole`, and the track’s index. | | `fromMs`, `toMs` (`int?`), `rate`, `looping`, `loopFromMs` | For `open`: what to play. | | `gain` | For `open` and `gain`. | `Phase` is `queued`, `starting`, `playing`, `finishing` or `finished`. ## Errors [Section titled “Errors”](#errors) Every failure the engine reports is a `RippleException`, a sealed class carrying a `Failure`. `code` and `message` are read through from it. | Exception | Codes | Thrown when | Met in use | | -------------------------- | -------------------------------------------------------------------------------------- | ------------------------------------------------------------------------ | ------------------------------------------------------------------------ | | `BundleException` | `bundleFormat`, `bundleVersion` | A document of the bundle is malformed, missing, or from a newer engine. | yes, at open | | `NeedsPythonException` | `needsPython` | The bundle needs Python and this is the engine without it. | yes, at open | | `ResumeException` | `boardGone`, `nodeGone` | A save cannot be reunited with this campaign: its board or node is gone. | yes, at resume | | `HostException` | `wrongTurn`, `noSuchChoice`, `choiceSpent`, `stopped`, `nothingAsked`, `unknownLocale` | A verb the story was not asking for. Nothing moved. | a host bug, except `unknownLocale` when the locale comes from the reader | | `InvalidArgumentException` | `invalidArgument`, and any unknown code | A malformed argument. Nothing was done. | a host bug | | `EngineTrapException` | `trap` | The engine panicked, now or earlier on this handle. | rare | | `ReentrantVerbException` | `reentrant` | A `Player` verb called from inside a handler. | a host bug | `Code` holds the engine’s numbers: `success` (0), `bundleFormat` (1), `bundleVersion` (2), `needsPython` (3), `wrongTurn` (10), `noSuchChoice` (11), `choiceSpent` (12), `stopped` (13), `nothingAsked` (14), `unknownLocale` (15), `boardGone` (20), `nodeGone` (21), `invalidArgument` (-1), `trap` (-2), and the binding’s own `reentrant` (-100). `Code.value` is the number; `Code.of(int)` maps one back. | `Failure` field | Set for | | ------------------------------ | ---------------------------------------------------------------------------------------------------- | | `code`, `message` | every failure | | `location` | `bundleFormat`: the document at fault | | `found`, `supported`, `oldest` | `bundleVersion`: the bundle’s format, the newest this engine reads, the oldest when there is a floor | | `asked`, `answered` | `wrongTurn`: what the story was asking, and the verb that answered it | | `offered`, `chosen` | `noSuchChoice`: how many choices were offered, and the index taken | | `subject` | the choice, locale, board or node a host or resume error is about | | `node` | `nodeGone`: the node | Two failures are not `RippleException`: | Exception | Thrown when | | ---------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | | `EngineNotFoundException` (`message`, `cause`) | The engine library could not be loaded or binds another contract (natively: not bundled, a 32-bit device, an `engine_dir` of another contract); on the web, the engine could not be fetched, or `load()` has not been awaited. | | `StateError` | Any call on a disposed `Campaign`, `Session` or `Mixer`. | **A trap poisons the handle**: every later call on it throws `EngineTrapException` again. Dispose the session and the campaign, open the campaign again, and resume from the last checkpoint. On the web a trap poisons only the campaign it happened in and that campaign’s sessions. ## Threading and lifetime [Section titled “Threading and lifetime”](#threading-and-lifetime) * **Opening is the one `Future`.** `Campaign.openDirectory`, `Campaign.openFiles` and `RippleEngine.load` are asynchronous; every verb and reader after them is synchronous, fast enough to call from a frame. * **Everything a verb answers is a copy.** Effects, passages and values are Dart objects that outlive the session and the campaign. * **`dispose()`** is on `Campaign`, `Session`, `Mixer`, `SoundscapeDriver` and `Player`. A campaign, session or mixer never disposed is freed when it is collected (natively by a `NativeFinalizer`). A second `dispose()` does nothing. * **A session outlives its campaign.** Disposing a campaign leaves its sessions working. * **One caller at a time.** A handle belongs to the isolate that made it, and one isolate at a time may use it. * **The driver and the player own no timer.** Both read the `Clock` they were given; call their `update()` from your loop. ## A complete host [Section titled “A complete host”](#a-complete-host) A terminal host over a bundle folder. It opens the campaign, plays it through `Player` with a speaker and a driver, greys out a spent choice, fills a form with its defaults, lets `update()` answer holds, registers callbacks, checkpoints before every answer, and recovers from a trap by resuming the last checkpoint. A game reads input from its own loop instead of `stdin`. ```dart import 'dart:io'; import 'package:ripplefx_engine/ripplefx_engine.dart'; /// One playthrough: the campaign, its sound, the player and the last save. final class StoryHost { StoryHost._(this.folder, this.campaign, this.audio); static Future open(String folder) async { final campaign = await Campaign.openDirectory(folder); return StoryHost._( folder, campaign, SoundscapeDriver(campaign, ConsoleSpeaker()), ); } static final options = const SessionOptions().copyWith( untranslated: Untranslated.fallBack, ); final String folder; Campaign campaign; SoundscapeDriver audio; late Player player; int seed = 0; String? lastSave; Generator? lastDice; void begin({required int seed, String? locale}) { this.seed = seed; _play( Session.create( campaign, SessionConfig( board: campaign.manifest.entries.first, seed: seed, locale: locale, options: options, ), ), ); } void _play(Session session) { player = Player( session, audio: audio, onEffect: _onEffect, onError: (error, stack) => stderr.writeln('ripple: $error'), ) ..onCallback('chapterTwoBegan', (event) => stdout.writeln('*')) ..onAnyCallback( (event) => stdout.writeln('[${event.eventName}] ${event.callback}'), ); player.start(); } // Every effect, in order. No player verb from here. void _onEffect(Effect effect) { switch (effect) { case Show(:final passage): stdout.writeln('\n== ${passage.title} ==\n${passage.text}'); case Changed(:final cvars?): for (final cvar in cvars) { stdout.writeln(' ${cvar.name} = ${cvar.value}'); } case Ended(:final title): stdout.writeln('The end${title == null ? '' : ': $title'}'); case Failed(:final message): stderr.writeln('The story could not go on: $message'); default: } } void pick(Passage passage, int index) { if (!passage.choices[index].available) return; // spent: never offered _save(); player.choose(index); } void fill(Prompt prompt) { _save(); player.answer({ for (final field in prompt.fields) field.name: field.defaultValue, }); } void _save() { lastSave = player.session.checkpoint(); lastDice = player.session.generator; } /// Once a frame: the sound moves on, and a due Hold is answered. void frame() { audio.update(); player.update(); } /// After a trap: drop everything on the old campaign and resume. Future recover() async { dispose(); campaign = await Campaign.openDirectory(folder); audio = SoundscapeDriver(campaign, ConsoleSpeaker()); final save = lastSave; if (save == null) { begin(seed: seed); return; } final session = Session.resume(campaign, save, seed: seed, options: options); if (lastDice case final dice?) session.generator = dice; _play(session); } void dispose() { player.dispose(); audio.dispose(); campaign.dispose(); } } Future main(List args) async { final StoryHost host; try { host = await StoryHost.open(args.first); } on NeedsPythonException { stderr.writeln('This campaign needs the engine with Python.'); exitCode = 1; return; } on BundleException catch (e) { stderr.writeln('Not a campaign this engine reads: ${e.message}'); exitCode = 1; return; } on EngineNotFoundException catch (e) { stderr.writeln(e.message); exitCode = 1; return; } host.begin(seed: DateTime.now().microsecondsSinceEpoch); for ( var turn = host.player.turn; turn != null && !turn.isOver; turn = host.player.turn ) { try { switch (turn) { case Show(:final passage) when passage.choices.isEmpty: host.player.leave(); case Show(:final passage): host.pick(passage, readChoice(passage)); case Ask(:final prompt): host.fill(prompt); default: // a Hold: update() answers it when its time comes host.frame(); await Future.delayed(const Duration(milliseconds: 16)); } } on EngineTrapException { await host.recover(); } on HostException catch (e) { stderr.writeln('refused: ${e.code.name}'); // nothing moved } } host.dispose(); } int readChoice(Passage passage) { for (final (i, choice) in passage.choices.indexed) { final spent = choice.available ? '' : ' (spent)'; stdout.writeln(' ${i + 1}. ${choice.text}$spent'); } stdout.write('> '); final picked = int.tryParse(stdin.readLineSync() ?? '') ?? 1; return picked < 1 || picked > passage.choices.length ? 0 : picked - 1; } /// Your audio API goes here; the driver decides when, how loud and from where. final class ConsoleSpeaker implements Speaker { @override Voice? open(VoiceSource source, VoiceRole role, VoiceEvents events) => _ConsoleVoice(source.path, events); @override bool resolves(VoiceSource source) => true; } final class _ConsoleVoice implements Voice { _ConsoleVoice(this.path, this.events); final String path; final VoiceEvents events; // ended() and duration(ms), from your API's callbacks @override void play(VoicePlay request) { final amplitude = request.gain * request.gain * request.gain; stdout.writeln('[sound] $path from ${request.fromMs} ms at $amplitude'); } @override void setGain(double gain) {} @override void pause() {} @override void resume() {} @override void stop() {} } ``` The terminal example in the package (`example/ripplefx_engine_example.dart`) and the Flame game in [`example/flame_player`](https://github.com/ConnectingTheDots-DCWK/ripple_effect_dart_integration/tree/main/example/flame_player), which opens a campaign from its assets with `Campaign.openFiles` and plays its sound over flame\_audio, are complete programs. ### The engine’s notice in a Flutter app [Section titled “The engine’s notice in a Flutter app”](#the-engines-notice-in-a-flutter-app) ```dart LicenseRegistry.addLicense(() async* { yield LicenseEntryWithLineBreaks( ['Ripple Effect Engine'], await rootBundle.loadString('assets/ENGINE-NOTICE.txt'), ); }); ``` The notice is the `NOTICE` file in the engine archive, the same text in every archive of a release. ## Related [Section titled “Related”](#related) * [Embedding the engine](/explanation/embedding-the-engine/) — why a host drives the story and the engine never calls back. * [How the engine ships](/explanation/how-the-engine-ships/) — the engine releases, the two variants, and the contract a binding pins. * [Play a campaign in your program](/how-to/play-a-campaign-in-your-program/) — the steps from an exported campaign to a running host. * [Host callbacks](/explanation/host-callbacks/) — what a named callback is for, on the author’s side and yours. # JavaScript and TypeScript binding > The JavaScript and TypeScript binding of the Ripple Effect engine: @ripplefx/player and @ripplefx/soundscape on npm, every class, member, option, effect and error. The engine’s binding for JavaScript and TypeScript is two packages on npm, for a browser page or for Node. [`@ripplefx/player`](https://www.npmjs.com/package/@ripplefx/player) carries the engine as a WebAssembly component, in both variants, and the TypeScript API over it. [`@ripplefx/soundscape`](https://www.npmjs.com/package/@ripplefx/soundscape) is the Web Audio speaker for a campaign’s soundscapes, and is optional. Version 1.5.x of each binds Ripple Effect contract 1.5: a package’s `MAJOR.MINOR` is the contract, and its `PATCH` is the package’s own. The source is public at [ConnectingTheDots-DCWK/ripple\_effect\_js\_ts\_integration](https://github.com/ConnectingTheDots-DCWK/ripple_effect_js_ts_integration). `@ripplefx/player`’s code is MIT. The engine inside it is under the [Ripple Effect Runtime Licence](https://legal.ripplefx.app/runtime/): free to ship, unmodified in function, as part of your own product. Reproduce the package’s `ENGINE-NOTICE` wherever your product lists the third-party software it uses. `@ripplefx/soundscape` carries no engine and is plain MIT. ## Packages and installing [Section titled “Packages and installing”](#packages-and-installing) ```sh npm install @ripplefx/player # the player npm install @ripplefx/player @ripplefx/soundscape # and the Web Audio speaker ``` | Package | Exports | Runs in | Needs | | ---------------------- | ---------------------------------------------------------------------------------------- | ----------------------------- | ------------------ | | `@ripplefx/player` | `Campaign`, `Session`, `Player`, `SoundscapeDriver`, the effects, the values, the errors | browsers, Node 22.18 or later | nothing else | | `@ripplefx/soundscape` | `WebAudioSpeaker`, `amplitude` | browsers (Web Audio) | `@ripplefx/player` | Both are ES modules. The player ships `dist/`, both engine variants under `generated/`, `LICENSE` and `ENGINE-NOTICE`. ### Bundlers [Section titled “Bundlers”](#bundlers) The package names each engine file as `new URL(…, import.meta.url)`, which Vite, webpack 5, esbuild and Astro all follow: the bundler copies the `.wasm` files beside the page with no configuration. In Node the files are read from disk. To serve the engine’s files from somewhere else, a CDN say, pass `wasmBase` to `Campaign.load` or `Campaign.fromFiles`. It is a URL, resolved against the page, holding a `python/` and a `no-python/` directory laid out as the package’s `generated/` is; each holds `engine.core.wasm`, `engine.core2.wasm` and `engine.core3.wasm`. Serve the engine with the page, never from a GitHub release: release downloads send no `Access-Control-Allow-Origin`, so a browser cannot read them. ### Serving [Section titled “Serving”](#serving) | What | Rule | | --------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `.wasm` files | Serve as `application/wasm`, so the engine compiles while it downloads. Any other type still works, after the download. A long cache lifetime on a hashed name. | | Cross-origin isolation | Not needed. No `COOP` or `COEP` header: the engine uses no `SharedArrayBuffer` and no threads. | | `X-Content-Type-Options: nosniff` | Set it; with the `.wasm` type it is the one header a static host has to be told about. | | The campaign | A compiled campaign (the editor’s *Export → Campaign*) served as a folder, as it is, `campaign.json` at its root. | | A campaign on another origin | That origin sends CORS headers on every file of the bundle. | | Media on another origin | That origin sends CORS headers, or Web Audio hears the file as silence. | ### Media paths [Section titled “Media paths”](#media-paths) A media path is the author’s own folder and file names, spaces, accents, `#` and `?` included. It is URL-encoded **a segment at a time**, never as a whole, because an encoded `/` names a different file. `Campaign.load` does this for you. For a resolver of your own, the package exports the function it uses. | Export | Signature | Does | | ------------ | -------------------------- | ------------------------------------------------------------------------------------- | | `encodePath` | `(path: string) => string` | Encodes each `/`-separated segment with `encodeURIComponent` and joins them with `/`. | ## Choosing the variant [Section titled “Choosing the variant”](#choosing-the-variant) The engine comes in two variants, `python` (with the Python interpreter) and `no-python`. `Campaign.load` and `Campaign.fromFiles` read `campaign.json` before any engine exists and pick one from the `python` option. | `python` | Loads | | ------------------ | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `'auto'` (default) | `python` when the bundle’s `needs` includes `python`, otherwise `no-python`. A bundle older than format 15 cannot say, and gets `python`. From format 15 an absent `needs` means none. | | `true` | `python`, always. | | `false` | `no-python`, always. A bundle whose `needs` includes `python` is refused with `NeedsPython`. A bundle older than format 15 opens, with its scripts unavailable. | | Variant | Over the wire | | ----------- | ------------------------------------------- | | `python` | about 1.36 MB, and the WASI shim it imports | | `no-python` | about 0.31 MB | The `python` variant runs with a WASI that has nothing behind it: no files, no environment, no network. The engine asks it for a clock and for entropy. In an engine without Python a Script node reports and a snippet renders a warning in place; `session.pythonAvailable` is `false`, and `consoleFeed` answers an `unavailable` failure rather than throwing. ## Platforms [Section titled “Platforms”](#platforms) | Platform | Status | | ------------------- | ------------------------------------------------------------------------------------------------------------ | | Chromium | Tested: every conformance fixture through a Vite bundle loading each bundle over HTTP; the speaker’s voices. | | Firefox | Tested, as Chromium. | | WebKit | Tested, as Chromium, in WebKitGTK. Safari on macOS and iOS is not yet confirmed for the speaker’s tracks. | | Node 22.18 or later | Tested: every conformance fixture, through `Session` and through `Player`, in both variants. No speaker. | The fixtures are 24 with Python and 21 without. The engine runs **on the calling thread**. There is no Worker transport: every verb is synchronous, and a Worker would make every verb a promise. A page whose campaign runs heavy scripts can run the binding in a Worker of its own and post the effects across. For the engine’s native and web targets, see [Engine targets](/reference/engine-targets/). ## The engine [Section titled “The engine”](#the-engine) No engine class is exported. A `Campaign` loads its engine: each `.wasm` file is compiled once per page and cached by URL, and each campaign gets an instance of its own, so a trap ruins one campaign and no other. | Export | Kind | What it is | | --------------- | --------- | -------------------------------------------------------------- | | `Variant` | type | `'python' \| 'no-python'`. | | `EngineOptions` | interface | `{ wasmBase?: string \| URL }`, extended by `CampaignOptions`. | | Member of `Campaign` | Type | What it is | | -------------------- | --------- | ------------------------------------------ | | `variant` | `Variant` | Which engine the campaign was opened in. | | `hasPython` | `boolean` | Whether that engine has the interpreter. | | `engineVersion` | `string` | The engine’s version, `MAJOR.MINOR.PATCH`. | ## Campaign [Section titled “Campaign”](#campaign) A compiled campaign, opened in an engine of the variant it needs. The constructor is private. ### Opening [Section titled “Opening”](#opening) ```ts static load(base: string | URL, options?: CampaignOptions): Promise static fromFiles(files: BundleFiles, options?: CampaignOptions): Promise ``` | Function | Takes | Does | | -------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------ | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `Campaign.load` | `base`: a URL ending in `/`, or the URL of `campaign.json` itself. A relative URL resolves against the page; a URL ending in neither gets a `/`. | Fetches `campaign.json` and every document its tables name (boards, passages, components, scripts, machines, soundscapes) while the engine loads, then opens the campaign. No media bytes are fetched. Media resolve against `base`, a segment at a time. | | `Campaign.fromFiles` | `files`: a `Map` or a plain object of bundle path to `string` or `Uint8Array`. Must contain `campaign.json`. | Opens a bundle you already hold. A string is a document; bytes are an asset handed to the engine. **Media resolve only through `options.mediaUrl`**: without it no picture or sound has a URL. | `BundleFiles` is `ReadonlyMap` or `Readonly>`. ### `CampaignOptions` [Section titled “CampaignOptions”](#campaignoptions) | Option | Type | Default | Meaning | | ---------- | ---------------------------------------------- | ------------------ | --------------------------------------------------------------------------------------------------------------------- | | `python` | `boolean \| 'auto'` | `'auto'` | Which variant to load. See [Choosing the variant](#choosing-the-variant). | | `fetch` | `RequestInit` | none | Passed to every `fetch` the loader makes: credentials, a signal. | | `mediaUrl` | `(path: string) => URL \| string \| undefined` | none | Where a media file is, for a campaign opened with `fromFiles`. A string resolves against the page. Ignored by `load`. | | `wasmBase` | `string \| URL` | beside the package | Where the engine’s files are served. See [Bundlers](#bundlers). | ### Members [Section titled “Members”](#members) | Member | Signature | Answers | | --------------------------------------- | ---------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------- | | `manifest` | `CampaignManifest` | The bundle’s `campaign.json`, as the engine read it. | | `variant`, `hasPython`, `engineVersion` | | See [The engine](#the-engine). | | `mediaUrl` | `(index: number) => URL \| undefined` | Where the media file at `index` in `manifest.media` is, or `undefined`. | | `mediaPath` | `(index: number) => string \| undefined` | That file’s path in the bundle. | | `coverUrl` | `URL \| undefined` | Where the project’s cover picture is, if it has one. | | `soundscape` | `(index: number) => Soundscape` | The soundscape at `index` in `manifest.soundscapes`. Throws `BundleError` for one that will not read. | | `boardIndex` | `(id: string) => number \| undefined` | The index of the board whose catalog id is `id`. | | `validate` | `() => { message: string; location: string }[]` | What is wrong with the bundle, each problem and where. Empty when nothing is. | | `coerceCvar` | `(type: string, value: RippleValue) => RippleValue \| undefined` | The value as a console variable of `type` would hold it, or `undefined` when it does not fit. | | `asText` | `(value: RippleValue) => string` | The value as the story prints it, the text a snippet shows. | | `describeCvarRefusal` | `(name: string, type: string, value: RippleValue) => string` | The sentence the engine refuses writing `value` to a variable of `type` with. English, for showing. | | `session` | `(start?: SessionStart) => Session` | A session on this campaign, not yet started. See [Session](#session). | `coerceCvar`, `asText` and `describeCvarRefusal` need no session. ### `CampaignManifest` [Section titled “CampaignManifest”](#campaignmanifest) | Field | Type | Meaning | | --------------------------------------------------------------------------------- | ----------------------------------------------------------------- | -------------------------------------------------------------------------- | | `format` | `number` | The bundle format. | | `generator` | `string`, optional | What wrote the bundle. | | `textFormat` | `string` | `markdown`, or `html` for a campaign exported as HTML. | | `project.title` | `string` | | | `project.description` | `string` | | | `project.defaultLocale` | `string` | The language the story was written in. | | `project.locales` | `readonly string[]` | Every language the campaign has. | | `project.cover` | `string`, optional | The cover picture’s path in the bundle. | | `entries` | `readonly number[]` | The boards a story may begin on, as indices into `boards`. | | `boards`, `passages`, `components`, `scripts`, `media`, `machines`, `soundscapes` | `readonly BundleEntry[]` | The bundle’s tables. A `BundleEntry` is `{ id, name, path }`, all strings. | | `cvars` | `readonly { name: string; type: string; default: RippleValue }[]` | The console variables the campaign declares. | | `needs` | `readonly string[]` | What the campaign needs of an engine: `python` when it runs scripts. | ## Session [Section titled “Session”](#session) The host API’s verbs, one to one with the engine’s. Every verb runs synchronously and answers the ordered effects it led to; the last of them says where the story now is. ```ts campaign.session(start?: SessionStart): Session Session.create(campaign: Campaign, start?: SessionStart): Session // the same Session.resume(campaign: Campaign, save: string, start?: ResumeStart): Session ``` `Session.create` throws `BundleError` when the campaign cannot be started as asked. `Session.resume` picks up a save that `pause()` or `checkpoint()` wrote, and throws `ResumeError` when the save no longer fits the campaign. A resumed session is started with `start()`, like a new one. ### `SessionStart` [Section titled “SessionStart”](#sessionstart) `SessionStart` is `SessionOptions` and these fields. | Field | Type | Default | Meaning | | ----------- | ------------------ | ----------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `seed` | `number \| bigint` | `0` | What the dice are seeded with. The one setting to always choose: left out, every reader rolls the same dice. | | `locale` | `string` | `project.defaultLocale` | The language to begin in. | | `board` | `number` | `manifest.entries[0]`, or `0` | The board to begin on, by index into `manifest.boards`. | | `entryNode` | `string` | the board’s entry | The node to begin at. | | `digest` | `string` | none | A label stamped on every save this session writes, a hash of the bundle say, so you can tell your own saves apart. The engine carries it and never compares it. | ### `ResumeStart` [Section titled “ResumeStart”](#resumestart) `ResumeStart` is `SessionOptions` and `seed` (`number | bigint`, default `0`). A save carries no dice state: to roll on where the story was, copy `generator` from the old session to the resumed one. ### `SessionOptions` [Section titled “SessionOptions”](#sessionoptions) Each defaults to the engine’s own. | Option | Type | Default | Meaning | | ----------------- | ------------------------- | ------------ | -------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `stepBudget` | `number` | `1048576` | How many node turns one board may take before the run is called a runaway and the story fails, rather than hang the page. | | `silentJumpLimit` | `number` | `64` | How many boards may hand over in a row without a word of story before the story fails. `0` fails the first such jump. | | `loopLimit` | `number` | `512` | How often one loop node may go round in one board run before the story fails. **`0` is no ceiling.** | | `scriptLimits` | `ScriptLimits` | the engine’s | `{ deadlineMs, memoryBytes, recursionDepth }`, all numbers: how long a script may run, how much it may hold, how deep it may recurse. | | `untranslated` | `'report' \| 'fall-back'` | `'report'` | A passage with no text in the language being read: `report` shows it empty and names the language; `fall-back` shows the default language’s text and still names it. | | `trace` | `boolean` | `false` | Collect the run trace in every batch, as `trace` effects. | | `tracePayloads` | `boolean` | `false` | Include payloads in the trace. | ### Verbs [Section titled “Verbs”](#verbs) | Verb | Answers | Notes | | --------------------------- | --------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------- | | `start()` | `Effect[]` | Begins the story, or a resumed one. | | `choose(index?: number)` | `Effect[]` | Takes the choice at `index` among those offered. With none, the reader leaves: the passage flows nowhere and the story ends there. | | `answer(values?: Answers)` | `Effect[]` | Answers the prompt showing. Values go to the engine exactly as given. With none, the reader leaves, as with `choose()`. | | `elapsed()` | `Effect[]` | Says the wait a `hold` asked for is over. A `Player` calls it for you. | | `setLocale(locale: string)` | `Effect[]` | Reads on in another language; the passage showing is shown again in it. | | `restart()` | `Effect[]` | Starts over, forgetting every entity and keeping the language. | | `stop()` | `Effect[]` | Ends the session for good. | | `pause()` | `Paused` | Puts the story down and ends the session, answering a save. Only on a passage or a prompt: anywhere else nothing stops, and there is no save and no effect. | | `checkpoint()` | `string \| undefined` | A save of where the story is, without putting it down. `undefined` unless the story is on a passage or a prompt. | | `writeCvar(name, value)` | `{ outcome, effects }` | Writes a console variable. | | `resetCvar(name)` | `{ outcome, effects }` | Puts a console variable back to its default. | | `resetAllCvars()` | `Effect[]` | Puts every console variable back. | | `removeEntity(key)` | `{ removed: boolean; effects: Effect[] }` | Forgets the entity under `key`. | | `consoleFeed(code: string)` | `{ result: PythonResult; effects: Effect[] }` | One line of Python against the live story: its entities, its variables, its sounds. | A refused verb throws `HostError` and nothing moves. `Paused` is `{ save?: string; effects: Effect[] }`. `Answers` is a `Map` or a plain object of field name to value; a nested plain object is taken as a map. A cvar write’s `outcome` is `'done'`, `'unknown'` (no such variable), or `{ refused: string }`, carrying the engine’s sentence in English, for showing and never for parsing. ### Readers [Section titled “Readers”](#readers) Each is a snapshot as of the last verb. | Reader | Type | What it is | | -------------------------------- | --------------------- | ------------------------------------------------------------------------------- | | `campaign` | `Campaign` | The campaign the session plays. | | `board` | `number` | The index of the board the story is on. | | `locale` | `string` | The language being read. | | `defaultLocale` | `string` | The language the story was written in. | | `ambience` | `Ambience \| null` | The soundscape in force, or `null` for silence. | | `entities` | `Entity[]` | Everything the story has made. | | `memory` | `Memory` | What the story remembers of its nodes and machines. | | `cvars` | `CvarSnapshot[]` | Every console variable, with its default and value. | | `guard` | `GuardReport` | What the script guard has given up on this playthrough. | | `pythonAvailable` | `boolean` | Whether this session’s engine can run the campaign’s scripts. | | `describeCvarWrite(name, value)` | `string \| undefined` | Why writing `value` to `name` would be refused, or `undefined` if it would not. | `GuardReport` is `{ abandoned: readonly string[]; refused: number; exhausted: boolean }`: the names the scripts stopped at the deadline ran under, sorted with repeats kept; how many runs were never started; whether no script will be started again. ### Python results [Section titled “Python results”](#python-results) | `PythonResult` field | Type | Meaning | | -------------------- | ------------------------- | --------------------------------------- | | `value` | `RippleValue` | The last expression’s value, or `null`. | | `output` | `string` | Everything it printed. | | `error` | `PythonFailure`, optional | Why it stopped. | | `PythonFailure` field | Type | Meaning | | --------------------- | ------------------ | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `type` | `string` | The Python exception’s class (`NameError`, `SyntaxError`), or one of the engine’s labels: `timeout`, `refused`, `closed`, `host-error`, `unavailable` (an engine without Python). | | `message` | `string` | | | `line` | `number`, optional | | | `traceback` | `string`, optional | | ### Trace and dice [Section titled “Trace and dice”](#trace-and-dice) | Member | Signature | Does | | ------------------ | ----------------------------------------- | --------------------------------------------------------------------------------------------------- | | `setTrace` | `(enabled: boolean) => void` | Turns the run trace on or off. | | `setTracePayloads` | `(enabled: boolean) => void` | Turns trace payloads on or off. | | `generator` | `{ lo: number; hi: number }`, get and set | The dice’s state. A save carries none; hand it to a resumed session to roll on where the story was. | ## Effects [Section titled “Effects”](#effects) An `Effect` is a plain object tagged on `kind`, so a `switch` over `effect.kind` is checked for exhaustiveness. Times and indices are numbers, story values are `RippleValue`s, and a picture or a sound comes with its URL. | `kind` | Fields | Notes | | ---------- | ----------------------------------- | ----------------------------------------------------------------------------------------------- | | `show` | `passage: Passage` | A passage. A turn. | | `ask` | `prompt: Prompt` | A prompt. A turn. | | `hold` | `ms: number` | A wait. A turn. Call `elapsed()` once `ms` have passed; a `Player` does. | | `ended` | `title?: string`, `ranOut: boolean` | The story is over as the author wrote it, or ran out of road (`ranOut`). A turn. | | `failed` | `message: string`, `node?: string` | The story stopped on an error. `message` is English, for showing and never for parsing. A turn. | | `stopped` | none | The session ended, after `stop()` or `pause()`. A turn. | | `jumped` | `board: number`, `node?: string` | The story moved to another board. `board` indexes `manifest.boards`; it is not the catalog id. | | `ambience` | `ambience: Ambience \| null` | The soundscape in force changed. `null` is silence. | | `audio` | `command: AudioCommand` | What a speaker is told. See [Sound](#sound). | | `changed` | `entities?`, `memory?`, `cvars?` | Which of the stores moved, each with its new contents. | | `callback` | `event: CallbackEvent` | A named callback the author declared. See [Named callbacks](#named-callbacks). | | `trace` | `event: RunEvent` | Only with `trace` on. The engine’s own `{ tag, val }` shape, kebab-case tags and all, unmapped. | The six turns (`show`, `ask`, `hold`, `ended`, `failed`, `stopped`) are the type `Turn`; one is always the last effect of a batch. `isTurn(effect: Effect): effect is Turn` tells them apart. ### Records [Section titled “Records”](#records) #### `Passage` [Section titled “Passage”](#passage) | Field | Type | Meaning | | -------------- | ------------------------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | | `node` | `string` | The passage’s node. | | `title` | `string` | | | `text` | `string` | The prose, after its snippets ran, in the language being read. Markdown, or HTML when `manifest.textFormat` is `html`. Render it with a library and sanitise what it produces. | | `choices` | `readonly Choice[]` | The choices offered, in order. | | `snippets` | `readonly { code: string; isInline: boolean }[]` | The snippets the prose ran. | | `media` | `ReadonlyMap` | Every link in the prose that names a file of the campaign, to that file’s URL. Links with no URL are absent. | | `mediaIndex` | `ReadonlyMap` | The same links, to the media index each names. | | `untranslated` | `string`, optional | The language the passage has no text in, when that is why it is empty. | | `soundscape` | `Soundscape`, optional | The soundscape the passage plays. | | `silence` | `boolean` | The passage asks for silence. | #### `Choice` [Section titled “Choice”](#choice) | Field | Type | Meaning | | ----------- | --------- | --------------------------------------------------------------------------------------- | | `port` | `string` | The choice’s port. | | `text` | `string` | | | `available` | `boolean` | `false` for a choice that has run out: shown, and refused with `choice-spent` if taken. | #### `Prompt` and `PromptField` [Section titled “Prompt and PromptField”](#prompt-and-promptfield) | Field | Type | Meaning | | --------------------- | ------------------------ | --------------------------- | | `Prompt.node` | `string` | | | `Prompt.title` | `string` | | | `Prompt.fields` | `readonly PromptField[]` | | | `PromptField.name` | `string` | The key to answer it under. | | `PromptField.type` | `string` | The field’s type. | | `PromptField.default` | `RippleValue` | | #### `Ambience` [Section titled “Ambience”](#ambience) | Field | Type | | ------------ | ------------ | | `soundscape` | `Soundscape` | | `board` | `number` | | `node` | `string` | #### `Soundscape` and `Track` [Section titled “Soundscape and Track”](#soundscape-and-track) Every time is in milliseconds. | Field | Type | Meaning | | ------------------------------ | ------------------ | ---------------------------------------------------------------------------------------- | | `Soundscape.id` | `string` | | | `Soundscape.name` | `string` | | | `Soundscape.length` | `number` | | | `Soundscape.looping` | `boolean` | | | `Soundscape.fadeIn`, `fadeOut` | `number` | | | `Soundscape.volume` | `number` | | | `Soundscape.tied` | `boolean` | | | `Soundscape.tracks` | `readonly Track[]` | One clip each. | | `Track.media` | `number` | Index into `manifest.media`. | | `Track.lane` | `number` | | | `Track.start` | `number` | Where on the timeline the clip begins. | | `Track.clipStart`, `clipEnd?` | `number` | Where in the file it begins and ends. | | `Track.volume`, `speed` | `number` | | | `Track.fadeIn`, `fadeOut` | `number` | The clip’s own fades. | | `Track.mode` | `TrackMode` | `auto`, `looping` or `once`. | | `Track.interval` | `number` | For `auto` only: the silence between one play ending and the next; `0` is a gapless bed. | #### `Entity`, `Memory`, `CvarSnapshot`, `Changed` [Section titled “Entity, Memory, CvarSnapshot, Changed”](#entity-memory-cvarsnapshot-changed) | Field | Type | Meaning | | ------------------------------- | ---------------------------------------- | ----------------------------------------- | | `Entity.key` | `string` | | | `Entity.component` | `string`, optional | | | `Entity.label` | `string` | | | `Entity.values` | `RippleValue` | | | `Memory.nodes` | `RippleValue` | | | `Memory.machines` | `readonly (readonly [string, string])[]` | Pairs of strings, one per state machine. | | `CvarSnapshot.name`, `type` | `string` | | | `CvarSnapshot.default`, `value` | `RippleValue` | | | `Changed.entities` | `readonly Entity[]`, optional | Present when the entities moved. | | `Changed.memory` | `Memory`, optional | Present when memory moved. | | `Changed.cvars` | `readonly CvarSnapshot[]`, optional | Present when the console variables moved. | #### `CallbackEvent` [Section titled “CallbackEvent”](#callbackevent) | Field | Type | Meaning | | ------------ | ----------------------- | ---------------------------------------------------------------- | | `callback` | `string` | The author’s name for it, the key a handler is registered under. | | `event` | `CallbackEventName` | What it was declared on, dotted: `cvar.changed`. | | `board` | `number`, optional | | | `node` | `string`, optional | | | `name` | `string`, optional | | | `from`, `to` | `RippleValue`, optional | | | `index` | `number`, optional | | `CallbackEventName` is one of `story.started`, `board.entered`, `soundscape.changed`, `passage.shown`, `choice.taken`, `delay.started`, `delay.elapsed`, `story.ended`, `story.failed`, `cvar.changed`, `entity.spawned`, `entity.removed`, `entity.renamed`, `entity.field.changed`, `machine.state.entered`, `node.entered`, `node.exited`, `node.failed`. `CallbackKind` is the same set as the engine’s WebAssembly interface spells it, with dashes (`cvar-changed`). #### `AudioCommand` [Section titled “AudioCommand”](#audiocommand) | `kind` | Fields | | ------ | -------------------------------------------------------------------------------- | | `play` | `soundscape: Soundscape`, `fadeInMs: number` | | `stop` | `fadeOutMs: number` | | `cut` | none | | `cue` | `media: number`, `url?: URL`, `volume: number`, `speed: number`, `node?: string` | #### `RunEvent` [Section titled “RunEvent”](#runevent) One step of the run trace, as the engine’s WebAssembly interface spells it. A debugging view. ### `resolveLink` [Section titled “resolveLink”](#resolvelink) ```ts resolveLink(passage: Passage, src: string): string ``` Rewrites one link in a passage’s prose to the URL of the file it names, through `passage.media`, or answers `src` unchanged. The prose keeps the author’s own links; give this to your renderer’s image hook. ## Values [Section titled “Values”](#values) A story’s value is a `RippleValue`. | Story type | JavaScript | | ---------- | ------------------------------------------------------------- | | Null | `null` | | Boolean | `boolean` | | Int | `bigint`, all 64 bits | | Float | `number`, NaN and the infinities included | | String | `string` | | List | `readonly RippleValue[]` | | Map | `ReadonlyMap`, in the engine’s key order | Int and Float are different types and stay different: `7n` is an Int and `7` a Float, and a story comparing them gets `false`. Times, indices and voice ids are JavaScript numbers. A seed takes either. **Writing a value back:** * a plain object is taken as a map, in its own key order, so `{ gold: 3n }` is accepted where a map is meant; * `undefined` is written as `null`; * a `bigint` outside 64 bits throws `RangeError`; * any other type (a function, a symbol) throws `TypeError`. | Export | Signature | Does | | ---------- | ----------------------------------------------------- | ---------------------------------------------------------------- | | `asRecord` | `(value: RippleValue) => Record` | A map value’s entries as a plain object; `{}` for anything else. | ## Player [Section titled “Player”](#player) The loop over a session: it delivers every effect in order, keeps the turn the last one named, waits out a `hold`, applies the story’s sound and dispatches named callbacks. ```ts new Player(session: Session, handlers: PlayerHandlers) ``` ### `PlayerHandlers` [Section titled “PlayerHandlers”](#playerhandlers) | Handler | Type | Meaning | | ---------- | -------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | | `onEffect` | `(effect: Effect) => void` | Required. Every effect, in the order the engine produced it. | | `onError` | `(error: unknown) => void` | An error from a verb the player made by itself (the `elapsed()` after a hold), or thrown by a handler while a batch was being delivered. **Without it, a failure after a hold is an uncaught exception** thrown from a timer, and a handler’s error goes to `reportError`, or `console.error` where there is none. | | `audio` | `SoundscapeDriver` | Where the story’s sound goes. Every `audio` effect is applied to it as well as handed to `onEffect`. Without one, nothing is heard. | ### Members [Section titled “Members”](#members-1) | Member | Signature | Does | | ------------------------------------------------------------------- | --------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | | `session` | `Session` | The session underneath, for what the player does not wrap: the state readers, `checkpoint`, cvars, entities. **A verb called on it goes round the player**: a pending hold is not cancelled and may wake a later turn, and nothing it answers reaches `onEffect` or the sound. | | `handlers` | `PlayerHandlers` | As given. | | `turn` | `Turn \| undefined` | The last passage, prompt, wait or ending delivered. | | `start()` | `void` | `session.start()`, delivered. | | `choose(index?)` | `void` | `session.choose(index)`, delivered. | | `answer(values?)` | `void` | `session.answer(values)`, delivered. | | `setLocale(locale)` | `void` | `session.setLocale(locale)`, delivered. | | `restart()` | `void` | `session.restart()`, delivered. | | `stop()` | `void` | `session.stop()`, delivered. | | `pause()` | `string \| undefined` | `session.pause()`: delivers its effects and answers the save, or `undefined` and nothing stopped unless the story is on a passage or a prompt. Still answers after `dispose()`, so a page can save on its way out. Not `SoundscapeDriver.pause`. | | `consoleFeed(code)` | `PythonResult` | `session.consoleFeed(code)`; what it led to is delivered like any verb’s effects. | | `onCallback`, `onAnyCallback`, `clearCallbacks()`, `callbackMisses` | | See [Named callbacks](#named-callbacks). | | `dispose()` | `void` | Stops waiting on any hold and delivers nothing more. After it, `start`, `choose`, `answer`, `setLocale`, `restart` and `stop` do nothing at all, and `pause` still answers a save. | A verb on the player throws whatever the session’s verb throws (`HostError`, `EngineTrap`), to you. ### Delivery [Section titled “Delivery”](#delivery) For each effect of a batch, in order: 1. a turn becomes `turn`; 2. an `audio` effect is applied to `audio`; 3. a `callback` effect is dispatched to its handlers; 4. the effect is handed to `onEffect`. A handler that throws is reported, and the rest of the batch still arrives. A handler that calls `dispose()` stops the batch there. ### Holds [Section titled “Holds”](#holds) When the last effect of a batch is a `hold`, the player sets a timer for `ms` and then calls `session.elapsed()` and delivers what it answers. Any verb called while the hold is pending cancels the timer first, so a restart during a pause in the story does not wake the story a moment later. An error from that `elapsed()` goes to `onError`. ## Named callbacks [Section titled “Named callbacks”](#named-callbacks) An author publishes named points in the editor: `chapterTwo` when a board is entered, `goldChanged` when a variable moves. A callback carries no code; you register the handler against its name. | Member | Signature | Does | | ---------------- | -------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------- | | `onCallback` | `(name: string, handler: CallbackHandler) => () => void` | Calls `handler` whenever the callback the author named `name` fires, replacing any handler already under that name. Answers the function that removes it. | | `onAnyCallback` | `(handler: CallbackHandler) => () => void` | Calls `handler` for every callback that fires, registered or not. Answers the function that removes it. | | `clearCallbacks` | `() => void` | Forgets every callback handler. The miss count is kept. | | `callbackMisses` | `number` | How many callbacks fired with nothing registered under their name. | `CallbackHandler` is `(fired: CallbackEvent) => void`. The named handler runs first, then every `onAnyCallback` handler, then `onEffect` receives the same event as `{ kind: 'callback', event }`. * **A handler runs where the engine placed the callback**, inside the verb: a callback about a passage arrives before the passage does. * **A handler must not call a verb on the player.** A verb called while a batch is being delivered, from a handler or from `onEffect`, throws `ReentrantVerb`. Schedule it with `queueMicrotask` or `setTimeout`. * **A name nobody registered is not an error.** The story plays the same, and `callbackMisses` counts it. * **A handler that throws does not stop the story.** The error goes to `onError`, and the rest of the batch still arrives. * **A name is a published API**, the only thing joining the author’s declaration to your code. Renaming one in the editor breaks the handler registered under the old name. The names and their events are listed in [Host callbacks](/reference/host-callbacks/). ## Sound [Section titled “Sound”](#sound) Three layers: the session says what the story asks for as `audio` effects; the engine’s scheduler decides every voice, gain and handover from them; a **speaker** makes the sounds. `SoundscapeDriver` drives the scheduler and does every duty it asks of a host. A speaker is the only part that touches an audio API. ```ts const audio = new SoundscapeDriver(campaign, speaker, options?); const player = new Player(session, { onEffect, audio }); ``` ### `SoundscapeDriver` [Section titled “SoundscapeDriver”](#soundscapedriver) ```ts new SoundscapeDriver(campaign: Campaign, speaker: Speaker, options?: SoundscapeOptions) ``` | `SoundscapeOptions` | Type | Default | Meaning | | ------------------- | -------------- | ----------------- | --------------------------------------------------------- | | `maxCues` | `number` | `8` | How many cues may sound at once; the oldest gives way. | | `gainStep` | `number` | `0.004` | The smallest change of gain worth writing. | | `tickMs` | `number` | `25` | How often the scheduler is ticked while anything sounds. | | `muted` | `boolean` | `false` | Starts muted: every gain written as zero, and remembered. | | `now` | `() => number` | `performance.now` | The clock, in milliseconds, monotonic. | | Member | Signature | Does | | ---------- | --------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `apply` | `(command: AudioCommand) => void` | Obeys one of the session’s audio commands. A `Player` with `audio` calls it for you. **A `play` whose soundscape was copied, rebuilt or parsed from JSON throws `RippleError`**: pass the effect’s own object, or one `campaign.soundscape()` answered. A `cue` with no `url` is ignored. Does nothing after `dispose()`. | | `setMuted` | `(muted: boolean) => void` | Mutes or unmutes at once; the gains the scheduler would have written are kept. | | `muted` | `boolean` | Whether it is muted. | | `pause` | `() => void` | Holds every voice where it is, and the clock with them, a hidden tab say. The story is not touched: not `Player.pause`. | | `resume` | `() => void` | Lets them go again. | | `playing` | `string \| undefined` | The id of the soundscape in force: the one sounding, or the one waiting its turn while the last fades out. | | `idle` | `boolean` | Whether nothing sounds, waits or cues. | | `dispose` | `() => void` | Stops everything and lets every voice go. | What the driver does, so that a speaker does not: * the timer; * the scheduler’s commands; * a late voice: the time `voice()` took, times `rate`, is added to `from`, and a looping clip wraps inside its loop; a clip already over plays nothing; * gains written before a voice was ready, applied when it is; * tracks whose file has no URL, told to the scheduler as silence rather than tried; * mute, which is the scheduler’s: it writes zero and remembers; * the handover between soundscapes, which never overlap. A handover waits for the outgoing soundscape’s fade-out. ### The `Speaker` contract [Section titled “The Speaker contract”](#the-speaker-contract) ```ts interface Speaker { voice(url: URL, role: VoiceRole): Promise; } type VoiceRole = 'track' | 'cue'; interface Voice { play(request: VoicePlay): void; setGain(gain: number): void; pause(): void; resume(): void; stop(): void; onEnded?: () => void; // set by the driver onDuration?: (ms: number) => void; // set by the driver } interface VoicePlay { from: number; // ms into the file to?: number; // ms into the file; the file's end when absent gain: number; // 0 to 1, on the engine's gain scale rate: number; // 1 is as recorded looping: boolean; loopFrom: number; // ms: where each turn of a loop comes round to } ``` | Member | Obligation | | --------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `Speaker.voice(url, role)` | Resolves once the voice can begin **at once**: load the file, or enough of it. **Rejects** when the file cannot be played; the scheduler hears that as silence and never retries. `role` is a hint: a `track` may be minutes long, a `cue` is a short sound effect that must start on time. | | `Voice.play(request)` | Begins at once and replaces whatever the voice was playing. May be called again on the same voice for a gapless bed whose file ended coming round, and must follow on as closely as the API allows. Plays from `from` to `to`, or to the file’s end. When `looping`, reaching `to` comes round to **`loopFrom`, not `from`**. | | `Voice.setGain(gain)` | Writes a new gain, on the same scale as `VoicePlay.gain`. The scheduler writes one every tick a fade moves; smooth between them over a few milliseconds, or a fade is a staircase. | | `Voice.pause()`, `resume()` | Hold the voice exactly where it is, and let it go again. The driver pauses every voice together. | | `Voice.stop()` | Ends the voice for good and lets its resources go. No `onEnded` follows. | | `Voice.onEnded` | Fire once per play, when a non-looping voice reaches the end of what it was given. Never after `stop()`, never for a looping voice. | | `Voice.onDuration(ms)` | Fire once, when the file’s length is known. Until then a track played to its file’s end has no end the scheduler can plan a fade-out or a gapless turn for. | **The gain scale is cubic.** The editor writes a gain to mpv’s volume control, whose curve is cubic, so a speaker that writes amplitude writes `gain ** 3`: half the gain is an eighth of the amplitude. A speaker that writes the gain as amplitude mixes every soundscape louder in its quiet parts than its author heard it. A speaker of your own should pass the checks `@ripplefx/soundscape` passes: a file ends near its length and reports it; a clip ends near its `to`; a loop never ends; a cue ends near its length; a paused cue ends later by the pause; a stopped voice never ends. The contract is also published as [`docs/speaker.md`](https://github.com/ConnectingTheDots-DCWK/ripple_effect_js_ts_integration/blob/main/docs/speaker.md). ### `@ripplefx/soundscape` [Section titled “@ripplefx/soundscape”](#ripplefxsoundscape) ```ts new WebAudioSpeaker(options?: WebAudioSpeakerOptions) ``` | `WebAudioSpeakerOptions` | Type | Default | Meaning | | ------------------------ | ---------------------------------- | --------------------------- | --------------------------------------------------------------------------------------------------- | | `context` | `AudioContext` | a new one | The context to play in. | | `destination` | `AudioNode` | the context’s `destination` | Where every voice goes. | | `crossOrigin` | `'anonymous' \| 'use-credentials'` | `'anonymous'` | The CORS mode for a track on another origin. | | `cueCache` | `number` | `32` | How many decoded cues are kept for the next time they are cued; the least recently used goes first. | | Member | Signature | Does | | --------- | ----------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `context` | `AudioContext` | The context it plays in. | | `unlock` | `() => Promise` | Resumes the context unless it is running. Browsers start no sound before the reader has clicked, tapped or pressed a key on the page, so call it from that gesture’s handler: a *Begin* button. | | `voice` | `(url: URL, role: VoiceRole) => Promise` | The `Speaker` method. | | `close` | `() => Promise` | Closes the context; nothing plays through this speaker afterwards. | | Export | Signature | Does | | ----------- | -------------------------- | -------------------------------------------------------------------------------------------------------- | | `amplitude` | `(gain: number) => number` | A gain on the engine’s scale as an amplitude: clamped to 0–1, a non-finite gain read as `0`, then cubed. | How it makes each kind of sound: * **A track streams** through a media element into a gain node. A music bed decoded whole is about 23 MB a minute. The element is routed into Web Audio once it is playing, muted until then, and the file’s end is watched for as well as listened for. * **A cue is decoded** once, cached, and started from its buffer, so a sound effect is never late. It is fetched with `mode: 'cors'`. * **Every gain goes through a `GainNode`**, on the cubic scale, smoothed between the scheduler’s steps. It never uses `HTMLMediaElement.volume`, which iOS ignores. A file on another origin needs that origin’s CORS headers, or Web Audio hears it as silence. MP3, AAC, FLAC and WAV play in every current browser; Ogg and Opus do not play in older Safari, and on iOS that is every browser. ## Errors [Section titled “Errors”](#errors) Every error the package throws is a `RippleError`, and each is a class you tell apart with `instanceof`. Every message is English, for a log; branch on the fields. | Class | Thrown by | Fields | Met in use | | --------------- | ------------------------------------------------------------------------------ | --------------------------------------------------------------------------------------- | ------------------------------------------------------------ | | `RippleError` | the base of the rest; also `SoundscapeDriver.apply` given a copied soundscape | `message` | no: a bug | | `LoadError` | `Campaign.load`, and the engine’s own download | `url: string`, `status: number \| undefined` (`undefined` when the fetch itself failed) | yes: a network or server failure | | `BundleError` | `Campaign.load`, `Campaign.fromFiles`, `campaign.soundscape`, `Session.create` | `failure: BundleFailure` | yes: a bundle that will not open, or one from a newer engine | | `NeedsPython` | `Campaign.load`, `Campaign.fromFiles` with `python: false` | none | yes, by your choice | | `ResumeError` | `Session.resume` | `failure: ResumeFailure` | yes: a save that no longer fits the campaign | | `HostError` | any session verb | `failure: HostFailure` | two kinds only (below) | | `ReentrantVerb` | a `Player` verb called while it delivers a batch | none | no: a bug | | `EngineTrap` | any call into the engine after it panicked | `cause` | yes, and rare | ### `BundleFailure` [Section titled “BundleFailure”](#bundlefailure) | `kind` | Fields | Meaning | | --------- | ------------------------------------------ | ---------------------------------------------------------------------------------------------------------------- | | `format` | `message`, `location?` | The bundle is unreadable, and where. | | `version` | `message`, `found`, `supported`, `oldest?` | The bundle’s format is one this engine cannot open: `found`, against the newest it `supported` and the `oldest`. | ### `ResumeFailure` [Section titled “ResumeFailure”](#resumefailure) | `kind` | Fields | Meaning | | ------------ | ------------------------ | ----------------------------------------------------------- | | `bundle` | `failure: BundleFailure` | The save itself will not read. | | `board-gone` | `board` | The board the save was on is not in this campaign any more. | | `node-gone` | `board`, `node` | The node the save was on is not on that board any more. | ### `HostFailure` [Section titled “HostFailure”](#hostfailure) A refused verb leaves the story where it was. | `kind` | Fields | Meaning | Met in use | | ---------------- | ------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------ | -------------- | | `choice-spent` | `port` | A choice drawn as unavailable was taken. | yes: handle it | | `unknown-locale` | `locale` | A language the campaign was not written in. | yes: handle it | | `wrong-turn` | `asked`, `answered` | A verb that does not fit the turn. `asked` is `present`, `ask`, `wait`, `ended`, `failed` or `stopped`; `answered` is `choose`, `answer` or `elapsed`. | no: a bug | | `no-such-choice` | `offered`, `chosen` | An index past the choices offered. | no: a bug | | `stopped` | none | The session is stopped. | no: a bug | | `nothing-asked` | none | The story is not waiting for anything. | no: a bug | ### `EngineTrap` [Section titled “EngineTrap”](#enginetrap) The engine panicked. The campaign’s instance is poisoned: every later call on that campaign, and on any session, player or driver over it, throws `EngineTrap` again. Other campaigns are unaffected. To recover, load the campaign again and resume your last save. ## Threading and lifetime [Section titled “Threading and lifetime”](#threading-and-lifetime) * **Every verb is synchronous** and runs on the calling thread. Nothing in `Session` returns a promise; only `Campaign.load`, `Campaign.fromFiles`, `Speaker.voice`, `WebAudioSpeaker.unlock` and `WebAudioSpeaker.close` do. * **A passage costs microseconds.** A Script node runs under its deadline (`scriptLimits.deadlineMs`), and the page does not paint while it runs. * **One queue, in call order.** Calls on a session are made one after another; a `Player` is that queue, and refuses a verb from inside its own delivery with `ReentrantVerb`. * **The timers** are the `Player`’s, for a hold, and the `SoundscapeDriver`’s, ticking every `tickMs` while anything sounds and stopping when it is idle or paused. | Object | Lets go with | | ------------------ | ------------------------------------------------------------ | | `Player` | `dispose()`: no more holds, no more deliveries. | | `SoundscapeDriver` | `dispose()`: cuts the sound and stops every voice. | | `WebAudioSpeaker` | `close()`: closes its `AudioContext`. | | `Session` | `stop()` ends it. It has no `dispose`. | | `Campaign` | No `dispose`. Its compiled engine stays cached for the page. | ## A complete host [Section titled “A complete host”](#a-complete-host) A page with a `#stage` element, a `#begin` button and a `#locale` select, rendering Markdown with `marked` and sanitising with `DOMPurify`. The callback names (`chapterTwo`, `goldChanged`, `skipAhead`) are the author’s, agreed with you. ```ts import { Campaign, type Effect, EngineTrap, HostError, LoadError, NeedsPython, type Passage, Player, type Prompt, type RippleValue, ResumeError, resolveLink, Session, type SessionOptions, SoundscapeDriver, } from '@ripplefx/player'; import { WebAudioSpeaker } from '@ripplefx/soundscape'; import DOMPurify from 'dompurify'; import { Marked } from 'marked'; const BASE = '/campaigns/the-city-of-elsenmar/'; const SAVE_KEY = 'elsenmar:save'; const stage = document.querySelector('#stage')!; const begin = document.querySelector('#begin')!; const languages = document.querySelector('#locale')!; const OPTIONS: SessionOptions = { untranslated: 'fall-back' }; // --- Rendering --------------------------------------------------------------- // The passage being drawn, for the image hook to resolve against. let drawing: Passage | undefined; const markdown = new Marked({ walkTokens(token) { if (token.type === 'image' && drawing) token.href = resolveLink(drawing, token.href); }, }); let campaign: Campaign; let player: Player | undefined; let audio: SoundscapeDriver | undefined; const speaker = new WebAudioSpeaker(); function el(tag: K, text?: string) { const node = document.createElement(tag); if (text !== undefined) node.textContent = text; return node; } function prose(passage: Passage): HTMLElement { drawing = passage; const html = campaign.manifest.textFormat === 'html' ? passage.text : (markdown.parse(passage.text, { async: false }) as string); drawing = undefined; const body = el('div'); body.innerHTML = DOMPurify.sanitize(html); return body; } function choose(index?: number): void { try { player?.choose(index); } catch (error) { // A choice drawn as unavailable is refused if pressed; nothing moved. if (error instanceof HostError && error.failure.kind === 'choice-spent') return; fail(error); } } function showPassage(passage: Passage): void { stage.replaceChildren(el('h2', passage.title), prose(passage)); if (passage.choices.length === 0) { const next = el('button', 'Continue'); next.onclick = () => choose(); stage.append(next); } passage.choices.forEach((choice, index) => { const button = el('button', choice.text || 'Continue'); button.disabled = !choice.available; // run out: shown, not taken button.onclick = () => choose(index); stage.append(button); }); } function showPrompt(prompt: Prompt): void { const form = el('form'); for (const field of prompt.fields) { const input = el('input'); input.name = field.name; if (field.type === 'bool') input.type = 'checkbox'; else if (field.type === 'int' || field.type === 'float') input.type = 'number'; if (field.default !== null && typeof field.default !== 'object') { input.value = String(field.default); } const label = el('label', field.name); label.append(input); form.append(label); } form.append(el('button', 'Continue')); form.onsubmit = (event) => { event.preventDefault(); const values: Record = {}; for (const field of prompt.fields) { const input = form.elements.namedItem(field.name) as HTMLInputElement; values[field.name] = field.type === 'bool' ? input.checked : field.type === 'int' ? BigInt(Math.trunc(Number(input.value || 0))) // an Int is a bigint : field.type === 'float' ? Number(input.value || 0) : input.value; } try { player?.answer(values); } catch (error) { fail(error); } }; stage.replaceChildren(el('h2', prompt.title), form); } function showHold(ms: number): void { // The Player waits the ms itself and calls elapsed(); this only draws it. const bar = el('progress'); bar.max = ms; stage.append(bar); const started = performance.now(); const step = () => { bar.value = performance.now() - started; if (bar.value < ms && bar.isConnected) requestAnimationFrame(step); }; requestAnimationFrame(step); } // --- Saving ------------------------------------------------------------------ interface Stored { save: string; generator: { lo: number; hi: number }; } function checkpoint(): void { const session = player?.session; const save = session?.checkpoint(); // undefined unless on a passage or a prompt if (!session || save === undefined) return; const stored: Stored = { save, generator: session.generator }; try { localStorage.setItem(SAVE_KEY, JSON.stringify(stored)); } catch { // Storage unavailable: the reader starts over next time. } } function stored(): Stored | undefined { try { const text = localStorage.getItem(SAVE_KEY); return text ? (JSON.parse(text) as Stored) : undefined; } catch { return undefined; } } function openSession(): Session { const seed = BigInt(Date.now()); const last = stored(); if (last) { try { const resumed = Session.resume(campaign, last.save, { ...OPTIONS, seed }); resumed.generator = last.generator; // a save carries no dice return resumed; } catch (error) { if (!(error instanceof ResumeError)) throw error; // The campaign changed under the save: start afresh. } } const wanted = navigator.language.split('-')[0]!; const locale = campaign.manifest.project.locales.includes(wanted) ? wanted : undefined; return campaign.session({ ...OPTIONS, seed, ...(locale ? { locale } : {}) }); } // --- Effects ----------------------------------------------------------------- function onEffect(effect: Effect): void { switch (effect.kind) { case 'show': showPassage(effect.passage); queueMicrotask(checkpoint); break; case 'ask': showPrompt(effect.prompt); queueMicrotask(checkpoint); break; case 'hold': showHold(effect.ms); break; case 'ended': stage.replaceChildren(el('h2', effect.ranOut ? 'The story ran out.' : (effect.title ?? 'The End'))); try { localStorage.removeItem(SAVE_KEY); } catch {} break; case 'failed': stage.replaceChildren(el('h2', 'The story stopped.'), el('p', effect.message)); break; case 'stopped': case 'jumped': case 'ambience': case 'audio': // applied to the driver by the Player case 'changed': case 'callback': // dispatched through onCallback below case 'trace': break; } } function fail(error: unknown): void { if (error instanceof EngineTrap) { // This campaign's instance is poisoned: load it again, resume the last save. void open(); return; } console.error(error); } // --- Playing ----------------------------------------------------------------- function play(): void { player?.dispose(); audio?.dispose(); audio = new SoundscapeDriver(campaign, speaker); player = new Player(openSession(), { onEffect, onError: fail, audio }); player.onCallback('chapterTwo', () => console.info('chapter two unlocked')); player.onCallback('goldChanged', (fired) => { const gold = fired.to; // a bigint when the variable is an Int console.info('gold', gold === undefined ? '' : campaign.asText(gold)); }); player.onCallback('skipAhead', () => { // A verb from a handler throws ReentrantVerb: schedule it instead. queueMicrotask(() => choose(0)); }); player.start(); } async function open(): Promise { player?.dispose(); audio?.dispose(); begin.hidden = true; stage.replaceChildren(el('p', 'Loading…')); try { campaign = await Campaign.load(BASE); // python: 'auto' } catch (error) { const why = error instanceof LoadError ? `${error.url} could not be fetched` : error instanceof NeedsPython ? 'this campaign needs Python' : (error as Error).message; stage.replaceChildren(el('h2', 'This campaign would not open.'), el('p', why)); return; } stage.replaceChildren(el('h1', campaign.manifest.project.title)); begin.hidden = false; } // No browser makes a sound before the reader's first gesture. begin.onclick = async () => { begin.hidden = true; await speaker.unlock(); play(); }; function setLocale(locale: string): void { try { player?.setLocale(locale); } catch (error) { if (error instanceof HostError && error.failure.kind === 'unknown-locale') return; fail(error); } } languages.onchange = () => setLocale(languages.value); // A hidden tab is not listening: hold the sound, keep the story. document.addEventListener('visibilitychange', () => { if (document.hidden) audio?.pause(); else audio?.resume(); }); // Save on the way out. window.addEventListener('pagehide', () => checkpoint()); void open(); ``` ## Related [Section titled “Related”](#related) * [Embedding the engine](/explanation/embedding-the-engine/): why a host is shaped as sessions, effects and a speaker. * [How the engine ships](/explanation/how-the-engine-ships/): versions, variants and the Runtime Licence the player carries. * [Play a campaign in your program](/how-to/play-a-campaign-in-your-program/): the steps from an exported campaign to a running host. * [Engine targets](/reference/engine-targets/): every platform the engine is built for. * [Host callbacks](/explanation/host-callbacks/): what an author’s named callbacks are for, and how the two halves meet. # The campaign window and saved games > What the campaign window shows, its header controls, the four panels, Play from here, and what a saved game holds and where it lives. The **Play** button at the bottom right of a board opens the campaign window and runs the story from that board’s Start, following Jumps from board to board until a path reaches an End. ![A story being played: rendered prose above a list of choices](/_astro/play-light.CcR2CsXk_2A80B.webp) ![A story being played: rendered prose above a list of choices](/_astro/play-dark.Dd_vqrUr_ZaeXi0.webp) | Property | Value | | ---------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | Engine | The native engine an exported campaign runs. Play and an export share one implementation. | | What is compiled | The whole project, not the open board. | | Before compiling | Play re-indexes what passages link to and where boards jump, the same index an export reads. A picture added to a passage since the project was opened is in the playthrough without exporting. | | Compile warnings | Listed in **Messages** when Play is pressed, from the same compiler as an export. Examples: a roll that cannot vary, a macro missing one of its ends. | | Board checks | Play checks every board with the engine’s checks. A finding names the cards it concerns. `W.GE.06` can be acknowledged per card with *I meant that*. See [Diagnostics](/reference/diagnostics/). | | Python off | If the project has Python turned off (project settings, *Capabilities*), every Script node and every passage with Python in it is marked as an error and listed in Messages. The campaign does not play until none remain. | ## Window [Section titled “Window”](#window) | Control | Behaviour | | ------------------------------ | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | Size | The window grows to fit the screen it is on and shrinks to fit a small one. | | Full-size button (header) | Takes the window to the app’s edges and back. Escape also brings it back. | | Language (header) | The reader picks the language and can change it mid-passage. A passage with nothing in the chosen language says so; it does not fall back. | | **Mute** (header) | Remembered between sessions. It belongs to the campaign: previewing a soundscape in its own tab is never silenced by it. A soundscape’s volume is the file’s and every clip’s is relative to it; there is no second master. | | Bookmark (header, beside Play) | Saves the playthrough under a name. Any number may be saved. Saving does not end the playthrough. See [Saved games](#saved-games). | | Start over | Always available, from the first passage to an ending. *Play again* is also offered on an ending. While a story runs, the header’s Play control reads **Start over**. | | Panel strip | A narrow strip down the right edge opens the four panels. An open panel takes a third of the window. | ## What a slide shows [Section titled “What a slide shows”](#what-a-slide-shows) | Element | Behaviour | | ------------------------------------- | ------------------------------------------------------------------------------------ | | Passage | Rendered as Markdown with its Python already run. | | Choices | The tiles beneath the passage. | | Choice with a used-up **Times** limit | Stays on screen, greyed out and unpressable. A passage never becomes an empty slide. | | Delay node | Holds the last passage on screen, greyed, with a thin bar while it waits. | | Input node | A form. Enter in a field is Continue. | | Loop that nothing breaks | Stops the campaign and offers the node that ran away. | ### Input form fields [Section titled “Input form fields”](#input-form-fields) | Field kind | Control | | ---------- | -------------------------------------------- | | Text | A text field. | | Number | A number field that does not accept letters. | | Truth | A switch. | | List | A line of comma-separated values. | A story can be paused, saved and resumed at an Input node; resuming asks again. The terminal player asks one line per field, and an unattended walk leaves every field blank. ### Loop ceiling [Section titled “Loop ceiling”](#loop-ceiling) | Property | Value | | ---------------------- | ------------------------------------------------------------------------------------ | | Meaning | The number of times one loop node may go round in a board before the campaign stops. | | Default | 512. | | Choices | 128, 512, 2,048, 8,192, or None for no ceiling. | | Where set | *Playing* in Settings. | | Interaction | A For node’s own *Max iterations* applies first when it is lower. | | Travels with an export | No. The ceiling is the app’s. | ## Play from here [Section titled “Play from here”](#play-from-here) Right-click any node on a board and choose **Play from here**. The campaign begins at that node instead of the Start. It starts with nothing made yet, and says so. The console in the campaign window sets up whatever the story should have had; it may do everything a Script node may. See [How a story runs](/explanation/how-a-story-runs/) for why nothing is invented. ## The four panels [Section titled “The four panels”](#the-four-panels) | Panel | Shows | Notes | | ------------------ | -------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | **Entities** | Everything the story has made, grouped under the component each came from. | A count on each group heading. A search line finds an entity by name, or a component’s name to find everything made from it. Every row has a bin; nothing is asked first, the playthrough is ephemeral and the component file is untouched. | | **Variables** | What every console variable holds now. | Editable by hand. See [Console variables](/reference/console-variable-declarations/). | | **Python console** | The Python console over the playthrough that is running. | The same console as the script editor, with the same scrollback and history. | | **Trace** | What the story did, line by line. | See below. | ### Trace panel [Section titled “Trace panel”](#trace-panel) | Property | Behaviour | | ------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | Lines | Written in the story’s own words, for example *Read* **The gate** *chose “Bribe the guard”*, *Set* **gold** *to 2 (was 10)*, *Rolled* **2d6+3 → 11**, *Jumped to* **The yard**, *Ambience* **tavern** where a passage brought a soundscape, and what a Script node printed. | | Line decoration | Each line wears its node’s icon and colour. | | Default reading | What a reader could have noticed. | | Toggle | Shows everything underneath: every turn, the values evaluated, the scheduler’s remarks, with a technical line behind each. | | **Copy** | Puts the plain lines on the clipboard. | | Tapping a line | Pauses the story, closes the window, opens the board with the node selected and centred. **Resume** brings the story back. | | Line inside a macro | Takes you to the card that called the macro, since that is what is on the board. | ## Host callbacks panel [Section titled “Host callbacks panel”](#host-callbacks-panel) The window has a **Host callbacks** side panel. It lists every fire: the name, the event, whichever payload fields are set, and what became of the lookup. The editor publishes the names but has no game to react to them, so normally nothing is registered and every fire shows as a name a game would have looked up. A name with nothing registered is not an error; the story plays exactly as it would have. See [Host callbacks](/reference/host-callbacks/). ## Saved games [Section titled “Saved games”](#saved-games) | Property | Value | | --------------- | -------------------------------------------------------------------------------------------------------------------------------- | | Location | `.ripple/saves/` in the project, one file per slot. | | Automatic save | Pausing writes it, into the slot a pause owns. It survives closing the app. | | Named saves | Made with the bookmark in the header, any number, alongside the automatic one. | | Version control | Saves are left out. A playthrough is a fact about the reading, not the story, and the automatic one is rewritten on every pause. | ### Contents of a save [Section titled “Contents of a save”](#contents-of-a-save) | Recorded | Notes | | ------------------------ | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | Where the playthrough is | The place in a board. | | Entities | Everything the story has made. | | Console variables | What each variable held, not its type. Any of the 24 types, `json` included. | | Node memory | What the FlipFlop, Once and State machine nodes remember. | | Soundscape | Which one was playing, so a load hears it before it sees the page. | | **Times** counts | A choice’s count is saved: loading finds the path as spent as it was, and starting over reopens it. A macro counts per call, so a corridor called from two places has a budget for each. | ### Opening a saved game [Section titled “Opening a saved game”](#opening-a-saved-game) | Property | Behaviour | | ------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | Where | The bookmark beside Play, or the command palette command *Open a saved game…*. | | List | Each save with the passage it is waiting at and how long ago it was written. A save made while waiting at an Input node shows *Asks for* and the field names instead. A save whose file was changed outside the app is marked *edited by hand*. | | Effect | Opening one discards the campaign in progress. The confirmation, *Open this saved game?*, says to save that first to keep it. | | Changed story | A save is refused when the story has changed since: the save records a place in a board that no longer exists. The save is listed with the reason beside it and left alone. | | Deleted board | A save naming a board the project no longer has is reported in Messages: there is nowhere to pick it up. | | Pause and resume in one sitting | Not refused. Resume hands back the board just edited. | | Newer save in an older build | Refused. A build older than the save does not reset a `json` variable to its default. Games saved before the upgrade have to be saved again. | ## Related [Section titled “Related”](#related) * [How to play and test a campaign](/how-to/play-a-campaign/) * [How to save and resume a playthrough](/how-to/save-and-resume-a-campaign/) * [Why the preview is the shipped story](/explanation/the-preview-is-the-shipped-story/) * [Console variable declarations](/reference/console-variable-declarations/) * [Project files and the .ripple folder](/reference/project-files/) # Board and navigation rules > The rules a board's canvas enforces, the two kinds of port and wire, and how script navigation, traces and the merged view behave. ![A branching story laid out on a canvas, passages wired to the choices that lead between them](/_astro/board-light.BI1_yn4D_Z1V0sGO.webp) ![A branching story laid out on a canvas, passages wired to the choices that lead between them](/_astro/board-dark.SXOEmDAO_20Hnfl.webp) ## Start [Section titled “Start”](#start) | Rule | Behaviour | | -------------------- | ------------------------------------------------------------------------------------ | | Count | A board has exactly one **Start** node. | | Create menu | Does not offer a second Start. | | Two Starts | Play refuses the board and names both Start nodes. | | Where a board begins | The node that is the Start. Script navigation draws the same answer the runner uses. | ## Creating and editing cards [Section titled “Creating and editing cards”](#creating-and-editing-cards) | Action | Result | | ------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | Right-click empty canvas | The Create menu. Every card is made from it: a passage, a Jump, a Script, a loop, a component. | | Drop a wire on empty canvas | The same menu opens there; the card you pick is wired to the port the wire came from. | | Right-click a node, *About this node* | What the card is for, what each control does, what each port takes or answers, and a link to the docs page, rendered as Markdown. The [nodes reference](/reference/nodes/) is written from the same text. | | Right-click a node, *Edit metadata…* | A list of names, types and values. A small tag shows on the card’s header while there is any. | | The board’s toolbar | Has no *add a passage* button. Cards are made only from the Create menu or by dropping a wire on empty canvas. | | Undo, Redo | In the Edit menu and on their keystrokes. | | Shortcuts card | View ▸ Shortcuts card brings it back. | ### Node metadata [Section titled “Node metadata”](#node-metadata) * Nothing on the board reads it. * It travels with the node into a compiled campaign and to an agent. * A copied node arrives with its notes. * Entry types are `int`, `float`, `bool` and `string`, a list of each, and `json` for anything nested. This is shorter than a port’s list of types: a node stores the value and not its type, so the editor infers the type from the value’s shape. `asset`, `componentRef`, `entity` and `markdown` are not offered, because each is a plain string once written. * A build of the editor older than the feature, re-saving such a board, keeps every wire and every field and drops the notes. ### Pickers [Section titled “Pickers”](#pickers) Every picker of something you made opens with a search field focused. Typing narrows by name or by path, which sits under each name. Entries are alphabetical. | Picker | Chooses | | ------------- | -------------------------- | | Jump | A board. | | Component | A component. | | Script | A `.py` file. | | Narration | A passage or a soundscape. | | State machine | A diagram. | | `cvar` node | A variable. | The exception is a Jump’s node picker, which keeps a board’s reading order. ### Links [Section titled “Links”](#links) | Setting | Wire drawing | | ------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------ | | Default | Curved. | | Settings ▸ Links ▸ Right-angled | Every wire on every canvas is straight legs with rounded corners. A handle on a wire is then a corner and lines up with its neighbours as you drag it. | ### Text size [Section titled “Text size”](#text-size) Cards stay at the size they were drawn at whatever the desktop’s text size is. Zoom changes a board’s size. The rest of the app grows with the setting, up to double. ## Narration nodes [Section titled “Narration nodes”](#narration-nodes) A Narration node holds a passage and grows one output port per choice. Wiring the last output makes a new spare appear beneath it. ### Choice fields [Section titled “Choice fields”](#choice-fields) Click a choice’s row on the card to edit it. | Field | Meaning | | --------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | Name | The label the card shows, so a branch can be found on a wide board. | | Words | What the reader is offered, in every language the story is written in. A choice with nothing in the reader’s language falls back to the language the story was written in. | | Times | Blank: the choice can be taken as often as the reader likes. A number: the ceiling. | | Wire caption | An annotation for the author. No reader sees it. | | Condition input | A small input on every wired choice’s row. See below. | ### Times [Section titled “Times”](#times) | Rule | Behaviour | | ----------------------------------------------------- | ---------------------------------------------------------------------------------------------------- | | Once a playthrough has taken the path that many times | The choice stays on screen, greyed out and unpressable. | | Card display | The ceiling is shown as `×3` at the end of the choice’s row. | | Scope of the count | The whole campaign. Jumping away and back, or saving and loading, finds the path as spent as it was. | | Starting over | Reopens the path. | | Macros | The count is per call: a macro called from two places has a separate budget for each. | | No number set | The choice remembers nothing. | ### Condition input [Section titled “Condition input”](#condition-input) | Rule | Behaviour | | ---------------------------------------- | ----------------------------------------------------------------------------------------------------------- | | Wired `bool` is true | The choice is offered. | | Wired `bool` is false | The choice is greyed out and unpressable, the same as a path that has run out. | | With *Times* | Both apply to the one choice. | | Cards in the flow leading to the passage | Run on every path to it, so the answer is current. | | Shape that works | A Do all with a chain per branch, completed into the passage. | | Cards hanging off the side of the board | Worked out once before the story starts, answer the same for ever, and the messages panel names the choice. | | A Comparator reading a dice roll | Follows the same rule. | ### Reads dialog [Section titled “Reads dialog”](#reads-dialog) The *Reads* row sits at the bottom of the card, beside the soundscape the card brings. It shows how many names the passage is handed and opens the dialog that declares them. | Rule | Behaviour | | --------------------------------------- | ------------------------------------------------------------------------------------- | | Declaration | Named, typed inputs, each with an optional default. | | Ports | The card grows a port for each. | | Use in the passage | Python reads them by name: `You owe {{ gold }} crowns.` | | A slot with a default and nothing wired | Uses the default. | | Where the names belong | To the card, not the passage file. One passage can be a template read by two cards. | | A name the card does not supply | A small warning shows where it stood; the rest renders. | | Name completion | Offered as you type when opened from a board. Not offered when opened in its own tab. | | Valid names | Names Python can read. | ## Jump [Section titled “Jump”](#jump) | Rule | Behaviour | | ----------------- | -------------------------------------------------------------------------------------------- | | Target stored | The target board’s catalog id, not its path. Renaming or moving a board never breaks a link. | | Arrow on the card | Opens the board the Jump points at. | ## Ports and wires [Section titled “Ports and wires”](#ports-and-wires) Every port is one of two kinds. The canvas draws them apart. | | Control | Data | | ----------------------- | ------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------- | | Carries | The story. | Values: a number, a name, a list of goblins, the state a machine is in. | | Shape | Triangle, pointing the way the flow runs. | Dot. | | Tooltip | None. | The type it takes: `number`, `list`, or `anything` for ports that accept whatever is given, such as a Comparator’s two sides. | | Examples | The way in `exec`; the choices, branches, the *first* and *again* of a gate, and the `completed` of a node that merely did something. | Any port that carries a value. | | Direction of evaluation | Pushed. | Read: the card gets what the card at the other end last published. | | Role of the wire | A step in the story. | A question the card at its end asks when its turn comes. | * The story is the path from Start along control wires. * Two branches that converge on a card run it twice. Nothing waits for both. * It is one engine, whether the campaign is played in the app or shipped. ### Evaluation [Section titled “Evaluation”](#evaluation) | Rule | Behaviour | | ------------------------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | | A card reading a data input | Gets what the card wired into it last published: this turn, an earlier one, the priming before the story, or nothing. Reading runs no card. | | A card that has not run on this path | Its output holds an older turn’s value, or nothing. `W.GE.06` asks about it. | | Priming | Before a story begins the engine walks the data wires back from everything the Start can reach. Cards the story never reaches are run once ahead of the story, in dependency order. | | Consequence of priming | A Number into a Set variable, a String into a Component’s name or a Get variable into an Output needs no `exec` wire. | | A value that depends on something the story does | Must be in the flow after it. A Comparator hanging off a Dice roller is primed before there is a roll, and answers for a total that did not exist yet. Wire the roller’s `completed` into the Comparator and the Comparator’s into the If. | ### The story only goes forwards [Section titled “The story only goes forwards”](#the-story-only-goes-forwards) Reading a value never makes a card run. The card reads **whatever the card wired into it published the last time it had a turn**: this turn, an earlier passage, the priming before the story began, or nothing at all. Nothing is fetched by running a card backwards. | State of a value | Meaning | Check | | --------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | | Computed on the way in | Its cards are in the flow just before whatever reads them, so every path to that passage runs them. For several values, put a **Do all** in front of the passage with a chain per value and its `completed` wired on. | None. This is the intended state. | | Worked out before the story | Nothing in the flow reaches it, so it was primed and answers the same on every visit. Answers the same on every visit. | [`W.GE.05`](/reference/diagnostics/#wge05) wherever a primed value’s cards read something the story changes. | | Left over from another path | Its cards are in the flow, but the way the reader got here this time did not pass them, so the value is an older turn’s. Which visit it came from cannot be worked out. | [`W.GE.06`](/reference/diagnostics/#wge06) when not every route to the reading card runs the card that makes the value. It can be answered per card with *I meant that*. | ### Marks on the board [Section titled “Marks on the board”](#marks-on-the-board) * A card the engine has something to say about wears a halo in the colour of how serious it is. * A small mark at the end of its header has a tooltip naming the check and the reason. * Only problems and cautions are marked. * The full records are in the messages panel, where they can be filtered. * Pressing Play checks every board, and a finding names the cards it concerns. Every check is in the [diagnostics reference](/reference/diagnostics/). ### Wires that go back [Section titled “Wires that go back”](#wires-that-go-back) | Wire | Meaning | | ------------------------------------------ | ---------------------------------- | | Control wire to an earlier card | A loop. | | Data wire back into the card that feeds it | Reads that card’s previous answer. | | Cycles | Nothing refuses one. | ## Script navigation [Section titled “Script navigation”](#script-navigation) ![A map of how the boards hand the story to each other](/_astro/overview-light.D1tBQF-v_Z1a4qXO.webp) ![A map of how the boards hand the story to each other](/_astro/overview-dark.DTmZb2Lk_FIgCs.webp) Opened from the first button in the left panel’s footer. | Element | Meaning | | ----------- | ---------------------------------------------------------------------------------------------------- | | Card | One per board. | | Output | One per way out of the board. | | Arrow label | The node it leaves by. For a Jump into the middle of a chapter, where it lands: `Down → The cellar`. | | Markers | Where a thread begins and ends. | | Editing | Nothing on it can be moved or rewired. | | Layout | Worked out for you; the same project lays out identically every time it is opened. | ## Traces [Section titled “Traces”](#traces) ![Every route between two passages picked out in colour while the rest of the board dims](/_astro/trace-light.DA4waBD6_Z1sd2fN.webp) ![Every route between two passages picked out in colour while the rest of the board dims](/_astro/trace-dark.W1DQuwA7_Z2qPgmR.webp) | Gesture | Result | | ----------------------------------- | ----------------------------------------------------------------------------------------------------------------- | | Pick two passages on a board | Every route between them lights up in colour, a hue per linear run, and the rest of the board goes under a scrim. | | Same gesture on the boards overview | Traces between two chapters. | | **What leads here** on a passage | Every route that arrives at it, from wherever the story begins. | | Lifecycle | Behaviour | | ----------------------------------- | ----------------------------------------------------------------------------------------------------------------- | | Confirming a trace | Finishes it. The board keeps its routes with only the cross beside them, and the next click is an ordinary click. | | Several boards | Two boards can each show a trace at once. | | Editing the board | Drops its trace. | | A route that arrives through a Jump | The board cannot draw it; the answer opens the merged view. | ## Merged view [Section titled “Merged view”](#merged-view) Every board on the route on one canvas, with the bookkeeping between two passages traversed rather than drawn. It is read-only, like script navigation. | Route element | Drawing | | -------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------- | | A choice that runs a script and returns to the passage it left | An arrow back to that passage. | | A Do-all’s branches | One after another on the route to `completed`, not a fork. Their passages are on the route and the count of ways in does not count them as alternatives. | | One wire’s end fanned out to several places | Treated the same way as a Do-all. | | A macro called on the route | The route goes inside it: the passages the macro holds are drawn where the call is, in each place it is called. | | An *If*, *Switch*, roll or loop on an arrow | The arrow names that node and the way it went, such as `Hit? → true`, beside the words of the choice if there was one. | | Interaction | Behaviour | | --------------------- | ------------------------------------------------------------------ | | Opening a merged view | Takes every board’s trace away. | | While one is open | A board’s two trace buttons wait, and say why, until it is closed. | ## Related [Section titled “Related”](#related) * [How to build a board](/how-to/work-on-the-canvas/) * [How to trace the routes between two passages](/how-to/trace-a-route/) * [Boards, nodes and macros](/explanation/boards-nodes-and-macros/) * [How a story runs](/explanation/how-a-story-runs/) * [Reading a large story back](/explanation/reading-a-story-back/) # Console variable declarations > The types a console variable may have, the .ripple/cvars.json file that declares them, board scopes, versions and what a save or export carries. A project declares its [console variables](/explanation/console-variables/) in `.ripple/cvars.json`, edited on the **Console variables** page. This page lists what a declaration holds and which formats carry it. ## The Console variables page [Section titled “The Console variables page”](#the-console-variables-page) | Item | Behaviour | | ------------ | -------------------------------------------------------------------------------------------------------------------------------------------- | | Reached from | The project buttons in the activity bar, or the command palette. | | Row | A name, a type and a default, plus a **Boards** column. | | Renaming | Free for every board that names the variable: a node stores the variable’s id, not its name. A script that named the old name stops working. | | Forgetting | Asks first. A node that names a forgotten variable reports that the variable is gone. | ## Types [Section titled “Types”](#types) Twenty-four types, and no others: the eight base types on their own, as `list<…>` and as `map<…>`. The same twenty-four are offered by every other editor in the app. | Type | Holds | Default | | -------------- | ----------------------------------------------- | ---------- | | `int` | A whole number. | `0` | | `float` | A number. | `0.0` | | `bool` | `true` or `false`. | `false` | | `string` | Text. | empty text | | `markdown` | Text with a renderer. | empty text | | `asset` | A path to a file in the project. | empty text | | `componentRef` | A path to a component. | empty text | | `json` | Any JSON value at all. | `{}` | | `list<…>` | An ordered list of any of the eight base types. | `[]` | | `map<…>` | Names to any of the eight base types. | `{}` | In the file a type is spelled canonically: `int`, `list`, `map`. * `markdown`, `asset` and `componentRef` accept the same values as `string`. They say what the variable is for, which decides the field the page draws. * `json` holds a list or an object as it stands, so a `list` is a list of lists if that is what it is given. * Every type refuses an empty value (null). Writes are coerced to the declared type when nothing is lost and refused otherwise. The coercion table is under [Console variables](/reference/python/console-variables/#coercion). ### Path-holding variables [Section titled “Path-holding variables”](#path-holding-variables) A variable of type `asset` or `componentRef`, on its own, in a list or in a map, is filled by name. | Where | Control | | -------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | The Console variables page | Type a few letters and pick from the project’s files, with the full path beside each. The pick is a chip showing the name; hovering shows the path, which is what the variable stores. A chip whose file is outside the project or no longer there is marked. | | A Set variable node’s typed value, on a card | A searchable dropdown of the project’s files. | | The campaign window’s panel | A text field: there is no project to offer. | ## `.ripple/cvars.json` [Section titled “.ripple/cvars.json”](#ripplecvarsjson) The file sits beside the project’s settings and travels with the project. It is not catalogued and is not opened as a document in the viewer panel. ```json { "version": 3, "cvars": [ { "id": "4f2a…", "name": "gold", "type": "int", "default": 10 }, { "id": "9c11…", "name": "tags", "type": "list", "default": [], "scope": ["28cf…"] } ] } ``` | Key | Rule | | --------- | ------------------------------------------------------------------------------------------------------------- | | `version` | Required. A file with no `version` is broken, not old. | | `cvars` | Array of objects; absent reads as none. | | `id` | Required string. | | `name` | Required string, trimmed, non-empty and unique across the file. A repeated name is refused as declared twice. | | `type` | One of the twenty-four types, spelled canonically. Anything else is refused. | | `default` | Coerced to `type` by the runtime’s rule; refused when it does not fit. | | `scope` | Array of board ids, each once, order kept. Absent or empty means everywhere; written only when non-empty. | The reader is strict: every failure names `cvars[i].`. A file written by a newer build is refused, and the page says so; a broken file is read as empty and never written over. Changing a variable’s type keeps the default if it still fits and otherwise takes the new type’s own (`3` becomes `3.0`; `"three"` becomes `0`). ### Versions [Section titled “Versions”](#versions) | Version | Added | | ------- | ---------------------- | | 1 | The first file. | | 2 | `scope`. | | 3 | The twenty-four types. | A version 1 file reads as version 2 with no scopes. Each bump restamps the document and changes nothing else; each exists so that an older build refuses the file rather than reading it and writing it back without what it could not name. Any build older than the file’s version refuses the file, even when no variable is scoped. ## Scope [Section titled “Scope”](#scope) The **Boards** column is a searchable menu of the project’s boards with a box beside each. It reads *Everywhere* until a board is ticked. | Aspect | Behaviour | | ------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | | Stored as | Board catalog ids, so a renamed or moved board keeps its scope. | | Get variable, Set variable, Variable branch | Offer the variables scoped to the node’s board and those scoped to none. | | A node already naming a variable scoped elsewhere | Keeps its name and shows *Not scoped to this board* under it. | | A card that cannot tell which board it is on | Offers every variable. | | A board that no longer exists | Its id stays in the file, and the page says *One board it names is gone* under the field. It counts as a board no node can be on. | | What a scope does not change | A scope decides where a variable is offered, not where it is read: the campaign holds every variable everywhere, a script names any of them from anywhere, and nothing about a scope reaches a bundle. | ## Saves, agents and exports [Section titled “Saves, agents and exports”](#saves-agents-and-exports) | Context | What carries the declarations | | ---------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | Saved game | Every variable, by value and not by type. A save made before the project had a variable holds it at its default; a variable added while a campaign is paused is there on resuming; one removed is gone. Save format 5. | | Older build and a newer save | Refused outright, so a `json` variable is never quietly put back to its default. | | Compiled campaign | The declarations in the bundle’s own form. | | Project export (scaffold) | `.ripple/cvars.json` beside the project’s settings, so every card still points at its variable. | | MCP | `project_info` returns the declarations, with a scope resolved to board paths and anything that no longer resolves dropped. `campaign_state` returns the live values. An agent can point a variable node at a variable by name. | ## Related [Section titled “Related”](#related) * [Console variables](/explanation/console-variables/): what a variable is and why the nodes need no wire. * [How to declare and scope a console variable](/how-to/declare-console-variables/) * [Console variables](/reference/python/console-variables/): the Python functions and the coercion table. * [Project files and the .ripple folder](/reference/project-files/) * [MCP tools](/reference/mcp-tools/) # Diagnostics > Every coded message the messages panel can show, with its wording, its meaning and its fix. A coded message is a rule with a name. It is always worded the same way and has a heading on this page. Most messages in the panel carry no code: they report on a file, a translation, or something the app could not do. The coded ones are the engine reading the shape of your boards. A macro’s take declared as a map or as JSON cannot carry a typed value from the card; the compile reports that without a code. Takes typed `int`, `float`, a path to a file, a component or a list of those deliver their value to the story. ## Code format [Section titled “Code format”](#code-format) ```plaintext W.GE.05 │ │ └ the fifth rule about the shape of a board │ └─── GE: boards. RT: a playthrough. EX: an export. └────── E a problem · W worth a look · I a record ``` | Letter | Level | Meaning | Counted on the tab | | ------ | ------------ | ---------------------------------------------------------------------------------------- | ------------------ | | `E` | Problem | A board cannot do what it says: it will not play, or a card in it can never do anything. | Yes | | `W` | Worth a look | The story plays and may not do what you meant. | Yes | | `I` | Record | Something true about your board that is very probably fine. | No | ## Index [Section titled “Index”](#index) | Code | Subject | | ------------------- | ------------------------------------------------------------------------ | | [`E.GE.01`](#ege01) | Board has no Start | | [`E.GE.02`](#ege02) | Board has more than one Start | | [`E.GE.03`](#ege03) | Required input not wired | | [`W.GE.04`](#wge04) | Cards on no path | | [`W.GE.05`](#wge05) | Value worked out before the story depends on something the story changes | | [`W.GE.06`](#wge06) | A way in skips the card whose value is read | | [`W.GE.07`](#wge07) | While loop without `break` | | [`W.GE.08`](#wge08) | Jump cannot land | | [`I.GE.09`](#ige09) | Several wires leave one way out | | [`I.GE.10`](#ige10) | Several wires arrive at one input | | [`I.GE.11`](#ige11) | Value comes back round to its card | | [`I.GE.12`](#ige12) | Path stops without an End | | [`I.GE.13`](#ige13) | For exceeds the ceiling | | [`E.GE.14`](#ege14) | Circle of cards with no way in | | [`W.GE.15`](#wge15) | Name Python cannot read | | [`W.GE.16`](#wge16) | Host callback watches something that is gone | | [`I.RT.01`](#irt01) | Card ran more than once | | [`W.RT.02`](#wrt02) | Something read a card that had not run | | [`I.RT.03`](#irt03) | Several wires arrived at one input | | [`W.RT.04`](#wrt04) | Card with several ways out had no behaviour | | [`I.RT.05`](#irt05) | Story was given your stand-in | | [`W.RT.06`](#wrt06) | Story was refused something it read | | [`I.RT.07`](#irt07) | Story was allowed something there was nothing of | | [`E.EX.01`](#eex01) | Script node in a no-Python campaign | | [`E.EX.02`](#eex02) | Passage that runs Python in a no-Python campaign | ## Where messages appear [Section titled “Where messages appear”](#where-messages-appear) | Where | Behaviour | | ----------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | Messages panel | Messages are clustered by subject (Boards, Translations, Version control), each heading with a count you can fold away. Cluster by level instead to see problems first. Three chips above the list turn problems, cautions and records on and off. Both choices are remembered. | | Code | The code is a link to its heading on this page. | | Cards | A card a problem or a caution names is drawn with a halo in that colour and a small mark at the end of its header. Hovering the mark lists the codes on that card and what each says. Records are not drawn on a card: several of them, such as two wires off one port or paths that stop without an End, are earned by well-drawn boards, and a mark that is usually nothing is a mark you stop seeing. | | Clicking a message that names cards | Opens the board and lifts those cards out of it. **Escape**, or the cross in the toolbar, puts the canvas back. | | A file written by a newer version | An uncoded message. A strip across the top of the project says what could not be read, and Messages keeps the note together with the file’s path. | | In the play window | `RT` messages appear in the trace as the story runs, and stay in the messages panel afterwards. | A finding names the cards it concerns. ### When checks run [Section titled “When checks run”](#when-checks-run) | When | What is checked | | ----------------- | ---------------------------------------------------------------------------------------------------------- | | While you edit | The board you are working on, about a second after you stop typing. | | Pressing **Play** | Every board at once. Checks that need more than one board, such as whether a Jump can land, are made then. | ## Boards [Section titled “Boards”](#boards) ### `E.GE.01` [Section titled “E.GE.01”](#ege01) **This board has no Start, so nothing on it can begin a story.** A Jump can still land on a card here and the story runs on from there, but the board cannot be played on its own and is left out of the campaign when you press Play. A macro board is called rather than begun and is never asked for a Start. Fix: add a Start card and wire it to where the story begins. ### `E.GE.02` [Section titled “E.GE.02”](#ege02) **This board has more than one Start.** A board plays with one way in. Clicking the message frames every Start found. Until only one remains the board does not compile, and the freshness checks on it answer for a story with two beginnings. Fix: delete all but one. ### `E.GE.03` [Section titled “E.GE.03”](#ege03) **A card has nothing wired into an input it cannot work without.** An If’s `condition` comes from another card, usually a Comparator; a Comparator’s two operands are the same. A card with a field standing in for the input is not asked. An empty input reads as *nothing*, so the card answers the same way every time. Fix: wire the input, or put the card that works it out in front of this one. ### `W.GE.04` [Section titled “W.GE.04”](#wge04) **A stretch of cards is not on any path the story can take.** No control wire leads to them from the Start and nothing reads a value from them, so none of it ever runs. Clicking frames the whole island. Cards that are only read, such as a Number feeding a Set, are not islands: they are worked out before the story starts. Fix: wire the stretch in, or delete it. ### `W.GE.05` [Section titled “W.GE.05”](#wge05) **A value is worked out before the story starts, and it depends on something the story changes.** Cards nothing flows into are run once, before the first passage. A comparison against a die, a variable read or a gate’s answer depends on something the story changes, so it answers the same on every visit. The rule behind this is in [How a story runs](/explanation/how-a-story-runs/). A choice’s guard follows the same rule: a `bool` wired into the small input on a choice’s row hanging off the side of the flow is worked out once. The message names the choice. Fix: put the cards in the flow that leads to whatever reads them. See [How to build a board](/how-to/work-on-the-canvas/). ### `W.GE.06` [Section titled “W.GE.06”](#wge06) **Some way in does not pass through the card whose value is being read.** A data wire reads what its card last published, and at least one route to the reader skips that card, so the value may be from an earlier visit or nothing. A running total or a flag set once is meant to behave this way, so the message is a question. See [How a story runs](/explanation/how-a-story-runs/). This is the only code with an answer: tick *I meant that* on its row in the messages panel and it stops asking about that card. The answer is remembered. Fix: make every route run the card, or tick *I meant that*. ### `W.GE.07` [Section titled “W.GE.07”](#wge07) **A While loop has nothing wired into `break`.** Only the ceiling on how many times one loop may go round can end it, and that ends the whole campaign with an error. Fix: wire a condition’s false side into `break`, or use a For, which counts. ### `W.GE.08` [Section titled “W.GE.08”](#wge08) **A Jump cannot land where it is pointed.** The board it names has no Start and the Jump names no card on it, or it names a card that board no longer has. Fix: pick the card again, or give that board a Start. ### `I.GE.09` [Section titled “I.GE.09”](#ige09) **Several wires leave one way out.** Legal. They run one at a time, each to the end of its wire before the next starts, in the order the wires were drawn, which the canvas does not show. The message lists them in running order. Fix: if the order matters, use a **Do all**, which says it on the card. ### `I.GE.10` [Section titled “I.GE.10”](#ige10) **Several wires arrive at one input.** One input takes one value. The first arrives; the others are drawn but never read. ### `I.GE.11` [Section titled “I.GE.11”](#ige11) **A value comes back round to the card that makes it.** Allowed, and how a running total is built: the wire reads what the card published last time, which is the previous turn, and nothing on the first. The message confirms it. A control wire back to an earlier card is a loop, which is how a hub or a conversation works. ### `I.GE.12` [Section titled “I.GE.12”](#ige12) **A path stops without an End.** The story has nowhere to go next, which plays as it ending. An End card says so on the canvas and gives the reader a last page. ### `I.GE.13` [Section titled “I.GE.13”](#ige13) **A For asks for more turns than the ceiling allows.** The ceiling wins, and it ends the campaign with an error rather than leaving through `exit`. Fix: correct the number, or raise the ceiling under *Playing* in the settings. ### `E.GE.14` [Section titled “E.GE.14”](#ege14) **Cards read from each other in a circle, and none of them has a way in.** There is no value to arrive at: each would be worked out when the next asks for it. Nothing drawn in this app produces this; a campaign built by something else could. ### `W.GE.15` [Section titled “W.GE.15”](#wge15) **A passage or a program is given a name its Python cannot read.** A Narration node’s *Reads* and a Script node’s inputs and outputs become variables typed in the prose or the code. A name must be a letter or an underscore followed by letters, digits and underscores, and not a Python keyword such as `class` or `None`. `Your roll` is a valid label on an Output node’s rows but nothing could write `{{ Your roll }}`, so the wire into that slot goes nowhere and the passage never sees it. The editor does not let you type such a name, so it appears only on a board written by an older version or edited by hand. Fix: rename it, for example `your_roll`. ### `W.GE.16` [Section titled “W.GE.16”](#wge16) **A host callback watches something that is no longer there.** A callback names a board, and optionally a node on it. If the board or card is deleted, the declaration is dropped from the compiled campaign or never fires, and whatever registered for that name is never told. It is the only message here that is not about a card: it names the callback, and carries the board when that board is still in the project. A callback aimed at a console variable or a component that has gone is reported when the campaign is built, because that check needs the project’s variables and index. A missing node is checked nowhere else. See [Host callbacks](/reference/host-callbacks/). ## A playthrough [Section titled “A playthrough”](#a-playthrough) Things the engine noticed while the story was running. ### `I.RT.01` [Section titled “I.RT.01”](#irt01) **A card ran more than once.** Ordinary in a loop or a hub the reader returned to; worth a look if you did not expect the story to pass that way twice. ### `W.RT.02` [Section titled “W.RT.02”](#wrt02) **Something read a card that had not run.** The value it found was nothing. This is `W.GE.05` and `W.GE.06` seen after the fact, on the path the reader took. ### `I.RT.03` [Section titled “I.RT.03”](#irt03) **Several wires arrived at one input,** and the first was taken. The observed version of `I.GE.10`. ### `W.RT.04` [Section titled “W.RT.04”](#wrt04) **A card with several ways out had no behaviour,** so the story stopped there rather than guess which way to go. The next three are about what a story reads from your computer: the date and time, and where you are. What each may read is under *Settings › Permissions*, and so is the stand-in each is told instead. They appear at the top of the play window as well as in the messages panel, once per playthrough each. ### `I.RT.05` [Section titled “I.RT.05”](#irt05) **The story was given your stand-in.** It read the date and time or the location, could not have the real one — you have not allowed it, or there was nothing to read — and was told the stand-in you typed instead. The story cannot tell the difference; that is what a stand-in is for. ### `W.RT.06` [Section titled “W.RT.06”](#wrt06) **The story was refused something it read.** It read the date and time or the location, which is not allowed here, and you have given no stand-in, so its script got a `PermissionError`. If the script catches it with `except PermissionError:`, the story carries on with whatever it says instead; if not, the passage shows a warning in its place. Fix: allow it under *Settings › Permissions*, give a stand-in there, or write the fallback in the script. ### `I.RT.07` [Section titled “I.RT.07”](#irt07) **The story was allowed something there was nothing of.** It may read the location, but this computer’s location service gave no position in time — it may be off, or have none to give — so `location_get()` gave `None` and a World node left by `unavailable`. Give a stand-in under *Settings › Permissions* to tell stories a place when that happens. ## An export [Section titled “An export”](#an-export) These appear only when a campaign has to play without Python, for a game or player built without the interpreter: when you export with **Needs no Python** ticked, or when the project has **Python** turned off under *Capabilities* in its settings. Each is a place the story would run Python. Any one of them stops the export. With Python off in the project, they also stop the campaign from playing and show on the cards as errors as you draw. None is wrong on a board, only for a player that cannot run it. To undo them, see [How to build a campaign with no Python](/how-to/turn-python-off/); the box is described in [Export options and bundle contents](/reference/export-options/). ### `E.EX.01` [Section titled “E.EX.01”](#eex01) **A Script node, in a campaign that has to play without Python.** A player without the interpreter would stop the story at this card. A Script node inside a macro is reported on the card that calls the macro when you export, and on the macro’s own board while you edit. Fix: move what the script does onto cards that need no Python, or export with the box unticked. See [How to build a campaign with no Python](/how-to/turn-python-off/). ### `E.EX.02` [Section titled “E.EX.02”](#eex02) **A passage that runs Python, in a campaign that has to play without Python.** The passage has a `ripple` code block or an inline `{{ … }}` in at least one of its languages, and a player without the interpreter would show a warning in its place. The message gives the count across every language. Fix: replace each with the text it would have produced, or split the passage so a choice or a branch picks between versions. ## Related [Section titled “Related”](#related) * [How a story runs](/explanation/how-a-story-runs/) * [Host callbacks](/reference/host-callbacks/) * [Export options and bundle contents](/reference/export-options/) * [How to build a campaign with no Python](/how-to/turn-python-off/) * [How to build a board](/how-to/work-on-the-canvas/) # Engine platforms and variants > Every platform the engine is built for, its floor, what each release archive holds, the two variants, and which binding ships which target. The engine is released as `engine-v` in [ripple\_engine\_releases](https://github.com/ConnectingTheDots-DCWK/ripple_engine_releases). Every target is built in both variants, and every archive is named in the release’s signed manifest with its SHA-256 digest. Why it is shaped this way is [How the engine ships](/explanation/how-the-engine-ships/). ## Targets [Section titled “Targets”](#targets) | Target | Platform | Floor | C and C++ | C# | Dart | JavaScript | | --------------------------- | ---------------------------------- | -------------- | ----------- | --------------------------------- | ---- | ---------- | | `x86_64-unknown-linux-gnu` | Linux, x64 | glibc 2.35 | yes | `linux-x64` | yes | | | `aarch64-unknown-linux-gnu` | Linux, arm64 | glibc 2.35 | yes | `linux-arm64` | yes | | | `x86_64-apple-darwin` | macOS, Intel | macOS 12.0 | yes | `osx-x64` | yes | | | `aarch64-apple-darwin` | macOS, Apple silicon | macOS 12.0 | yes | `osx-arm64` | yes | | | `x86_64-pc-windows-msvc` | Windows, x64 | | yes | `win-x64` | yes | | | `aarch64-pc-windows-msvc` | Windows, arm64 | | yes | `win-arm64` | yes | | | `aarch64-linux-android` | Android, arm64 | API 24 | yes | `android-arm64` | yes | | | `x86_64-linux-android` | Android, x86\_64 (the emulator) | API 24 | yes | `android-x64` | yes | | | `aarch64-apple-ios` | iOS, device | iOS 13.0 | static only | `ios-arm64`, static only | yes | | | `aarch64-apple-ios-sim` | iOS, arm64 simulator | iOS 13.0 | static only | `iossimulator-arm64`, static only | yes | | | `wasm32-unknown-emscripten` | a program compiled with Emscripten | Emscripten 4.0 | yes | | | | | `wasm32-component` | browsers and Node | | | | web | yes | * **The C archives** are in every release from engine 1.5.1; 1.5.0 carried the web component and the editor’s own libraries only. * **Nothing older or 32-bit** is built: no 32-bit Android, no Intel iOS simulator, no musl Linux. * **Android** is linked with segments aligned for 16 KB pages. * **iOS** shipped a static library only until engine 1.5.4, which added a shared one. The C, C++ and C# bindings link the static library. * **Engine 1.5.1’s macOS shared library** names its build machine’s path instead of `@rpath`, and cannot be loaded. It is fixed from 1.5.2. The static library is unaffected. * **`wasm32-unknown-emscripten`** is available from engine 1.5.3. It is single-threaded, and a panic ends the module instead of poisoning one handle. Emscripten 3.1.38, which Unity 6000.0 to 6000.4 bundle, links the engine but cannot open a campaign from a folder; Unity 6000.5 and later bundle Emscripten 4.0.19. .NET’s own browser build bundles an older Emscripten, so it is not a target. * **`wasm32-component`** is not a rustc triple: the `python` component is built for `wasm32-wasip2` and imports WASI’s clock and random numbers, while the `no-python` one is built for `wasm32-unknown-unknown` and imports nothing. ## Variants [Section titled “Variants”](#variants) | Variant | What it is | Archive name | | ----------- | --------------------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------- | | `python` | The engine with its sandboxed Python interpreter. | `ripple-engine-c--.tar.gz`, `ripple-engine--wasm32-component.tar.gz` | | `no-python` | The same engine and API without it. About half the size natively. Refuses at open a campaign whose manifest says it needs Python. | the same names with `-no-python` after the version | | Binding | How the variant is chosen | | ------------------------- | ----------------------------------------------------------------------------------------------------------------- | | C and C++ | `RIPPLE_ENGINE_PYTHON`, a CMake option, `ON` by default. | | C# | The package referenced: `RippleFx.Engine.Native` or `RippleFx.Engine.Native.NoPython`. | | Dart | Native: the `python` user-define in the pubspec, `true` by default. Web: `--dart-define=RIPPLEFX_PYTHON=false`. | | JavaScript and TypeScript | `Campaign.load`’s `python` option: `'auto'` by default, which reads the campaign’s `needs`, or `true` or `false`. | ## What an archive holds [Section titled “What an archive holds”](#what-an-archive-holds) **The C archive**, `ripple-engine-c-[-no-python]-.tar.gz`, for every native target and for Emscripten: | Path | What it is | | ------------------------------------------------------------------------ | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `include/ripple/ripple.h` | The C header, C99. | | `lib/libripple_effect_engine.so`, `.dylib` or `ripple_effect_engine.dll` | The shared library, with `ripple_effect_engine.dll.lib`, its import library, on Windows. Linux and Android carry the soname `libripple_effect_engine.so`; macOS and iOS the install name `@rpath/libripple_effect_engine.dylib`. Not in the Emscripten archive. | | `lib/libripple_effect_engine.a` or `ripple_effect_engine.lib` | The static library. | | `lib/native-static-libs.txt` | The system libraries a static link needs, for this target. One empty line for Emscripten, whose compiler links them itself. | | `NOTICE` | The Runtime Licence notice and the third-party notices, naming the variant. | **The web component**, `ripple-engine-[-no-python]-wasm32-component.tar.gz`: | Path | What it is | | ------------------------ | ------------------------------------------------ | | `lib/ripple_engine.wasm` | The component. | | `wit/` | The interface it exports, `ripple:engine@1.5.0`. | | `NOTICE` | As above. | **The desktop archive**, `ripple-engine-[-no-python]-.tar.gz`, also carries two command-line tools: `bin/ripple`, the terminal player, which plays a bundle (`ripple --auto --seed N` for an unattended run), and `bin/compile`, which compiles a project into a bundle. ## Beside the archives [Section titled “Beside the archives”](#beside-the-archives) | File | What it is | | ------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------ | | `engine-manifest.json` | Every archive with its size and SHA-256, the engine’s version, its contract and the contracts it reads, and the bundle and save formats. | | `engine-manifest.json.sig` | An Ed25519 signature over the manifest’s exact bytes. | | `ripple-conformance-.tar.gz` | The conformance fixtures, each a bundle with the inputs to replay and what a correct host sees, and a `FORMAT.md` saying how to drive one. | | `SHA256SUMS` | The digest of every file above. | ## Versions [Section titled “Versions”](#versions) | Contract | Codename | Reads campaigns of | Engine releases | | -------- | ------------- | ------------------ | --------------- | | 1.5 | Multiclassing | 1.3, 1.4 and 1.5 | 1.5.0 to 1.5.4 | A binding’s version is the contract’s `MAJOR.MINOR` and its own patch. The engine release each binding pins is in its repository’s `engine.json`. ## Related [Section titled “Related”](#related) * [How the engine ships](/explanation/how-the-engine-ships/): releases, pinning, the variants and the licence. * [Embedding the engine](/explanation/embedding-the-engine/): the API every binding shares. * [How to play a campaign in your own program](/how-to/play-a-campaign-in-your-program/): installing a binding for your platform. * [How to export for a player without Python](/how-to/export-for-a-player-without-python/): a campaign the `no-python` variant opens. # Export options and bundle contents > What the Export dialog makes, the switches on it, what a project copy and a compiled campaign contain, the HTML export profile file and the export diagnostics. Export makes one of two things. The dialog asks which. | Format | Result | Opens in the editor | | ---------------- | ----------------------------------- | ------------------- | | **Project copy** | A folder, with `.ripple/` included. | Yes | | **Campaign** | A compiled bundle a runtime plays. | No | *Zip files* and *Include media* mean the same in both formats. ## Dialog controls [Section titled “Dialog controls”](#dialog-controls) Controls above the dividing line decide what travels. Controls below it decide what the passages become. | Control | Applies to | Effect | | ---------------------- | -------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | **Zip files** | Both | Writes one zip archive instead of a folder. | | **Include media** | Both | Three switches, one each for **Images**, **Videos** and **Audio**. A kind that is off has its files listed, so they can be added back later, and left behind. Passages, boards and soundscapes still point where the files would have been. | | **Needs no Python** | Campaign | Stops the export if any Script node or Python snippet is found. See [Needs no Python](#needs-no-python). | | **Strip media** | Both | Removes embedded pictures, videos and audio from the prose, for a reader that cannot show them. One child switch per kind. | | **Strip links** | Both | Unwraps a link to its words. | | **Output passages as** | Campaign | Markdown or HTML. See [Passages as HTML](#passages-as-html). | | **Profile** | Campaign, HTML | The export profile that names the classes. | Strip switches: * A link to a picture is a picture, not a link, and answers to **Strip media**. * Neither strip switch changes what travels beside the passages. That is **Include media**. * A strip for a kind that is not in the export is disabled, since there is nothing to strip. It returns to its earlier state when the kind is back in the export. ## Project copy [Section titled “Project copy”](#project-copy) A folder that opens in the editor, with `.ripple/` included. It is the project with its media resolved. A picture the prose points at from outside the project lands in `media/external/`. Audio is usually the largest kind by size. The export profile does not travel with a project copy. ## Campaign [Section titled “Campaign”](#campaign) The story compiled to a bundle that a runtime plays with no editor present. Every reference is resolved ahead of time, every board’s evaluation order is decided, every passage’s languages are flattened into one file, and everything only an editor needs is discarded. * campaign/ * campaign.json the manifest * boards/ * the\_gates.board.json * passages/ * reaching\_the\_gates.passage.json every language, in one file * components/ * goblin.component.json * scripts/ * damage.py * soundscapes/ * tavern.soundscape.json every clip’s file as a media index * media/ * art/ * map.png * music/ * tavern.ogg ### What a bundle holds [Section titled “What a bundle holds”](#what-a-bundle-holds) | Aspect | Rule | | -------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | Languages | Only the languages the project has. A language that is switched off stays in the project and does not ship. | | Media that exists | Only media the project holds. A picture a passage or component points at but does not have is left out. | | Media that is used | Only media the story uses. A picture nobody embedded and a sound no soundscape plays are left out. Both omissions are named in Messages, as a missing file is. | | Component file lists | An attribute holding a list of files carries those files, with rewritten paths. The campaign never names a picture that is not in it. | | Slot types | A Script node’s inputs and an Output node’s are read as the type they declared. An `int` input given `3.7` reads `3`. A `list` input given a list of text arrives as numbers. A value that cannot be read as the type becomes that type’s blank rather than stopping the story. What a script writes is left as it is. | | Slot defaults | A default on a macro take, a script input or output, an Output node’s slot, a Macro Out’s answer or an Input node’s form field is used when nothing else fills the slot. Four of these become ordinary values on the board at compile time. The two a reader or program answers, a form field’s and a script output’s, are carried in the campaign. | | Macros | Nothing of a macro reaches the bundle. Each call is spliced into the board that made it, so two calls are two copies. | | Python in prose | Each passage’s snippets are lifted into a table of their own, with a marker left where each was. A program playing the campaign inserts the answers by number. | | Sound | A Narration node names its soundscape by index into `soundscapes/`. The runtime decides which is in force and tells its host, which renders it. The command-line player prints a line per change and plays nothing, which is conforming. | | Node metadata | Travels under `metadata` on the node. The runtime hands it to the host on the node’s turn and does not read it. | | Needs | The manifest records under `needs` whether the campaign needs Python. Information, not a gate. | ### Format and compatibility [Section titled “Format and compatibility”](#format-and-compatibility) * A campaign carries a format number. A runtime refuses one newer than it implements, naming both numbers. * A campaign exported by an earlier 1.x build plays unchanged. Only the first shape of the format is refused, by name; exporting again from the project produces a current one. * A saved game is unaffected. ## Needs no Python [Section titled “Needs no Python”](#needs-no-python) On a build without the Python interpreter, a Script node stops the story where it stands, and a passage shows a warning where its Python was. With **Needs no Python** ticked, the export checks every Script node, and every passage with a `ripple` code block or an inline `{{ … }}` in any language. It stops if it finds one. | Code | Finding | Opens | | ------------------------------------------ | ------------------------------------------------------------------------------- | ------------------ | | [`E.EX.01`](/reference/diagnostics/#eex01) | A Script node. | Its card | | [`E.EX.02`](/reference/diagnostics/#eex02) | A passage with Python in it. The message counts snippets across every language. | Its Narration card | * Each finding is listed in Messages. * The dialog stays open. * A campaign that passes carries no scripts. * Turning **Python** off under *Capabilities* in the project’s settings marks every card that runs Python as an error on its board, makes Play refuse to start while any is left, and ticks the export’s box and locks it. * The manifest’s `needs` field lets a program loading a campaign tell whether it needs Python before it starts. A player without the interpreter still plays the campaign. A host that prefers to refuse one reads the field. ## Passages as HTML [Section titled “Passages as HTML”](#passages-as-html) With **Output passages as** set to HTML, a campaign has every passage written out as an HTML fragment per language. The editor and the play view stay Markdown. An export profile names the class each kind of element gets: a paragraph, a heading, emphasis, a link, a table cell, a Python snippet. Choosing HTML opens the profile beside the export controls. | Column | Content | | ------------------ | ---------------------------------------------------------------- | | **In the passage** | The kind of element. A tooltip shows the Markdown it stands for. | | **CSS class** | The class assigned, saved as you type. | An element with no class assigned gets no attribute. With no profile, every element goes out bare: a plain `

`, a plain ``. ### Profile file [Section titled “Profile file”](#profile-file) | | | | --------------- | ------------------------------------------------------------------------------- | | Path | `.ripple/export/.profile.json` | | Name | A file name: letters, digits, spaces, dashes. A name already in use is refused. | | Scope | One profile serves any number of exports. | | Version control | Committed with the story, so a build reproduces it. | | Delete | Removes the file under `.ripple/export/`. Nothing else changes. | ## Diagnostics [Section titled “Diagnostics”](#diagnostics) The export’s two codes, [`E.EX.01`](/reference/diagnostics/#eex01) and [`E.EX.02`](/reference/diagnostics/#eex02), are listed under [Needs no Python](#needs-no-python); [Diagnostics](/reference/diagnostics/) has the rest. ## Related [Section titled “Related”](#related) * [What an export is](/explanation/what-an-export-is/) * [How to export a project copy or a campaign](/how-to/export-a-project-or-campaign/) * [How to export for a player without Python](/how-to/export-for-a-player-without-python/) # Host callbacks > The rules for a callback's name, the eighteen events, the targets that aim one, what happens to a target that is gone, and the callbacks.json file. A host callback is a **name** and an **event**, optionally aimed at one thing the event happens to. It is declared on the **Host callbacks** page, opened from the activity bar button or the command palette, and stored in `.ripple/callbacks.json`. A callback carries no code and cannot change the story. ## Declaration [Section titled “Declaration”](#declaration) | Field | Rule | | ------ | ---------------------------------------------------------------------------------------------------------------------------------------------------------------- | | Name | An identifier-shaped word, such as `chapterTwoBegan`. Unique in the project. Trimmed, non-empty, and free of control characters, so it cannot hold a line break. | | Event | One of the eighteen events below. The set is closed. | | Target | Optional. Absent means every occurrence of the event. Only the fields the chosen event can use are offered. | Renaming a callback breaks any host code registered under the old name. Changing a callback’s event removes any target fields the new event cannot use. A field the new event requires cannot be filled in for you, so that change is refused until the target has it. The **Forget it** button removes a declaration after a confirmation. A host listening for that name never receives it again. ## Events [Section titled “Events”](#events) | Event | Fires when | May be aimed at | Must be aimed at | | ----------------------- | ---------------------------------------------- | -------------------------- | ---------------- | | `story.started` | a playthrough begins | nothing | | | `story.ended` | the story reaches an End | nothing | | | `story.failed` | the story stops because something went wrong | nothing | | | `board.entered` | the story arrives on a board | board | | | `passage.shown` | a passage is put in front of the reader | board, node | | | `choice.taken` | the reader takes a choice | board, node | | | `soundscape.changed` | the soundscape in force changes | nothing | | | `delay.started` | a Delay node begins waiting | board, node | | | `delay.elapsed` | a Delay node’s wait is over | board, node | | | `cvar.changed` | a console variable moves | console variable, `equals` | console variable | | `entity.spawned` | the story makes something | component | | | `entity.removed` | the story forgets something | component | | | `entity.renamed` | something the story made is renamed | component | | | `entity.field.changed` | an attribute of something the story made moves | component, attribute | attribute | | `machine.state.entered` | a state machine enters a state | state machine, state | state machine | | `node.entered` | the flow arrives at a card | board, node | | | `node.exited` | a card’s turn is over | board, node | | | `node.failed` | the story breaks at a card | board, node | | The three `node.*` events came with bundle format 14 and the other fifteen with format 13. The event names are the words a host matches on. They are the same in every language and are written exactly as shown in the host’s code. ### Node events [Section titled “Node events”](#node-events) `node.entered`, `node.exited` and `node.failed` fire on every turn a card takes, whatever kind of card it is. * A passage exits once the reader has chosen, not when it appears. `node.entered` fires as the flow arrives, which for a passage is just before it is shown. `node.exited` fires once the reader has answered and the turn is finished. * A loop that runs a card four times fires four times. The **Only when it becomes a particular value** condition (`equals`) applies to values, and a turn has no value. * A card inside a macro cannot be picked, because a macro is spliced into the board that calls it and its cards are on no canvas under their own names. Aim at the card that calls the macro, which is what the picker offers. ## Targets [Section titled “Targets”](#targets) An event with no target fires at every occurrence. The notification carries which board, entity or value, so a host can also filter on its own side. | Target field | Panel label | Meaning | | ------------ | --------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `board` | Board (default: Every board) | A board, by its catalog id, so a renamed or moved board is still the same board. | | `node` | Node (default: Any node) | A node on that board. Requires `board`; the picker reads “Pick a board first” until one is chosen. | | `cvar` | Console variable | A console variable, by its editor id, so renaming the variable leaves the declaration alone. | | `equals` | Only when it becomes a particular value, then Value | A condition on `cvar.changed`. The value field has the variable’s own type. It can be typed only once a variable is picked, and picking another variable drops a condition typed against the first. The file accepts any JSON value for `equals`; whether it fits the variable’s declared type is checked by the compiler. | | `component` | Component (default: Every component) | A component, by its catalog id. Aimed at the component, never at one entity, because entity keys are made while the story runs. | | `attribute` | Attribute | An attribute label on that component. With no component picked, any attribute of that name on anything the story made. | | `machine` | State machine | A state machine instance name, not a node. | | `state` | State (default: Any state) | A state on that machine. Absent means any transition of the machine. | ### The equals condition [Section titled “The equals condition”](#the-equals-condition) `equals` fires on the transition into the value, not on holding it. | Write | Fires | | -------------------------------------------- | -------------------------- | | `score` set to 100 when it is already 100 | no | | `score` drops to 50, then climbs back to 100 | yes, on reaching 100 again | ## Missing targets [Section titled “Missing targets”](#missing-targets) | Target gone | Compiled campaign | Panel | | ---------------- | --------------------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------- | | Board | The callback is left out, with a warning (`W.GE.16`). | The Board field says the board is gone and the callback is left out. | | Console variable | The callback is left out, with a warning from the compiler. | The Console variable field says so. | | Component | The callback is left out, with a warning from the compiler. | The Component field says so. | | Node | The callback travels intact and never fires. Whether a node id is on a board is not checked when the campaign is built. Flagged as `W.GE.16`. | The Node field says so while the board it names is open. | `W.GE.16` is named for the board and node cases; no code is stated for the other two. The declaration stays in `callbacks.json` in every case, so restoring the board from version control brings the callback back. A callback is never removed from the file for you. ## Host callbacks side panel [Section titled “Host callbacks side panel”](#host-callbacks-side-panel) The campaign window has a **Host callbacks** side panel. It lists every fire with its name, its event, the payload fields that are set, and the result of the lookup. The editor publishes the names but has no host registered against them, so every fire normally shows as a name a host would have looked up. A name with nothing registered is not an error, and the story plays exactly as it would have. ## `.ripple/callbacks.json` [Section titled “.ripple/callbacks.json”](#ripplecallbacksjson) ```json { "version": 1, "callbacks": [ { "id": "cb_8f21", "name": "storyBegan", "event": "story.started" }, { "id": "cb_7f3a", "name": "achievementHit", "event": "cvar.changed", "target": { "cvar": "gold", "equals": 100 } } ] } ``` | Key | Rule | | ----------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `version` | Integer. A file with no `version` is broken, not old. A file from a newer build is refused, and the panel does not write to it. | | `callbacks` | Array. Absent reads as none. | | `id` | Required string. The editor’s own handle, used to tell a rename from a delete and a create. It never reaches the compiled campaign. | | `name` | Required. The rules under [Declaration](#declaration). A repeated name is refused. | | `event` | Required. One of the eighteen events. Anything else is refused. | | `target` | Object. Absent, null and `{}` all mean every occurrence. The event decides which keys are legal; a key the event cannot use is refused. `node` without `board` is refused. | The file is separate from `project.json`, so a refusal does not affect the project’s title or languages. It is not catalogued and does not open as a document. ## What travels [Section titled “What travels”](#what-travels) | Carried by | What it holds | | ------------------- | --------------------------------------------------------- | | A project copy | `.ripple/callbacks.json`. | | A compiled campaign | The table a host dispatches from: name, event and target. | No handler travels, because none is written here. ## Limits [Section titled “Limits”](#limits) * A callback is a notification only; it cannot change the story. * `soundscape.changed` reports which soundscape came into force, and nothing finer: not a loop, a fade or a timeline marker. * Callbacks are not ordered against the host’s frames. The engine returns a batch of everything a move led to, the callbacks among it. A callback arrives before the page it is about is drawn; when a handler runs after that is the host’s to schedule. ## Related [Section titled “Related”](#related) * [Host callbacks](/explanation/host-callbacks/): why the story names an occasion and the host writes the handler. * [How to declare, aim and forget a host callback](/how-to/declare-a-host-callback/) * [How to play and test a campaign](/how-to/play-a-campaign/) * [The campaign window and saved games](/reference/campaign-window/): the Host callbacks side panel. * [Diagnostics](/reference/diagnostics/): the `W.GE.16` warning. # Installers and first launch > The installer for each platform, what it needs and where it installs, the pages a first launch shows, and what Windows and macOS say about an unsigned installer. Ripple Effect is one download per platform from the [download page](https://ripplefx.app/download), which always points at the latest release. After the first install the app updates itself; [Updates, logs and Messages](/explanation/updates-logs-and-privacy/) describes how. ## Installers [Section titled “Installers”](#installers) | Platform | Installer | Needs | Installs to | | ------------------------ | ------------ | ---------------------------------------------- | --------------------------------------- | | Linux, Debian and Ubuntu | `.deb` | glibc 2.38 or newer: Ubuntu 24.04+, Debian 13+ | `/opt/ripple_effect` | | Linux, Fedora and RHEL | `.rpm` | glibc 2.38 or newer: Fedora 39+ | `/usr/share/ripple-effect` | | Windows | Setup `.exe` | Windows 10 or 11 | `%LOCALAPPDATA%\Programs\Ripple Effect` | | macOS | `.dmg` | macOS 12 Monterey or newer | Applications, by dragging the app there | * **Linux.** Both packages put `ripple_effect` on `PATH`. The glibc floor exists because version control ships a libgit2 that needs it, and a package that claimed less would install and then be unable to open a repository; a package refused with a complaint about `libc6` or `GLIBC_2.38` is this floor. The `.deb` declares three runtime libraries for sound: ALSA, PulseAudio and PipeWire. * **Windows.** Installs for the current account only and never asks for an administrator. Running a later installer over an existing install upgrades it in place. * **macOS.** One universal build, for Apple silicon and Intel. Install commands for Linux: ```sh sudo apt install ./ripple_effect--linux.deb sudo dnf install ./ripple_effect--linux.rpm ``` ## The first launch [Section titled “The first launch”](#the-first-launch) The first launch shows pages before the home page, in this order. | Page | Skippable | Contents | | ------------------------------ | --------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | Agreement pages | No | The agreements the app asks you to accept; see [Licensing](/about/licensing/). | | Make it yours | Yes | Theme, contrast, colour vision and how wires are drawn, beside a small preview of the app that changes as you choose. | | Help make Ripple Effect better | Yes | Whether to send usage statistics, with neither answer picked and the full list of what would be sent shown. Also states how crash reports are asked, and carries the coffee. | Everything on the two skippable pages is in Settings afterwards. An upgrade from 1.4 shows the pages once, like a new install, and resets nothing already set. ## Code signing [Section titled “Code signing”](#code-signing) None of the installers is signed by an authority the machine already trusts. Windows and macOS each stop the app once, on first run, and say they do not know it. The warning is about the missing certificate, not about anything the installer does. Linux checks nothing of the sort and asks nothing. * Windows | Shown | Meaning | Choice | | ------------------------------------------- | ------------------------- | ---------------------------------- | | **Windows protected your PC** (SmartScreen) | The installer is unknown. | **More info**, then **Run anyway** | **An Application Control policy has blocked this file** is Smart App Control; see [below](#smart-app-control). * macOS Gatekeeper says the app cannot be opened because it is from an unidentified developer, or that it could not verify it. The app must be in Applications first. Then either: * right-click it and choose **Open**, which adds an Open button to the dialog; or * clear the quarantine mark: ```sh xattr -dr com.apple.quarantine "/Applications/Ripple Effect.app" ``` Either is needed once; the app opens normally afterwards. * Linux No signing prompt. See [Why the installers are unsigned](/explanation/unsigned-installers/). ## Smart App Control [Section titled “Smart App Control”](#smart-app-control) Windows 11 only. Setup refuses with ```text An Application Control policy has blocked this file ``` (error 4551). | Fact | Detail | | --------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | What it is | A Windows 11 feature that runs only apps Microsoft’s reputation service already knows or that carry a trusted signature. An unsigned installer is neither. | | Per-app exception | None. Unlike SmartScreen it has no **Run anyway**; the only way to install is to turn it off. | | When it is enabled | Only on a clean install of Windows 11 (a reset counts). | | Evaluation mode | Starts in *Evaluation*, which blocks nothing while Windows decides whether the feature suits the machine, then switches itself **On**, with a notification, or Off. | | When it is already off | A PC upgraded from Windows 10, or seen running developer tools during evaluation. | | Where to check and turn off | **Settings → Privacy & security → Windows Security → App & browser control → Smart App Control settings**, then **Off**. | Caution Turning Smart App Control off is a decision, not a toggle. Microsoft says recent Windows updates allow Smart App Control to be turned back on without reinstalling. On older builds, once it is off the only way back is a clean reset of Windows. Weigh that before switching it off for one app. ## Related [Section titled “Related”](#related) * [How to install Ripple Effect](/how-to/install-the-app/) * [Why the installers are unsigned](/explanation/unsigned-installers/) * [Settings, accessibility and updates controls](/reference/settings/) * [Updates, logs and Messages](/explanation/updates-logs-and-privacy/) # What an assistant may read, play and write > What an assistant connected over MCP may read, play and write, which Agents switch each tool needs, and what is always refused. ## The switches [Section titled “The switches”](#the-switches) Both are on the **Agents** page of the Settings dialog, opened with the gear in the app bar or with the command palette’s Agents command. Both default to off, and both belong to the app rather than to one project: the app serves whichever project is open. | Switch | Effect | Depends on | | ----------------------------- | ------------------------------------------------------------------------------------------------ | ------------------------------------------------------------------------- | | **Let agents reach this app** | The server listens. An assistant can read the open project and play it. Reading changes nothing. | None. | | **Let agents change files** | An assistant can write. | **Let agents reach this app**: it has no effect while that switch is off. | The server is a private local channel that only your own account can open. It is never a network port. A write tool called while **Let agents change files** is off answers `refused`. Every tool call answers the following while the app is closed or agent access is off, and the client does not need restarting once access is on: ```text ripple_effect is not running, or agent access is switched off. Open the project in ripple_effect and switch on "Let agents reach this app" under Settings → Agents. ``` The assistant is shown the full tool list either way. ## Tool groups by permission [Section titled “Tool groups by permission”](#tool-groups-by-permission) Forty-four tools in seven groups. Each tool is described on the [MCP tools](/reference/mcp-tools/) page. | Group | Tools | Needs | | ---------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------- | | Orientation | `project_info`, `project_outline`, `catalog_find` | **Let agents reach this app** | | Reading | `board_read`, `passage_read`, `component_read`, `soundscape_read`, `board_links` | **Let agents reach this app** | | What only the app can answer | `trace_between`, `trace_into`, `translation_status`, `diagnostics_read`, `node_types`, `python_capabilities`, `python_eval` | **Let agents reach this app** | | Playtesting | `campaign_start`, `campaign_choose`, `campaign_answer`, `campaign_state`, `campaign_trace`, `campaign_end`, `campaign_autoplay` | **Let agents reach this app** | | Writing | `passage_write`, `passage_create`, `board_add_passage`, `board_add_node`, `board_connect`, `board_disconnect`, `board_set_choice`, `board_set_node`, `board_remove_node`, `component_write`, `file_create`, `file_move`, `file_delete`, `save` | **Let agents change files** | | Sound | `soundscape_set`, `soundscape_add_clip`, `soundscape_set_clip` | **Let agents change files** | | Spelling, grammar and style | `writing_read`, `grammar_check` | **Let agents reach this app** | | | `dictionary_edit`, `style_rule_write`, `style_rule_delete` | **Let agents change files** | Every tool that takes a board, passage, component, soundscape or sound accepts an id, a name or a project-relative path. An ambiguous name comes back as a list of candidates rather than a guess. ### What the assistant can read [Section titled “What the assistant can read”](#what-the-assistant-can-read) | Subject | Answer | Tools | | ----------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------- | | The project | The shape of the whole project in about twenty lines. | `project_info`, `project_outline`, `catalog_find` | | A board | A compact digest. | `board_read` | | A passage | Every language it has. | `passage_read` | | Components, scripts, state machines | Their contents. | `component_read` | | Links between boards | What leads to what across boards. | `board_links` | | Routes | Every route between two passages. | `trace_between`, `trace_into` | | Translations | Which have fallen behind. | `translation_status` | | Messages | What the Messages tab is saying. | `diagnostics_read` | | Python | What the sandbox says the Python can do; `python_eval` evaluates Python against the project’s definitions, read-only. | `python_capabilities`, `python_eval` | | Node kinds | `node_types` lists every kind of node in this build: the name to add it by, the card’s name and what it does. Named, it describes one in full with the fields to fill in and the ports to wire. It is read off the editor’s own palette when asked. | `node_types` | | Node metadata | `board_read` prints it as a `meta` line under the node. | `board_read` | | The dictionary and the style rules | How many words, a lookup, every rule and whether it compiles, one rule’s source. Never the whole dictionary. | `writing_read` | | Spelling and grammar | What the checker underlines in one passage or note, when asked. | `grammar_check` | ### What the assistant can play [Section titled “What the assistant can play”](#what-the-assistant-can-play) The runner is the one the Play button uses, with the same Python and the same component values. A playthrough writes nothing and plays the boards as they are on disk, not what is half-edited in a tab. | Capability | Behaviour | Tools | | ------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------- | | Start, choose, read state | Starts a campaign, makes choices and reads the state. | `campaign_start`, `campaign_choose`, `campaign_state`, `campaign_end` | | Autoplay | Walks every branch and reports which endings are reachable, which passages nothing leads to, where a path runs out of wire and which jumps are broken. | `campaign_autoplay` | | `campaign_trace` | Reads the trace the play window shows: what the playthrough has done so far, line by line, with the board and the node each came from. It continues from where it last read. | `campaign_trace` | | Input node | Reaches the assistant as a slide listing the fields it asks for; the assistant fills the form in with a value per name. An unattended walk goes through one with every field blank. | `campaign_answer` | ### What the assistant can write [Section titled “What the assistant can write”](#what-the-assistant-can-write) Needs **Let agents change files**. | Write | Tools | | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------- | | A passage in any language, or a passage and its translations together | `passage_write` | | A new passage added to a board and wired to the choice that leads there | `passage_create`, `board_add_passage` | | Wires and the words on them | `board_connect`, `board_disconnect`, `board_set_choice` | | A component’s fields | `component_write` | | New boards, macros, components and scripts, made the way the app makes them | `file_create` | | Any kind of node, including a Jump and a Macro call with its ports already on the card; `board_add_node` and `board_set_node` take `metadata`, an object that replaces what the node had | `board_add_node`, `board_set_node` | | Nodes removed or re-pointed | `board_remove_node`, `board_set_node` | | A soundscape laid out clip by clip, each clip answered where it settled on the timeline | `soundscape_set`, `soundscape_add_clip`, `soundscape_set_clip` | | Files moved or removed | `file_move`, `file_delete` | | Work the app is still holding, written out | `save` | | Words added to the project’s dictionary or taken out, without reading it | `dictionary_edit` | | Style rules written, checked as they are written, or deleted | `style_rule_write`, `style_rule_delete` | Every write goes through the app’s own document sessions: adding a passage is a single undo, autosave sees it, version control lists it, the change is posted to Messages as it happens, and the **Changed this session** list on the Agents page shows it. ## Always refused [Section titled “Always refused”](#always-refused) | Action | Reason | | ---------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------ | | Resolve a conflict | The author decides between two pending versions. | | Write the registry directly, or re-issue a catalog id | Renames and moves go through the verb the file tree uses, so a Jump keeps pointing at the right board. | | Remove a board’s Start, or add a second one | A board has one way in. | | Declare a console variable | That is the project’s page, not a tool. | | Delete anything outside the project, or in `.ripple/`, or with unsaved edits | Deleting a file cannot be undone; it is guarded rather than prompted. | ## The tools an assistant is shown [Section titled “The tools an assistant is shown”](#the-tools-an-assistant-is-shown) The bridge asks the running app what it offers when a client starts it, and the client is told when that changes: an update to the app, a project opened, a switch thrown, a language or a variable declared. The app answers for this build and this project. Every tool description ends with a link to its own section of the [MCP tools](/reference/mcp-tools/) page; the Python tools point at the [Python reference](/reference/python/) and the node-building tools at the [Nodes](/reference/nodes/) reference. | Part of a tool | Taken from | | ----------------------------- | ---------------------------------------------------------------------- | | `board_add_node`’s node types | An enum off the editor’s own palette, with the card names beside them. | | Every `locale` argument | The languages the project is written in. | | `cvar` | The variables the project declares. | | A style rule’s `name` | The rules the project has. | | A write tool’s description | Says when editing is off. | A client with the bridge configured appears on the Agents page as soon as agent access is on, not at its first call. ## The connected mark [Section titled “The connected mark”](#the-connected-mark) The app bar shows only what has news. Pressing the mark opens the Agents page. | State | App bar | | ---------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------- | | Switch off, or on and nobody connected | Nothing; the page is behind the gear. | | A client connected | The client’s mark: Claude’s, OpenAI’s, Gemini’s, or a plain robot for any other. The tooltip carries the client’s name and version. | | Two or more clients connected | The tooltip counts them. | | The local connection could not be opened | A plug in the error colour. The tooltip reads *Agents cannot reach this app*. | The Agents page lists the connected clients under **Connected**, and the files agents have written since the app started under **Changed this session**. ## What the session log records [Section titled “What the session log records”](#what-the-session-log-records) Every tool call, resource read and prompt a client makes leaves a line in the session log: which client, which tool, which argument *names* it passed, how long it took and whether it worked. Argument contents are never recorded. The log can say that an assistant wrote to a board, not what it wrote. See [Session log](/reference/session-log/). ## Prompts [Section titled “Prompts”](#prompts) | Prompt | Arrives with | | ------------------- | ----------------------------------- | | *Writing a passage* | The passage you name already in it. | | *Editing a board* | The board you name already in it. | Both state that Python in prose is monty rather than CPython, so an attribute is `component("X")["attr"]`, and that a node id is numbered across a whole board rather than per type. ## Files for assistants [Section titled “Files for assistants”](#files-for-assistants) This documentation site publishes three files, and a set of the Reference pages alone, so an assistant can take the whole of it as one file. | File | Content | | ----------------- | ----------------------------------------------------------------------------------- | | `/llms.txt` | Index. Linked from the site’s header. | | `/llms-full.txt` | The whole site. Linked from the site’s header. | | `/llms-small.txt` | The budget version, which leaves out the asides, the tutorials and the about pages. | | The Reference set | Only the pages under Reference: the node catalog, the MCP tools and the Python API. | ## Bridge path [Section titled “Bridge path”](#bridge-path) The client starts a small program installed beside the app, which talks to the app for it. **Copy client configuration** on the Agents page copies the block most clients read, already pointing at the right place. The program is not on `PATH`, and the connection between it and the app is private to your user account. | Platform | Path | | ------------- | ------------------------------------------------------------------ | | Linux, `.deb` | `/opt/ripple_effect/ripple_effect_mcp` | | Linux, `.rpm` | `/usr/share/ripple-effect/ripple_effect_mcp` | | Windows | `%LOCALAPPDATA%\Programs\Ripple Effect\ripple_effect_mcp.exe` | | macOS | `/Applications/Ripple Effect.app/Contents/MacOS/ripple_effect_mcp` | A build run from source has no such program beside it; `melos run editor:mcp:bridge` makes one and prints where it put it. ## Related [Section titled “Related”](#related) * [How to connect an AI assistant (MCP client)](/how-to/connect-an-mcp-client/) * [MCP tools](/reference/mcp-tools/) * [Working with an AI assistant](/explanation/working-with-an-assistant/) * [The session log and reports](/reference/session-log/) # MCP tools > The forty-four tools an assistant is offered over MCP, grouped as the app groups them — what each answers, what it takes, and which need Let agents change files. The tools the app offers a connected MCP client, with the arguments each takes. An assistant reads the same descriptions from the app itself, for the build and the project it has open. **Every description ends with a link to this page**, at the tool’s own heading. Three point elsewhere, because their subject has a reference of its own: `python_eval` and `python_capabilities` at [the Python API](/reference/python/), `node_types` and the two tools that build nodes, `board_add_node` and `board_set_node`, at [the node catalog](/reference/nodes/). | Machine-readable form | Contents | | ------------------------------------ | -------------------------------------------------------------- | | [`/llms-full.txt`](/llms-full.txt) | The whole of this site as one file. | | [`/llms.txt`](/llms.txt) | An index of it. | | [`/llms-small.txt`](/llms-small.txt) | The budget version, without the tutorials and the about pages. | The first two are linked from the site’s header. Forty-four tools, in seven groups. The first four groups need **Let agents reach this app**. The next two need **Let agents change files** as well; a write tool called while that is off answers `refused`. The last group mixes the two: `writing_read` and `grammar_check` read, and the other three write. | | Tools | | ------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | [Orientation](#orientation) | `project_info`, `project_outline`, `catalog_find` | | [Reading the story](#reading-the-story) | `board_read`, `passage_read`, `component_read`, `soundscape_read`, `board_links` | | [What only the app can answer](#what-only-the-app-can-answer) | `trace_between`, `trace_into`, `translation_status`, `diagnostics_read`, `node_types`, `python_capabilities`, `python_eval` | | [Playtesting](#playtesting) | `campaign_start`, `campaign_choose`, `campaign_answer`, `campaign_state`, `campaign_trace`, `campaign_autoplay`, `campaign_end` | | [Writing](#writing) | `passage_write`, `passage_create`, `board_add_passage`, `board_add_node`, `board_connect`, `board_disconnect`, `board_set_choice`, `board_set_node`, `board_remove_node`, `component_write`, `file_create`, `file_move`, `file_delete`, `save` | | [Sound](#sound) | `soundscape_set`, `soundscape_add_clip`, `soundscape_set_clip` | | [Spelling, grammar and style](#spelling-grammar-and-style) | `writing_read`, `grammar_check`, `dictionary_edit`, `style_rule_write`, `style_rule_delete` | ## How to read the tables [Section titled “How to read the tables”](#how-to-read-the-tables) **A reference** — every `board`, `passage`, `component`, `script`, `soundscape` and `audio` argument — is a catalog id, an unambiguous prefix of one (the short form a board digest prints after `@`), the name a person uses, or the project-relative path `catalog_find` prints beside every entry. A name that matches several entries comes back as `ambiguous` with the candidates listed, rather than a guess; a path is never ambiguous. **A node** is its id as a board digest prints it — `narration_3`, `jump_0` — and node ids are numbered across the whole board rather than per type. **A port** is its id — `choice_0`, `out`, `exec` — as the digest prints it. **A locale** is a BCP 47 tag the project is written or translated in, and defaults to the project’s own default language. **A path** is always project-relative. **A time** is always integer milliseconds. An argument marked **required** must be sent; every other one may be left out, and a write tool leaves out what you leave out. Every answer is either a result or an error with a `code`, a `message` and usually a `hint` naming the tool that would have answered: | Code | When | | ---------------- | ------------------------------------------------------------------------------------------------------------ | | `notRunning` | the app is closed, or Let agents reach this app is off | | `noProject` | the app is running with nothing open | | `notFound` | no entry by that id, name or path | | `ambiguous` | a name matching several entries; `candidates` lists them | | `conflicted` | the document has a conflict the author has not resolved | | `refused` | Let agents change files is off, or the tool declines on policy — a clip on a lane the author locked, for one | | `busy` | a campaign is already running, or a write is in flight | | `outsideProject` | a path resolved outside the project root | | `failed` | anything else, with the app’s own words | ## Orientation [Section titled “Orientation”](#orientation) ### `project_info` [Section titled “project\_info”](#project_info) What project is open, what languages it is written in, how much of each kind of file it holds, and whether anything is unsaved or in conflict. Ask this first if you need paths or locales. It also lists every console variable the project declares, with its type and default and the boards it is scoped to. Takes nothing. ### `project_outline` [Section titled “project\_outline”](#project_outline) The story’s spine: every board, how big it is, where it ends and which board it hands the story to. This is the first thing to read to understand a project. Takes nothing. ### `catalog_find` [Section titled “catalog\_find”](#catalog_find) Find indexed files by name. Answers id, kind, name and project-relative path, which is what every other tool takes. | Argument | | What | | -------- | - | ----------------------------------------------------------------------------------------------------------------------------------- | | `query` | | Part of a name, case-insensitive. Omit to list everything. | | `kind` | | Only entries of this kind: `board`, `macro`, `component`, `script`, `narrationBundle`, `machine`, `media`, `audio` or `soundscape`. | ## Reading the story [Section titled “Reading the story”](#reading-the-story) ### `board_read` [Section titled “board\_read”](#board_read) One board as a digest: every node, what it points at, and every wire between them with the words on it. About a twentieth of the size of the `.graph.json`, which holds mostly geometry — read this rather than the file unless you mean to change it. A node’s metadata is printed as a `meta` line under it. | Argument | | What | | --------- | -------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `board` | required | The board. | | `include` | | Any of `choices` — the words a player reads rather than the card labels; `prose` — each passage’s text inline; `geometry` — node positions, for drawing. Nodes and wires are always included. | | `locale` | | The language for `choices` and `prose`. | ### `passage_read` [Section titled “passage\_read”](#passage_read) One passage in every language it has, plus what the app knows that the files do not: which translations have fallen behind, what Python is embedded in it, and which nodes on which boards show it. | Argument | | What | | --------- | -------- | ------------- | | `passage` | required | The passage. | | `locale` | | One language. | ### `component_read` [Section titled “component\_read”](#component_read) One component: its name, its cover, and every attribute with the type it is written as. Read this before `component_write` — it is the shape you are merging into, and the types are the wire names a file uses rather than the ones a person would guess. | Argument | | What | | ----------- | -------- | -------------- | | `component` | required | The component. | ### `soundscape_read` [Section titled “soundscape\_read”](#soundscape_read) One soundscape: the timeline’s own properties, what the author has said about its lanes, and every clip with its file resolved to a name and a path, how long that file lasts, and how long the clip occupies on the timeline. Times are milliseconds. Read this before the `soundscape_*` writes — the clip ids are what `soundscape_set_clip` takes, and the lanes say where a clip may go. `usedBy` names the Narration nodes that bring it. | Argument | | What | | ------------ | -------- | --------------- | | `soundscape` | required | The soundscape. | ### `board_links` [Section titled “board\_links”](#board_links) Which board hands the story to which, and where it lands. Answers both directions for one board, or the whole project when given none. A board says where it jumps to and nothing says what jumps at it, so this is the question no single file can answer. | Argument | | What | | -------- | - | ---------------------------------------------- | | `board` | | One board. Omit for every link in the project. | ## What only the app can answer [Section titled “What only the app can answer”](#what-only-the-app-can-answer) Questions the files cannot answer alone. `trace_between` and `trace_into` answer, across boards, what lies between two passages and what leads to one; `board_links` (under Reading the story) answers which boards hand the story to which. An assistant can use them to establish whether an ending is reachable without playing the story. ### `trace_between` [Section titled “trace\_between”](#trace_between) Every route between two passages: what lies on some path from one to the other, across boards, split into the runs a reader would take. Answers the union of the routes rather than a list of them, because a story that loops has unboundedly many. | Argument | | What | | -------- | -------- | ------------------------------------------------------------------------------------------------------------------------ | | `from` | required | A passage, or a node as `board/node`. The direction is discovered: both orders are tried and whichever has a route wins. | | `to` | required | The other end. Same forms as `from`. | ### `trace_into` [Section titled “trace\_into”](#trace_into) Everything that leads to one passage, from wherever the story begins. The question to ask about a passage when you do not already know where a reader would have come from. | Argument | | What | | --------- | -------- | ------------------------------------- | | `passage` | required | A passage, or a node as `board/node`. | ### `translation_status` [Section titled “translation\_status”](#translation_status) Which passages have fallen behind their source in each language, and which have never been written. Reports passages only — the words on a choice live on a node inside a board and are not covered, and the answer says so. | Argument | | What | | -------- | - | ------------------------------------------------------------ | | `locale` | | Only this language. Omit for every language the project has. | ### `diagnostics_read` [Section titled “diagnostics\_read”](#diagnostics_read) What the app’s messages panel is currently saying about this project: failed operations, translations behind their source, passages nothing points at, and what the engine found wrong with the shape of a board. Read-only. Takes nothing. One row per message, seven columns: ```plaintext code | severity | category | message | nodes | detail | path ``` **`code`** is empty for most of them — those are the app talking about a file or an operation. A row that has one is a rule with a name, and [the diagnostics reference](/reference/diagnostics/) says what each one means: the letter is how loud it is (`E` a problem, `W` worth a look, `I` a record) and the middle is what it is about. **`nodes`** names the cards a finding is about, space-separated, which is what a person clicking that row would be shown. **`category`** is the same word the panel groups by. ### `node_types` [Section titled “node\_types”](#node_types) Every kind of node a board can hold, read off the editor’s own palette at the moment of asking rather than written down anywhere: the type `board_add_node` takes, what the card is called, and what it does. Read this before adding a node of a type you have not used. Name one and it answers that one in full — its fields and every port, which is what `board_connect` takes. The same list, for the build these pages describe, is the [nodes reference](/reference/nodes/). | Argument | | What | | -------- | - | ------------------------------------------------------------------------------------------------ | | `type` | | One type, in full — `cvar.set`, `random.dice`, `input`. Omit for the whole palette, a line each. | ### `python_capabilities` [Section titled “python\_capabilities”](#python_capabilities) What the Python in this app can actually do, probed rather than documented: about eighty tiny scripts run in a real interpreter just now, and judged only by whether they raised. Read this before writing any Python. It is not full CPython — there is no `random` module and no `class` statement at all, which puts attribute access out of reach by every route. Takes nothing; the same report the [sandbox reference](/reference/python/sandbox/) describes. ### `python_eval` [Section titled “python\_eval”](#python_eval) Runs one snippet against this project’s components and answers what it printed, what it evaluated to, or how it failed. Use it to check a snippet before writing it into a passage. It reads the component files as they are and cannot change anything — the [agent surface](/reference/python/) of the Python API. | Argument | | What | | -------- | -------- | ---------------------------------------------------------------------------------------------- | | `source` | required | Python. The value of the last expression is the result, so end with the thing you want to see. | ## Playtesting [Section titled “Playtesting”](#playtesting) One campaign at a time, and it is the app’s: if you have one paused in the play window, `campaign_start` says so and refuses rather than throwing it away. A playthrough lives in memory and writes nothing. ### `campaign_start` [Section titled “campaign\_start”](#campaign_start) Plays the story, for real: the same runner the author presses play on, with the Python and the component instances that go with it. Answers the first passage and what it offers. Boards are read as they are **on disk**, so unsaved edits are not in what is being played, and the answer names them. | Argument | | What | | -------- | -------- | ---------------------------------------------------------------------------------------------------- | | `board` | required | The board to begin on. | | `node` | | A node on that board to begin at instead of its Start. Restarting comes back here, not to the Start. | | `locale` | | The language to read it in. | ### `campaign_choose` [Section titled “campaign\_choose”](#campaign_choose) Takes one of the choices the current passage offers, by its port id, and answers the next passage. | Argument | | What | | -------- | -------- | --------------------------------------------------------------------------------------------------- | | `portId` | required | From the choices on the last slide — `choice_0`, `choice_1` and so on. Not the words a reader sees. | ### `campaign_answer` [Section titled “campaign\_answer”](#campaign_answer) Fills in the form the story is waiting at — an Input node, which `campaign_state` reports as state `input` with the fields it asks for — and answers the next slide. A value per field name; a field left out is left blank. Send a number as a number or as its text, a truth as `true` or `"true"`, a list as an array or a comma-separated line: the story reads each as the field’s declared type either way. | Argument | | What | | -------- | -------- | ---------------------------------------------------------- | | `values` | required | Field name to value, for the fields the last slide listed. | ### `campaign_state` [Section titled “campaign\_state”](#campaign_state) Where the playthrough is: the passage on screen, the choices it offers, the language it is being read in, the console variables’ values, and every component it has materialised so far with what each one now holds. Takes nothing. ### `campaign_trace` [Section titled “campaign\_trace”](#campaign_trace) What the playthrough has done so far, in order: the passages read and the choices taken, the variables set and what they were, the dice rolled, the boards jumped to, the entities forged, what a script printed, and anything that went wrong. Each line names its board and node. The same record the play window’s trace panel draws. | Argument | | What | | -------- | - | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `after` | | Only lines with a key greater than this — the `next` of the last answer, to continue from where it stopped. Omit for everything kept. | | `depth` | | `story` (the default) is what a reader could have noticed. `everything` adds the machinery underneath — every turn, the values evaluated, the scheduler’s remarks — with a technical detail on each line. | | `limit` | | At most this many lines, oldest first. Defaults to 200. | ### `campaign_autoplay` [Section titled “campaign\_autoplay”](#campaign_autoplay) Walks every branch of the story to a budget and reports what a human playtester would never sit through: which endings are reachable, which passages nothing reaches, where a path runs out of wire, which Jumps are broken, and how much of what it read is untranslated. An Input node is walked through with every field blank. Stops early and says so if it meets a script that never finishes, since one of those cannot be stopped and costs a core until the app is closed. Ends with no playthrough left running. | Argument | | What | | -------- | - | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `from` | | The board to walk from. Omit to walk from the board the current playthrough began on. | | `budget` | | How many moves it may spend. Every restart and every replayed step counts, because taking a second branch means starting over and walking back to it. Defaults to 500. | ### `campaign_end` [Section titled “campaign\_end”](#campaign_end) Throws the playthrough away, component instances and all. There is one campaign and the app owns it, so this is also how an assistant gives it back to you. Takes nothing. ## Writing [Section titled “Writing”](#writing) Every tool from here on needs **Let agents change files**. Each goes through the app’s own document session for the file, so a call is one undo step, autosave sees it, it is posted to Messages and listed on the Agents page, and a document with an unresolved conflict is refused rather than written over. Every write answers with the paths it changed. ### `passage_write` [Section titled “passage\_write”](#passage_write) Writes one language of one passage. Stamps the passage’s translation record for exactly the language written, which is the reason to use this rather than an ordinary file write: a stamp written by hand is how a translation comes to claim it is current when the prose beside it is not. | Argument | | What | | ---------- | -------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `passage` | required | The passage. | | `locale` | required | The language to write. It must be one the project is written in — `project_info` lists them. | | `markdown` | required | The whole passage in that language. An empty string clears it. A link or image with a leading slash, `![map](/art/map.png)`, is measured from the project root and survives the passage being moved; one without is measured from the passage’s folder. | | `also` | | More languages, as locale to markdown, written in the same breath. Use this when translating rather than calling twice: the translation record is stamped once per write, and a translation stamped before its source reads as out of date. Written together, they are not. | ### `passage_create` [Section titled “passage\_create”](#passage_create) Makes a new passage beside the board that will show it, named `-`. Answers its id, which `board_add_passage` takes. Beside the board rather than anywhere else because a passage resolves its images against its own folder’s parent. | Argument | | What | | -------- | -------- | -------------------------------------------------------------------------------------------------------------------------------------------------- | | `board` | required | The board it belongs to. | | `title` | required | What to call it. Becomes part of the folder name. | | `text` | | The prose, in the language the story is written in. Omit for an empty passage with just a heading. Links and images resolve as on `passage_write`. | ### `board_add_passage` [Section titled “board\_add\_passage”](#board_add_passage) Adds a passage to a board: a Narration node **and** the passage file behind it, wired to where it follows from. One undo step, so the whole thing goes back with one Ctrl+Z. | Argument | | What | | --------- | -------- | -------------------------------------------------------------------------------------- | | `board` | required | The board. | | `title` | required | The card’s title, and the name of the passage made for it. | | `after` | | A node on that board to follow from. The new passage is wired to its next free choice. | | `via` | | The words a reader sees on the choice that leads here. Only meaningful with `after`. | | `passage` | | An existing passage to show instead of making a new one. | ### `board_add_node` [Section titled “board\_add\_node”](#board_add_node) Adds any node that is not a passage — a Jump to hand the story to another board, an End, a Script, and every value and control node besides; `node_types` is the list of what this build offers. Wires it to where it follows from, in one undo step. Passages have their own tool, `board_add_passage`, because it makes the passage file too. | Argument | | What | | ------------ | -------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `board` | required | The board. | | `type` | required | What kind of node. `node_types` lists every one this build has, and the client is shown them as an enum with the card names beside them. | | `title` | | What the card says. | | `after` | | A node to follow from. Wired to its first free exit — a Start leaves by `out`, a passage by its next free choice. | | `via` | | The words a reader sees on the choice that leads here. Only meaningful when `after` is a passage. | | `target` | | For a Jump: the board it hands the story to. | | `targetNode` | | For a Jump: a node on the target board to resume at, instead of its Start. | | `script` | | For a Script node: the `.py` it runs. | | `component` | | For a Component node: the component it materialises. | | `macro` | | For a Macro node (type `macro`): the macro it calls. It comes with a port per thing that macro takes and answers. | | `machine` | | For a State machine node (type `fsm`): the `.fsm.mmd` it drives. | | `soundscape` | | For a Narration node: the soundscape that plays from this passage on, until another passage brings its own; `silence`, which stops what is playing; or `none`, which clears the field so the passage keeps whatever is playing. | | `audio` | | For a Play sound node (type `flow.sound`): the audio file it cues, by name or path. Any audio file in the project is one. | | `event` | | For a State machine node: the event it fires. Blank reads the state without moving; `[*]` resets. | | `instance` | | For a State machine node, which copy of the machine; for a Component node, the name of the entity it makes. Blank means the machine itself, or the card’s title. | | `cvar` | | For a Get, Set or Variable branch node (types `cvar.get`, `cvar.set`, `cvar.branch`): the console variable it names, by name or id. `project_info` lists them, with the boards each is scoped to; prefer one scoped to this board or to none, as the editor offers. | | `value` | | For a Set variable or Variable branch node: the literal, in the variable’s type — a number, a boolean, a string, a list or an object. Refused when it does not fit. | | `comparison` | | For a Variable branch node: `equal`, `notEqual`, `greater`, `greaterEqual`, `less` or `lessEqual`. | | `metadata` | | The node’s own metadata: names to values, yours to use for anything, which nothing on the board reads. Any node has it. Replaces what the node had — `{}` clears it. A value may be a number, a boolean, a string, a list of one of those, or any JSON under a name; the editor draws the flat ones as typed rows and the rest as JSON. | ### `board_connect` [Section titled “board\_connect”](#board_connect) Wires one port to another on a board. A choice goes to exactly one place, so wiring one that already leads somewhere is refused rather than moved — `board_disconnect` it first. | Argument | | What | | ---------- | -------- | --------------------------------------------------------------------------------------------------------- | | `board` | required | The board. | | `from` | required | The node the flow leaves. | | `fromPort` | required | The port it leaves by — `choice_0`, `out`, and so on. | | `to` | required | The node it arrives at. | | `toPort` | | Where it arrives. Defaults to that node’s control input, which is what you want for every narrative node. | ### `board_disconnect` [Section titled “board\_disconnect”](#board_disconnect) Takes a wire out. The other half of `board_connect`, which refuses to move a choice that already leads somewhere — disconnect it first, then connect it where you meant. | Argument | | What | | ---------- | -------- | ------------------------- | | `board` | required | The board. | | `from` | required | The node the wire leaves. | | `fromPort` | required | The port it leaves by. | ### `board_set_choice` [Section titled “board\_set\_choice”](#board_set_choice) A choice’s two strings. The **name** is what the card shows, so a branch can be found on a wide board; the **text** is what a reader is offered, and it has one per language. They are two strings and they stay two — a reader offered “Choice 3” is being shown the author’s notes. Merges, so setting one does not clear the other or any translation. **times** is how often the path may be taken, which a reader sees greyed out once it is spent. | Argument | | What | | -------- | -------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | | `board` | required | The board. | | `node` | required | The node the choice is on. | | `portId` | required | Which choice — `choice_0`, `choice_1`, and so on. | | `name` | | The card’s label. Not shown to a reader. | | `text` | | What a reader is offered, in one language. | | `locale` | | Which language the text is in. Defaults to the language the story is written in. | | `times` | | How many times this path may be taken in one playthrough. Counted for the whole campaign, so a jump back and a reloaded save find it as spent as it was. Leave it out to change nothing; send `0` to take the limit off. | ### `board_set_node` [Section titled “board\_set\_node”](#board_set_node) Retitles a node, or changes what it points at — a Jump’s board, a Script node’s file, a Component node’s component, a Narration node’s passage or soundscape — or replaces its metadata. This is how a revision is made rather than an addition. Every argument `board_add_node` takes for a node’s kind is taken here with the same meaning, and what you leave out is left as it is. | Argument | | What | | --------------------------------------------------------------------------------------------------------------------------------- | -------- | -------------------------------------------------------------------------------------------------------------- | | `board` | required | The board. | | `node` | required | The node being changed. | | `title` | | What the card says. | | `passage` | | For a Narration node: the passage it shows. Its own prose, if it still had any, is cleared. | | `targetNode` | | For a Jump: a node on the target board to resume at. Named apart from `node`, which is the node being changed. | | `target`, `script`, `component`, `machine`, `soundscape`, `audio`, `event`, `instance`, `cvar`, `value`, `comparison`, `metadata` | | As on `board_add_node`. | ### `board_remove_node` [Section titled “board\_remove\_node”](#board_remove_node) Takes a node off a board, and every wire touching it. Refuses the last Start, because a board without one cannot be played at all. | Argument | | What | | -------- | -------- | ---------- | | `board` | required | The board. | | `node` | required | Its id. | ### `component_write` [Section titled “component\_write”](#component_write) Sets a component’s attributes, and its name and cover. Attributes you name are set or added; ones you do not are left alone, so a partial write cannot drop the rest. Types are the wire names — `int`, `float`, `bool`, `string`, `markdown`, `asset`, `componentRef` and `json`, each on its own or as `list<…>` or `map<…>` — and numbers and booleans are written as JSON numbers and booleans, never as strings. The older spellings, `assetList` and `componentList`, are accepted and mean what they always did. | Argument | | What | | ------------ | -------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `component` | required | The component. | | `attributes` | | Each is `{label, type, value}`. `type` is required for an attribute that is not there yet, and optional for one that is. The value of a `list<asset>` or `list<componentRef>` is an array of project-relative paths. | | `remove` | | Labels to delete. Separate from `attributes` so that sending two cannot silently drop five. | | `name` | | What the component is called — which is what `component("...")` resolves by in a passage, so changing it changes every snippet that names it. | | `cover` | | An image path, project-relative like every other path here. | ### `file_create` [Section titled “file\_create”](#file_create) Makes a new board, macro, component, script, state machine, soundscape, note or folder, seeded the way the app seeds one — a board arrives with its Start node, a macro with its Macro In and Macro Out. An assistant should not have to know this app’s on-disk shapes, and a hand-written board is one more thing that can be subtly wrong. A **passage** is not here, because it goes beside the board that shows it: `passage_create` makes one. | Argument | | What | | ----------- | -------- | ------------------------------------------------------------------------------------- | | `kind` | required | `board`, `macro`, `component`, `script`, `machine`, `soundscape`, `note` or `folder`. | | `name` | required | Without the extension; the right one is added. | | `directory` | | Project-relative. Defaults to the top of the project. | ### `file_move` [Section titled “file\_move”](#file_move) Moves or renames a file, keeping its identity. This rather than an assistant’s own file tools: a Jump stores an id, and a rename made outside the app issues a new one, which unwires every board that pointed at the file without saying a word. | Argument | | What | | -------- | -------- | ---------------------------------------------------------------------------------------------------- | | `from` | required | A project-relative path. | | `to` | required | Where it goes, project-relative, including the file name. Both a move and a rename at once are fine. | ### `file_delete` [Section titled “file\_delete”](#file_delete) Removes a file from the project, and from the index, the tree and the open tabs with it. **This is the one thing here that cannot be undone** — a board edit is in the undo stack and a passage is in version control, and this is neither. Refuses the project’s own bookkeeping, an ordinary folder, and anything with unsaved edits, and posts a caution rather than an info to Messages. | Argument | | What | | -------- | -------- | ------------------------------------------------------------- | | `path` | required | A project-relative path. A passage folder counts as one file. | ### `save` [Section titled “save”](#save) Writes out what the app is holding but has not put on disk. Use it when a tool refuses because a file has unsaved edits. It **saves and does not resolve**: a file something else changed while the app held edits to it is a decision you have pending, and it is named in the answer and left alone. | Argument | | What | | -------- | - | ----------------------------------------------------------- | | `path` | | Project-relative. Omit to write everything that is waiting. | ## Sound [Section titled “Sound”](#sound) Three writes for a soundscape, beside `soundscape_read` above and `file_create(kind: "soundscape")`. Every time is milliseconds, and every call is one edit on the timeline’s own session — one undo step in the editor, and a clip added while the tab is open appears on it. Nothing here imports a sound: `catalog_find(kind: "audio")` lists what the project holds, and a file that is not in it is yours to add. Nothing here reaches the editor’s mute and solo either, since those are not in the file. **A clip tool answers where the clip settled, not where it was asked for.** The timeline keeps one clip per spot on a lane and pushes a clip put down on another past it; the answer carries the clip as it landed, with `moved: true` and a sentence when the start or the lane is not what was sent. ### `soundscape_set` [Section titled “soundscape\_set”](#soundscape_set) Sets the timeline’s own properties — how long one pass is, whether it starts over, its fades and gain, and whether it is tied to the passage that starts it — and names or locks its lanes. What you leave out is left alone. | Argument | | What | | ------------ | -------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `soundscape` | required | The soundscape. | | `length` | | One pass of the timeline, in ms. A clip past it is cut there unless its mode is `auto`. | | `loop` | | Whether the timeline starts over at its length. | | `fadeIn` | | How long the whole soundscape takes to come up from silence when it starts, in ms. 0 is none. | | `fadeOut` | | How long it takes to fall silent when another soundscape replaces it or a passage stops it, in ms. 0 cuts. | | `volume` | | Gain over the whole soundscape, 0 to 1. | | `tied` | | `true` stops it the moment the story leaves the passage that started it; `false` keeps it playing until another passage brings its own, which is what a bed under a chapter wants. | | `lanes` | | Each is `{lane, name?, locked?}`, merged into what the author has said about that lane. A lane is its number, counting from 0 at the top; a name is for the person looking at the timeline and never reaches the story. A locked lane takes no more clips. An empty name takes the name away. | ### `soundscape_add_clip` [Section titled “soundscape\_add\_clip”](#soundscape_add_clip) Puts one audio file on the timeline as a clip and answers the clip as it settled, with the id `soundscape_set_clip` takes. Every field but `audio` is optional: a clip starts at 0, plays the whole file once at full gain, and goes on the first lane with room for it, past any lane the author has locked. A lane named outright that is locked is `refused`, with the `soundscape_set` that unlocks it. | Argument | | What | | ------------ | -------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `soundscape` | required | The soundscape. | | `audio` | required | The sound, by id, name or project-relative path — `catalog_find(kind: "audio")` lists them. | | `lane` | | Which row of the timeline, counting from 0 at the top. | | `start` | | Where on the timeline the clip begins, in ms. | | `clipStart` | | Where in the file the clip starts playing from, in ms into the file. 0 is the beginning. | | `clipEnd` | | Where in the file the clip stops, in ms into the file — or the word `end` to play to the end of the file, which is what a clip does until it is trimmed. A number past the file’s length is refused. | | `volume` | | Gain, 0 to 1, relative to the soundscape’s own. | | `speed` | | Playback rate; 1 is the file’s own. Above 0. | | `fadeIn` | | A ramp up at the clip’s start, in ms; 0 is none. On an `auto` clip it ramps every play, and every turn of a gapless one. | | `fadeOut` | | A ramp down at the clip’s end, in ms; 0 cuts. | | `mode` | | How the clip behaves when the timeline comes round. `once` — plays at its position on the first pass and never again: a door, a stinger. `loop` — plays at its position on every pass of a looping timeline and is cut where the timeline ends: rain that restarts with the rain. `auto` — starts at its position and then loops over itself for as long as the soundscape is in force, with no regard for where the timeline ends: music, a bed. With an `interval`, `auto` is a sound every so often instead. | | `interval` | | For an `auto` clip: the silence between one play ending and the next beginning, in ms. 0 is gapless — a bed. Anything else is a bell every so often. Read by no other mode. | ### `soundscape_set_clip` [Section titled “soundscape\_set\_clip”](#soundscape_set_clip) Changes one clip — any of its fields, or the file it plays — or takes it off the timeline. What you leave out is left alone; `clipEnd: "end"` untrims a clip back to the end of its file. | Argument | | What | | --------------------------------------------------------------------------------------------------- | -------- | ------------------------------------------------------------------------------------------------------- | | `soundscape` | required | The soundscape. | | `clip` | required | The clip’s id, as `soundscape_read` prints it. | | `audio` | | A different sound for this clip, by id, name or path. The trim, the gain and the mode stay as they are. | | `lane`, `start`, `clipStart`, `clipEnd`, `volume`, `speed`, `fadeIn`, `fadeOut`, `mode`, `interval` | | As on `soundscape_add_clip`. | | `remove` | | `true` takes the clip off the timeline; every other argument is then ignored. | ## Spelling, grammar and style [Section titled “Spelling, grammar and style”](#spelling-grammar-and-style) The project’s spelling dictionary, its style rules, and what the checker underlines — the *Writing* page of project settings and the editor’s squiggles, as tools. Each is shaped by what it costs to read: nothing lists the whole dictionary, which can run to thousands of words, and findings are asked for one document at a time rather than arriving with every passage read. The checker reads English only. See [How to check spelling and grammar](/how-to/check-spelling-and-grammar/) and [How to write a style rule](/how-to/write-a-style-rule/). ### `writing_read` [Section titled “writing\_read”](#writing_read) The project’s spelling dictionary and its style rules, as cheaply as the question allows. Bare, it answers how many words the dictionary holds and every rule by name with whether it compiles — never the words themselves. Pass `find` to look words up, and `rule` to read one rule’s source with how its own tests fare. There is no call that lists the whole dictionary, on purpose: `dictionary_edit` adds and removes without it. | Argument | | What | | -------- | - | ------------------------------------------------------------------------------------------------------------- | | `find` | | Words containing this, ignoring case — a whole word answers whether it is there. At most a hundred come back. | | `rule` | | A rule by name. Answers its source, whether it compiles, and which of its `test` and `allows` lines pass. | ### `grammar_check` [Section titled “grammar\_check”](#grammar_check) What the checker underlines in one passage or one Markdown note, as the editor would: with the project’s dictionary, the names it declares and its own style rules, and with the Python in the prose left out. Each finding is its line and column, the words, its tier and Harper’s kind, Harper’s message, and the suggestions already applied — the text to put in place of the words. Ask when you want it, after writing a passage or before a review, rather than for everything. Read from disk, so unsaved edits are not in it, and the answer says so when there are some. | Argument | | What | | --------- | - | ----------------------------------------------------------------------------------------------------------------------------- | | `passage` | | The passage. Give this or `note`. | | `note` | | A Markdown note, by its project-relative path, read as the project’s default language. | | `locale` | | Which of a passage’s languages to check. Omit for every English one it has; one that is not English is listed as not checked. | | `tiers` | | Only these: `spelling` (red in the editor), `grammar` (amber), `style` (blue). Omit for all three. | | `limit` | | At most this many findings in each language, from the top. Defaults to 50; the answer says how many more there were. | ### `dictionary_edit` [Section titled “dictionary\_edit”](#dictionary_edit) Adds words to the project’s spelling dictionary and takes words out, in one write, without reading it first. A word added stops being underlined everywhere in the project, for everyone who opens it. Answers what changed, what was already there, and what was not — naming the same word in another case when that is why. One word each: a phrase with a space is refused. A dictionary saved by a newer version of the app is refused whole. | Argument | | What | | -------- | - | ------------------------------------------------------ | | `add` | | Words to add. Case is kept: add a name as it is spelt. | | `remove` | | Words to take out, exactly as the dictionary has them. | ### `style_rule_write` [Section titled “style\_rule\_write”](#style_rule_write) Writes one of the project’s style rules, in Weir, creating it or replacing it whole, and answers what the checker made of it: whether it compiles, and how many of its own `test` and `allows` lines pass, with what each failing one expected and got. **A rule that does not compile is refused** unless `allowBroken` is true, which saves it anyway as the editor’s *Save anyway* does; it is then listed in Messages until it compiles, and the write posts a caution. Two rules may not share a name in different cases. | Argument | | What | | ------------- | -------- | -------------------------------------------------------------------------------------------------------------- | | `name` | required | The rule’s name, which is its file: letters, digits, `_` and `-`, starting with a letter or a digit, up to 64. | | `source` | required | The whole rule — see [What a rule says](/how-to/write-a-style-rule/#what-a-rule-says). | | `allowBroken` | | Save a rule that does not compile. Off by default; a broken rule catches nothing. | ### `style_rule_delete` [Section titled “style\_rule\_delete”](#style_rule_delete) Deletes one style rule. It is not in any undo stack, so the write posts a caution; version control is the way back. | Argument | | What | | -------- | -------- | ------------------------------------------------------------------------------------------------ | | `name` | required | The rule, by its exact name. A name that differs only in case is answered with the one it meant. | ## Beyond the tools [Section titled “Beyond the tools”](#beyond-the-tools) **Resources**, for the files that are already the answer. A client can attach one as context with no tool call: `ripple://project` is the project’s settings, `ripple://board/{id}` and `ripple://macro/{id}` a board’s or a macro’s digest, `ripple://passage/{id}` a passage in every language under a `## <locale>` heading (and `ripple://passage/{id}/{locale}` one of them), and `ripple://component/{id}`, `ripple://script/{id}`, `ripple://machine/{id}` and `ripple://soundscape/{id}` the file verbatim. The list names each by its catalog name and path, is rebuilt on every request, and a client that subscribes is told when a resource changes — including when you edit a board an assistant is holding. **Two prompts**, invoked by you rather than by the assistant, each arriving with the thing you name already in it: *writing-a-passage* (`passage?`) — what a passage is on disk, the two forms embedded Python takes, and what the sandbox cannot do; and *editing-a-board* (`board?`) — how node ids are numbered, where a choice’s two strings live, what a wire’s caption is and is not, what a Jump stores, and how a soundscape is laid out. ## Related [Section titled “Related”](#related) * [What an assistant may read, play and write](/reference/mcp-permissions/) — the permissions behind the two switches. * [How to connect an AI assistant (MCP client)](/how-to/connect-an-mcp-client/) — connecting a client to the app. * [Working with an AI assistant](/explanation/working-with-an-assistant/) — what an assistant does with these tools. * [Reading a large story back](/explanation/reading-a-story-back/) — the questions the trace tools answer. # Node families, slots and state > The families of nodes, the types and defaults every slot shares, the rules for macros and Do all, and what is remembered for a whole playthrough. The [nodes reference](/reference/nodes/) lists every card, port by port. This page describes what is shared between them: the families the Create menu groups them into, the slot types, defaults and file pickers, the node pairs that take part in a macro, and what keeps state. ## Families [Section titled “Families”](#families) The Create menu groups nodes into these families. Lists and Maps are described below; every other card is in the [nodes reference](/reference/nodes/). * Flow * Story * Math * Logic * Lists * Maps * Entities * Value * Chance * Macro In and Macro Out ### Lists [Section titled “Lists”](#lists) | Node | Behaviour | | ------ | ----------------------------------------------------------------------------------------------------- | | Insert | Prepends, appends, inserts at an index, or replaces at one. Returns the whole list. | | At | A single item. A negative index counts from the end; -1 is the last. | | Remove | Removes by the same counting. Returns the whole list. An index out of range leaves it unchanged. | | Size | How many, and whether there are none. Its `length` is what a For node’s ceiling takes to walk a list. | * Pointed at text, the list nodes work a character at a time and return the kind that went in: appending `!` to `hi` gives `hi!`. * An index out of range never stops the story. ### Maps [Section titled “Maps”](#maps) | Node | Behaviour | | ------ | ------------------------------------------------------------------------ | | Get | The value under a name, plus a `found` output. | | Set | Returns the whole map. | | Remove | Returns the whole map. | | Keys | The keys, in the same order as Values. | | Values | The values, in the same order as Keys, so the first goes with the first. | | Merge | The second map wins on conflicts. | | Size | How many. | * An input that is not a map is treated as an empty map. A Set with nothing wired to its subject makes a map of one pair; chained Sets build a map. * A name that is not there never stops the story. ## Ports [Section titled “Ports”](#ports) A port carries either flow or a value. Every card’s way in is `exec` unless the [nodes reference](/reference/nodes/) says otherwise. Value ports are typed: a `number` joins a `number`, and `any` joins anything. ## Slots [Section titled “Slots”](#slots) A slot is a named, typed row on a card that declares its own ports: a macro’s takes and answers, an Output node, an Input node, a Script node, a component’s attributes, and console variables. ### Slot types [Section titled “Slot types”](#slot-types) Every slot offers the same twenty-four types: eight kinds, each alone, as a list, or as a map from names to it. | Kind | Meaning | | ---------------- | ----------------------- | | `int` | Integer. | | `float` | Number with a fraction. | | `bool` | Boolean. | | `string` | Text. | | `markdown` | Markdown text. | | a path to a file | `asset`. | | a component | `component`. | | JSON | `json`. | The menus offer neither `number` nor `entity`. Files that hold them read, and the row shows the word until it is retyped. ### Declared type and port type [Section titled “Declared type and port type”](#declared-type-and-port-type) What a slot declares and what its port carries are not the same set. The file keeps the narrower declared word. | Declared | Port carries | | ----------------------------------------- | ------------------------------------------------------------------------------------- | | `int`, `float` | `number` | | A lone `asset`, `component` or `markdown` | `string` | | A list of assets or of components | The tag a component’s `assetList` and `componentList` attributes carry, so they join. | | A map | `json` | An Input node’s types are narrowed on the way into a campaign. It is drawn as a form, and a form has no file picker, so an `asset` field reaches the player as a text box asking for a path. It can still be declared; the word is kept in the project. ### Which cards declare ports [Section titled “Which cards declare ports”](#which-cards-declare-ports) Output, Input, Script, Macro In and Macro Out. Double-click the card; a Script node’s *Inputs* and *Outputs* are buttons that open the same dialog. Each row is a name and a type, and a type is two questions: what it is, and how many (one, a list, or a map). ### Coercion [Section titled “Coercion”](#coercion) * A slot gets the type it asked for. A script input declared `int` and wired `3.7` reads `3`. * A value that cannot be read as the type becomes that type’s blank. It does not stop the story. * What a script writes to an output is left as it is. ### Defaults [Section titled “Defaults”](#defaults) A slot can carry a default, used when nothing fills it. | Slot | Default applies | | ---------------- | ------------------------------------------------------ | | Macro take | When the calling card wires nothing and types nothing. | | Script input | When nothing is wired. A wired value wins. | | Script output | Only where the program never set that name. | | Output node slot | When nothing fills it. | | Macro Out answer | When nothing fills it. | | Input node field | Prefilled for the reader to accept or type over. | Where a wire and the card’s own literal both answer a port, the wire wins, and the field on the card disappears while the wire is attached. Where neither is there, the declared default stands in, and then the type’s own blank. A default on a `json` or a `map<...>` slot cannot travel, and the app says so when you compile. ### Slots that point at a file [Section titled “Slots that point at a file”](#slots-that-point-at-a-file) A slot that points at a picture, a document or a component offers the project’s files by name, with the full path beside each. * The pick shows as a chip with its name. Hovering it shows the path, which is what is stored. * A chip whose file is outside the project or missing is marked, so a file renamed outside the app is visible. * On a card (a macro take, a Set variable node) the slot is a searchable dropdown, like the Narration node’s passage picker. ### Names [Section titled “Names”](#names) A name read by a script must be a name Python can read. | Where | Rule | | -------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | Script node inputs and outputs; a passage’s inputs | A letter or an underscore, followed by letters, digits and underscores. Python’s own words, such as `class`, are refused, with the reason shown under the field. A board that carries a name Python cannot read reports it. | | Output and Input node slots | Read by people; `Your roll` is a valid name. | ## Macros [Section titled “Macros”](#macros) A macro is a file of its own, `*.macro.graph.json`, made from the file tree’s **+** beside a board. It opens on the same canvas, with two nodes where a board has its Start and its Ends. | Node | Declares | Ports | Count | | --------- | ------------------------------------------------------------------------------------------------ | -------------------------------------------------------------------------------------------------------------------------- | ------------ | | Macro In | What the macro takes, described as an Input node is: a name and a type per row, by double-click. | One output per name, carrying what the calling board wired in or typed on the card. | Exactly one. | | Macro Out | What the macro answers, described the same way. | One input per name. Its way in takes any number of wires, so every branch inside the macro can finish at the same way out. | Exactly one. | Everything between the two is the same node set a board has. ### The Macro card [Section titled “The Macro card”](#the-macro-card) * Dragging the file onto a board creates a **Macro** card with a port per name, and a field beside every name it takes. * A field can be wired or typed. The wire wins. * A take can carry a default. * Play on the macro’s own tab reads it on its own, with nothing wired in. ### Rules [Section titled “Rules”](#rules) * A macro is copied into whatever calls it when the campaign is compiled. Calling one twice makes two independent copies: a Once gate inside a shop fires once per shop, not once for the whole story. * A macro cannot call itself. * A macro cannot be jumped to, and a board cannot be called. A Jump lists boards; a Macro card lists macros. * A story can be paused inside a macro and picked up where it left off. * A trace line in the play window about something inside a macro takes you to the card that called it. * A route traced across boards goes inside the macros it calls: the passages a macro holds are drawn where the call is, so a macro reused in several places appears on the route in each. ## Do all [Section titled “Do all”](#do-all) A **Do all** runs everything wired to it, one branch after another, then carries on from `completed`. * Every branch runs to the end of its wire before the next starts. * A passage does not end a wire. A passage on branch two holds branch three until every passage after it has been read. * `completed` is where the flow carries on. * A board written before the `completed` port existed gains it when it opens. ## Entities on a board [Section titled “Entities on a board”](#entities-on-a-board) A `*.component.json` dragged from the file tree onto a board becomes a **Component** node with one typed output per attribute, plus one carrying the whole component as a map. The node says which of its two modes it is in. | Mode | Behaviour | | --------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | Component | A window onto the file. Outputs only. | | Entity | Makes the story a thing, keyed by the name on the node. Attribute inputs appear alongside the outputs; there is no Set/Get pair, because a second node naming the same entity later in the story is the setter. | * Two nodes with two names are two independent entities. A node on a later board naming the same entity is the entity the first board made. * A Component node whose name is wired from upstream makes one entity per name it is handed. The Entities category of the node menu holds four more. | Node | Behaviour | | ------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | Spawn | Makes many entities at once from a component. Inputs: a count; how they are told apart, numbers or letters; and what goes between the name and the number. “Goblin” with numbers and a dash makes Goblin-1, Goblin-2, and so on. Output: the names, as a list. Returning to a Spawn node finds the entities it already made rather than making new ones, so a paused campaign resumes into the encounter it left. A second wave needs a different name. | | Delete Entity | Forgets one by name. Deleting a name nothing answers to removes nothing and is not an error. The component file is untouched, so a node naming it later makes a fresh one. | | Entity Exists | Answers whether the campaign still has the entity. | | Entity Names | Answers every entity the story has made, as a list of names, sorted by name, or only those made from one component. Wire `names` into a For node with a List at node to act on each. | ## Console variables on a board [Section titled “Console variables on a board”](#console-variables-on-a-board) A Set on one board and a Branch on another need no wire between them: the variable is the wire. | Node | Behaviour | | --------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | | Get variable | Publishes what the variable holds on `value`. | | Set variable | Writes the variable, from a wire into `value` or the value typed on the card (the wire wins), and publishes the new value on `result`. | | Variable branch | Compares the variable against a value, on the card or wired in, and leaves by `yes` or `no`. Equality reads both sides as text. Ordering works only between two numbers or two strings, by the Comparator’s rules. | All three take the flow rather than being pulled, so a read after a write is never stale. ## State machine node [Section titled “State machine node”](#state-machine-node) The node is made by dropping a state machine file on a board, or by adding the node and picking the machine from the list on its card. A new machine file is made from the file tree’s right-click menu, **New state machine**. The node takes an event and answers the state the machine is in after it. | Input or output | Behaviour | | ----------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------- | | `event`, wired or typed | Blank reads the state without moving. `[*]` resets the machine to its start. | | `state` | Wire into a Switch, one row per state, to branch on where the machine is. | | `changed` | Flow leaves here when an arrow fired or the machine was reset. | | `unchanged` | Flow leaves here otherwise. An event the current state has no arrow for is not an error: the machine stays put and the flow takes `unchanged`. | * Two nodes anywhere in the story drive the same machine unless one is given an **instance** name. * Left blank, the instance is the machine’s own name. ## Python on a board [Section titled “Python on a board”](#python-on-a-board) | Place | Behaviour | | ----------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | Script node | References a `.py` file in the project, so one script can serve many nodes. Declares inputs and outputs as rows, a name and a type each, and has a port per name. The *Inputs* and *Outputs* boxes in its editor are buttons that open the ports dialog every other node uses. Values cross as native Python types. Whatever the script assigns to an output name is published on that port. | | Output node | Shows its inputs to the reader as a slide with one way on. Slots are typed as a Script node’s are and may carry a default. | | Input node | Asks the reader for values and hands what was typed on as ports. Its default is filled in for the reader to accept or type over. | | Passage | An inline `{{ … }}` span and a fenced ` ```ripple ` block. Both are replaced by what they evaluate to when the passage is presented. | Output and Input are described by double-clicking the card: a name and a type per row. A passage only reads. Making, changing or deleting an entity, or writing a console variable, is refused from inside prose. The refusal renders in place as a `⚠` chip and the story carries on. ````markdown The goblin has {{ entity_get("Goblin-1")["HP"] }} hit points left. ```ripple names = entity_names(of="Goblin") f"{len(names)} goblins are still standing." ``` ```` ### The console [Section titled “The console”](#the-console) * The console beside every `.py` file is a live session, one per project, so `x` survives between lines. * The prompt grows to three lines as you type. Shift+Enter starts a new line; Enter runs what is there. The arrow keys walk through what has been run. * During a playthrough, the same console is available beside the Entities and Variables panels, over the story that is running. There it may do everything a Script node may, because it stands in for the nodes the story did not run, which *Play from here* needs. ## Play sound [Section titled “Play sound”](#play-sound) One file, once, at a volume and a speed. The story carries on at once: `completed` fires the moment the sound is cued. | Route | Behaviour | | ----------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------ | | Play sound node | See the [nodes reference](/reference/nodes/#play-sound). | | `play_sound("sfx/bell.ogg")` from a Script node | The same, from Python, by the project’s spelling of the path or the bundle’s. `volume` and `speed` by name. See the [Python reference](/reference/python/sounds/). | A passage’s snippets cannot call it: they run again on every redraw and every change of language, and a sound rung from prose would ring twice. ## State kept for the whole playthrough [Section titled “State kept for the whole playthrough”](#state-kept-for-the-whole-playthrough) | Kept | Reset by | | ----------------------------- | -------------- | | FlipFlop, Once, State machine | Starting over. | | A choice’s limited-use count | Starting over. | * Leaving a board and coming back does not reset them. * Saving and loading does not reset them. A saved game carries what they remember. ## Related [Section titled “Related”](#related) * [Boards, nodes and macros](/explanation/boards-nodes-and-macros/) * [How a story runs](/explanation/how-a-story-runs/) * [How to reuse a stretch of story with a macro](/how-to/make-a-macro/) * [How to run several jobs before a passage](/how-to/order-jobs-before-a-passage/) * [Console variable declarations](/reference/console-variable-declarations/) # Nodes > Every kind of node a board or a macro can hold — what each is for, what it takes on its ports, what it answers, and where to read more. Every card the Create menu offers, in the menu’s order and under its categories. Each entry gives, in this order: what the card is for, in the card’s own words; what is on the card; the ports it takes and the ports it answers on; and, where one exists, something to read. An agent receives the same list for the build it is talking to from `node_types`. **Ports.** A port carries either *flow* or a value. Flow is the story, pushed from card to card; a value is read when a card takes its turn, and is whatever the card at the other end last published. A card’s way in is `exec` unless its table says otherwise. Every value port is typed: a `number` joins a `number`, and `any` joins anything. How the two kinds are run is in [Board and navigation rules](/reference/canvas-rules/); the cards that declare, keep or read state across a playthrough are grouped in [Node families, slots and state](/reference/node-families/). **Cards that declare ports.** | Card | Where the declaration is opened | | ---------------------------------- | ------------------------------------------------------------------------------------------- | | Output, Input, Macro In, Macro Out | Double-click the card. | | Script | The *Inputs* and *Outputs* buttons, which open the same dialog. | | Narration | The *Reads* row at the bottom of the card, which shows how many names the passage is given. | Each row is a name and a type. A type answers two questions: what it is, and how many of it. | Question | Values | | -------------- | -------------------------------------------------------------------------- | | What it is | `int`, `float`, `bool`, `string`, `markdown`, `asset`, `component`, `json` | | How many of it | one, a list, or a map of names to it | That is 24 types. Output, Input, Script, Macro In and Macro Out each offer the same 24, as do a component’s attributes and the console variables. `number` and `entity` are still read wherever they appear and are no longer suggested: `int` or `float` says which, and an entity is a name, which is a `string`. **Declared type and port type.** What a row declares and what its port carries are not the same set. The file keeps the declared word. | Declared | Port carries | | ------------------------------------------- | ---------------------------------------------------------------------------------------------------- | | `int`, `float` | `number` | | A single `asset`, `component` or `markdown` | `string` | | A list of assets or of components | The tag a component’s own `assetList` and `componentList` attributes carry, so those join each other | | A map | `json` | **Input.** An Input node’s types are narrowed on the way into a campaign. What it declares is drawn as a form for the reader to fill in, and a form has no file picker, so an `asset` field reaches the player as a text box asking for a path. The declaration is kept in the project. **Names.** A name that becomes a Python variable must be one Python can read: a letter or an underscore, then letters, digits and underscores. A Python keyword such as `class` is refused, with the reason under the field. This applies to a passage’s inputs and to a Script node’s inputs and outputs. An Output node’s rows and an Input node’s fields are read by people, so `Your roll` is a valid name there. A board that carries a name Python cannot read reports it. **Coercion.** A slot receives the type it declared. * An `int` Script input given `3.7` reads `3`. * A `list<int>` input handed a list of text receives numbers. * A value that cannot be read as the declared type becomes that type’s blank; the story is not stopped. * This holds for a Script node’s inputs and an Output node’s rows. What a script writes is left as it is. **Defaults.** Any slot can carry a default, the value the slot takes when nothing fills it. Slots that take one: a Macro In’s takes, a Script node’s inputs and outputs, an Output node’s slots, a Macro Out’s answers and an Input node’s form fields. | Situation | Value used | | ----------------------- | ------------------------------------------------------------------------------------------------------------ | | A wire answers the port | The wire. A card’s own literal is superseded by a wire, and the field disappears while the wire is attached. | | No wire | The card’s own literal, where the card has one. | | Neither | The declared default. | | No default either | The type’s blank. | An Input form field’s default is filled in for the reader to accept or type over. A script output’s default applies only where the program never set that name. A default on a `json` or a `map<…>` slot cannot travel into a campaign, and the app reports it when you compile. **File slots.** A slot that points at a file is filled by name. This covers a picture, a document or a component, in a component’s attributes, a macro take’s or script input’s default, and a console variable. Typing a name lists the project’s files, with the full path beside each. The pick is shown as a chip, hovering shows the path, and the path is what is stored. A chip whose file is outside the project or missing is marked. On a card (a macro take, a Set variable node) the slot is a searchable dropdown instead, like the Narration node’s passage picker. **Metadata.** Every card can carry metadata. Right-click the card and choose *Edit metadata…*: a list of names, types and values. A small tag shows on the card’s header while there is any. Nothing on the board reads it. It travels with the node into a compiled campaign and to an agent, and a copied node keeps its metadata. | Offered | Not offered | | -------------------------------------------------------------------------------- | -------------------------------------------------------------------------------- | | `int`, `float`, `bool`, `string`, a list of each, and `json` for anything nested | `asset` and `string` are not told apart; a whole `3` and a fractional `3.0` are. | ## Flow [Section titled “Flow”](#flow) ### Start [Section titled “Start”](#start) ![The Start card](/_astro/start-light.DGDpM78R_Nc6x1.webp) ![The Start card](/_astro/start-dark.BnxHn_xV_1SpJxt.webp) Where the story begins. A marker: it holds no text of its own, only the single wire that leaves it. **Answers** | Port | Carries | Meaning | | ----- | ------- | ------------------------------------ | | `out` | `flow` | The single wire the story leaves by. | ### Macro [Section titled “Macro”](#macro) ![The Macro card](/_astro/macro-light.DJN9Gs0f_1BQSUz.webp) ![The Macro card](/_astro/macro-dark.Cn7V9Rqx_Z1TqC7L.webp) Runs a macro here — a board a board can call. Drag a macro out of the file tree and drop it on the canvas, or pick one here. It grows an input per thing the macro takes and an output per thing it answers, and the flow carries on where the macro ends. Wire a take, or type it on the card; the wire wins. The macro is spliced in when the campaign is compiled, so calling one twice is two copies of it and a macro cannot call itself. Plus one **input per thing the macro takes**, with a field beside it to type a literal, and one **output per thing it answers**. **On the card:** **Macro**. **Takes** | Port | Carries | Meaning | | ------ | ------- | ----------- | | `exec` | `flow` | The way in. | **Answers** | Port | Carries | Meaning | | ----------- | ------- | -------------------------------------------- | | `completed` | `flow` | Taken where the macro reached its Macro Out. | **Read more:** [Macros](/reference/node-families/#macros) ### Jump [Section titled “Jump”](#jump) ![The Jump card](/_astro/jump-light.BdB5YHHJ_ZzkdTX.webp) ![The Jump card](/_astro/jump-dark.UGAgvfhg_ZalmB3.webp) Continues the story on another board. Pick the board it goes to, and use the arrow to open that board here. It is how a chapter hands over to the next one, and how a session joins onto the one before it. It has no output: what happens next happens on the board it names. Leave Node on `Start` to begin that board at the beginning, or name a node to resume partway in — which is how a chapter can be re-entered without replaying it. **On the card:** **Board**; **Node** — Leave it on Start to begin that board at its beginning, or name a node to resume partway in. **Takes** | Port | Carries | Meaning | | ---- | ------- | --------------------------------------------------------------------------------- | | `in` | `flow` | The way in. There is no way out: what happens next happens on the board it names. | **Read more:** [Reading a story back](/explanation/reading-a-story-back/) ### While [Section titled “While”](#while) ![The While card](/_astro/while-light.CZcRDALy_1kwPW1.webp) ![The While card](/_astro/while-dark.D9sZ1VKe_212rJj.webp) Repeats a stretch of the story. Wire the end of the loop back into `continue` and it goes round again through `loop`. Wire something into `break` and it leaves through `exit`. It holds no condition of its own: what decides is whatever you wired to those two inputs. A loop nothing breaks out of does not run for ever: one loop node may go round 512 times in a board before the campaign stops and offers the node that ran away. The ceiling is under *Playing* in Settings. **Takes** | Port | Carries | Meaning | | ---------- | ------- | ----------------------------------------------------- | | `continue` | `flow` | Wire the end of the loop back here to go round again. | | `break` | `flow` | Wire something here to leave. | **Answers** | Port | Carries | Meaning | | ----------- | -------- | --------------------------------------- | | `loop` | `flow` | Taken on every pass. | | `exit` | `flow` | Taken on break. | | `iteration` | `number` | Which pass this is, counting from zero. | ### For [Section titled “For”](#for) ![The For card](/_astro/for-light.rdpql6UM_ZoFv2U.webp) ![The For card](/_astro/for-dark.Cv8rS2Sb_1qcUHy.webp) Repeats a stretch of the story, but not forever. The same two ways in and two ways out as a While. What it adds is a ceiling: once it has gone round that many times it leaves through `exit` whatever the graph says. It is the loop to reach for when the condition is somebody else’s and you would rather not find out the hard way that it never comes false. Its own ceiling applies before the one under *Playing* in Settings when it is lower. **On the card:** **Max iterations**, default `100` — The ceiling. **Takes** | Port | Carries | Meaning | | ---------- | ------- | ----------------------------------------------------- | | `continue` | `flow` | Wire the end of the loop back here to go round again. | | `break` | `flow` | Wire something here to leave early. | **Answers** | Port | Carries | Meaning | | ----------- | -------- | ----------------------------------------------- | | `loop` | `flow` | Taken on every pass. | | `exit` | `flow` | Taken on break, or once the ceiling is reached. | | `iteration` | `number` | Which pass this is, counting from zero. | ### Switch [Section titled “Switch”](#switch) ![The Switch card](/_astro/switch-light.Dz2HyKip_Z2qPGFt.webp) ![The Switch card](/_astro/switch-dark.DqdcT7ct_258YIp.webp) Sends the flow down one branch of several, by name. Type into the last row and another appears beneath it, each with its own way out. Clear the last one again and it goes away. Wire the thing being decided into `value`. Cases are matched as text: a number is compared as the digits you would write it with, so 42 and “42” are the same case. Nothing matching is not an error and not a fall-through — the story simply stops there. A branch nobody wrote is worse than a branch that is plainly missing. **Takes** | Port | Carries | Meaning | | ------- | ------- | ---------------------------------------------------------------------- | | `in` | `flow` | The way in. | | `value` | `any` | The thing being decided. Matched as text, so 42 and “42” are one case. | **Answers** | Port | Carries | Meaning | | -------- | ------- | -------------------------------------------------------------------------------------------------------- | | `case.0` | `flow` | One way out per row. Type into the last row and another appears; nothing matching stops the story there. | ### If [Section titled “If”](#if) ![The If card](/_astro/flow.if-light.BFq9H-2U_Z5Q925.webp) ![The If card](/_astro/flow.if-dark.HwKfF2n2_Z1DF5j2.webp) Sends the flow one of two ways, on a yes or no wired into it. It asks nothing itself: `condition` takes a true-or-false from somewhere else, usually a Comparator. That is what makes it the piece a While or a For has been waiting for — wire `true` back into `continue` and `false` into `break`. **Takes** | Port | Carries | Meaning | | ----------- | ------- | ----------------------------------------- | | `exec` | `flow` | The way in. | | `condition` | `bool` | True or false, usually from a Comparator. | **Answers** | Port | Carries | Meaning | | ------------- | ------- | --------------------------------- | | `trueBranch` | `flow` | Taken when the condition is true. | | `falseBranch` | `flow` | Taken when it is false. | ### Do all [Section titled “Do all”](#do-all) ![The Do all card](/_astro/flow.all-light.Dzeh8xTH_8ljN1.webp) ![The Do all card](/_astro/flow.all-dark.D4sGXCWv_Z1CJ26P.webp) Runs everything wired to it, one branch after another, then carries on from `completed`. Wire the last branch and another appears beneath it, so it grows as many as you need. They run top to bottom, each to the end of its wire before the next starts — a passage on a branch holds the branches below it until every passage reachable from it has been read. So put the jobs on the branches and the story on `completed`: it fires once every branch has run out. For the bookkeeping a story needs before a passage: three things to set up, side by side, and then the passage. **Takes** | Port | Carries | Meaning | | ------ | ------- | ----------- | | `exec` | `flow` | The way in. | **Answers** | Port | Carries | Meaning | | ----------- | ------- | --------------------------------------------------------------------------------------------------------- | | `completed` | `flow` | Taken once every branch has run out. Boards written before it had one grow the port the moment they open. | | `branch_0` | `flow` | One per branch, run top to bottom; wire the last and another appears. | ### State machine [Section titled “State machine”](#state-machine) ![The State machine card](/_astro/fsm-light.BQfcGiDq_Z1RsCk5.webp) ![The State machine card](/_astro/fsm-dark.D7Cj9Cs1_ZPyyoH.webp) Fires an event at a state machine and says what state it is in. The machine is a `.fsm.mmd` file — a mermaid state diagram. `event` is what to fire, from the card or from a wire; leave it blank to read the state without moving. `[*]` puts the machine back at its start. `changed` is taken when an arrow fired, `unchanged` when none did — which is not an error. `state` is where the machine is now; a Switch on it is how the story branches. `instance` names *which* one: leave it blank and every node driving this machine drives the same one. It remembers for the whole playthrough, across boards and saves; starting over forgets. **On the card:** **Machine**; **Instance**; **Event** — Blank reads the state without moving. **Takes** | Port | Carries | Meaning | | ------- | -------- | ---------------------------------------------------------------------------------------- | | `exec` | `flow` | The way in. | | `event` | `string` | What to fire; blank reads the state without moving; `[*]` resets. A wire beats the card. | **Answers** | Port | Carries | Meaning | | ----------- | -------- | ----------------------------------- | | `changed` | `flow` | Taken when an arrow fired. | | `unchanged` | `flow` | Taken when none did — not an error. | | `state` | `string` | Where the machine is now. | **Read more:** [Finite state machines](https://brilliant.org/wiki/finite-state-machines/) · [Mermaid state diagrams](https://docs.mermaidviewer.com/diagrams/state.html) · [State machines in Ripple Effect](/explanation/state-machines/) ### Variable branch [Section titled “Variable branch”](#variable-branch) ![The Variable branch card](/_astro/cvar.branch-light.WGVgGmpv_yKCra.webp) ![The Variable branch card](/_astro/cvar.branch-dark.BuqeOCcZ_2irPTL.webp) Sends the flow one of two ways depending on a console variable. Pick the variable, a comparison, and what to compare it against — on the card, or wired into `value`. The comparison is the Comparator’s: equality goes through text, so 42 and “42” are equal, and only two numbers or two strings order. A Get variable node into a Comparator into an If does the same in three cards; this is the one card for the common case, a story that branches on a flag. **On the card:** **Variable**; **Comparison**, default `equal` — The same set a Comparator offers; **Value**. **Takes** | Port | Carries | Meaning | | ------- | ------- | ------------------------------------------------------------ | | `exec` | `flow` | The way in. | | `value` | `any` | What to compare the variable against. A wire beats the card. | **Answers** | Port | Carries | Meaning | | ----- | ------- | -------------------------------- | | `yes` | `flow` | Taken when the comparison holds. | | `no` | `flow` | Taken when it does not. | **Read more:** [Console variables](/explanation/console-variables/) ### Delay [Section titled “Delay”](#delay) ![The Delay card](/_astro/flow.delay-light.Z0oGo35q_Z1dFUP3.webp) ![The Delay card](/_astro/flow.delay-dark.DziNyZHh_1mXUpS.webp) Holds the story for a while, then carries on. The wait is written the ISO 8601 way: `PT2S` is two seconds, `PT1M30S` a minute and a half, `P1DT12H` a day and a half. Years and months are not a length of time and are refused. The last passage stays on screen while it waits. An agent playing the story, or a test, does not wait at all. **On the card:** **Duration**, default `PT1S`. **Takes** | Port | Carries | Meaning | | ------ | ------- | ----------- | | `exec` | `flow` | The way in. | **Answers** | Port | Carries | Meaning | | ----------- | ------- | ---------------------------- | | `completed` | `flow` | Taken when the wait is over. | **Read more:** [ISO 8601 durations](https://en.wikipedia.org/wiki/ISO_8601#Durations) ### FlipFlop [Section titled “FlipFlop”](#flipflop) ![The FlipFlop card](/_astro/flow.flipflop-light.hQzMFQh0_2v6yVR.webp) ![The FlipFlop card](/_astro/flow.flipflop-dark.Beqcum1N_Z29XKw6.webp) Takes one way out, then the other, then the first again — every time the flow reaches it. The first visit goes out of `a`. `is A` is true while `a` is the side running. It remembers for the whole playthrough: leaving the board and coming back, or saving and loading, does not reset it. Starting over does. **Takes** | Port | Carries | Meaning | | ------ | ------- | ----------- | | `exec` | `flow` | The way in. | **Answers** | Port | Carries | Meaning | | ----- | ------- | ----------------------------------------------------- | | `a` | `flow` | Taken on the first visit, and every second one after. | | `b` | `flow` | Taken on the visits in between. | | `isA` | `bool` | True while `a` is the side running. | **Read more:** [What remembers, and for how long](/reference/node-families/#state-kept-for-the-whole-playthrough) ### Once [Section titled “Once”](#once) ![The Once card](/_astro/flow.once-light.BmqFD4q0_Z1sqce3.webp) ![The Once card](/_astro/flow.once-dark.D_EoubJ7_ZgDh1T.webp) The first time the flow reaches it goes out of `first`; every time after goes out of `again`. `visits` is how many times so far, counting this one, so a Switch on it can tell the third visit from the second. It remembers for the whole playthrough: leaving the board and coming back, or saving and loading, does not reset it. Starting over does. **Takes** | Port | Carries | Meaning | | ------ | ------- | ----------- | | `exec` | `flow` | The way in. | **Answers** | Port | Carries | Meaning | | -------- | -------- | ------------------------------------------ | | `first` | `flow` | Taken on the first visit only. | | `again` | `flow` | Taken on every visit after. | | `visits` | `number` | How many visits so far, counting this one. | **Read more:** [What remembers, and for how long](/reference/node-families/#state-kept-for-the-whole-playthrough) ### End [Section titled “End”](#end) ![The End card](/_astro/end-light.IwG62AlC_Z1wQmYz.webp) ![The End card](/_astro/end-dark.CETvgCak_hBdRD.webp) Where a path stops. Its input accepts any number of wires, so every branch of a story can finish at the same ending. **Takes** | Port | Carries | Meaning | | ---- | ------- | -------------------------------------------------- | | `in` | `flow` | Any number of wires; every branch can finish here. | ## Story [Section titled “Story”](#story) ### Narration [Section titled “Narration”](#narration) ![The Narration card](/_astro/narration-light.Bm8jGhVZ_2n5A6W.webp) ![The Narration card](/_astro/narration-dark.D3n6LYXN_YgWaA.webp) A passage of prose, in every language the project is written in. The text lives in a localised passage beside the board rather than on the node, so one passage can be read by several nodes and a translator can work on it in any editor. The card shows the first few rendered lines of the story’s own language; double-click it to write. It always carries one spare choice at the bottom. Wire that one up and another appears beneath it, so a passage grows as many branches as the reader needs. Click a choice’s row on the card to give it a name, which is what the card shows, and its words, which are what the reader is offered — in every language the story is written in. A soundscape — a timeline of music and sounds, a file of its own — plays from the moment this passage is shown, and **stays in force** through every passage after it until one brings its own: a tavern goes on being a tavern. A passage with none changes nothing; a passage set to *Silence* stops whatever is playing. A soundscape marked *tied* belongs to its passage and stops when the story moves on. The *Reads* row at the bottom of the card says how many names the passage is handed and opens the dialog that declares them; each grows a port, and the passage’s Python reads it by that name. The names belong to the card, not the passage, so one passage can be read by two cards that fill it differently. A choice has a *Times* box beside its name: blank for as often as the reader likes, or a number, shown as `×3` at the end of its row. Every wired choice also grows a `bool` input: wired, the choice is offered only while it is true. A choice that has run out, or whose answer is false, stays on screen greyed out and unpressable. **On the card:** **Passage** — Which passage file the card reads. New makes one beside the board; **Soundscape** — *None*, *Silence*, or one of the project’s soundscapes. **Takes** | Port | Carries | Meaning | | ---- | ------- | ------------------------- | | `in` | `flow` | The way in, at the title. | **Answers** | Port | Carries | Meaning | | ---------- | ------- | --------------------------------------------------------------------------------------------------------------------- | | `choice_0` | `flow` | One per choice, in order; always one spare at the bottom. The words the reader sees are set on the row, per language. | **Read more:** [Passages and languages](/explanation/passages-and-translations/), [Soundscapes and sounds](/explanation/soundscapes-and-sounds/) ### Output [Section titled “Output”](#output) ![The Output card](/_astro/display-light.De6JSAlI_18GaKp.webp) ![The Output card](/_astro/display-dark.xpVcV-QG_Z1qXO2q.webp) Shows values the story worked out. Double-click to say what it shows — a name and a type per row — and it grows a port per name. Wire a Script node’s outputs into them. It is a slide with one way on, so it reads as a passage rather than as a debugger. Plus one **input per name** in *Shows*, typed as the shape says. **On the card:** **Shows**. **Takes** | Port | Carries | Meaning | | ------ | ------- | ----------- | | `exec` | `flow` | The way in. | **Answers** | Port | Carries | Meaning | | ----------- | ------- | ------------------------------- | | `completed` | `flow` | Taken when the reader moves on. | **Read more:** [Python scripting](/explanation/python-in-a-story/) ### Input [Section titled “Input”](#input) ![The Input card](/_astro/input-light.CauA7jfJ_Q90Rl.webp) ![The Input card](/_astro/input-dark.DyQlqKRg_253BUA.webp) Asks the reader for values. Double-click to say what to ask for — a name and a type per row — and it grows a port per name, carrying what was typed. A `bool` is a switch, a `number` a number field, a list a line of comma-separated values. It is a form with one way on. Say what you are asking, and why, in the passage before it. Plus one **output per name** in *Asks*, typed as the shape says, carrying what the reader typed. **On the card:** **Asks**. **Takes** | Port | Carries | Meaning | | ------ | ------- | ----------- | | `exec` | `flow` | The way in. | **Answers** | Port | Carries | Meaning | | ----------- | ------- | --------------------------------------- | | `completed` | `flow` | Taken when the reader submits the form. | **Read more:** [Playing a campaign](/reference/campaign-window/) ### Play sound [Section titled “Play sound”](#play-sound) ![The Play sound card](/_astro/flow.sound-light.1p12WAYu_Zkn7rg.webp) ![The Play sound card](/_astro/flow.sound-dark.BYWMFpDy_1wEMaG.webp) Plays one sound, once, and carries straight on. The file is any audio in the project — drop one into the folder and it is in the list. Volume runs from 0 to 1 and speed from a little above 0, 1 being the file’s own. Nothing waits for it: `completed` fires the moment the sound is cued, so a door slams and the next passage is already showing. For music under a scene, set a soundscape on the passage instead. **On the card:** **Sound** — Any audio file the project holds; **Volume**, default `1.0`; **Speed**, default `1.0`. **Takes** | Port | Carries | Meaning | | ------ | ------- | ----------- | | `exec` | `flow` | The way in. | **Answers** | Port | Carries | Meaning | | ----------- | ------- | ----------------------------------------------------- | | `completed` | `flow` | Taken the moment the sound is cued, not when it ends. | **Read more:** [Soundscapes and sounds](/explanation/soundscapes-and-sounds/) ## Math [Section titled “Math”](#math) ### Operator [Section titled “Operator”](#operator) ![The Operator card](/_astro/math.operator-light.BSJUIJW6_1jbnYG.webp) ![The Operator card](/_astro/math.operator-dark.D134-WYa_ZV6hmI.webp) Adds, subtracts, multiplies or divides the two numbers wired into it. Both sides only take numbers. Dividing by zero gives an infinity rather than stopping the board — a story that fell over because a wire happened to carry a zero would be worse than one that carries an odd number. **On the card:** **Operation**, default `add` — Add, subtract, multiply or divide. **Takes** | Port | Carries | Meaning | | ------ | -------- | ---------------------- | | `exec` | `flow` | The way in. | | `a` | `number` | The left-hand number. | | `b` | `number` | The right-hand number. | **Answers** | Port | Carries | Meaning | | ----------- | -------- | ----------------------------------------------- | | `completed` | `flow` | Taken when the node has run. | | `result` | `number` | The answer; dividing by zero gives an infinity. | ## Logic [Section titled “Logic”](#logic) ### Comparator [Section titled “Comparator”](#comparator) ![The Comparator card](/_astro/logic.comparator-light.C-FdgQm2_Z2c3wvP.webp) ![The Comparator card](/_astro/logic.comparator-dark.xmJR7qTQ_1hHPMu.webp) Compares the two values wired into it and answers true or false. Neither side declares a type, so anything may be plugged in. Equality reads both as text — 42 and “42” are equal here for the same reason they are one case on a Switch. Ordering is only answered for two numbers or two pieces of text; anything else compares as false. Its result is what an If node is waiting for. **On the card:** **Compare**, default `equal` — Equal, not equal, less, greater, and the two or-equals. **Takes** | Port | Carries | Meaning | | ------ | ------- | --------------------- | | `exec` | `flow` | The way in. | | `a` | `any` | The left-hand value. | | `b` | `any` | The right-hand value. | **Answers** | Port | Carries | Meaning | | ----------- | ------- | ---------------------------- | | `completed` | `flow` | Taken when the node has run. | | `result` | `bool` | True or false, for an If. | ## Lists [Section titled “Lists”](#lists) These four cards read a list or a piece of text, and read both the same way. A list is itself; any other value is its text, one character at a time. The same card works on `["bread", "rope"]` and on `"hi"`, and the result is the kind that went in: appending `!` to `hi` gives `hi!`. * An index counts from zero, and from the end when it is negative: `-1` is the last item. * An index past either end does not stop the story; it is an ordinary thing to reach inside a loop. * A map has no order for an index to mean. Insert and Remove hand it back untouched, At answers nothing and Size counts none. The [Maps](#maps) cards read maps. ### Insert [Section titled “Insert”](#insert) ![The Insert card](/_astro/list.insert-light.BdNxU7pX_1XzG1V.webp) ![The Insert card](/_astro/list.insert-dark.QcncjYZa_Z5gpYW.webp) Puts something into a list, and answers the whole list. Into a list, another list is added item by item and anything else goes in whole — appending `5` to `[3]` gives `[3, 5]`, and appending `[1, 2]` gives `[3, 1, 2]`. Into a piece of text, whatever you give it is read as text and spliced in, one character at a time: appending `!` to `hi` gives `hi!`. What comes out is the kind that went in. The index counts from the end when it is negative, so -1 is the last, and past either end it simply goes on the end — except under **Replace at**, which leaves the list alone rather than quietly writing somewhere else. **On the card:** **Placement**, default `append` — prepend, append, insert at or replace at. The `index` row is dimmed under prepend and append, which do not read it. **Takes** | Port | Carries | Meaning | | --------- | -------- | ------------------------------------------ | | `exec` | `flow` | The way in. | | `subject` | `any` | The list, or the text. | | `index` | `number` | Where, under *insert at* and *replace at*. | | `item` | `any` | What goes in. | **Answers** | Port | Carries | Meaning | | ----------- | ------- | ------------------------------------------ | | `completed` | `flow` | Taken when the node has run. | | `result` | `any` | The whole thing, as the kind that went in. | ### At [Section titled “At”](#at) ![The At card](/_astro/list.at-light.Ddc5bH-2_Z2fpxJ4.webp) ![The At card](/_astro/list.at-dark.Bq5f9L_X_2dM0vD.webp) Answers one item of a list, or one character of a piece of text. Counting starts at zero, and a negative index counts from the end — -1 is the last item. Past either end it answers nothing rather than stopping the story, because walking off the end of a list is an ordinary thing to do inside a loop. **Takes** | Port | Carries | Meaning | | --------- | -------- | ---------------------------------------------------- | | `exec` | `flow` | The way in. | | `subject` | `any` | The list, or the text. | | `index` | `number` | Which one, from zero, or from the end when negative. | **Answers** | Port | Carries | Meaning | | ----------- | ------- | ------------------------------------- | | `completed` | `flow` | Taken when the node has run. | | `item` | `any` | The item, or nothing past either end. | ### Remove [Section titled “Remove”](#remove) ![The Remove card](/_astro/list.remove-light.CVHovXEh_2ogymx.webp) ![The Remove card](/_astro/list.remove-dark.TYhPTXxr_1FQbMA.webp) Takes one item out of a list, and answers the whole list. Point it at a piece of text instead and it takes one character out, the same way. What comes out is the kind that went in. Counting is At’s: from zero, and from the end when the index is negative — -1 is the last item. Past either end it leaves the list alone rather than quietly taking the nearest one instead. Its own card rather than a fifth placement on **Insert**, because putting something in and taking something out are two different things to want and one of them should not be hidden inside a dropdown on the other. **Takes** | Port | Carries | Meaning | | --------- | -------- | ---------------------------------------------------- | | `exec` | `flow` | The way in. | | `subject` | `any` | The list, or the text. | | `index` | `number` | Which one, from zero, or from the end when negative. | **Answers** | Port | Carries | Meaning | | ----------- | ------- | ------------------------------------------ | | `completed` | `flow` | Taken when the node has run. | | `result` | `any` | The whole thing, as the kind that went in. | ### Size [Section titled “Size”](#size) ![The Size card](/_astro/list.size-light.B0eyeeMF_ZHreuC.webp) ![The Size card](/_astro/list.size-dark.BcA2KSqf_2eSVoh.webp) How many items a list has, and whether it has none. A piece of text is counted in characters, and an emoji counts as one. `length` is what a For node’s ceiling wants when you mean to walk a list item by item; `empty` is what an If node is waiting for. **Takes** | Port | Carries | Meaning | | --------- | ------- | ---------------------- | | `exec` | `flow` | The way in. | | `subject` | `any` | The list, or the text. | **Answers** | Port | Carries | Meaning | | ----------- | -------- | ---------------------------- | | `completed` | `flow` | Taken when the node has run. | | `length` | `number` | How many. | | `empty` | `bool` | Whether there are none. | ## Maps [Section titled “Maps”](#maps) These seven cards read a map: a set of names, each holding a value. A map is itself; any other value is read as an empty map. * A name is always text. * No card holds a map as a literal. A **Set** with nothing wired to its subject returns a map of one pair, and a chain of Set cards builds a larger one. * A name that is not present does not stop the story. **Get** answers nothing and reports it on `found`; **Remove** returns the map unchanged. ### Get [Section titled “Get”](#get) ![The Get card](/_astro/map.get-light.Gar23OjN_1SNfWT.webp) ![The Get card](/_astro/map.get-dark.Bb-twfj0_Z2k3bDR.webp) Answers what a map holds under one name. A name nothing is under answers nothing rather than stopping the story, because reaching for a name that may not be there is an ordinary thing to do. `found` is how you tell a name that is not there from a name that is there holding nothing: the two read the same on `value`, and only one of them means the story never wrote it. **Takes** | Port | Carries | Meaning | | --------- | -------- | ----------------------- | | `exec` | `flow` | The way in. | | `subject` | `any` | The map. | | `key` | `string` | The name to look under. | **Answers** | Port | Carries | Meaning | | ----------- | ------- | ------------------------------------- | | `completed` | `flow` | Taken when the node has run. | | `value` | `any` | What is under it, or nothing. | | `found` | `bool` | Whether anything was under it at all. | ### Set [Section titled “Set”](#set) ![The Set card](/_astro/map.set-light.CHoYNgR8_ZYryQ1.webp) ![The Set card](/_astro/map.set-dark.CTlivbVW_ZLCmY2.webp) Writes one name into a map, and answers the whole map. Point it at nothing and it answers a map of one pair, which is how a map is made: there is no other card that builds one from scratch. A name already there is written over. Nothing is changed in place — what comes out is the map to carry on with, which is what makes a chain of these the way a map is built. **Takes** | Port | Carries | Meaning | | --------- | -------- | --------------------------------- | | `exec` | `flow` | The way in. | | `subject` | `any` | The map, or nothing to start one. | | `key` | `string` | The name to write. | | `value` | `any` | What goes under it. | **Answers** | Port | Carries | Meaning | | ----------- | ------- | ---------------------------- | | `completed` | `flow` | Taken when the node has run. | | `result` | `any` | The whole map. | ### Remove [Section titled “Remove”](#remove-1) ![The Remove card](/_astro/map.remove-light.CWOEiYwu_Zt6CfE.webp) ![The Remove card](/_astro/map.remove-dark.BS9A5X19_154nra.webp) Takes one name out of a map, and answers the whole map. A name that is not there leaves the map alone rather than stopping the story. **Takes** | Port | Carries | Meaning | | --------- | -------- | --------------------- | | `exec` | `flow` | The way in. | | `subject` | `any` | The map. | | `key` | `string` | The name to take out. | **Answers** | Port | Carries | Meaning | | ----------- | ------- | ---------------------------- | | `completed` | `flow` | Taken when the node has run. | | `result` | `any` | The whole map. | ### Keys [Section titled “Keys”](#keys) ![The Keys card](/_astro/map.keys-light.XqR6v41M_Z1bluo4.webp) ![The Keys card](/_astro/map.keys-dark.B9PoA3Yr_Z1eew9V.webp) Every name a map holds something under, as a list. In name order, which is the order a map reads as text in — so what an Output node shows and what this answers are never in two different orders. This and a **Size** node are how a map is walked: the length is a For node’s ceiling, an **At** node picks a name out of the list, and a **Get** node reads what is under it. **Takes** | Port | Carries | Meaning | | --------- | ------- | ----------- | | `exec` | `flow` | The way in. | | `subject` | `any` | The map. | **Answers** | Port | Carries | Meaning | | ----------- | ------------ | ---------------------------- | | `completed` | `flow` | Taken when the node has run. | | `keys` | `stringList` | The names, in name order. | ### Values [Section titled “Values”](#values) ![The Values card](/_astro/map.values-light.BrWNATGC_15gfRW.webp) ![The Values card](/_astro/map.values-dark.jegVnKlZ_Z37NCA.webp) Everything a map holds, as a list. In the same order a **Keys** node answers, so the two line up: the first of these is under the first of those. **Takes** | Port | Carries | Meaning | | --------- | ------- | ----------- | | `exec` | `flow` | The way in. | | `subject` | `any` | The map. | **Answers** | Port | Carries | Meaning | | ----------- | ------- | ------------------------------------------------- | | `completed` | `flow` | Taken when the node has run. | | `values` | `any` | What is held, in the order the names come out in. | ### Merge [Section titled “Merge”](#merge) ![The Merge card](/_astro/map.merge-light.pvOrRFl2_PrL62.webp) ![The Merge card](/_astro/map.merge-dark.GtzDuTb0_2w6Cgo.webp) Two maps into one. `other` wins: a name in both comes out holding what `other` had. Either side may be nothing, which leaves the other as it was. **Takes** | Port | Carries | Meaning | | --------- | ------- | ----------------------- | | `exec` | `flow` | The way in. | | `subject` | `any` | The first map. | | `other` | `any` | The second, which wins. | **Answers** | Port | Carries | Meaning | | ----------- | ------- | ---------------------------- | | `completed` | `flow` | Taken when the node has run. | | `result` | `any` | The two together. | ### Size [Section titled “Size”](#size-1) ![The Size card](/_astro/map.size-light.nrWyZRIb_1NXkhI.webp) ![The Size card](/_astro/map.size-dark.BnKiO4aC_Z117ddt.webp) How many names a map holds, and whether it holds none. `length` is what a For node’s ceiling wants when you mean to walk a map name by name; `empty` is what an If node is waiting for. **Takes** | Port | Carries | Meaning | | --------- | ------- | ----------- | | `exec` | `flow` | The way in. | | `subject` | `any` | The map. | **Answers** | Port | Carries | Meaning | | ----------- | -------- | ---------------------------- | | `completed` | `flow` | Taken when the node has run. | | `length` | `number` | How many names. | | `empty` | `bool` | Whether there are none. | ## Entities [Section titled “Entities”](#entities) ### Component [Section titled “Component”](#component) ![The Component card](/_astro/component-light.Dc_midKX_1OwUrW.webp) ![The Component card](/_astro/component-dark.BcR9AMvL_Z2dGiEe.webp) Reads a component into the board — a character, an item, a room. Drag one out of the file tree and drop it on the canvas, or pick it here. Every attribute gets its own output, typed the way the component declares it, so a number joins a number and text joins text. `component` carries the whole thing as one value, for passing along rather than picking apart. It has two modes. In **Component** it is a window onto the file: outputs, and nothing else. In **Entity** it stops being a window and makes the story a *thing*: one entity, kept for the length of the campaign under the name you give it. Every attribute grows an input, so what happens earlier in the story can set it. Two nodes with two names are two independent swords; a node on a later board with the same name is the same sword. Either way it reads the file and never writes to it. The component itself is edited where it lives. Plus one **output per attribute**, typed as the component declares it — and in Entity mode one **input per attribute** as well. **On the card:** **Component**; **Entity mode**, default `false` — Component off, Entity on; **Name** — The entity’s name, in Entity mode. **Answers** | Port | Carries | Meaning | | ----------- | ----------- | ------------------------------------------------------------------------------ | | `completed` | `flow` | Taken when the node has run. In Entity mode this is when the entity exists. | | `component` | `component` | The whole component as one value, for passing along rather than picking apart. | **Read more:** [Components and entities](/explanation/components-and-entities/) ### Spawn [Section titled “Spawn”](#spawn) ![The Spawn card](/_astro/component.spawn-light.D2RVE2uq_11O5Ak.webp) ![The Spawn card](/_astro/component.spawn-dark.ZTQsQz7r_Zqps69.webp) Makes many entities of one component at once — six goblins, a hand of cards, a shelf of potions. Pick the component, say how many, and choose how they are told apart: numbers or letters, and what goes between. `Goblin` with numbers and a dash makes Goblin-1, Goblin-2, and so on. `entities` carries their names as a list of text. That is how anything else reaches them: a Script node can walk it and give each one its own hit points, and a Component node set to Entity with that name is that goblin. The names it makes depend only on what is on the node, so coming back to it finds the goblins it already made rather than six more. A second wave wants a different name, which is what the `name` input is for. It runs when the flow reaches it, so anything reading `entities` has to be wired after `completed` — a Script hanging off it with nothing flowing in reads nothing. **On the card:** **Component**; **Name**; **How many**, default `1`; **Numbering**, default `numbers` — Numbers or letters; **Separator**, default `dash` — What goes between the stem and the number. **Takes** | Port | Carries | Meaning | | ------- | -------- | ------------------------------------------------------- | | `exec` | `flow` | The way in. | | `name` | `string` | The stem the names are built on. A wire beats the card. | | `count` | `number` | How many to make. A wire beats the card. | **Answers** | Port | Carries | Meaning | | ----------- | ------------ | ------------------------------- | | `completed` | `flow` | Taken when the node has run. | | `entities` | `stringList` | Their names, as a list of text. | **Read more:** [Components and entities](/explanation/components-and-entities/) ### Delete Entity [Section titled “Delete Entity”](#delete-entity) ![The Delete Entity card](/_astro/component.delete-light.SDOSKO5P_ZTbM2W.webp) ![The Delete Entity card](/_astro/component.delete-dark.2QaVbYpT_TgvTQ.webp) Forgets one entity: the goblin is dead, the potion is drunk, the sword was left behind. Name it on the card, or wire a name in — a name from upstream wins, which is how a loop clears out everything a Spawn node made. It removes the entity from the campaign and nothing from the project: the component file it was made from is untouched, and a Component node set to Entity naming it later simply makes a fresh one from that file. A name that names nothing is not an error — it removes nothing and the story carries on. Ask an Entity Exists node first if that matters. **On the card:** **Name**. **Takes** | Port | Carries | Meaning | | ------ | -------- | ----------------------------------------------------------------------------------------- | | `exec` | `flow` | The way in. | | `name` | `string` | Which entity to forget. A wire beats the card; a name nothing answers to removes nothing. | **Answers** | Port | Carries | Meaning | | ----------- | ------- | ---------------------------- | | `completed` | `flow` | Taken when the node has run. | **Read more:** [Components and entities](/explanation/components-and-entities/) ### Entity Exists [Section titled “Entity Exists”](#entity-exists) ![The Entity Exists card](/_astro/component.exists-light.DTFXmv0j_Z18Kpb7.webp) ![The Entity Exists card](/_astro/component.exists-dark.DlDlKz2Z_Z2uqmGa.webp) Asks whether the campaign has an entity under a name, and answers true or false. Name it on the card, or wire a name in — a name from upstream wins. It asks about entities, not about components. False means the story has not made this one, or has deleted it; the component file may be sitting there all the same, and a Component node in Component mode would read it happily. It runs when the flow reaches it, so anything reading `exists` has to be wired after `completed` — an If node hanging off it with nothing flowing in reads nothing. **On the card:** **Name**. **Takes** | Port | Carries | Meaning | | ------ | -------- | ------------------------------------------------- | | `exec` | `flow` | The way in. | | `name` | `string` | Which entity to ask about. A wire beats the card. | **Answers** | Port | Carries | Meaning | | ----------- | ------- | --------------------------------------------- | | `completed` | `flow` | Taken when the node has run. | | `exists` | `bool` | Whether the campaign has one under that name. | **Read more:** [Components and entities](/explanation/components-and-entities/) ### Entity Names [Section titled “Entity Names”](#entity-names) ![The Entity Names card](/_astro/component.names-light.B_fpF9xc_Z1KdcIY.webp) ![The Entity Names card](/_astro/component.names-dark.CrXJqaX3_Q86wX.webp) Answers every entity the story has made, as a list of names in alphabetical order. Pick a component to count only those — the goblins, not the swords — or leave it empty for all of them. A name wired into `of` beats the pick, so the story can decide on the way past. This is the roster as it stands, not the wave a Spawn node made: anything deleted since is gone from it, and anything made on another board is in it. Wire `names` into a `For` node with a `List at` to do something to each one — the same shape a Spawn node’s `entities` output takes. It runs when the flow reaches it, so anything reading `names` has to be wired after `completed`. **On the card:** **Of**. **Takes** | Port | Carries | Meaning | | ------ | -------- | ------------------------------------------------------------------------------------------------- | | `exec` | `flow` | The way in. | | `of` | `string` | Which component to count, by name. A wire beats the card, and nothing at all counts every entity. | **Answers** | Port | Carries | Meaning | | ----------- | ------------ | ---------------------------------- | | `completed` | `flow` | Taken when the node has run. | | `names` | `stringList` | Every name, in alphabetical order. | **Read more:** [Components and entities](/explanation/components-and-entities/) ## Value [Section titled “Value”](#value) ### Script [Section titled “Script”](#script) ![The Script card](/_astro/script-light.B68i76i1_Z1jfPOv.webp) ![The Script card](/_astro/script-dark.B8nORb_u_Zkrfdx.webp) Runs a Python file, and publishes what it assigned. Pick the script, then say what it reads and what it writes — the same rows of name and type every other card’s ports are written in, behind the *Inputs* and *Outputs* buttons — and the node grows a port per name. Inputs arrive in Python as globals of that name; assign the output names and they come back out. An input may declare a default, which stands in when nothing is wired to it, and an output a default used only where the program never set that name. An input gets the type it declared: an `int` input given 3.7 reads 3. Double-click to write the script, and to try it in the console beside it. The file is a real `.py` in the project, so a snippet can be shared by as many nodes as want it. It shows nothing on its own. Wire an Output node to its outputs to put them on screen. Plus one **input per name** in *Inputs* and one **output per name** in *Outputs*, typed as the shape says. **On the card:** **Script**; **Inputs**; **Outputs**; **Test inputs** — Values to run the script with from the editor, without a board. **Takes** | Port | Carries | Meaning | | ------ | ------- | ----------- | | `exec` | `flow` | The way in. | **Answers** | Port | Carries | Meaning | | ----------- | ------- | ------------------------------ | | `completed` | `flow` | Taken when the script has run. | **Read more:** [Python scripting](/explanation/python-in-a-story/) · [The Python API](/reference/python/) ### Get variable [Section titled “Get variable”](#get-variable) ![The Get variable card](/_astro/cvar.get-light.1rw3oryy_Z2fAUBE.webp) ![The Get variable card](/_astro/cvar.get-dark.CEJw8_pN_Z27YQux.webp) Reads a console variable onto `value`. Console variables are declared on the project’s variables page — a name, a type and a default — and are set anywhere and read anywhere, across boards and saves, with no wire between. This is the reading half; a Set variable node or a Script node’s cvar\_set is the writing half. It takes the flow rather than being pulled, so that it always answers what the variable holds *now*: put it in the story after whatever sets the variable, and wire `value` onwards from there. **On the card:** **Variable**. **Takes** | Port | Carries | Meaning | | ------ | ------- | ----------- | | `exec` | `flow` | The way in. | **Answers** | Port | Carries | Meaning | | ----------- | ------- | ------------------------------------------------------ | | `completed` | `flow` | Taken when the node has run. | | `value` | `any` | What the variable holds now, typed as the variable is. | **Read more:** [Console variables](/explanation/console-variables/) · [The twenty-four types and the coercion rule](/reference/python/console-variables/#types) ### Set variable [Section titled “Set variable”](#set-variable) ![The Set variable card](/_astro/cvar.set-light.C9Vfef4N_ap1bu.webp) ![The Set variable card](/_astro/cvar.set-dark.CkL1iiS__Z1vHwQi.webp) Sets a console variable. Wire a value into `value`, or type one on the card; the wire wins when both are there. The value has to be the variable’s type — a whole number for an int, true or false for a bool — and one that is not ends the run with a message naming this node, rather than being bent to fit. `result` is what the variable holds after the write. **On the card:** **Variable**; **Value**. **Takes** | Port | Carries | Meaning | | ------- | ------- | ---------------------------------------------------------------------------------------------- | | `exec` | `flow` | The way in. | | `value` | `any` | What to write. A wire beats the card; a value of the wrong type ends the run naming this node. | **Answers** | Port | Carries | Meaning | | ----------- | ------- | ---------------------------------------- | | `completed` | `flow` | Taken when the node has run. | | `result` | `any` | What the variable holds after the write. | **Read more:** [Console variables](/explanation/console-variables/) · [The twenty-four types and the coercion rule](/reference/python/console-variables/#types) ### Random number [Section titled “Random number”](#random-number) ![The Random number card](/_astro/random.number-light.DhmdU_IN_2gmAMm.webp) ![The Random number card](/_astro/random.number-dark.BQfNJdDA_1Epybx.webp) A number nobody chose. `% X` is how many numbers are in the range and `+ Y` is where it starts, so a six-sided die is 6 and 1. Turn on `decimals` and it answers with a fraction from the same range. It rolls again every time the flow reaches it. **On the card:** **% X**, default `100`; **+ Y**, default `0`; **Decimals**, default `false` — Answer with a fraction from the same range. **Takes** | Port | Carries | Meaning | | ------ | ------- | ----------- | | `exec` | `flow` | The way in. | **Answers** | Port | Carries | Meaning | | ----------- | ------- | ------------------------------------------- | | `completed` | `flow` | Taken when the node has run. | | `value` | `any` | A fresh number every time the flow arrives. | ### Random string [Section titled “Random string”](#random-string) ![The Random string card](/_astro/random.string-light.DoHm6ZLL_Z1kRoNN.webp) ![The Random string card](/_astro/random.string-dark.B1Yu7KgF_Z2ncFPv.webp) A string nobody chose. The pattern is tried against every printable character on its own, and the ones it matches are what the string is drawn from — `[A-Za-z0-9]` for alphanumeric, `[0-9a-f]` for hex. An empty pattern means all of them. A pattern that matches nothing gives an empty string, which is the quieter answer than a failure mid-story. **On the card:** **Pattern**, default `[A-Za-z0-9]`; **Length**, default `8`. **Takes** | Port | Carries | Meaning | | ------ | ------- | ----------- | | `exec` | `flow` | The way in. | **Answers** | Port | Carries | Meaning | | ----------- | ------- | ------------------------------------------- | | `completed` | `flow` | Taken when the node has run. | | `value` | `any` | A fresh string every time the flow arrives. | **Read more:** [Regular expression character classes](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Guide/Regular_expressions/Character_classes) ### number [Section titled “number”](#number) ![The number card](/_astro/value.number-light.DE0vwn6I_hnx60.webp) ![The number card](/_astro/value.number-dark._JWpkUuq_Z1HA6CE.webp) Holds a `number` for the rest of the board to read. Wire `value` into whatever needs it. Its `exec` lets it sit in the story rather than only hang off it; left unwired it simply holds its value. Its data port carries the tag `number`, so the editor will only let it join something that expects the same thing — or something that expects anything at all. **On the card:** **number**, default `0` — The number. **Takes** | Port | Carries | Meaning | | ------ | ------- | ----------- | | `exec` | `flow` | The way in. | **Answers** | Port | Carries | Meaning | | ----------- | -------- | ----------------------------- | | `completed` | `flow` | Taken when the node has run. | | `value` | `number` | The number typed on the card. | ### bool [Section titled “bool”](#bool) ![The bool card](/_astro/value.bool-light.CBh3K1kS_ZFxj0i.webp) ![The bool card](/_astro/value.bool-dark.DUbttacu_1J5GsS.webp) Holds a `bool` for the rest of the board to read. Wire `value` into whatever needs it. Its `exec` lets it sit in the story rather than only hang off it; left unwired it simply holds its value. Its data port carries the tag `bool`, so the editor will only let it join something that expects the same thing — or something that expects anything at all. **On the card:** **bool**, default `false` — The truth. **Takes** | Port | Carries | Meaning | | ------ | ------- | ----------- | | `exec` | `flow` | The way in. | **Answers** | Port | Carries | Meaning | | ----------- | ------- | ---------------------------- | | `completed` | `flow` | Taken when the node has run. | | `value` | `bool` | The truth typed on the card. | ### string [Section titled “string”](#string) ![The string card](/_astro/value.string-light.CGdBIkxU_Ot4cw.webp) ![The string card](/_astro/value.string-dark.DB27F1mi_Z2jGmfE.webp) Holds a `string` for the rest of the board to read. Wire `value` into whatever needs it. Its `exec` lets it sit in the story rather than only hang off it; left unwired it simply holds its value. Its data port carries the tag `string`, so the editor will only let it join something that expects the same thing — or something that expects anything at all. **On the card:** **string** — The text. **Takes** | Port | Carries | Meaning | | ------ | ------- | ----------- | | `exec` | `flow` | The way in. | **Answers** | Port | Carries | Meaning | | ----------- | -------- | ---------------------------- | | `completed` | `flow` | Taken when the node has run. | | `value` | `string` | The text typed on the card. | ### list\<number> [Section titled “list\<number>”](#listnumber) ![The list\<number> card](/_astro/value.numberList-light.CrFdFYah_Z1JOAIF.webp) ![The list\<number> card](/_astro/value.numberList-dark.0WphW7ID_2kVhNV.webp) Holds a `list<number>` for the rest of the board to read. Wire `value` into whatever needs it. Its `exec` lets it sit in the story rather than only hang off it; left unwired it simply holds its value. Its data port carries the tag `numberList`, so the editor will only let it join something that expects the same thing — or something that expects anything at all. **On the card:** **list\<number>** — The list, as `list<number>`. **Takes** | Port | Carries | Meaning | | ------ | ------- | ----------- | | `exec` | `flow` | The way in. | **Answers** | Port | Carries | Meaning | | ----------- | ------------ | ---------------------------- | | `completed` | `flow` | Taken when the node has run. | | `value` | `numberList` | The list typed on the card. | ### list\<bool> [Section titled “list\<bool>”](#listbool) ![The list\<bool> card](/_astro/value.boolList-light.krzGU-fl_Z2oAxzh.webp) ![The list\<bool> card](/_astro/value.boolList-dark.BPlMDV8u_Z2tKV72.webp) Holds a `list<bool>` for the rest of the board to read. Wire `value` into whatever needs it. Its `exec` lets it sit in the story rather than only hang off it; left unwired it simply holds its value. Its data port carries the tag `boolList`, so the editor will only let it join something that expects the same thing — or something that expects anything at all. **On the card:** **list\<bool>** — The list, as `list<bool>`. **Takes** | Port | Carries | Meaning | | ------ | ------- | ----------- | | `exec` | `flow` | The way in. | **Answers** | Port | Carries | Meaning | | ----------- | ---------- | ---------------------------- | | `completed` | `flow` | Taken when the node has run. | | `value` | `boolList` | The list typed on the card. | ### list\<string> [Section titled “list\<string>”](#liststring) ![The list\<string> card](/_astro/value.stringList-light.CplPkOuM_zSwqy.webp) ![The list\<string> card](/_astro/value.stringList-dark.GnyuMt3j_tjfvB.webp) Holds a `list<string>` for the rest of the board to read. Wire `value` into whatever needs it. Its `exec` lets it sit in the story rather than only hang off it; left unwired it simply holds its value. Its data port carries the tag `stringList`, so the editor will only let it join something that expects the same thing — or something that expects anything at all. **On the card:** **list\<string>** — The list, as `list<string>`. **Takes** | Port | Carries | Meaning | | ------ | ------- | ----------- | | `exec` | `flow` | The way in. | **Answers** | Port | Carries | Meaning | | ----------- | ------------ | ---------------------------- | | `completed` | `flow` | Taken when the node has run. | | `value` | `stringList` | The list typed on the card. | ## World [Section titled “World”](#world) What the story reads of the reader’s world: where they are and what time it is, each only when the reader allows it. A story with one of these on a board asks before it begins. Each takes the flow and has an `unavailable` way out for a read that got nothing; left unwired, the story carries on by `completed` or `no`. Python reads the same things — see [The world outside the story](/reference/python/the-world/). ### Get location [Section titled “Get location”](#get-location) ![The Get location card](/_astro/location.get-light.CgZg1VHt_OmlFz.webp) ![The Get location card](/_astro/location.get-dark.XUKTQHCE_ZfQAYR.webp) Reads where the reader is, if they allowed it. A story that has one of these asks the reader before it begins, and the answer is kept under Settings → Permissions. What comes out is what `location_get()` hands a script: a real reading has a latitude, a longitude and how many metres it may be off, and no label — nothing is looked up to name the place. A stand-in the reader typed as a place name has the `label` and nothing else. Without a reading the flow leaves by `unavailable`, or by `completed` with nothing on the outputs when that is not wired. Either way the play window and Messages say what happened. **Takes** | Port | Carries | Meaning | | ------ | ------- | ------------------------------------------- | | `exec` | `flow` | The way in. It reads when the flow arrives. | **Answers** | Port | Carries | Meaning | | ------------- | -------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `completed` | `flow` | Taken with a reading, a stand-in included. | | `unavailable` | `flow` | Taken when there was no reading: the reader did not allow it and gave no stand-in, or allowed it and there was nothing to give. Unwired, the story carries on the way it would have otherwise. | | `label` | `string` | The place’s name, from a stand-in typed as one. | | `latitude` | `number` | Degrees north, or south when negative. | | `longitude` | `number` | Degrees east, or west when negative. | | `accuracy` | `number` | How many metres the reading may be off. | **Read more:** [The world outside the story](/reference/python/the-world/) ### Near [Section titled “Near”](#near) ![The Near card](/_astro/location.near-light.BBChd8DK_2dSU08.webp) ![The Near card](/_astro/location.near-dark.MAnVyg3O_1zhX6t.webp) Sends the flow one way when the reader is near a place, and another when they are not. Type the place as a latitude and a longitude — `41.9028, 12.4964` — which is what a map gives you when you copy a point, or wire them in; a wire beats the card. *Within* is how close counts, in metres. `distance` is how far the reader was, measured along the ground. A reading with no coordinates is no reading here: a stand-in the reader typed as a town has nothing to measure from, and leaves by `unavailable` — or by `no` when that is not wired. **On the card:** **Place** — A latitude and a longitude, as `41.9028, 12.4964`; **Within**, default `1000.0` — How close counts, in metres. **Takes** | Port | Carries | Meaning | | ----------- | -------- | --------------------------------------------- | | `exec` | `flow` | The way in. It reads when the flow arrives. | | `latitude` | `number` | The place’s latitude. A wire beats the card. | | `longitude` | `number` | The place’s longitude. A wire beats the card. | **Answers** | Port | Carries | Meaning | | ------------- | -------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `yes` | `flow` | Taken when the reader is within the distance. | | `no` | `flow` | Taken when they are further away. | | `unavailable` | `flow` | Taken when there was no reading: the reader did not allow it and gave no stand-in, or allowed it and there was nothing to give. Unwired, the story carries on the way it would have otherwise. | | `distance` | `number` | How far the reader was, in metres. | **Read more:** [The world outside the story](/reference/python/the-world/) ### Get date & time [Section titled “Get date & time”](#get-date--time) ![The Get date & time card](/_astro/clock.get-light.Rp41Kv0Y_Zfx6JM.webp) ![The Get date & time card](/_astro/clock.get-dark.BBUzBU19_Z1Ivh01.webp) Reads what time it is where the reader is, if they allowed it. The editor allows the clock unless the reader said otherwise under Settings → Permissions. Every number is on the reader’s own wall clock, and `weekday` counts from Monday, which is 1. `iso` is the whole moment as one piece of text, with its offset from UTC — what `datetime.now().isoformat()` gives a script. Without a reading the flow leaves by `unavailable`, or by `completed` with nothing on the outputs when that is not wired. **Takes** | Port | Carries | Meaning | | ------ | ------- | ------------------------------------------- | | `exec` | `flow` | The way in. It reads when the flow arrives. | **Answers** | Port | Carries | Meaning | | ------------- | -------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `completed` | `flow` | Taken with a reading, a stand-in included. | | `unavailable` | `flow` | Taken when there was no reading: the reader did not allow it and gave no stand-in, or allowed it and there was nothing to give. Unwired, the story carries on the way it would have otherwise. | | `iso` | `string` | The moment, as `2026-10-19T18:30:00+02:00`. | | `year` | `number` | The year. | | `month` | `number` | The month, January 1. | | `day` | `number` | The day of the month. | | `hour` | `number` | The hour, 0 to 23. | | `minute` | `number` | The minute. | | `weekday` | `number` | The day of the week, Monday 1, Sunday 7. | **Read more:** [The world outside the story](/reference/python/the-world/) ### Time branch [Section titled “Time branch”](#time-branch) ![The Time branch card](/_astro/clock.branch-light.DTM8FExP_Z11cm1d.webp) ![The Time branch card](/_astro/clock.branch-dark.C5dbuORG_Z1S65yx.webp) Sends the flow one way at certain hours or on certain days, and another otherwise. Type a window — *From* `18:00` *until* `22:00` — and pick the days it holds on. The start is in it and the end is not, so 18:00 until 22:00 is over at ten. A window whose start is later than its end runs past midnight; leave a side empty to run from midnight or to it, both for the whole day. No days picked is every day. All of it is the reader’s own wall clock. Without a reading the flow leaves by `unavailable`, or by `no` when that is not wired. **On the card:** **From** — When the window opens, as `18:00`. Empty is midnight; **Until** — When it closes, as `22:00`. Empty is midnight; **Days** — The days it holds on. None picked is every day. **Takes** | Port | Carries | Meaning | | ------ | ------- | ------------------------------------------- | | `exec` | `flow` | The way in. It reads when the flow arrives. | **Answers** | Port | Carries | Meaning | | ------------- | ------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `yes` | `flow` | Taken inside the window, on one of its days. | | `no` | `flow` | Taken otherwise. | | `unavailable` | `flow` | Taken when there was no reading: the reader did not allow it and gave no stand-in, or allowed it and there was nothing to give. Unwired, the story carries on the way it would have otherwise. | **Read more:** [The world outside the story](/reference/python/the-world/) ## Chance [Section titled “Chance”](#chance) ### Dice roller [Section titled “Dice roller”](#dice-roller) ![The Dice roller card](/_astro/random.dice-light.Dn9W1dLO_vAxT7.webp) ![The Dice roller card](/_astro/random.dice-dark.DLQenHOR_Z1oQQht.webp) Rolls dice notation, the way a table would. `2d6+4` is two six-sided dice and four; `4d6kh3` keeps the highest three of four; `1d20+1d4+2` chains a d20, a d4 and a bonus. Keep or drop with `kh`, `kl`, `dh`, `dl`. Pick a preset, or type your own. `Rolls` is how many times over. `total` is the sum of everything, `results` one total per roll, `dice` every face that counted. The two criticals are a natural twenty or a natural one on the first dice term. A wire into `notation` or `rolls` beats what is typed on the card. **On the card:** **Notation**, default `1d20` — Type it, or pick one of the presets; **Rolls**, default `1`. **Takes** | Port | Carries | Meaning | | ---------- | -------- | ------------------------------------------- | | `exec` | `flow` | The way in. | | `notation` | `string` | The dice notation. A wire beats the card. | | `count` | `number` | How many times over. A wire beats the card. | **Answers** | Port | Carries | Meaning | | ------------------- | ------------ | ----------------------------------- | | `completed` | `flow` | Taken when the node has run. | | `total` | `number` | The sum of everything. | | `results` | `numberList` | One total per roll. | | `dice` | `numberList` | Every face that counted. | | `isCriticalHit` | `bool` | A natural twenty on the first term. | | `isCriticalFailure` | `bool` | A natural one on the first term. | **Read more:** [Dice notation, explained](https://rolldyce.com/dice-notation/) ### Percentile check [Section titled “Percentile check”](#percentile-check) ![The Percentile check card](/_astro/random.check-light.Ca5XNfe4_Z1jQoct.webp) ![The Percentile check card](/_astro/random.check-dark.DevD0YLq_2wx8id.webp) Rolls a d100 against a chance out of a hundred. At or under the chance is `success`, over it is `failure` — so 0 never succeeds and 100 always does. What was rolled comes out of `roll`, for a passage that wants to say how close it was. A wire into `chance` beats what is typed on the card. **On the card:** **Chance**, default `50`. **Takes** | Port | Carries | Meaning | | -------- | -------- | ---------------------------------------- | | `exec` | `flow` | The way in. | | `chance` | `number` | Out of a hundred. A wire beats the card. | **Answers** | Port | Carries | Meaning | | --------- | -------- | ----------------------------- | | `success` | `flow` | Taken at or under the chance. | | `failure` | `flow` | Taken over it. | | `roll` | `number` | What the d100 came up. | **Read more:** [Percentile systems, in the dice notation guide](https://rolldyce.com/dice-notation/) ### Weighted branch [Section titled “Weighted branch”](#weighted-branch) ![The Weighted branch card](/_astro/random.weighted-light.8SxB9huX_Z2txHk0.webp) ![The Weighted branch card](/_astro/random.weighted-dark.BdZwOtED_1pOQs7.webp) Sends the flow down one branch of several, drawn by weight. Each row is an outcome and how likely it is against the others — 60 / 30 / 10, or 6 / 3 / 1, which is the same branch. Type into the last row and another appears beneath it. A row with no name, or a weight of nothing, is never drawn. `picked` is the name of the row that won. Wire a number from 0 to 100 into `roll` to decide with it instead of rolling. **Takes** | Port | Carries | Meaning | | ------ | -------- | ------------------------------------------------------------------- | | `in` | `flow` | The way in. | | `roll` | `number` | Optional: a number from 0 to 100 to decide with instead of rolling. | **Answers** | Port | Carries | Meaning | | ----------- | -------- | --------------------------------------------------------------------------------- | | `picked` | `string` | The name of the row that won. | | `outcome.0` | `flow` | One way out per row, drawn by weight. Type into the last row and another appears. | ## Macro [Section titled “Macro”](#macro-1) These cards are offered on a macro’s own canvas only, where a board would have its Start and its End cards. All cards above are offered on both. ### Macro In [Section titled “Macro In”](#macro-in) ![The Macro In card](/_astro/macro.in-light.CHtrwbLA_dPi0s.webp) ![The Macro In card](/_astro/macro.in-dark.BlsHXSfp_Z1IvToE.webp) Where this macro begins, and what it takes. Double-click to say what it takes — a name and a type per row — and it grows a port per name, carrying what the board that called the macro wired in or typed on the card. A take declared as a map or as JSON cannot be typed on the calling card. A macro has exactly one of these, and it stands where a Start would. Plus one **output per name** in *Takes*, typed as the shape says. **On the card:** **Takes**. **Answers** | Port | Carries | Meaning | | ----- | ------- | ------------------------------------ | | `out` | `flow` | The single wire the macro begins by. | **Read more:** [Macros](/reference/node-families/#macros) ### Macro Out [Section titled “Macro Out”](#macro-out) ![The Macro Out card](/_astro/macro.out-light.c1p83_7k_Z1RcOlH.webp) ![The Macro Out card](/_astro/macro.out-dark.DmbjtNXj_1CR0mJ.webp) Where this macro ends, and what it answers. Double-click to say what it answers — a name and a type per row — and it grows a port per name. What is wired in here is what the Macro node on the calling board publishes, and the flow carries on from there. A macro has exactly one of these, and it stands where an End would. Plus one **input per name** in *Answers*, typed as the shape says. **On the card:** **Answers**. **Takes** | Port | Carries | Meaning | | ---- | ------- | ------------------------------------------------------------------- | | `in` | `flow` | Any number of wires; every branch inside the macro can finish here. | **Read more:** [Macros](/reference/node-families/#macros) ## Related [Section titled “Related”](#related) * [Boards, nodes and macros](/explanation/boards-nodes-and-macros/) explains what a card is and how a macro differs from a board. * [How to build a board](/how-to/work-on-the-canvas/) puts cards on a canvas and wires them. * [How a story runs](/explanation/how-a-story-runs/) says how flow and values are run. * [Diagnostics](/reference/diagnostics/) lists what a board reports about its cards. # Passages, choices, languages and reading time > The passage file format, the Reads row, choice limits, project languages, translation staleness and the reading-time estimate. ## Localised passage [Section titled “Localised passage”](#localised-passage) A passage is Markdown. On a board it is a Narration node; on disk it is a **localised passage**, a folder named `*.loc.narr.bdl` that the app shows as one file. It holds one `.md` per language the project has. * chapters/ * the\_gates.graph.json * reaching\_the\_gates.loc.narr.bdl/ * en-US.md the passage, in the language the story is written in * it-IT.md its translation * touch.json when each was last touched, so a clone can tell which is older | File | Contents | | ------------- | --------------------------------------------------------------------------------- | | `<locale>.md` | The passage in that language, as ordinary Markdown. | | `touch.json` | When each language file was last touched. Written with sorted keys and UTC times. | ![One passage in two languages, with a mark on the translation that has fallen behind](/_astro/localisation-light.Qtc3whV9_bSm2C.webp) ![One passage in two languages, with a mark on the translation that has fallen behind](/_astro/localisation-dark.edpj7g3H_GhpHH.webp) ## Writing [Section titled “Writing”](#writing) Double-click a Narration node to write in it. The editor is the Markdown editor the app uses everywhere: a live render, a formatting toolbar, and image embeds (local and remote, raster and SVG). | Control | Behaviour | | --------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | Drop a file on the editor | The link is written. | | Image button (beside Link, in every Markdown editor: a passage, a bundle, a document open in a tab) | Offers the project’s pictures, documents and sounds by name as you type. For anything outside the folder, accepts a URL or a path. Writes `![alt](path)`; the selection becomes the alt text, otherwise a prompt is left selected. | | Picture from outside the project | Copied into `.ripple/external/` first. With **Import outside assets** switched off in the File menu it is linked instead, and Messages says so. | ### Link spellings [Section titled “Link spellings”](#link-spellings) | Spelling | Resolves against | | ----------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------- | | Starts with a slash, `![map](/art/map.png)` (what the app writes) | The project’s root. | | No leading slash, `![](../art/map.png)` | The bundle’s *parent* folder, as a file in its place would be. Beside `chapters/the_gates.graph.json`, this means `art/map.png`. | ### Python in a passage [Section titled “Python in a passage”](#python-in-a-passage) Two places hold Python: an inline `{{ … }}` span and a fenced ` ```ripple ` block. A passage only reads. See [Python in a story](/explanation/python-in-a-story/) and [Python reference](/reference/python/). ## Reads [Section titled “Reads”](#reads) The **Reads** row sits at the bottom of the Narration card, beside the soundscape the node brings. It shows how many names the passage is handed and opens the dialog where they are declared: a name and a type each, written as every other card’s ports are. | Rule | Detail | | -------------------- | ---------------------------------------------------------------------------------------------------------------------------- | | Port per name | The card grows one port for each declared name. | | Reading a name | The passage’s Python reads it by name: `You owe {{ gold }} crowns.` with a number on the `gold` port. | | Default | A slot with a default and nothing wired uses the default. | | Owner | The names belong to the card, not to the passage file. One passage can be read by two cards that fill it differently. | | Name not handed over | A small warning shows where it stood; the rest of the passage renders as usual. | | Completions | Opened from a board, the card’s names are offered as you type. Opened in its own tab, they are not: there is no card to ask. | ### Name rule [Section titled “Name rule”](#name-rule) A name is a letter or an underscore, then letters, digits and underscores. A Python keyword such as `class` is refused, with the reason shown under the field. A board that carries a name Python cannot read reports [`W.GE.15`](/reference/diagnostics/). The same rule holds for a Script node’s inputs and outputs. An Output node’s rows and an Input node’s fields are read by people, so `Your roll` is valid there. ## Choices [Section titled “Choices”](#choices) A Narration node’s choices carry two limits besides their wording. When either says no, the choice stays on screen, greyed out and unpressable. The reason is in [Passages and translations](/explanation/passages-and-translations/). | Limit | Where | Values | Card mark | | ---------- | ----------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------ | | Times | The *Times* box beside the choice’s name | Blank: unlimited. A number: how often one playthrough can take the choice. Once used up the choice stays on screen, greyed out. | `×3` at the end of the choice’s row. | | A question | A small input on every wired choice’s row | A `bool` wired into it: the choice is offered only while it is true. | The input on the row. | The two combine: *three times, and only while you carry the lantern* is one choice with both. A card hanging off the side of the board, outside the flow that leads to the passage, is worked out once before the story starts and answers the same for ever. The Messages panel says so and names the choice ([Diagnostics](/reference/diagnostics/)). A Comparator reading a dice roll follows the same rule. The cards themselves are in [Nodes](/reference/nodes/); to build the limit, see [How to limit a choice](/how-to/limit-a-choice/). ## Languages [Section titled “Languages”](#languages) | Setting | Where | Behaviour | | --------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | Written in | Chosen when the project is made. The starting point comes from the computer’s settings; the choice is offered rather than applied. It decides the name of the file every passage is written into, such as `en-US.md`. | Changing it leaves your prose alone: every passage gets an empty file for the new language and everything written stays where it is as a translation. Until carried across, passages play empty. There is no migration. | | Translated into | Project settings, *Languages* page. Project settings pages are *General*, *Languages*, *Writing*, *Narration roots* and *Capabilities*. | See the actions below. | | Action | Effect | | -------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------- | | Switch a translation language on | An empty translation is put into every bundle. | | Switch a translation language off | Nothing is deleted. The files stay, the editor shows them marked retired, and readers stop being offered the language. | | Switch off with text written in the language | Asked once, on save. **Keep them** is the focused button. Clicking through or dismissing the dialog deletes nothing. | ## Stale translations [Section titled “Stale translations”](#stale-translations) The locale chips along the top of a bundle each stand for one language. A **dot** on a chip means that translation is older than its source. The Messages panel lists every translation that has fallen behind, across the whole project. Age is read from `touch.json`, not the filesystem, because a `git clone` sets every file’s time to the moment of checkout. `touch.json` is written with sorted keys and UTC times so it stays quiet in a diff. ## Reading time [Section titled “Reading time”](#reading-time) Under every passage: the word count on the left, and on the right a choice of reading pace (slow, average or fast) with the estimate beside it. It updates as you type, and the chosen pace is remembered. | Rule | Detail | | ----------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | Languages | Fifty-four, each with its own reading speed. | | Counted by character | Japanese, Chinese, Thai and Khmer, which are written without spaces between words. | | Mixed scripts | A passage mixing scripts is counted as both. | | Sources of figures | Seventeen languages carry figures from reading studies; the rest are worked out from a close relative. The tooltip on the estimate says which applies. | | Code | Python snippets and fenced blocks are removed before counting. A link counts as the words read, not the length of its URL. | | Mixed-language passages | Off by default; a per-project setting. When on, paragraphs in a language other than the passage’s own are found and priced at that language’s speed. Reads every paragraph after each pause in typing. Short paragraphs are left alone, since a language guessed from one line is guessed, not known. | The arithmetic is the separate package [`dart_read_time`](https://github.com/ConnectingTheDots-DCWK/dart_read_time): pure Dart, MIT. ## Related [Section titled “Related”](#related) * [Passages and translations](/explanation/passages-and-translations/) * [How to write a passage](/how-to/write-a-passage/) * [How to limit a choice](/how-to/limit-a-choice/) * [How to add, switch off or change a project’s languages](/how-to/manage-languages/) * [Nodes](/reference/nodes/) # Project files and the .ripple folder > Every file kind the app owns, what each file in .ripple/ holds, the declared attribute types, and the layout of a component and a passage on disk. A project is a folder. The app writes no scaffold into it and imposes no layout. Its own bookkeeping is confined to one directory, `.ripple/`, at the project root. Every file the app owns is plain text. ## File kinds [Section titled “File kinds”](#file-kinds) Three kinds are JSON namespaced inside `.json`, so ordinary tools still read them. A state machine is a mermaid diagram, and a script is Python. | Pattern | Holds | | -------------------- | -------------------------------------------------------------------- | | `*.graph.json` | A board: nodes, wires and the choices on them | | `*.macro.graph.json` | A macro: a board that a board can call | | `*.component.json` | A component: a name, a cover and an ordered list of typed attributes | | `*.soundscape.json` | A soundscape: a timeline of clips a passage plays | | `*.fsm.mmd` | A state machine, as a mermaid `stateDiagram-v2` | | `*.loc.narr.bdl/` | One passage in several languages: a folder of ordinary `.md` files | | `*.py` | A script that a Script node runs | | `.ripple/` | The app’s own bookkeeping | Every file the app owns carries an integer `version`, except three. A script is a `.py` file and a state machine is a mermaid diagram whose own header is its version, because neither is in a format of ours. A Markdown passage carries none either; the `touch.json` in its folder does. A path inside a file is measured from the project root, with forward slashes, and never contains `..`, except a link in a passage’s prose (see [Passages](#passages)). A path outside the project is written absolute. The per-folder state in `.ripple/` is written with sorted, relative paths. ## The .ripple folder [Section titled “The .ripple folder”](#the-ripple-folder) Nothing the app keeps about a project is written into your story. It lives in `.ripple/`, beside your files. * my-story/ * .ripple/ * project.json the title, description, cover and languages * registry.json the index of boards, components and the other files, and their ids * workspace.json the layout, the open documents, the side bar and Messages, and which folders were open * cvars.json the console variables the project declares * callbacks.json the host callbacks the project declares * dictionary.json the words added to the spelling dictionary * lints.json the diagnostics the author has answered * saves/ paused and bookmarked playthroughs * … * export/ export profiles * … * external/ pictures and audio picked from outside the project, copied in * … * style/ the project’s own style rules, one Weir file each * … * chapters/ * the\_gates.graph.json * the\_gates\_opening.loc.narr.bdl/ * en-US.md * it-IT.md * touch.json * tavern.soundscape.json * music/ * tavern.ogg * items/ * sword.component.json * scripts/ * damage.py * notes.md | Entry | Holds | Version control | | ----------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------- | | `project.json` | The title, description, cover and languages | Travels | | `registry.json` | The index of every file in the project, by catalog id; the boards a story may begin at; the links between boards | Travels | | `workspace.json` | The layout, the open documents, the side bar and Messages, and which folders were open. Per machine | Ignored by the built-in version control | | `cvars.json` | The console variables the project declares. See [Console variable declarations](/reference/console-variable-declarations/) | Travels | | `callbacks.json` | The named host callbacks: a name, an event and what the event is aimed at. See [Host callbacks](/reference/host-callbacks/) | Travels | | `dictionary.json` | The words added to the spelling dictionary, in sorted order. A newer version is refused and never written over. See [How to check spelling and grammar](/how-to/check-spelling-and-grammar/) | Travels | | `lints.json` | What the author has answered about the boards, for one card and one rule, keyed by lint name. Written even when empty. Not catalogued. A version this build does not know, or a file that is not this shape, reads as nothing answered, so the warning returns | Travels | | `saves/` | Paused and bookmarked playthroughs, one file a slot | Ignored by the built-in version control: a playthrough is a fact about your reading, not about the story | | `export/` | Export profiles, as `<name>.profile.json`. See [Export options and bundle contents](/reference/export-options/) | Travels | | `external/` | Pictures and audio picked from outside the project, copied in | Travels | | `style/` | The project’s style rules, as `<name>.weir`, in Weir, Harper’s rule language. No version of the app’s; a rule that does not compile is reported, never refused. See [How to write a style rule](/how-to/write-a-style-rule/) | Travels | ### The registry [Section titled “The registry”](#the-registry) `registry.json` has a `version`, `roots` (the boards a story may begin at, by id; authored, never derived), `links` (where the boards Jump to, derived from the boards and cached) and `entries` (every file the last walk found: `id`, `kind`, `name`, `path`). * A Jump stores the target’s catalog id; the ids live in the registry. * An id is 32 lowercase hexadecimal characters, minted once when a file is first seen and kept for as long as the path is known. * A file is indexed as soon as it is in the folder, whether or not the app made it, so that a picker can offer it. A file imported from outside the project is listed as it lands. * Nothing under a dotted folder is indexed, nor is a dotted file, nor anything outside the project folder. The one exception is a file directly under `.ripple/external/`. * What an export or a compiled campaign carries is decided by what points at a file, not by the index, so the index adds nothing to either. | `kind` | File | Recognised by | | ----------------- | ----------------------------------------------------- | -------------------------------- | | `board` | `*.graph.json` | Suffix | | `macro` | `*.macro.graph.json` | Suffix, tested before `board` | | `component` | `*.component.json` | Suffix | | `script` | `*.py` | Extension | | `narrationBundle` | `*.loc.narr.bdl/`, a directory | Suffix | | `machine` | `*.fsm.mmd` | Suffix | | `soundscape` | `*.soundscape.json` | Suffix | | `audio` | An audio extension; see [Viewer kinds](#viewer-kinds) | Extension | | `asset` | Anything else: an image, a video, a PDF, a loose note | A file that is none of the above | `name` is the file’s stem for every kind except `asset` and `audio`, which keep the whole basename (`map.png`). `path` is root-relative. ### Files from outside the project [Section titled “Files from outside the project”](#files-from-outside-the-project) A picture or a sound picked from outside the project is copied into `.ripple/external/`, not into a folder the app invents at the top of the story. Copying in is a switch, **Import outside assets**, a checked row of the File menu and a palette command; with it off the file is linked. A link that leaves the project is reported under the field and in Messages every time the project is opened. ## Viewer kinds [Section titled “Viewer kinds”](#viewer-kinds) The viewers recognise a file from its name alone, lowercased. Compound suffixes are tested first, and a macro’s before a board’s. | Kind | Matches | | ----------------- | --------------------------------------------------------- | | `macro` | `.macro.graph.json` | | `graph` | `.graph.json` | | `component` | `.component.json` | | `soundscape` | `.soundscape.json` | | `narrationBundle` | `.loc.narr.bdl` | | `machine` | `.fsm.mmd` | | `script` | `.py` | | `pdf` | `.pdf` | | `svg` | `.svg` | | `raster` | `.png` `.jpg` `.jpeg` `.gif` `.webp` `.bmp` | | `audio` | `.mp3` `.wav` `.ogg` `.oga` `.flac` `.m4a` `.aac` `.opus` | | `markdown` | `.md` `.markdown` | | `text` | Everything else | A text file above 4 MiB is refused by the text viewer, and a `<locale>.md` above 4 MiB is skipped by the passage reader. ## Components [Section titled “Components”](#components) ```json { "version": 3, "name": "Goblin", "cover": "art/goblin.png", "attributes": [ { "label": "HP", "type": "int", "value": 7 }, { "label": "Alive", "type": "bool", "value": true }, { "label": "Bio", "type": "markdown", "value": "A small, angry thing." }, { "label": "Loot", "type": "list<asset>", "items": ["art/gold.png"] } ] } ``` | Key | Rule | | ------------ | ---------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `version` | Format 3. An older file is lifted when it is opened or moved; a build that predates a format refuses a newer component rather than misreading it | | `name` | String, default `""` | | `cover` | Optional string; root-relative, absolute when outside the project | | `attributes` | Ordered. Each has `label` (default `""`), `type` (default `string`), and `value`. `list<asset>` and `list<componentRef>` carry their paths under `items` instead | ### Declared types [Section titled “Declared types”](#declared-types) A declared type is a base at an arity, written `base`, `list<base>` or `map<base>`. A map is always keyed by strings. Eight bases at three arities make the twenty-four types that every editor in the app offers. The same set describes component attributes, node shapes, macro takes and console variables. | Base | Default | Meaning | | -------------- | ------- | ------------------------------ | | `int` | `0` | Whole number | | `float` | `0.0` | Number | | `bool` | `false` | True or false | | `string` | `""` | Text | | `markdown` | `""` | Text, edited as Markdown | | `asset` | `""` | A path to a file | | `componentRef` | `""` | A path to another component | | `json` | `{}` | Any JSON value, stored decoded | * Numbers and booleans are JSON numbers and booleans, never strings. * An attribute whose type this build cannot name keeps whatever it arrived with. * `number` and `entity` are read and never offered. `number` is the older spelling of a number; `entity` is a name and reads as `string`. * Older spellings read forever and are written in the new form the next time you touch the type menu: `assetList` as `list<asset>`, `componentList` as `list<componentRef>`, `numberList` as `list<number>`, `boolList` as `list<bool>`, `stringList` as `list<string>`. * Whether an attribute carries `items` is decided by its type, never by its contents. * Coercion never throws. An `int` from a float rounds, from a boolean is 0 or 1, and from a string parses, otherwise 0. A `float` likewise, otherwise 0.0. A `bool` from a number is non-zero and from a string is one of `true`, `yes`, `1`, `on`. A `string` from a map or a list is its JSON. A `json` from nothing is `{}`. A `list<T>` or `map<T>` coerces entry by entry. * An integer is at most 2⁵³ − 1. * A component written by a later version keeps its paths, in lists and maps both, when you open and save it. Paths in a component, the cover and each `list<asset>` item, are measured from the project root. An `asset` or `componentRef` slot stores the path. Its chip is marked when the file is outside the project or missing. ## Passages [Section titled “Passages”](#passages) A passage on disk is a localised passage: a folder, the only kind of file of ours that is a directory, which the app shows as one document. * chapters/ * the\_gates.graph.json * reaching\_the\_gates.loc.narr.bdl/ * en-US.md the passage, in the language the story is written in * it-IT.md its translation * touch.json when each was last touched, so a clone can tell which is older | Rule | Value | | ---------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | Name | The folder’s basename with `.loc.narr.bdl` removed | | Name when the app creates it | `<board>-<passage title>`, beside the board: `gate.graph.json` and `gate-at-the-gate.loc.narr.bdl/`, so passages sort next to the board they belong to | | A language file | `<locale>.md`, where `<locale>` is a known locale id exactly. No normalisation | | Anything else in the folder | `README.md`, `en.md`, a subfolder: reported as unrecognised and never read | | Languages read | Every language found, whether or not the project has it switched on | | Size ceiling | A `<locale>.md` over 4 MiB is skipped. One unreadable file does not fail the passage | | Links in the prose | A link the app writes starts with `/` and is measured from the project root: `![map](/art/map.png)`. A link written by hand without the slash is relative to the bundle’s parent folder: `![](../art/map.png)` beside `chapters/the_gates.graph.json` means `art/map.png` | | `touch.json` | `{ "version": 1, "touched": { "<locale>": "<ISO 8601, UTC>" } }`, keys sorted. A cache: a problem reading it reads as empty, and a walk rebuilds it | ![One passage in two languages, with a mark on the translation that has fallen behind](/_astro/localisation-light.Qtc3whV9_bSm2C.webp) ![One passage in two languages, with a mark on the translation that has fallen behind](/_astro/localisation-dark.edpj7g3H_GhpHH.webp) See [Passages, choices, languages and reading time](/reference/passages/) for what a passage holds. ## Version gate [Section titled “Version gate”](#version-gate) A file from a newer build is refused where it is authored and read as empty where it is a cache. A document the author cannot get back, such as the index, the project settings, a component or a board, stops the app rather than being half-read and written back over. A cache that a walk of the folder rebuilds simply rebuilds. `touch.json` and `lints.json` are caches. ## Related [Section titled “Related”](#related) * [A project is a folder](/explanation/projects-are-folders/) * [Components and entities](/explanation/components-and-entities/) * [Passages and translations](/explanation/passages-and-translations/) * [How to create or import a project](/how-to/create-or-import-a-project/) * [How to keep a history and back up a project](/how-to/keep-a-history-and-back-up/) # Python reference > The twenty host functions Python in a story may call, the five surfaces they run on, how arguments are checked and how a refusal reads. Every function a script may call is a **host function**: bound under the name an author types, present without an `import`, and answered by the app or by the runtime playing a campaign. There is no `import ripple`, no class to instantiate and no object syntax. A component or an entity crosses as a dict, because the interpreter has no `class`. ## Functions [Section titled “Functions”](#functions) Twenty names, bound on every surface, in four groups. Signatures are the ones the app registers; the page for each group lists what every function answers and refuses. ### Components and entities [Section titled “Components and entities”](#components-and-entities) Detail: [Components and entities](/reference/python/components-and-entities/). | Function | Does | | --------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `component_names()` | Every component the project holds. | | `component_get(name)` | One component as its file describes it, as a dict of labels, types and starting values, or `None`. A type you do not recognise is one a later version wrote. | | `entity_names(of=None)` | What the story has made, all of it or only what came from one component. | | `entity_get(name)` | A snapshot of one entity’s attributes, or `None`. Changing the dict changes nothing. | | `entity_of(name)` | Which component an entity was made from, or `None`. | | `entity_set(name, attribute, value)` | Writes one attribute. | | `entity_create(name, of=None)` | Makes an entity from a component and answers its attributes. Refuses a name that is taken. | | `entity_delete(name)` | Unmakes one. The board’s nodes still name it and make a fresh one if they run again. | | `entity_rename(name, to)` | Renames one, keeping what it is and what it holds. A node still naming the old one makes a fresh one if it runs again. | | `component(name)` | The attributes of a component or entity, as a dict, or `None`. Superseded by `component_get` for the definition and `entity_get` for what the story has changed. | | `components()` | Every name you can ask for. Superseded by `component_names` and `entity_names`, which do not run the two together. | | `set_attribute(name, attribute, value)` | Writes to an entity; `True` when it landed. Superseded by `entity_set`, which says why when it cannot. | ### Console variables [Section titled “Console variables”](#console-variables) Detail: [Console variables](/reference/python/console-variables/). A value is coerced to the declared type when that loses nothing, and refused otherwise. | Function | Does | | ----------------------- | -------------------------------------------------------------------------------------------------------------------------- | | `cvar_names()` | Every console variable, in declaration order. | | `cvar_type(name)` | Its type as the file spells it: one of the twenty-four, such as `int`, `markdown`, `json`, `list<string>` or `map<float>`. | | `cvar_get(name)` | What it holds now. A list or map is a copy. | | `cvar_set(name, value)` | Writes it, coercing to the declared type when nothing is lost and refusing otherwise. | | `cvar_reset(name)` | Puts it back to its default. | ### Sound [Section titled “Sound”](#sound) Detail: [Sounds](/reference/python/sounds/). | Function | Does | | ----------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------- | | `play_sound(path, volume=1.0, speed=1.0)` | Plays the file at `path` (`sfx/bell.ogg`, as the project spells it) once and returns at once. `volume` runs from 0 to 1, `speed` from a little above 0. | A soundscape is set on a passage, not from Python. ### The world outside the story [Section titled “The world outside the story”](#the-world-outside-the-story) Detail: [The world outside the story](/reference/python/the-world/). Read only when the reader allows it; a read they did not allow raises `PermissionError`, unless they gave a stand-in. | Function | Does | | ---------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `location_get()` | Where the reader is, as a dict of `label`, `latitude`, `longitude`, `accuracy` and `when`, any of which may be `None`; or `None` when there is no position yet. | | `location_distance(latitude, longitude)` | How far the reader is from there, in metres, or `None` when the reading has no coordinates. | The clock is the standard library’s own: `datetime.now()` and `date.today()`, read the same way. ## Surfaces [Section titled “Surfaces”](#surfaces) Who is asking decides what may happen. Every function is bound on every surface; what differs is what a call does. | Surface | Where | Reads | Writes | | ------------- | ------------------------------------------- | ------------------------ | ------- | | `script` | A Script node on a board | Yes | Yes | | `passage` | `{{ … }}` and ` ```ripple ` in prose | Yes | Refused | | `console` | The editor’s Python console, at design time | Definitions and defaults | Refused | | `agent` | `python_eval` over MCP | Definitions and defaults | Refused | | `playthrough` | The campaign window’s console, during play | Yes | Yes | * A passage refuses writes because of where the call runs: a state change belongs in a node you can point at on the canvas. * A passage refuses a sound because its snippets run again on every redraw. * The console and an agent refuse writes because nothing is running at design time: a project with no playthrough has no entities, and a console variable holds its default. * Reads work at design time. `component_get` and `component_names` answer from the files. `entity_names()` is `[]` and `entity_get(name)` is `None`. These are answers, not failures. * The playthrough surface may do everything a Script node may. *Play from here* starts a story with nothing made, and this console stands in for the nodes the story did not run. The reasons are in [Python in a story](/explanation/python-in-a-story/). ## Calling [Section titled “Calling”](#calling) Required arguments are positional. The optional `of` is keyword-only. `play_sound` is the exception: `volume` and `speed` may be given by position or by name. No keyword other than those listed is accepted. ```python entity_names(of="Goblin") # right entity_names("Goblin") # raises: too many positional arguments entity_create("Goblin-7", frm="Goblin") # raises: unknown keyword, names the ones it knows ``` | Function | Positional | Keywords | | --------------- | ---------- | ----------------- | | `entity_names` | 0 | `of` | | `entity_create` | 1 | `of` | | `play_sound` | 1 to 3 | `volume`, `speed` | Other functions take exactly the positional arguments in their signature and no keywords. A wrong call raises rather than answering. `component`, `components` and `set_attribute` are exempt: what they do with a mistake is as frozen as their names. **Host functions cannot be called inside `map()`, `filter()` or `sorted(key=…)`.** The interpreter raises. Inside a `def` or a comprehension they work: ```python sorted(entity_names(), key=lambda n: entity_get(n)["HP"]) # raises pairs = [(entity_get(n)["HP"], n) for n in entity_names()] # works weakest = sorted(pairs)[0][1] ``` ## Reads are copies [Section titled “Reads are copies”](#reads-are-copies) A value crosses from the app to Python through JSON, so what `entity_get`, `component_get` and `cvar_get` answer is a snapshot. Changing it is legal, silent and has no effect: ```python e = entity_get("Mira") e["HP"] = e["HP"] - 3 # runs, changes nothing ``` `entity_set` and `cvar_set` are how a script writes. ## Refusals [Section titled “Refusals”](#refusals) Every refusal reaches Python as a `RuntimeError` carrying one sentence and nothing else. ```python try: entity_create("Goblin-1", of="Goblin") except RuntimeError: pass # it already existed ``` The sentences a surface refuses with are fixed, because a script may already have branched on which it got. | Refused | Sentence | | ---------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------- | | A passage, any write | `a passage describes the world; only a Script node changes it` | | Console or agent, an entity write | `no campaign is running, so there are no entities to change — press play, or put this in a Script node` | | Console or agent, a variable write | `no campaign is running, so a console variable holds its default — press play, or put this in a Script node` | | A passage, `play_sound` | `a passage cannot play a sound — its snippets run again every time it is redrawn, so put this on a Play sound node or in a Script node` | | Console or agent, `play_sound` | `no campaign is running, so there is nothing to play this through — press play, or put this on a Play sound node` | A read of the world the reader did not allow is the one refusal that is not a `RuntimeError`: it is Python’s own `PermissionError`, so `except PermissionError:` catches it and nothing else. See [The world outside the story](/reference/python/the-world/#when-a-read-is-refused). A refusal inside a passage does not end the run. It renders in place as a `⚠ RuntimeError: …` chip and the story carries on, which is why every message is one line. A name quoted in a refusal is cut to about forty characters. ## Interpreter [Section titled “Interpreter”](#interpreter) The interpreter runs a sandboxed Python subset, not CPython: no `class`, no `random`, no `import requests`, no filesystem, no real standard library. What is available is what the **sandbox report** behind the console’s `i` button states on the day you ask, and `python_capabilities` returns the same text to an agent. Where this page and the report disagree, the report is right. A script that has not finished after two seconds is stopped. See [The sandbox and its limits](/reference/python/sandbox/). ## Versioning [Section titled “Versioning”](#versioning) These names are a contract with every passage and script written against them from 1.3 onward (`play_sound` from 1.4, `location_get` and `location_distance` from 1.6), and are added to rather than repurposed. The shape `component_get` answers is shared with `component_read` over MCP. ## Related [Section titled “Related”](#related) * [Python in a story](/explanation/python-in-a-story/): why Python is a host surface and what each surface is for. * [How to run Python in a story](/how-to/script-a-passage/) * [How to build a campaign with no Python](/how-to/turn-python-off/) * [MCP tools](/reference/mcp-tools/): `python_eval`, `python_capabilities` and `component_read`. # Components and entities > The twelve Python functions over a campaign's components and the entities a playthrough has made, with the arguments, answers and refusals of each. A **component** is what a `*.component.json` file describes. An **entity** is one the story has made from it, keyed by name. Nine functions read definitions, read entities, and write entities; three older functions are kept. These are host functions, not a Python library: there is no module to import them from, and they cannot be called inside `map()`, `filter()` or `sorted(key=…)`. A comprehension works. Where a function may be called is decided by the [surface](/reference/python/#surfaces). Every function reads on every surface. The four writes (`entity_set`, `entity_create`, `entity_delete`, `entity_rename`) work in a Script node and in the playthrough console, and are refused elsewhere. ## Summary [Section titled “Summary”](#summary) | Function | Answers | Writes | Raises | | ------------------------------------ | ----------------------------------------------------- | ------ | ----------------------------------- | | `component_names()` | list of component names, in name order | no | never | | `component_get(name)` | the component as its file describes it, or `None` | no | never | | `entity_names(of=None)` | list of entity names, in name order | no | `of` names no component | | `entity_get(name)` | a copy of the entity’s attributes, or `None` | no | never | | `entity_of(name)` | the name of the component it was made from, or `None` | no | never | | `entity_set(name, attribute, value)` | `None` | yes | no entity under `name` | | `entity_create(name, of=None)` | the new entity’s attributes | yes | name taken; `of` names no component | | `entity_delete(name)` | `None` | yes | no entity under `name` | | `entity_rename(name, to)` | `None` | yes | no entity under `name`; `to` taken | `of` is passed by keyword: `entity_names(of="Goblin")`, `entity_create("Goblin-7", of="Goblin")`. A wrong argument count or an unknown keyword is refused with a message naming the function. ## Definitions [Section titled “Definitions”](#definitions) ### `component_names()` [Section titled “component\_names()”](#component_names) Every component the campaign holds, in name order. Components only; `components()` runs components and entities together. ### `component_get(name)` [Section titled “component\_get(name)”](#component_getname) One component as its file describes it: labels, types and starting values, as a dict, or `None`. Resolves by name or by catalogue id. ```python >>> component_get("Goblin") { "id": "9f2c…", "name": "Goblin", "cover": "art/goblin.png", "attributes": [ {"label": "HP", "type": "int", "value": 7}, {"label": "Rate", "type": "float", "value": 1.5}, {"label": "Alive", "type": "bool", "value": True}, {"label": "Bio", "type": "markdown", "value": "A small, angry thing."}, {"label": "Loot", "type": "list<asset>", "items": ["gold.png"]} ] } ``` | Key | Present | Holds | | ---------------------------------------------- | ---------------------------------------------------------------------- | ---------------------------------------------- | | `id` | always | the component’s catalogue id | | `name` | always | the component’s name | | `cover` | only when the file has one | a path | | `attributes` | always | an ordered list of `label`, `type` and `value` | | `items` (on an attribute, in place of `value`) | only on the two lists of paths, `list<asset>` and `list<componentRef>` | the list of paths | Values cross as native Python types: an `int` is an `int`, and a `json` attribute is the decoded dict or list. The shape is the one `component_read` answers over MCP, without `path`. **`type` is an open set.** It is the wire name: one of `int`, `float`, `bool`, `string`, `markdown`, `asset`, `componentRef` or `json`, on its own or as `list<…>` or `map<…>`, twenty-four in all. A type this version does not know keeps the spelling the file gave it, so a script that switches on `type` needs a default branch. **A path is relative to whatever answered.** The editor answers paths relative to the project; a compiled campaign answers paths into its own media folder. Do not join a path onto a directory of your own. In a played campaign, a file the project does not have is left out of the answer. **An entity never shadows a definition.** `component_get` does not depend on whether an entity of that name has been made. This is the one place it differs from `component()`. ## Entities, read [Section titled “Entities, read”](#entities-read) ### `entity_names(of=None)` [Section titled “entity\_names(of=None)”](#entity_namesofnone) Every entity the story has made, in name order. `of` names a component and limits the list to entities made from it. `of` naming no component raises (*there is no component called …*); `of=None`, or an empty name, is no filter. At design time the answer is `[]`. ### `entity_get(name)` [Section titled “entity\_get(name)”](#entity_getname) The entity’s attributes as a dict, or `None` when the story has made nothing under that name. It does not fall back to the component file. | Answer | Means | | ------ | ---------------------------- | | `None` | no such entity | | `{}` | an entity with no attributes | Both are falsy. The dict is a copy: changing it changes nothing, and `entity_set` writes. At design time the answer is `None`. ### `entity_of(name)` [Section titled “entity\_of(name)”](#entity_ofname) The name of the component the entity was made from, or `None` for an entity made from no component or a name nothing answers to. `component_get(entity_of("Goblin-1"))` is the definition behind an entity. ## Entities, written [Section titled “Entities, written”](#entities-written) Script node and playthrough console only. On a passage, the refusal is *a passage describes the world; only a Script node changes it*. On the editor’s console and for an agent over MCP, it is *no campaign is running, so there are no entities to change — press play, or put this in a Script node*. ### `entity_set(name, attribute, value)` [Section titled “entity\_set(name, attribute, value)”](#entity_setname-attribute-value) Writes one attribute. Answers `None`. Raises when nothing answers to `name`: *the campaign has made no entity called …*. An attribute the component never had can be written; components and entities are schemaless. ### `entity_create(name, of=None)` [Section titled “entity\_create(name, of=None)”](#entity_createname-ofnone) Makes an entity and answers its attributes, for example `entity_create("Goblin-7", of="Goblin")["HP"]`. | Call | Result | | ------------------------------------------------- | ------------------------------------------------------------------------ | | `of` omitted, a component of the same name exists | made from that component, as a Component node in Entity mode does | | `of` omitted, no such component | an entity with no attributes | | `of` given and naming no component | raises: *there is no component to make … from* | | `name` already taken | raises: *… is already taken, and it never takes over a name that exists* | `entity_create` is never get-or-create. The board’s Component and Spawn nodes are get-or-create. ### `entity_delete(name)` [Section titled “entity\_delete(name)”](#entity_deletename) Unmakes an entity. Answers `None`. Raises when nothing answers to `name`. It leaves no tombstone: a Component node still naming the entity makes a fresh one from the file if it runs again. The Delete Entity node behaves the same way. ### `entity_rename(name, to)` [Section titled “entity\_rename(name, to)”](#entity_renamename-to) Renames an entity, keeping what it is and what it holds. Answers `None`. Raises when nothing answers to `name`, and when `to` is taken: *… is already taken, so … was not renamed to it*. Renaming to the name it already has does nothing. The component it came from does not change: a renamed goblin still groups under Goblin in the entities panel, and `entity_of` still answers `Goblin`. Caution A Component node reads its entity name off the node on every visit. After `entity_rename("Goblin-7", "Steve")`, the next run of that node finds nothing under `Goblin-7` and makes a fresh goblin from the file. This is the delete rule applied to rename. ## Kept functions [Section titled “Kept functions”](#kept-functions) Bound on every surface, with their original behaviour. The sandbox panel lists them in amber with the replacement. | Function | Superseded by | Behaviour | | --------------------------------------- | ------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `component(name)` | `component_get(name)` for the definition, `entity_get(name)` for what the story has changed | The attributes of a component or entity as a dict, or `None`. An entity of the same name wins over the definition. `component()` with no argument is `None`. | | `components()` | `component_names()` and `entity_names()` | Every name that can be asked for, components and entities together. | | `set_attribute(name, attribute, value)` | `entity_set(name, attribute, value)`, which says why when it cannot | `True` when the write landed, `False` when it did not; with fewer than three arguments, `False`. In the editor’s console it answers `False`, since there is no entity. A passage and an agent are refused in words, as for every other write. | `component()` shadows; neither `component_get` nor `entity_get` does, which is why the replacements are two functions rather than one. ## Related [Section titled “Related”](#related) * [Components and entities](/explanation/components-and-entities/) explains what the two are and why a passage may read and not write. * [How to read and change entities from Python](/how-to/use-entities-from-python/) applies these functions. * [How to use components and entities](/how-to/use-components-and-entities/) covers the board side. * [The sandbox and its limits](/reference/python/sandbox/) lists the kept functions. * [Python reference](/reference/python/#refusals) lists the fixed refusal sentences of each surface. # Console variables > The five Python functions over a project's console variables, the twenty-four types, the coercion table and the refusals. Five functions over the console variables a project declares: typed, named values with a default, set anywhere and read anywhere, across boards and across saves. They are bound on the same five [surfaces](/reference/python/#surfaces) as the components API. ## Functions [Section titled “Functions”](#functions) | Function | Answers | Writes on | | ----------------------- | -------------------------------------------------------- | -------------------------------- | | `cvar_names()` | every name, in declaration order | — | | `cvar_type(name)` | the type, spelled as the file spells it | — | | `cvar_get(name)` | what the variable holds now; a list or map is a **copy** | — | | `cvar_set(name, value)` | `None`; the value is coerced or refused | Script node, playthrough console | | `cvar_reset(name)` | `None`; the variable returns to its default | Script node, playthrough console | At design time (the editor’s console, or an agent) a variable holds its default, and `cvar_get` answers that. A passage may read and not write. Every function that takes a name raises `there is no console variable called "<name>"` for a name nothing declares. The name is checked **before** the surface, so a passage with a typo is told about the typo, not about passages. A blank name raises `<function>: needs a name`. ## Types [Section titled “Types”](#types) Eight scalars and, for each, a list and a string-keyed map: twenty-four types. The set is closed, and every editor in the app offers the same twenty-four. | `type` | Holds | Default | | -------------- | -------------------------------------------------------- | ------- | | `int` | a whole number | `0` | | `float` | a number | `0.0` | | `bool` | `true` or `false` | `false` | | `string` | text | `""` | | `markdown` | text with a renderer | `""` | | `asset` | a project-relative path to a file | `""` | | `componentRef` | a project-relative path to a component | `""` | | `json` | any JSON value | `{}` | | `list<T>` | an ordered list of the scalar `T`, for each of the eight | `[]` | | `map<T>` | string keys to the scalar `T`, for each of the eight | `{}` | `cvar_type` answers one of those twenty-four strings, such as `int`, `markdown`, `json`, `list<string>` or `map<float>`. A declared variable never holds `None`. `markdown`, `asset` and `componentRef` behave as `string`. They state what a variable is for, which is what the editor draws a six-line field or a path from; the engine has no filesystem and does not check that a path resolves. `json` is the one type that nests. It takes a list or a dict as itself, so a `list<json>` holds lists and a `map<json>` holds dicts. Every other type refuses a collection where it expects a scalar. ## Coercion [Section titled “Coercion”](#coercion) Every write is lossless or refused. One function decides, and the Set node, `cvar_set`, the campaign window and the compiler all ask it. | Into | Accepted | Refused | | --------------------------------------------- | ------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------- | | `int` | an int; a whole, finite float (`3.0` becomes `3`); the text of either (`"42"`, `" 42 "`, `"3.0"`) | `3.5`, `"3.5"`, NaN, an infinity, a bool, other text, any collection, `None` | | `float` | any number, as a float (`2` becomes `2.0`); the text of a number | a bool, other text, any collection, `None` | | `bool` | a bool; exactly the text `true` or `false`, trimmed | `"True"`, `"yes"`, `1`, `0`, `""`, any collection, `None` | | `string`, `markdown`, `asset`, `componentRef` | text as itself; a number or a bool as its text (`42` becomes `"42"`, `True` becomes `"true"`) | any collection, `None` | | `json` | anything, as it stands: a scalar, a list, a dict | `None`, and nothing else | | `list<T>` | a list whose every element coerces to `T` | any scalar (never wrapped), a map, a list with one element that does not coerce, `None` | | `map<T>` | a map whose every key is a string and every value coerces to `T` | any scalar, a list, a non-string key, a value that does not coerce, `None` | A coerced collection is a fresh list or map, never the one passed in. ### Refusal sentence [Section titled “Refusal sentence”](#refusal-sentence) A refused write reads: ```text <name> holds a <type>, and <value> is not one ``` *an* replaces *a* before a type beginning with a vowel. A string value is quoted, a list is named *a list*, a map *an object*, and `None` *nothing*. Examples: `gold holds an int, and 3.5 is not one`; `tags holds a list<string>, and "sword" is not one`. The Set node’s report and a card’s error text use the same sentence. ## Refusals [Section titled “Refusals”](#refusals) Every refusal is a `RuntimeError` a script can catch. | Caller | Sentence | | --------------------- | ------------------------------------------------------------------------------------------------------------ | | passage | the components API’s own: *a passage describes the world; only a Script node changes it* | | editor console, agent | `no campaign is running, so a console variable holds its default — press play, or put this in a Script node` | Both sentences are fixed. ## Examples [Section titled “Examples”](#examples) ```python # Spend gold if there is enough. gold = cvar_get("gold") if gold >= 25: cvar_set("gold", gold - 25) # A list is a copy: append to it, then write it back. tags = cvar_get("tags") if "wanted" not in tags: tags.append("wanted") cvar_set("tags", tags) # Coercion: these land, and the last one is refused. cvar_set("gold", 3.0) # 3 cvar_set("gold", "42") # 42 try: cvar_set("gold", 3.5) # RuntimeError: gold holds an int, and 3.5 is not one except RuntimeError: pass # What the project declares, with types. declared = {name: cvar_type(name) for name in cvar_names()} ``` ## In a save and an export [Section titled “In a save and an export”](#in-a-save-and-an-export) | Item | Rule | | -------------------------------------------------- | ---------------------------------------------------------------------------------- | | Saved game | carries every variable by name | | Variable absent from a save | holds its default | | Variable removed from the declarations | dropped on resume | | Variable added while a campaign is paused | present on resume | | Variable retyped so the saved value no longer fits | falls back to the default | | Compiled campaign | carries each declaration (name, type, default); every playthrough starts from them | | Project copy | carries `.ripple/cvars.json` itself | The twenty-four types are carried by `.ripple/cvars.json` version 3 and saved-game format 5. A save records what each variable held, not its type, so a build older than that format refuses a newer save. ## Related [Section titled “Related”](#related) * [Console variables](/explanation/console-variables/): what a variable is for and where it lives. * [How to declare and scope a console variable](/how-to/declare-console-variables/) * [Console variable declarations](/reference/console-variable-declarations/): the file format. * [The sandbox and its limits](/reference/python/sandbox/) # The sandbox and its limits > What the Python sandbox has and lacks, where host functions may be called from, and the ceilings a script runs under. Scripts, passage snippets, the console and `python_eval` run in a restricted Python interpreter, not CPython. This page lists what is absent, where the app’s own functions may be called from, and the limits a script is held to. ## The report is the authority [Section titled “The report is the authority”](#the-report-is-the-authority) The interpreter’s limits change between its releases, so the app probes them rather than documenting them. The **sandbox report**, behind the `i` button on the console, runs a suite of small snippets in the real interpreter and reports the outcome row by row. `python_capabilities` returns the same text to an agent ([MCP tools](/reference/mcp-tools/#python_capabilities)). Where this page and the report disagree, the report is right. | Probed group | Asks | Rows | | -------------- | ------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | Language | Which statements and expressions the parser accepts | `def`, `lambda`, `class`, decorators, `yield`, `match`, f-strings, `%` formatting, comprehensions, conditional expressions, `:=`, chained comparison, unpacking, `*args` and `**kwargs`, `global`, closures, recursion, `try` / `except` / `raise` / `finally`, `assert`, `while` / `break` / `continue`, `for` / `else`, slicing, set, bytes and triple-quoted literals, bitwise operators, `//` `**` `%` | | Modules | What `import` can reach | `json`, `math`, `re`, `datetime`, `sys`, `os`, `random`, `collections`, `itertools`, `functools`, `statistics`, `string`, `time`, `copy`, `typing`, `dataclasses`, `enum`, `decimal`, `hashlib`, `base64`, `uuid`, `textwrap`, `operator`, and the network (`requests`, `urllib`) | | Built-ins | Names and methods available without importing | `len`, `range`, `enumerate`, `zip`, `sorted` (with and without `key=`), `reversed`, `map`, `filter`, `sum`, `min`, `max`, `abs`, `round`, `any`, `all`, `str`, `int`, `float`, `bool`, `list`, `dict`, `set`, `tuple`, `isinstance`, `type`, `repr`, `hex`, `chr`, `ord`, `divmod`, `pow`, `print`, `dir`, `eval`, `exec`, `open`, `input`, and the `str`, `dict` and `list` methods in common use | | Host functions | Call-shape probes: where the app’s own functions may be called from | The seven call shapes under [Host functions](#host-functions) | Each row reads supported or not, with the interpreter’s own words when it is not. A missing feature is not a fault in the project. Beyond the four probed groups, the report has a **What this app binds** section listing the functions the surface binds, in declaration order, and a **Limits** section (see [Limits](#limits)). The report can be copied as text. ### Known absences and limits [Section titled “Known absences and limits”](#known-absences-and-limits) These are the rows the report attaches a hint to, with the hint beside each. A hint is shown when that row’s probe fails; the `random` row also names the Dice roller and Weighted branch nodes, which the report’s own hint does not. | Not supported or limited | Instead | | ---------------------------------------- | --------------------------------------------------------------------------------------------------------------------- | | `class` | Use a dict. A component or entity crosses as a dict: `component_get("Goblin")["HP"]`, never `goblin.hp`. | | `random` | Wire a Random number or Random string node. The Dice roller and Weighted branch nodes also cover chance on the board. | | `yield` (generators) | Build and return a list. | | `match` | A chain of `if` / `elif`. | | `%` formatting, `str.format` | Use an f-string. | | `collections` (where the report says no) | A plain `dict` covers most of it. | | `statistics` (where the report says no) | `sum(xs) / len(xs)`. | | Network (`requests`, `urllib`) | None. The sandbox has no network. | | `open`, the filesystem | A script reads the story through the host functions, not the disk. | | `os` | Importable, but the calls that touch a disk are not wired up. | | `input` | A passage asks the reader questions; a script does not. | | `dir()`, `inspect` | None. Nothing inside the sandbox can list what the sandbox has, which is why the report asks one question at a time. | A snippet is Python-shaped rather than Python-portable. ## Host functions [Section titled “Host functions”](#host-functions) Everything the app binds is a **host function**: present without an `import`, with no module to import it from. The report’s Host functions rows ask where one may be called from. The probes are written so that they change nothing. | Call shape | Works | If not | | ---------------------------- | ----- | ----------------------------------------------------------------------------- | | At the top level | yes | | | Inside a `def` | yes | | | Inside a comprehension | yes | | | Inside `map()` or `filter()` | no | Rewrite it as a comprehension. | | Inside `sorted(key=…)` | no | Build a list of `(key, name)` pairs in a comprehension first, then sort that. | | With keyword arguments | yes | Every optional argument in the API is keyword-only. | | Catching a refusal | yes | A refusal arrives as `RuntimeError`. | ```python pairs = [(entity_get(n)["HP"], n) for n in entity_names()] weakest = sorted(pairs)[0][1] ``` A value a host function answers is a copy: `e = entity_get("Mira"); e["HP"] -= 3` runs and changes nothing, and `entity_set` is how a script writes. See [Reads are copies](/reference/python/#reads-are-copies). ## Limits [Section titled “Limits”](#limits) | Limit | Value | Past it | | --------------- | --------------------------------------------------- | ------------------------------------------------------------- | | Time | 2000 ms (two seconds), checked on every instruction | The script is stopped with a `Timeout`; the story carries on. | | Memory | 64 MB, approximately | `MemoryError` | | Recursion depth | 200 frames | `RecursionError` | | Runaway scripts | 3 per playthrough | See below. | The report marks each limit as enforced. ### Runaway scripts [Section titled “Runaway scripts”](#runaway-scripts) A script that was stopped by the time limit is not started again in the same playthrough. The match is on the script’s source, not its name: a snippet’s name is the same for every passage, and a Script node’s source includes its inputs, so the same node with different inputs is a different script. After three distinct scripts have been stopped in one playthrough, no script starts at all. A script that reaches this has an infinite loop in it. ## Interpreter [Section titled “Interpreter”](#interpreter) The interpreter is monty; [Open source](/about/open-source/) says whose it is. A campaign can be built with no Python at all: see [Export options](/reference/export-options/). ## Related [Section titled “Related”](#related) * [Python reference](/reference/python/): the functions, the five surfaces and the refusal sentences. * [Python in a story](/explanation/python-in-a-story/): why Python is a host surface. * [How to run Python in a story](/how-to/script-a-passage/): writing a script and reading its result. * [How to build a campaign with no Python](/how-to/turn-python-off/): leaving the interpreter out of a build. # Sounds > The signature of play_sound, its arguments, its behaviour on each surface, and the sentences it refuses with. `play_sound` is the Python counterpart of the [Play sound node](/reference/nodes/#play-sound). It is bound on every surface; what differs per surface is whether it cues a sound or refuses. ## `play_sound(path, volume=1.0, speed=1.0)` [Section titled “play\_sound(path, volume=1.0, speed=1.0)”](#play_soundpath-volume10-speed10) Cues a file the campaign carries: once, immediately, returning before anything has been heard. The return value is always `None`. The host may not have opened the file yet, and the story is not held for it. | Argument | Required | Default | Meaning | | -------- | -------- | ------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `path` | yes | none | The file, as the project spells it (`sfx/bell.ogg`) or as the bundle does (`media/sfx/bell.ogg`). Both name the same file, because an export files every asset under `media/`. Leading and trailing whitespace is ignored. | | `volume` | no | `1.0` | Gain, 0 to 1. | | `speed` | no | `1.0` | Playback rate, 1 being the file’s own. Must be more than 0. | `volume` and `speed` may be given by position or by name. No other keyword is accepted; this is the [calling rule](/reference/python/#calling) every function on this tier keeps. ```python play_sound("sfx/bell.ogg") play_sound("sfx/bell.ogg", volume=0.4) play_sound("media/sfx/bell.ogg", 0.4, 1.2) ``` ## Not provided [Section titled “Not provided”](#not-provided) | Absent | Where it is done instead | | ------------------------------------------------------------ | ---------------------------------------------------------------------- | | Setting a soundscape from Python | A soundscape is set on a passage, as a document the project can reuse. | | Stopping a sound, waiting for one, or asking what is playing | None. A cue is fire and forget. | | A path outside the bundle | None. Every sound a story can play is in the campaign. | ## Surfaces [Section titled “Surfaces”](#surfaces) | Surface | `play_sound` | | -------------- | ---------------------------------------------------------------------------------------------------------------------------- | | script | Cues. | | playthrough | Cues when the campaign window handed the console something to play through; otherwise refuses with the design-time sentence. | | passage | Refuses, with a sentence of its own. | | console, agent | Refuses, with the design-time sentence. | A passage always refuses; its snippets run again on every redraw and change of language. ## Refusals [Section titled “Refusals”](#refusals) Every refusal is a `RuntimeError` a script may catch, carrying one of these sentences. The sentences are frozen, because a script may branch on which it received. | When | Sentence | | -------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------- | | A passage calls it. | `a passage cannot play a sound — its snippets run again every time it is redrawn, so put this on a Play sound node or in a Script node` | | The design-time console or an agent calls it, or a live console has nothing to play through. | `no campaign is running, so there is nothing to play this through — press play, or put this on a Play sound node` | | `path` is blank or not a string. | `play_sound: needs a path` | | `path` names nothing the campaign carries. | `play_sound: nothing in this campaign is called "<path>"` | | `volume` is outside 0 to 1. | `play_sound: volume is <v>, and it runs from 0 to 1` | | `volume` is not a number. | `play_sound: volume has to be a number` | | `speed` is at or below 0. | `play_sound: speed is <v>, and it has to be more than nothing` | | `speed` is not a number. | `play_sound: speed has to be a number` | Order of checks: the arguments are checked before the surface, so a call in the design-time console reports a wrong volume rather than only that the console cannot play. The path is resolved after both, because a surface that cannot play has nothing to resolve against. ## What a host is told [Section titled “What a host is told”](#what-a-host-is-told) A cue reaches whatever is playing the campaign as the file’s bundle path, a gain and a rate, together with the id of the Script node whose program made the call. One event is sent per call, in call order. The runtime plays nothing itself. ## Versioning [Section titled “Versioning”](#versioning) The name and the sentences above are a contract with every script written against them from 1.4 onward. They are added to, not repurposed. Adding an argument is a widening; changing what an argument means is not. ## Related [Section titled “Related”](#related) * [Soundscapes and one-shot sounds](/explanation/soundscapes-and-sounds/): why the runtime plays nothing and where a soundscape is set. * [How to add sound to a story](/how-to/add-sound-to-a-story/) * [Nodes](/reference/nodes/#play-sound): the Play sound node. # The world outside the story > location_get, location_distance and datetime.now — what each answers, when it is refused, and what a reader's stand-in does to it. A story may read two things about the reader’s world: **where they are** and **what time it is**. Each is read only when the reader allows it. A story that reads either is asked about it before it begins, and the answer is kept in the app’s settings, where the reader can change it. The same reads are on a board as the [World nodes](/reference/nodes/#get-location). ## `location_get()` [Section titled “location\_get()”](#location_get) Where the reader is, as a dict, or `None` when the host has no position yet. | Key | Holds | | ----------- | --------------------------------------------------------------------------------------------------------------------------------- | | `label` | A place’s name. Only a stand-in typed as a name has one; a real reading has none, because nothing is looked up to name the place. | | `latitude` | Degrees north, negative for south. | | `longitude` | Degrees east, negative for west. | | `accuracy` | How many metres the reading may be off. | | `when` | When the reading was taken, as ISO 8601 text. | Any key may be `None`. A real reading has coordinates, an accuracy and a time; a stand-in typed as `41.9028, 12.4964` has coordinates and nothing else; a stand-in typed as `Rome, Italy` has a label and nothing else. ```python here = location_get() if here and here["label"]: said = "in " + here["label"] elif here: said = f"at {here['latitude']:.2f}, {here['longitude']:.2f}" else: said = "somewhere" ``` ## `location_distance(latitude, longitude)` [Section titled “location\_distance(latitude, longitude)”](#location_distancelatitude-longitude) How far the reader is from a place, in metres along the ground, or `None` when the reading has no coordinates or there is no reading yet. It is allowed and refused exactly as `location_get()` is. | Argument | Meaning | | ----------- | ------------ | | `latitude` | −90 to 90. | | `longitude` | −180 to 180. | ```python near_rome = (location_distance(41.9028, 12.4964) or 1e9) < 10_000 ``` Both arguments are positional and required. A place off the globe, or one that is not a number, is refused before anything is read. ## `datetime.now()` and `date.today()` [Section titled “datetime.now() and date.today()”](#datetimenow-and-datetoday) The standard library’s own names, unchanged: `datetime.now()` is the reader’s wall clock, `datetime.now(timezone.utc)` the same moment in UTC, `date.today()` the reader’s date. **Within one step of the story time does not move**, so two reads in one passage see one moment. ```python from datetime import datetime hour = datetime.now().hour greeting = "Good evening" if hour >= 18 else "Good day" ``` There is no `time` module. ## When a read is refused [Section titled “When a read is refused”](#when-a-read-is-refused) A read is answered in one order: 1. **The real value**, when the reader allowed it and the host has one. 2. **The reader’s stand-in**, when they gave one. A stand-in is what the reader would rather a story believed, typed in the app’s settings beside the answer; a story cannot tell it from the real thing. 3. **Otherwise a refusal**: `PermissionError` when the reader did not allow it. When they allowed it and the host has nothing, `location_get()` answers `None` and the clock raises `OSError`. So `except PermissionError:` is how a story says what happens without: ```python from datetime import datetime try: hour = datetime.now().hour except PermissionError: hour = 12 # a story that cannot know, pretends noon ``` | Refused | Exception | Sentence | | -------------------------------------------- | ----------------- | -------------------------------------------------------------------------------------------------------- | | The location, not allowed | `PermissionError` | `The location has not been allowed for this story` | | The clock, not allowed | `PermissionError` | `The date and time has not been allowed for this story` | | The clock, allowed with nothing to give | `OSError` | `this host has not said what time it is` | | Any read in the editor’s console or over MCP | `PermissionError` | `no story is playing, so nothing has allowed this read — press play` | | `location_distance`, a place off the globe | `RuntimeError` | `location_distance: the latitude has to be a number from -90 to 90` (or the longitude, from -180 to 180) | Each read that does not get the real value is also said once in the play window and in Messages, with a [diagnostic code](/reference/diagnostics/#irt05): `I.RT.05` for a stand-in, `W.RT.06` for a refusal, `I.RT.07` for nothing to give. ## In the editor [Section titled “In the editor”](#in-the-editor) The clock is allowed unless the reader says otherwise; the location is asked about. With the location allowed, the editor asks the computer’s own location service where it is when the story begins, at about the accuracy of a town, and waits a few seconds for an answer before beginning without one. **A playthrough an agent drives over MCP is never told the real position**: it gets the stand-in or the refusal. Another program playing an exported campaign decides for itself how it asks and what it hands over. See [How to play a campaign in your own program](/how-to/play-a-campaign-in-your-program/). ## Versioning [Section titled “Versioning”](#versioning) These names are part of the 1.6 contract and are added to rather than repurposed. A campaign that reads either is bundle format 16, which an older player refuses rather than playing without asking. ## Related [Section titled “Related”](#related) * [Nodes](/reference/nodes/#get-location): Get location, Near, Get date & time and Time branch. * [Python reference](/reference/python/): every host function and how a refusal reads. # The session log and reports > What a session folder holds, what is redacted and recorded, what the crash-report dialog lists, what usage statistics carry, and where the files live. Each run of the app writes a **session**: a folder of log files and two small records. No setting uploads the session log. The one thing that sends session files is the crash report, which asks first; usage statistics send a separate small record and no log, and are off until switched on. What each carries is listed below, and in [What the app sends](/about/what-the-app-sends/). ## Where the files live [Section titled “Where the files live”](#where-the-files-live) | | | | -------------------- | --------------------------------------------------------------------------------------------------------------------- | | Location | `diagnostics/sessions/<date>/<id>/` inside the application-support directory (the platform’s application data folder) | | `<date>` | The day the session started, in UTC, as `YYYY-MM-DD` | | `<id>` | Eight lowercase hexadecimal characters | | Open the current one | **Help ▸ Troubleshooting ▸ Open this session’s folder** | | Id shown | **Settings ▸ Support**, under the row *Open this session’s folder*, as *This session is* followed by the id | | Pack the current one | **Settings ▸ Support ▸ Save this session as a zip**: the session’s log and reports in one file | All of these are also in the command palette. ## Files in a session folder [Section titled “Files in a session folder”](#files-in-a-session-folder) Each file has exactly one writer. A writer with nothing to say does not create its file, so an absent `engine.log` means the engine never started, and an absent `flutter.log` means no framework error was reported. | File | Written by | Written | Holds | | --------------- | ------------------------------------------------------- | ------------------------------------------ | --------------------------------------------------------------------------------------------------------------------------------------------- | | `session.json` | the app | at open, at the first error, at close | When the session started and ended, its verdict, the error counts | | `metadata.json` | the app | at open, and again once the engine reports | Version and build of the app, operating system, a short allowlist of device fields, the engine’s version and whether it was built with Python | | `editor.log` | the app | continuously | Every record the app writes | | `flutter.log` | the app | on a framework error | Errors the app reported; also in `editor.log` | | `platform.log` | the app | on an unhandled platform error | Errors the operating system reported to the app; also in `editor.log` | | `engine.log` | the engine (the part that plays and compiles campaigns) | continuously | The engine’s own records, with the same redaction. A crash in it records the file and line it came from, in a release build too | A log file that outgrows 16 MiB continues into a numbered part in the same folder: `editor.log`, `editor.2.log`, `editor.3.log`. Parts are kept and deleted with their session. ### `session.json` [Section titled “session.json”](#sessionjson) | Key | Meaning | | ------------------ | ------------------------------------------------------ | | `version` | Integer, currently 1 | | `id` | The folder’s name | | `startedAt` | UTC timestamp | | `endedAt` | Present only once the session closed in an orderly way | | `pid` | The process id | | `verdict` | `clean`, `abnormal`, or absent | | `errors`, `fatals` | Counts of records at those levels | The file is written at open, again the first time a record at `ERROR` or `FATAL` is written (the verdict becomes `abnormal`), and at close. A crash writes what happened and closes off the session in one go, so the file is complete even when the app is not. Unknown keys are ignored; a file from a newer version is neither parsed nor deleted. ## The record line [Section titled “The record line”](#the-record-line) One record is one line, in fixed columns: ```plaintext 2026-09-08T14:03:11.482Z ERROR workspace could not open the project {path: <path>/act1, ms: 12} ``` | Column | Content | | ------ | ----------------------------------------------------------------------------- | | 1 | UTC timestamp, `YYYY-MM-DDTHH:MM:SS.mmmZ`. Times are never in the local clock | | 2 | Level: `TRACE`, `DEBUG`, `INFO`, `WARN`, `ERROR`, `FATAL` | | 3 | The feature that wrote it | | 4 | The message, then optional fields as `{key: value}` | | Continuation marker | Carries | | ------------------- | -------------------------------- | | four spaces, `\|` | The rest of a multi-line message | | four spaces, `!` | The error | | four spaces, `#` | One stack frame each | | four spaces, `…` | A notice that the record was cut | Every line after the first begins with four spaces, so only the first line of a record starts at column 0. A record is capped at 8192 characters. Warnings and above reach the file at once, together with the records written just before them. ### The `call` field [Section titled “The call field”](#the-call-field) A record about work that crosses between the app and the engine carries an integer `call`, and both sides write the same one, for a start and for an outcome. A compile is written down this way, so the two halves can be read together: ```plaintext editor.log INFO compile Compiling a campaign {call: 1} engine.log INFO compiler compiling a campaign {call: 1, chars: 162} engine.log INFO compiler compiled {call: 1, ms: 2, chars: 133} ``` ## What is recorded [Section titled “What is recorded”](#what-is-recorded) | Kind | What a line says | Not recorded | | ------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------- | | Network requests | Checking for an update, fetching the contributor list and downloading an installer each log where the request went, what came back and how long it took | The query string (everything after the `?`) and any response content | | MCP calls | Every tool call, resource read and prompt an MCP client makes: which client, which tool, which argument names, how long it took and whether it worked | Argument contents | | Background workers | The version-control worker, and on Windows agent traffic, each record that they failed | | ## What is redacted [Section titled “What is redacted”](#what-is-redacted) Every string written to any of the six files is redacted before it reaches a file. The rules run in this order: | # | Matches | Becomes | | - | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------- | | 1 | Credentials: GitHub tokens (`ghp_`, `gho_`, `ghu_`, `ghs_`, `ghr_`, `github_pat_`), `sk-` keys, `Bearer`, `Basic` and `Token` headers, `key=value` where the key admits it is a secret | `<redacted>`, keeping the scheme or key name | | 2 | User-info in a URL (`://user:pass@`) | `://<redacted>@` | | 3 | The folders of known projects, longest first | `<project>` | | 4 | Your home folder | `~` | | 5 | E-mail addresses, including accented and non-Latin local parts | `<email>` | | 6 | Your account name, as a whole word, when it has at least 3 characters (including names ending in an accented letter) | `<user>` | | 7 | Any other absolute path, keeping the last segment | `<path>/act1.graph.json` | | 8 | IP addresses, except `127.0.0.1`, `0.0.0.0` and `::1` | `<ip>` | The app’s own install directories are exempt from rule 7. Caution No list of rules can know that a board’s name is private, so look through a log before posting it. ## Retention [Section titled “Retention”](#retention) At startup the app judges every earlier session from `session.json` alone, without reading a log file. | `endedAt` | `verdict` | Outcome | | ------------------------------------------- | ---------- | ----------------------- | | present | `clean` | deleted | | present | `abnormal` | kept | | absent | `abnormal` | kept | | absent | absent | deleted | | unreadable, or a `version` it does not know | | kept, until the age cap | Kept sessions are capped at twenty and thirty days, newest first. The session in progress is never considered. ## The crash report [Section titled “The crash report”](#the-crash-report) If the app crashed, the next launch shows a dialog titled *Ripple Effect closed unexpectedly*, with the date and time of the crash. It is asked once per crash, and only about the newest crash. If the app cannot reach its server when it would ask, the question is deferred to a later launch and says it is about an earlier crash; it is never asked mid-session. | Control | Effect | | -------------- | ----------------------------------------------------------------------------- | | *Send report* | Sends the report. The answer is final for that report | | *Don’t send* | Keeps it on the computer. The answer is final for that report | | *Ask me later* | Leaves the question owed; also what closing the app with the dialog open does | | *Show files* | Opens the files so you can read them before answering | There is no setting that sends crash reports automatically and none that refuses them. If a send fails, the dialog names the kind of failure (no connection, the server turned it down, or something else) and offers *Ask me later* as well. The dialog lists each file in the report, its size, and what it contains. The report is the memory snapshot plus these session files, as they exist at the crash. | File | What the dialog says it holds | | --------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | | minidump | A snapshot of the app at the moment it crashed: what each part was running, the libraries loaded, the processor’s state and small pieces of the memory around them. It names your operating system and processor, and on Linux and macOS the command line and environment variables the app was started with | | `session.json` | When that run started, and whether anything had gone wrong before the crash | | `metadata.json` | The app’s version and how it was installed, your operating system and version, and your device’s model | | `editor.log` | What the app recorded during that run | | `engine.log` | What the story engine recorded during that run | | `flutter.log` | Errors the app reported during that run | | `platform.log` | Errors the operating system reported to the app during that run | When a log is large, only its end is sent, and the dialog marks the file *last X of Y*. Only the first part of a rolled log is attached. Alongside the files: the app’s version, whether you were on the home screen or in a project (never which one), the id of the run so it can be matched with its logs, and a random number the crash reporter made for this installation. The dialog states that there is no account and no name in the report. It also shows a report id: quote it when writing to the address shown in the dialog to ask for a sent report to be deleted. The files are masked as in the table above before the dialog opens, and the folders of every known project are masked too. The one thing no masking can promise: the memory snapshot may hold fragments of whatever text the app was working on at that moment. ## Usage statistics [Section titled “Usage statistics”](#usage-statistics) Off until switched on. The switch is **Settings ▸ Data control ▸ Usage statistics**, *Off* or *On*, and the first-launch pages ask the same question with neither answer preselected. While it is on, the row shows the identifier and lists everything that is sent. | Field | Content | | ---------------- | ----------------------------------------------------------------------------------- | | Use | That this installation was in use today: one record a day at most | | Day | The day it was sent, in UTC, never the time | | Identifier | A random identifier for this installation, shown in the row while statistics are on | | Version | The app’s version | | Operating system | The family: Linux, macOS or Windows | Nothing is sent until the switch has been on for an hour. Nothing you write, no file or project names and no network address is included, and no account is involved. Switching off destroys the identifier, so rows already sent can no longer be tied to you; switching on again starts a new installation. To have rows sent under an identifier deleted, write to the address shown in the row with the identifier before switching off. The governing text is the Privacy Notice, section 4.5. ## Reporting a bug [Section titled “Reporting a bug”](#reporting-a-bug) | Control | Where | | ----------------------------------------------------------------------------- | ------------------------------------------------------------------ | | Support page | **Settings ▸ Support**, or *Support and feedback* in the Help menu | | Copy the package details, copy the device details, open this session’s folder | **Help ▸ Troubleshooting** | | *Show every tip again* | The Help menu itself | The support page opens the matching form, filled in with the version, your operating system and which installer this copy came from, in the public repository [`ripple_effect_support`](https://github.com/ConnectingTheDots-DCWK/ripple_effect_support), which is also where questions and feature requests go. The steps are in [How to report a bug](/how-to/report-a-bug/). ## Related [Section titled “Related”](#related) * [Updates, logs and Messages](/explanation/updates-logs-and-privacy/): why the log and Messages are kept apart, and what stays on the machine * [How to report a bug](/how-to/report-a-bug/) * [What the app sends](/about/what-the-app-sends/) * [Settings, accessibility and updates controls](/reference/settings/) # Settings, accessibility and updates controls > Every page and option of the Settings dialog, what Contrast and Colour vision change, the release notes and engine update controls and the Support page. The gear in the app bar, on every screen, opens the Settings dialog. Settings belong to the app, not to a project, so they follow you from project to project. Every option is a choice among a few named things, drawn as a segmented control with the current choice lit, and a line under the control says what that choice means. A choice takes effect when pressed; there is no Apply. The exception to the segmented control is *Language*, a drop-down. The app bar keeps only what has news: an available update, and the agents mark while an assistant is connected or could not reach the app. The home page’s bar also has the News button, with a count while something is unread; see [News](#news). ## Pages [Section titled “Pages”](#pages) The dialog has six pages down a rail on its left. Every page has the same height, and both columns scroll. | Page | Rail description | Holds | | -------------- | ------------------------------------------ | ------------------------------------------------------------------------------------------------------ | | *General* | Appearance, accessibility, language | How the app looks, reads and plays | | *Data control* | What leaves this machine | *Usage statistics* | | *Updates* | Channel, release notes, engine fixes, news | *Release channel*, *Release notes* and its filters, *Engine updates*, *Use the bundled engine*, *News* | | *Agents* | Assistants that reach the app | The assistant connection; see [MCP tools](/reference/mcp-tools/) | | *Support* | Questions, bugs, ideas | [The Support page](#support-page) | | *About* | Version, credits, licences | The About box | *Agents*, *Support* and *About* are pages of the dialog rather than settings. The command palette’s *Agents*, *Support* and *About* commands open their pages, and so does the agents mark in the app bar. *Documentation* leads the pointers in the About box. Panels that explain a feature (the variables panel, the agents page, the Python sandbox, the export dialog, the metadata editor and the state machine editor’s bar) say what you are looking at and link to the documentation page that carries the rest. ## General [Section titled “General”](#general) Rows in page order. | Row | Choices | Meaning | | --------------- | --------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------- | | *Language* | *System*, then each available language under its own name | The language of the app’s own menus, dialogs, messages and tips | | *Appearance* | *System*, *Light*, *Dark* | *System* follows the operating system; the others are always that theme | | *Links* | *Curved*, *Right-angled* | How wires are drawn on a board | | *Contrast* | *Standard*, *More* | How firmly text and controls separate; see [Contrast](#contrast) | | *Colour vision* | *Off*, *Protan*, *Deutan*, *Tritan* | Which palettes are swapped; see [Colour vision](#colour-vision) | | *Playing* | *128*, *512*, *2,048*, *8,192*, *None* | The loop ceiling | | *Writing* | *Check spelling and grammar*, *Off* | Whether English passages and notes are checked as you type; see [How to check spelling and grammar](/how-to/check-spelling-and-grammar/) | ### Language [Section titled “Language”](#language) * Each language is listed under its own name, so a language is findable when the app is showing one that cannot be read. * *System* follows the operating system’s language and says which language it currently resolves to. * The setting is the language of the app only. A project’s own text and languages are set in the project’s settings; see [Passages, choices, languages and reading time](/reference/passages/). * The row is shown only in a build that ships more than one language. * A stored language this build does not offer is read as *System*. * Italian is currently the only language besides English. It was drafted by a model and spot-checked rather than read line by line, so report anything that reads oddly. ### Appearance [Section titled “Appearance”](#appearance) *System* follows whatever the OS is set to. *Light* and *Dark* are always that theme. Appearance is independent of Contrast and Colour vision, so dark and *More* contrast can be on together. The default look is quiet and monochrome: the main button in each view is filled with the text colour, and selection, focus and the active tab are drawn in it too, in light and dark alike. Colour is kept for what it tells you: node families, traced routes, version-control marks and warnings. Links in Markdown are underlined, and the app icon is a dark plate with the mark in white. ### Links [Section titled “Links”](#links) | Choice | Wires | | -------------- | -------------------------------------------------------------------------------------------------------------- | | *Curved* | Curve from port to port. Double-click a wire to add a handle and drag it to route the wire. | | *Right-angled* | Run in straight legs and turn at right angles. A handle is a corner; dragged, it lines up with its neighbours. | ### Playing [Section titled “Playing”](#playing) *Playing* is the loop ceiling: how many times one loop node may go round before a campaign stops with an error and offers you the node. It is counted per loop and per board; jumping away and coming back starts the count again. | Choice | Behaviour | | ------------------------------ | ---------------------------------------------------------------------------------------------------------------------- | | *128*, *512*, *2,048*, *8,192* | The campaign stops with an error at that count | | *None* | A loop node may go round for ever; a *While* with nothing breaking out of it never ends and does not stop the campaign | ## Contrast [Section titled “Contrast”](#contrast) | Choice | Effect | | ---------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | | *Standard* | Text and controls clear the usual accessibility floor. | | *More* | Body and caption text reach the stricter accessibility grade (AAA). Every text field, button, chip, switch and checkbox has a findable edge. Muted text, control edges and hairlines are firmer. | Colours stay the same and the page does not change shape. ## Colour vision [Section titled “Colour vision”](#colour-vision) | Choice | Palettes recoloured for | | -------- | -------------------------------------------------- | | *Off* | The house colours | | *Protan* | Protanopia and protanomaly | | *Deutan* | Deuteranopia and deuteranomaly, the commonest kind | | *Tritan* | Tritanopia and tritanomaly | A mode recolours everything the app uses colour to tell apart: * the node families on a board; * traced routes; * the marks version control puts on files; * status colours; * syntax highlighting; * the diff, which stops being red and green. Palettes are chosen so that things which share a shape do not share a colour, and are checked against a simulation of all eight kinds of colour blindness. Neutral greys are not changed. ### Limits [Section titled “Limits”](#limits) * Three places tell categories apart by hue and nothing else: the board minimap and the overview’s, where a family is a few-pixel rectangle with no icon or label; the traced routes; and a soundscape clip too narrow to have its mode written on it. A colour-vision mode improves them without making them readable to everyone. * There is no mode for either achromatic kind of colour blindness. * The app has no screen-reader support. See [What the accessibility settings do and do not claim](/explanation/accessibility-limits/). ## Text size [Section titled “Text size”](#text-size) Text size is not a setting of the app. | Part | Behaviour | | ---------------- | -------------------------------------------------------------- | | Chrome | Follows the system’s text size, up to double | | Cards on a board | Stay at the size they were drawn at; zoom makes a board bigger | ## Data control [Section titled “Data control”](#data-control) | Row | Choices | Behaviour | | ------------------ | ----------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | | *Usage statistics* | *Off*, *On* | *Off* until pressed to *On*. The full list of what is sent is shown under the control, and the identifier is shown while on. Nothing is sent until it has been on for an hour. | Switching off destroys the identifier. To have what was sent deleted, write to the address shown on the page with the identifier before switching off. Switching on again starts a new installation. What is sent is listed in [What the app sends](/about/what-the-app-sends/). ## Updates [Section titled “Updates”](#updates) ### Release channel [Section titled “Release channel”](#release-channel) Which releases the app offers you. **Stable** offers tested releases only. **Preview** also offers early builds of the next version, as soon as they are ready: their version ends in `-prev` and a number, such as 1.6.0-prev.2. A preview can change or break things before they reach Stable, and a project saved in one may be written in a newer format that Stable cannot open until it catches up. | Control | Effect | | ---------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------- | | *Release channel*: *Stable* | Only stable releases are offered. | | *Release channel*: *Preview* | Previews are offered too. When a stable release is newer than the latest preview, that release is offered instead. Switching asks you to confirm first. | After a switch the app checks again and says what it found under the control: a newer release, and that the download button at the top of the window installs it; nothing newer, so you stay where you are; or that the update server could not be reached. A version you install from the app is never older than the one you are running, on either channel. Switching back to Stable stops the previews; it does not reinstall an older version. When you switch back to Stable while running a preview that is newer than Stable, the app warns you first and recommends staying on Preview. If you switch anyway, the preview you are running gets no updates until Stable passes it, and a project saved in the preview may not open in Stable. A copy installed from a preview starts on Preview. The download page only ever offers Stable: Preview is reached from inside the app. ### Release notes [Section titled “Release notes”](#release-notes) After an update the app shows what changed in each release you have not read yet, newest first. An update that skipped versions shows every one in between. Each release wears tags under its name: its size, and what it brings. | Tag | Meaning | | ------------------------- | ---------------------------------------------------------------------------- | | *Major*, *Minor*, *Patch* | The size of the release, read from its version number: 2.0.0, 1.5.0, 1.5.1. | | *Hotfix* | A patch cut quickly to fix one problem. It takes the place of *Patch*. | | *New features* | Something you could not do before. | | *Improvements* | Something you could already do works better. | | *Fixes* | Something that did not work now does. | | *Action needed* | Something you have to do or know, such as a file older versions cannot open. | | *Security* | A security problem is fixed. | *Don’t show release notes again*, at the bottom of the notice, turns it off. The notice says where to turn it back on. | Control | Effect | | --------------------------------------- | -------------------------------------------------------------------------------------------------------------------- | | *Release notes*: *Show after an update* | The notice appears after an update, for the releases the filters below let through. | | *Release notes*: *Don’t show* | No notice appears. *What’s new*, on the About page, still lists every release. The filters stay on the page, greyed. | | *Release type* | Which sizes of release are announced. | | *Contents* | Which kinds of change are announced. | A release is announced when its size is ticked **and** at least one of its contents is ticked. Unticking *Patch* hides every patch, whatever it fixes. With nothing ticked in a group, no release is announced. Releases you skip while the notice is off or filtered out are not shown later. A preview’s notes are shown only to a preview, and wear a *Preview* tag; a stable release’s notes cover every preview before it. ### Engine updates [Section titled “Engine updates”](#engine-updates) The part of the app that plays and compiles campaigns can be fixed without a new installer. A fix is fetched in the background, checked against a signature before it is trusted, and used from the next launch. If the app crashes with a fix, the fix is set aside automatically. A fix never changes what the app can do. A button in the app bar shows while a fix is arriving, and again once it is ready, with the version it moves to. | Control | Effect | | ------------------------ | ------------------------------------------------------------------------------------------------------------- | | *Engine updates*: *On* | Fixes are fetched in the background and used from the next launch. | | *Engine updates*: *Off* | The engine stays the one this version came with, or the last fix already fetched. | | *Use the bundled engine* | Removes the fixes already fetched. The bundled engine is used from the next launch, and the page confirms it. | While a fix is in use the page reads *Running engine* followed by its version, a fix fetched after this version was installed. The engine button opens a dialog with two states. While a fix downloads, it says the fix is being downloaded and checked and that nothing changes until the app next starts. When the fix is ready, it says it has been downloaded and checked, takes effect at the next start, and that there is nothing to install. What the request carries is in [What the app sends](/about/what-the-app-sends/). ### News [Section titled “News”](#news) Short items from us, such as a new binding or integration, a change worth knowing about, or something to do before an update, shown on the home screen without an update. The **News** button on the home page’s bar opens them as a screen of their own, newest first, each with a banner and its title. A number on the button counts the items you have not opened. Opening an item marks it and everything older as read, and *Mark all as read* does it for the lot. The first time, only items from the last thirty days count as unread. | Control | Effect | | ------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------- | | *News*: *On* | Once per launch the app asks `ripplefx.app` for the news. The request carries nothing about you, and which items you have read stays on your machine. | | *News*: *Off* | Nothing is fetched, and the News button is hidden. | The news needs version 2.0 of the licence agreement. A copy that accepted 1.0 fetches nothing until it has accepted 2.0, whatever the switch says, and the page says so under it. If the news cannot be loaded, the screen says so and the next launch tries again. What the request carries is in [What the app sends](/about/what-the-app-sends/#news). ## Support page [Section titled “Support page”](#support-page) Opened by *Settings ▸ Support* or *Support and feedback* in the Help menu. Bugs, questions and ideas go to the public support repository, `ripple_effect_support`, on GitHub. | Entry | Does | | -------------------- | -------------------------------------------------------------------------------------------------------------------------------- | | *Ask a question* | Opens Discussions: usage, ideas, showing what you made | | *Report a bug* | Opens the bug form with the version, operating system and installer origin already answered, and the technical details filled in | | *Request a feature* | Opens a new issue tagged as an enhancement | | *Browse open issues* | Shows whether a problem is already known | *What to attach* shows only while a session folder exists: | Entry | Does | | ---------------------------- | ---------------------------------------------------------------------------------------------------------------- | | *Save this session as a zip* | Packs the session’s log and reports into one file to attach to an issue. Its detail line names the session’s id. | The page states what is replaced before anything is written down: username and home folder, the project’s location, e-mail addresses, IP addresses and anything shaped like a key or a token. Times are recorded in UTC. Nothing uploads these files; the one exception is a crash report, which the app asks about first and sends only if you say yes. ### Help menu [Section titled “Help menu”](#help-menu) The rest of what you might attach, and the tips, are in the Help menu rather than on the page. All of them are also command palette commands. | Entry | Does | | ------------------------------------------------ | -------------------------------------------------------------- | | *Show every tip again* | Each tip appears once more, as you reach the thing it is about | | *Troubleshooting* ▸ *Copy package info* | Copies which build this is and how it was installed | | *Troubleshooting* ▸ *Copy device info* | Copies the operating system underneath | | *Troubleshooting* ▸ *Open this session’s folder* | Opens the log, the crash reports and the build information | **Links the app opens:** those for reporting a bug, asking a question, the survey, the documentation, the licence and the Privacy Notice go through an address on `ripplefx.app`, which redirects to where the thing lives. ## First launch [Section titled “First launch”](#first-launch) After the agreement pages, a first launch shows two pages. Both can be skipped, and everything on them is in Settings afterwards. | Page | Contents | | ------ | ----------------------------------------------------------------------------------------------------------------------------------------------------------- | | First | Appearance, *Contrast*, *Colour vision* and how wires are drawn, beside a small preview of the app that changes as you choose | | Second | The *Usage statistics* question with neither answer chosen and the full list of what would be sent, how crash reports are asked, and a mention of donations | ## Related [Section titled “Related”](#related) * [How to change the app’s language, contrast and colour vision](/how-to/change-app-language-contrast-and-colour/) * [What the accessibility settings do and do not claim](/explanation/accessibility-limits/) * [Updates, logs and Messages](/explanation/updates-logs-and-privacy/) * [How to report a bug](/how-to/report-a-bug/) * [The session log and reports](/reference/session-log/) # Soundscape files > Audio file types, the soundscape file format, clip and timeline properties, editor shortcuts, the rule for which soundscape is in force, and where the files live. ## Audio files [Section titled “Audio files”](#audio-files) Every audio file in the project folder is in the registry and in every picker. | Property | Value | | ------------- | ---------------------------------------------------------------- | | Extensions | `.mp3`, `.wav`, `.ogg`, `.oga`, `.flac`, `.m4a`, `.aac`, `.opus` | | Registry kind | `audio` | | Registry name | The whole basename, extension included | | Outside file | *Import…* copies it to `.ripple/external/` | | Opens as | A tab with play, pause and a scrub bar | ## Soundscape file [Section titled “Soundscape file”](#soundscape-file) | Property | Value | | ---------------- | -------------------------------------------- | | Name | `*.soundscape.json` | | Contents | A timeline of clips over one clock, on lanes | | Registry kind | `soundscape` | | Format `version` | 1 | | Created from | The **+** menu of the file tree | | Named by | A Narration node, by catalog id | A clip names its audio by catalog id, so renaming or moving an audio file never breaks a clip. The file carries integer milliseconds throughout and every default written out. A file written by a newer build is refused rather than half-read. ### Timeline properties [Section titled “Timeline properties”](#timeline-properties) Edited behind the gear in the editor’s toolbar. | Property | Key | Default | Meaning | | ------------------- | --------- | ------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | Length | `length` | `30000` | Timeline length in milliseconds. A clip past it is legal and shaded; what lies past it is cut at play time. | | Loop timeline | `loop` | `false` | Whether the timeline repeats. | | Fade in | `fadeIn` | `0` | Length over which the whole soundscape rises when it begins. The file stores whole milliseconds; the editor field also accepts a length such as `0.25 s` and a typed clock. | | Fade out | `fadeOut` | `0` | Length over which the whole soundscape falls when it is replaced. | | Volume | `volume` | `1.0` | Gain, 0 to 1. Every clip’s volume is relative to it. | | Tied to the passage | `tied` | `false` | The soundscape stops when the story moves on. See [Which soundscape is in force](#which-soundscape-is-in-force). | ### Clip properties [Section titled “Clip properties”](#clip-properties) Edited in the panel on the right when a clip is selected. | Property | Key | Default | Meaning | | ----------------- | ---------------------- | ----------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | File | `audio` | required | Which audio file it plays, picked by name or by folder; the path is shown under the field. | | From, To | `clipStart`, `clipEnd` | `0`, `null` | The part of the file played, as clocks in `m:ss.mmm`. `clipEnd` is `null` or at least `clipStart`. | | Playback | `mode` | `once` | See below. | | Rest | `interval` | `0` | Auto-loop only. A silence between one play and the next. Off is gapless. On, plays follow the clip’s own rhythm across the whole soundscape, independent of the timeline, and the lane shows where every play falls. | | Volume | `volume` | `1.0` | The clip’s own gain, 0 to 1, relative to the timeline’s. | | Speed | `speed` | `1.0` | Playback rate, above 0. | | Fade in, Fade out | `fadeIn`, `fadeOut` | `0` | How long it ramps at either end of every play. The file stores whole milliseconds; the editor field accepts a length such as `0.25 s` or a typed clock. | | Lane | `lane` | | Integer lane index. | | Position | `start` | | Where the clip begins on the timeline, in milliseconds. | | Id | `id` | required | String id of the clip. | | Playback | Behaviour | | -------------------- | ----------------------------------------------------------------------------------------- | | *Once* (`once`) | At its position on the first pass of the timeline, and never again. | | *Loop* (`loop`) | On every pass of a looping timeline, cut where the timeline ends. | | *Auto-loop* (`auto`) | Over and over for as long as the soundscape plays, regardless of where the timeline ends. | An auto-loop clip ramps at every turn of its clip, with or without a rest, so a file that does not loop cleanly can be used as a bed: the fade covers the seam. Where a clip’s two ramps together outlast it, it rises and falls without reaching full volume. The two ramps combine as the quieter of the two, not their product. ### Lanes [Section titled “Lanes”](#lanes) | Property | Key | Meaning | | -------- | ------------------ | ----------------------------------------------------------------------------------------------------- | | Name | `lanes.<n>.name` | Set by double-clicking the lane header, or from its right-click menu, for example *Bed* or *Weather*. | | Locked | `lanes.<n>.locked` | A locked lane takes no clip; a bed laid out once stays where it was put. | A lane with no name that is not locked is not written. Lane keys are integer strings. ### Values and tolerances [Section titled “Values and tolerances”](#values-and-tolerances) | Value | Rule | | ----------------- | --------------------------------------------- | | Times | Whole milliseconds, never negative. | | `volume` | 0 to 1; absent reads as 1. | | `speed` | Above 0; absent reads as 1. | | `length` | Absent reads as 30000. | | `loop`, `tied` | Absent reads as `false`. | | `tracks`, `lanes` | Absent reads as none. | | Scalars | Decoded strictly: `"loop": "yes"` is refused. | | `audio` | A catalog id of an `audio` entry. | ### Example [Section titled “Example”](#example) ```json { "version": 1, "length": 30000, "loop": false, "fadeIn": 0, "fadeOut": 0, "volume": 1.0, "tied": false, "tracks": [ { "id": "bed", "audio": "bc68e337…", "lane": 0, "start": 0, "clipStart": 0, "clipEnd": null, "volume": 1.0, "speed": 1.0, "fadeIn": 0, "fadeOut": 0, "mode": "auto", "interval": 0 } ], "lanes": { "0": { "name": "Bed", "locked": true } } } ``` ## Editor controls [Section titled “Editor controls”](#editor-controls) ### Adding and editing clips [Section titled “Adding and editing clips”](#adding-and-editing-clips) | Action | Effect | | ------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------ | | *Add clip* | Offers the project’s audio, searched by name or by folder, each row showing where the file is; or *Import a file…* when there is none yet. | | Double-click empty canvas | The same offer. | | Drag a sound from the file tree | The clip lands where you let go. | | Drag a block | Moves the clip. | | Pull either edge of a block | Trims the clip. | | Drag a fade grip | Moves with the slope: inward lengthens the ramp, back into the corner removes it. | | Select a clip | Edits it in the panel on the right. | | Double-click a lane header | Names the lane. | | Right-click a lane header | Names the lane, or locks it. | | Right-click elsewhere | Duplicate or delete a clip, add one here, select everything on a lane, fit the pane, choose a lane row height, undo and redo. | Each clip block draws the shape of its sound inside it, the part of the file it plays, so trimming moves the picture with the sound. The shape appears after the file is decoded. The block also draws the clip’s ramps: a slope up at the start, a slope down at the end, and the quiet part shaded over the waveform. An auto-loop clip shows the same shape on every repeat. ### Mute and solo [Section titled “Mute and solo”](#mute-and-solo) Every lane header carries **M** and **S**; the lane’s menu has both in words. A clip can be silenced from its panel, and a silenced clip is drawn faint. Mute and solo are not saved and do not reach the story: a soundscape plays every clip it holds. ### Preview [Section titled “Preview”](#preview) | Input | Effect | | ------------------------------ | ------------------------------------------------------------------------------------------------------------- | | Space | Play and pause. | | Shift+Space | Stop. | | The ruler, or any empty canvas | Scrub bar. | | Toolbar undo | One undo for the whole file; an edge trimmed on the timeline and a volume typed in the panel share a history. | Preview plays through the same path as a campaign. A gain slider is live, and a dragged clip does not restart the bed under it. ## Which soundscape is in force [Section titled “Which soundscape is in force”](#which-soundscape-is-in-force) A **Narration** node carries a soundscape row under its choices. | Answer | Effect | | ------------ | -------------------------------------------------------------------------------------------------------------------------------------- | | A soundscape | It plays from that passage on, through every later passage that brings none of its own. Naming the same one again does not restart it. | | *Silence* | Stops whatever is playing outright. | | *None* | Changes nothing. | * Two soundscapes never sound at once: the old one finishes its fade before the new one begins its own. * A soundscape marked **tied** belongs to its passage and stops when the story moves on. * A saved game remembers what was playing. A load restores it before the page is seen; a tied soundscape comes back only at its own passage. * The runtime reports which soundscape is in force and plays nothing; the host renders it. The campaign window renders through the app’s mixer. See [Soundscapes and one-shot sounds](/explanation/soundscapes-and-sounds/). ### Muting [Section titled “Muting”](#muting) The campaign window’s header has a **mute** button, remembered between sessions. It belongs to the campaign: previewing a soundscape in its own tab is never silenced by it. A soundscape’s volume is the file’s and every clip’s is relative to it; there is no second master. ## Where the files live [Section titled “Where the files live”](#where-the-files-live) * my-story/ * .ripple/ * external/ * rain.ogg an outside file brought in with Import… * music/ * tavern.ogg * chapters/ * the\_gates.graph.json names tavern by id * tavern.soundscape.json ## Related [Section titled “Related”](#related) * [Soundscapes and one-shot sounds](/explanation/soundscapes-and-sounds/) explains the two shapes sound takes in a story. * [How to add sound to a story](/how-to/add-sound-to-a-story/) applies these controls. * [Sounds](/reference/python/sounds/) documents `play_sound` from Python. * [Nodes](/reference/nodes/#play-sound) documents the Play sound node. * [Export options and bundle contents](/reference/export-options/) says what a compiled campaign carries. # State machine files > The syntax of a *.fsm.mmd file, what the reader accepts and refuses, how the editor shows it, and what a saved game and an export carry of it. ## File [Section titled “File”](#file) | Property | Value | | ------------ | ---------------------------------------------------------------------------------------------- | | Name | `*.fsm.mmd` | | Content | A mermaid `stateDiagram-v2` | | Version line | None. Mermaid owns the format, so the `stateDiagram-v2` header is the version mermaid gave it. | | Created from | **New state machine** in the file tree’s right-click menu | | Rendered by | Any mermaid renderer | The full mermaid syntax is mermaid’s own [state diagram reference](https://mermaid.js.org/syntax/stateDiagram.html). The tables below give the subset Ripple Effect reads. ## Example [Section titled “Example”](#example) Every transition is `From --> To : event`. ```text stateDiagram-v2 [*] --> Locked Locked --> Unlocked : key Unlocked --> Open : push Open --> Unlocked : pull Unlocked --> Locked : lock ``` ## Lines the reader understands [Section titled “Lines the reader understands”](#lines-the-reader-understands) Before parsing, the reader strips `%%` comments, trims each line and skips blank lines. | Line | Meaning | | ------------------------------------------------------ | ------------------------------------------------------------------------------------- | | `stateDiagram` or `stateDiagram-v2` | The header. It must come first. A missing header is one error, and parsing continues. | | `%%` … | Comment. | | `direction …` | Ignored. | | `[*] --> S` | The start: the state the machine begins in. | | `S --> T : event` | A transition from `S` to `T` that fires on `event`. | | `S --> [*] : event` | A transition to the end. | | `state "Long name" as S` | A state with a label. | | `state S` | A state with no label. | | `S : description` | A state with a label taken from the description. | | `note left of S : …`, `note right of S …` … `end note` | Ignored, as one line or as a block. | A state id matches `[A-Za-z_][\w.\-]*`: letters, digits, underscores, dots and dashes, not starting with a digit. ## Lines the reader refuses [Section titled “Lines the reader refuses”](#lines-the-reader-refuses) A refused line is reported with its line number. | Line | Result | | --------------------------------------------- | ------------------------------------- | | `state X <<choice>>` and other stereotypes | Refused | | `state X {` (composite state) and a stray `}` | Refused | | `--` (concurrency) | Refused | | A second `[*] -->` | Error: a machine starts in one state. | | `[*] --> [*]` | Error | | No start, or an empty diagram | Error at line 0 | | Any other line | Refused | A diagram with any error yields no machine. A compiler carries an empty machine for it and says so. ## Warnings [Section titled “Warnings”](#warnings) | Case | Result | | --------------------------- | -------------------------------------------------------------------------------------------------------------------------------------- | | An arrow with no event | Drawn and kept in the transitions, but it can never fire, and an export does not write it to the transition table. The editor says so. | | An event on the start arrow | Ignored. | ## Model [Section titled “Model”](#model) | Part | Content | | ------------- | --------------------------------------------------------------------------------------------------------- | | Initial state | The target of the start arrow. | | States | In order of first mention, never `[*]`. A label from `state "…" as` or `id : …` overrides a bare mention. | | Transitions | In file order, each with `from`, `to`, `event` and `line`. | ## Editor [Section titled “Editor”](#editor) | Behaviour | Detail | | ----------------------------- | --------------------------------------------------------------------------------------------------- | | Layout | The text on one side, the picture on the other. | | Redraw | As you type. | | Picture | Fitted to the pane and never scaled up. Zoom and pan; the **Fit to the pane** button fits it again. | | Problems | Listed with their lines. | | While the text does not parse | The last machine that parsed stays drawn, greyed. | | Counts | A card shows how many states and transitions a machine has. | ## In a saved game [Section titled “In a saved game”](#in-a-saved-game) A save carries what every machine remembers. A save made before a project had machines reads as remembering nothing. ## In an export [Section titled “In an export”](#in-an-export) A compiled campaign carries each machine as a transition table, so a runtime needs no mermaid parser. The compiler writes the table with the same reader the editor draws from, so a file that draws is a file that plays. ## Related [Section titled “Related”](#related) * [State machines](/explanation/state-machines/): why a machine is a drawing and how it behaves in a story. * [How to add a state machine to a board](/how-to/add-a-state-machine/): the State machine node, its event and its instance. * [Nodes](/reference/nodes/): the State machine node’s ports. * [Export options and bundle contents](/reference/export-options/): what a compiled campaign carries. # Version control in the app > The Changes section of the side bar, its terms, what starting a history writes, the backup bar, the conflict view and the list of undone changes. Version control is built into the app and is reached through the **Changes** section of the side bar, the second icon on the activity bar beside Files. The history underneath is an ordinary git repository, and anything that reads one reads this. The engine is libgit2, compiled into the app; no installation of git is needed. ## Terms [Section titled “Terms”](#terms) The app’s words are used on screen. Git’s own word appears in a tooltip, a diagnostic, or where it is what a reader would search for. | Term in the app | Git term | | -------------------------- | -------------- | | Changes (side bar section) | Source control | | Save point | Commit | | Storyline | Branch | | Ready to save | Staged | | Not yet included | Unstaged | | Mark ready | Stage | | Take back out | Unstage | | Set aside | Stash | | Back up | Push | | Get changes | Pull | | Check for changes | Fetch | | Where your backup lives | Remote | ## Starting a history [Section titled “Starting a history”](#starting-a-history) A project with no history shows the offer *This project has no history yet.* with the action **Let us help you set this up**. The setup page, **Keep a history of this project**, reports: | Check | Result text | | -------- | ------------------------------------------------------------------------------------------------------------------------------------------------ | | Engine | *Version control is built in* (*Nothing to install — engine {version}.*), or *The engine did not load in this build.* | | Folder | *This folder can be written to* | | Network | *Backing up over the internet will work*, or a statement that this build cannot reach a remote over HTTPS; everything on the machine still works | | Identity | Taken from the git already set up on the computer (`~/.gitconfig`), or *Nothing was already set up here* | Every save point records a name and an email address, written into this project only. Any name and address are accepted; they are a label, not an account, and nothing checks them. Neither may contain `<` or `>`. Changing the identity on the setup page applies to this project only. **Start keeping a history** does three things: 1. Starts a history in the folder. 2. Writes a short list of things to leave out. An ignore file already present is not rewritten; patterns are only appended, and only those missing. 3. Takes the first save point of everything in the project. Further effects: | Effect | Detail | | ------------------ | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `.gitattributes` | One line, `* text=auto`, written at start and not touched again. Keeps line endings consistent for co-authors on other platforms. | | Ignored by the app | `.ripple/workspace.json`, which is per machine. Everything else in `.ripple/` is shared; see [Project files and the .ripple folder](/reference/project-files/). | | Large files | Files too large to take back out of a history are left out and named, not committed. A file of 25 MB is reported as large enough to slow the operation; files above 95 MB are left out. | | Author | The first save point is authored by the user, never by the app. | A project inside a folder that already uses version control is refused: *This project sits inside a folder that is already using version control.* A second history is not started inside it. ## Changes [Section titled “Changes”](#changes) The Changes section has three views, selected by a switch: **Changes**, **History**, **Set aside**. ### Changes view [Section titled “Changes view”](#changes-view) Files are listed in two groups, each headed with its count. | Group | Meaning | | ---------------- | ------------------------------- | | Ready to save | Marked for the next save point. | | Not yet included | Changed but not marked. | A file can appear in both groups when it was marked ready and edited again. | Control | Effect | | ---------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | **Mark ready (stage)** / **Mark all of these ready (stage)** | Moves files to *Ready to save*. | | **Take back out (unstage)** / **Take all of these back out (unstage)** | Moves files to *Not yet included*. | | **Undo these changes** | Asks *Undo these changes?*; **Undo them** or **Keep them**. Restores the file to the version marked ready, or to the last save point if none was; an untracked file is deleted. | | Click a file | Opens a comparison side by side with the version before it. | | Right-click a file in the file tree | Offers the comparison for a file with changes. | | Refresh (**Look again**) | Re-reads the list. Shown with an explanation when the folder is not being watched, so that the list only moves on request. | Every text file has a diff, including boards and components. A file that is not text, or is too long to lay out, is listed as changed with *This file is not text.* or *This file is too long to compare.* and no comparison. A diff tab compares a file with its last save point. The tooltips distinguish the two comparisons: *Staged* is the last save point against what is ready; *Unstaged* is what is ready against what is on disk. Status words in the list: | Word | Meaning | | ---------------------- | ---------- | | Unchanged | | | Not in the history yet | Untracked | | Added | | | Changed | | | Deleted | | | Renamed | | | Changed kind | | | Two versions disagree | Conflicted | The list follows changes anywhere in the project as files change, whether or not the folder is open in the tree. ### Marks in the file tree [Section titled “Marks in the file tree”](#marks-in-the-file-tree) Coloured dots appear on changed files and on the folders holding them. Ignored files do not roll up to their folders. | Mark | Tooltip | | -------- | --------------------------------- | | New | New since the last save point | | Modified | Changed since the last save point | | Ignored | Left out of the history | ## Save points [Section titled “Save points”](#save-points) A save point is written in the box at the foot of the Changes view. | Field | Detail | | ------- | ----------------------------------------------------------------------------------------------------------------------------- | | Summary | Required; placeholder *What changed?*. Without it: *A save point needs a line saying what changed.* | | Detail | Optional; opened with **Add more detail**, closed with **Hide the details**; placeholder *Anything else worth knowing later*. | | Button | **Save {count} files**, or *Nothing marked ready* when none is. | Refusals: no name and email (*A name and an email address are needed before a save point can be made.*); nothing marked ready. The command palette carries the same operations: **Save a save point…**, **Take the last save point back**, **New storyline…**, **Move to another storyline…**, **Check for changes**, **Get changes from the backup**, **Back up now**, **Set this work aside…**. **Take the last save point back** asks *Take the last save point back?* Everything it recorded stays where it is on disk and returns to being marked ready; no file changes. The first save point cannot be taken back. A save point can also be taken back from its row in History. ## History [Section titled “History”](#history) Lists each save point with its summary, author and how long ago it was made. An empty history reads *No save points yet.* ## Storylines [Section titled “Storylines”](#storylines) A storyline is a branch. | Operation | Detail | | ------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | New storyline | Starts at the current save point and moves onto it. A storyline must start from a save point, so none can be made before the first. | | Rename this one… | | | Move to another storyline | With unsaved changes, asks *Move to “{name}”?* and offers **Set them aside and move**, or cancel to save them first. | | Delete one… | The current storyline cannot be deleted. A deleted storyline is kept and can be restored with **Put back**; the message states how many save points were only on it. | A storyline name cannot contain spaces, `..`, `~`, `^`, `:` or a trailing `/`. ## Set aside [Section titled “Set aside”](#set-aside) **Set aside** puts the work in progress out of the way and returns the project to its last save point. The work is named when set aside; moving to another storyline offers the same, labelled *Set aside before moving to {name}*. | Control | Effect | | ----------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------- | | **Put what is here out of the way** | Sets the current changes aside. Refused when everything is already in a save point. | | **Bring back** | Restores the set-aside work; it stays in the list until thrown away. Refused when it touches files changed since: save or set aside what is here first. | | **Throw it away** | Asks *Throw this away?*; the one operation in the view that cannot be undone. | ## Recently undone [Section titled “Recently undone”](#recently-undone) **Recently undone** is part of the **Set aside** view. Undoing changes to a file keeps what is thrown away first, under a tag in the repository, `refs/tags/ripple/undo/`, so it remains reachable from a command line. Each entry offers **Put back**; the notice after is *Put back what was undone.* The message offering it back stays in Messages after the notification has gone. | Property | Detail | | --------------------- | ----------------------------------------------------------------------------- | | Lifetime of the list | Kept since the project was opened; the list starts again when the app closes. | | What survives closing | What each entry held is still in the history afterwards. | | Scope of an undo | Only the paths of the action it undoes. | ## Backing up [Section titled “Backing up”](#backing-up) Backing up is separate from keeping a history; a project can have a history and never be backed up. One remote is supported. ### Where it lives [Section titled “Where it lives”](#where-it-lives) The dialog **Where should this be backed up?** takes an **Address**: a web address starting with `https://`, or the full path to a folder (a shared drive or synced folder needs no account). SSH addresses and `git://` addresses are refused with an instruction to use the HTTPS one; any other address that is neither is refused with *That should either start with https\:// or be the full path to a folder.* | Notice | Content | | ------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | Make the repository on the site first | The app does not create it. A missing repository is reported as a wrong password. See [How to keep a history and back up a project](/how-to/keep-a-history-and-back-up/). | | Check whether it is public or private | A public repository means anyone can read the story, including every draft saved. | ### Sign-in [Section titled “Sign-in”](#sign-in) A web address asks for sign-in once, with a personal access token and a username on that site. A folder needs no sign-in. | Property | Detail | | ------------------------- | ------------------------------------------------------------------------------------------- | | Credential | A personal access token, not a password. | | Storage | The computer’s secure storage, one token per host, shared by every project backed up there. | | No password store running | *Kept for this session only*; asked again next time. No plaintext file is written. | ### The backup bar [Section titled “The backup bar”](#the-backup-bar) The main button states what it is about to do. | State | Button | | ------------------------------------------ | ---------------------------------------------------------- | | No remote | **Back this up somewhere** | | Storyline never backed up | **Back this storyline up** | | Backup has save points this copy lacks | **Get {count} changes** | | This copy has save points the backup lacks | **Back up {count} save points** | | Both | **Get {behind} changes, then back up {ahead} save points** | The **More** menu holds **Check without taking anything**, **Change where it is backed up…** and **Sign in to {host} again…**. The bar also shows *Last checked {date} at {time}*. ### Results [Section titled “Results”](#results) | Message | Meaning | | -------------------------------------------------------------- | ------------------------------------------------------------------------------------------------ | | Nothing new. | Nothing to get. | | Brought {count} save points across. | Fast-forward. | | Brought {count} save points across and joined them with yours. | Merged without disagreement. | | Some of what came down disagrees with what you have. | A conflict; see below. | | There is no copy of this storyline on the backup yet. | Nothing to get for this storyline. | | Somebody else has changed things since you last looked. | Back up is refused; get their changes first, then back yours up. A forced push is never offered. | ## Conflicts [Section titled “Conflicts”](#conflicts) A conflict arises when changes got from the backup touch the same places as local changes. Detection is done in memory: the working files, the staging area and the current save point are untouched until each disagreement has an answer. No conflict markers are written into files. The view is headed *One file disagrees* or *{count} files disagree*, with *Somebody else changed the same places you did. Nothing has been changed here yet — pick what should be kept, or leave it for now.* Both versions are shown side by side. | Choice | Detail | | ------------------------------------------------------------------------ | ------------------------------------------------------------------------------------------------------------------------ | | **Keep mine** | Always offered. Labelled **Keep it deleted** when your side deleted the file. | | **Take theirs** | Always offered. Labelled **Let theirs delete it** when their side deleted the file. | | **Write it myself…** (*What it should say*, with *Theirs, to read from*) | Offered for text files of kind Markdown, plain text, narration bundle and state machine; never for boards or components. | | **Leave it for now** | Always offered; no file changes | Boards and components offer only mine or theirs, because a line-by-line combination can be invalid JSON. A file that is not text offers one or the other with no comparison. The count of unresolved files shows on the confirm button (*{count} still to pick*); the join is refused until every disagreement is settled. When all are settled: *Joined the changes together.* An assistant connected over MCP is refused a document with a conflict pending; see [What an assistant may read, play and write](/reference/mcp-permissions/). ## Messages [Section titled “Messages”](#messages) | Message | Cause | | ---------------------------------------------------------------------------------- | ----------------------------------------------------------- | | Version control is not available in this build. | The engine did not load. | | The list of changes will not keep up with this folder on its own. | The folder could not be watched; use the refresh button. | | A personal access token is needed for {host}. | No token is stored for that host. | | There are changes here that are not in a save point yet. | Raised when moving storyline; save or set them aside first. | | This project has nowhere to back up to yet. / has nowhere to get changes from yet. | No remote set. | | There are no save points to back up yet. | | ## Related [Section titled “Related”](#related) * [History, sharing and conflicts](/explanation/history-and-sharing/) * [How to keep a history and back up a project](/how-to/keep-a-history-and-back-up/) * [What an assistant may read, play and write](/reference/mcp-permissions/) * [Project files and the .ripple folder](/reference/project-files/) * [The workspace window, menus and palette](/reference/workspace-window/) # The workspace window, menus and palette > The parts of the home screen and the project window, the menus, the shortcuts, Find in project, the command palette, tips and the Messages panel. ## Home screen [Section titled “Home screen”](#home-screen) A grid of the projects the app knows about. A project that another program has moved or deleted disappears from the grid. | Control | Action | | ------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | **New project** | Asks for a title, a location, an optional description and cover, and the language you are writing in. The starting language comes from the computer’s settings and can be changed. Creates the folder and one file in it, `.ripple/project.json`. | | **Import a folder** | Opens an existing directory. If it has been a project before, its title, description, cover and language arrive filled in and nothing is written into it. If not, you name it. | The New project dialog has a rail on its left: | Entry | Result | | ------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | *Blank* | An empty project. | | **Templates** | Lists the example projects this build carries and copies the chosen one where you point it. The first is *The City Of Elsenmar*: three chapters, two languages, characters and items as components, dice, variables and Python in the prose. | On the home page the menu bar holds **File** and **Help** only. ## Project window [Section titled “Project window”](#project-window) ![The project as a folder: the file tree beside an open passage](/_astro/files-light.p9mrFCWm_Z2icsSu.webp) ![The project as a folder: the file tree beside an open passage](/_astro/files-dark.l_awLRhE_1dveJP.webp) | Part | Position | Contents | | ------------ | ----------------------------------- | --------------------------------------------------------------------------------------------------- | | Activity bar | Narrow strip of icons down the left | Picks what the side bar shows; holds four buttons at its bottom, and a Messages toggle. | | Side bar | Beside the activity bar | **Files**, **Changes** or **Search**. Clicking the section already showing folds the side bar away. | | Editor area | Centre | The open documents, in a docking area. | | Messages | Under the documents | See [Messages](#messages). | ### Activity bar buttons [Section titled “Activity bar buttons”](#activity-bar-buttons) The four buttons at the bottom are in view whether the side bar is open or not. | Button | Opens | | ------------------- | ---------------------------------------------------------------------------------------- | | *Script navigation* | How the boards hand the story to each other. | | *Export project* | Packages the project for a build or a translator. | | *Project settings* | Pages down a rail: *General*, *Languages*, *Writing*, *Narration roots*, *Capabilities*. | | *Console variables* | The page where a project declares its typed variables. | A fifth control at the bottom of the activity bar shows or hides Messages and carries its count. It is not one of the four. ### File tree [Section titled “File tree”](#file-tree) | Capability | Detail | | --------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------- | | Selection and editing | Multi-select, cut, copy, paste, rename, delete, spring-loaded folders, drag-to-move. | | Keys | `Ctrl+X`, `Ctrl+C`, `Ctrl+V`, `Ctrl+A` (`Cmd` on a Mac), `Delete`, `F2` to rename, `Escape` to clear the selection. | | **+** button | Makes a board, a macro, a component, a script, a state machine, a soundscape, a localised passage or a note, and any other kind by the extension you type. | | Other files | A PDF, a picture, a spreadsheet or a sound sits in the tree and opens. | ### Editor tabs [Section titled “Editor tabs”](#editor-tabs) * A tab is a view. A closed board keeps its camera, its undo stack and its unsaved edits. * Tabs follow renames and close when their file is deleted. * Opening a file from the tree, or landing on one from a board, scrolls its tab into view. * Open documents, their arrangement, and how the side bar and Messages were left are remembered per project in `.ripple/workspace.json`. ### Docking [Section titled “Docking”](#docking) | Gesture | Result | | --------------------------- | ------------------------------------------------------------------------------- | | Drag a tab out of its strip | Places it beside another document. | | Drop a tab on another strip | Tabs it there. | | Drag a divider | Resizes. | | Drag a tab along its strip | Neighbouring tabs make room live; the order shown is the order kept on release. | A drop that would leave everything where it is does not light up as a target. The docking is `fl_panel`; see [Open source](/about/open-source/). ### Document editors [Section titled “Document editors”](#document-editors) | Document | Editor | | ------------- | ----------------------------------- | | Board | Canvas | | Passage | Markdown editor with a live preview | | Component | Attribute editor | | Script | Editor beside a Python console | | State machine | Editor beside its diagram | | Soundscape | Timeline | | Audio file | Play, pause and a scrub bar | | Anything else | Plain text, image or PDF | Every editor fills its tab under one bar that is the same in every tab: the controls on the left, and at the far right whether the file is written (*Saved* or *Unsaved*, in words) and a button that reads the file from disk again. ## Zen Mode [Section titled “Zen Mode”](#zen-mode) | Property | Detail | | ---------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------- | | Enter | `F11` (`Ctrl+Cmd+F` on a Mac) or *View ▸ Zen Mode*. The same keys leave it. | | Hidden | The app bar, the side bar, Messages and the tabs. | | Banner | A small banner at the top names the document on entry and whenever it changes. It carries an *Exit Zen Mode* button when the pointer reaches the top edge. | | `Ctrl+Tab`, `Ctrl+Shift+Tab` | In Zen Mode, move between every open document, whichever group it is in. Outside it, move along the tabs of the group you are in. | ## Menu bar [Section titled “Menu bar”](#menu-bar) In a project the app bar carries **File**, **Edit**, **View**, **Campaign**, **Changes** and **Help**. On the home page it carries **File** and **Help**. Together they offer everything the command palette can, with the shortcut beside each row and a check beside each option that is on. | Menu | Contents | | ------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | **File** | *New file…*, *New folder…*, *Go to file…*, *Save*, *Save all*; the submenu *This file* (*Reload from disk*, *Keep my version*, *Rename this file…*, *Delete this file…*, *Show this file in the file manager*, *Copy path*); *Close tab*, *Close other tabs*, *Close all tabs*; *Project settings…*, *Export project…*; the submenu *Project* (*Show the project folder*, *Reload files*, *Re-index the project*, *Import outside assets*); *Close project*. *Settings…* opens the settings dialog. On the home page: *New project…*, *Import a folder…*, settings. | | **Edit** | Undo, redo, cut, copy, paste, duplicate, select all. They act on the text field you are typing in, or on the board in front of you. Duplicate is for boards only. | | **View** | *Command Palette*, *Show files*, *Show changes*, *Find in project*, *Dismiss the messages that can be dismissed*, *Zen Mode*, and the options *Side bar*, *Messages*, *Minimap*, *Shortcuts card*, *Create on drop*, *Performance mode*, *Hidden files*, each checked while on. | | **Campaign** | *Play campaign…*, *Open a saved game…*, *Resume campaign*, *Abort campaign*, *Trace what leads here*, *Script navigation*, *Console variables*, *Host callbacks*, *What this Python can and cannot do*. | | **Changes** | *See what changed in this file*, *Mark everything ready*, *Save a save point…*, *Take the last save point back*, *Set this work aside…*, *New storyline…*, *Move to another storyline…*, *Check for changes*, *Get changes from the backup*, *Back up now*. | | **Help** | Documentation, what is new, tips, troubleshooting, support and about. *Troubleshooting* holds *Show every tip again* and the rows that copy the package and device reports and open this session’s folder. | Undo and Redo are in Edit rather than on the board toolbar. A right-click, or a wire let go over empty canvas, makes any card. The shortcuts card is restored from *View ▸ Shortcuts card*. ## Find in project [Section titled “Find in project”](#find-in-project) Open the **Search** section of the side bar with `Ctrl+Shift+F` (`Cmd+Shift+F` on a Mac), or run *Find in project* from the palette. | Option | Effect | | ------------------------------------------- | --------------------------------------------------------------------- | | Match case, whole word, regular expressions | Standard matching switches. | | Files to include and exclude | Restrict which files are searched. | | Switches | Boards and components, narration, console variables, everything else. | Boards and components are read for what you wrote in them (a node’s title, a choice’s text in each language, a field’s value), never their JSON. Each result says where it is, for example *Choice text (Italian) in “At the gate”*, *Field “Backstory”*, *Line 12, English*. Names that other things refer to, such as a variable’s or a state’s, are marked. Clicking a result opens the document at the match. ### Replace [Section titled “Replace”](#replace) A replace field sits under the search field. Typing in it shows what each result would become. | Action | Detail | | ------------------------------------------ | -------------------------------------------------------------------------------------------------------------------------------------------------- | | Replace one, replace a file’s, replace all | Replace all asks first and shows the counts. | | Preview | Shows what a file would look like after. For a board or a component, only the words that change. | | Undo | Replacements go into the open editors and are undone there; a board’s in one step. | | Left alone | Names something else refers to; a result whose text changed since the search; a file another program changed meanwhile. Each is said in the panel. | ## Command palette [Section titled “Command palette”](#command-palette) | Property | Detail | | --------------------- | ----------------------------------------------------------------------------------------------------- | | Open | `Ctrl+Shift+P` (`Cmd+Shift+P` on a Mac), or *View ▸ Command Palette*. | | Contents | Every command the app has, by name, from anywhere in a project. | | Filter | Typo-tolerant. | | Scope | Offers only what can run. | | Palette-only commands | Some commands have no button, for example *Show the tip for…* and *Follow the system*. | | *Recently used* | While nothing is typed, the last command you ran is offered first. It is remembered between launches. | ## Tips [Section titled “Tips”](#tips) The first time you open each kind of thing (the home screen, a board, a bundle, a component, the campaign window) a short tip appears once, naming the words for it. There is no tour. A tip is brought back from the palette (*Show the tip for…*); *Help ▸ Troubleshooting ▸ Show every tip again* re-arms all of them. ## Messages [Section titled “Messages”](#messages) Messages is what the app says to you about your open project, for example a translation that has fallen behind its source. It is cleared when another project opens. It is separate from the session log; see [The session log and reports](/reference/session-log/). | Property | Detail | | --------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------- | | Position | Under the documents, a third of their height, with a count beside its name. | | Close | The cross on the panel. | | Reopen | The button at the bottom of the activity bar (it carries the count), or the View menu. | | Grouping | By subject (Boards, Translations, Version control), with a count on each heading that can be folded away; or by how loud, with everything broken first. | | Chips | Three, turning problems, cautions and records on and off. | | Remembered | The grouping and the chips. | | Engine messages | Wear their code. The code opens that rule’s heading on [Diagnostics](/reference/diagnostics/). | | Messages naming cards | Clicking one opens the board and lifts those cards out of the canvas. `Escape`, or the cross in the toolbar, puts the canvas back. | | *I meant that* | A tick on the one check that can be wrong about a deliberate choice (`W.GE.06`). It stays ticked. | Most messages have no code: they are the app reporting on a file, a translation, or something it could not do. Coded messages are the engine reading the shape of your boards. ## Related [Section titled “Related”](#related) * [How to arrange the workspace](/how-to/arrange-the-workspace/) * [How to find and replace across a project](/how-to/find-and-replace-in-project/) * [Diagnostics](/reference/diagnostics/) * [Project files and the .ripple folder](/reference/project-files/) * [A project is a folder](/explanation/projects-are-folders/) # Tutorials > Walkthroughs that build something, step by step, and say where every file lands. Each tutorial builds one thing from nothing and stops when it works. They are written to be followed in the app with the page open beside it, and every one says what landed on disk at each step — because a project is a folder, and knowing what is in the folder is most of knowing the app. ## Here now [Section titled “Here now”](#here-now) * [**Your first story**](/tutorials/your-first-story/) — a project, a board, three passages, a choice and the play button. Ten minutes. ## Planned [Section titled “Planned”](#planned) These lessons are planned and not yet written: * **A dice roll that matters** — a Dice roller, a Comparator and an If, so a climb can fail. * **Six goblins** — a component, a Spawn node, and a script that gives each one its own hit points. * **A door with a state** — a state machine file, the node that drives it, and a Switch on where it is. * **The same story in two languages** — adding a language, the staleness dot, and what the reader sees. * **Handing it to a build** — compiling a campaign, delivering the prose as HTML, and reading the bundle without the app. * **An assistant in the folder** — connecting Claude Code, and what to ask it first. If one of these would help you now, say so in the [support discussions](https://github.com/ConnectingTheDots-DCWK/ripple_effect_support/discussions) — the order is decided by who asks. ## Where to go next [Section titled “Where to go next”](#where-to-go-next) Start with [Your first story](/tutorials/your-first-story/). Four of the planned lessons have a guide that covers the same task now: [add a state machine](/how-to/add-a-state-machine/) for a door with a state, [manage a project’s languages](/how-to/manage-languages/) for the same story in two languages, [export a project or campaign](/how-to/export-a-project-or-campaign/) for handing it to a build, and [connect an assistant](/how-to/connect-an-mcp-client/) for an assistant in the folder. To run what you have built, see [play and test a campaign](/how-to/play-a-campaign/). # Your first story > Build a playable story from an empty folder, with a project, a board, three passages, a choice and the Play button. In this tutorial you build a small story from nothing and play it: a traveller at a gate, two ways through it, and an ending. At each step you look at what the app wrote into the project folder. You need the app installed. If your platform stops an app it does not know, [How to install Ripple Effect](/how-to/install-the-app/) says what to click. If you would rather read a finished story first, *New project → Templates* makes you a copy of one; here we build a small one from nothing. 1. **Make a project.** Press **New project** on the home screen. Give it a title, *The Gate* will do, pick where the folder should go, and choose the language you are writing in. The language is offered from your computer’s settings, but the choice is yours: it decides the name of the file every passage is written into. Look at what the app creates: the folder with exactly one thing in it: * the\_gate/ * .ripple/ * project.json That is the project. There is no scaffold and no convention; everything else in the folder will be yours. See [A project is a folder](/explanation/projects-are-folders/) for why. 2. **Make a board.** In the file tree on the left, press **+** and choose **Board**. Name it `gate`. It opens on the canvas holding one node, **Start**, which is where the story begins. A board has exactly one. On disk you now have `gate.graph.json`. It is JSON. 3. **Write the first passage.** Right-click an empty part of the canvas and choose **Create ▸ Story ▸ Narration**. A right-click, or a wire let go over empty canvas, is how every card is made. A card lands where you clicked, reading *Empty — double-click to write.*, so double-click it. The Passage dialog asks for a title, *At the gate*, and which passage the card reads. Press **New** to make one, and write: ```markdown The gate is shut, and the guard on the wall has seen you. ``` Close the dialog. The writing is already saved: everything in the app writes as you work, and the dialog only decides the title and which passage the card points at. Beside the board there is now a folder that the app shows as one file: * the\_gate/ * gate.graph.json * gate-at-the-gate.loc.narr.bdl/ * en-US.md * touch.json `en-US.md`, or whichever language you chose, is your sentence as plain Markdown. A translation would be a second `.md` beside it. The folder name follows the board and the title. 4. **Wire it in.** Drag from the port on the right of **Start** to the port on the left of your passage. The story now has a first page. 5. **Give the passage two choices.** Drag from the passage’s first output port and let go over empty canvas. The Create menu opens where you dropped it, already wired to whatever you pick. Choose **Narration**, double-click the new card, title it *Talk to the guard*, press **New** and write `The guard leans over the wall and listens.` Do it again from the passage’s second port: title it *Climb the wall*, press **New** and write `The stones are loose, but you find a hold.` A spare port appears beneath each one you wire. Now click each choice’s row on the first card. A choice has a **name**, which is what the card shows so you can find the branch on a wide board, and its **words**, which are what the reader is offered, in every language the story is written in. Give the first one *Call up to the guard* and the second *Look for another way*. 6. **End it.** Right-click on empty canvas, choose **End**, and wire both branches into it. An End accepts any number of wires, so every branch can finish at the same place. 7. **Play it.** Press **Play**. The campaign window opens on your first passage with the two choices beneath it. Pick one, read the next passage, and reach the end. What you just played is what an exported campaign plays: the same engine, with no preview copy that could disagree with it. See [Why the preview is the shipped story](/explanation/the-preview-is-the-shipped-story/). ## What you have [Section titled “What you have”](#what-you-have) * the\_gate/ * .ripple/ * project.json * registry.json * workspace.json * gate.graph.json * gate-at-the-gate.loc.narr.bdl/ * en-US.md * touch.json * gate-talk-to-the-guard.loc.narr.bdl/ * en-US.md * touch.json * gate-climb-the-wall.loc.narr.bdl/ * en-US.md * touch.json You have built a board, three passages, and the app’s index in `.ripple/`. Every file is plain text. ## Where to go next [Section titled “Where to go next”](#where-to-go-next) * **A second board.** Make one, put a passage on it, and hand the story over with a **Jump** from the first board. Then open *Script navigation* from the activity bar’s bottom icons to see the two boards drawn as a map. See [How to build a board](/how-to/work-on-the-canvas/). * **A dice roll.** A dice roller node can decide whether the climb works. [Nodes](/reference/nodes/) lists every node. * **A macro.** A macro lets you write a stretch of story once and call it from every board. See [How to reuse a stretch of story with a macro](/how-to/make-a-macro/). * **A second language.** Project settings adds one; every passage gains an empty file for it, and the reader picks the language in the campaign window. See [How to add, switch off or change a project’s languages](/how-to/manage-languages/). * **Keep a history.** [How to keep a history and back up a project](/how-to/keep-a-history-and-back-up/). # Licensing > The licences that govern the app, the engine and the hosted service, what each permits and forbids, and where each document is published. Ripple Effect is **proprietary software**. It is not open source, and its source is not published. The licence for the app is the **End-User Licence Agreement** (EULA), published at [legal.ripplefx.app/eula](https://legal.ripplefx.app/eula/). That document is the authority; this page states it in plain words, and where the two differ the document is right. ## Documents [Section titled “Documents”](#documents) | Document | Governs | Address | | -------------------------- | ---------------------------------------------- | --------------------------------------------------------------------------------------------------------- | | End-User Licence Agreement | The app | [legal.ripplefx.app/eula](https://legal.ripplefx.app/eula/) | | Runtime Licence | The engine, published on its own | [legal.ripplefx.app/runtime](https://legal.ripplefx.app/runtime/) | | Terms of Service | The website and the hosted service | [legal.ripplefx.app/terms](https://legal.ripplefx.app/terms/) | | Privacy Notice | What is processed, on what basis, for how long | [legal.ripplefx.app/privacy](https://legal.ripplefx.app/privacy/) | | Sub-processors | Who else touches it | [legal.ripplefx.app/subprocessors](https://legal.ripplefx.app/subprocessors/) | | Ripple Effect Licence 1.0 | Builds earlier than 1.5 | [legal.ripplefx.app/v/ripple-effect-licence-1.0](https://legal.ripplefx.app/v/ripple-effect-licence-1.0/) | ## Which licence governs a build [Section titled “Which licence governs a build”](#which-licence-governs-a-build) | Build | Governing licence | | ---------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | Earlier than 1.5 | The short Ripple Effect Licence that shipped with it, for as long as you use it. 1.4 was the last release that did not ask you to accept anything. The notice stays published, unchanged. | | 1.5 and later | The EULA, from the first build that asks you to accept it. It replaces the earlier licence rather than sitting on top of it. Everything the earlier licence permitted, the EULA permits. | | 1.5.1 and later | EULA 2.0 for a new installation, accepted at first launch. An installation that accepted 1.0 stays under 1.0 until it accepts 2.0. The first launch of 1.5.1 shows 2.0 with **Not now** beside the boxes, and you can accept it then, or later from the home page or **Settings › About**. The app requires it no sooner than thirty days after that first launch. You can decline, and keep using the last release made under 1.0. | ## Acceptance at first launch [Section titled “Acceptance at first launch”](#acceptance-at-first-launch) | Item | Behaviour | | ---------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | | Agreement | Shown at first launch. | | Network requests | None of any kind, including an update check, until you accept. After that, version 2.0 sets four rules every request keeps: nothing you author and no identifier without your say, anything about you only if you switch it on or answer yes, and nobody but us reached unless the agreement names them. [What the app sends](/about/what-the-app-sends/) lists every request. | | Mechanism | The app underneath is not started until you answer. | | Privacy Notice | Shown alongside the Agreement, to read rather than to accept. | ## The EULA [Section titled “The EULA”](#the-eula) ### What it permits [Section titled “What it permits”](#what-it-permits) | Item | Provision | | ----------------------- | ------------------------------------------------------------------------------------------------------------ | | Use | Install and run the official builds on machines you own or control, for any purpose, personal or commercial. | | Cost | Free. | | Seats, keys, activation | No seat count, no licence key, no activation. | | Account | None required. Signing in is optional and only for the hosted service. | An account exists only for the hosted service, which is a separate thing under the [Terms of Service](https://legal.ripplefx.app/terms/). If you never create one, those terms never apply to you, and nothing in the app requires you to. ### What stays yours [Section titled “What stays yours”](#what-stays-yours) Article 3 of the EULA: > Stories, projects, boards, components, scripts and exports you author with it are yours, and nothing in this licence claims any interest in them. | Item | Provision | | --------------------- | -------------------------------------------------------------------------------------------------- | | Interest in your work | The app claims none. | | Selling or publishing | Permitted, with no obligation back. | | Origin of the wording | Carried word for word from the licence the EULA replaced. | | Survival | Article 3 survives termination of the agreement, for any reason. | | Checkable | The formats are plain text: a project is a folder of Markdown and JSON that opens without the app. | ### What is not permitted [Section titled “What is not permitted”](#what-is-not-permitted) Without prior written permission you may not: * copy, redistribute, sell, sublicense, rent or host the software; * modify, translate or create derivative works from it; * reverse engineer, decompile or disassemble it, except to the extent that law expressly permits despite this term; * remove or alter any notice of copyright, authorship or licence it carries. ### Source code [Section titled “Source code”](#source-code) | Item | Position | | ----------------------------- | ---------------------------------------------------------------------------------------------------------- | | Source | Not published. | | Access | Where granted, granted separately and in writing, and carries no rights beyond those stated in that grant. | | Pull requests against the app | Not open. | ### Third-party components [Section titled “Third-party components”](#third-party-components) Ripple Effect is built on software written by other people, each under its own licence. Those licences govern those components and are unaffected by the EULA. The complete list is in the application, under **About → Third-party licences**. ### No warranty [Section titled “No warranty”](#no-warranty) The software is provided as is, without warranty of any kind. To the fullest extent permitted by law the author is not liable for any claim, damages or other liability arising from the software or its use, including loss of or damage to any data, project or document. The agreement states this at length. Nothing in it limits anyone’s rights as a consumer; the agreement says so in its own consumer-protection article. [Version control](/reference/version-control/) is built into the app. ### Termination [Section titled “Termination”](#termination) | Case | Effect | | ---------------------------- | --------------------------------------------------------------------------------------- | | Breach that can be put right | The agreement ends automatically after thirty days’ notice. | | Breach that cannot | The agreement ends immediately. | | On termination | You must stop using the software and remove your copies of it. Your work is unaffected. | ### Changes to the agreement [Section titled “Changes to the agreement”](#changes-to-the-agreement) | Kind of version | Effect | | --------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | Minor | Changes the wording without changing the terms. Applies when published. | | Major | Changes the terms. You are told in the app and on the website, and asked to accept it no sooner than thirty days after that. Until you do, the version you accepted governs you. | * The licence carries no effective date. It takes effect for your installation when you accept it. Nothing about a release of the app is scheduled around a date on a document. * A new feature is not a new version. The licence covers Ripple Effect including every component shipped with it, so a part of the app that did not exist when you accepted the agreement is already covered. A new version follows a change to what the agreement says: what you may do, what is owed to you, who is liable for what. * A version nobody has accepted yet can still be corrected. Once a version has been accepted, or published as final, the text at its own address never changes, and the version you accepted is never altered. * Every version that has ever been in force stays published at its own permanent address. ## The Runtime Licence [Section titled “The Runtime Licence”](#the-runtime-licence) The engine that plays a story is published on its own, as native libraries, command-line tools and WebAssembly components, so that a game, a story player or another tool can play campaigns made here. It is under the [Runtime Licence](https://legal.ripplefx.app/runtime/), not the EULA, because the EULA forbids what an integrator needs to do: ship it to other people. | Term | Provision | | -------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | Cost | Free and royalty-free, for any product, commercial or not. No fee, no revenue threshold, no registration. | | Redistribution | Unmodified in function, as part of your product, not on its own. Signing it, stripping its symbols, wrapping it in a framework or transpiling its WebAssembly is permitted. Changing what it does, or passing it off as your own, is not. | | Credit | Its `NOTICE` text, wherever your product lists the third-party software it uses. No logo, no splash screen. | | Your users | Get no more than you have. The `NOTICE` says so, which is enough for a product shipped with no terms. If your product has terms, they must not contradict it; the licence’s annex has a clause you can paste. | | Versions | A version of the licence stays with the releases it came with. A product shipped with one engine release is never re-licensed; a later licence governs later releases only. | | Bindings | A library that calls the engine from another language can be under any licence, an open-source one included; the engine’s own bindings are MIT. The engine it carries stays under the Runtime Licence. | | Replacement | Not permitted: no engine, runtime or authoring tool that plays or writes the same campaigns, built with the engine, its documentation or its test material. | ## Open-source packages [Section titled “Open-source packages”](#open-source-packages) Several packages underneath the app are separate projects with their own repositories, published under the MIT licence, most of them written by the author, one not. They are named on the [open source page](/about/open-source/). The packages are open source; the app is not. ## The hosted service and the paid tier [Section titled “The hosted service and the paid tier”](#the-hosted-service-and-the-paid-tier) The hosted service is governed by the [Terms of Service](https://legal.ripplefx.app/terms/). A paid tier is planned: media storage, real-time collaboration and team workspaces, reached by signing in. The Terms exist now and describe it in advance; Part B applies from the release that introduces each feature. The Terms state that no feature that is free in the application on the date they take effect will be moved behind an account or a subscription. ## Contact [Section titled “Contact”](#contact) Licensing, redistribution or a source grant: <legal@ripplefx.app>. Copyright © 2026 ConnectingTheDots, a trade name of William Karol Di Cioccio, VAT IT02224180667. All rights reserved. ## Related [Section titled “Related”](#related) * [Open source](/about/open-source/): which packages are published, and under what. * [Updates, logs and Messages](/explanation/updates-logs-and-privacy/): the privacy side of what the app keeps and sends. * [What the app sends](/about/what-the-app-sends/): every request the app makes. * [Version control in the app](/reference/version-control/): the built-in history. * [A project is a folder](/explanation/projects-are-folders/): why your work opens without the app. # Open source > Ripple Effect is proprietary, but several packages it stands on are open source, four of them ours, and so are the engine's bindings; this page explains which, why, and whose the rest is. Ripple Effect is [proprietary](/about/licensing/) and its source is not published. Several of the packages it is built on are open source, and four of them are ours: the node editor, the docking layout the workspace is drawn with, the reading-time estimate and the crash reporter. The distinction is worth keeping straight, because the two halves are easy to blur: **the packages are open, the app is not.** The four are published on their own and take pull requests. `fl_nodes_v2`, `fl_panel` and `fl_crashpad` are Flutter packages and need Flutter; `dart_read_time` is pure Dart. The bindings that let other programs play a campaign are open source too, though the engine they carry is not. ## fl\_nodes\_v2: the node editor [Section titled “fl\_nodes\_v2: the node editor”](#fl_nodes_v2-the-node-editor) [github.com/ConnectingTheDots-DCWK/fl\_nodes\_v2](https://github.com/ConnectingTheDots-DCWK/fl_nodes_v2) · MIT This is the canvas: geometry, wiring, selection, undo, the execution model (control pushed, data pulled) and the minimap. It knows nothing about narrative. A node is whatever the host says a node is, and Ripple Effect is one host. Everything about how a card behaves on the canvas, described in [Boards, nodes and macros](/explanation/boards-nodes-and-macros/), is this package behaving, and a contribution there reaches Ripple Effect and every other application built on it. ## fl\_panel: the docking layout [Section titled “fl\_panel: the docking layout”](#fl_panel-the-docking-layout) [github.com/ConnectingTheDots-DCWK/fl\_panel](https://github.com/ConnectingTheDots-DCWK/fl_panel) · MIT This is a tree of splits, single panels and tab groups that you resize, dock and rearrange. You can tear a tab out of its strip, drop a panel beside another and get the same layout back tomorrow. Every panel holds ordinary Flutter widgets, and nothing about files or documents is in the package: a tab is a name the host resolves. What may go where is answered by the tabs themselves and by a policy the host writes, so an application can say that its tool panels only share a strip with tool panels. It is what the [workspace](/reference/workspace-window/) is drawn with. The project page is one docking area over this package: the documents are tabs and splits, and the layout is kept per project in `.ripple/workspace.json`. The package went public first so that it was built in the open rather than around one application’s shape. Its tree, solver and file format import nothing from Flutter, so a layout can be built, edited and saved headless. The [post that introduces it](https://ripplefx.app/blog/docking-is-a-tree-and-a-tab-is-a-name) says why the tree is the shape it is. ## dart\_read\_time: how long a passage takes to read [Section titled “dart\_read\_time: how long a passage takes to read”](#dart_read_time-how-long-a-passage-takes-to-read) [github.com/ConnectingTheDots-DCWK/dart\_read\_time](https://github.com/ConnectingTheDots-DCWK/dart_read_time) · MIT This is the estimate under every passage: fifty-four languages, mixed scripts counted a run at a time, and an honest word about whether the figure for a language is measured or reasoned. It is pure Dart with no Flutter in it, so a browser or a command line can use it too. [Passages, choices, languages and reading time](/reference/passages/) says what the row under the editor does with it. ## fl\_crashpad: native crash reports [Section titled “fl\_crashpad: native crash reports”](#fl_crashpad-native-crash-reports) [github.com/ConnectingTheDots-DCWK/fl\_crashpad](https://github.com/ConnectingTheDots-DCWK/fl_crashpad) · MIT This is native crash reporting for Flutter over Google Crashpad, on desktop, Android and iOS. It knows nothing about any app, logger or backend. In Ripple Effect it catches a native crash; every report is sanitised before it can be read or sent. [Updates, logs and Messages](/explanation/updates-logs-and-privacy/) covers what the app does with a report. ## The engine’s bindings [Section titled “The engine’s bindings”](#the-engines-bindings) | Binding | Repository | Published as | | ------------------------- | ----------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------- | | JavaScript and TypeScript | [ripple\_effect\_js\_ts\_integration](https://github.com/ConnectingTheDots-DCWK/ripple_effect_js_ts_integration) | `@ripplefx/player` and `@ripplefx/soundscape` on npm | | C and C++ | [ripple\_effect\_c\_cpp\_integration](https://github.com/ConnectingTheDots-DCWK/ripple_effect_c_cpp_integration) | GitHub releases, consumed with CMake | | C# | [ripple\_effect\_csharp\_integration](https://github.com/ConnectingTheDots-DCWK/ripple_effect_csharp_integration) | `RippleFx.Engine` on NuGet | | Dart | [ripple\_effect\_dart\_integration](https://github.com/ConnectingTheDots-DCWK/ripple_effect_dart_integration) | `ripplefx_engine` on pub.dev | These are how a game, an app or a page plays a campaign exported from the editor. Each is MIT. **The engine each one carries or downloads is not**: it is a compiled build of the engine, under the [Runtime Licence](/about/licensing/#the-runtime-licence), which lets you ship it unmodified in function as part of your own product. `@ripplefx/soundscape` carries no engine and is plain MIT. [Embedding the engine](/explanation/embedding-the-engine/) explains what they share, and [How the engine ships](/explanation/how-the-engine-ships/) explains what they put in your build. ## monty: Python, in a sandbox [Section titled “monty: Python, in a sandbox”](#monty-python-in-a-sandbox) [github.com/pydantic/monty](https://github.com/pydantic/monty) · MIT **Not ours.** `monty` is a sandboxed Python interpreter written in Rust by the pydantic team, and it is how a Script node, a `{{ … }}` span in a passage and the console run Python at all. Our thanks to [runyaga](https://github.com/runyaga), whose [`dart_monty`](https://pub.dev/packages/dart_monty) first brought the interpreter to Dart. The engine links the interpreter directly, and a runaway script is stopped at its deadline. The interpreter is a subset of Python: no filesystem, no network, no `class`, and a standard library that is a short list. That restriction is what makes it safe to run a stranger’s story. [Python in a story](/explanation/python-in-a-story/) explains the trade, [The sandbox and its limits](/reference/python/sandbox/) says what it allows, and the [Python reference](/reference/python/) lists what the app binds into it. ## Everything else [Section titled “Everything else”](#everything-else) The complete list of third-party components and their licences is in the application, under **Settings → About → Third-party licences**. Each is governed by its own licence, which the app’s does not touch. ## Related [Section titled “Related”](#related) * [Licensing](/about/licensing/): the terms the app itself is under. * [The workspace window, menus and palette](/reference/workspace-window/): what the docking layout looks like in use. * [Python in a story](/explanation/python-in-a-story/): why scripts run in a sandbox. * [A project is a folder](/explanation/projects-are-folders/): where `.ripple/workspace.json` sits. * [The sandbox and its limits](/reference/python/sandbox/): what a script may do. * [What the app sends](/about/what-the-app-sends/): what leaves the machine, including crash reports. # What the app sends > Every request that leaves your machine, what each carries, what never leaves, what a crash report contains, and what an account will and will not change. Ripple Effect works on files on your own disk and needs no network to do it. This page lists what leaves your machine anyway. Some of it the app asks the website for without being told to (the update check, the engine fix check, the news and the contributor list with its avatars), one you have to switch on, one asked for every time, and one that is only ever something you did yourself. From version 2.0 of the [End-User Licence Agreement](https://legal.ripplefx.app/eula/) the agreement does not list these one by one. It sets four rules every request has to keep: nothing before you accept; nothing you author and no identifier without your say; anything about you only if you switch it on or answer yes; and nobody but us reached unless the agreement names them. A request carrying nothing about you, made only to `ripplefx.app` or a subdomain, is allowed as a kind. The [Privacy Notice](https://legal.ripplefx.app/privacy/) is still the complete list, and says each new one before the release that makes it. The [Privacy Notice](https://legal.ripplefx.app/privacy/) is the formal version, with legal bases and retention periods. This page states the same facts. ## Summary [Section titled “Summary”](#summary) | Request | Destination | When | Switch | Carries anything of yours | | ------------------------------------------------------------- | --------------------------------------------------------------------------- | ------------------------------------------------------------ | --------------------------------------------------- | ------------------------- | | [Update check](#update-check) | `ripplefx.app` | Once per launch | None | No | | [Engine fix check](#engine-fix-check) | `ripplefx.app` | Once per launch, half a minute after start | Settings ▸ Updates | No | | [News](#news) | `ripplefx.app`, and pictures from `ripplefx.app` or a subdomain only | Once per launch, from 1.5.1, once you have accepted EULA 2.0 | Settings ▸ Updates | No | | [Contributor list and avatars](#contributor-list-and-avatars) | `ripplefx.app` for the list, `avatars.githubusercontent.com` for the images | When the About box opens | None | No | | [Usage statistics](#usage-statistics) | Ripple Effect’s hosted backend | At most one row a day, after an hour on | Settings ▸ Data control, off until you switch it on | No | | [Crash report](#crash-report) | Ripple Effect’s hosted backend | Only when you press *Send report* | Asked once per crash | It can | | [Your own actions](#requests-you-cause) | Where you point them | When you act | n/a | Depends | ## Before the licence is accepted [Section titled “Before the licence is accepted”](#before-the-licence-is-accepted) From version 1.5 the app asks you to accept the [End-User Licence Agreement](https://legal.ripplefx.app/eula/) at first launch and makes no network request of any kind until you have: not an update check, not anything. If you close that window, nothing has been sent. A copy that accepted version 1.0 is shown version 2.0 on its first launch of 1.5.1, may accept it then or choose **Not now**, and is required to accept it no sooner than thirty days later. Until then 1.0 governs it, and it makes only the requests 1.0 lists. Until you answer, the app underneath has not been built, so there is nothing there to make a request. ## Update check [Section titled “Update check”](#update-check) | Field | Detail | | --------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | Destination | `ripplefx.app` | | Frequency | Once per launch | | Question | What is the most recent release on each release channel? The answer lists Stable and Preview both, and the app picks the one for the channel you follow and compares it with its own version on your machine. | | Carries | No identifier, no version number, not which release channel you follow, and nothing about you. | | Server sees | The address the request came from, as with any request over the internet. | | Distinguishes installations | No. Nothing in the request is unique to one. | | Switch | None. It carries nothing at all, and the agreement names it as the one request of its kind with no switch. | ## Engine fix check [Section titled “Engine fix check”](#engine-fix-check) | Field | Detail | | ----------------- | ---------------------------------------------------------------------------------------------------------------------------------------- | | Destination | `ripplefx.app` | | Frequency | Once per launch, half a minute after the app has started | | Question | Does a fix exist for the engine the app is running? | | Carries | The engine’s version, its milestone, your platform and an identifier of the interface between the app and its engine. Nothing about you. | | A fix that exists | Downloaded, checked against our signature, and used from the next launch. It never changes what the app can do. | | Switch | *Engine updates* under **Settings ▸ Updates**. *Use the bundled engine* there goes back to the engine that shipped with the app. | | Safeguard | A fix is set aside automatically if the app crashes with it. | ## News [Section titled “News”](#news) | Field | Detail | | ------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------ | | Destination | `ripplefx.app`, which serves the feed at `get.ripplefx.app/news/feed.xml` | | Availability | From version 1.5.1, and only once you have accepted version 2.0 of the licence agreement. A copy that accepted 1.0 asks nothing until then. | | Frequency | Once per launch. If it fails, the next launch tries again; nothing retries in between. | | Question | What is the current news? | | Carries | No identifier, no version number, no cookie and nothing about you. No conditional header either, so the site cannot tell what your copy saw last time. | | Pictures | Fetched from `ripplefx.app` or one of its subdomains, and from nowhere else. A picture an item points at anywhere else is not fetched. | | What you have read | Recorded on your machine only, as the date of the newest item you opened. Never sent. | | Switch | *News* under **Settings ▸ Updates**. Off, nothing is fetched and the News button is hidden. | ## Contributor list and avatars [Section titled “Contributor list and avatars”](#contributor-list-and-avatars) | Field | List | Avatars | | ----------- | ------------------------------------------------------------------------------------------------------------------------ | ---------------------------------------------------------------------------------------------------------------------- | | Trigger | Opening the About box | Opening the About box | | Destination | `ripplefx.app` | `avatars.githubusercontent.com`, from your machine and not through the site | | Carries | The app’s name and version, nothing else about you | A request for each image; GitHub sees the address it came from, as any image host would | | Notes | The site fetches the list from GitHub on its own behalf and keeps it for an hour, so your request does not reach GitHub. | One of the avatar addresses is built into the app, so the request is made even when the list itself cannot be fetched. | GitHub is named in the [sub-processors list](https://legal.ripplefx.app/subprocessors/) as an independent controller for requests made to it. ## Usage statistics [Section titled “Usage statistics”](#usage-statistics) | Field | Detail | | ------------------ | ----------------------------------------------------------------------------------------------------------------------------------------------- | | Destination | Ripple Effect’s hosted backend | | Availability | From version 1.5. | | Default | Off. Opt-in. | | Asked | Once, with the complete list of what would be sent in front of the question rather than behind a link. Neither answer is preselected. | | Where to change it | **Settings ▸ Data control**, where the same list stays in front of you. | | Frequency | At most one row a day. Nothing at all until the statistics have been on for an hour, so switching them on and straight off again sends nothing. | | Adding to the list | Asks again. | | Never included | Any text you wrote; anything about your projects; anything about what you did in them. | ### What a row carries [Section titled “What a row carries”](#what-a-row-carries) | Field | Content | | ---------- | ------------------------------------------- | | Use | Whether the app was in use on the given day | | Day | The day | | Identifier | A random identifier for the installation | | Version | The app’s version | | System | Your operating system’s family | ### The identifier [Section titled “The identifier”](#the-identifier) | State | Behaviour | | --------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | On | The app holds a random identifier it made when you switched statistics on, shown in the same settings row. The data is pseudonymous: while the identifier exists you can quote it to us and have the rows deleted. | | Turned off | The identifier is destroyed. Collection stops and the only link between you and what was already sent is severed. Nobody can then connect those rows to you, including us, so there is nothing left to delete and nothing we could hand back if you asked. | | Turned on again | A new identifier is made. You are a new installation and nothing joins the two. | ## Crash report [Section titled “Crash report”](#crash-report) The only thing the app sends that can contain anything of yours, and the only one that asks. It goes to Ripple Effect’s hosted backend. ### How it asks [Section titled “How it asks”](#how-it-asks) | Step | Behaviour | | --------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | | The app crashes | It saves a report on your machine and sends nothing. | | Next start | A window says the app crashed. It lists every file it would send with its size and a sentence about what is in it, shows a report id to quote if you later want a sent report deleted, and offers *Show files* so you can open them. | | Your answer | The report goes only if you press *Send report*. | | Declined | The report stays on your machine and is never offered again. | There is no setting: no “always send”, no “never send”. The question is asked once per crash, at the moment you can see what it is about, and your answer is final for that report. ### What a report contains [Section titled “What a report contains”](#what-a-report-contains) | Item | Content | | ---------------- | ------------------------------------------------------------------------------------------------------------------------------------------ | | Memory snapshot | The app at the moment it crashed: what each part of it was running, the libraries it had loaded, and small pieces of memory around them. | | Diagnostic files | The crashed run’s session record and logs, each cut to its last 2 MiB. The window tells you when one was cut. | | Manifest | A short file naming the files, the version that sent them and when the crash happened. | | Labels | The app’s version; whether you were on the home screen or inside a project, never which project; an id tying the snapshot to its own logs. | ### Masking [Section titled “Masking”](#masking) Before the window opens, the following are replaced throughout: * your username and home folder * the folder of every project the app knows about * e-mail addresses * network addresses * anything shaped like a password or a key What masking cannot promise A memory snapshot may hold fragments of whatever text the app was working on. No automatic rule can know that a passage you wrote is private. The window says this too. ### Identity [Section titled “Identity”](#identity) There is no account and nothing in a report names you. It does carry a random number the crash reporter made for your installation, so that crashes from one computer can be told apart from crashes from different ones. The number is not derived from your hardware or anything about you and is used for nothing else. If you never send a report it never leaves your machine. ## Requests you cause [Section titled “Requests you cause”](#requests-you-cause) These are yours rather than the app’s, and are listed because they put bytes on the wire. | Action | What happens | | ---------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | Version-control push | Goes to the repository you configured, with credentials you supplied. The credentials live in your operating system’s own credential store and never in a file of ours. | | Opening a documentation or support page | Hands a URL to your browser. From version 1.5 it goes through a redirect on `ripplefx.app`, which sees the request and nothing else. What your browser does next is between you and wherever it went. | | Image in a document, referenced by `http` or `https` | Drawing the document fetches the image from whoever hosts it, and that host sees the request. The app does not ask before making it, and an image small enough to be invisible is still a request. This includes a project somebody sent you, whose documents you did not write. | | Image in the project folder | Read from your disk. No network is involved. | ## What never leaves [Section titled “What never leaves”](#what-never-leaves) | Item | Detail | | ---------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | Your work | Passages, boards, projects, components, scripts and exports are not sent anywhere. There is no background sync. | | Spelling and grammar | Checked on your machine, as you type. Your words, the project’s dictionary and its style rules are not sent anywhere. | | What you do in the app | Which boards you open, how long you spend and what you click are not recorded or sent, with or without statistics switched on. The statistics say that the app was used on a day, and nothing about how. | | The session log | Not sent, unless it goes inside a crash report you sent. It is written on your machine, with private things replaced as they are written, and a run where nothing went wrong is deleted the next time you start the app. | | Network responses | The update check, the news, the contributor list and an installer download each leave a line in the session log saying where the request went, what came back and how long it took. The address is recorded without anything after the `?`, and no response content is written. | ## What is planned [Section titled “What is planned”](#what-is-planned) An account for the hosted service, and any feature that sends something to a third party, are covered by the [Terms of Service](https://legal.ripplefx.app/terms/) and the [sub-processors list](https://legal.ripplefx.app/subprocessors/), which name a provider before a feature ships. | Topic | Terms | | ----------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | What an account is for | The hosted service: media storage, real-time collaboration and team workspaces. Nothing else. | | Signed out | The editor does not change. No feature that is free today will move behind an account. If you never sign in, none of it applies to you. | | A feature that sends something to a third party | It does so only on an action you take. The provider is named in the sub-processors list before the feature ships. What is sent is not used to train anybody’s models. | ## The documents [Section titled “The documents”](#the-documents) | Document | Covers | | -------------------------------------------------------------- | ------------------------ | | [Privacy Notice](https://legal.ripplefx.app/privacy/) | The formal account | | [End-User Licence Agreement](https://legal.ripplefx.app/eula/) | The app | | [Terms of Service](https://legal.ripplefx.app/terms/) | The site and the service | | [Sub-processors](https://legal.ripplefx.app/subprocessors/) | Who else touches it | Questions: <legal@ripplefx.app>. ## Related [Section titled “Related”](#related) * [Updates, logs and Messages](/explanation/updates-logs-and-privacy/) * [The session log and reports](/reference/session-log/) * [Settings, accessibility and updates controls](/reference/settings/) * [How to report a bug](/how-to/report-a-bug/)