Deploy Hallway 8 (multi-arc memory game)
Browse files- .gitattributes +1 -0
- CLAUDE.md +18 -5
- NOTICE +9 -0
- README.md +20 -1
- anomalies.py +2 -0
- app.py +2 -0
- docs/MAKING_OF.md +244 -0
- hallway.py +2 -0
- memory.py +2 -0
- scripts/deploy_hf.py +2 -0
- scripts/export_landing_media.py +198 -0
- scripts/make_private_hf.py +2 -0
- static/audio.js +90 -2
- static/audio/landing.mp4 +3 -0
- static/audio/landing.ogg +2 -2
- static/game.js +10 -0
- static/style.css +3 -0
- tests/test_game.py +2 -0
.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).
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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**
|
| 132 |
-
|
| 133 |
-
|
| 134 |
-
|
|
|
|
|
|
|
| 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 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
|
|
|
|
|
|
| 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:
|
| 3 |
-
size
|
|
|
|
| 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:
|