A step is a point where the story stops and waits for the player. There are two kinds of
step: an ordinary step, written s!(…), and a choice, written choice!{…}. Their text is
written t![…]. These three are forms: fixed syntax, allowed only in .kix files, that
desugars to ordinary struct expressions and calls. The exact syntax of forms (delimiters,
attributes, elements) is in Forms.
Only a scene may run a step (E3002).
Steps are calls of push_step
There is one primitive that suspends the story, in std::narrative:
#[builtin] pub trait Step { type Output; }
#[builtin]pub pushdown fn push_step<S: Step>(s: S) -> S::Output;push_step shows the step, waits for input, and returns what the input carried. It is not pure:
it suspends the story. Two standard types implement Step:
| Type | Written as | Output |
|---|---|---|
Say | s!(…) | (): NextStep carries no information |
Choice<A, N> | choice!{…} | A: the arm the player selected |
So
s!(who = "Azusa", voice = AZUSA_0072, render = hanako_jumps, t!["ぬっ"]);means
push_step(Say { who: Some("Azusa"), voice: Some(AZUSA_0072), render: Some(hanako_jumps), text: Some(t!["ぬっ"]), history: None,});The struct form is ordinary code and may be written by hand; it is how a .kis file writes a
step. The full declarations are in the STD Reference,
std::narrative.
s!: ordinary step
s!(t!["It was the last day of summer."]);s!(who = "Azusa", render = azusa_smiles, t!["Did you forget again?"]);s!(render = cg_fireworks);s! shows one message and waits for NextStep. Its attributes are the fields of Say:
| Attribute | Type | Meaning |
|---|---|---|
who | Text | The speaker name as display text. Without who, the message is narration |
voice | VoiceAsset | The voice line played with the message |
render | Render | How the screen changes when the step is entered; see Presentation |
history | Text | The text the backlog records instead of the message text |
All attributes are optional; an absent attribute is None. The element is at most one t![…],
the message text. An s! without text shows no message and still waits for input, for example
after a render that shows a CG. A step without render leaves the screen unchanged.
A step has one speaker. A conversation is a sequence of steps.
By default the backlog records the line with its interpolated values as they were when it was
shown. history = t![…] replaces the recorded text:
s!(who = "Azusa", history = t!["(Azusa names the date)"], t!["Today is day " {DAY} "."]);NextStep carries no information, so save traces do not record it; only choices are recorded.
Text and text effects
t![…] builds a value of type Text from juxtaposed items: plain strings, ruby
(["数多"]("あまた")), text with a text effect ())) and
interpolated values ({expr}):
t "の" )) "花。"]A string literal is already a Text, so who = "Mio" needs no t!. A text effect is a constant
evaluated at compile time, so a message stays a constant of its step. Effects include styles
(colour, bold, emphasis, …), reveal speed, and cues inside a step: a timed pause, a click wait
(a click that continues the text inside the same step and is not recorded in the save trace), and
marks that a render can schedule animations at. In the story, {expr} of a finite runtime value
is tabulated like any other const fn call.
The item syntax and the rules are in Forms; every effect is listed in the
STD Reference, std::narrative::text.
choice!: choice step
enum Confess { Yes, No }
let answer = choice!{ id = "confess", t!["Tell her the truth"] => Confess::Yes, t!["Say nothing"] => Confess::No,};
match answer { Confess::Yes => AzusaConfession(), Confess::No => s!(t!["I kept quiet."]),}choice! is a single step. The engine shows one option per offered arm and waits for the player
to select one. The choice then evaluates to that arm’s variant, and ordinary code branches on it.
Arms are data, not blocks of code: what happens after the selection is written after the choice.
| Attribute | Type | Required | Meaning |
|---|---|---|---|
id | string literal | yes | Stable identifier, unique within the function |
render | Render | no | Runs when the choice is entered, as for s! |
Each element is an arm, t![…] => Variant, optionally with a guard,
t![…] if condition => Variant:
- The text is the option shown to the player.
- All arms of one choice use variants of the same enum, declared by the author, and each
variant at most once. The variants must be fieldless (
E3020). The choice has that enum type, somatchon it is checked for exhaustiveness like any othermatch. A variant without an arm still has to be handled, or covered with_.
choice!{…} is push_step(Choice { … }): each arm becomes a ChoiceArm { text, shown, value }
whose shown is its guard, or true without one.
Guards
t!["Leave"] if VISITED_A && VISITED_B && VISITED_C => Map::Leave,An arm with a guard is not shown unless its condition is true when the choice is entered. The
compiler reports a choice! that can be reached in a state where no arm is shown (E5003), since
the player would be stuck.
Disabled arms
An option shown greyed out, such as a destination that is still locked, is not an arm of
choice!: it has no value and cannot be selected. It is presentation, added by the choice’s
render to the screen’s ChoiceMenu:
match choice!{ id = "map", render = if VISITED_A { show_map } else { show_map_locked }, t!["Stall A"] => Map::A, t!["Stall B"] if VISITED_A => Map::B,} { … }
const fn show_map_locked(mut screen: Screen) -> Screen { screen.choice_menu().disabled_arm(1, t!["Stall B"]); screen}disabled_arm(at, text) inserts a display-only option at position at of the shown list,
clamped to its length. The ChoiceMenu belongs to the step only: it is emptied when the next
step is entered. Because a disabled arm is not part of choice!, no analysis sees it; the guard
alone decides what the player can select. See the STD Reference,
std::screen.
Stable choice IDs
A save records each selection as the function path, the choice id and the variant name, for
example story::Prologue/confess/Confess::Yes. Replay supplies the call context, so a choice id
only has to be unique within its function, even when the function is called from many places or
runs in a loop. The id must be a string literal, and an id used twice in one function is
E3019.
Because the ID does not depend on line numbers or arm order, saves survive reordering and
rewording of the script. Renaming the function, the choice id or the variant breaks the saves
that pass through it: loading such a save reports a divergence at that choice. See
Saves, Replay and Determinism.
Last updated Oct 29, 2026