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'' '' '' '' '' '' ) 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)