# Field Notes β€” Building *Hollow* > πŸ„ **Build Small Hackathon** Β· Track: *An Adventure in Thousand Token Wood* > A horror NPC that asks for your memories, then claims them as its own. > Live: https://huggingface.co/spaces/build-small-hackathon/hollow These are the field notes β€” the honest dev log. What I set out to build, the decisions that mattered, and the bugs that taught me something. Written so the next person doesn't have to relearn them the hard way. --- ## The idea A lost child stands at the edge of the wood. It has no memories of its own, so it asks for yours. You give it one β€” a fear, a person, a place β€” and it keeps it in a "treasure". Turns later it does the unsettling thing: it recites your memory back **in the first person**, as if it had always been its own. That moment β€” the **recall** β€” is the whole pitch. Everything else exists to earn it and to give it weight. How you *treat* the child while you feed it branches three endings: - **Redemption** β€” sustained warmth gives it back its own buried past; it confesses what it is, returns your memories, and leaves in peace. - **Visitor Loop** β€” fed plenty but treated like a diary, it stays a predator, consumes you, and the last line of the game is *you* speaking its opening line. - **Wound Loop** β€” sustained cruelty; it has been quietly keeping every cruel thing you said, and recites them back, un-redacted. The horror isn't a jump-scare. It's that the thing in the fog becomes more *you* the more of yourself you hand over. --- ## Constraints shaped everything The hackathon's spirit is *small*: a model ≀32B, runnable by anyone. That single constraint drove most of the architecture. - **Model: `Qwen/Qwen3-8B`.** Transformers + ZeroGPU on the Space, the same model via Ollama locally. One code path, switched on `bool(os.environ.get("SPACE_ID"))`. - **No cloud APIs, no fine-tuning, no vector DB, no LangGraph.** The "memory" is a plain dict in Gradio's `gr.State`; extraction is one deterministic JSON call per turn parsed with Pydantic. - **Pure CSS, no JavaScript.** Gradio Blocks for the whole UI. This turned out to be the most interesting constraint of all (see the heartbeat story below). ### Why not a bigger model (Gemma 4 12B)? I seriously considered jumping to a 12B model for more "presence". I didn't, and I'm glad. A 12B in 4-bit doesn't comfortably fit the 8 GB of local VRAM I was testing on, the tooling was a week old, and every extra billion params is slower inference β€” which on ZeroGPU means *less* of the judges' daily quota per playthrough. Worst of all, swapping the model meant re-tuning the brittle parts (the JSON extraction, the recall thresholds) for zero narrative upside. Qwen3-8B already nailed the in-character voice. The lesson: **the model was never the bottleneck β€” the prompt design and the state machine were.** --- ## The parts that fought back ### 1. A single `+` froze the entire game Each turn, a second model call extracts a small JSON: `affinity_delta`, `new_memories`, `tone_delta`, `cruel_quote`. Early on, the bond meter simply *never moved* on positive turns. The model was emitting `"affinity_delta": +2` β€” a leading `+`, which is **invalid JSON**. Every positive turn silently fell back to delta 0, so the bond froze and nothing was ever captured. Fix: a sanitizer that strips leading `+` before parsing, an explicit "never write a leading + sign" instruction in the prompt, *and* dual worked examples (one positive, one negative) so the model has a positive anchor. All three together β€” removing any one re-introduced the bug intermittently. ### 2. Hollow started reciting its own words The recall is the star, and it nearly ate itself. On a recall turn, Hollow retells your memory in the first person. The extraction step would then grab *that line* as a brand-new "memory", store it, and recall it again next time β€” a feedback loop where the child slowly converged on repeating one self-referential sentence forever. Fix, in two layers: the extraction prompt is told to take memories **only** from what the visitor said, never from Hollow's reply; and `apply_update` drops any candidate memory whose token overlap with Hollow's own reply is >60%. Plus a `repetition_penalty` on the reply generation (only the reply β€” extraction stays deterministic at temperature 0). Belt and suspenders, and both earn their keep. ### 3. ZeroGPU does not like threads The obvious way to keep the UI responsive during generation is a worker thread. On ZeroGPU this quietly breaks: `@spaces.GPU` needs the main request context, and a thread pool severs it. So the "Hollow is thinking…" animation had to be something that needs no thread at all β€” which is why the typing dots are pure CSS. Related: do all the GPU work (reply + extraction) inside a **single** `@spaces.GPU` acquisition per turn, or you pay the cold-start twice. ### 4. A heartbeat that loops, with no JavaScript For the endings I wanted a heartbeat pounding under the child's final monologue, then a climax sound β€” a scream, a sigh, a flatline. But Gradio **replaces the DOM on every component update**, so an `