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 (
'
'
'
'
f'{label}'
f'{tier}'
'
'
'
'
f''
f''
'
'
'
'
)
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'
{safe}'
f' — it calls itself {safe}
'
)
else:
title = '
Hollow
'
if show_end and state.get("ended"):
title += (f'')
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 = ''
_HOWTO_HTML = (
'
'
'
How to Play
'
'
You found a child at the edge of the wood. It has no memories of its own '
'— so it asks for yours.
'
'
Tell it something true: a person, a place, a moment you lived. It keeps each '
'one in its treasure.
'
'
Later it speaks your memories back — in the first person, as if it had '
'lived them. That is what it wants.
'
'
But it remembers how you treat it. Be gentle, or be cruel. '
'Three endings wait in the fog.
'
'
type and press enter · mute · '
'begin again · turn your sound on
'
'
'
)
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''
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 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 = """
"""
_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''
f''
''
''
''
'
'
'
AN ADVENTURE IN THOUSAND TOKEN WOOD
'
'
Hollow
'
'
something is here, at the edge of the wood '
'— it remembers what you give it.
'
'
'
'
Tester Run
'
'
The Full Stay
'
'
How to Play
'
'
'
'
'
'
A LOST CHILD WAITS · TURN YOUR SOUND ON
'
)
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('')
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(
'
'
' '
'
'
' '
'
'
' '
'
'
'
Offer your hand
'
'
Keep your distance
'
'
'
'
'
'
'
'
'
)
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''
f''
''
''
'')
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''
''
''
)
# 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(
'
'
'
HOLLOW
'
'
a thing that waited at the edge of the wood
'
'
Qwen3-8B · Kokoro-82M · FLUX · Gradio'
' · 🔌 off the grid · 🎨 off-brand
'
'
'
)
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 (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)