Core Concepts
Presentation
The story says when the screen changes; render functions say how. This chapter gives the
model. The full screen API (geometry, planes, entities, properties, layers, animation, audio) is
in the STD Reference, std::screen.
Render functions
pub type Render = const fn(Screen) -> Screen; // std::screen, in the preludeA render is a const fn from the screen before a step to the screen after it, including the
animations that lead from one to the other. A step takes it as its render attribute:
s!(who = "Azusa", render = azusa_smiles, t!["Did you forget again?"]);
const fn azusa_smiles(mut screen: Screen) -> Screen { screen.entity(HANAKO).apply_animation(jump(20, ms(300))); screen.entity(AZUSA).image().change(AZUSA::CLOSE_EYE_SMILE).apply_animation(cut()); screen}When the story reaches the step, the engine first resets the step-only state of the screen (the
timeline, scheduled animations, pending one-shot sounds, the choice menu), then runs the render on
the current screen, shows the result, and waits. A step without render gets the same reset and
leaves the logical screen unchanged.
Inline renders
A closure that captures nothing coerces to a const fn pointer, so a short render can be written
in place:
s!( render = |mut screen| { screen.background().change(BG::ROOF).apply_animation(fade(ms(500))); screen }, t!["The wind on the roof is cold."]);The closure may use constants and call const fn, but it captures nothing: a closure that uses a
local or a story variable is not a Render.
Composing renders
A step has one render. Several renders are combined by an ordinary const fn:
const fn roof_scene(screen: Screen) -> Screen { azusa_smiles(roof_background(screen))}Walkthrough
screen.entity(AZUSA).image().change(AZUSA::CLOSE_EYE_SMILE).apply_animation(cut());screen.entity(AZUSA)selects the entity whose id isAZUSA, sayEntityId(217), and spawns it if the screen has none. It is a reference into the screen: changing it changes the screen..image()is the entity’s mainImageLayer..change(AZUSA::CLOSE_EYE_SMILE)asks to swap that layer to the imageAZUSA::CLOSE_EYE_SMILE. The result is a&mut Swap, a pending change: a reference into the layer.- Because this swaps a layer, it needs a
Transition. Without one the change does not happen. .apply_animation(cut())switches the image at once, and the change takes effect.
References may be bound to locals, so a render that changes the same entity several times can name it once:
let azusa = screen.entity(AZUSA);azusa.image().change(AZUSA::CLOSE_EYE_SMILE).apply_animation(cut());azusa.opacity().change(1.0).apply_animation(tween(ms(300)));The screen is an ECS world
Screen follows the entity–component–system pattern:
| Part | Defined in | Role |
|---|---|---|
| Entities | render functions | Spawned, changed and despawned by renders; identified by an EntityId constant |
| Components | std::screen, a fixed set | Data attached to entities: plane, z, position, anchor, scale, rotation, opacity, colour filter, mix, mask, local motion, fill, image parts, label, movie, flip, gate |
| Resources | std::screen, a fixed set | Data of the screen itself: the Hud, the choice menu, the camera, the screen filter, the audio channels |
| Systems | the engine | Process entities at runtime, for example advancing animations frame by frame |
EntityId(0) is reserved for BACKGROUND, the background entity; studio ids start at 1.
Every component type has equality, so two screens can always be compared. Systems are not written in Kagari and are not part of the logical screen: the screen a step shows is the set of entities and their components after its render, not the frames the engine draws while an animation plays.
The component set is fixed: a studio adds no components. It extends the library with const fn
helpers and constant data in its own modules, such as character slots, face images and recipes
like a dip to black. std::stage is the standard example of such a layer: its characters are
plain const fn over std::screen. The compiler type-checks studio helpers like any other code.
Changes need animations
A change of anything visible produces a pending change, and only apply_animation turns it into
an actual change on the screen. Every visible change therefore states how it is animated, even if
the answer is “instantly”.
Different targets take different animation types, so the type checker catches mismatches such as
passing a Transition to a position change:
| Target | Changed with | Animation type |
|---|---|---|
A property Prop<T>: position, scale, rotation, opacity, filter, mix, mask and local motion (shader effects), fill; Hud visibilities; camera offset, zoom and rotation; channel volume | .change(v), giving a Change<T> | Tween: duration, easing, delay |
A layer: ImageLayer (including the background and the side image), LabelLayer, MovieLayer | .change(x), .paint(p) or .clear(), giving a Swap | Transition: cut, crossfade, rule, masked, wipe, blinds, through a colour |
| An entity or the camera as a whole | .apply_animation(m) directly | Motion: a relative path that returns to where it started |
| An entity’s pose along a shader path | .travel(to, motion) directly | TransitionalMotion: duration, easing, delay |
| A music or ambience channel | .change(track) or .stop(), giving a ChannelChange | AudioFade |
Filters, blend modes, masks, generated content such as gradients or weather, and shader-driven
motion are shaders: WGSL files that run on the GPU, held by the screen as effect values that
pair a shader with its arguments. The screen and each entity have a filter(); an entity also
has a mix() (how it composites over what is below it), a mask() and a local_motion(), and an
image layer can show a generated paint. Tweening an effect to the same shader with other
arguments interpolates the arguments. The standard library ships ready-made shaders in
std::shaders; see std::screen::shader.
Music carries over between steps: a step whose render leaves the music channel on the track that
is already playing does not restart it. A render restarts the track from its start with
screen.replay_bgm().
const fn mio_enters(mut screen: Screen) -> Screen { screen.entity(MIO).position().change(point(640, 80)).apply_animation(instant()); screen.entity(MIO).image().change(MIO::NEUTRAL).apply_animation(cut()); screen.entity(MIO).opacity().change(1.0).apply_animation(tween(ms(300)).ease(Easing::OutQuad)); screen}A spawned entity starts invisible, with opacity 0.0, so every appearance states its animation.
Change<T>, Swap and ChannelChange are #[must_use], so a statement that ends with a change
still pending, such as screen.background().change(BG::ROOF);, is a warning. Only layout
settings that cannot be interpolated are set directly: set_plane, set_z, set_anchor,
set_flip, set_fill_size and gate.
Animations never change the logical screen
The logical screen of a step is the persistent state after its render:
- the entities with their components;
- the Hud, the camera and the screen filter;
- the audio channels.
Saves, screen equality and the consistency check use only the logical screen, and animations never change it:
- A
Tweenor aTransitionalways runs from the old value to the value set bychange. The logical value is the new one from the momentchangeruns. - A
Motionis a relative path that must end where it started. A motion that does not is a compile error. - The step’s timeline, its scheduled animations, one-shot sounds and the choice menu belong to the step only and are not part of the logical screen. The engine resets them when the next step is entered.
So a click, which completes the step’s animations, skip mode, or the player turning animations off can never change the logical screen.
Renders are blind to the story
A render is a const fn, so it is:
- Deterministic. The same input screen always gives the same output screen. Loading a save replays the story, and replay must reproduce the screen exactly (see Saves, Replay and Determinism).
- Total where it runs. The compiler evaluates every render on every screen that can reach its step (see Consistency), within the evaluation budget. A render that does not finish is a compile error, so the engine always reaches the step.
- Blind to the story. Its only input is the screen. A
const fncannot read story variables, and arenderattribute is a function pointer, so it cannot capture any either.
The story, in turn, never reads the screen: Screen is not a finite type,
so no pushdown fn can hold one. Story state determines presentation, never the other way around.
Presentation that depends on the story is chosen by the story:
match AFFECTION_MIO { 3..=5 => s!(who = "Mio", render = mio_smiles, t!["You came."]), _ => s!(who = "Mio", render = mio_neutral, t!["Oh. It's you."]),}A render pointer is a finite value, so it may also be chosen by an expression,
render = if MET_MIO { mio_smiles } else { mio_neutral }, and stored in a story variable; the
compiler sees the choice as part of the story state either way.
Keyframes
A render whose output does not depend on its input screen is a keyframe: it declares the complete screen. The compiler detects keyframes itself; no annotation is required. Any other render is a delta.
Screen::empty() returns a screen with only the background entity, BACKGROUND, opaque and with
no image: the Hud shows the text window and the quick menu, the camera is neutral, the screen
filter is None, and every audio channel is silent. A keyframe can therefore be written
explicitly:
const fn roof_evening(_screen: Screen) -> Screen { let mut screen = Screen::empty(); screen.background().change(BG::ROOF_EVENING).apply_animation(fade(ms(500))); screen}Keyframes bound how far back renders must be replayed to rebuild the screen at a step: from the last keyframe on the path, instead of from the start of the story.
Keyframes are recommended at choice points and chapter starts. The keyframe-placement check is
opt-in, through keyframe-check = true in the [build] table of
kagari.toml: when it is on, every choice! and the first step of each
entry scene need a keyframe render, and a step without one gets the warning W5011. A step with no
render is not a keyframe. Interrupt scenes, such as a phone call that can happen anywhere, should
not start with a keyframe: they are meant to overlay whatever screen they interrupt.
Consistency
A render transforms the previous screen, so the screen at a step depends on how the step was reached:
match choice!{ id = "where", t!["Go to the roof"] => Where::Roof, t!["Stay in the classroom"] => Where::Classroom,} { Where::Roof => s!(render = roof, t!["The wind is cold."]), Where::Classroom => s!(render = classroom, t!["It's quiet."]),}s!(who = "Azusa", t!["So, about tomorrow…"]);The last step shows the roof on one path and the classroom on the other. If that is intended, it is fine. If it is a leftover from a branch, it is exactly the kind of bug that is invisible to everyone but the author.
The compiler computes, for each step, the screens that can reach it, and reports a step whose
shown screen (the logical screen after its render) is not determined by the story state, together
with the renders that last changed the screen on the differing paths. This is the warning W5004.
The story state at a step is everything the story itself could tell apart: the step, the values of the story variables, the locals of every frame on the call stack that are still used later, the entry arguments, and the call sites on the stack. Two arrivals at a step that agree on all of it must show the same screen. So an interrupt scene, such as a phone call that can happen anywhere, may show a different screen at each of its call sites, but not two screens for one call site. A merge is reported once, at the first step after it; the steps after that one differ for the same reason.
The author fixes a report in one of two ways:
- Insert a keyframe at the first inconsistent step, the one reported, if the difference is a leftover. This is the fix the warning suggests.
- Promote the dependency into a story variable, if it is intended: set a variable in each branch and choose the render of the merged step by it. The compiler then sees the dependency as story state.
Unbounded screens
A render may read a component from its input screen and compute a new value from it, for example moving an entity 10 units to the right of where it is. Inside a loop, such as a hub the player returns to, that can produce a different screen on every pass, so the set of screens at a step has no bound and the consistency check could not finish.
Renders are Turing-complete const fn, so whether that set is finite cannot be decided in
general. Tentative: the compiler computes the sets by evaluating each render on the screens that
reach its step, until nothing changes, and stops when a step’s set grows past a fixed cap,
currently 64 screens. The render that produced the new screen is reported as the error E5005.
The compiler then treats that render as if it left the screen unchanged and computes the sets
again, so that the rest of the program is still checked.
The rule is sound: when the program is accepted, the computed sets are exact. It may reject a
render that is bounded only above the cap. Write renders that set absolute values (a keyframe, or
change(point(640, 80)) rather than “10 units further”) inside loops.
Settings in renders
A render sometimes needs a setting, for example to draw a decorative effect only when the player enabled it. It cannot read the setting’s value: the value is only known at runtime, the screen at a step would then depend on the settings, and the analyses above would no longer hold for every value of the settings.
A render uses a deferred reference instead. PETALS_ON in static PETALS_ON: Setting<bool>
is a handle to a setting, not its value (see Settings and meta).
In a const fn the handle has no way to read the value: Setting::get is a fn, and there is no
match or arithmetic on a handle. A render can only pass it to Entity::gate, and the engine reads
the value at draw time: the entity is drawn only while the setting is true.
static PETALS_ON: Setting<bool> = setting(true);const PETALS: EntityId = EntityId(40);const PETALS_IMAGE: ImageAsset = asset("petals.png");
const fn petals(mut screen: Screen) -> Screen { screen.entity(PETALS).set_plane(Plane::Front); screen.entity(PETALS).gate(Some(PETALS_ON)); screen.entity(PETALS).image().change(PETALS_IMAGE).apply_animation(cut()); screen.entity(PETALS).opacity().change(1.0).apply_animation(tween(ms(500))); screen}The handle is part of the logical screen. Equality on handles compares which setting they refer
to, so a render using one is still a function of its input screen, and screens are still
comparable. gate(None) removes the gate.
The Hud
A story sometimes wants to hide the text window, for example during a CG. That is a request from the story to the system. It goes through the screen, keeping the flow one-way:
pub enum Visibility { Shown, Hidden }
impl Hud { #[builtin] pub const fn text_window(&mut self) -> &mut Prop<Visibility>; #[builtin] pub const fn quick_menu(&mut self) -> &mut Prop<Visibility>; #[builtin] pub const fn side_image(&mut self) -> &mut ImageLayer;}Hud is a resource of the screen, reached with screen.hud(). A render changes it like any other
property or layer, and the game view of the system reads it through the
Narrative handle. The story never reads the Hud; the Hud
reads the story’s output, and as part of the logical screen it is covered by the consistency
check.
s!(render = |mut screen| { screen.hud().text_window().change(Visibility::Hidden).apply_animation(instant()); screen });A Prop<Visibility> interpolates as a fade, so tween(ms(300)) instead of instant() fades the
window out. The side image is the speaker’s face shown next to the text, swapped with a
Transition like any layer.
The standard screen API
The STD Reference defines the screen API:
std::screen: geometry and colours, planes and drawing order, entities with their spawn defaults and despawning, properties and layers, the Hud, the choice menu, the camera, the shader effect slots, and the step’s timeline.std::screen::shaderandstd::shaders: shader roles, effect values and the std shader library.std::screen::anim: easings, tweens, transitions and motions with their presets.std::screen::audio: channels, loops and one-shot sounds.std::stage: the character layer built on top.
#[builtin] functions have no body: the engine implements them, as the interface to the screen.
Everything else is ordinary const fn code, evaluated by the compiler like any studio function.
Last updated Oct 29, 2026