Skip to content

Instantly share code, notes, and snippets.

@randrews
Created June 10, 2026 06:12
Show Gist options
  • Select an option

  • Save randrews/7b26a7d0582c34966d4c5f1da0a624b2 to your computer and use it in GitHub Desktop.

Select an option

Save randrews/7b26a7d0582c34966d4c5f1da0a624b2 to your computer and use it in GitHub Desktop.

Plan: Scroll Overlay Feature

Context

Scripts currently have two ways to give text to the player: log() (status messages appended to the log panel) and say() (a short speech bubble above the object, auto-expires after 3 s). Neither is suitable for longer messages or interactive menus. The new scroll(lines[]) action opens a full-screen overlay with scrollable text and optional menu choices. Choosing an option sends a named message back to the object that opened the scroll (via the existing Action::Send / run_send pipeline), letting scripts branch on player decisions.


Step 1 — Core: ScrollLine + Action::Scroll (kiln-core/src/action.rs)

Add two types:

pub enum ScrollLine {
    Text(String),
    Choice { choice: String, display: String },
}

// New variant on the existing Action enum:
Action::Scroll(Vec<ScrollLine>),

Action::Scroll has no time_cost (same as Say — zero cost, fires immediately from the queue).

Parsing from Rhai happens in script.rs when the host function converts the Rhai Array arg — not here.


Step 2 — Core: ActiveScroll + GameState changes (kiln-core/src/game.rs)

pub struct Scroll {
    pub source: ObjectId,
    pub lines: Vec<ScrollLine>,
}

Add to GameState:

pub active_scroll: Option<Scroll>,

In resolve(), handle the new action variant:

Action::Scroll(lines) => {
    self.active_scroll = Some(Scroll { source: ba.source, lines });
}

Add a method for the frontend to close the scroll and optionally dispatch a choice:

pub fn close_scroll(&mut self, choice: Option<&str>) {
    let source = self.active_scroll.take().map(|s| s.source);
    if let (Some(src), Some(ch)) = (source, choice) {
        self.scripts.run_send(src, ch, None);
        self.drain_errors();
    }
}

(run_send is already pub(crate) — make it pub or add this wrapper in game.rs which is within kiln-core.)


Step 3 — Core: scroll() Rhai host function (kiln-core/src/script.rs)

Register alongside the existing say / log fns:

engine.register_fn("scroll", move |ctx: NativeCallContext, arr: Array| {
    let lines = arr.into_iter().filter_map(|item| {
        if let Ok(s) = item.clone().into_string() {
            Some(ScrollLine::Text(s))
        } else if let Ok(pair) = item.into_array() {
            if pair.len() == 2 {
                let choice = pair[0].clone().into_string().ok()?;
                let display = pair[1].clone().into_string().ok()?;
                Some(ScrollLine::Choice { choice, display })
            } else { None }
        } else { None }
    }).collect();
    emit(&q, source_of(&ctx), Action::Scroll(lines));
});

Step 4 — TUI: scroll UI state (kiln-tui/src/main.rs)

Add to Ui:

scroll_offset: u16,
scroll_anim: Option<ScrollAnimState>,

where:

enum ScrollAnimState {
    Opening(f32),                                    // 0.0 → 1.0 over 0.5 s
    Open,
    Closing { progress: f32, choice: Option<String> }, // 1.0 → 0.0, then dispatch
}

Animation advance (in the per-frame tick block, before/instead of game.tick()):

let dt_s = dt.as_secs_f32();
match &mut ui.scroll_anim {
    Some(ScrollAnimState::Opening(p)) => {
        *p += dt_s / 0.5;
        if *p >= 1.0 { ui.scroll_anim = Some(ScrollAnimState::Open); }
    }
    Some(ScrollAnimState::Closing { progress, choice }) => {
        *progress -= dt_s / 0.5;
        if *progress <= 0.0 {
            let ch = choice.take();
            game.close_scroll(ch.as_deref());
            ui.scroll_anim = None;
            ui.scroll_offset = 0;
        }
    }
    _ => {}
}

