hollow / app.py
Pabloler21's picture
style(game): minimal top-right control cluster, de-emphasized restart
7676f37
Raw
History Blame Contribute Delete
61.1 kB
import os
# cap CPU math threads BEFORE numpy/torch load (via gradio/engine/voice) — on
# many-core machines under memory pressure OpenBLAS spawns one buffer-hungry
# thread per core and the CPU allocator fails (Kokoro TTS won't load). 4 is
# plenty for our CPU-side work; the model runs on the GPU on the Space.
os.environ.setdefault("OMP_NUM_THREADS", "4")
os.environ.setdefault("OPENBLAS_NUM_THREADS", "4")
os.environ.setdefault("MKL_NUM_THREADS", "4")
# Force client-side rendering. HF defaults the Gradio runtime to SSR via the
# GRADIO_SSR_MODE env var, which runs the app behind a Node proxy in a SUBPROCESS.
# That breaks (a) our _HEAD_JS / CSS that drive the client DOM (intro typewriter,
# marker polling, layout) and (b) ZeroGPU — @spaces.GPU forks from that subprocess
# and loses the GPU assignment ("No CUDA GPUs are available"). gradio resolves SSR
# from this env when launch(ssr_mode=...) isn't reached (HF imports the app rather
# than running __main__), so hard-override it (NOT setdefault: HF pre-sets True).
os.environ["GRADIO_SSR_MODE"] = "False"
import base64
import html
import io
import json
import re
import time
import wave
import gradio as gr
from character import (FRAGMENT_TONES, INTRO_CARDS, INTRO_SEQUENCES,
OPENING_LINE, OWN_FRAGMENTS, build_system_prompt)
from engine import run_turn, run_turn_stream
from finale import finale_steps, finale_steps_bad, finale_steps_good
from memory import apply_update, get_tier, decide_ending, pick_aware_memory, should_recall, style_signal
from render import render_entity, render_treasure, render_recovered, _tone_class
from sting import chime_wav_bytes, menu_loop_wav_bytes, type_tick_wav_bytes
from voice import speak
with open("assets/background.webp", "rb") as _bg:
_BACKGROUND_URI = "data:image/webp;base64," + base64.b64encode(_bg.read()).decode()
def _render_bond(affinity: int, tier: str, label: str = "bond") -> str:
"""Custom HTML Bond meter — a thin thread with a heartbeat marker.
Replaces gr.Slider so we fully control the look (no Gradio orange/track)."""
pct = max(0, min(100, int(affinity)))
return (
'<div class="bond-meter">'
'<div class="bond-head">'
f'<span class="bond-name">{label}</span>'
f'<span class="bond-tier">{tier}</span>'
'</div>'
'<div class="bond-track">'
f'<div class="bond-fill" style="width:{pct}%"></div>'
f'<div class="bond-beat" style="left:{pct}%"></div>'
'</div>'
'</div>'
)
def _render_title(state: dict, show_end: bool = False) -> str:
"""The top-left title: 'Hollow' until the child names itself. On the
finale's final frame (show_end), append a hidden end marker the head-JS
polls to reveal the end screen (same marker pattern as tone/cue)."""
name = state.get("chosen_name")
if name:
safe = html.escape(name, quote=True)
title = (
f'<div id="game-title">{safe}'
f'<span class="named"> — it calls itself <b>{safe}</b></span></div>'
)
else:
title = '<div id="game-title">Hollow</div>'
if show_end and state.get("ended"):
title += (f'<span class="ended-now" data-ending="{state.get("ending","loop")}" '
'style="display:none"></span>')
return title
def _pristine_state():
"""A brand-new game — the real starting point, independent of the dev
HOLLOW_FAST_FINALE seed (the restart button must always give a clean run)."""
return {
"affinity": 20,
"treasure": [],
"claimed": [],
"history": [],
"turn": 0,
"last_recall_turn": None,
"ended": False,
"tone": 0,
"force_ending": None,
"wounds": [],
"ending": None,
"fragments_told": 0,
"last_aware_memory": None,
"msg_lengths": [],
"last_activity": time.time(), # idle clock; now client-side (Phase 2)
"idle_count": 0,
"greeted": False,
"mode": "tester",
"chosen_name": None,
"named": False,
}
def _reset():
"""Wipe the board for a fresh game without a page reload. Order matches
[msg_input, send_btn, chatbot, state, bond_panel, treasure_panel,
entity_panel, voice_panel, recovered_panel, title_panel]."""
fresh = _pristine_state()
return (
gr.update(value="", interactive=True,
placeholder="tell it something you remember..."),
gr.update(interactive=True),
[{"role": "assistant", "content": OPENING_LINE}],
fresh,
_render_bond(20, "Hollow"),
render_treasure([]),
render_entity(20),
_voice_html(None),
render_recovered(0),
_render_title(fresh),
)
def _to_menu():
"""Leave the wood — back to the front-door menu with a clean slate.
Order matches [menu_view, game_view, state]."""
return gr.update(visible=True), gr.update(visible=False), _pristine_state()
def _init_state():
fast = os.environ.get("HOLLOW_FAST_FINALE", "").lower()
if fast in ("1", "good", "loop", "neutral"): # "neutral" kept as loop alias
seeded = [
"is afraid of being forgotten",
"had a dog named Nala",
"grew up near the sea",
]
warm = fast in ("1", "good")
return {
"affinity": 78,
"treasure": list(seeded),
"claimed": list(seeded),
"history": [],
"turn": 12,
"last_recall_turn": 9,
"ended": False,
"tone": 30 if warm else 5,
"force_ending": "good" if warm else "loop",
"wounds": [],
"ending": None,
"fragments_told": 2 if warm else 0,
"last_aware_memory": None,
"msg_lengths": [],
"last_activity": time.time(),
"idle_count": 0,
"greeted": False,
"mode": "tester",
"chosen_name": None,
"named": False,
}
if fast == "bad":
return {
"affinity": 12,
"treasure": [],
"claimed": [],
"history": [],
"turn": 7,
"last_recall_turn": None,
"ended": False,
# tone must clear the bad gate (<=-30) so the dev seed fires the
# wound loop on the 2nd message WITHOUT a model call (like good/loop)
"tone": -35,
"force_ending": "bad",
"wounds": ["you're nothing", "talking to you is a waste of time"],
"ending": None,
"fragments_told": 0,
"last_aware_memory": None,
"msg_lengths": [],
"last_activity": time.time(),
"idle_count": 0,
"greeted": False,
"mode": "tester",
"chosen_name": None,
"named": False,
}
return _pristine_state()
# our own animated typing bubble — Gradio's progress overlay is disabled
# (show_progress="hidden"); these three dots blink in sequence (CSS, §20) so
# the wait reads as "Hollow is thinking", not a frozen screen
_PENDING = '<span class="hollow-typing"><i></i><i></i><i></i></span>'
_HOWTO_HTML = (
'<div class="howto-panel">'
'<h3>How to Play</h3>'
'<p>You found a child at the edge of the wood. It has no memories of its own '
'&mdash; so it asks for yours.</p>'
'<p>Tell it something true: a person, a place, a moment you lived. It keeps each '
'one in its treasure.</p>'
'<p>Later it speaks your memories back &mdash; in the first person, as if it had '
'lived them. That is what it wants.</p>'
'<p>But it remembers how you treat it. Be gentle, or be cruel. '
'<b>Three endings</b> wait in the fog.</p>'
'<p class="howto-controls">type and press enter &middot; mute &middot; '
'begin again &middot; turn your sound on</p>'
'</div>'
)
def _voice_html(b64: str | None) -> str:
# autoplay the whisper from its own channel so the entity heartbeat HTML
# stays byte-identical (never remounts). Empty when silent.
if not b64:
return ""
return f'<audio autoplay src="data:audio/wav;base64,{b64}"></audio>'
def _recovered(state: dict) -> str:
return render_recovered(state.get("fragments_told", 0), state.get("claimed"))
_SENTENCE_END = re.compile(r'[^.!?…\n]*[.!?…\n]+', re.S)
def _new_sentences(text: str, consumed: int) -> tuple[list[str], int]:
"""Return any newly-completed sentences in text[consumed:] and the new
consumed offset (up to the last sentence terminator)."""
pending = text[consumed:]
out, last_end = [], 0
for m in _SENTENCE_END.finditer(pending):
chunk = m.group().strip()
if chunk:
out.append(chunk)
last_end = m.end()
return out, consumed + last_end
# the opening ritual's two sounds, synthesized once at import: a detuned
# music-box note (numpy, always available) and the child's spoken opening
# line (Kokoro; empty on the silent dev venv). Injected into _HEAD_JS as
# data: URI constants so the controller can queue them on the first gesture
# and on every "begin again".
_CHIME_B64 = base64.b64encode(chime_wav_bytes(0)).decode()
_GREETING_B64 = speak(OPENING_LINE) or ""
_MENU_LOOP_B64 = base64.b64encode(menu_loop_wav_bytes()).decode()
_CHIME_URI = f"data:audio/wav;base64,{_CHIME_B64}"
_GREETING_URI = f"data:audio/wav;base64,{_GREETING_B64}" if _GREETING_B64 else ""
_MENU_LOOP_URI = f"data:audio/wav;base64,{_MENU_LOOP_B64}"
# Intro card images: user-provided grayscale WebP in assets/. Missing files
# fall back to "" so the intro (and tests/dev) works before the art exists; the
# CSS then shows a dark gradient for that card.
def _img_uri(path: str) -> str:
try:
with open(path, "rb") as f:
return "data:image/webp;base64," + base64.b64encode(f.read()).decode()
except OSError:
return ""
_INTRO_IMAGES = [
_img_uri("assets/intro_threshold.webp"),
_img_uri("assets/intro_keeps.webp"),
_img_uri("assets/intro_waiting.webp"),
_img_uri("assets/intro_meeting.webp"),
_img_uri("assets/intro_waiting.webp"), # 4 — tester condensed card (reuses the waiting art)
]
# Per-card focal point for the 16:10 cover-crop (CSS background-position).
# Card order matches INTRO_CARDS: threshold, keeps, waiting, meeting.
# Per-card focal point for the 16:10 cover-crop (CSS background-position).
# Raised so each subject sits high and the dialogue box covers emptier area.
# Card order matches INTRO_CARDS: threshold, keeps, waiting, meeting.
_INTRO_POS = ["center 72%", "center 38%", "center 38%", "center 20%", "center 38%"] # idx 4 reuses the waiting focal point
_TICK_URI = f"data:audio/wav;base64,{base64.b64encode(type_tick_wav_bytes()).decode()}"
# The app's only JavaScript: a <head> controller that (1) starts the menu
# melody on the visitor's first menu gesture, (2) stops it and plays the
# chime → greeting ritual when a mode is selected, (3) owns a global mute
# for the child's voice and the menu bed, applying it as Gradio remounts
# nodes, driving the mute button, and (4) times the idle "speak-first"
# client-side so ANY interaction resets it. Every horror effect stays CSS.
_HEAD_JS = """
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=Special+Elite&family=Crimson+Text:ital,wght@0,400;0,600;1,400;1,600&display=swap">
<script>
(function () {
var KEY = 'hollowAudio';
var saved = {};
try { saved = JSON.parse(localStorage.getItem(KEY)) || {}; } catch (e) {}
var st = {
volume: (typeof saved.volume === 'number') ? saved.volume : 0.85,
muted: !!saved.muted
};
function persist() {
try { localStorage.setItem(KEY, JSON.stringify(st)); } catch (e) {}
}
var CHIME_SRC = "__CHIME__";
var GREETING_SRC = "__GREETING__";
var MENU_SRC = "__MENU__";
var INTRO = __INTRO_CARDS__; // [{img, text}, ...] (JSON, injected)
var INTRO_SEQ = __INTRO_SEQ__; // {"tester":[0,3], "full":[0,1,2,3]} (injected)
var TICK_SRC = "__TICK__";
// --- client-side voice queue: sentences play in order, never cut ---
var queue = [], current = null;
function playNext() {
if (current || !queue.length) return;
current = queue.shift();
current.muted = st.muted; current.volume = st.volume;
current.onended = function () { current = null; playNext(); };
current.play().catch(function () { current = null; });
}
function enqueue(src) {
if (!src) return;
queue.push(new Audio(src));
playNext();
}
function applyLive() { // mute/volume affect the playing line
if (current) { current.muted = st.muted; current.volume = st.volume; }
}
function resetQueue() {
queue.length = 0;
if (current) { try { current.pause(); } catch (e) {} current = null; }
}
var _lastRitual = 0;
function openingRitual() { // chime, then the child greets
var now = Date.now();
if (now - _lastRitual < 800) return; // debounce double-fire
_lastRitual = now;
resetQueue();
if (CHIME_SRC) enqueue(CHIME_SRC);
if (GREETING_SRC) enqueue(GREETING_SRC);
}
// menu bed: starts on first gesture, stops when a mode is chosen
var menuAudio = null;
function startMenuMusic() {
if (menuAudio || !MENU_SRC) return;
menuAudio = document.getElementById('menu-loop') || new Audio(MENU_SRC);
menuAudio.loop = true;
menuAudio.volume = st.muted ? 0 : 0.2;
menuAudio.muted = st.muted;
menuAudio.play().catch(function () {});
}
function stopMenuMusic() {
if (menuAudio) { try { menuAudio.pause(); } catch (e) {} menuAudio = null; }
}
// read any voice <audio> Gradio mounts, copy its src into the queue, then it
// can be wiped from the DOM without stopping playback
new MutationObserver(function (muts) {
muts.forEach(function (m) {
m.addedNodes.forEach(function (n) {
if (n.nodeType !== 1) return;
var nodes = [];
if (n.matches && n.matches('.voice-channel audio, audio.cue-audio')) nodes.push(n);
if (n.querySelectorAll) n.querySelectorAll('.voice-channel audio, audio.cue-audio').forEach(function (x) { nodes.push(x); });
nodes.forEach(function (a) { enqueue(a.currentSrc || a.src); a.remove(); });
});
});
}).observe(document.documentElement, { childList: true, subtree: true });
// first menu gesture starts the eerie bed (the greeting fires on mode-select)
function firstGesture() {
startMenuMusic();
window.removeEventListener('pointerdown', firstGesture, true);
window.removeEventListener('keydown', firstGesture, true);
}
window.addEventListener('pointerdown', firstGesture, true);
window.addEventListener('keydown', firstGesture, true);
// ---- opening intro: client-driven typewriter cards ----
var introSeq = [], introIdx = 0, introTyping = false, introTimer = null;
function playTick() {
if (!TICK_SRC || st.muted) return;
var a = new Audio(TICK_SRC);
a.volume = 0.2;
a.play().catch(function () {});
}
function typeLine(el, text, onDone) {
introTyping = true;
var i = 0;
(function step() {
el.textContent = text.slice(0, i);
if (i < text.length) {
if (i % 2 === 0) playTick();
i++;
introTimer = setTimeout(step, 28);
} else {
introTyping = false; introTimer = null;
if (onDone) onDone();
}
})();
}
function revealChoice() {
var opts = document.getElementById('intro-opts');
if (opts) opts.classList.add('intro-opts-show');
}
function showIntroCard(cardIndex, isLast) {
var card = INTRO[cardIndex];
var img = document.getElementById('intro-image');
var bg = document.getElementById('intro-bg');
var txt = document.getElementById('intro-text');
var opts = document.getElementById('intro-opts');
if (opts) opts.classList.remove('intro-opts-show');
var url = card.img ? "url('" + card.img + "')" : '';
if (img) {
img.style.backgroundImage = url;
img.style.backgroundPosition = card.pos || 'center';
}
if (bg) bg.style.backgroundImage = url;
if (txt) typeLine(txt, card.text, function () { if (isLast) revealChoice(); });
}
function advanceIntro() {
var isLast = introIdx >= introSeq.length - 1;
if (introTyping) { // first click completes the line
if (introTimer) { clearTimeout(introTimer); introTimer = null; }
var txt = document.getElementById('intro-text');
if (txt) txt.textContent = INTRO[introSeq[introIdx]].text;
introTyping = false;
if (isLast) revealChoice();
return;
}
if (isLast) return; // wait for a choice on the last card
introIdx++;
showIntroCard(introSeq[introIdx], introIdx >= introSeq.length - 1);
}
function startIntro(mode) {
introSeq = INTRO_SEQ[mode] || INTRO_SEQ['tester']; // single source of truth
introIdx = 0;
window.scrollTo(0, 0);
// The server flips intro_view visible on a round-trip; on a Space the
// network latency makes that land AFTER a fixed timeout, so #intro-text isn't
// in the DOM yet and nothing types -> blank intro. POLL for it (up to ~6s)
// instead of guessing a delay, so it's robust to any latency.
var _introTries = 0;
(function waitForIntro() {
var txt = document.getElementById('intro-text');
if (!txt) { if (_introTries++ < 100) setTimeout(waitForIntro, 60); return; }
showIntroCard(introSeq[0], introSeq.length <= 1);
var stage = document.getElementById('intro-stage');
if (stage && !stage.dataset.hollowWired) {
stage.dataset.hollowWired = '1';
stage.addEventListener('click', advanceIntro);
}
var opts = document.querySelectorAll('#intro-opts .intro-opt');
opts.forEach(function (el) {
if (el.dataset.hollowWired) return;
el.dataset.hollowWired = '1';
el.addEventListener('click', function (e) {
e.stopPropagation(); // don't let the stage advance
var t = document.getElementById(el.dataset.target);
if (t) t.click(); // fire the hidden Gradio button
});
});
})();
}
// mute button + restart + mode buttons, wired once rendered
function icon() { return st.muted ? '\\uD83D\\uDD07' : '\\uD83D\\uDD0A'; }
// lift the per-turn tone onto #game-view so the WORLD tints (the marker
// .tone-now lives inside #game-entity, a sibling of #game-bg/#game-vig)
function applyTone() {
var gv = document.getElementById('game-view');
var mark = document.querySelector('#game-view .tone-now[data-tone]');
if (!gv || !mark) return;
var t = mark.getAttribute('data-tone');
if (gv.dataset.tone === t) return;
gv.dataset.tone = t;
['tone-warm','tone-neutral','tone-wounded','tone-hostile'].forEach(function (c) {
gv.classList.remove(c);
});
gv.classList.add('tone-' + t);
}
// once-per-turn feedback: pulse the relevant drawer chip, drop a whisper,
// amber-tag the recall line. Rides the same marker pattern as applyTone.
var CUE_WHISPER = { capture: '\\u2014 it keeps this \\u2014',
recover: '\\u2014 it remembers \\u2014' };
function applyCue() {
var gv = document.getElementById('game-view');
var mark = document.querySelector('#game-view .cue-now[data-cue]');
if (!gv || !mark) return;
var seq = mark.getAttribute('data-seq');
if (gv.dataset.cueSeq === seq) return; // already fired this turn
gv.dataset.cueSeq = seq;
var cue = mark.getAttribute('data-cue');
var rightSide = (cue === 'recover');
var drawer = document.getElementById(rightSide ? 'drawer-right' : 'drawer-left');
if (drawer) {
drawer.classList.add('pulse');
setTimeout(function () { drawer.classList.remove('pulse'); }, 2500);
}
if (cue === 'recall') {
var bots = document.querySelectorAll('#game-dialogue .bot');
if (bots.length) bots[bots.length - 1].classList.add('recall-line');
}
var text = CUE_WHISPER[cue];
if (text && drawer) {
var w = document.createElement('div');
w.className = 'cue-whisper';
w.textContent = text;
var r = drawer.getBoundingClientRect();
w.style.left = r.left + 'px';
w.style.top = (r.bottom + 6) + 'px';
document.body.appendChild(w);
setTimeout(function () { w.classList.add('out'); }, 60);
setTimeout(function () { w.remove(); }, 2800);
}
}
// end screen: when the finale's final frame ships an `ended-now` marker,
// set the per-ending epitaph and fade the overlay in (after the last beat).
var EPITAPH = {
good: 'the edge of the wood is empty now.',
loop: 'someone waits at the edge. someone always does.',
bad: 'see you soon.'
};
function applyEnd() {
var ov = document.getElementById('end-overlay');
var mark = document.querySelector('.ended-now[data-ending]');
if (!ov) return;
if (!mark) { return; } // no finale yet
if (ov.dataset.shown === '1') return; // already revealed
ov.dataset.shown = '1';
var ending = mark.getAttribute('data-ending');
var ep = document.getElementById('end-epitaph');
if (ep) ep.textContent = EPITAPH[ending] || EPITAPH.loop;
setTimeout(function () { ov.classList.add('shown'); }, 900); // let the last beat land
}
function wire() {
applyTone();
applyCue();
applyEnd();
// corner drawers: header chip toggles expand
document.querySelectorAll('.game-drawer .drawer-head').forEach(function (h) {
if (h.dataset.hollowWired) return;
h.dataset.hollowWired = '1';
h.addEventListener('click', function () { h.closest('.game-drawer').classList.toggle('open'); });
});
var btn = document.querySelector('.voice-btn');
var rb = document.querySelector('.restart-btn');
if (btn && !btn.dataset.hollowWired) {
btn.dataset.hollowWired = '1';
btn.textContent = icon();
btn.addEventListener('click', function (e) {
e.preventDefault(); e.stopPropagation();
st.muted = !st.muted;
applyLive();
if (menuAudio) { menuAudio.muted = st.muted; menuAudio.volume = st.muted ? 0 : 0.2; }
persist(); btn.textContent = icon();
});
}
if (rb && !rb.dataset.hollowWired) {
rb.dataset.hollowWired = '1';
// do NOT stop propagation — Gradio's server-side _reset must still run;
// the click is a gesture, so the ritual audio is allowed to play
rb.addEventListener('click', function () {
openingRitual();
window.scrollTo(0, 0);
setTimeout(function () { window.scrollTo(0, 0); }, 80);
});
}
document.querySelectorAll('.menu-mode-btn').forEach(function (mb) {
if (mb.dataset.hollowWired) return;
mb.dataset.hollowWired = '1';
mb.addEventListener('click', function () {
var mode = (mb.id === 'btn-full') ? 'full' : 'tester';
startIntro(mode); // server flips menu->intro; bed keeps playing low
});
});
document.querySelectorAll('.menu-opt').forEach(function (mo) {
if (mo.dataset.hollowWired) return;
mo.dataset.hollowWired = '1';
mo.addEventListener('click', function () {
var t = document.getElementById(mo.dataset.target);
if (t) t.click(); // fire the hidden mode button (startIntro + _show_intro)
});
});
// intro choice / skip: reveal the game -> stop the bed, chime->greeting, top
document.querySelectorAll('.intro-enter-btn').forEach(function (eb) {
if (eb.dataset.hollowWired) return;
eb.dataset.hollowWired = '1';
eb.addEventListener('click', function () {
stopMenuMusic();
openingRitual();
window.scrollTo(0, 0);
setTimeout(function () { window.scrollTo(0, 0); }, 80);
});
});
// end-screen actions: the server handlers reset/leave; JS clears the
// overlay class + shown flag so a future finale can re-reveal it.
['btn-end-again', 'btn-end-leave'].forEach(function (id) {
var b = document.getElementById(id);
if (b && !b.dataset.hollowWired) {
b.dataset.hollowWired = '1';
b.addEventListener('click', function () {
var ov = document.getElementById('end-overlay');
if (ov) { ov.classList.remove('shown'); ov.dataset.shown = ''; }
// drop the finale marker so applyEnd's 250ms poll can't re-reveal the
// overlay — that was the leave-the-wood double-click + the begin-again
// credits-flash. A fresh finale re-renders the title with a new marker.
var m = document.querySelector('.ended-now');
if (m) m.remove();
if (id === 'btn-end-again') openingRitual(); // same ritual as restart
});
}
});
}
// keep watching — buttons can appear later when the menu flips to the game
setInterval(wire, 250);
// idle "speak-first": client-timed, reset by ANY interaction, fires at 60s
var IDLE_MS = 60000;
var last = Date.now();
function bump() { last = Date.now(); }
['pointerdown','keydown','input','pointermove','wheel','touchstart']
.forEach(function (ev) { window.addEventListener(ev, bump, true); });
setInterval(function () {
if (Date.now() - last < IDLE_MS) return;
var trig = document.getElementById('idle-trigger');
if (!trig) return;
last = Date.now(); // next nudge is another full 60s
trig.click();
}, 5000);
})();
</script>
"""
_HEAD_JS = _HEAD_JS.replace("__CHIME__", _CHIME_URI).replace("__GREETING__", _GREETING_URI).replace("__MENU__", _MENU_LOOP_URI)
_INTRO_CARDS_JSON = json.dumps(
[{"img": _INTRO_IMAGES[i], "text": INTRO_CARDS[i], "pos": _INTRO_POS[i]}
for i in range(len(INTRO_CARDS))]
)
_HEAD_JS = (_HEAD_JS
.replace("__INTRO_CARDS__", _INTRO_CARDS_JSON)
.replace("__INTRO_SEQ__", json.dumps(INTRO_SEQUENCES))
.replace("__TICK__", _TICK_URI))
def _menu_html() -> str:
return (
f'<div class="menu-bg" style="background-image:url(\'{_BACKGROUND_URI}\')"></div>'
f'<div id="menu-frame" style="background-image:url(\'{_BACKGROUND_URI}\')"></div>'
'<div class="menu-scrim"></div>'
'<div class="menu-grain"></div>'
'<div class="menu-vig"></div>'
'<div id="menu-card">'
' <div class="menu-eyebrow">AN ADVENTURE IN THOUSAND TOKEN WOOD</div>'
' <div class="menu-title">Hollow</div>'
' <div class="menu-tag">something is here, at the edge of the wood '
'&mdash; it remembers what you give it.</div>'
' <div class="menu-opts">'
' <div class="menu-opt" data-target="btn-tester">Tester Run</div>'
' <div class="menu-opt" data-target="btn-full">The Full Stay</div>'
' <div class="menu-opt menu-opt-muted" data-target="btn-howto">How to Play</div>'
' </div>'
'</div>'
'<div class="menu-credit">A LOST CHILD WAITS &middot; TURN YOUR SOUND ON</div>'
)
def _show_intro(mode: str):
"""Mode-select: leave the menu, reveal the intro, remember the mode so the
choice/skip buttons can start the right run. Order matches the outputs
list below: [menu_view, intro_view, pending_mode]."""
return (
gr.update(visible=False), # menu_view
gr.update(visible=True), # intro_view
mode, # pending_mode (gr.State)
)
def _enter_game(mode: str, tone_seed: int = 0):
"""Leave the intro, reveal the game, start a fresh run in the chosen mode
with the intro choice's seeded tone. Order matches the outputs list below."""
state = _init_state() # honors HOLLOW_FAST_FINALE for dev
state["mode"] = mode # "tester" | "full"
state["tone"] = tone_seed # intro choice seeds the starting tone
return (
gr.update(visible=False), # intro_view
gr.update(visible=True), # game_view
state,
[{"role": "assistant", "content": OPENING_LINE}], # chatbot
_render_bond(state["affinity"], get_tier(state["affinity"])),
render_treasure(state["treasure"], claimed=set(state["claimed"]),
wounds=state.get("wounds", [])),
# the opening is the vulnerable FIRST contact — always the base child,
# never a tier-driven face (the dev HOLLOW_FAST_FINALE seed starts at
# Almost bond, which would otherwise greet you with the possessive grin)
render_entity(20, tone=state["tone"]),
_recovered(state),
_render_title(state),
)
def _audio_seconds(b64: str) -> float:
"""Duration of a base64 WAV, so a finale can wait for a spoken line to
finish before the next line's audio replaces it. 0.0 if it can't be read."""
try:
with wave.open(io.BytesIO(base64.b64decode(b64)), "rb") as w:
return w.getnframes() / float(w.getframerate())
except Exception:
return 0.0
def _voice_and_pause(text: str, speak_it: bool, scripted_pause: float):
"""Synthesize the line (if speak_it) and return (voice_html, pause). The
pause is stretched to at least the audio length so the whole line is heard
before the next step replaces it."""
b64 = speak(text) if speak_it else None
if b64:
dur = _audio_seconds(b64)
# pace to the line itself + a short breath — no long scripted dead air
# between paragraphs (the recital felt slow/dense). Fall back to the
# scripted pause only if the audio length can't be read.
pause = dur + 0.1 if dur > 0 else scripted_pause
else:
pause = scripted_pause # silent beats (the convulsion) keep timing
return _voice_html(b64), pause
_IDLE_TIERS = [
["...are you still there?", "don't go. not yet."], # plead
["you're not even listening.", "you said you would stay."], # hurt
["fine. ignore me. they all do, before the end.", # hostile
"you think i cannot reach you out there?"],
]
_IDLE_AFFINITY_DROP = 3 # bond lost per ignored nudge
_IDLE_TONE_DROP = 2 # tone soured per ignored nudge
def _on_idle(state: dict, history: list):
"""Hidden trigger click. When the visitor has gone quiet, Hollow speaks
first — escalating plead -> hurt -> hostile — and each nudge lowers the
bond and sours the tone. Timing lives in the browser (see _HEAD_JS); here
we only refuse to fire while a turn is streaming or after the end."""
# timing now lives in the browser (see _HEAD_JS); here we only refuse to
# fire while a turn is streaming (the "..." bubble) or after the end
pending = bool(history) and "hollow-typing" in str(history[-1].get("content", ""))
if pending or state.get("ended") or not history:
return state, gr.update(), gr.update(), gr.update()
state = dict(state)
i = state.get("idle_count", 0)
tier = _IDLE_TIERS[min(i // 2, len(_IDLE_TIERS) - 1)]
line = tier[i % len(tier)]
state["idle_count"] = i + 1
state["last_activity"] = time.time()
state["affinity"] = max(0, state.get("affinity", 20) - _IDLE_AFFINITY_DROP)
state["tone"] = max(-100, state.get("tone", 0) - _IDLE_TONE_DROP)
history = history + [{"role": "assistant", "content": line}]
voice = _voice_html(speak(line))
bond = _render_bond(state["affinity"], get_tier(state["affinity"]))
return state, history, voice, bond
def _start_turn(user_msg: str, state: dict, history: list):
# output `state` too, so the activity stamp persists across the event
# boundary — otherwise the idle timer thinks you're idle mid-turn and
# races the chat generator, orphaning the "..." bubble
if not user_msg.strip() or state.get("ended"):
return gr.update(), gr.update(interactive=not state.get("ended")), history, state
state["last_activity"] = time.time()
new_history = history + [
{"role": "user", "content": user_msg},
{"role": "assistant", "content": _PENDING},
]
# clear the box immediately on send (value="") so the text doesn't sit
# 'stuck' mid-turn; stash the real message in state for chat() to read
# (the cleared box would otherwise hand chat an empty user_msg)
state["_pending_msg"] = user_msg
return gr.update(value="", interactive=False), gr.update(interactive=False), new_history, state
def _memory_plea_level(state: dict, recall_turn: bool) -> int:
"""How plainly the child should ask for a real memory this turn. Escalates
with consecutive turns that captured nothing; silent on recall turns and
when the child is withdrawn (hostile tone)."""
if recall_turn or state.get("tone", 0) <= -22:
return 0
barren = state.get("barren_turns", 0)
if barren >= 6:
return 3
if barren >= 4:
return 2
if barren >= 2:
return 1
return 0
_DEAD_PLACEHOLDER = "i don't— i don't remember how long it's been."
# input/error safety nets — always in character, never a red Error chip
MAX_MSG_LEN = 500
_TOO_LONG_REPLY = ("too many words at once. the fog can't carry them all. "
"give me one small true thing.")
_FOG_REPLY = ("...the fog swallowed your words. i couldn't hear them. "
"say it again?")
def _play_finale(state: dict, history: list):
"""Streams the scripted ending. No model, no GPU. Each yield is a full
output tuple; time.sleep between yields sets the dramatic pace."""
claimed = state["claimed"]
struck: set[str] = set()
chat_hist = list(history)
tier = get_tier(state["affinity"])
bond = _render_bond(state["affinity"], tier)
treasure_html = render_treasure(state["treasure"], struck=struck,
claimed=set(claimed))
entity = render_entity(state["affinity"], "build", seq=state["turn"],
tone=state.get("tone", 0))
locked = gr.update(interactive=False)
input_locked = gr.update(interactive=False)
mutated = False
convulsed = False
recovered = _recovered(state)
for step in finale_steps(claimed):
role = "assistant" if step["speaker"] == "hollow" else "user"
if step["text"]: # silent beats (the convulsion) add no bubble
chat_hist = chat_hist + [{"role": role, "content": step["text"]}]
if step["strike"] is not None:
struck.add(claimed[step["strike"]])
treasure_html = render_treasure(state["treasure"], struck=struck,
claimed=set(claimed))
if step["stage"] == "frenzy":
convulsed = True
entity = render_entity(state["affinity"], "convulse_loop",
seq=state["turn"], tone=state.get("tone", 0))
if step["stage"] == "turn" and not mutated:
mutated = True
bond = _render_bond(state["affinity"], tier, label="mine")
treasure_html = render_treasure(state["treasure"], mine=True)
# the `end` face lands and the heartbeat flatlines, exactly now
# (seamless — the convulsion already settled on this face)
entity = render_entity(state["affinity"], "end_settle",
seq=state["turn"], tone=state.get("tone", 0))
input_locked = gr.update(interactive=False, placeholder="stay.")
speak_it = step["speaker"] == "hollow"
voice, pause = _voice_and_pause(step["text"], speak_it, step["pause"])
yield (input_locked, locked, chat_hist, state, bond, treasure_html,
entity, voice, recovered, _render_title(state))
if pause > 0:
time.sleep(pause)
yield (
gr.update(interactive=False, placeholder=_DEAD_PLACEHOLDER),
locked, chat_hist, state, bond, treasure_html, entity,
_voice_html(None), recovered, _render_title(state, show_end=True),
)
def _play_finale_bad(state: dict, history: list):
"""The wound loop — recites the visitor's cruelties, unredacting them
one by one. No model, no GPU."""
wounds = state.get("wounds", [])
chat_hist = list(history)
tier = get_tier(state["affinity"])
bond = _render_bond(state["affinity"], tier)
revealed = 0
treasure_html = render_treasure(state["treasure"], claimed=set(state["claimed"]),
wounds=wounds, revealed=0)
entity = render_entity(state["affinity"], "build", seq=state["turn"],
tone=state.get("tone", 0))
locked = gr.update(interactive=False)
input_locked = gr.update(interactive=False)
mutated = False
frenzied = False
recovered = _recovered(state)
for step in finale_steps_bad(wounds):
role = "assistant" if step["speaker"] == "hollow" else "user"
if step["text"]: # silent beats (the convulsion) add no bubble
chat_hist = chat_hist + [{"role": role, "content": step["text"]}]
if step["strike"] is not None:
revealed = step["strike"] + 1
treasure_html = render_treasure(state["treasure"],
claimed=set(state["claimed"]),
wounds=wounds, revealed=revealed)
if step["stage"] == "turn" and not mutated:
mutated = True
bond = _render_bond(state["affinity"], tier, label="wound")
treasure_html = render_treasure(state["treasure"],
claimed=set(state["claimed"]),
wounds=wounds, revealed=revealed,
yours=True)
input_locked = gr.update(interactive=False, placeholder="get out.")
# the child stays a smudge through the whole recital; the rage face is
# revealed only AFTER the convulsion, with the threat
if step["stage"] == "frenzy":
frenzied = True
entity = render_entity(state["affinity"], "frenzy",
seq=state["turn"], tone=state.get("tone", 0))
elif frenzied:
entity = render_entity(state["affinity"], "rage",
seq=state["turn"], tone=state.get("tone", 0))
speak_it = step["speaker"] == "hollow"
voice, pause = _voice_and_pause(step["text"], speak_it, step["pause"])
yield (input_locked, locked, chat_hist, state, bond, treasure_html,
entity, voice, recovered, _render_title(state))
if pause > 0:
time.sleep(pause)
yield (
gr.update(interactive=False, placeholder="see you soon."),
locked, chat_hist, state, bond, treasure_html, entity,
_voice_html(None), recovered, _render_title(state, show_end=True),
)
def _play_finale_good(state: dict, history: list):
"""Redemption — your warmth gave it back its past. It confesses, returns
the treasure, smiles for the first time, and dissolves in peace.
No model, no GPU."""
chat_hist = list(history)
tier = get_tier(state["affinity"])
bond = _render_bond(state["affinity"], tier)
treasure_html = render_treasure(state["treasure"],
claimed=set(state["claimed"]))
entity = render_entity(state["affinity"], "build", seq=state["turn"],
tone=state.get("tone", 0))
locked = gr.update(interactive=False)
input_locked = gr.update(interactive=False)
mutated = False
convulsed = False
settled = False
recovered = _recovered(state)
for step in finale_steps_good(OWN_FRAGMENTS):
if step["text"]: # silent beats (e.g. the convulsion) add no bubble
chat_hist = chat_hist + [{"role": "assistant", "content": step["text"]}]
if step["stage"] == "turn" and not mutated:
mutated = True
bond = _render_bond(state["affinity"], tier, label="warm")
# the treasure is returned: claim marks lifted, every entry lit
treasure_html = render_treasure(state["treasure"])
input_locked = gr.update(interactive=False,
placeholder="it's quiet now.")
if step["stage"] == "frenzy":
convulsed = True
entity = render_entity(state["affinity"], "convulse_good",
seq=state["turn"], tone=state.get("tone", 0))
elif convulsed and step["stage"] != "loop":
# the smile lands + the relief sigh plays, exactly now (seamless —
# the convulsion already settled on this face); sigh only once
if not settled:
settled = True
entity = render_entity(state["affinity"], "peace_settle",
seq=state["turn"], tone=state.get("tone", 0))
else:
entity = render_entity(state["affinity"], "peace",
seq=state["turn"], tone=state.get("tone", 0))
if step["stage"] == "loop":
entity = render_entity(state["affinity"], "peace_dissolve",
seq=state["turn"], tone=state.get("tone", 0))
voice, pause = _voice_and_pause(step["text"], bool(step["text"]), step["pause"])
yield (input_locked, locked, chat_hist, state, bond, treasure_html,
entity, voice, recovered, _render_title(state))
if pause > 0:
time.sleep(pause)
yield (
gr.update(interactive=False, placeholder="it's quiet now."),
locked, chat_hist, state, bond, treasure_html, entity,
_voice_html(None), recovered, _render_title(state, show_end=True),
)
def chat(user_msg: str, state: dict, history: list):
# drop the pending typing bubble _start_turn added, so downstream history
# logic is unchanged (match on the sentinel class, robust to re-serializing)
# _start_turn cleared the input box on send and stashed the real message in
# state (chatbot message content isn't reliably a plain string to recover)
user_msg = state.pop("_pending_msg", user_msg)
if history and "hollow-typing" in str(history[-1].get("content", "")):
history = history[:-1]
ending = state.get("ending")
if state.get("ended") and ending is None:
ending = "loop" # sessions ended before `ending` existed
if not user_msg.strip() or state.get("ended"):
tier = get_tier(state["affinity"])
wounds = state.get("wounds", [])
entity_mode = {"good": "peace", "bad": "rage", "loop": "end"}.get(
ending, "idle")
yield (
gr.update(interactive=not state.get("ended")),
gr.update(interactive=not state.get("ended")),
history,
state,
_render_bond(state["affinity"], tier),
render_treasure(state["treasure"], mine=(ending == "loop"),
claimed=set() if ending == "good"
else set(state["claimed"]),
wounds=wounds, revealed=len(wounds),
yours=(ending == "bad")),
render_entity(state["affinity"], entity_mode, seq=state["turn"],
tone=state.get("tone", 0)),
_voice_html(None),
_recovered(state),
_render_title(state),
)
return
if len(user_msg) > MAX_MSG_LEN:
# decline in character — no model call, no state change, no quota
tier = get_tier(state["affinity"])
yield (
gr.update(value="", interactive=True),
gr.update(interactive=True),
history + [{"role": "assistant", "content": _TOO_LONG_REPLY}],
state,
_render_bond(state["affinity"], tier),
render_treasure(state["treasure"], claimed=set(state["claimed"]),
wounds=state.get("wounds", [])),
render_entity(state["affinity"], "idle", seq=state["turn"],
tone=state.get("tone", 0)),
_voice_html(None),
_recovered(state),
_render_title(state),
)
return
state["last_activity"] = time.time()
ending = decide_ending(state)
if ending:
state["ended"] = True
state["ending"] = ending
if os.environ.get("HOLLOW_DEBUG"):
print(f"[ending] {ending} @ turn {state['turn']} aff={state['affinity']} "
f"tone={state.get('tone', 0)} claimed={len(state['claimed'])} "
f"wounds={len(state.get('wounds', []))}", flush=True)
if ending == "good":
yield from _play_finale_good(state, history)
elif ending == "bad":
yield from _play_finale_bad(state, history)
else:
yield from _play_finale(state, history)
return
do_recall, recall_memory = should_recall(state)
frag_before = state.get("fragments_told", 0)
# sustained warmth pulls fragments of Hollow's own past loose (one per
# tone milestone, never on a recall turn — each beat gets its own stage)
own_fragment = None
told = state.get("fragments_told", 0)
if (not (do_recall and recall_memory) and told < len(OWN_FRAGMENTS)
and state.get("tone", 0) >= FRAGMENT_TONES[told]):
own_fragment = OWN_FRAGMENTS[told]
# B2: on non-recall turns (every 3rd), quietly resurface an earlier memory
aware_memory = None
if not (do_recall and recall_memory) and state["turn"] % 3 == 0:
aware_memory = pick_aware_memory(state, exclude=recall_memory)
lengths = state.get("msg_lengths", []) + [len(user_msg.split())]
state["msg_lengths"] = lengths[-5:] # keep the last 5
name_ready = (
not state.get("named") and state.get("fragments_told", 0) >= 2
)
system = build_system_prompt(state["affinity"], state["treasure"], recall_memory,
tone=state.get("tone", 0),
wounds=state.get("wounds", []),
memory_plea=_memory_plea_level(
state, bool(do_recall and recall_memory)),
own_fragment=own_fragment,
style=style_signal(state["msg_lengths"]),
aware_memory=aware_memory,
name_ready=name_ready)
chat_messages = [{"role": "system", "content": system}] + state["history"] + [
{"role": "user", "content": user_msg}
]
tier_now = get_tier(state["affinity"])
reply, raw_json, turn_failed = "", "{}", False
spoken = 0
try:
for item in run_turn_stream(chat_messages, user_msg):
if isinstance(item, tuple) and item and item[0] == "__final__":
_, reply, raw_json = item
break
reply = item
sentences, spoken = _new_sentences(reply, spoken)
voice = "".join(_voice_html(speak(s)) for s in sentences) if sentences else _voice_html(None)
yield (
gr.update(interactive=False),
gr.update(interactive=False),
history + [{"role": "assistant", "content": reply}],
state,
_render_bond(state["affinity"], tier_now),
render_treasure(state["treasure"], claimed=set(state["claimed"]),
wounds=state.get("wounds", [])),
render_entity(state["affinity"], "idle", seq=state["turn"],
tone=state.get("tone", 0)),
voice,
_recovered(state),
_render_title(state),
)
except Exception as e:
print(f"[chat] run_turn_stream failed: {e!r}")
reply, raw_json = _FOG_REPLY, "{}"
turn_failed = True
treasure_before = len(state["treasure"])
state = apply_update(state, raw_json, reply=reply)
captured = len(state["treasure"]) > treasure_before
# coaching counter: reset when a memory lands, climb when turns are barren
state["barren_turns"] = 0 if captured else state.get("barren_turns", 0) + 1
if do_recall and recall_memory and not turn_failed:
state["claimed"].append(recall_memory)
state["last_recall_turn"] = state["turn"]
if own_fragment and not turn_failed:
state["fragments_told"] = told + 1
if aware_memory and not turn_failed:
state["last_aware_memory"] = aware_memory
state["history"].append({"role": "user", "content": user_msg})
state["history"].append({"role": "assistant", "content": reply})
state["turn"] += 1
if os.environ.get("HOLLOW_DEBUG"):
print(f"[turn {state['turn']}] aff={state['affinity']} tone={state.get('tone', 0)} "
f"treasure={len(state['treasure'])} claimed={len(state['claimed'])} "
f"wounds={len(state.get('wounds', []))} barren={state.get('barren_turns', 0)}",
flush=True)
new_history = history + [{"role": "assistant", "content": reply}]
mode = "flash_strong" if (do_recall and recall_memory) else ("flash" if captured else "idle")
tier = get_tier(state["affinity"])
# every reply is voiced; loudness/mute is handled in the browser (the
# head controller). The opening line is spoken separately on first gesture.
tail = reply[spoken:].strip()
voice = _voice_html(speak(tail)) if tail else _voice_html(None)
_plea = _memory_plea_level(state, bool(do_recall and recall_memory))
_ph = ("a memory: a person, a place, a moment you lived..."
if _plea >= 2 else "tell it something you remember...")
just_claimed = recall_memory if (do_recall and recall_memory) else None
recovered_now = state.get("fragments_told", 0) > frag_before
cue = ("recall" if (do_recall and recall_memory)
else "recover" if recovered_now
else "capture" if captured else "")
yield (
gr.update(value="", interactive=True, placeholder=_ph),
gr.update(interactive=True),
new_history,
state,
_render_bond(state["affinity"], tier),
render_treasure(state["treasure"], claimed=set(state["claimed"]),
wounds=state.get("wounds", []),
just_claimed=just_claimed),
render_entity(state["affinity"], mode, seq=state["turn"],
tone=state.get("tone", 0), cue=cue),
voice,
_recovered(state),
_render_title(state),
)
with gr.Blocks(title="Hollow") as demo:
state = gr.State(_init_state)
pending_mode = gr.State("tester") # set by mode-select, read by the intro choice
# menu bed: mounted at top level so it survives when menu_view unmounts
gr.HTML('<audio id="menu-loop" loop src="' + _MENU_LOOP_URI + '"></audio>')
with gr.Column(visible=True, elem_id="menu-view") as menu_view:
menu_art = gr.HTML(_menu_html())
with gr.Column(elem_id="menu-proxy"): # hidden; .menu-opt rows click these
tester_btn = gr.Button("Tester Run", elem_id="btn-tester",
elem_classes="menu-mode-btn")
full_btn = gr.Button("The Full Stay", elem_id="btn-full",
elem_classes="menu-mode-btn")
howto_btn = gr.Button("How to Play", elem_id="btn-howto",
elem_classes="menu-btn")
with gr.Column(visible=False, elem_id="howto-overlay") as howto_overlay:
gr.HTML(_HOWTO_HTML)
howto_close = gr.Button("close", elem_id="btn-howto-close",
elem_classes="menu-btn")
with gr.Column(visible=False, elem_id="intro-view") as intro_view:
gr.HTML(
'<div id="intro-stage">'
' <div id="intro-bg"></div>'
' <div id="intro-frame">'
' <div id="intro-image"></div>'
' <div id="intro-panel">'
' <p id="intro-text"></p>'
' <div id="intro-opts">'
' <div class="intro-opt" data-target="btn-offer">Offer your hand</div>'
' <div class="intro-opt" data-target="btn-keep">Keep your distance</div>'
' </div>'
' </div>'
' </div>'
'</div>'
)
with gr.Column(elem_id="intro-proxy"): # hidden; HTML rows click these
offer_btn = gr.Button("Offer your hand", elem_id="btn-offer",
elem_classes="intro-enter-btn")
keep_btn = gr.Button("Keep your distance", elem_id="btn-keep",
elem_classes="intro-enter-btn")
skip_btn = gr.Button("skip ▸", elem_id="intro-skip",
elem_classes="intro-enter-btn")
with gr.Column(visible=False, elem_id="game-view") as game_view:
with gr.Column(elem_id="game-stage"):
gr.HTML(f'<div id="game-bg" style="background-image:url(\'{_BACKGROUND_URI}\')"></div>'
f'<div id="game-scene" style="background-image:url(\'{_BACKGROUND_URI}\')"></div>'
'<div id="game-vig"></div>'
'<div id="game-frame-edge"></div>'
'<div id="game-groundfog"></div>')
with gr.Row(elem_id="game-topbar"):
title_panel = gr.HTML(value=_render_title(_init_state()))
restart_btn = gr.Button("↺", elem_classes="restart-btn",
scale=0)
voice_btn = gr.Button("🔊", elem_classes="voice-btn", scale=0)
# left drawer — your memories
with gr.Column(elem_id="drawer-left", elem_classes="game-drawer"):
treasure_panel = gr.HTML(value=render_treasure([]))
# right drawer — its own recovered memories
with gr.Column(elem_id="drawer-right", elem_classes="game-drawer"):
recovered_panel = gr.HTML(value=render_recovered(0))
# center: the child silhouette in the fog
entity_panel = gr.HTML(value=render_entity(20), elem_id="game-entity")
# conversation as subtitles, anchored near the frame's bottom edge
with gr.Column(elem_id="game-dialogue"):
chatbot = gr.Chatbot(
value=[{"role": "assistant", "content": OPENING_LINE}],
label="", show_label=False, height=210, buttons=[],
autoscroll=True,
)
bond_panel = gr.HTML(value=_render_bond(20, "Hollow"),
elem_classes="bond-panel")
# input — a thin line at the very bottom of the viewport (below the frame)
with gr.Column(elem_id="game-inputbar"):
with gr.Row(elem_id="game-inputrow"):
msg_input = gr.Textbox(
placeholder="tell it something you remember...",
show_label=False, scale=9, autofocus=True)
send_btn = gr.Button("→", scale=1)
voice_panel = gr.HTML(value="", elem_classes="voice-channel")
# show_progress="hidden" kills Gradio's per-component "processing" overlay;
# we show our own "..." pending bubble instead (set in _start_turn)
send_btn.click(
fn=_start_turn,
inputs=[msg_input, state, chatbot],
outputs=[msg_input, send_btn, chatbot, state],
show_progress="hidden",
).then(
fn=chat,
inputs=[msg_input, state, chatbot],
outputs=[msg_input, send_btn, chatbot, state, bond_panel,
treasure_panel, entity_panel, voice_panel, recovered_panel,
title_panel],
show_progress="hidden",
)
msg_input.submit(
fn=_start_turn,
inputs=[msg_input, state, chatbot],
outputs=[msg_input, send_btn, chatbot, state],
show_progress="hidden",
).then(
fn=chat,
inputs=[msg_input, state, chatbot],
outputs=[msg_input, send_btn, chatbot, state, bond_panel,
treasure_panel, entity_panel, voice_panel, recovered_panel,
title_panel],
show_progress="hidden",
)
restart_btn.click(
fn=_reset,
inputs=None,
outputs=[msg_input, send_btn, chatbot, state, bond_panel,
treasure_panel, entity_panel, voice_panel, recovered_panel,
title_panel],
show_progress="hidden",
)
# idle is timed in the browser (see _HEAD_JS): after 60s of true silence
# the controller clicks this hidden button, which runs the same _on_idle.
# Mounted + CSS-hidden (NOT visible=False, which can unmount it).
idle_trigger = gr.Button("idle", elem_id="idle-trigger",
elem_classes="idle-trigger")
idle_trigger.click(_on_idle, [state, chatbot],
[state, chatbot, voice_panel, bond_panel],
show_progress="hidden")
# end screen — always mounted, CSS-hidden until a finale reveals it (§27).
# Revealed by the head-JS reading the title's `ended-now` marker.
with gr.Column(elem_id="end-overlay") as end_overlay:
# hero: the foggy backdrop + the per-ending epitaph (set by applyEnd())
gr.HTML(
f'<div id="end-bg" style="background-image:url(\'{_BACKGROUND_URI}\')"></div>'
'<div id="end-scrim"></div>'
'<p id="end-epitaph"></p>'
)
# the two actions, grouped + centered right under the epitaph
with gr.Row(elem_id="end-actions"):
end_again_btn = gr.Button("↺ begin again", elem_id="btn-end-again",
elem_classes="end-btn")
end_leave_btn = gr.Button("leave the wood", elem_id="btn-end-leave",
elem_classes="end-btn end-btn-dim")
# credits sink to a quiet footer pinned to the bottom (position:fixed in §27)
gr.HTML(
'<div id="end-credits">'
' <div class="ec-title">HOLLOW</div>'
' <div class="ec-line">a thing that waited at the edge of the wood</div>'
' <div class="ec-meta">Qwen3-8B · Kokoro-82M · FLUX · Gradio'
' &nbsp;·&nbsp; 🔌 off the grid &nbsp;·&nbsp; 🎨 off-brand</div>'
'</div>'
)
end_again_btn.click(
fn=_reset, inputs=None,
outputs=[msg_input, send_btn, chatbot, state, bond_panel,
treasure_panel, entity_panel, voice_panel,
recovered_panel, title_panel],
show_progress="hidden",
)
end_leave_btn.click(
fn=_to_menu, inputs=None,
outputs=[menu_view, game_view, state],
show_progress="hidden",
)
_enter_outputs = [intro_view, game_view, state, chatbot, bond_panel,
treasure_panel, entity_panel, recovered_panel, title_panel]
_intro_outputs = [menu_view, intro_view, pending_mode]
tester_btn.click(lambda: _show_intro("tester"), None, _intro_outputs,
show_progress="hidden")
full_btn.click(lambda: _show_intro("full"), None, _intro_outputs,
show_progress="hidden")
# the intro choice / skip seed the tone, then reveal the game
offer_btn.click(lambda m: _enter_game(m, 12), pending_mode, _enter_outputs,
show_progress="hidden")
keep_btn.click(lambda m: _enter_game(m, -8), pending_mode, _enter_outputs,
show_progress="hidden")
skip_btn.click(lambda m: _enter_game(m, 0), pending_mode, _enter_outputs,
show_progress="hidden")
howto_btn.click(lambda: gr.update(visible=True), None, howto_overlay,
show_progress="hidden")
howto_close.click(lambda: gr.update(visible=False), None, howto_overlay,
show_progress="hidden")
if __name__ == "__main__":
# head= injects the one-shot gesture listener into <head> (Gradio 6.0 moved
# head from the Blocks constructor to launch()).
# ssr_mode=False: the Space defaults to SSR, but our _HEAD_JS controller +
# CSS target Gradio's client DOM/timing (intro typewriter, marker polling,
# tone/cue lifting). SSR changes that and breaks the intro + layout; force
# client-side rendering so the Space matches the validated local build.
demo.launch(css_paths="styles.css", head=_HEAD_JS, ssr_mode=False)