Pabloler21 Claude Fable 5 commited on
Commit
4deeed5
Β·
1 Parent(s): 98232c3

docs: Field Notes dev-log (earns the πŸ““ badge)

Browse files

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

Files changed (1) hide show
  1. FIELD_NOTES.md +179 -0
FIELD_NOTES.md ADDED
@@ -0,0 +1,179 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Field Notes β€” Building *Hollow*
2
+
3
+ > πŸ„ **Build Small Hackathon** Β· Track: *An Adventure in Thousand Token Wood*
4
+ > A horror NPC that asks for your memories, then claims them as its own.
5
+ > Live: https://huggingface.co/spaces/build-small-hackathon/hollow
6
+
7
+ These are the field notes β€” the honest dev log. What I set out to build, the
8
+ decisions that mattered, and the bugs that taught me something. Written so the
9
+ next person doesn't have to relearn them the hard way.
10
+
11
+ ---
12
+
13
+ ## The idea
14
+
15
+ A lost child stands at the edge of the wood. It has no memories of its own, so
16
+ it asks for yours. You give it one β€” a fear, a person, a place β€” and it keeps it
17
+ in a "treasure". Turns later it does the unsettling thing: it recites your memory
18
+ back **in the first person**, as if it had always been its own. That moment β€” the
19
+ **recall** β€” is the whole pitch. Everything else exists to earn it and to give it
20
+ weight.
21
+
22
+ How you *treat* the child while you feed it branches three endings:
23
+
24
+ - **Redemption** β€” sustained warmth gives it back its own buried past; it
25
+ confesses what it is, returns your memories, and leaves in peace.
26
+ - **Visitor Loop** β€” fed plenty but treated like a diary, it stays a predator,
27
+ consumes you, and the last line of the game is *you* speaking its opening line.
28
+ - **Wound Loop** β€” sustained cruelty; it has been quietly keeping every cruel
29
+ thing you said, and recites them back, un-redacted.
30
+
31
+ The horror isn't a jump-scare. It's that the thing in the fog becomes more *you*
32
+ the more of yourself you hand over.
33
+
34
+ ---
35
+
36
+ ## Constraints shaped everything
37
+
38
+ The hackathon's spirit is *small*: a model ≀32B, runnable by anyone. That single
39
+ constraint drove most of the architecture.
40
+
41
+ - **Model: `Qwen/Qwen3-8B`.** Transformers + ZeroGPU on the Space, the same model
42
+ via Ollama locally. One code path, switched on `bool(os.environ.get("SPACE_ID"))`.
43
+ - **No cloud APIs, no fine-tuning, no vector DB, no LangGraph.** The "memory" is a
44
+ plain dict in Gradio's `gr.State`; extraction is one deterministic JSON call per
45
+ turn parsed with Pydantic.
46
+ - **Pure CSS, no JavaScript.** Gradio Blocks for the whole UI. This turned out to
47
+ be the most interesting constraint of all (see the heartbeat story below).
48
+
49
+ ### Why not a bigger model (Gemma 4 12B)?
50
+
51
+ I seriously considered jumping to a 12B model for more "presence". I didn't, and
52
+ I'm glad. A 12B in 4-bit doesn't comfortably fit the 8 GB of local VRAM I was
53
+ testing on, the tooling was a week old, and every extra billion params is slower
54
+ inference β€” which on ZeroGPU means *less* of the judges' daily quota per playthrough.
55
+ Worst of all, swapping the model meant re-tuning the brittle parts (the JSON
56
+ extraction, the recall thresholds) for zero narrative upside. Qwen3-8B already
57
+ nailed the in-character voice. The lesson: **the model was never the bottleneck β€”
58
+ the prompt design and the state machine were.**
59
+
60
+ ---
61
+
62
+ ## The parts that fought back
63
+
64
+ ### 1. A single `+` froze the entire game
65
+
66
+ Each turn, a second model call extracts a small JSON: `affinity_delta`,
67
+ `new_memories`, `tone_delta`, `cruel_quote`. Early on, the bond meter simply
68
+ *never moved* on positive turns. The model was emitting `"affinity_delta": +2` β€”
69
+ a leading `+`, which is **invalid JSON**. Every positive turn silently fell back
70
+ to delta 0, so the bond froze and nothing was ever captured.
71
+
72
+ Fix: a sanitizer that strips leading `+` before parsing, an explicit "never write
73
+ a leading + sign" instruction in the prompt, *and* dual worked examples (one
74
+ positive, one negative) so the model has a positive anchor. All three together β€”
75
+ removing any one re-introduced the bug intermittently.
76
+
77
+ ### 2. Hollow started reciting its own words
78
+
79
+ The recall is the star, and it nearly ate itself. On a recall turn, Hollow retells
80
+ your memory in the first person. The extraction step would then grab *that line*
81
+ as a brand-new "memory", store it, and recall it again next time β€” a feedback loop
82
+ where the child slowly converged on repeating one self-referential sentence forever.
83
+
84
+ Fix, in two layers: the extraction prompt is told to take memories **only** from
85
+ what the visitor said, never from Hollow's reply; and `apply_update` drops any
86
+ candidate memory whose token overlap with Hollow's own reply is >60%. Plus a
87
+ `repetition_penalty` on the reply generation (only the reply β€” extraction stays
88
+ deterministic at temperature 0). Belt and suspenders, and both earn their keep.
89
+
90
+ ### 3. ZeroGPU does not like threads
91
+
92
+ The obvious way to keep the UI responsive during generation is a worker thread.
93
+ On ZeroGPU this quietly breaks: `@spaces.GPU` needs the main request context, and
94
+ a thread pool severs it. So the "Hollow is thinking…" animation had to be
95
+ something that needs no thread at all β€” which is why the typing dots are pure CSS.
96
+ Related: do all the GPU work (reply + extraction) inside a **single** `@spaces.GPU`
97
+ acquisition per turn, or you pay the cold-start twice.
98
+
99
+ ### 4. A heartbeat that loops, with no JavaScript
100
+
101
+ For the endings I wanted a heartbeat pounding under the child's final monologue,
102
+ then a climax sound β€” a scream, a sigh, a flatline. But Gradio **replaces the DOM
103
+ on every component update**, so an `<audio autoplay>` *restarts* on every streamed
104
+ step. That's actually the trick behind the one-shot scare flashes β€” but it's death
105
+ for a continuous heartbeat.
106
+
107
+ The way out: Gradio **does not re-send a component whose value is byte-identical**
108
+ to the previous frame. So during the finale's "build" phase the entity panel emits
109
+ the exact same HTML β€” including a single `<audio loop>` heartbeat β€” across many
110
+ streamed steps. Gradio sees no change, never remounts it, and the heartbeat plays
111
+ unbroken. The instant the climax changes the HTML, the heartbeat element vanishes
112
+ and the climax sound takes its place. A continuous looping bed, zero JavaScript,
113
+ falling straight out of how the framework already works.
114
+
115
+ ### 5. Making the final image land without a "pop"
116
+
117
+ Each ending's portrait convulses through faces and settles on a final one. The
118
+ first cut had a visible *pop*: the convulsion would end on the blurred smudge, then
119
+ the final face would snap in a frame later. Fixing it meant adding a "settle-face"
120
+ layer that fades the final image in over the last 40% of the convulsion and holds
121
+ it (CSS `animation-fill-mode: forwards`). The settle render is then the *same frame*
122
+ already on screen β€” seamless. The climax sound is moved onto that settle so it lands
123
+ exactly when the face does, not a beat early.
124
+
125
+ ### 6. The CSS harness lies
126
+
127
+ More than once I "verified" CSS in a static test harness and shipped something
128
+ broken, because the harness lacks Gradio's real cascade. The only reliable check is
129
+ to launch the actual app, screenshot it headless, and look. `prefers-reduced-motion`
130
+ bit me too: disabling *opacity* animations there once silently killed the terror
131
+ flash, because the flash *is* an opacity animation. Now reduced-motion only stops
132
+ ambient drift, never the scares.
133
+
134
+ ### 7. You can't commit a `.wav` to a Space
135
+
136
+ Hugging Face rejects raw binary audio. So nothing is committed β€” every sound
137
+ (heartbeat, sigh, flatline, the scream sting, the music-box chimes) is **synthesized
138
+ in NumPy at import time** and base64-inlined into the HTML. The whole soundscape is
139
+ a few hundred lines of `sin`, envelopes, and `tanh` soft-clipping. Same rule would
140
+ apply to any new binary that isn't an image.
141
+
142
+ ---
143
+
144
+ ## Tuning the pacing against the real model
145
+
146
+ Thresholds for the endings (how warm is "warm enough"?) can't be guessed β€” the
147
+ model's behavior is the ground truth. So I wrote a small simulator that drives
148
+ scripted visitors (a warm one, a transactional one, a cruel one) through the *real*
149
+ Ollama model and reports per-turn affinity, tone, and captures. That's how the gates
150
+ got set: warm play climbs tone to ~27, transactional play stalls near ~13, so the
151
+ redemption/loop split sits cleanly at 20. The cruel ending fires around message 6,
152
+ warm redemption around 10, the visitor loop around 12. None of those numbers are
153
+ invented β€” they're measured.
154
+
155
+ ---
156
+
157
+ ## What I'd tell myself on day one
158
+
159
+ - **The constraint is the design.** "≀32B, no JS, no binaries" sounds limiting and
160
+ was actually generative β€” the heartbeat trick only exists *because* of the no-JS
161
+ rule.
162
+ - **Determinism where it counts.** The reply can be warm and surprising; the
163
+ extraction must be boring and exact. Splitting temperature by purpose fixed a
164
+ whole class of flakiness.
165
+ - **Verify against the thing that runs**, not the thing that's convenient to test.
166
+ - **Small, scripted, no-GPU finales** mean the emotional climax is rock-solid and
167
+ costs nothing β€” the model does the improv, the script does the payoff.
168
+
169
+ ---
170
+
171
+ ## Badges pursued
172
+
173
+ - πŸ”Œ **Off-the-Grid** β€” runs entirely on a local/open model (Qwen3-8B), no external APIs.
174
+ - 🎨 **Off-Brand** β€” bespoke grayscale Fear-&-Hunger-style portraits and a fully
175
+ custom horror UI; nothing looks like a default Gradio app.
176
+ - πŸ““ **Field Notes** β€” this document.
177
+
178
+ Thanks for reading. If you play it: be kind to the thing in the fog. Or don't.
179
+ It remembers either way.