Logic & Data: Variables, Conditions & Expressions
Introduction · Feature index · Cheatsheet
Most of this page is experimental: released and in daily use, but the syntax may still be refined within 1.x. Two features here are not available to authors yet — coordinate literals (
draft) and state machines (development). Each carries its own badge.
10. Commands
{name attr=value} · {name begin}…{end name}The always-self-closing-or-paired rule and the begin/{end …} pairing are released; individual commands carry their own status.
Commands are the core mechanism for interactivity. They are enclosed in { } curly braces.
Every command is always either self-closing or paired — never both. There is no "optional pairing".
Self-closing commands
{command_name attribute=value}Paired commands
Use begin to open, close with {end command_name}:
{command_name attribute=value begin}
Content affected by the command.
{end command_name}The content inside a paired command is equivalent to a content attribute:
{format color="#00f" begin}formatted text{end format}
{format color="#00f", content="formatted text"}Both forms produce identical results. The content attribute is set by the parser to the inner text of every paired block, giving the author a choice between inline or block style without requiring special parser rules.
Print shorthand
You have {player.gold} gold.An expression alone inside braces prints its value; released and widely used, but the surrounding expression grammar may still move.
{...} prints automatically when the first token is a domain name (part, story, shared, context, or a manifest-renamed equivalent — see Domains), a literal (42, "text", true, [1,2,3], a coordinate literal), or a known function name (a built-in, or an author-defined {function}):
Hello, {story.player.name}! You have {story.player.gold} gold.Prose evaluates the whole expression grammar — a call, arithmetic, a ternary, an index, all of it:
You look {story.player.calm > 0 ? "settled" : "restless"}.
You have {upper(story.player.title)}, and {story.player.gold + 1} coins after the tip.
You have {plural(story.player.gold, one="{} coin", other="{} coins")}.Prose reads; it never writes. {set} remains the only way to change state — an expression in prose that could write would make the rendered page a place where story state changes, which breaks replay, revert and cooperative sync at once. An assignment written into prose is reported (eval/invalid-expression) and prints nothing.
A brace you mean literally is escaped: \{not a command\} (see Escaping & raw text).
Every other first token is an error, not a print. An unrecognized first token is never silently printed — that would put the author's markup on the reader's page, exactly what the reader channel exists to prevent. The diagnostic names what kind of mistake it is:
| First token is... | Code |
|---|---|
| A bare reserved word (below) outside a valid keyword position | parse/reserved-word-misuse |
| A domain-shaped path whose domain segment doesn't match any of the four (or a rename) | link/unknown-domain |
| A bare, undotted identifier that is not a function parameter in scope | link/unknown-domain — every read carries a domain, so a dotless name names nothing |
| A malformed/incomplete expression (unbalanced parens, trailing operator) | eval/invalid-expression |
undefined — whether from a never-set variable, a deleted one (below), or a failed operation — always prints as nothing, never the literal text "undefined".
What the reader sees
Every printable value has a defined rendered form, so a reader never meets a debug shape. Values print in their canonical, locale-independent form; locale-dependent presentation is always an explicit call — formatNumber(), formatDate(), plural(), select().
| Value | Prints as | Notes |
|---|---|---|
undefined, a deleted variable, a failed operation | nothing | Never the text undefined |
true / false | true / false | Lowercase, never a translated word — translation is select() |
| An integer | 11 | No thousands separator; that is formatNumber() |
| A float | 0.3 | 15 significant digits, trailing zeros dropped, . as the decimal mark — {0.1 + 0.2} prints 0.3 |
| A long float | 0.333333333333333 | The same cap; shorten it yourself with round(x, n) |
NaN, ±Infinity | nothing | A non-finite result is as unprintable as undefined |
-0 | 0 | |
| A string | its text, unquoted | Printed verbatim and never re-parsed — {…} inside a value is text, not a command |
| An empty array | nothing | |
| An array | sword, rope | Elements joined with , , each printed by this table; strings lose their quotes |
| A nested array | 1, [2, 3] | Brackets on the inner level only, so element boundaries survive |
| A point | 48.148600, 17.107700 | Six decimals (~11 cm); a comma decimal mark could not be read back |
| A path | path((48.148600, 17.107700), (48.150000, 17.110000)) | Composite geographic values print as valid Rea source |
| An area | area((48.148600, 17.107700), …) | |
| A circle | circle((48.148600, 17.107700), 250) | |
| A buffer | buffer(path(…), 50) | A bare point argument takes parentheses |
| A union / a difference | area(…) + circle(…) / area(…) - circle(…) | |
| A datetime | 2026-08-17T20:15:00Z | The ISO source form; the reader's locale comes from formatDateTime() |
| A duration | PT90M | The ISO source form; a human phrase is a built-in's job |
| A date held as a string or a millisecond number | per the string / integer row | Date built-ins operate on ISO strings and timestamps |
| Anything else — a regex, a host-supplied object | nothing, plus eval/invalid-expression | The backstop: an engine-authored shape never reaches a page |
Attributes
{voice speed=3, emotion="whisper" begin}One comma-separated parameter grammar shared by commands, functions, media and arrays.
Commands and functions share a unified parameter syntax. Parameters are comma-separated.
Named parameters use key=value:
{voice speed=3, pitch=7, emotion="whisper" begin}
She leaned close and said the secret word.
{end voice}String values with spaces are quoted:
{button action="show_map", title="The Kingdom of Arath"}Boolean attributes can be specified without a value (presence means true):
{video src="intro.mp4", autoplay, loop, muted}Positional parameters precede named parameters. In function calls, positional arguments come first:
{plural(story.player.gold, zero="no coins", one="{} coin", other="{} coins")}
{formatNumber(story.player.score, style="decimal", maximumFractionDigits=0)}
{max(a, b)}{} inside a named parameter value inserts the first positional argument's value.
Comma and attribute grammar
No feature is registered under the id comma-grammar in .vitepress/data/features.ts.
A comma at the top nesting level — not inside (), [], or a quoted string — separates attributes:
{image src="a.png", alt="Hello, world"}is valid: the comma inside the quoted string is not a separator, because quotes (like () and []) suspend top-level comma-splitting for their contents.
A top-level comma on a construct that takes no attributes is an error, surfaced as soon as it's typed:
{break, foo="x"} {comment ERROR — {break} takes no attributes at all}A bare word in attribute-value position is never a literal — it is always a variable reference:
{image src="a.png", alt=Hello, world}is an error: Hello is parsed as a bare identifier reference, but alt requires a literal, and strings always require quotes (see Data types). The trailing , world is a further bare positional token with no attribute to bind to, but the error is already caught at Hello.
Accepted literal forms for an attribute value:
| Form | Example | Notes |
|---|---|---|
| Quoted string | "Hello, world" | The only string form — unquoted bare-word strings are never valid. |
| Number | 42, 3.14, -7 | |
| Boolean | true, false | |
undefined | undefined | |
| Variable reference | story.player.gold | A dotted, domain-prefixed path — not a bare word. |
| Parenthesized expression | (a + b) * 2 |
name= is the one attribute this rule doesn't apply to: it assigns a symbolic label for later reference (see Command naming), not a value to evaluate, so its bare identifier is neither a literal nor a variable read.
Command naming
Commands can be named for later reference using name=:
{if story.player.gold > 100, name=rich_check begin}
You flash your wealth.
{end if}Named commands track execution state (see Built-in Functions).
Reserved keyword
end is a reserved keyword and cannot be used as a command name. It is recognized exclusively as the closer in paired commands: {end command_name}.
The broader list of reserved bare words that also apply to domain, function, and attribute names lives in Reserved bare words.
Common command attributes
| Attribute | Description |
|---|---|
name | Assign a name for reference |
repeat | true (default) or false — evaluate once only |
once | Shorthand for repeat=false — display only on first encounter |
11. Variables & Data Types
Declaring variables
{set story.player.gold = 100}Available since the first release and in real use, but still in its testing phase — assignment and the mandatory-domain rule may be refined before they are frozen.
Every variable reference and every {set} target must carry a domain prefix. A bare, dotless name in {set} position is an error (parse/dotless-set). The domain is the mandatory first segment; everything after it is the author's free, undeclared namespace, organized into logical categories. There is no separate "persistent" vs. "non-persistent" variable kind — the domain alone determines a variable's lifetime (see Scoping):
{set story.player.name = "Aiden"}
{set story.player.gold = 100}
{set story.quest.has_key = true}
{set story.player.inventory = ["sword", "torch", "map"]}Common free-namespace patterns, under whichever domain fits the variable's lifetime:
| Pattern | Use case | Example |
|---|---|---|
| Character name | Character state | story.player.gold, story.elena.location |
| Object category | Items, tools, environment | story.tool.knife, story.door.state |
| Story concept | Flags, quests, progress | story.quest.has_key, story.flag.visited |
| Multi-level nesting | Fine-grained organization | story.role.king.power, story.map.zone.3 |
Scoping
part. · story. · shared. · context.Exactly four mandatory domains — part, story, shared, context — each with its own lifetime; heading-scope no longer exists.
Without a manifest domain remap (below), exactly these four domains exist:
| Domain | Lifetime and purpose |
|---|---|
part. | Local temporary state. Reset every time the reader's active part changes (a terminal gate, a cross-part link, or any other part-file transition) — including re-entering a previously-visited part; there is no restore. |
story. | Persists across the whole .reast package, across every part, for the lifetime of the reading session (and across sessions, via save/restore). |
shared. | Persists like story., but every write also replicates to every connected reader in the cooperative session (env/shared-write-conflict — last write wins). |
context. | Read-only interaction with the world, the reader, the device, and the running session — see Context domain. |
{set story.player.gold = 50}
{set shared.player.name = "Aiden"}
{set part.scratch.attempts = 0}shared. and part. are domains in their own right, not modifiers on story. — shared.player.name is "player.name under the shared domain," not "the story-domain player.name, shared."
Heading-scope no longer exists. There is no domain-free variable form — every {set} target and every variable read carries one of the four domain names above (or a manifest rename). An author who wants temporary, section-local state uses part. instead: it behaves like story. (persists across heading boundaries, loop bodies, {if} branches) except its reset trigger is a part change, not a heading boundary.
Loop variables ({for} item/index, {while} counter) are not a special case. They are ordinary domain-prefixed variables, chosen by the author like any {set} target — part.item is the typical choice for scratch state, but story.item or any other domain works exactly as well. The loop header performs the equivalent of {set <path> = <value>} on every iteration/entry; see For Loop and While Loop. Nothing about the variable is auto-cleared when the loop ends — it keeps whatever value it last held, for as long as its domain says it should.
Function parameters are the one dotless exception — see Custom Functions for why (recursion needs a fresh binding per call frame, which a shared domain path cannot provide).
Reserved bare words
No feature is registered under the id reserved-words in .vitepress/data/features.ts.
begin, end, else, elseif, true, false, undefined are reserved as:
- A domain name (first segment only):
{set begin.x = 1}is an error, even though it's dotted — the domain segment is the one place a bare reserved word is genuinely ambiguous with the keyword. - A function name:
{function true(...) ...}is an error. - An attribute name:
{image end="..."}is an error.
They do not need reserving past the first segment of a path (story.quest.true is fine), and they do not need reserving as loop/lexical identifiers outside these three positions.
Other command keywords (if, for, while, switch, case, default, on, state, function, return, break, continue) are reserved as command names but not as domain names — {set story.on.event_name = true} is legal, because only the first group can appear as a bare, undotted attribute value (true/false/undefined are literals; begin/end/else/elseif are structural keywords), where the ambiguity actually arises.
Domain renaming
No feature is registered under the id domain-renaming in .vitepress/data/features.ts.
A manifest MAY remap the four domain names to author-chosen identifiers:
{
"domains": {
"context": "周囲",
"story": "物語"
}
}- Keys are always the canonical English names (
part,story,shared,context) — only the in-story identifier is localized. - A rename target must not collide with a reserved bare word (above) or with another domain's name (canonical or renamed). Both checks run at manifest-load time, before any
.reafile is parsed. - A rename target must be a valid Rea identifier under the Unicode rule (below).
- Omitting
domains, or omitting individual keys, uses the canonical English name for the un-remapped domain(s) — partial remaps are allowed. - An unrecognized key inside
domainsfollows the existingpkg/manifest-unknown-keyconvention (info, no reader consequence) rather than a hard failure.
Context domain
No feature is registered under the id context-domain in .vitepress/data/features.ts.
context. is the platform's read-only window onto the reader, the device, the world, and the running session. Default capability → path table:
| Path | Type | Notes |
|---|---|---|
context.reader.name | string | |
context.reader.language | string | |
context.reader.age | integer | |
context.story.title | string | Platform metadata — not the reserved story.meta.* subtree (below). |
context.story.chapter | string | |
context.story.progress | float | |
context.time.now | datetime | |
context.time.date | string | |
context.time.hour | integer | |
context.time.minute | integer | |
context.time.day | integer | |
context.time.month | integer | |
context.time.year | integer | |
context.time.weekday | string | Day name, lowercase. |
context.time.season | string | Hemisphere-aware. |
context.weather | string | clear, rain, snow, fog, storm. |
context.temperature | float | Celsius. |
context.wind | float | m/s. |
context.humidity | float | Percentage, 0–100. |
context.location | point | Test it against an area with matches — see Coordinate literals. |
context.location.lat / .lng / .alt / .acc | float | |
context.heading | float | Compass heading, 0–360. |
context.speed | float | m/s. |
context.orientation | float | Device rotation, 0–360. |
context.tilt.x / .y | float | Forward/backward and left/right tilt. |
context.acceleration.x / .y / .z | float | |
context.light | float | Ambient light in lux. |
context.device.camera | boolean | |
context.device.gps | boolean | |
context.device.vibration | boolean | |
context.group.size | integer | |
context.group.readers | array | |
context.group.role | string |
This replaces the old reader.*, world.*, device.*, group.* built-in namespaces — a breaking change with no compatibility shim; a story referencing world.location gets link/unknown-domain. A scanned/spoken payload ({scan}/{listen}) stays a lexical event.kind/event.value binding, not a context.* path — there is no "last scan result" to read continuously. The two built-ins that used to hang off a namespace become plain global builtins, matching the un-prefixed style of max()/length()/number(): world.has(feature) is has(feature) and group.readers_in_role(role) is readers_in_role(role).
What the reader carries is not context. — {give}/{take} and the coin wallet write story.reader.inventory, story.reader.pocket and story.reader.coins, under story. because they are consequences of the story that a save must carry. context. is only what the platform observes and the story cannot change.
A manifest MAY remap individual context.* paths, independently of a domain rename:
{
"domains": { "context": "周囲" },
"context": { "location": "位置" }
}Writability: context. is read-only in full — {set context.* = ...} at any path is an error (eval/context-write-refused). Every current path is populated by the platform, never by {set}.
Redaction: no context. path's value may ever appear in a diagnostic record, at any severity — the type name is reportable, the value never is. No carve-outs by path (context.time.hour is not "just the hour, that's fine" — it is redacted the same as context.location).
Story metadata
No feature is registered under the id story-metadata in .vitepress/data/features.ts.
meta is reserved as a first free segment only under story.:
story.meta.title
story.meta.chapter
story.meta.progresspart.meta.*, shared.meta.*, and context.meta.* are ordinary author namespace — not reserved. {set story.meta.* = ...} at any path under this subtree is an error: eval/story-meta-write-refused, same partition and shape as eval/context-write-refused, kept as a distinct code because the two are different write-refusal reasons (built-in read-only namespace vs. a reserved-but-otherwise-normal domain).
Unicode identifiers
No feature is registered under the id unicode-identifiers in .vitepress/data/features.ts.
Rea identifiers (domain segments, after any manifest rename, and everything in the author's free namespace) are defined over Unicode Identifier and Pattern Syntax characters (ID_Start/ID_Continue, per UAX #31) plus _ and digits in continue position, excluding the ASCII punctuation Rea's own grammar already reserves (., ,, =, braces, brackets, parens, quotes). 周囲.位置 is exactly as valid as context.location.
Every identifier is compared and stored in NFC (Normalization Form C), normalized the moment it's read, before any comparison, storage, or diagnostic — a precomposed and a combining-sequence spelling of the same visible identifier are always the same variable.
Confusable-script mixing produces a diagnostic, not an error: an identifier that mixes characters from two or more of {Latin, Cyrillic, Greek} which are members of Unicode's confusables table for each other, evaluated pairwise across the identifier's characters, gets style/confusable-identifier (info) naming the mixed scripts. Common/inherited scripts (digits, _, combining marks) never count toward "more than one script," so a single non-Latin identifier (周囲.位置, context.météo) is never flagged — this is deliberately narrow: it catches a homoglyph swap meant to look identical, not the normal case of a localized identifier mixing, say, CJK and Latin.
Deletion via undefined
No feature is registered under the id deletion in .vitepress/data/features.ts.
{set story.x = undefined} deletes the variable — reading story.x again afterward is indistinguishable from it never having been set. Assigning undefined to a node with children deletes the entire subtree (story.player.stats.strength = undefined also removes any story.player.stats.strength.* nested properties). There is no domain-clear command — deleting everything under a domain is {set domain.root = undefined} on the shallowest common ancestor, since subtree-delete already covers it.
A {set shared.x = undefined} replicates like any other shared write, through the same last-write-wins conflict resolution — no special case. A checkpoint/save taken after a deletion simply doesn't contain that path; restoring an earlier snapshot naturally brings the variable back, since restore replaces the whole variable state with the snapshot's.
Data types
string · integer · float · boolean · array · regex · undefinedThe type set is released, including the always-double-quoted string rule that keeps bare words unambiguous.
| Type | Example | Description |
|---|---|---|
string | "hello" | Text value, always double-quoted |
integer | 42 | Whole number |
float | 3.14 | Decimal number |
boolean | true, false | Logical value |
array | [1, 2, "adam"] | Ordered collection |
regex | /^[a-z]+$/i | Regular expression — a literal on the right of matches. Not yet a value a variable can hold: a save is JSON, and a stored pattern would not survive it. |
undefined | undefined | Null/empty value |
Strings always require double quotes — there are no unquoted string literals. A bare word in an expression is always a variable reference, never a string. This eliminates ambiguity:
{set story.player.name = "Aiden"}
{set story.player.weapon = "sword"}
{if story.player.weapon = "sword" begin}Here "sword" is a string value and story.player.weapon is a variable — the quotes are the only thing that tells them apart, and they are never optional.
In command attributes, string values also require quotes. Bare attribute values are interpreted as numbers, booleans, or identifier references — not as strings:
{voice speaker="elena", emotion="whisper", speed=3 begin}
{input name=story.answer.guess, type="number", placeholder="Enter a number"}speed=3 is a number (no quotes), name=story.answer.guess is a variable reference (the domain-prefixed path where input is stored), and emotion="whisper" is a string value (quoted).
Arrays
["sword", "map"] · [hp=100, dex=8]The one collection type, with positional and named items in a single literal; 0-based indexing is settled.
Arrays are the universal collection type. Items are comma-separated and can be positional (indexed by position) or named (indexed by key), or both:
{set story.player.inventory = ["sword", "torch", "map"]}
{set story.stats = [strength=10, dexterity=8, wisdom=12]}
{set story.mixed = ["positional first", 12.345, shift=true]}Positional items are accessed by 0-based index (the first item is .0, the second .1, etc.), named items by key:
{story.player.inventory.0}
{story.stats.strength}
{story.mixed.0}
{story.mixed.shift}When mixing positional and named items, positional items must come before named items — consistent with function parameters. Named items can be reordered freely.
Named items are not implemented [strength=10, dexterity=8] parses each item as an equality test, so the array comes out as [false, false] and story.stats.strength reads as unset. Only positional arrays work today. Keep named values in their own paths until this lands:
{comment WRONG — yields [false, false]}
{set story.stats = [strength=10, dexterity=8]}
{comment RIGHT}
{set story.stats.strength = 10}
{set story.stats.dexterity = 8}Date, time & duration values
datetime("2026-06-15T10:30") · duration("P1DT2H")ISO 8601 constructors including * wildcards for time-of-day patterns.
Constructor types (runtime types without literal syntax):
| Constructor | Description |
|---|---|
datetime("2026-06-15T10:30:00") | ISO 8601 timestamp, supports wildcards * |
duration("P1DT2H30M") | ISO 8601 duration |
Coordinate literals
@(lat, lng) · circle(p, m) · area(p1, p2, p3) · path(...) · buffer(shape, m) · distance(a, b) · bearing(a, b) · a + b · a - bA point is a literal because a story set in a real place writes a great many of them; everything with an extent is an ordinary call on points, so the shapes compose without syntax of their own. Both arguments of @(lat, lng) are expressions, matches asks whether a point is inside an area, and + / - are union and difference. Latitude comes first, radii are in metres.
A geographic point is written @(lat, lng) — latitude first, then longitude, the order every mapping service and GPS reading uses. Both arguments are ordinary expressions, so a point can be built from variables as readily as from numbers:
{set story.home = @(48.14, 17.10)}
{set story.here = @(story.lat, story.lng)}The point is the only literal. Everything with an extent is built from points by an ordinary function, so the shapes compose the way any other value does:
| Constructor | Description |
|---|---|
path(p1, p2, …) | An ordered chain of at least two points. A path has no interior. |
area(p1, p2, p3, …) | A closed ring of at least three points. The ring closes itself. |
circle(centre, metres) | Everything within metres of a point. |
buffer(shape, metres) | Everything within metres of a path, area, circle or point. |
area1 + area2 | Union — everything in either area. |
area1 - area2 | Difference — everything in the first that is not in the second. |
Every radius is in metres. area() closes its own ring, so repeating the first point at the end changes nothing: area(p1, p2, p3) and area(p1, p2, p3, p1) are the same triangle. buffer() is what gives a path an interior — a route only becomes somewhere a reader can be once it has a width:
{set story.park = circle(@(48.14, 17.10), 500)}
{set story.forest = area(@(48.14, 17.10), @(48.15, 17.10), @(48.15, 17.11), @(48.14, 17.11))}
{set story.corridor = buffer(path(@(48.14, 17.10), @(48.15, 17.11)), 50)}
{set story.donut = circle(@(48.14, 17.10), 1000) - circle(@(48.14, 17.10), 200)}Ask whether a point is inside an area with matches, the same operator that tests a string against a pattern:
{if context.location matches story.park begin}
You feel a strange resonance. This is the place from the story!
{end if}Two points also answer questions about the space between them, which is what lets a story say how far and which way rather than only whether the reader arrived:
| Measurement | Description |
|---|---|
distance(a, b) | Great-circle metres between two points. |
bearing(a, b) | Initial heading from a to b, in degrees clockwise from true north, 0 to 360. |
{if distance(context.location, story.tower) > 400 begin}
The tower is still {round(distance(context.location, story.tower))} m off, bearing {round(bearing(context.location, story.tower))}°.
{end if}A point prints as lat, lng at six decimals — about 11 cm, finer than any GPS reading. The form is the same in every locale: a locale that renders the decimal separator as a comma would print a value nothing could read back.
Built-in names
path, area, circle, buffer, distance and bearing are core built-ins, and a built-in cannot be redefined. A {function area() begin} of your own is reported as link/redefines-builtin and the built-in keeps running — the alternative, letting the declaration win silently, would break every geographic call in the document with nothing said about it.
DateTime wildcards
Wildcards enable time-based patterns using datetime() constructor strings:
{if context.time.now matches datetime("*-12-24T*") begin}
Merry Christmas, {context.reader.name}!
{end if}
{if context.time.now matches datetime("*-*-*T22:*:*") begin}
The night deepens around you...
{end if}12. Expressions & Operators
+ - * / % = != < > and or ! matches inThe full precedence table is released; single = for equality is a settled decision, not a placeholder.
Expressions can appear anywhere inside { }. They follow standard precedence rules.
Expression atoms
An expression is built from these atomic elements:
| Atom | Example | Description |
|---|---|---|
| Literal | 42, "text", true, [1, 2, 3] | Number, string, boolean, or array |
| Variable | story.player.gold, story.quest.has_key | Domain-prefixed variable path |
| Function call | max(a, b), length(inv.items) | Call with comma-separated arguments |
| Grouped expression | (story.player.gold + bonus) * 2 | Parentheses override precedence |
Operator precedence (highest to lowest)
| Precedence | Operator | Description |
|---|---|---|
| 1 | ( ) | Grouping |
| 2 | . | Property access |
| 3 | f() | Function call |
| 4 | -, ! | Unary minus, logical NOT |
| 5 | *, /, % | Multiply, divide, modulo |
| 6 | +, - | Add, subtract, string concatenation |
| 7 | matches, !matches | Pattern match, negated |
| 8 | in, !in | Membership test, negated |
| 9 | <, <=, >, >= | Comparison |
| 10 | =, != | Equality, inequality |
| 11 | and | Logical AND |
| 12 | or | Logical OR |
| 13 | ? : | Ternary conditional |
Pattern & membership tests
matches /regex/ · "x" in arrayKeyword operators rather than symbols, each with a !-prefixed negation.
matches tests a value against a regular expression and in tests membership in an array. Both are keywords rather than symbols, and both take a ! prefix to negate:
{if story.player.name matches /^[A-Z]/ begin}
{if "sword" !in story.player.inventory begin}Ternary conditional
{gold > 0 ? "hopeful" : "dejected"}Inline conditional values at the lowest precedence, so nesting needs explicit parentheses.
The ternary operator provides inline conditional values:
{set story.mood = story.player.health < 50 ? "desperate" : "determined"}
The hero looks {story.player.gold > 0 ? "hopeful" : "dejected"}.The condition is evaluated first; if truthy, the expression before : is returned, otherwise the expression after :. Ternary has the lowest precedence — use parentheses when nesting:
{(context.time.hour >= 20 ? 2 : 1) * story.combat.base_damage}Notes:
=in expressions is equality (not assignment). Assignment uses{set}.and/oruse short-circuit evaluation.- Unary
-negates a number:-story.player.gold,-(a + b). +with a string operand performs concatenation:"Hello, " + story.player.name- Property access chains are left-to-right:
context.group.readers.0.name
String behavior
Strings are opaque values — {expression} syntax is NOT interpreted inside string literals. To build dynamic strings, use concatenation:
{set story.msg.greeting = "Hello, " + context.reader.name + "!"}The {expression} syntax works only in narrative text (outside of string literals), where it is evaluated and its result is inserted inline.
Type coercion in expressions
number(x) · string(x) · boolean(x) · integer(x)Implicit coercion inside expressions plus four explicit conversion functions; the "string + anything = string" rule is settled.
When operands have different types, Rea applies implicit coercion:
- Addition / Concatenation (
+): if either operand is a string, the result is a string (concatenation). Otherwise numeric addition - Arithmetic (
-,*,/,%): operands coerced to numbers. Non-numeric strings produceundefined - Comparison (
<,>,<=,>=): both coerced to numbers if possible, otherwise string comparison - Equality (
=,!=): no coercion — types must match, except""equalsfalse(both falsy) - Boolean context (
if,and,or,!): falsy values arefalse,0,"",undefined, empty array[]
Core rule: string + anything = string. When + encounters a string operand, the other operand is converted to its string representation and the result is concatenated.
| Expression | Result | Why |
|---|---|---|
"gold: " + 42 | "gold: 42" | String + number → concatenation |
"has key: " + true | "has key: true" | String + boolean → concatenation |
42 + 8 | 50 | Number + number → addition |
"3" + "7" | "37" | String + string → concatenation |
"3" * 2 | 6 | Arithmetic coerces to number |
"hello" * 2 | undefined | Non-numeric string → arithmetic fails |
Explicit type conversion
To convert between types explicitly, use conversion functions:
| Function | Description |
|---|---|
number(x) | Convert to number. number("42") → 42, number("abc") → undefined |
string(x) | Convert to string. string(42) → "42", string(true) → "true" |
boolean(x) | Convert to boolean. Falsy values → false, everything else → true |
integer(x) | Convert to integer (truncates). integer(3.7) → 3 |
{set story.total = number(story.reader_input) + story.player.gold}
{set story.label = "Score: " + string(story.player.score)}
{set story.has_items = boolean(length(story.player.inventory))}Examples
{story.player.gold * 2 + story.combat.bonus}
{story.player.level >= 10 and story.quest.has_key}
{story.player.name matches /^[A-Z]/}
{"sword" in story.player.inventory}
{!story.door.is_locked or story.quest.has_master_key}
{story.player.health < 50 ? "run" : "fight"}
{-story.combat.penalty + story.combat.bonus}
{context.reader.name + " the " + upper(story.player.class)}13. Control Flow
If / Else If / Else
{if cond begin} … {else} … {end if}The most used command in the language; released and stable in practice, still formally experimental with the rest of the command layer.
{if story.player.gold > 100 begin}
The merchant smiles greedily.
{else if story.player.gold > 50}
The merchant nods politely.
{else}
The merchant looks at you with pity.
{end if}A conditional written on one line may share that line with prose, before it, after it, or both: The lamp is heavy.{if story.card.lantern.weight > 2 begin} It drags at your belt.{end if} prints the first sentence always and the second only when the condition holds. The same holds for {set}, {goto} and {label}. To write one of these as text rather than run it, escape the brace (\{if ...}) or put it in an inline code span.
Switch / Case
{switch expr begin} {case "x"} … {end switch}Multi-way branching with a {default} fallback.
{switch} takes exactly one subject expression; each {case} matches exactly one literal value — there is no comma-list, multi-value match. An author wanting several values to hit the same branch uses {if}/or inside the case body, or repeats the branch.
{switch story.player.class begin}
{case "warrior"}
You draw your sword.
{case "mage"}
You raise your staff.
{case "rogue"}
You melt into the shadows.
{default}
You stand your ground.
{end switch}For Loop
{for item in list, index begin} … {end for}Iterates arrays with an optional 0-based index variable.
The loop item (and optional index) is an ordinary domain-prefixed variable, chosen by the author like any {set} target — the loop header sets it on every iteration, exactly as {set <path> = <value>} would:
{for part.item in story.player.inventory begin}
You have: {part.item}
{end for}With an index variable (defined after a comma before begin, also domain-prefixed):
{for part.item in story.player.inventory, part.index begin}
{part.index + 1}. {part.item}
{end for}The index variable starts at 0 and increments with each iteration. Neither part.item nor part.index is cleared when the loop ends — they keep their last value, like any other part. variable, until the active part changes.
While Loop
{while cond, n begin} … {end while}Condition-driven repetition with an optional iteration counter; the runtime bounds it against runaway loops.
{while story.lock.attempts > 0 begin}
You try the lock again...
{set story.lock.attempts = story.lock.attempts - 1}
{end while}With an iteration counter (defined after a comma before begin, domain-prefixed like the {for} index):
{while story.lock.attempts > 0, part.tryNumber begin}
Attempt {part.tryNumber + 1}: You try the lock again...
{set story.lock.attempts = story.lock.attempts - 1}
{end while}The counter variable starts at 0 and increments with each iteration.
The {while} counter is not implemented The parser does not split , part.tryNumber off the condition — it swallows it, so the counter is never bound and the condition is malformed. {for}'s index variable works correctly; {while}'s counter does not. Count with an ordinary {set} until it lands:
{set part.tryNumber = 0}
{while story.lock.attempts > 0 begin}
Attempt {part.tryNumber + 1}: you try the lock again...
{set part.tryNumber = part.tryNumber + 1}
{set story.lock.attempts = story.lock.attempts - 1}
{end while}Break & Continue
{break} · {continue}Specified alongside the loops but not implemented — a loop currently runs to its natural end. Authors exit early with a condition instead.
{for part.item in story.player.inventory begin}
{if part.item = "cursed_ring" begin}
{continue}
{end if}
You inspect the {part.item}.
{if part.item = "golden_key" begin}
This is the one! {break}
{end if}
{end for}State Machines
{state_machine door, initial="locked" begin}The parser accepts states, transitions and guards, and the runtime tracks the current state; persistence and sharing across readers are still being built, so it is not offered to authors yet.
Formal state machines model entities that transition between named states based on events and conditions. Useful for doors, NPCs, weather systems, or any entity with distinct behavioral modes:
{state_machine door, initial="locked" begin}
{state locked begin}
The door is locked tight.
{end state}
{state closed begin}
The door is closed but unlocked.
{end state}
{state open begin}
The doorway stands open before you.
{end state}
{end state_machine}
{on unlock machine="door", state="locked" when story.quest.has_key begin}
You turn the key. Click!
{-> closed}
{end on}
{on open machine="door", state="closed" begin}
The door swings open.
{-> open}
{end on}
{on lock machine="door", state="closed" begin}
You lock the door behind you.
{-> locked}
{end on}
{on close machine="door", state="open" begin}
You pull the door shut.
{-> closed}
{end on}A {state} block holds what the reader sees while the machine is in it. Each transition is a top-level {on <event> machine="...", state="..." begin} handler — the same flat form every other event in the language takes — carrying its guard, its content, and the {-> target} divert that moves the machine on. A handler with no machine= applies to every machine that declares the state.
State machine attributes:
| Attribute | Description |
|---|---|
initial | Starting state (required) |
persist | true to save state across sessions |
shared | true to share state between readers |
A machine's current state is readable at story.<id>.state — story state like any other, written by the machine rather than by a {set}:
{if story.door.state = "locked" begin}
You need a key.
{end if}
{trigger door.unlock}Guard conditions on transitions prevent invalid state changes:
{on unlock when story.quest.has_key and !story.alarm.active begin}
{-> closed}
{end on}14. Functions
{function greet(name) begin} … {end function}Definition, calls, default parameters and {return} all work; the four calling-context classifications are released but may gain linting rules.
Custom functions defined with {function}…{end function} — pure, template, hybrid, and side-effect classifications, calling-context behavior, parameters with default values, and which classifications a .rext file may export — now live on their own page: see Custom Functions.
15. Events
{on story_start begin} … {end on}The {on …} block and lifecycle events are released; sensor-driven events depend on features that are still in development or draft.
Events respond to platform triggers. They are defined using {on event_name begin}:
{on story_start begin}
{set story.player.gold = 100}
{set story.player.health = 100}
{end on}
{on chapter_start begin}
The next chapter of your journey begins...
{end on}
{on shake begin}
The ground trembles beneath your feet!
{end on}Built-in events
| Event | Trigger |
|---|---|
story_start | Story is opened for the first time |
story_resume | Story is reopened after being closed |
chapter_start | A new chapter begins |
chapter_end | A chapter is completed |
timer | A timer reaches zero |
shake | Device is shaken |
screenshot | Reader takes a screenshot |
idle | Reader is inactive for a period |
proximity | Another reader is nearby (cooperative) |
location_enter | Reader enters a geographic area |
location_exit | Reader leaves a geographic area |
time_match | Real-world time matches a pattern |
weather_match | Weather condition matches a pattern |
scan | Reader scans a QR code or barcode |
Parameterized events
Some events accept parameters that filter when they fire:
{on time_match datetime("*-12-25T*") begin}
Merry Christmas!
{end on}
{on weather_match "snow" begin}
Snowflakes drift past the window.
{end on}
{on shake, intensity=3 begin}
The ground trembles violently!
{end on}The parameter narrows the event trigger. Without parameters, the event fires on any match (e.g., {on scan begin} fires on any scan, {on scan "CODE-42" begin} fires only when "CODE-42" is scanned).
Save & checkpoints
{checkpoint name="before_boss"} · {restore name=…}Automatic progress saving works today. Author-declared checkpoints, {restore} and {save enabled=false} are specified but unbuilt, and the cooperative "all readers must agree" rule needs a design pass first.
The platform auto-saves reader progress after every choice. Authors can define named checkpoints for explicit save/restore points:
{checkpoint name="before_boss"}Readers can restore to any checkpoint via the platform UI. Authors can also restore checkpoints programmatically:
{restore name="before_boss"}Restore MAY carry an optional guard, evaluated at the moment of restore against current context.* and the checkpoint's own recorded metadata:
{restore name="before_boss", when=context.time.now - checkpoint.saved_at < duration("PT10M")}checkpoint.saved_at is an implicit field on every named checkpoint — the timestamp at save time. If when= evaluates false, the restore is refused (flow/restore-condition-failed, info): reading continues from the current position, distinct from restoring a checkpoint that doesn't exist at all (link/unknown-checkpoint). Named-checkpoint restore is blocked the same way {undo} is when an irretractable effect (an active {exclusive} lock, an in-progress {vote}) happened since the checkpoint — same env/undo-blocked code, not a separate one.
What a snapshot captures
A snapshot (whether auto-save or named checkpoint) captures the complete reader state:
| Category | What is saved |
|---|---|
| Variables | All {set} values under part., story., and (cooperative) shared. — context.* is never snapshotted; see below. |
| RNG stream position | The seeded random stream's position, so a feature relying on it ({vote}'s random tiebreak, dice notation) doesn't desync on restore. |
| Position | Current passage, line offset, active choice stack |
| Visit counts | How many times each anchor/heading has been visited |
| Reader attributes | Language, name, role, custom metadata |
| State machines | Current state of every {state_machine} |
| Once-block flags | Which {once} blocks have already fired |
| Cycle indices | Current position in each {cycle} |
| Label text | Current text of each {label} (after any {replace}) |
| Card inventory | Items given/taken via {give}/{take} |
| Deck state | Which storylets have been drawn, remaining pool |
| Timer state | Active timers are paused on save and resumed on restore |
| Media playback | Audio/video positions are not saved — media restarts on restore |
context.* values are live-read, never frozen into a snapshot — restoring a save re-reads current sensor/platform state rather than replaying stale values (a save that pinned yesterday's GPS position would be actively wrong on restore). part. state is snapshotted like any other domain; restoring a checkpoint recorded mid-part restores its part. values intact, and they stay intact until the reader next actually changes part — restore itself is a state replacement, not a part transition, so it does not trigger part.'s normal reset.
Scrolling up to re-read is not restoring. Scroll position is purely cosmetic — current variables are unaffected, no diagnostic, no confirmation needed. An implementation MUST NOT let a reader change story state (a new choice, a new {set}-bearing event) while scrolled to a re-read position without first either restoring to that position (an explicit, confirmed operation) or blocking the interaction.
In cooperative reading, a snapshot additionally captures:
| Category | What is saved |
|---|---|
| Shared variables | All shared.* values |
| Per-reader state | Each reader's individual state (variables, position, inventory) |
| Role assignments | Current {define role} bindings |
| Lock state | Which {lock} blocks are active and who holds them |
| Vote/race results | Completed vote/race outcomes |
Checkpoints in cooperative reading require all connected readers to agree before restoring. If a reader is disconnected, their consent is not required — the platform restores their state when they reconnect.
Manual save
Readers can manually save at any point during reading (not just at author-defined checkpoints). Manual saves capture the same data as checkpoints. Authors can disable manual save for specific sections:
{save enabled=false}
{comment Auto-save still occurs but reader cannot manually save/load}When {save enabled=false} is active, the platform UI hides the save button. Auto-save continues at choices so that progress is not lost on app crash.
Save portability across story versions
Saves are bound to a specific story version (the version metadata field). When a story is updated:
- Patch version change (e.g.,
1.0.0→1.0.1): saves are loaded normally. Missing new variables use their default values. Removed variables are silently ignored. - Minor version change (e.g.,
1.0→1.1): the platform attempts to load the save. If the reader's current position no longer exists (passage was removed/renamed), the platform falls back to the nearest valid checkpoint or the beginning of the current chapter. - Major version change (e.g.,
1.x→2.x): saves are incompatible. The platform notifies the reader and offers to start fresh.
The platform stores saves as JSON. The schema includes a spec_version field (the Rea language version) and a story_version field (the author's version), enabling the runtime to detect compatibility.