Kagari
Type to search documentation.

Guides

Staging Recipes

Stability: Stable — recipes built only from current std items.

Composing renders

A render is a const fn(Screen) -> Screen. Larger effects are composed from two kinds of function:

  • Helpers take &mut Screen and return nothing, like the methods of Character. They can be called from any render and from each other.
  • Renders take the screen by value, call helpers and projections, and return it. A step uses one with s!(render = name, …).

Inside a render, the timeline cursor decides when each change starts: statements between two cursor moves start together; barrier(), wait(d) and at_mark(m) move the cursor. Every change states its animation, and a spawned entity starts at opacity 0.0, so every appearance fades or tweens its opacity in.

An entity that should exist only during one effect (a curtain, a card) is despawned in the same render that fades it out: it leaves the logical screen at once and is still drawn until its fade ends.

Shared declarations

Every recipe below assumes these declarations. Studio entities sit above the characters:

kix
use std::narrative::Mark;
use std::narrative::text::text_style;
use std::screen::BOTTOM_CENTER;
use std::screen::shader::{ColorShader, filter};
use std::stage::{hide_all, side_face};
const HANAKO: Character = character(EntityId(1)).offset(offset(0, 40)).anchor(BOTTOM_CENTER);
mod HANAKO {
pub const NORMAL: ImageAsset = asset("hanako_normal.png");
pub const ANGRY: ImageAsset = asset("hanako_angry.png");
pub const SURPRISED: ImageAsset = asset("hanako_surprised.png");
}
const CENTER: Point = point(640, 720);
const RIGHT: Point = point(960, 720);
const CURTAIN: EntityId = EntityId(100);
const TINT: EntityId = EntityId(101);
const CARD: EntityId = EntityId(102);
const ITEM: EntityId = EntityId(103);
const STILL: EntityId = EntityId(104);
mod BG {
pub const CLASSROOM: ImageAsset = asset("bg_classroom.png");
pub const NIGHT: ImageAsset = asset("bg_night.png");
}
mod CG {
pub const FIREWORKS: ImageAsset = asset("cg_fireworks.png");
}
const BLINDS_RULE: ImageAsset = asset("rule_blinds.png");
const KEY_ITEM: ImageAsset = asset("item_key.png");
/// The arguments of the flashback filter.
#[shader_arg]
struct Memory {
strength: Decimal,
}
const MEMORY: ColorShader<Memory> = asset("memory.wgsl");
const THEME: AudioAsset = asset("bgm_theme.ogg");
const KNOCK: AudioAsset = asset("se_knock.ogg");

The slot constant CENTER shadows the std anchor std::screen::CENTER, so recipes that need the anchor write it by path.

Opening a scene

The background comes in through a rule while the music starts; after the barrier, Hanako appears with her side face:

kix
const fn opening(mut screen: Screen) -> Screen {
screen.background().change(BG::CLASSROOM).apply_animation(rule(BLINDS_RULE, ms(1000)));
screen.music().change(looped(THEME).region(secs(16), Some(secs(80)))).apply_animation(crossfade(ms(1000)));
screen.barrier();
HANAKO.show(&mut screen, HANAKO::NORMAL, CENTER, tween(ms(300)));
side_face(&mut screen, Some(HANAKO::NORMAL), fade(ms(300)));
screen
}

Dip to black

A Front entity with only a fill covers the screen. It animates TRANSPARENT → BLACK, the background switches while the screen is black, and the fill animates back. The curtain is despawned at once, so it is not part of the logical screen; it is still drawn until its fade ends.

kix
/// Fades the screen to black, swaps the background while it is hidden, and fades back.
const fn dip_to_black(screen: &mut Screen, next: ImageAsset) {
screen.entity(CURTAIN).set_plane(Plane::Front);
screen.entity(CURTAIN).opacity().change(1.0).apply_animation(instant());
screen.entity(CURTAIN).fill().change(BLACK).apply_animation(tween(ms(1000)));
screen.barrier();
screen.background().change(next).apply_animation(cut());
screen.entity(CURTAIN).fill().change(TRANSPARENT).apply_animation(tween(ms(1000)));
screen.despawn(CURTAIN);
}
const fn night_falls(mut screen: Screen) -> Screen {
hide_all(&mut screen, tween(ms(500)));
side_face(&mut screen, None, fade(ms(300)));
dip_to_black(&mut screen, BG::NIGHT);
screen
}

For a plain background change through a colour, through(BLACK, d, hold) on the background swap does the same without an entity.

Flashback

A fill over the stage mixed with the color mix shader, plus a shader screen filter with typed arguments. The tint stays on the logical screen until the end render fades and despawns it.

kix
/// Tints the stage with a colour mix and a shader filter, for a memory.
const fn flashback(mut screen: Screen) -> Screen {
screen.entity(TINT).set_plane(Plane::Front);
screen.entity(TINT).mix().change(Some(std::shaders::mix::color())).apply_animation(instant());
screen.entity(TINT).fill().change(hex(0x382000)).apply_animation(instant());
screen.entity(TINT).opacity().change(0.6).apply_animation(tween(ms(800)));
screen.filter().change(Some(filter(MEMORY, Memory { strength: 0.8 }))).apply_animation(tween(ms(800)));
screen
}
/// Ends the flashback.
const fn flashback_end(mut screen: Screen) -> Screen {
screen.entity(TINT).opacity().change(0.0).apply_animation(tween(ms(800)));
screen.despawn(TINT);
screen.filter().change(None).apply_animation(tween(ms(800)));
screen
}