Detect scroll opening: After game.tick(dt), if game.active_scroll.is_some() and ui.scroll_anim.is_none(), set ui.scroll_anim = Some(ScrollAnimState::Opening(0.0)).

Pause ticks while scroll is active: wrap the game.tick(dt) call:

if game.active_scroll.is_none() { game.tick(dt); }

Input interception — add an early branch in the key-event handler:

if game.active_scroll.is_some() && !matches!(ui.scroll_anim, Some(ScrollAnimState::Closing{..})) {
    match key.code {
        KeyCode::Esc => {
            // Only allow Esc if there are no choices; otherwise the player must choose.
            let has_choices = game.active_scroll.as_ref()
                .map(|s| s.lines.iter().any(|l| matches!(l, ScrollLine::Choice { .. })))
                .unwrap_or(false);
            if !has_choices { begin_close(&mut ui, None); }
        }
        KeyCode::Up   => ui.scroll_offset = ui.scroll_offset.saturating_sub(1),
        KeyCode::Down => ui.scroll_offset += 1,
        KeyCode::Char(c) => {
            // check if `c` matches a choice letter (assigned in render order a/b/c…)
            if let Some(ch) = resolve_choice(game.active_scroll.as_ref(), c) {
                begin_close(&mut ui, Some(ch));
            }
        }
        _ => {}
    }
    return Ok(false); // consume event, no player movement
}

begin_close sets ui.scroll_anim = Some(ScrollAnimState::Closing { progress: 1.0, choice }).

Mouse wheel up/down also routes to scroll_offset adjustment when scroll is active.


Step 5 — TUI: draw_scroll_overlay (kiln-tui/src/render.rs)

New function called from draw() after all board/log rendering:

pub fn draw_scroll_overlay(buf: &mut Buffer, area: Rect, scroll: &ActiveScroll,
                            offset: u16, anim: f32)

Dim the background

Walk every cell in area, set fg = Rgb(60,60,60), bg = Color::Black. Do this before drawing the overlay so the scroll sits on top.

Compute overlay size

  • content_width: min(60, area.width * 3/4)
  • text_lines: each ScrollLine::Text word-wrapped to content_width - 4, counted; Choice lines count as 1 each
  • content_height: min(text_lines + 2, area.height * 4/5) (+ 2 for borders)
  • Animated: actual_h = (content_height as f32 * anim).round() as u16 (grow from 0 → full height)

Draw

Center the overlay rect (x = (area.width - content_width)/2, y = (area.height - actual_h)/2). Use Block::bordered() rendered into the buffer via ratatui's render-to-buffer path, then write text lines cell-by-cell (following the draw_speech_bubbles pattern for direct buffer writes). Style:

  • Border: Rgb(180, 160, 100) fg, Rgb(30, 25, 10) bg
  • Plain text: white fg, dark bg
  • Choice lines: [a] prefix in yellow, text in Rgb(200, 200, 100), dark bg

Choice letters are assigned a…z in order of appearance among ScrollLine::Choice variants; this order must match the input resolver in main.rs.


Verification

  1. cargo build — no errors across the workspace.
  2. cargo test — existing tests still pass.
  3. Write/update maps/start.toml to include a test object with a bump handler that calls scroll(["Some text.", ["eat", "Eat the muffin"], ["ignore", "Ignore it"]]). Walk into the object; confirm:
    • Screen dims, scroll opens with animation.
    • Up/Down arrows scroll content.
    • Pressing a or b closes the scroll with animation and dispatches fn eat() / fn ignore() on the object (log the choice in those handlers to verify).
    • Pressing Esc closes without dispatch.
    • Ticks (object movement, bubble timers) resume after scroll closes.
  4. cargo clippy — no warnings.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment