Spaces:
Running on Zero
Running on Zero
| # 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 `<audio autoplay>` *restarts* on every streamed | |
| step. That's actually the trick behind the one-shot scare flashes β but it's death | |
| for a continuous heartbeat. | |
| The way out: Gradio **does not re-send a component whose value is byte-identical** | |
| to the previous frame. So during the finale's "build" phase the entity panel emits | |
| the exact same HTML β including a single `<audio loop>` heartbeat β across many | |
| streamed steps. Gradio sees no change, never remounts it, and the heartbeat plays | |
| unbroken. The instant the climax changes the HTML, the heartbeat element vanishes | |
| and the climax sound takes its place. A continuous looping bed, zero JavaScript, | |
| falling straight out of how the framework already works. | |
| ### 5. Making the final image land without a "pop" | |
| Each ending's portrait convulses through faces and settles on a final one. The | |
| first cut had a visible *pop*: the convulsion would end on the blurred smudge, then | |
| the final face would snap in a frame later. Fixing it meant adding a "settle-face" | |
| layer that fades the final image in over the last 40% of the convulsion and holds | |
| it (CSS `animation-fill-mode: forwards`). The settle render is then the *same frame* | |
| already on screen β seamless. The climax sound is moved onto that settle so it lands | |
| exactly when the face does, not a beat early. | |
| ### 6. The CSS harness lies | |
| More than once I "verified" CSS in a static test harness and shipped something | |
| broken, because the harness lacks Gradio's real cascade. The only reliable check is | |
| to launch the actual app, screenshot it headless, and look. `prefers-reduced-motion` | |
| bit me too: disabling *opacity* animations there once silently killed the terror | |
| flash, because the flash *is* an opacity animation. Now reduced-motion only stops | |
| ambient drift, never the scares. | |
| ### 7. You can't commit a `.wav` to a Space | |
| Hugging Face rejects raw binary audio. So nothing is committed β every sound | |
| (heartbeat, sigh, flatline, the scream sting, the music-box chimes) is **synthesized | |
| in NumPy at import time** and base64-inlined into the HTML. The whole soundscape is | |
| a few hundred lines of `sin`, envelopes, and `tanh` soft-clipping. Same rule would | |
| apply to any new binary that isn't an image. | |
| --- | |
| ## Tuning the pacing against the real model | |
| Thresholds for the endings (how warm is "warm enough"?) can't be guessed β the | |
| model's behavior is the ground truth. So I wrote a small simulator that drives | |
| scripted visitors (a warm one, a transactional one, a cruel one) through the *real* | |
| Ollama model and reports per-turn affinity, tone, and captures. That's how the gates | |
| got set: warm play climbs tone to ~27, transactional play stalls near ~13, so the | |
| redemption/loop split sits cleanly at 20. The cruel ending fires around message 6, | |
| warm redemption around 10, the visitor loop around 12. None of those numbers are | |
| invented β they're measured. | |
| --- | |
| ## What I'd tell myself on day one | |
| - **The constraint is the design.** "β€32B, no JS, no binaries" sounds limiting and | |
| was actually generative β the heartbeat trick only exists *because* of the no-JS | |
| rule. | |
| - **Determinism where it counts.** The reply can be warm and surprising; the | |
| extraction must be boring and exact. Splitting temperature by purpose fixed a | |
| whole class of flakiness. | |
| - **Verify against the thing that runs**, not the thing that's convenient to test. | |
| - **Small, scripted, no-GPU finales** mean the emotional climax is rock-solid and | |
| costs nothing β the model does the improv, the script does the payoff. | |
| --- | |
| ## Badges pursued | |
| - π **Off-the-Grid** β runs entirely on a local/open model (Qwen3-8B), no external APIs. | |
| - π¨ **Off-Brand** β bespoke grayscale Fear-&-Hunger-style portraits and a fully | |
| custom horror UI; nothing looks like a default Gradio app. | |
| - π **Field Notes** β this document. | |
| Thanks for reading. If you play it: be kind to the thing in the fog. Or don't. | |
| It remembers either way. | |