alvations commited on
Commit
c9c35b3
·
verified ·
1 Parent(s): 58b4064

Deploy Hallway 8 (multi-arc memory game)

Browse files
.gitattributes CHANGED
@@ -34,3 +34,4 @@ saved_model/**/* filter=lfs diff=lfs merge=lfs -text
34
  *.zst filter=lfs diff=lfs merge=lfs -text
35
  *tfevents* filter=lfs diff=lfs merge=lfs -text
36
  static/audio/landing.ogg filter=lfs diff=lfs merge=lfs -text
 
 
34
  *.zst filter=lfs diff=lfs merge=lfs -text
35
  *tfevents* filter=lfs diff=lfs merge=lfs -text
36
  static/audio/landing.ogg filter=lfs diff=lfs merge=lfs -text
37
+ static/audio/landing.mp4 filter=lfs diff=lfs merge=lfs -text
CLAUDE.md CHANGED
@@ -28,6 +28,7 @@ static/style.css skins (body[data-skin=...]) + layout
28
  static/game.js client: arc select, rendering, pacing, drift, sid, controls
29
  scripts/deploy_hf.py deploy/update the HF Space (run locally)
30
  scripts/make_private_hf.py toggle Space visibility (--public to release)
 
31
  tests/test_game.py test suite (no pytest needed)
32
  docs/ARCS.md design rules, shared tuning, built + planned arcs
33
  ```
@@ -102,7 +103,14 @@ first user gesture (autoplay policy). An exported render of the landing theme is
102
  saved at `static/audio/landing.ogg` (mono OGG Vorbis, ~42s, normalized; rendered
103
  offline from the same synth via OfflineAudioContext). An always-visible mute toggle (`#sound-toggle`) persists to
104
  `localStorage` (`h8_muted`). Audio only starts from a user gesture (browser
105
- autoplay policy). To add a soundscape for a new arc, add a `_<skin>(bus)` builder
 
 
 
 
 
 
 
106
  and branch to it in `play()`.
107
 
108
  ## UI conventions
@@ -128,11 +136,16 @@ and branch to it in `play()`.
128
 
129
  ## Working conventions (IMPORTANT)
130
 
131
- - **Commits/PRs are authored solely by the repo owner** (`alvations
132
- <alvations@gmail.com>`). Never add Claude as author or co-author, and never add
133
- Claude/Co-Authored-By/session trailers to commits or PR bodies. Use
134
- `git -c user.name=... -c user.email=... commit --author="alvations <alvations@gmail.com>"`.
 
 
135
  - **Do not put any AI model identifier** in commits, code, comments, or docs.
 
 
 
136
  - **Do not reference the source film or any external IP** anywhere in the repo
137
  (docs, code, comments, or commit messages). Keep the game's own naming.
138
  - **Never spoil the game in the instructions/intros.** State the rule generically
 
28
  static/game.js client: arc select, rendering, pacing, drift, sid, controls
29
  scripts/deploy_hf.py deploy/update the HF Space (run locally)
30
  scripts/make_private_hf.py toggle Space visibility (--public to release)
31
+ scripts/export_landing_media.py render landing theme -> ogg + 1080p youtube mp4 (local)
32
  tests/test_game.py test suite (no pytest needed)
33
  docs/ARCS.md design rules, shared tuning, built + planned arcs
34
  ```
 
103
  saved at `static/audio/landing.ogg` (mono OGG Vorbis, ~42s, normalized; rendered
104
  offline from the same synth via OfflineAudioContext). An always-visible mute toggle (`#sound-toggle`) persists to
105
  `localStorage` (`h8_muted`). Audio only starts from a user gesture (browser
106
+ autoplay policy).
107
+
108
+ **Movement SFX:** committing a choice plays `window.ambience.step(dir, dwellMs)`,
109
+ synthesised (footstep thud + scuff, plus a directional scene-change swish).
110
+ `dwellMs` is how long the player lingered (`performance.now() - decideStart`,
111
+ where `decideStart` is set when the controls appear): a quick, confident press
112
+ gets firm footsteps; a longer deliberation softens to a tiptoe, then to a near
113
+ silent shuffle. Routed through the master gain, so the mute toggle covers it. To add a soundscape for a new arc, add a `_<skin>(bus)` builder
114
  and branch to it in `play()`.
115
 
116
  ## UI conventions
 
136
 
137
  ## Working conventions (IMPORTANT)
138
 
139
+ - **Commits/PRs are authored solely by the repo owner.** Use the owner's git
140
+ identity already recorded in the repo: read it with
141
+ `git log -1 --format='%an <%ae>'`. Never add Claude as author or co-author, and
142
+ never add Claude/Co-Authored-By/session trailers to commits or PR bodies.
143
+ Commit with
144
+ `git -c user.name="<owner>" -c user.email="<owner-email>" commit --author="<owner> <owner-email>"`.
145
  - **Do not put any AI model identifier** in commits, code, comments, or docs.
146
+ - **License/attribution.** Apache-2.0. Copyright holder: `alvations (Melon Lab)`.
147
+ New source files carry an SPDX header (`SPDX-License-Identifier: Apache-2.0`)
148
+ plus the copyright line; never remove `LICENSE`, `NOTICE`, or existing headers.
149
  - **Do not reference the source film or any external IP** anywhere in the repo
150
  (docs, code, comments, or commit messages). Keep the game's own naming.
151
  - **Never spoil the game in the instructions/intros.** State the rule generically
NOTICE ADDED
@@ -0,0 +1,9 @@
 
 
 
 
 
 
 
 
 
 
1
+ Hallway 8
2
+ Copyright 2026 alvations (Melon Lab)
3
+
4
+ This product includes software authored by alvations, Melon Lab.
5
+
6
+ Licensed under the Apache License, Version 2.0 (the "License"); see the LICENSE
7
+ file. Unless required by applicable law or agreed to in writing, software
8
+ distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
9
+ WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
README.md CHANGED
@@ -46,7 +46,9 @@ feels too long, a calm that shouldn't be there).
46
  Arcs live as self-contained JSON files in `data/arcs/`. Adding a new one is just
47
  dropping in another file (see *Adding an arc* below) — no code changes needed.
48
  The design rules and the queue of future arcs are kept in
49
- [`docs/ARCS.md`](docs/ARCS.md).
 
 
50
 
51
  ## What makes it unsettling
52
 
@@ -269,3 +271,20 @@ self-contained:
269
  To give the arc its own colours, add a `body[data-skin="<name>"]` block in
270
  `static/style.css`. The new arc appears automatically on the opening screen —
271
  no code changes required.
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
46
  Arcs live as self-contained JSON files in `data/arcs/`. Adding a new one is just
47
  dropping in another file (see *Adding an arc* below) — no code changes needed.
48
  The design rules and the queue of future arcs are kept in
49
+ [`docs/ARCS.md`](docs/ARCS.md). For how this game was built end to end with an AI
50
+ coding agent (the method, the verify-loop, the gotchas, and a replication
51
+ checklist), see [`docs/MAKING_OF.md`](docs/MAKING_OF.md).
52
 
53
  ## What makes it unsettling
54
 
 
271
  To give the arc its own colours, add a `body[data-skin="<name>"]` block in
272
  `static/style.css`. The new arc appears automatically on the opening screen —
273
  no code changes required.
274
+
275
+ ---
276
+
277
+ ## License and attribution
278
+
279
+ Hallway 8 is licensed under the **Apache License, Version 2.0**. See
280
+ [`LICENSE`](LICENSE) for the full text.
281
+
282
+ Copyright 2026 **alvations** (Melon Lab).
283
+
284
+ Author: alvations. Affiliation: Melon Lab. Attribution notices are in the
285
+ [`NOTICE`](NOTICE) file; per the license, redistributions and derivative works
286
+ must retain that `NOTICE`. Each source file also carries an SPDX header
287
+ (`SPDX-License-Identifier: Apache-2.0`) and the copyright line.
288
+
289
+ When contributing, keep your commit author identity as the copyright holder and
290
+ do not remove the license headers or the `NOTICE`.
anomalies.py CHANGED
@@ -1,3 +1,5 @@
 
 
1
  """Anomaly selection and the small "drift" tricks.
2
 
3
  Nothing here screams. An anomaly is a single property quietly swapped for a
 
1
+ # SPDX-License-Identifier: Apache-2.0
2
+ # Copyright 2026 alvations (Melon Lab)
3
  """Anomaly selection and the small "drift" tricks.
4
 
5
  Nothing here screams. An anomaly is a single property quietly swapped for a
app.py CHANGED
@@ -1,3 +1,5 @@
 
 
1
  """A tiny Flask server for the memory-loop game.
2
 
3
  Run: python app.py then open http://127.0.0.1:5000
 
1
+ # SPDX-License-Identifier: Apache-2.0
2
+ # Copyright 2026 alvations (Melon Lab)
3
  """A tiny Flask server for the memory-loop game.
4
 
5
  Run: python app.py then open http://127.0.0.1:5000
docs/MAKING_OF.md ADDED
@@ -0,0 +1,244 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Building Hallway 8 with Claude Code
2
+
3
+ This is the method behind the game: how it was designed and built end to end
4
+ through a conversation with an AI coding agent (Claude Code), how each feature
5
+ was verified before it shipped, and the gotchas that cost time so you can skip
6
+ them. If you want to reproduce *this* game, read the README. If you want to
7
+ reproduce the *process* on a game of your own, read this.
8
+
9
+ The whole thing was built by talking to the agent in plain language, one change
10
+ at a time, and having it write the code, run it, screenshot it, test it, and
11
+ commit it. There was no separate design doc up front; the design emerged in the
12
+ first few messages and then got refined by playing.
13
+
14
+ ---
15
+
16
+ ## 0. Setup that made it work
17
+
18
+ A few things were decided once, early, and paid off every turn:
19
+
20
+ - **A tiny, no-build stack.** Python + Flask backend, vanilla HTML/CSS/JS
21
+ frontend, JSON for content. No bundler, no framework, no npm. The agent can
22
+ edit any file and immediately run the app, which keeps the feedback loop fast.
23
+ - **A `CLAUDE.md` at the repo root.** This is the agent's standing memory: the
24
+ stack, the core mechanic, the conventions (see below), and "keep it this way"
25
+ notes next to load-bearing decisions. Every session reads it first, so you
26
+ don't re-explain the project or re-litigate settled choices.
27
+ - **Explicit conventions, stated once.** Commit authorship, a no-spoilers rule,
28
+ no em-dashes, colour constraints. Written down, they get honoured for free.
29
+ - **A test runner with zero dependencies** (`python tests/test_game.py`) and a
30
+ **headless browser** (Chromium via Playwright) for UI verification. The agent
31
+ can prove a change works instead of guessing.
32
+
33
+ The single highest-leverage artifact is `CLAUDE.md`. Start it on day one and
34
+ update it whenever a decision becomes load-bearing.
35
+
36
+ ---
37
+
38
+ ## 1. The design conversation
39
+
40
+ The game started as a discussion, not a spec: *how do you make a "spot the
41
+ change" loop that is about doubting your memory rather than hunting for a weird
42
+ sentence?* That produced a small set of principles that became the mechanic:
43
+
44
+ - Never describe everything; show only a subset of details each loop.
45
+ - Change *meaning*, not wording. Draw each detail from a pool of interchangeable
46
+ sentences so players can't diff two loops literally.
47
+ - One quiet mutation per changed loop, never signposted.
48
+ - Frame it as a memory test, not "find the anomaly."
49
+
50
+ Only after the mechanic felt right did any code get written. Lesson: **spend the
51
+ first messages on the core loop and its framing, not on architecture.**
52
+
53
+ ---
54
+
55
+ ## 2. The working loop
56
+
57
+ Every feature, from the MVP to the audio, followed the same rhythm:
58
+
59
+ 1. **Ask in plain language** for one change ("add an arc about taking the
60
+ stairs", "the top bar is hidden on mobile", "make the crackle rarer").
61
+ 2. **The agent implements it** across whatever files it touches.
62
+ 3. **It verifies before claiming success:**
63
+ - runs `python tests/test_game.py`,
64
+ - drives the real app in headless Chromium and screenshots the result,
65
+ - for logic, plays the game against the server's own answer to prove
66
+ win/lose paths.
67
+ 4. **It commits** (authored as the owner) and **pushes**.
68
+ 5. You look at the screenshot / play it, and the next message is the next
69
+ change. Bugs and polish are just more turns.
70
+
71
+ This is the important part: **the agent is expected to prove each change, not
72
+ assert it.** Screenshots and a passing test suite in the same turn as the edit
73
+ are what make it safe to move fast.
74
+
75
+ ---
76
+
77
+ ## 3. Architecture decisions (and why)
78
+
79
+ - **Server owns the truth.** `correct = (choice == "back") == has_anomaly`. The
80
+ answer lives only in server state and is stripped from every payload (keys
81
+ starting with `_` are dropped). The browser literally cannot be inspected to
82
+ cheat. Decide early what must never reach the client.
83
+ - **Content is data, not code.** Each arc is one JSON file of properties,
84
+ sentence pools, and story. New files appear on the menu automatically. This is
85
+ what let "add an arc" be a 20-minute change with no engine edits.
86
+ - **Session state keyed by a client-echoed id, not a cookie.** (See lessons.)
87
+ - **Single process, in-memory state.** Fine for this game; it dictates
88
+ single-worker deployment. Know this constraint before you scale.
89
+
90
+ ---
91
+
92
+ ## 4. Content model: arcs
93
+
94
+ An arc is a skin + backstory over the shared mechanic. The schema and the design
95
+ rules (every arc needs a strong diegetic hook, an NPC that can become the
96
+ anomaly, a loop-aware NPC twist, and a non-physical `sense` layer) live in
97
+ `docs/ARCS.md`, along with shared tuning and a queue of planned arcs. Keeping a
98
+ living design doc meant new arcs stayed consistent and we never re-derived the
99
+ rules.
100
+
101
+ The difficulty ramp and the doubt-prompt curve are engine-wide and documented
102
+ so every arc inherits them.
103
+
104
+ ---
105
+
106
+ ## 5. Procedural audio
107
+
108
+ There are no audio files in the game. Ambience is synthesised live in the
109
+ browser with the Web Audio API (`static/audio.js`): oscillators and filtered
110
+ noise per arc (fluorescent buzz, subway roll, stairwell wind), plus a muted
111
+ arpeggio for the landing. Rare events (a flicker, a passing train, a gust) are
112
+ scheduled sparsely. This kept the repo tiny and every soundscape editable as
113
+ code.
114
+
115
+ For sharing, the same synth is rendered offline (`OfflineAudioContext`) into an
116
+ OGG and a 1080p MP4 by `scripts/export_landing_media.py`, so the exported media
117
+ is exactly what players hear.
118
+
119
+ ---
120
+
121
+ ## 6. Verification discipline
122
+
123
+ What the agent actually did to trust its own work:
124
+
125
+ - **Logic:** ran the app in-process and played optimally by reading the server's
126
+ hidden answer, asserting the game wins in exactly eight correct calls and that
127
+ a fresh, cookie-less client keeps its arc (the regression test).
128
+ - **UI:** launched Chromium, drove the flow, and screenshotted the landing, each
129
+ skin, the win screens, and mobile viewports. A picture caught things a test
130
+ never would (colours too similar, HUD clipped on mobile).
131
+ - **Audio:** rendered offline and inspected the node graph / sample peaks.
132
+
133
+ If you take one habit from this project: **make the agent show you a screenshot
134
+ or a green test in the same message it says "done".**
135
+
136
+ ---
137
+
138
+ ## 7. Deployment
139
+
140
+ The game ships as a Docker Space on Hugging Face (`Dockerfile`, port 7860,
141
+ single worker). `scripts/deploy_hf.py` creates/updates the Space and uploads;
142
+ `scripts/make_private_hf.py` toggles visibility for a private-until-release flow.
143
+
144
+ Deployment is **local-only** here: the sandboxed web session's network policy
145
+ blocks `huggingface.co` (a 403 at the egress proxy), so deploys must run from a
146
+ machine with normal internet. Know your environment's network policy before you
147
+ plan a deploy.
148
+
149
+ ---
150
+
151
+ ## 8. Hard-won lessons (the gotchas)
152
+
153
+ These cost real time. Skip them:
154
+
155
+ - **Cross-site iframe drops cookies.** On Spaces the app runs in an iframe, so
156
+ the Flask session cookie was silently dropped and every request span a fresh
157
+ default slot (the arc kept reverting, progress vanished). It reproduced only
158
+ when hosted, never locally. Fix: hand the client a session id and echo it on
159
+ every request via a header; keep the cookie only as a same-origin fallback.
160
+ - **`localStorage` throws on opaque origins / private mode.** An unguarded read
161
+ in a constructor broke audio for some users (and for offline rendering). Wrap
162
+ storage access in try/catch.
163
+ - **Browser autoplay policy.** Audio can only start after a user gesture. Start
164
+ ambience on entering a loop / first interaction, and always ship a mute
165
+ toggle.
166
+ - **Chromium refuses "unsafe" ports** (e.g. 5060, 6000) for page loads. Serve
167
+ test instances on 8070+.
168
+ - **Centered flexbox clips tall content.** `align-items:center; min-height:100vh`
169
+ hides the top of anything taller than the viewport, which buried the mobile
170
+ progress bar. Top-align on mobile and let it scroll.
171
+ - **A live write token is a secret.** When a token has to be used, treat it as
172
+ exposed the moment it's pasted and rotate it after.
173
+
174
+ ---
175
+
176
+ ## 9. Writing rules (the game's voice)
177
+
178
+ - **Never spoil the game in the instructions.** State the rule generically
179
+ (proceed while it matches your memory, turn back if anything changed). Do not
180
+ name the specific detail or character a player must notice; they discover it.
181
+ - **No em-dashes.** Commas, periods, colons instead. (Arrows in sign content are
182
+ fine.)
183
+ - **Weave the rule into the story**, per arc, instead of listing it as rules.
184
+
185
+ These are enforced by writing them into `CLAUDE.md`, so every future edit obeys
186
+ them without a reminder.
187
+
188
+ ---
189
+
190
+ ## 10. Conventions worth copying
191
+
192
+ From this repo's `CLAUDE.md`, the ones that generalise:
193
+
194
+ - Commits are authored solely by the repo owner. Decide attribution up front.
195
+ - No AI model identifier in commits, code, comments, or docs.
196
+ - Develop on the branch/flow the owner authorises; commit and push when a change
197
+ is complete and verified.
198
+ - After UI changes, verify in a real browser and run the tests before committing.
199
+ - Remind the owner when a change needs a manual deploy to reach production.
200
+
201
+ ---
202
+
203
+ ## 11. Replication checklist
204
+
205
+ To build a similar game with an AI coding agent:
206
+
207
+ 1. **Write the mechanic down** in a few sentences before any code. Nail the
208
+ framing (what the player is really doing).
209
+ 2. **Create `CLAUDE.md`** with: the stack, the one-line mechanic, what must
210
+ never leak to the client, and your conventions (attribution, tone, colours).
211
+ 3. **Ask for a thin, runnable MVP** in a no-build stack. Make "run it" trivial.
212
+ 4. **Add a test runner and browser verification early.** Insist the agent proves
213
+ changes with a screenshot or a passing test in the same turn.
214
+ 5. **Model content as data** so new content is a file, not an engine change.
215
+ 6. **Iterate one change per message.** Design by playing: react to screenshots,
216
+ ask for the next tweak.
217
+ 7. **Write a living design doc** (`docs/ARCS.md`-style) once you have more than
218
+ one variant, so consistency survives.
219
+ 8. **Keep secrets out of the repo and chat**; when a token is unavoidable,
220
+ rotate it after use.
221
+ 9. **Update `CLAUDE.md` whenever a decision becomes load-bearing** (the iframe
222
+ fix, the audio design, the tuning curves all live there now).
223
+ 10. **Deploy from where the network allows it**, and script the deploy so it is
224
+ one command.
225
+
226
+ ---
227
+
228
+ ## 12. Prompt playbook
229
+
230
+ Real prompts from this build that produced good results, lightly generalised:
231
+
232
+ - "Think of the mechanic first: how do we make it *feel* like X, not Y?"
233
+ - "Build a thin, runnable version I can play."
234
+ - "Add an arc about <situation>; keep the mechanic, give it its own NPC and a
235
+ psychological twist."
236
+ - "There's a bug: <symptom>. Write a proper test that reproduces it, then fix it,
237
+ and run random walks to be sure."
238
+ - "Show me a preview of <screen>." (invites a screenshot)
239
+ - "Make the instructions relate to the backstory, and don't reveal any details."
240
+ - "Optimise for mobile; the <element> isn't visible."
241
+ - "Save everything we've learned into `CLAUDE.md`."
242
+
243
+ The through-line: describe the *goal and the feel*, let the agent choose the
244
+ implementation, and make it show you the result.
hallway.py CHANGED
@@ -1,3 +1,5 @@
 
 
1
  """The corridor: building a loop and describing it.
2
 
3
  A "room" is not a paragraph. It is a set of properties, each currently at its
 
1
+ # SPDX-License-Identifier: Apache-2.0
2
+ # Copyright 2026 alvations (Melon Lab)
3
  """The corridor: building a loop and describing it.
4
 
5
  A "room" is not a paragraph. It is a set of properties, each currently at its
memory.py CHANGED
@@ -1,3 +1,5 @@
 
 
1
  """Tracks how the player behaves across loops.
2
 
3
  The point of this module is not scoring. It is to give the game a small
 
1
+ # SPDX-License-Identifier: Apache-2.0
2
+ # Copyright 2026 alvations (Melon Lab)
3
  """Tracks how the player behaves across loops.
4
 
5
  The point of this module is not scoring. It is to give the game a small
scripts/deploy_hf.py CHANGED
@@ -1,4 +1,6 @@
1
  #!/usr/bin/env python3
 
 
2
  """Deploy this repo to a Hugging Face Docker Space.
3
 
4
  Run from a machine with normal internet access (not the sandboxed web session,
 
1
  #!/usr/bin/env python3
2
+ # SPDX-License-Identifier: Apache-2.0
3
+ # Copyright 2026 alvations (Melon Lab)
4
  """Deploy this repo to a Hugging Face Docker Space.
5
 
6
  Run from a machine with normal internet access (not the sandboxed web session,
scripts/export_landing_media.py ADDED
@@ -0,0 +1,198 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #!/usr/bin/env python3
2
+ # SPDX-License-Identifier: Apache-2.0
3
+ # Copyright 2026 alvations (Melon Lab)
4
+ """Export the landing theme to shareable media, straight from the game's synth.
5
+
6
+ The landing music is generated live in the browser (`static/audio.js`). This
7
+ script drives that same code headlessly through an OfflineAudioContext, so the
8
+ exported files are exactly what a player hears, then packages them:
9
+
10
+ - static/audio/landing.ogg a small looping-ish OGG (used as an asset)
11
+ - static/audio/landing.mp4 a 1080p still-card video for YouTube etc.
12
+
13
+ The card shows the landing screen's text and style ("8 / Somewhere you can't
14
+ quite leave. / Choose a way through.").
15
+
16
+ Run locally (not in the sandboxed web session):
17
+
18
+ pip install playwright imageio-ffmpeg soundfile numpy
19
+ playwright install chromium
20
+ python scripts/export_landing_media.py # both files
21
+ python scripts/export_landing_media.py --seconds 300 # longer video
22
+ python scripts/export_landing_media.py --only mp4 # or: ogg
23
+
24
+ Nothing here needs the Flask server: audio.js is loaded directly.
25
+
26
+ NOTE: the arpeggio constants below mirror `_landing` in static/audio.js. If you
27
+ retune the landing theme there, mirror the change here (or vice versa).
28
+ """
29
+
30
+ from __future__ import annotations
31
+
32
+ import argparse
33
+ import base64
34
+ import os
35
+ import sys
36
+
37
+ import numpy as np
38
+ import soundfile as sf
39
+
40
+ ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
41
+ AUDIO_JS = os.path.join(ROOT, "static", "audio.js")
42
+ OUT_DIR = os.path.join(ROOT, "static", "audio")
43
+ SR = 44100
44
+
45
+ # The landing card: the select screen's text and style, sized for 1080p.
46
+ CARD_HTML = """
47
+ <!doctype html><html><head><meta charset=utf-8><style>
48
+ html,body{margin:0;height:100%}
49
+ body{width:1920px;height:1080px;display:flex;align-items:center;justify-content:center;
50
+ background:radial-gradient(120% 90% at 50% -12%, hsl(212 8% 10%), #0b0c0e 72%);
51
+ color:#d7d7d2;font-family:"Iowan Old Style","Palatino Linotype",Palatino,Georgia,'DejaVu Serif',serif;}
52
+ .wrap{text-align:center}
53
+ .title{font-size:10rem;font-weight:300;letter-spacing:.18em;margin:0 0 2.4rem;color:#e6e6e1}
54
+ .lead{color:#7d8088;font-size:2rem;line-height:2.4}
55
+ </style></head><body>
56
+ <div class=wrap>
57
+ <div class=title>8</div>
58
+ <div class=lead>Somewhere you can't quite leave.<br>Choose a way through.</div>
59
+ </div>
60
+ </body></html>
61
+ """
62
+
63
+ # Renders the landing theme to a mono WAV blob and downloads it. The synthesis
64
+ # reuses audio.js helpers (window.ambience.constructor); only the scheduling is
65
+ # unrolled here (audio.js schedules with setTimeout, which offline render skips).
66
+ AUDIO_JS_RENDER = """
67
+ async ([SR, dur, fadeIn, fadeOut]) => {
68
+ const wr = (dv,o,s)=>{ for(let i=0;i<s.length;i++) dv.setUint8(o+i, s.charCodeAt(i)); };
69
+ const Amb = window.ambience.constructor; const amb = new Amb();
70
+ const off = new OfflineAudioContext(1, Math.floor(SR*dur), SR);
71
+ amb.ctx = off;
72
+ const bus = off.createGain(); bus.gain.value=1; bus.connect(off.destination);
73
+ const verb = amb._reverb(3.2,2.6); const verbG = amb._gain(0.5); verb.connect(verbG).connect(bus);
74
+ const voice = amb._gain(0.042); voice.connect(bus); // mirrors _landing
75
+ const padG = amb._gain(0.0126); padG.connect(bus);
76
+ const pad = amb._noise(4); pad.connect(amb._filter('lowpass',240,0.5)).connect(padG); pad.start(0);
77
+ const chords=[[57,60,64],[53,57,60,64],[48,52,55,60],[55,59,62]]; // vi-IV-I-V
78
+ const step=0.22, ring=0.95, perChord=12;
79
+ let idx=0, t=0.05;
80
+ while (t < dur-0.02){
81
+ const c=chords[Math.floor(idx/perChord)%chords.length];
82
+ const tones=c.concat(c.map(m=>m+12));
83
+ const seq=tones.concat(tones.slice(1,-1).reverse());
84
+ amb._pluck(voice, verb, amb._mtof(seq[idx%seq.length]), t, ring);
85
+ t+=step; idx++;
86
+ }
87
+ const buf = await off.startRendering();
88
+ const d = buf.getChannelData(0);
89
+ let peak=0; for(let i=0;i<d.length;i++){const a=Math.abs(d[i]); if(a>peak)peak=a;}
90
+ const g = peak? 0.89/peak : 1; // normalise for standalone playback
91
+ const N=d.length, fi=Math.floor(SR*fadeIn), fo=Math.floor(SR*fadeOut);
92
+ const ab=new ArrayBuffer(44+N*2); const dv=new DataView(ab);
93
+ wr(dv,0,'RIFF'); dv.setUint32(4,36+N*2,true); wr(dv,8,'WAVE'); wr(dv,12,'fmt ');
94
+ dv.setUint32(16,16,true); dv.setUint16(20,1,true); dv.setUint16(22,1,true);
95
+ dv.setUint32(24,SR,true); dv.setUint32(28,SR*2,true); dv.setUint16(32,2,true); dv.setUint16(34,16,true);
96
+ wr(dv,36,'data'); dv.setUint32(40,N*2,true);
97
+ let o=44;
98
+ for(let i=0;i<N;i++){ let s=d[i]*g; if(i<fi)s*=i/fi; if(i>=N-fo)s*=(N-i)/fo;
99
+ s=Math.max(-1,Math.min(1,s)); dv.setInt16(o,s*32767,true); o+=2; }
100
+ const blob=new Blob([ab],{type:'audio/wav'}); const url=URL.createObjectURL(blob);
101
+ const a=document.createElement('a'); a.href=url; a.download='landing.wav';
102
+ document.body.appendChild(a); a.click();
103
+ return N;
104
+ }
105
+ """
106
+
107
+
108
+ def _launch(p):
109
+ """Launch Chromium, tolerating this repo's sandbox layout or a normal install."""
110
+ import glob
111
+ try:
112
+ return p.chromium.launch()
113
+ except Exception:
114
+ hits = sorted(glob.glob("/opt/pw-browsers/chromium-*/chrome-linux/chrome"))
115
+ if not hits:
116
+ raise
117
+ return p.chromium.launch(executable_path=hits[-1])
118
+
119
+
120
+ def render_wav(page, seconds, fade_in, fade_out, dest):
121
+ page.set_content("<!doctype html><html><body></body></html>")
122
+ page.add_script_tag(path=AUDIO_JS)
123
+ with page.expect_download(timeout=120000) as di:
124
+ page.evaluate(AUDIO_JS_RENDER, [SR, seconds, fade_in, fade_out])
125
+ di.value.save_as(dest)
126
+ return dest
127
+
128
+
129
+ def render_card(page, dest):
130
+ page.set_content(CARD_HTML)
131
+ page.wait_for_timeout(300)
132
+ page.screenshot(path=dest)
133
+ return dest
134
+
135
+
136
+ def wav_to_ogg(wav_path, ogg_path):
137
+ data, sr = sf.read(wav_path)
138
+ sf.write(ogg_path, data, sr, format="OGG", subtype="VORBIS")
139
+
140
+
141
+ def mux_video(card_png, wav_path, out_mp4, fps=24):
142
+ import subprocess
143
+ import imageio_ffmpeg
144
+
145
+ ff = imageio_ffmpeg.get_ffmpeg_exe()
146
+ subprocess.run(
147
+ [ff, "-y", "-hide_banner", "-loglevel", "error",
148
+ "-loop", "1", "-framerate", str(fps), "-i", card_png,
149
+ "-i", wav_path,
150
+ "-c:v", "libx264", "-tune", "stillimage", "-preset", "medium",
151
+ "-pix_fmt", "yuv420p", "-r", str(fps),
152
+ "-c:a", "aac", "-b:a", "192k", "-ar", str(SR),
153
+ "-shortest", "-movflags", "+faststart", out_mp4],
154
+ check=True,
155
+ )
156
+
157
+
158
+ def main():
159
+ ap = argparse.ArgumentParser(description=__doc__)
160
+ ap.add_argument("--seconds", type=float, default=120, help="video length (s)")
161
+ ap.add_argument("--ogg-seconds", type=float, default=42.24, help="ogg length (s)")
162
+ ap.add_argument("--only", choices=["ogg", "mp4"], help="produce just one")
163
+ ap.add_argument("--out-dir", default=OUT_DIR)
164
+ args = ap.parse_args()
165
+
166
+ os.makedirs(args.out_dir, exist_ok=True)
167
+ tmp = os.path.join(args.out_dir, "_landing_tmp.wav")
168
+ card = os.path.join(args.out_dir, "_landing_card.png")
169
+
170
+ from playwright.sync_api import sync_playwright
171
+
172
+ with sync_playwright() as p:
173
+ browser = _launch(p)
174
+ ctx = browser.new_context(
175
+ viewport={"width": 1920, "height": 1080}, accept_downloads=True
176
+ )
177
+ page = ctx.new_page()
178
+
179
+ if args.only != "mp4":
180
+ render_wav(page, args.ogg_seconds, 0.4, 0.6, tmp)
181
+ wav_to_ogg(tmp, os.path.join(args.out_dir, "landing.ogg"))
182
+ print("wrote landing.ogg")
183
+
184
+ if args.only != "ogg":
185
+ render_wav(page, args.seconds, 0.6, 1.8, tmp)
186
+ render_card(page, card)
187
+ mux_video(card, tmp, os.path.join(args.out_dir, "landing.mp4"))
188
+ print("wrote landing.mp4")
189
+
190
+ browser.close()
191
+
192
+ for f in (tmp, card):
193
+ if os.path.exists(f):
194
+ os.remove(f)
195
+
196
+
197
+ if __name__ == "__main__":
198
+ sys.exit(main())
scripts/make_private_hf.py CHANGED
@@ -1,4 +1,6 @@
1
  #!/usr/bin/env python3
 
 
2
  """Toggle the visibility of the Hugging Face Space.
3
 
4
  Keep it private until release, then flip it public on the day.
 
1
  #!/usr/bin/env python3
2
+ # SPDX-License-Identifier: Apache-2.0
3
+ # Copyright 2026 alvations (Melon Lab)
4
  """Toggle the visibility of the Hugging Face Space.
5
 
6
  Keep it private until release, then flip it public on the day.
static/audio.js CHANGED
@@ -1,3 +1,6 @@
 
 
 
1
  /* Procedural ambience, generated live with the Web Audio API.
2
  *
3
  * No audio files: each arc's soundscape is synthesised from oscillators and
@@ -18,7 +21,11 @@ class Ambience {
18
  this.bus = null;
19
  this.sources = [];
20
  this.timers = [];
21
- this.muted = localStorage.getItem("h8_muted") === "1";
 
 
 
 
22
  }
23
 
24
  _ensure() {
@@ -89,6 +96,14 @@ class Ambience {
89
  return 440 * Math.pow(2, (m - 69) / 12);
90
  }
91
 
 
 
 
 
 
 
 
 
92
  // one soft, ringing arpeggio note (bell/harp-like) at a scheduled time
93
  _pluck(out, verb, freq, t, dur) {
94
  const o = this.ctx.createOscillator();
@@ -286,6 +301,77 @@ class Ambience {
286
  tick();
287
  }
288
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
289
  // --- controls ------------------------------------------------------------
290
 
291
  play(skin) {
@@ -332,7 +418,9 @@ class Ambience {
332
 
333
  setMuted(m) {
334
  this.muted = m;
335
- localStorage.setItem("h8_muted", m ? "1" : "0");
 
 
336
  if (this.master && this.ctx) {
337
  const t = this.ctx.currentTime;
338
  this.master.gain.cancelScheduledValues(t);
 
1
+ /* SPDX-License-Identifier: Apache-2.0
2
+ * Copyright 2026 alvations (Melon Lab)
3
+ */
4
  /* Procedural ambience, generated live with the Web Audio API.
5
  *
6
  * No audio files: each arc's soundscape is synthesised from oscillators and
 
21
  this.bus = null;
22
  this.sources = [];
23
  this.timers = [];
24
+ let muted = false;
25
+ try {
26
+ muted = localStorage.getItem("h8_muted") === "1";
27
+ } catch (e) {} // localStorage can throw on opaque origins / private mode
28
+ this.muted = muted;
29
  }
30
 
31
  _ensure() {
 
96
  return 440 * Math.pow(2, (m - 69) / 12);
97
  }
98
 
99
+ // A short noise source that is NOT tracked (one-shot SFX manage their own
100
+ // lifetime), so repeated sound effects don't grow the sources list.
101
+ _noiseOneShot(seconds) {
102
+ const s = this.ctx.createBufferSource();
103
+ s.buffer = this._noiseBuffer(seconds || 0.3);
104
+ return s;
105
+ }
106
+
107
  // one soft, ringing arpeggio note (bell/harp-like) at a scheduled time
108
  _pluck(out, verb, freq, t, dur) {
109
  const o = this.ctx.createOscillator();
 
301
  tick();
302
  }
303
 
304
+ // --- action sound effects -----------------------------------------------
305
+ // A footstep: a soft low heel-thud plus a short scuff. Routed through the
306
+ // master gain so the mute toggle covers it.
307
+ _footstep(t, gain, pitch, bright) {
308
+ const out = this.master;
309
+ const o = this.ctx.createOscillator();
310
+ o.type = "triangle";
311
+ const base = 110 * pitch;
312
+ o.frequency.setValueAtTime(base * 1.7, t);
313
+ o.frequency.exponentialRampToValueAtTime(base * 0.7, t + 0.07);
314
+ const g = this._gain(0.0001);
315
+ o.connect(g).connect(out);
316
+ g.gain.setValueAtTime(0.0001, t);
317
+ g.gain.linearRampToValueAtTime(gain, t + 0.006);
318
+ g.gain.exponentialRampToValueAtTime(0.0006, t + 0.13);
319
+ o.start(t);
320
+ o.stop(t + 0.17);
321
+
322
+ const n = this._noiseOneShot(0.2);
323
+ const bp = this._filter("bandpass", 1600 * bright, 0.8);
324
+ const ng = this._gain(0.0001);
325
+ n.connect(bp).connect(ng).connect(out);
326
+ ng.gain.setValueAtTime(0.0001, t);
327
+ ng.gain.linearRampToValueAtTime(gain * 0.5, t + 0.004);
328
+ ng.gain.exponentialRampToValueAtTime(0.0004, t + 0.05);
329
+ n.start(t);
330
+ n.stop(t + 0.12);
331
+ }
332
+
333
+ // A brief directional swish, a nod to an old-school scene-change: sweeps up
334
+ // when moving on, down when turning back.
335
+ _swish(t, back) {
336
+ const n = this._noiseOneShot(0.5);
337
+ const bp = this._filter("bandpass", back ? 1600 : 700, 0.7);
338
+ const g = this._gain(0.0001);
339
+ n.connect(bp).connect(g).connect(this.master);
340
+ bp.frequency.setValueAtTime(back ? 1600 : 700, t);
341
+ bp.frequency.exponentialRampToValueAtTime(back ? 600 : 1800, t + 0.28);
342
+ g.gain.setValueAtTime(0.0001, t);
343
+ g.gain.linearRampToValueAtTime(0.045, t + 0.06);
344
+ g.gain.exponentialRampToValueAtTime(0.0004, t + 0.3);
345
+ n.start(t);
346
+ n.stop(t + 0.4);
347
+ }
348
+
349
+ // Play the movement sound for a committed choice. `dwellMs` is how long the
350
+ // player lingered before pressing: a quick, confident press gets real
351
+ // footsteps; a long deliberation softens to a tiptoe, then to near silence.
352
+ step(dir, dwellMs) {
353
+ this._ensure();
354
+ if (!this.ctx) return;
355
+ if (this.ctx.state === "suspended") this.ctx.resume();
356
+ const t = this.ctx.currentTime + 0.01;
357
+ const back = dir === "back";
358
+ const pitch = back ? 0.82 : 1.0; // turning back sits a touch lower
359
+
360
+ if (dwellMs < 2200) {
361
+ // confident: two firm steps + a scene-change swish
362
+ this._footstep(t, 0.22, pitch, 1.0);
363
+ this._footstep(t + 0.19, 0.19, pitch * 1.03, 1.05);
364
+ this._swish(t, back);
365
+ } else if (dwellMs < 5000) {
366
+ // hesitant: light, slower tiptoe
367
+ this._footstep(t, 0.08, pitch * 1.15, 1.4);
368
+ this._footstep(t + 0.27, 0.06, pitch * 1.18, 1.45);
369
+ } else {
370
+ // long deliberation: a single, near-silent shuffle
371
+ this._footstep(t, 0.03, pitch * 1.2, 1.6);
372
+ }
373
+ }
374
+
375
  // --- controls ------------------------------------------------------------
376
 
377
  play(skin) {
 
418
 
419
  setMuted(m) {
420
  this.muted = m;
421
+ try {
422
+ localStorage.setItem("h8_muted", m ? "1" : "0");
423
+ } catch (e) {}
424
  if (this.master && this.ctx) {
425
  const t = this.ctx.currentTime;
426
  this.master.gain.cancelScheduledValues(t);
static/audio/landing.mp4 ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:eb0c5cc7f0c2a84c8b83a731bff60bb646def0d55983397863b4c9828d487b81
3
+ size 3080185
static/audio/landing.ogg CHANGED
@@ -1,3 +1,3 @@
1
  version https://git-lfs.github.com/spec/v1
2
- oid sha256:c3848234bf5e8eb9b66d050cd8c17ebd155bf651066719c30fd6baa279bffbc9
3
- size 295875
 
1
  version https://git-lfs.github.com/spec/v1
2
+ oid sha256:f14f4becbae11c74fbb35f97f38948bba35995da5588d93b95c5486f4de00db1
3
+ size 295547
static/game.js CHANGED
@@ -1,3 +1,6 @@
 
 
 
1
  /* Eight -- client.
2
  *
3
  * The server owns the truth (whether a place changed) and ships several arcs
@@ -38,6 +41,7 @@ let meta = null; // current arc's presentation meta
38
  let selectedArc = null; // arc id in play
39
  let pendingChoice = null; // choice awaiting a confidence rating
40
  let hesitated = false; // player already admitted doubt this turn (one re-decide)
 
41
  let drift = 0; // slowly accumulating presentation drift
42
 
43
  // --- helpers ---------------------------------------------------------------
@@ -183,6 +187,7 @@ async function renderRoom(p) {
183
 
184
  await sleep(400);
185
  el.controls.classList.remove("hidden");
 
186
  }
187
 
188
  // Lingering on a detail may earn an unsettling aside from the server.
@@ -279,6 +284,11 @@ const HESITATION = [
279
 
280
  // Commit the pending choice to the server and move on.
281
  async function commit(confidence) {
 
 
 
 
 
282
  el.confidence.classList.add("hidden");
283
  const p = await post("/api/act", { choice: pendingChoice, confidence });
284
  pendingChoice = null;
 
1
+ /* SPDX-License-Identifier: Apache-2.0
2
+ * Copyright 2026 alvations (Melon Lab)
3
+ */
4
  /* Eight -- client.
5
  *
6
  * The server owns the truth (whether a place changed) and ships several arcs
 
41
  let selectedArc = null; // arc id in play
42
  let pendingChoice = null; // choice awaiting a confidence rating
43
  let hesitated = false; // player already admitted doubt this turn (one re-decide)
44
+ let decideStart = 0; // when the controls appeared, to time the player's dwell
45
  let drift = 0; // slowly accumulating presentation drift
46
 
47
  // --- helpers ---------------------------------------------------------------
 
187
 
188
  await sleep(400);
189
  el.controls.classList.remove("hidden");
190
+ decideStart = performance.now(); // start timing the player's deliberation
191
  }
192
 
193
  // Lingering on a detail may earn an unsettling aside from the server.
 
284
 
285
  // Commit the pending choice to the server and move on.
286
  async function commit(confidence) {
287
+ // Movement sound, keyed to how long the player lingered: a quick, confident
288
+ // press gets footsteps; a long deliberation softens toward silence.
289
+ if (window.ambience) {
290
+ window.ambience.step(pendingChoice, performance.now() - decideStart);
291
+ }
292
  el.confidence.classList.add("hidden");
293
  const p = await post("/api/act", { choice: pendingChoice, confidence });
294
  pendingChoice = null;
static/style.css CHANGED
@@ -1,3 +1,6 @@
 
 
 
1
  :root {
2
  --bg: #0b0c0e;
3
  --fg: #d7d7d2;
 
1
+ /* SPDX-License-Identifier: Apache-2.0
2
+ * Copyright 2026 alvations (Melon Lab)
3
+ */
4
  :root {
5
  --bg: #0b0c0e;
6
  --fg: #d7d7d2;
tests/test_game.py CHANGED
@@ -1,3 +1,5 @@
 
 
1
  """Tests for the memory-loop game.
2
 
3
  Runnable two ways:
 
1
+ # SPDX-License-Identifier: Apache-2.0
2
+ # Copyright 2026 alvations (Melon Lab)
3
  """Tests for the memory-loop game.
4
 
5
  Runnable two ways: