hollow / FIELD_NOTES.md
Pabloler21's picture
docs: Field Notes dev-log (earns the πŸ““ badge)
4deeed5
|
Raw
History Blame Contribute Delete
9.1 kB

A newer version of the Gradio SDK is available: 6.20.0

Upgrade

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.