native_v0, today the only runtime profile, rejects .wgsl shader assets at build, so the MEMORY filter does not build yet (see Runtime Profiles). Drop MEMORY and the filter() lines; the tint alone works.

Still (CG)

The text window hides first; after the barrier, the CG comes in through a rule. The next step brings the window back:

kix
/// Hides the text window, then reveals a CG through a rule.
const fn still(mut screen: Screen) -> Screen {
screen.hud().text_window().change(Visibility::Hidden).apply_animation(tween(ms(300)));
screen.barrier();
screen.entity(STILL).set_plane(Plane::Front);
screen.entity(STILL).opacity().change(1.0).apply_animation(instant());
screen.entity(STILL).image().change(CG::FIREWORKS).apply_animation(rule(BLINDS_RULE, ms(1000)));
screen
}
/// Brings the text window back over the CG.
const fn still_text(mut screen: Screen) -> Screen {
screen.hud().text_window().change(Visibility::Shown).apply_animation(tween(ms(300)));
screen
}
kix
s!(render = still);
s!(render = still_text, t!["The fireworks lit the sky."]);

The CG counts as seen for the gallery once it is on a step’s logical screen (see std::system).

Chapter card

A label entity over the text window fades in, waits, fades out and is despawned. The despawn is immediate on the logical screen, so the next step does not inherit the card, while the fade-out is still drawn:

kix
/// Shows a chapter title over the text window, then removes it once it has faded out.
const fn chapter_card(mut screen: Screen) -> Screen {
screen.entity(CARD).set_plane(Plane::OverWindow);
screen.entity(CARD).set_anchor(std::screen::CENTER);
screen.entity(CARD).position().change(point(640, 360)).apply_animation(instant());
screen.entity(CARD).label().set_style(text_style(48, WHITE));
screen.entity(CARD).label().change(t!["Chapter 1"]).apply_animation(cut());
screen.entity(CARD).opacity().change(1.0).apply_animation(tween(ms(500)));
screen.wait(ms(2500));
screen.entity(CARD).opacity().change(0.0).apply_animation(tween(ms(500)));
screen.despawn(CARD);
screen
}

Item popup

The item spawns small and grows with an overshooting easing while it fades in:

kix
/// Pops an item up in the middle of the screen.
const fn item_popup(mut screen: Screen) -> Screen {
screen.entity(ITEM).set_plane(Plane::Front);
screen.entity(ITEM).set_anchor(std::screen::CENTER);
screen.entity(ITEM).position().change(point(640, 300)).apply_animation(instant());
screen.entity(ITEM).image().change(KEY_ITEM).apply_animation(cut());
screen.entity(ITEM).scale().change(0.8).apply_animation(instant());
screen.entity(ITEM).scale().change(1.0).apply_animation(tween(ms(300)).ease(Easing::OutBack));
screen.entity(ITEM).opacity().change(1.0).apply_animation(tween(ms(300)));
screen
}
/// Removes the item popup.
const fn item_popup_end(mut screen: Screen) -> Screen {
screen.entity(ITEM).opacity().change(0.0).apply_animation(tween(ms(300)));
screen.despawn(ITEM);
screen
}

The two scale changes start at the same cursor: the first sets 0.8 instantly, and the second interpolates from 0.8 to 1.0.

Mid-line cue

The text names a point with mark(1); the render anchors the cursor there:

kix
/// Changes Hanako's face when the text reaches mark 1.
const fn surprised_at_mark(mut screen: Screen) -> Screen {
screen.at_mark(Mark(1));
HANAKO.face(&mut screen, HANAKO::SURPRISED, fade(ms(300)));
screen
}
kix
s!(who = "Hanako", render = surprised_at_mark, t!["Wait" ![""](pause(ms(500)).and(mark(1))) "—what?"]);

The reveal stops for 500 ms after “Wait”, and the face changes as “—what?” begins. A click shows the whole line and completes the face change at once.

Sound bursts and motion

One-shots with delays make a burst; a character motion and a camera quake add weight. Neither motion changes the logical screen:

kix
const fn knocking(mut screen: Screen) -> Screen {
screen.play(sound(KNOCK));
screen.play(sound(KNOCK).delay(ms(250)));
screen.play(sound(KNOCK).volume(0.6).delay(ms(500)));
HANAKO.face(&mut screen, HANAKO::ANGRY, fade(ms(300)));
HANAKO.act(&mut screen, shake(10, ms(600), 3));
screen
}
const fn earthquake(mut screen: Screen) -> Screen {
screen.camera().apply_animation(quake(8, 4, ms(500)));
HANAKO.move_to(&mut screen, RIGHT, tween(ms(1000)));
screen
}

Disabled arm

A display-only arm in the choice the step shows. It cannot be selected and no analysis sees it; story code decides when to use this render (see std::narrative):

kix
const fn locked_arm(mut screen: Screen) -> Screen {
screen.choice_menu().disabled_arm(1, t!["Locked"]);
screen
}

Last updated Oct 29, 2026