Core Concepts
Scenes and Instant Procedures
The story is made of scenes, pushdown fn marked #[scene], and instant procedures, the
other pushdown fn. Scenes call each other and return; within a scene, control flow is Rust’s.
There is no goto.
Calls
FestivalHub(); // call a scenelet answer = Confession(); // a scene that returns a valuevisit(Map::A); // call an instant procedureAll calls use ordinary call syntax. A call pushes a frame holding the return point and the callee’s locals, then runs the callee. When the callee returns, its frame is popped and the caller continues (see The Story Machine).
Scenes and instant procedures
#[scene]pushdown fn StallA() { if VISITED_A { s!(t!["The stall is already packing up."]); return; } visit(Map::A); s!(who = "Hanako", t!["Welcome to stall A!"]);}
pushdown fn visit(stall: Map) { match stall { Map::A => VISITED_A = true, Map::B => VISITED_B = true, Map::C => VISITED_C = true, Map::Leave => {} }}#[scene] marks a pushdown fn as a scene, the unit the story is checked in:
- Only a scene may call
push_step, that is, runs!andchoice!, because a step is not pure: it suspends the story. Elsewhere this isE3002. - Only a scene may call another scene (
E3003). Scenes may be recursive. - Only a scene may draw a random value (
E3024). - The compiler analyzes each scene once, as one component of the whole story, so a scene called from many places is not re-analyzed per caller.
A pushdown fn without #[scene] is an instant procedure:
- It holds finite types, may read and write story variables, and may call other instant
procedures and
const fn. - It never runs a step, never calls a scene and never draws a random value.
- It is not recursive: the calls among instant procedures form no cycle (
E3005). Its only loops areforover a constant range or an array;loopandwhileareE3004.
So an instant procedure always finishes without input, and the compiler treats a call of one as a single transition inside the calling scene.
Scene (#[scene] pushdown fn) | Instant procedure (pushdown fn) | |
|---|---|---|
Run steps (s!, choice!, push_step) | yes | no (E3002) |
| Call scenes | yes | no (E3003) |
Call instant procedures and const fn | yes | yes |
Draw randomness (rng_choose) | yes | no (E3024) |
| Read and write story variables, write meta | yes | yes |
| Recursion | yes, through a step | no (E3005) |
loop, while | yes, every lap through a step | no (E3004) |
for over a constant range or an array | yes | yes |
#[scene] on a function that is not a pushdown fn is E3018. Scenes are named in PascalCase,
which tells them apart from instant procedures at the call site; the snake_case lint does not
apply to them.
Control flow
The story uses Rust’s control flow: if, if let, match, loop, while, for, break,
continue and return, with loop labels for leaving nested loops. The syntax is in
Control Flow and Loops.
#[scene]pushdown fn Week() { 'week: for day in 0..7 { match Day(day) { DayEnd::Normal => continue, DayEnd::Hospital => break 'week, } }}In a pushdown fn, for loops only over a range whose bounds are constant expressions, or over
an array (E3006), and the loop variable has the matching Int type: day in
for day in 0..7 is an Int<0, 6>.
There is no goto and no labelled position to jump to. A hub is a loop, a scene that ends
early returns, and a branch is an if or a match. Two consequences:
- Control can never jump into another function or into the middle of a block. Call/return nesting, which the analysis relies on, holds by construction.
- Irreducible control flow, such as a jump into the middle of a loop, cannot be written. No story needs it.
Conditions are ordinary boolean expressions, and several variables are matched as a tuple:
match (VISITED_A, VISITED_B, VISITED_C) { (true, true, true) => FestivalEnd(), _ => {}}In summary:
| Need | Write |
|---|---|
| Call a scene | Name(x) |
| Leave a scene | return; / return value; |
| Branch | if, match (exhaustive, with ranges and tuples) |
| Hub or repeated choice | loop { match choice!{…} { … } } with break |
| Bounded repetition | for i in 0..n |
| Change a counter | x += 1; |
| Write meta | META_FIELD.write(v); (Entry Scenes and Endings) |
| Draw at random (scenes only) | rng_choose::<T>() |
| End the story | return from the entry scene (Entry Scenes and Endings) |
Returning a value
enum Answer { Accepted, Rejected }
#[scene]pushdown fn Confession() -> Answer { match choice!{ id = "confess", t!["Tell her the truth"] => Confess::Yes, t!["Say nothing"] => Confess::No, } { Confess::Yes => Answer::Accepted, Confess::No => Answer::Rejected, }}
#[scene]pushdown fn Day3() { match Confession() { Answer::Accepted => AzusaRoute(), Answer::Rejected => s!(t!["The wind stopped."]), }}A scene that needs to report how it ended declares a return type of a finite type and returns the outcome. Rust’s typing makes every path produce a value: a path that can reach the end of the body without one is a type error. A loop that never ends needs no value.
Termination
Everything between two steps runs instantly, so it must always finish. The compiler rejects:
- a
looporwhilein a scene whose body has a path around it that does not pass through a step (E5001), and - a cycle of calls that does not pass through a step, such as a scene that calls itself before any
step (
E5002).
for over a constant range or array is bounded and needs no step. Every branch counts as
possible: the check does not try to prove that a stepless path is never taken.
The check looks through calls. A call of a scene that may return without passing a step counts as stepless, and so does every call of an instant procedure. So a loop whose only step is inside a called scene is accepted only if that scene steps on every path.
A story that runs in a cycle, such as a day loop, is a loop in one driver scene that calls each
day in turn. Scenes that call each other in a ring and never return grow the call stack on every
lap.
Closures in the story
A pushdown fn may take a closure parameter and call it:
pushdown fn twice(f: pushdown Fn()) { f(); f();}
twice(|| AFFECTION_MIO += 1);Closures in the story are second-class: they can be passed and called, but
not stored, returned or captured (E3015). A closure is an instant procedure: it cannot run a
step or call a scene.
Randomness
A scene draws a random value with rng_choose::<T>(), where T: RandChoose: a fieldless enum or
an Int<lo, hi>. Each value has equal probability. Only a scene may draw; a call from an instant
procedure is E3024, and a T that is not RandChoose is E2026.
enum Coin { Heads, Tails }
match rng_choose::<Coin>() { Coin::Heads => s!(t!["Heads."]), Coin::Tails => s!(t!["Tails."]),}The generator is xoroshiro128++. Its state is part of the narrative state and its seed is in the
save, so replay reproduces every draw (see Saves, Replay and Determinism). The
compiler treats each draw as a free choice over every value of T, like a choice of the player.
Last updated Oct 29, 2026