Narrative & Interaction: Dialogue, Flow & Input
Introduction · Back to main specification
Implementation status: Choices and branching (16) including nested, conditional, sticky, and fallback choices are fully implemented. Dialogue attribution (via
@speaker:) works. First-visit content ({once}/{then}/{end once}) is implemented. Narrative utilities within Section 16 ({cycle}/{replace}), Cards (17), Voice & Audio (18), Input & Interaction (19), Cooperative Reading (20), and Real-World Interactions (21) are specified but not yet implemented. See REA-CHEATSHEET.md for detailed status.
16. Choices & Branching
Choices are the heart of interactive stories. Rea supports both simple and complex branching.
Simple choices
Use * for one-time choices and + for repeatable (sticky) choices:
The path splits before you.
* [Take the left path]
The left path leads deeper into the forest.
-> dark_forest
* [Take the right path]
The right path follows the river.
-> river_bank
+ [Look around]
You survey your surroundings carefully.
-> the_crossroadsChoice text rules:
* BEFORE [LABEL] AFTER
╰─┬──╯ ╰─┬─╯ ╰─┬─╯
│ │ └── shown only after picking (narration)
│ └── shown as clickable choice text
└── shown in BOTH the choice AND the narration- Text in
[ ]is displayed as the choice label - Text after
[ ]is narration shown after the choice is picked - Text before
[ ]appears in both the choice and the narration
* "I need to think about this[."]," you said.
The merchant waited patiently.When chosen, displays: "I need to think about this," you said. The merchant waited patiently.
As a choice, displays: "I need to think about this."
Conditional choices
Choices can have conditions:
* {has_key} [Unlock the door]
The key fits perfectly. The door swings open.
* {gold >= 50} [Bribe the guard]
The guard pockets your gold and steps aside.
* [Walk away]
You turn and leave quietly.Hidden choices
A choice marked hidden renders no button. It stays in the group's pool — conditions, one-time consumption and narration all work as usual — but it can only fire through something other than a tap: the reader describing it in free-text input, or a real-world input matching its card's activation fields (a scanned code, a photographed mark, a spoken phrase):
* hidden [&look_under_sofa] Jozef bent down and looked under the old sofa, where he found a mysterious envelope marked _Secret!_
{give secret_envelope}The hidden keyword comes first on the choice line; a condition can follow it:
* hidden {player.curious} [&look_under_sofa] …Hidden choices are usually bound to an action card with [&card_id] — the card's description: is what free-text matching compares against, and its scan:/mark:/listen: fields are what real-world inputs match. Because the label only appears after the choice fires, hint at hidden content in the surrounding prose; the label and narration are the reward, not the invitation. Groups built mostly from hidden choices are covered in Exploration menus.
Diverts
Use -> to jump to a named section (anchor):
-> the_clearing
[#the_clearing]
You arrive at a small clearing bathed in moonlight.Nested choices
Choices can be nested using increasing * or +:
* [Talk to the stranger]
"Who are you?" you ask.
* * [Press harder]
"Tell me your real name!"
* * [Let it go]
"Never mind. Forget I asked."
- - The stranger shifts uncomfortably.
* [Ignore the stranger]
You walk past without a word.
- The night continued in silence.- - serves as a gather point — where nested branches reconverge (inspired by Ink's weave system).
Gather points
Gathers use - at the appropriate nesting level to collect all branches back together:
What do you do?
* [Fight]
You draw your weapon!
* [Flee]
You turn and run!
* [Negotiate]
"Can we talk about this?"
- Whatever you chose, the outcome was the same: trouble found you.Fallback choices
A choice without text acts as a fallback (chosen automatically when no other options remain):
* [Ask about the weather]
"Fine day, isn't it?"
* [Ask about the news]
"Heard anything interesting?"
* ->
The conversation fizzled out. -> leave_tavernTunnels (divert and return)
A tunnel diverts into a section and automatically returns to the caller when it ends. Use ->-> to enter a tunnel:
You approach the locked door.
->-> examine_lock
After examining it, you consider your options.
* [Pick the lock]
->-> pick_lock_sequence
The door is open!
* [Find another way]
-> alternative_pathThe tunneled section uses ->-> at its end (or simply reaches its last line) to return:
[#examine_lock]
The lock is old and rusted. Iron, with a simple mechanism.
->->
[#pick_lock_sequence]
You pull out your tools and get to work.
{if dexterity > 5 begin}
The pins click into place smoothly.
{else}
It takes several attempts, but finally...
{end if}
->->Tunnels are useful for reusable passages (e.g., recurring inspections, shared dialogue sequences) without manually routing back.
First-visit content
Show content only on the first visit to a passage, with optional fallback for subsequent visits:
[#the_tavern]
{once begin}
The tavern is warm and lively. A bard plays in the corner.
You've never seen a place quite like this.
{then}
The familiar tavern. The bard nods as you enter.
{end once}
The barkeep waves you over.The {once begin} block renders its primary content on the first encounter and the {then} fallback on all subsequent visits. If {then} is omitted, nothing is shown after the first visit.
Text replacement (live labels)
Labels mark text that can be replaced in-place as the story progresses:
The door is {label door_state begin}locked{end label}.
{// Later, after unlocking}
{replace door_state = "open"}Combined with choices for interactive reveal:
You see a {label clue begin}mysterious symbol{end label} on the wall.
* [Examine the symbol]
{replace clue = "rune of protection"}
Of course — it's a rune of protection!Cycling text (tap-to-cycle)
Inline text that readers can tap to cycle through options, useful for character customization or exploratory narrative:
You chose the {cycle color begin}red|blue|green|black{end cycle} cloak.The reader taps the highlighted word to cycle: red → blue → green → black → red → ...
The selected value is accessible as a variable: {color} returns the current selection.
Varying text
Text can vary based on visit count using | within { }:
{You enter the tavern.|You return to the tavern.|The tavern again. This is becoming a habit.}Modes:
| Prefix | Behavior |
|---|---|
| (none) | Sequence — plays in order, sticks on last |
& | Cycle — loops indefinitely |
! | Once — plays each once, then nothing |
~ | Shuffle — random order |
It was {&Monday|Tuesday|Wednesday|Thursday|Friday|Saturday|Sunday}.
He laughed. {!A genuine laugh.|A polite chuckle.|He didn't laugh this time.}
The coin landed on {~heads|tails}.Hub-and-spoke pattern
A central hub anchor that readers return to after exploring branches. Combined with {once begin}, each branch adds new context to the hub:
[#town_square]
You stand in the town square.
{once name=visit_market begin}
* [Visit the market]
You explore the bustling market stalls.
{set flag.visited_market = true}
-> town_square
{end once}
{once name=visit_temple begin}
* [Enter the temple]
The temple is quiet and cool inside.
{set flag.temple_blessing = true}
-> town_square
{end once}
{if flag.visited_market and flag.temple_blessing begin}
* [Head to the castle]
With supplies and blessing, you're ready.
-> castle_gates
{end if}Parallel storylines
Multiple storylines that advance independently and converge at key moments:
{parallel begin}
{thread elena_thread begin}
[#elena_journey]
Elena travels west through the forest.
{set elena.location = "forest"}
{wait gareth_thread.reached("bridge") begin}{end wait}
They meet at the bridge.
{end thread}
{thread gareth_thread begin}
[#gareth_journey]
Gareth takes the mountain path.
{set gareth.location = "mountain"}
[#bridge]
He arrives at the old stone bridge.
{end thread}
{end parallel}In cooperative reading, different readers can follow different threads simultaneously, experiencing the story from different character perspectives.
Storylets (quality-based narrative)
Storylets are modular content blocks with prerequisites and effects — the building blocks for non-linear, discovery-driven narratives. Instead of rigid branching, the platform selects eligible storylets and presents them as available options.
{storylet the_merchants_plea begin}
require: gold > 20 and visited("market")
priority: 5
repeatable: false
A merchant approaches you with a desperate look.
"Please, I need someone to deliver this package to the northern tower."
* [Accept the quest]
{set quest.has_merchant_quest = true}
{set player.gold = player.gold + 10}
"Bless you! Here's an advance."
* [Decline]
The merchant's shoulders slump.
{end storylet}
{storylet the_hidden_path begin}
require: quest.has_merchant_quest and world.hour >= 20
priority: 10
repeatable: false
As night falls, you notice a faint glow among the trees.
A path you've never seen before reveals itself.
-> hidden_path_adventure
{end storylet}Storylet attributes:
| Attribute | Description |
|---|---|
require | Condition that must be true for this storylet to appear |
priority | Higher priority storylets appear first (default: 0) |
repeatable | true to allow replaying, false for one-time (default) |
cooldown | Minimum visits/time before reappearing |
weight | Relative probability when multiple storylets are eligible |
tags | Categorization for filtering (tags: tavern, social) |
trigger | Real-world input kind that can wake this storylet (see Triggered storylets) |
match | Optional case-insensitive regex the input value must match |
Storylet deck — present available storylets as a hand of cards the reader can choose from:
{deck from="tavern_stories", max=3, shuffle begin}
Choose what catches your attention:
{end deck}This presents up to 3 eligible storylets tagged tavern_stories, shuffled.
Storylets enable organic, non-linear narratives where the story adapts to the reader's state, encouraging exploration and replay.
Triggered storylets
A storylet with a trigger: line is woken by the world instead of a deck: at almost any moment while reading, a real-world input — scanning a QR sticker on a bench, saying a phrase aloud, tapping an NFC tag — can interrupt the main story, play the storylet as a side path, and return exactly where the reader left off:
{storylet bench_secret begin}
trigger: scan
match: "^REAST-BENCH-.*"
require: story.act >= 2
weight: 2
repeatable: false
The code on the bench flickers to life. A voice whispers: "You found me."
* [Follow the whisper]
-> bench_alley
* [Ignore it]
{end storylet}
{storylet magic_word begin}
trigger: listen
match: "abracadabra"
The word hangs in the air — and the wall answers.
{end storylet}trigger:names the input kind. The set is open — the reader app decides which kinds it can physically capture. Common kinds:scan(QR/barcode payload),listen(recognized speech transcript),text,vision,nfc,shake,location. A storylet withouttrigger:behaves exactly as before (deck-only); a storylet may carry bothtrigger:andtags:and appear in decks toomatch:is a case-insensitive regular expression tested against the input's value (the QR payload, the transcript). Omit it to accept any input of that kind- Selection follows normal storylet rules: among storylets whose kind and
match:fit,require:conditions, drawn-state,cooldown:andpriority:are respected, then one is picked by weighted random. One input wakes exactly one storylet - Inside the body,
event.kindandevent.valueexpose the triggering input to conditions and text (they are also visible torequire:during selection), so the scanned payload or the spoken words can be quoted back to the reader:Its tag reads {event.value}.
Interruption and return
A triggered storylet plays like an author-written tunnel (->->): the engine remembers the main-story position — including a pending, not-yet-answered choice group — plays the storylet, and resumes the main story exactly where it was when the storylet ends (its last line, or an explicit divert out). State changes made inside ({set}, {give}, coins) persist into the main story. Saves taken mid-storylet restore into the storylet with the return position intact. A new trigger is ignored while a triggered storylet is already running — side paths never nest.
When an input matches nothing — no eligible storylet, no pending exploration menu option — the reader app gives gentle feedback ("that did nothing… yet") rather than an error, so scanning stray codes is always safe. When both a pending exploration menu and a triggered storylet could answer the same input, the menu wins — see Priority with storylet triggers.
Exploration menus
A choice group can also be a hidden exploration menu — a set of hidden choices that wake only when the reader produces a matching real-world input: scanning a QR code, photographing a hand-drawn mark, saying a phrase, or typing a description:
{menu select=2 begin}
* hidden [&qr_door] The service door clicks open…
* hidden [&painted_tree] The painted tree shimmers…
* hidden [&couch_secret] Under the couch you find an envelope…
* [Give up and move on]
{end menu}Wrapping a choice group in {menu select=N begin} … {end menu} changes how many discoveries the group waits for before the story moves on:
select= value | Behavior |
|---|---|
| (omitted) | Normal single-pick choice group — unchanged from today |
N | Re-presents the group after each activation until N options were chosen, or none remain eligible |
all | Stays open while any option is still eligible |
Each activation plays that option's narration and effects exactly like a tapped choice — {set}, {give}, diverts, all run through the same path. A one-time (*) option leaves the pool once chosen; a repeatable (+) option stays available. A visible option can end the menu early by diverting elsewhere, or simply counts toward N like any other pick.
Activation channels — scan:, mark:, listen: — are declared on the referenced card (see Real-world activation in Cards), not on the choice line itself. The same [&card_id] reference and hidden flag work whether the card wakes from a tap, a scan, a mark, or a voice line.
Undo and saves inside a menu
Each discovery is a separate recorded choice, so undo steps back one discovery at a time — undoing inside a select=2 menu returns to just before the last activation, with the earlier discovery still in place. Saves taken mid-menu resume with the same set of remaining eligible options.
Priority with storylet triggers
A single scan, spoken phrase, or drawn-mark photo can only mean one thing. If the reader has a pending exploration menu open when they produce that input, the menu is checked first; only if nothing in the menu matches does the input fall through to wake a storylet (see Real-World Interactions).
Undo & back navigation
The platform provides built-in back navigation, allowing readers to revisit previous passages. Authors can control this behavior:
{undo enabled=false}By default, undo is enabled for solo reading and disabled for cooperative reading (shared state cannot be rewound). Authors can explicitly disable it for puzzle sections where undoing defeats the purpose:
{lock condition="has_key" begin}
{undo enabled=false}
The door slams shut behind you. There is no going back.
{// Reader cannot undo past this point until the lock section ends}
{end lock}Undo operates at the choice level — each reader choice creates a restore point. Undo reverts all variable changes since the last choice.
Undo in cooperative reading
Undo is disabled by default in cooperative mode because shared state cannot be rewound unilaterally. If an author explicitly enables undo in cooperative reading ({undo enabled=true}):
- Undo affects only the individual reader's local state — their variables, position, and inventory.
- Shared variables (
shared.*) are never reverted by undo. Once a shared variable is set, it stays set for all readers. - If the reader undoes past an
{exclusive}block they claimed, the exclusive lock is not released — other readers' state depends on it. - If the reader undoes past a
{vote}they participated in, their vote is not retracted — the vote outcome stands. - The undo stack is limited to the current chapter. Readers cannot undo across chapter boundaries.
Checkpoints
The platform automatically saves reader progress at safe points (chapter boundaries, after choices). Authors can create explicit checkpoints for critical moments:
{checkpoint}After a checkpoint, the reader can resume from this exact point if they close and reopen the story. The checkpoint captures all variable state (story-scoped and heading-scoped).
{checkpoint name="before_boss"}
You stand at the gates of the Dark Fortress.Named checkpoints allow the reader to select a specific save point when resuming. Unnamed checkpoints overwrite the previous unnamed checkpoint.
| Attribute | Description | Default |
|---|---|---|
name | Optional label for the checkpoint (reader-visible) | auto-generated |
Platform requirements:
- The runtime must persist checkpoint state between sessions — saving progress is a core priority
- On story open, the platform checks for existing checkpoints and offers to resume
- In cooperative mode,
{checkpoint}saves the individual reader's state; shared state is managed by the server independently - The platform may implement additional auto-save beyond author checkpoints (e.g., on app background, before battery-critical shutdown)
Multi-part stories
A longer story can be split into story parts — separate .rea files listed in the bundle manifest as parts (see Part 5 for the manifest schema). The reader plays through a sequence of parts: only the current part is the live document, and scrolling up reveals the previously-visited parts — the actual path taken, never an un-taken branch. There are two ways to move between parts.
Gate [[ target ]] — an automatic, text-free transition. It occupies its own line and is terminal: when the flow reaches it, nothing after it in the current part renders, and the gate marks where the story continues. Scrolling past the current part's end reveals the gated part inline, as a seamless continuation.
You step through the archway; there is no going back.
[[ story/0005-forest.rea ]]Because a gate ends the part, content placed after it is unreachable — the editor flags it as a warning. A gate may target a scene within the part with [[ part.rea:scene ]], resuming at that [#scene] anchor. Gates inside an {if} express variable-driven branching without a manual choice:
{if has_key begin}
[[ story/0006-castle.rea ]]
{end if}
{if not has_key begin}
[[ story/0006-bush.rea ]]
{end if}Cross-part link — a normal navigation link whose target is a part file lets the reader choose to move on by tapping:
[enter the castle > story/0006-castle.rea] rises ahead of you.Variables carry across parts: each part's top-level {set} commands run once as it is entered, on top of the state accumulated so far. Saved progress records the ordered path of visited parts plus the current part and in-part position, so a resume replays the visited parts for the scroll-back and continues the current part where the reader left off (see Part 5, Reading state).
17. Cards: Characters, Items & Actions
Cards are interactive story elements that readers can tap to inspect. They bring the story world to life beyond plain text.
Character cards [@]
[@elena]
You see [@elena] standing by the fountain.Character cards are defined in metadata or a dedicated block:
{define character elena begin}
name: Elena Voss
title: The Wandering Scholar
image: media/elena.png
description: A tall woman with silver-streaked hair and ink-stained fingers.
{end define}When a reader taps [@elena], they see the character's card with portrait, name, title, and description.
Item cards [$]
You find a [$golden_key] on the ground.
{define item golden_key begin}
name: Golden Key
image: media/golden_key.png
description: An ornate key, warm to the touch. It seems to hum faintly.
rarity: rare
{end define}Items can be added to a reader's inventory:
{give golden_key}
{take golden_key}
{if "golden_key" in reader.inventory begin}
The key grows warm in your pocket.
{end if}Coins & wallet
Stories that need money use the built-in coin wallet. It has three tiers — gold, silver, bronze — with the fixed base ratio 1 gold = 10 silver = 100 bronze. The internal tier names never change (so save files stay portable), but authors can rename the labels shown to the reader and adjust the conversion ratios:
{coins gold="Dukát" silver="Groš" bronze="Halier"}
{coins silver_per_gold=5 bronze_per_silver=4}
{earn gold 2}
{earn silver 5}
{spend bronze 3}
{if reader.coins.total >= 100 begin}
You can afford the enchanted blade.
{end if}{spend} automatically breaks higher denominations when the reader lacks the exact tier, and refuses (changing nothing) when the wallet cannot cover the cost. The balance is mirrored into reader-facing variables and persisted across saves:
| Variable | Contents |
|---|---|
reader.coins | Normalized {gold, silver, bronze, total} balance |
reader.coins.total | Total value in bronze base units |
reader.coinNames | Author display labels {gold, silver, bronze} |
Action cards [&]
Action cards represent story branching points with visual emphasis:
[&open_the_gate] Open the ancient gate
[&climb_the_wall] Scale the wall insteadNote: Action cards use
&(ampersand) to distinguish from custom anchors, which use[#name].
Like character and item cards, an action can carry a {define action} block with a name and description:
{define action open_the_gate begin}
name: The Ancient Gate
description: Push open the rusted gate; force the old gate; shove past the entrance
{end define}description: is shown on the card and doubles as the semantic target for free-text action input — what a reader can type to name the action.
Real-world activation
An action card can also wake from a real-world input instead of — or alongside — a tap. Three optional fields sit next to description::
{define action qr_door begin}
name: The service door
scan: ^REAST-DOOR-.*
{end define}
{define action painted_tree begin}
name: The painted tree
mark: emb1:Zk3q… // signature computed by the editor from the drawing
{end define}
{define action couch_secret begin}
name: Under the couch
description: look under the couch; lift the sofa; search beneath the seat
listen: under the couch
{end define}| Field | Matches against | Comparison |
|---|---|---|
scan: | A scanned QR/barcode payload | Case-insensitive regular expression |
listen: | A speech transcript | Case-insensitive regular expression |
mark: | A photographed hand-drawn mark | Exact signature match |
A card can combine any number of these fields — couch_secret above answers to both a typed description and a spoken phrase.
mark:is opaque. Its value is a signature the editor's "Draw a mark" tool computes from a drawing or photo — never write or edit it by hand. To create or change a mark, redraw it in the editor; see Real-world exploration menus for the authoring workflow.
These fields shine when the option playing the card is hidden — see Exploration menus in Choices & Branching. A visible option with activation fields answers to both: the reader can tap its button or produce the matching real-world input.
Card sets & categories
character, item and action are the three built-in card sets. Authors can declare additional sets to group cards that share the same acquisition, loss and usage rules — for example an ability set, an attribute set, or a themed relic set. A set is declared with a {define cardset <id> begin} block:
{define cardset ability begin}
name: Ability Cards
description: Stat-granting cards a hero can equip.
acquire: Earned by completing quests.
lose: Lost when the character is defeated.
use: Play to apply the listed attribute bonus.
{end define}A set may carry the human-readable rule fields acquire, lose and use, plus any additional key: value properties. The set id becomes the kind of every card that belongs to it.
A card joins a set by using the set id where character/item/action would otherwise appear:
{define ability spinach begin}
name: Spinach
strength: +2
{end define}Rule hooks
A set may attach executable hooks that run for every card of that set. The hooks are {on_acquire}, {on_lose} and {on_use}:
{define cardset ability begin}
name: Ability Cards
{on_acquire begin}
{set ability_count = ability_count + 1}
{end on_acquire}
{on_use begin}
{set last_ability_used = event.card_id}
{end on_use}
{end define}An individual card may override any hook while still inheriting the set's other rules. Here ginko redefines on_use but keeps the set's on_acquire:
{define ability ginko begin}
name: Ginko
intelligence: +2
{on_use begin}
{set intelligence = intelligence + 2}
{end on_use}
{end define}Resolution order: for each hook, a card-level definition takes precedence over the set-level definition; hooks the card does not redefine fall through to the set. Card
{on_give}/{on_take}(item lifecycle) and{on_use}are merged with the owning set's{on_acquire}/{on_lose}/{on_use}accordingly.
Playing a card
{play <card_id>} triggers a card's usage. It runs the card's {on_use} hook (falling back to the owning set's {on_use} when the card does not redefine it), so an attribute card applies its attribute and an action card runs its effect through the same command:
{play ginko} Runs ginko's on_use → intelligence + 2
{play spinach} Runs the ability set's on_use for spinachCard ids may contain letters, digits, hyphens and underscores. Playing an unknown card is a no-op. Each successful play emits a card-played runtime event carrying the card id and its set kind, which hosts can observe to update the UI.
Redefining built-in sets
The three built-in sets may be redefined to attach shared rules without changing how their cards are written. Redefining action to add a usage cost applies to every [&] action card:
{define cardset action begin}
name: Combat Actions
use: Spend an action point to play.
{on_use begin}
{set actions_played = actions_played + 1}
{end on_use}
{end define}When an author redefinition and the implicit built-in collide, the author's declaration wins.
Dialogue attribution
Use @character_id: at the start of a line to attribute dialogue. This links speech to a character card and enables automatic voice assignment:
@elena: "The map leads to the northern tower. We must hurry."
@gareth: "Are you sure about this? The guards patrol that area."
@elena: "Trust me. I know a way through the gardens."The platform uses the character's defined voice settings (from {define character}) to render TTS automatically. When no voice is defined, the platform assigns a distinct voice based on the character's properties.
Dialogue attribution also works with inline narration:
@elena: "Follow me," she whispered, slipping into the shadows.
@gareth: He hesitated. "I have a bad feeling about this."Anonymous or unnamed speakers use a description:
@stranger: "You shouldn't be here."
@crowd: "Long live the king!"18. Voice & Audio
Text-to-Speech
The {voice} command controls TTS rendering:
{voice speaker="narrator", speed=5, pitch=5 begin}
In the beginning, there was nothing but silence.
{end voice}
{voice speaker="elena", emotion="whisper", speed=3 begin}
Can you hear it? The walls are listening.
{end voice}Voice attributes:
| Attribute | Range/Values | Default | Description |
|---|---|---|---|
description | string | — | Free-text description of the desired voice (e.g., "A deep, melancholic male voice") |
speaker | string | "narrator" | Voice identity or character description |
speed | 1-9 | 5 | Speech rate |
volume | 1-9 | 5 | Loudness |
pitch | 1-9 | 5 | Voice pitch |
emotion | string | (neutral) | Emotional tone (in the text's language): whisper, shout, sad, excited, angry, calm |
tone | string | — | Overall speech tone: formal, informal, friendly, authoritative, narrative |
pause | 1-9 | — | Pause before speaking (1 = shortest, 9 = longest) |
Reset to defaults by calling {voice} without attributes.
Audio playback
{audio src="media/thunder.ogg", volume=0.8}
{audio src="media/ambient.ogg", loop, volume=0.3, name=ambient_music}
{stop ambient_music}19. Input & Interaction
Text input
{input name=player_name, placeholder="Enter your name"}
Hello, {player_name}!Input behavior: Execution pauses at {input} until the reader submits a value. The value is stored in the variable specified by name. If the reader submits an empty value, the variable is set to an empty string "".
Numeric input
{input name=guess, type="number", min=1, max=100, placeholder="Guess a number"}Numeric input validates against min/max constraints. Out-of-range values are clamped to the nearest bound. Non-numeric input defaults to 0.
Free-text action input
{input type="action"} turns a scene from a menu into a place: the reader types what they want to do in their own words, and the story activates the choice that best describes it — even when the wording differs from anything on screen:
The room is small and dusty. An old couch sags in the corner.
{input type="action", placeholder="What do you do?"}
* [Open the window]
Fresh air streams in.
* hidden [&look_under_sofa] Jozef bent down and looked under the old sofa, where he found a mysterious envelope marked _Secret!_
{define action look_under_sofa begin}
name: Look under the sofa
description: lift or look under the old couch in the corner; check beneath the sofa; search under the seat
{end define}Unlike a plain text input, the submission is not stored in a variable — it is matched against the eligible options of the pending choice group, visible and hidden alike, with conditions already applied and consumed one-time options excluded. For options bound to an action card, the card's description: is the semantic target — write it as a compact list of intents, synonyms welcome, in the language of the story; the card's name: and the option label are considered as well.
A match activates the option through the exact same path as a tap — narration, effects, undo, saves and analytics behave identically. A submission that matches nothing shows a gentle non-match message in the field and the group stays open, so guessing is always safe.
Matching runs entirely on the reader's device: the reader app provides a small multilingual embedding model, and a built-in word-overlap matcher answers when no model is available (or while it is still loading), so free-text input always works — offline, private, no per-interaction cost. Because the model is multilingual, the reader's wording can even drift from the author's description language within reason.
The typed sentence itself never leaves the device and is not stored in story state; only the resulting choice is recorded.
Buttons
{button label="Continue the journey", target=next_chapter}
{button label="Open inventory", action=show_inventory}Buttons with target navigate to anchors (equivalent to -> anchor). Buttons with action trigger named events that {on action_name begin} blocks can handle.
Timer
{timer duration=30, on_expire="-> times_up" begin}
You have 30 seconds to decide!
* [Cut the red wire]
-> red_wire
* [Cut the blue wire]
-> blue_wire
{end timer}Timer behavior: When a timer expires, the on_expire divert fires immediately — even if the reader is mid-choice. Pending choices are canceled and the story continues at the divert target. If no on_expire is set, the timer block simply ends and reading continues after {end timer}. Timers pause when the app is backgrounded and resume when foregrounded. Nested timers are not allowed — a new {timer} inside an active timer replaces the outer one.
Verb-target interaction
Inspired by Texture's word-on-word mechanic, verb-target interaction lets readers drag action words onto highlighted targets in the text. This creates a tactile, discovery-driven experience:
{verbs begin}
examine: "Look closely at"
use: "Use"
talk: "Talk to"
{end verbs}
You see a {target chest begin}wooden chest{end target} and
{target old_man begin}an old man{end target} sitting nearby.
{on use chest begin}
You open the chest and find a silver dagger inside.
{give silver_dagger}
{end on}
{on examine chest begin}
The chest is old oak, bound with iron bands. A faint glow seeps from within.
{end on}
{on talk old_man begin}
"Ah, an adventurer! That chest has been waiting for someone brave."
{end on}
{on examine old_man begin}
His eyes are sharp despite his age. A map peeks from his coat pocket.
{end on}How it works: Available verbs float as draggable elements. The reader drags a verb onto a highlighted target word. The matching {on verb target begin} block fires. Unmatched combinations show a default response:
{on default begin}
That doesn't seem to work.
{end on}Verbs can be conditional and context-sensitive:
{verbs begin}
unlock: "Unlock" {if has_key}
pick: "Pick the lock" {if dexterity > 5}
{end verbs}20. Cooperative Reading
Rea natively supports multi-reader experiences where multiple people read the same story simultaneously.
Reader roles
{define role captain begin}
name: The Captain
description: Leader of the expedition. Makes final decisions.
max: 1
{end define}
{define role crew begin}
name: Crew Member
description: Follows orders. Has unique skills.
max: 4
{end define}Role-specific content
{if group.role = "captain" begin}
Only you can see the secret map. What do you tell your crew?
{else}
The captain is studying something. You wait for orders.
{end if}Synchronized choices
{vote timeout=60 begin}
The crew must decide together:
* [Go north through the mountains]
* [Go south along the coast]
* [Stay and make camp]
{end vote}
The majority chose: {vote.result}Reader-to-reader communication
{whisper to="captain" begin}
Only the captain sees this: the treasure is hidden under the third stone.
{end whisper}
{broadcast begin}
Everyone sees this: a storm is approaching!
{end broadcast}Waiting for readers
{wait readers=all begin}
Waiting for all readers to reach this point...
{end wait}Shared state
Readers share a common state namespace. Any reader can modify shared variables, and changes propagate to other readers:
{set shared.torch_lit = true}
{set shared.door_opened_by = reader.name}
{if shared.torch_lit begin}
The torch illuminates the passage for everyone.
(Lit by {shared.door_opened_by})
{end if}State synchronization
By default, shared variable changes propagate automatically in real-time. The {synchronize} command gives authors explicit control over when state is sent and received:
{synchronize out}Pushes the current reader's shared state to the server — other readers receive the update.
{synchronize in}Pulls the latest shared state from the server into the current reader's view.
Automatic sync mode can be toggled on or off. When enabled, the platform synchronizes at regular intervals without explicit {synchronize} calls:
{synchronize auto="on", interval=5}This enables automatic sync every 5 seconds. To switch back to manual control:
{synchronize auto="off"}After auto=off, changes only propagate when {synchronize out} or {synchronize in} is called explicitly.
| Attribute | Description | Default |
|---|---|---|
out | Push local shared state to server | — |
in | Pull latest shared state from server | — |
auto | Enable/disable periodic sync (on/off) | on |
interval | Seconds between automatic syncs (when auto=on) | platform-defined |
Usage patterns:
- Turn-based games:
auto=off, explicit{synchronize out}after each player's turn - Real-time collaboration:
auto=onwith short interval (default behavior) - Critical sections:
{synchronize out}after{exclusive}blocks to ensure immediate propagation
Conflict resolution
When multiple readers attempt conflicting actions simultaneously, the platform resolves conflicts:
{exclusive action="open_chest" begin}
{// Only one reader can open the chest}
You reach the chest first and pry it open.
{set shared.chest_opened = true}
{end exclusive}
{race timeout=10 begin}
{// First reader to complete wins}
* [Grab the gem]
You snatch the gem before anyone else!
{give ruby}
{end race}Live presence
Readers can see each other's reading position and reactions in real-time:
{presence show="cursor" begin}
{// Show where each reader is in the text}
{end presence}
{react options=["😮", "😂", "😢", "❤️"] begin}
{// Floating emoji reactions visible to all readers}
{end react}Reader events
{on reader_join begin}
{broadcast begin}A new adventurer has joined the party!{end broadcast}
{end on}
{on reader_leave begin}
{broadcast begin}{event.reader_name} has left the party.{end broadcast}
{end on}
{on reader_idle, timeout=120 begin}
{whisper to=event.reader begin}Are you still there?{end whisper}
{end on}Edge cases and platform behavior
Disconnection
When a reader disconnects (network loss, app close, crash):
- During
{wait}: the platform adjusts the required reader count. Ifreaders=all, disconnected readers are excluded after a grace period (default: 30 seconds). Remaining readers proceed. - During
{vote}: the disconnected reader's vote is excluded from the tally. If they had already voted, their vote stands. - During
{race}: the disconnected reader is disqualified. If no readers remain, the race ends with no winner and the platform executes the{else}branch (if any) or skips the block. - During
{exclusive}: if the disconnected reader held the exclusive lock, the lock is released after the grace period, allowing another reader to claim it. - General: the platform fires
{on reader_leave begin}and preserves the disconnected reader's local state. If they reconnect within the session window (configurable in metadata, default: 5 minutes), they resume from their last position with state intact.
Shared variable conflicts
When multiple readers modify a shared variable simultaneously:
- Last-write-wins is the default resolution strategy. The platform uses server timestamps to determine order.
- For numeric accumulation (e.g.,
{set shared.gold = shared.gold + 10}), the platform applies atomic increment — each reader's+10is applied independently, not based on a stale read. - Authors can request explicit locking for critical sections:
{exclusive action="modify_treasury" begin}
{set shared.gold = shared.gold + player.contribution}
{end exclusive}Vote edge cases
- Timeout with no votes: the
{vote}block evaluates toundefined. Authors should handle this:
{if vote.result = undefined begin}
No decision was made. The captain decides.
{end if}- Tie: the platform picks randomly among tied options.
vote.resultreflects the chosen option;vote.tiedistrue. - Single reader: if only one reader is present, their choice wins immediately without waiting for timeout.
Race edge cases
- Timeout with no completions:
race.winnerisundefined. The block's content is skipped. - Simultaneous completion: server timestamp determines the winner.
Role reassignment
Roles are not automatically reassigned when a reader disconnects. If the captain leaves, the story continues without a captain until:
- The author handles it via
{on reader_leave begin}with explicit reassignment logic, or - A new reader joins and claims the vacant role
Authors should always write defensive role checks:
{if group.readers_in_role("captain") = 0 begin}
The crew is leaderless. Someone must step up.
{end if}Solo mode behavior
Cooperative stories must be playable by a single reader without modification. The platform applies these degradation rules automatically:
| Command / Property | Multi-reader behavior | Solo degradation |
|---|---|---|
{vote timeout=N begin} | All readers vote, majority wins | Reader's choice wins instantly (no timeout) |
{wait readers=all begin}...{end wait} | Blocks until all readers reach the point | Instant pass |
{wait EXPR begin}...{end wait} | Blocks until expression is true | Unchanged — condition may be time/state-based |
{exclusive begin} | Only one reader can claim the action | Always available — reader claims instantly |
{race timeout=N begin} | First reader to complete wins | Reader always wins instantly (no timeout) |
{whisper to=ROLE begin} | Only target role sees the text | Shown as normal text |
{broadcast begin} | All readers see the message | Shown as normal text |
{presence show=... begin} | Shows other readers' positions | Hidden (no-op) |
{react options=[...] begin} | Emoji reactions visible to all | Hidden (no other readers to react) |
{synchronize out/in} | Push/pull shared state to/from server | No-op — single reader, no server sync needed |
{synchronize auto=on/off} | Toggle automatic periodic sync | No-op — state is always local |
{on reader_join begin} | Fires when a reader joins | Never fires |
{on reader_leave begin} | Fires when a reader leaves | Never fires |
{on reader_idle begin} | Fires when a reader is idle | Can fire — solo reader can be idle |
group.size | Number of connected readers | Returns 1 |
group.readers | List of reader objects | Returns [self] |
group.role | Current reader's role | Returns first defined role |
group.readers_in_role(R) | Count of readers in role R | Returns 1 for all roles |
Solo principles:
- No waiting for absent readers — timeouts and reader-count waits skip instantly
- No hidden content — solo reader sees all role-gated content (plays all roles)
- No broken state —
group.*returns valid data (size=1,readers=[self]) - Author override — stories can opt into single-role mode via metadata
Role handling in solo mode
By default, the solo reader is assigned to all roles simultaneously. Role-gated blocks ({if group.role = "captain" begin}) evaluate to true, and when multiple role blocks exist for the same passage, all display with a visual role badge (e.g., [Captain], [Crew]).
Authors who want single-role solo play (reader picks one role, replays for others) can opt in via the manifest:
{ "solo_mode": "single_role" }21. Real-World Interactions
Rea integrates with real-world sensors and APIs through the world.* namespace, making stories that respond to the reader's physical context. All sensor access requires reader permission and degrades gracefully — if a sensor is unavailable, the story continues without it.
Capability requirements
Declare which real-world features a story needs. The reader app checks availability before starting:
{require gps}
{require camera}
{require accelerometer}
{require nfc optional}Adding optional means the feature enhances the story but isn't required. The world.has() function checks at runtime:
{if world.has("nfc") begin}
Tap the NFC tag hidden under the bench.
{else}
Type the code printed on the bench: {input type="text", name=bench_code}
{end if}Location
GPS coordinates use the @ point literal and @@ area literal:
{if world.location matches @@48.14;17.10/500 begin}
You feel a strange resonance. This is the place from the story!
{end if}Location properties:
| Property | Type | Description |
|---|---|---|
world.location | point | Current (lat, lng) position |
world.location.lat | float | Latitude |
world.location.lng | float | Longitude |
world.location.alt | float | Altitude in meters (if available) |
world.location.acc | float | Accuracy in meters |
world.heading | float | Compass heading in degrees (0-360) |
world.speed | float | Movement speed in m/s |
Waypoints
Inspired by geocaching, waypoints define named locations that the reader must visit:
{waypoint old_bridge, @@48.1432;17.1056/50 begin}
The old bridge creaks beneath your feet. Under the third plank,
you find a leather pouch with a strange symbol.
{set story.symbol_found = true}
{end waypoint}
{waypoint castle_ruins, @@48.1510;17.1120/100, require=story.symbol_found begin}
The symbol glows as you approach the ruins.
A hidden passage reveals itself in the eastern wall.
{end waypoint}Waypoints have optional attributes:
| Attribute | Description |
|---|---|
require | Condition that must be true to activate |
hint | Text shown to help reader find the location |
proximity | Distance in km at which hint becomes visible |
icon | Map marker icon |
hidden | Waypoint invisible on map until require is met |
Multi-stage routes
Chain waypoints into sequential or non-sequential routes:
{route treasure_hunt, sequential begin}
waypoint: old_bridge
waypoint: castle_ruins
waypoint: hidden_cave
complete: "You've completed the treasure hunt!"
{end route}Setting sequential forces visiting waypoints in order. Without it, readers can visit in any order.
Geo-fencing zones
Define areas that trigger events when the reader enters or exits:
{zone dark_forest @@48.14;17.10@48.15;17.10@48.15;17.11@48.14;17.11 begin}
{on enter begin}
The trees close in around you. The forest feels alive.
{set world.ambient = "forest"}
{end on}
{on exit begin}
You emerge from the forest, blinking in the sunlight.
{set world.ambient = "default"}
{end on}
{end zone}Time of day
{if world.hour >= 22 or world.hour < 6 begin}
The darkness around you feels real tonight.
{else}
Daylight makes the story feel less frightening.
{end if}Time properties:
| Property | Type | Description |
|---|---|---|
world.hour | integer | Current hour (0-23) |
world.minute | integer | Current minute (0-59) |
world.weekday | string | Day name (lowercase) |
world.date | string | ISO date string |
world.season | string | Season based on hemisphere |
Night mode
Combine time and light sensor for atmosphere:
{if world.hour >= 22 and world.light < 50 begin}
{set ui.theme = "dark"}
The chapter can only be read in darkness. Turn off the lights.
{end if}Weather
{if world.weather = "rain" begin}
How fitting — it's raining in the story and outside your window.
{end if}Weather properties:
| Property | Type | Description |
|---|---|---|
world.weather | string | Current condition (clear, rain, snow, fog, storm) |
world.temperature | float | Temperature in Celsius |
world.wind | float | Wind speed in m/s |
world.humidity | float | Humidity percentage (0-100) |
QR and barcode scanning
{scan type="qr", target="REAST-SECRET-42" begin}
Scan the QR code hidden in the real world to unlock this chapter.
{end scan}Supported scan types:
| Type | Description |
|---|---|
qr | QR code (most common) |
barcode | Any supported barcode (EAN, UPC etc) |
aztec | Aztec code (boarding passes) |
datamatrix | Data Matrix code |
The target attribute matches the scanned value. Use pattern for regex matching:
{scan type="qr", pattern="^REAST-.*" begin}
You found one of the hidden codes! {set story.codes_found = story.codes_found + 1}
{end scan}A {scan} block is blocking — the story stops at that point and waits for the code. For codes the reader may encounter anywhere along the way, use triggered storylets (trigger: scan) or an exploration menu option with a scan: card field instead: those are opt-in interruptions that fire whenever the input arrives.
NFC tags
{nfc target="reast:chapter5" begin}
Tap your device on the NFC tag to reveal the hidden message.
{end nfc}
{nfc read, name=tag_data begin}
The tag contains: {tag_data}
{end nfc}Camera and photo
{capture type="photo", name=reader_photo begin}
Take a photo of your surroundings to continue.
{end capture}| Type | Description |
|---|---|
photo | Single photo capture |
video | Short video recording (max duration attribute) |
selfie | Front camera photo |
Motion and orientation
Access device sensors for physical interactions:
{on shake, intensity=2 begin}
You shake the magic 8-ball. The answer appears: {~Yes|No|Maybe|Ask again}
{end on}
{on tilt, direction="north", threshold=15 begin}
The compass needle swings north. The hidden door opens.
{end on}Motion properties:
| Property | Type | Description |
|---|---|---|
world.tilt.x | float | Forward/backward tilt (-180 to 180) |
world.tilt.y | float | Left/right tilt (-90 to 90) |
world.orientation | float | Device rotation (0-360, compass) |
world.acceleration.x | float | Acceleration along X axis |
world.acceleration.y | float | Acceleration along Y axis |
world.acceleration.z | float | Acceleration along Z axis |
Light level
{if world.light < 10 begin}
In complete darkness, the phosphorescent text begins to glow.
{end if}
{if world.light > 500 begin}
The bright sunlight reveals invisible ink on the page.
{end if}world.light returns ambient light in lux (0 = darkness, 500+ = bright daylight).
Vibration and haptics
{vibrate 200}
{vibrate pattern=[100, 50, 100, 50, 300]}Pattern: array of alternating vibrate/pause durations in milliseconds.
Proximity
{on proximity "near" begin}
You hold the device close to the object. A secret message appears.
{end on}Voice input
{listen language="en", name=spoken_word begin}
Speak the magic word to open the door.
{end listen}
{if spoken_word = "abracadabra" begin}
The door slowly creaks open.
{end if}Like {scan}, a {listen} block stops and waits at one point. For phrases the reader can say at any moment, use triggered storylets (trigger: listen) or an exploration-menu option with a listen: card field.
Priority: exploration menus vs. storylet triggers
A scan, spoken phrase, or photographed mark is a single physical event — it cannot mean two things at once. If the reader has a pending exploration menu open when they produce that input, the engine checks the menu's scan:/mark:/listen: options first. Only when nothing in the menu matches does the same input fall through to wake a storylet trigger.
Dice and randomization
Inspired by tabletop RPG conventions, Rea supports dice notation for game-like interactions:
{set combat.roll = dice("2d6+3")}
You rolled {combat.roll}!
{if combat.roll >= 10 begin}
Critical success! The dragon flees.
{else if combat.roll >= 7}
You wound the dragon.
{else}
The dragon swipes you aside.
{end if}Dice notation:
| Notation | Description |
|---|---|
d6 | Single six-sided die |
2d6 | Two six-sided dice, summed |
2d6+3 | Two d6 plus modifier |
d20adv | Roll with advantage (best of two d20) |
d20dis | Roll with disadvantage (worst of two d20) |
4d6kh3 | Roll 4d6, keep highest 3 |
d100 | Percentile die |
Real-world challenges
Combine multiple sensors into challenge-style interactions inspired by geocaching and adventure games:
{challenge night_vigil begin}
require: world.hour >= 23 and world.light < 20
require: world.location matches @@48.14;17.10/200
timeout: 30m
hint: "Find the old chapel after midnight. Bring no light."
You stand in darkness before the ancient chapel.
The stars above spell out a message only visible at this hour.
{set story.star_message = "VERITAS"}
{end challenge}Challenge attributes:
| Attribute | Description |
|---|---|
require | One or more conditions (all must be true) |
timeout | Time limit (e.g. 30m, 2h) |
hint | Guidance shown when conditions are partially met |
retry | Allow retry after failure (default: true) |
reward | Variable set on completion |
Privacy & data handling
Rea stories can access GPS, camera, microphone, and motion sensors. The platform enforces strict privacy rules:
Permission tiers:
| Tier | Sensors | Behavior |
|---|---|---|
| None | time, date, season | No permission needed — non-identifying |
| Low | weather, light, vibration | Single prompt, approximate data only |
| Medium | GPS (approximate), accelerometer, gyroscope | Explicit permission, while-story-open only |
| High | GPS (precise), camera, microphone, NFC | Per-use permission with preview of what's captured |
Data handling rules:
- Ephemeral by default. Sensor values exist only during the current reading session. No persistent location history, no sensor logs
- No author access to raw data. Authors receive boolean/event results (
world.location matches @@...→true/false), not exact coordinates. Exception:{capture}gives photos for in-story display only - No server transmission of precise location. In cooperative mode, other readers see events ("Reader A reached waypoint_X"), never raw coordinates
- Session-only microphone.
{listen}transcribes locally. Audio is never stored or transmitted — only recognized text is available as a variable - Weather via approximate geolocation. Weather API calls use IP-based location, not GPS coordinates
Reader-facing guarantees:
- Before story starts: sensor requirements shown (from metadata
sensors:field) - Each sensor request displays a purpose description (author-provided via
hintattribute) - Reader can deny any sensor — story degrades gracefully
- Reader can revoke permissions mid-story
- All captured media and session state are deletable by the reader
Sensor availability
Not all devices support all sensors. The Reast reader app provides fallbacks:
| Sensor | Browser support | Fallback |
|---|---|---|
| GPS location | All browsers | Manual city/region input |
| Camera/QR | All browsers | Manual text code input |
| Accelerometer | Chrome, Edge | Tap/swipe gestures |
| Gyroscope | Chrome, Edge | Compass direction buttons |
| Light sensor | Limited | Time-of-day estimation |
| NFC | Android Chrome | QR code alternative |
| Vibration | Chrome, Firefox | Visual pulse effect |
| Voice input | Chrome | Text input |
| Weather | Via API | Reader self-reports or skip |