alvations commited on
Commit
161d0a9
·
verified ·
1 Parent(s): d952ac9

Deploy Hallway 8 (multi-arc memory game)

Browse files
Dockerfile ADDED
@@ -0,0 +1,25 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Container image for hosting the game (works as-is on Hugging Face Spaces
2
+ # with the Docker SDK, which expects the app on port 7860).
3
+ FROM python:3.11-slim
4
+
5
+ # Hugging Face Spaces runs containers as a non-root user (uid 1000).
6
+ RUN useradd -m -u 1000 user
7
+ USER user
8
+ ENV HOME=/home/user \
9
+ PATH=/home/user/.local/bin:$PATH \
10
+ HOST=0.0.0.0 \
11
+ PORT=7860
12
+
13
+ WORKDIR /app
14
+
15
+ COPY --chown=user requirements.txt .
16
+ RUN pip install --no-cache-dir --user -r requirements.txt
17
+
18
+ COPY --chown=user . .
19
+
20
+ EXPOSE 7860
21
+
22
+ # IMPORTANT: a single worker. Game state (and each corridor's answer) lives in
23
+ # memory in one process; multiple workers would split that state and break runs.
24
+ # Threads give plenty of concurrency for this lightweight app.
25
+ CMD ["gunicorn", "-w", "1", "--threads", "8", "-b", "0.0.0.0:7860", "app:app"]
LICENSE ADDED
@@ -0,0 +1,201 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ Apache License
2
+ Version 2.0, January 2004
3
+ http://www.apache.org/licenses/
4
+
5
+ TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
6
+
7
+ 1. Definitions.
8
+
9
+ "License" shall mean the terms and conditions for use, reproduction,
10
+ and distribution as defined by Sections 1 through 9 of this document.
11
+
12
+ "Licensor" shall mean the copyright owner or entity authorized by
13
+ the copyright owner that is granting the License.
14
+
15
+ "Legal Entity" shall mean the union of the acting entity and all
16
+ other entities that control, are controlled by, or are under common
17
+ control with that entity. For the purposes of this definition,
18
+ "control" means (i) the power, direct or indirect, to cause the
19
+ direction or management of such entity, whether by contract or
20
+ otherwise, or (ii) ownership of fifty percent (50%) or more of the
21
+ outstanding shares, or (iii) beneficial ownership of such entity.
22
+
23
+ "You" (or "Your") shall mean an individual or Legal Entity
24
+ exercising permissions granted by this License.
25
+
26
+ "Source" form shall mean the preferred form for making modifications,
27
+ including but not limited to software source code, documentation
28
+ source, and configuration files.
29
+
30
+ "Object" form shall mean any form resulting from mechanical
31
+ transformation or translation of a Source form, including but
32
+ not limited to compiled object code, generated documentation,
33
+ and conversions to other media types.
34
+
35
+ "Work" shall mean the work of authorship, whether in Source or
36
+ Object form, made available under the License, as indicated by a
37
+ copyright notice that is included in or attached to the work
38
+ (an example is provided in the Appendix below).
39
+
40
+ "Derivative Works" shall mean any work, whether in Source or Object
41
+ form, that is based on (or derived from) the Work and for which the
42
+ editorial revisions, annotations, elaborations, or other modifications
43
+ represent, as a whole, an original work of authorship. For the purposes
44
+ of this License, Derivative Works shall not include works that remain
45
+ separable from, or merely link (or bind by name) to the interfaces of,
46
+ the Work and Derivative Works thereof.
47
+
48
+ "Contribution" shall mean any work of authorship, including
49
+ the original version of the Work and any modifications or additions
50
+ to that Work or Derivative Works thereof, that is intentionally
51
+ submitted to Licensor for inclusion in the Work by the copyright owner
52
+ or by an individual or Legal Entity authorized to submit on behalf of
53
+ the copyright owner. For the purposes of this definition, "submitted"
54
+ means any form of electronic, verbal, or written communication sent
55
+ to the Licensor or its representatives, including but not limited to
56
+ communication on electronic mailing lists, source code control systems,
57
+ and issue tracking systems that are managed by, or on behalf of, the
58
+ Licensor for the purpose of discussing and improving the Work, but
59
+ excluding communication that is conspicuously marked or otherwise
60
+ designated in writing by the copyright owner as "Not a Contribution."
61
+
62
+ "Contributor" shall mean Licensor and any individual or Legal Entity
63
+ on behalf of whom a Contribution has been received by Licensor and
64
+ subsequently incorporated within the Work.
65
+
66
+ 2. Grant of Copyright License. Subject to the terms and conditions of
67
+ this License, each Contributor hereby grants to You a perpetual,
68
+ worldwide, non-exclusive, no-charge, royalty-free, irrevocable
69
+ copyright license to reproduce, prepare Derivative Works of,
70
+ publicly display, publicly perform, sublicense, and distribute the
71
+ Work and such Derivative Works in Source or Object form.
72
+
73
+ 3. Grant of Patent License. Subject to the terms and conditions of
74
+ this License, each Contributor hereby grants to You a perpetual,
75
+ worldwide, non-exclusive, no-charge, royalty-free, irrevocable
76
+ (except as stated in this section) patent license to make, have made,
77
+ use, offer to sell, sell, import, and otherwise transfer the Work,
78
+ where such license applies only to those patent claims licensable
79
+ by such Contributor that are necessarily infringed by their
80
+ Contribution(s) alone or by combination of their Contribution(s)
81
+ with the Work to which such Contribution(s) was submitted. If You
82
+ institute patent litigation against any entity (including a
83
+ cross-claim or counterclaim in a lawsuit) alleging that the Work
84
+ or a Contribution incorporated within the Work constitutes direct
85
+ or contributory patent infringement, then any patent licenses
86
+ granted to You under this License for that Work shall terminate
87
+ as of the date such litigation is filed.
88
+
89
+ 4. Redistribution. You may reproduce and distribute copies of the
90
+ Work or Derivative Works thereof in any medium, with or without
91
+ modifications, and in Source or Object form, provided that You
92
+ meet the following conditions:
93
+
94
+ (a) You must give any other recipients of the Work or
95
+ Derivative Works a copy of this License; and
96
+
97
+ (b) You must cause any modified files to carry prominent notices
98
+ stating that You changed the files; and
99
+
100
+ (c) You must retain, in the Source form of any Derivative Works
101
+ that You distribute, all copyright, patent, trademark, and
102
+ attribution notices from the Source form of the Work,
103
+ excluding those notices that do not pertain to any part of
104
+ the Derivative Works; and
105
+
106
+ (d) If the Work includes a "NOTICE" text file as part of its
107
+ distribution, then any Derivative Works that You distribute must
108
+ include a readable copy of the attribution notices contained
109
+ within such NOTICE file, excluding those notices that do not
110
+ pertain to any part of the Derivative Works, in at least one
111
+ of the following places: within a NOTICE text file distributed
112
+ as part of the Derivative Works; within the Source form or
113
+ documentation, if provided along with the Derivative Works; or,
114
+ within a display generated by the Derivative Works, if and
115
+ wherever such third-party notices normally appear. The contents
116
+ of the NOTICE file are for informational purposes only and
117
+ do not modify the License. You may add Your own attribution
118
+ notices within Derivative Works that You distribute, alongside
119
+ or as an addendum to the NOTICE text from the Work, provided
120
+ that such additional attribution notices cannot be construed
121
+ as modifying the License.
122
+
123
+ You may add Your own copyright statement to Your modifications and
124
+ may provide additional or different license terms and conditions
125
+ for use, reproduction, or distribution of Your modifications, or
126
+ for any such Derivative Works as a whole, provided Your use,
127
+ reproduction, and distribution of the Work otherwise complies with
128
+ the conditions stated in this License.
129
+
130
+ 5. Submission of Contributions. Unless You explicitly state otherwise,
131
+ any Contribution intentionally submitted for inclusion in the Work
132
+ by You to the Licensor shall be under the terms and conditions of
133
+ this License, without any additional terms or conditions.
134
+ Notwithstanding the above, nothing herein shall supersede or modify
135
+ the terms of any separate license agreement you may have executed
136
+ with Licensor regarding such Contributions.
137
+
138
+ 6. Trademarks. This License does not grant permission to use the trade
139
+ names, trademarks, service marks, or product names of the Licensor,
140
+ except as required for reasonable and customary use in describing the
141
+ origin of the Work and reproducing the content of the NOTICE file.
142
+
143
+ 7. Disclaimer of Warranty. Unless required by applicable law or
144
+ agreed to in writing, Licensor provides the Work (and each
145
+ Contributor provides its Contributions) on an "AS IS" BASIS,
146
+ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
147
+ implied, including, without limitation, any warranties or conditions
148
+ of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
149
+ PARTICULAR PURPOSE. You are solely responsible for determining the
150
+ appropriateness of using or redistributing the Work and assume any
151
+ risks associated with Your exercise of permissions under this License.
152
+
153
+ 8. Limitation of Liability. In no event and under no legal theory,
154
+ whether in tort (including negligence), contract, or otherwise,
155
+ unless required by applicable law (such as deliberate and grossly
156
+ negligent acts) or agreed to in writing, shall any Contributor be
157
+ liable to You for damages, including any direct, indirect, special,
158
+ incidental, or consequential damages of any character arising as a
159
+ result of this License or out of the use or inability to use the
160
+ Work (including but not limited to damages for loss of goodwill,
161
+ work stoppage, computer failure or malfunction, or any and all
162
+ other commercial damages or losses), even if such Contributor
163
+ has been advised of the possibility of such damages.
164
+
165
+ 9. Accepting Warranty or Additional Liability. While redistributing
166
+ the Work or Derivative Works thereof, You may choose to offer,
167
+ and charge a fee for, acceptance of support, warranty, indemnity,
168
+ or other liability obligations and/or rights consistent with this
169
+ License. However, in accepting such obligations, You may act only
170
+ on Your own behalf and on Your sole responsibility, not on behalf
171
+ of any other Contributor, and only if You agree to indemnify,
172
+ defend, and hold each Contributor harmless for any liability
173
+ incurred by, or claims asserted against, such Contributor by reason
174
+ of your accepting any such warranty or additional liability.
175
+
176
+ END OF TERMS AND CONDITIONS
177
+
178
+ APPENDIX: How to apply the Apache License to your work.
179
+
180
+ To apply the Apache License to your work, attach the following
181
+ boilerplate notice, with the fields enclosed by brackets "[]"
182
+ replaced with your own identifying information. (Don't include
183
+ the brackets!) The text should be enclosed in the appropriate
184
+ comment syntax for the file format. We also recommend that a
185
+ file or class name and description of purpose be included on the
186
+ same "printed page" as the copyright notice for easier
187
+ identification within third-party archives.
188
+
189
+ Copyright [yyyy] [name of copyright owner]
190
+
191
+ Licensed under the Apache License, Version 2.0 (the "License");
192
+ you may not use this file except in compliance with the License.
193
+ You may obtain a copy of the License at
194
+
195
+ http://www.apache.org/licenses/LICENSE-2.0
196
+
197
+ Unless required by applicable law or agreed to in writing, software
198
+ distributed under the License is distributed on an "AS IS" BASIS,
199
+ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
200
+ See the License for the specific language governing permissions and
201
+ limitations under the License.
README.md CHANGED
@@ -1,10 +1,250 @@
1
  ---
2
- title: Hallway8
3
- emoji: 🚀
4
- colorFrom: blue
5
- colorTo: pink
6
  sdk: docker
 
7
  pinned: false
8
  ---
9
 
10
- Check out the configuration reference at https://huggingface.co/docs/hub/spaces-config-reference
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
  ---
2
+ title: Hallway 8
3
+ emoji: 🚪
4
+ colorFrom: gray
5
+ colorTo: indigo
6
  sdk: docker
7
+ app_port: 7860
8
  pinned: false
9
  ---
10
 
11
+ # Hallway 8
12
+
13
+ [![Deploy to Hugging Face Spaces](https://huggingface.co/datasets/huggingface/badges/resolve/main/deploy-to-spaces-md.svg)](https://huggingface.co/new-space?sdk=docker)
14
+
15
+ A small browser game about memory and doubt.
16
+
17
+ You move through a place that should be identical every time. If anything
18
+ differs from what you remember, **turn back**. If nothing has changed, **go
19
+ on**. Reach the eighth loop without contradicting your own memory. Get it wrong
20
+ and you start over.
21
+
22
+ The trick is that the place is never described the same way twice. Only a
23
+ handful of its details are mentioned each loop, and each is phrased from a pool
24
+ of interchangeable sentences — so you can't compare two loops word-for-word.
25
+ You're forced to remember *meaning*, not wording. Somewhere in that fog, one
26
+ detail occasionally changes for real.
27
+
28
+ ## Scenarios (arcs)
29
+
30
+ The game ships with several **arcs** — each its own backstory, vocabulary, and
31
+ visual skin, all sharing the one mechanic. You pick one from the opening screen.
32
+
33
+ - **Hallway 8** — locked in after hours, you follow a sign to the fire exit and
34
+ walk corridor after identical corridor toward the eighth fire door.
35
+ - **Stairway 8** — the elevator is out of order, so you descend eight flights on
36
+ foot, checking each landing before you keep going down to the ground floor.
37
+ - **Coach 8** — the conductor sent you forward to coach 8, but the night train
38
+ never seems to reach it; you walk carriage after identical carriage.
39
+
40
+ Every arc carries more than physical changes: each has an **NPC** who is usually
41
+ passive but can become the anomaly (turning to face you, moving, vanishing) —
42
+ including a loop-aware twist where they *acknowledge you* — and a psychological
43
+ `sense` layer where the wrongness is in perception, not objects (a corridor that
44
+ feels too long, a calm that shouldn't be there).
45
+
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
+
53
+ - **Partial description.** Each corridor has eight properties (lighting, sound,
54
+ the man down the hall, the exit sign, a door, a camera, the floor, a poster),
55
+ but only four to six are ever mentioned per loop.
56
+ - **Rotating prose.** Every detail is drawn from several equivalent sentences,
57
+ so the wording drifts even when nothing has actually changed.
58
+ - **A single quiet mutation.** When a corridor *is* different, exactly one
59
+ property has shifted to a plausible variant — nothing screams "look here."
60
+ - **Confidence as a mechanic.** Before you commit, you rate how sure you are.
61
+ - **A corridor that notices you.** Linger on one detail across loops and the
62
+ narration begins to react to your habits.
63
+ - **Presentation drift.** Heading, pacing, font weight, letter spacing, and the
64
+ background shift a fraction each loop — felt more than seen.
65
+
66
+ ---
67
+
68
+ ## Run it locally
69
+
70
+ You need **Python 3.9+** (any recent Python 3 works).
71
+
72
+ **1. Get the code**
73
+
74
+ ```bash
75
+ git clone https://github.com/alvations/hallway8.git
76
+ cd hallway8
77
+ ```
78
+
79
+ **2. (Optional but recommended) create a virtual environment**
80
+
81
+ ```bash
82
+ python -m venv .venv
83
+ source .venv/bin/activate # Windows: .venv\Scripts\activate
84
+ ```
85
+
86
+ **3. Install the dependencies**
87
+
88
+ ```bash
89
+ pip install -r requirements.txt
90
+ ```
91
+
92
+ **4. Start the server**
93
+
94
+ ```bash
95
+ python app.py
96
+ ```
97
+
98
+ You'll see something like `Running on http://127.0.0.1:5000`.
99
+
100
+ **5. Open the game in your browser**
101
+
102
+ Go to **<http://127.0.0.1:5000>** and press **Begin**.
103
+
104
+ To stop the server, press `Ctrl+C` in the terminal.
105
+
106
+ ### Options
107
+
108
+ | Variable | Default | What it does |
109
+ |--------------|-------------|---------------------------------------------------------|
110
+ | `PORT` | `5000` | Port the server listens on. |
111
+ | `HOST` | `127.0.0.1` | Interface to bind. Use `0.0.0.0` to expose on a network.|
112
+ | `SECRET_KEY` | random | Set a fixed value to keep sessions stable across restarts. |
113
+
114
+ Example: `PORT=8000 python app.py` then open <http://127.0.0.1:8000>.
115
+
116
+ ---
117
+
118
+ ## Host it on Hugging Face Spaces
119
+
120
+ This repo is ready to run as a **Docker Space**. The included `Dockerfile`
121
+ serves the app on port `7860`, and the metadata block at the very top of this
122
+ README is the Space card Hugging Face reads — so there's nothing extra to
123
+ configure.
124
+
125
+ **Prerequisites:** a free Hugging Face account (<https://huggingface.co/join>).
126
+ For the git and CLI methods you'll also need a **write access token** from
127
+ <https://huggingface.co/settings/tokens>.
128
+
129
+ ### Step 1 — create the Space
130
+
131
+ Click the **Deploy to Spaces** badge at the top of this README, or go to
132
+ <https://huggingface.co/new-space> and:
133
+
134
+ 1. **Owner / Space name** — e.g. `hallway8`.
135
+ 2. **SDK** — choose **Docker**, then the **Blank** template.
136
+ 3. **Hardware** — the free **CPU basic** tier is plenty.
137
+ 4. **Visibility** — Public or Private, your call. Create the Space.
138
+
139
+ You now have an empty Space at
140
+ `https://huggingface.co/spaces/<your-username>/hallway8`.
141
+
142
+ ### Step 2 — get the code into the Space
143
+
144
+ Pick whichever is easiest. All three upload the same files; the Space rebuilds
145
+ on every change.
146
+
147
+ **Option A — drag and drop (no tools needed)**
148
+
149
+ In the Space's **Files** tab, click **Add file → Upload files** and upload the
150
+ whole project. Make sure these are all present at the Space root: `Dockerfile`,
151
+ `app.py`, `hallway.py`, `anomalies.py`, `memory.py`, `requirements.txt`,
152
+ `README.md`, and the `data/`, `templates/`, and `static/` folders.
153
+
154
+ **Option B — push with git**
155
+
156
+ A Space is a git repo. Add it as a remote and push:
157
+
158
+ ```bash
159
+ git remote add space https://huggingface.co/spaces/<your-username>/hallway8
160
+ git push space main
161
+ ```
162
+
163
+ When prompted, enter your **Hugging Face username** and paste your **access
164
+ token** as the password. (To avoid the prompt, run `pip install -U huggingface_hub`
165
+ then `hf auth login` once — it installs a git credential helper.)
166
+
167
+ > If the Space was created with its own starter `README.md`, your first push may
168
+ > be rejected as out of date. Either `git pull space main --rebase` first, or
169
+ > `git push space main --force` to overwrite the starter files with this repo.
170
+
171
+ **Option C — upload with the Hugging Face CLI**
172
+
173
+ ```bash
174
+ pip install -U huggingface_hub
175
+ hf auth login # paste your write token once
176
+ hf upload <your-username>/hallway8 . . --repo-type=space
177
+ ```
178
+
179
+ ### Step 3 — watch it build and play
180
+
181
+ Open the Space's **Logs** (or **App**) tab. Hugging Face builds the Docker
182
+ image and starts the container; the first build takes a couple of minutes. Once
183
+ the status turns **Running**, the game plays right there in the browser at
184
+ `https://huggingface.co/spaces/<your-username>/hallway8`.
185
+
186
+ ### Recommended settings
187
+
188
+ - In **Settings → Variables and secrets**, add a secret named `SECRET_KEY` with
189
+ any long random string. This keeps player sessions valid across Space
190
+ restarts. (Without it a fresh key is generated on each boot, which simply
191
+ resets any in-progress runs.)
192
+ - The free tier **sleeps after a period of inactivity** and wakes on the next
193
+ visit — normal, and fine for a game like this.
194
+
195
+ > **Why the Dockerfile pins a single worker:** the game keeps each corridor's
196
+ > answer in server memory so the page can't be inspected to cheat. That state
197
+ > lives in one process, so the app must run with a single worker (the Dockerfile
198
+ > already does this). Don't scale it to multiple workers/replicas.
199
+
200
+ ### Add your own "Open in Spaces" badge
201
+
202
+ Once your Space is live, you can point people straight at it by adding this near
203
+ the top of the README (swap in your username):
204
+
205
+ ```markdown
206
+ [![Open in Spaces](https://huggingface.co/datasets/huggingface/badges/resolve/main/open-in-hf-spaces-md.svg)](https://huggingface.co/spaces/<your-username>/hallway8)
207
+ ```
208
+
209
+ ### Other hosts
210
+
211
+ The same `Dockerfile` runs anywhere that takes a container — Fly.io, Render,
212
+ Railway, Google Cloud Run, etc. Point the platform at the image (or use
213
+ `gunicorn -w 1 --threads 8 -b 0.0.0.0:$PORT app:app`) and keep it to a single
214
+ worker for the reason above.
215
+
216
+ ---
217
+
218
+ ## Layout
219
+
220
+ ```
221
+ app.py Flask server; owns all game state, arc selection, and answers
222
+ hallway.py loads an arc, builds and describes a loop
223
+ anomalies.py anomaly selection + wording/heading drift
224
+ memory.py tracks the player's habits across loops
225
+ data/arcs/*.json one file per arc: meta/skin, properties, sentence pools, story
226
+ templates/ the page
227
+ static/ style (skins) + client logic (rendering, pacing, drift)
228
+ Dockerfile container image for Hugging Face Spaces and other hosts
229
+ ```
230
+
231
+ The browser only ever receives a description — never whether a place has
232
+ changed — so you can't read the page source to cheat.
233
+
234
+ ### Adding an arc
235
+
236
+ Copy an existing file in `data/arcs/` and edit it. Each arc is fully
237
+ self-contained:
238
+
239
+ - **`meta`** — `id`, `title`, `tagline`, a `skin` name, the two action labels
240
+ (`go_on` / `turn_back`), a `progress` HUD template (placeholders `{level}`,
241
+ `{goal}`, `{floor}`, `{remaining}`), and friendly `labels` per property.
242
+ - **`properties`** — each with a `baseline` value, a pool of interchangeable
243
+ `values` sentences, and `anomalies` (each an alternate value with its own
244
+ sentence pool). Only these ever change between loops.
245
+ - **`title_variants`**, **`anchor_order`**, **`adaptive`**, and **`framing`**
246
+ (the intro and win text).
247
+
248
+ To give the arc its own colours, add a `body[data-skin="<name>"]` block in
249
+ `static/style.css`. The new arc appears automatically on the opening screen ���
250
+ no code changes required.
anomalies.py ADDED
@@ -0,0 +1,47 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Anomaly selection and the small "drift" tricks.
2
+
3
+ Nothing here screams. An anomaly is a single property quietly swapped for a
4
+ plausible variant. The drift helpers change wording, punctuation, and the
5
+ heading itself so the player can never compare two loops character-for-character
6
+ and must fall back on memory of *meaning*.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import random
12
+ from typing import Dict, List, Optional, Tuple
13
+
14
+
15
+ def pick_anomaly(properties: Dict[str, dict], rng: random.Random) -> Tuple[str, str]:
16
+ """Choose one property and one of its anomalous values.
17
+
18
+ Returns (property_name, anomaly_value).
19
+ """
20
+ candidates = [name for name, spec in properties.items() if spec.get("anomalies")]
21
+ prop = rng.choice(candidates)
22
+ value = rng.choice(list(properties[prop]["anomalies"].keys()))
23
+ return prop, value
24
+
25
+
26
+ def drift_heading(title_variants: List[str], loops: int, rng: random.Random) -> str:
27
+ """The heading is usually 'Hallway 8', but every so often it slips.
28
+
29
+ Early on it is stable. The longer the player survives, the more the
30
+ heading is allowed to wander -- a meta anomaly they can never act on.
31
+ """
32
+ if loops < 4:
33
+ return title_variants[0]
34
+ # a small, growing chance of a slipped heading
35
+ if rng.random() < min(0.05 + loops * 0.01, 0.30):
36
+ return rng.choice(title_variants)
37
+ return title_variants[0]
38
+
39
+
40
+ def drift_sign(sign_variants: List[str], rng: random.Random) -> str:
41
+ """The exit sign's spacing/arrow drifts even when it is *not* the anomaly."""
42
+ return rng.choice(sign_variants)
43
+
44
+
45
+ def render_sentence(template: str, sign_text: str) -> str:
46
+ """Fill the shared placeholders in a sentence template."""
47
+ return template.replace("{SIGN}", sign_text)
app.py ADDED
@@ -0,0 +1,208 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """A tiny Flask server for the memory-loop game.
2
+
3
+ Run: python app.py then open http://127.0.0.1:5000
4
+
5
+ The game ships several *arcs* (skins/backstories) that share one mechanic.
6
+ Game state -- and, crucially, the answer to each loop -- lives on the server.
7
+ The browser only ever receives the description, never whether a place is
8
+ anomalous, so the page cannot be inspected to cheat.
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ import os
14
+ import random
15
+ import secrets
16
+ from typing import Dict
17
+
18
+ from flask import (
19
+ Flask,
20
+ jsonify,
21
+ render_template,
22
+ request,
23
+ session,
24
+ )
25
+
26
+ from hallway import Hallway, GOAL, DEFAULT_ARC, list_arcs
27
+ from memory import PlayerMemory
28
+
29
+ app = Flask(__name__)
30
+ app.secret_key = os.environ.get("SECRET_KEY", secrets.token_hex(32))
31
+
32
+ # Available arcs, and a cache of one Hallway per arc (each just holds parsed
33
+ # JSON, so sharing them across requests is safe).
34
+ ARCS = {a["id"]: a for a in list_arcs()}
35
+ _HALLWAYS: Dict[str, Hallway] = {}
36
+
37
+
38
+ def get_hallway(arc_id: str) -> Hallway:
39
+ if arc_id not in ARCS:
40
+ arc_id = DEFAULT_ARC
41
+ if arc_id not in _HALLWAYS:
42
+ _HALLWAYS[arc_id] = Hallway(arc_id)
43
+ return _HALLWAYS[arc_id]
44
+
45
+
46
+ # Server-side session store, keyed by a token kept in the signed cookie.
47
+ # Holds the live PlayerMemory, the current (answer-bearing) room, the RNG,
48
+ # and which arc is being played. In-memory is fine for a single process.
49
+ STATE: Dict[str, dict] = {}
50
+
51
+
52
+ def _slot() -> dict:
53
+ """Return this browser's server-side state, creating it if needed."""
54
+ token = session.get("token")
55
+ if not token or token not in STATE:
56
+ token = secrets.token_hex(16)
57
+ session["token"] = token
58
+ STATE[token] = _fresh(DEFAULT_ARC)
59
+ return STATE[token]
60
+
61
+
62
+ def _fresh(arc_id: str) -> dict:
63
+ return {
64
+ "mem": PlayerMemory(),
65
+ "room": None,
66
+ "rng": random.Random(secrets.randbits(64)),
67
+ "arc": arc_id if arc_id in ARCS else DEFAULT_ARC,
68
+ }
69
+
70
+
71
+ def _hall(slot: dict) -> Hallway:
72
+ return get_hallway(slot["arc"])
73
+
74
+
75
+ def _next_room(slot: dict) -> dict:
76
+ mem: PlayerMemory = slot["mem"]
77
+ mem.loops += 1
78
+ room = _hall(slot).build(mem, slot["rng"])
79
+ slot["room"] = room
80
+ return room
81
+
82
+
83
+ def _payload(slot: dict, room: dict, extra: dict | None = None) -> dict:
84
+ mem: PlayerMemory = slot["mem"]
85
+ hall = _hall(slot)
86
+ data = {
87
+ "room": hall.public(room),
88
+ "meta": hall.meta,
89
+ "level": mem.level,
90
+ "goal": GOAL,
91
+ "best": mem.best_level,
92
+ "loops": mem.loops,
93
+ }
94
+ if extra:
95
+ data.update(extra)
96
+ return jsonify(data)
97
+
98
+
99
+ @app.route("/")
100
+ def index():
101
+ return render_template("index.html")
102
+
103
+
104
+ @app.route("/api/arcs", methods=["GET"])
105
+ def api_arcs():
106
+ return jsonify({"arcs": list(ARCS.values()), "default": DEFAULT_ARC})
107
+
108
+
109
+ @app.route("/api/new", methods=["POST"])
110
+ def api_new():
111
+ body = request.get_json(silent=True) or {}
112
+ arc_id = body.get("arc", DEFAULT_ARC)
113
+ if arc_id not in ARCS:
114
+ arc_id = DEFAULT_ARC
115
+
116
+ token = session.get("token") or secrets.token_hex(16)
117
+ session["token"] = token
118
+ STATE[token] = _fresh(arc_id)
119
+ slot = STATE[token]
120
+
121
+ room = _next_room(slot)
122
+ return _payload(slot, room, {"intro": _hall(slot).intro()})
123
+
124
+
125
+ @app.route("/api/room", methods=["GET"])
126
+ def api_room():
127
+ slot = _slot()
128
+ room = slot["room"] or _next_room(slot)
129
+ return _payload(slot, room)
130
+
131
+
132
+ @app.route("/api/inspect", methods=["POST"])
133
+ def api_inspect():
134
+ """The player lingers on one detail. The place may notice."""
135
+ slot = _slot()
136
+ mem: PlayerMemory = slot["mem"]
137
+ prop = (request.get_json(silent=True) or {}).get("prop", "")
138
+ line = None
139
+ if prop:
140
+ mem.record_inspect(prop)
141
+ # Occasionally, staring too long earns an unsettling aside.
142
+ if slot["rng"].random() < 0.35:
143
+ line = slot["rng"].choice(_hall(slot).data["adaptive"]["long_look"])
144
+ return jsonify({"line": line})
145
+
146
+
147
+ @app.route("/api/act", methods=["POST"])
148
+ def api_act():
149
+ """The player commits: 'continue' goes on, 'back' turns around."""
150
+ slot = _slot()
151
+ mem: PlayerMemory = slot["mem"]
152
+ room = slot["room"]
153
+ if room is None:
154
+ room = _next_room(slot)
155
+
156
+ body = request.get_json(silent=True) or {}
157
+ choice = body.get("choice")
158
+ confidence = body.get("confidence")
159
+ mem.record_confidence(confidence)
160
+
161
+ has_anomaly = room["_has_anomaly"]
162
+ # Correct play: turn back on a changed place, go on when nothing changed.
163
+ correct = (choice == "back") == has_anomaly
164
+
165
+ if choice == "back":
166
+ mem.turn_backs += 1
167
+ else:
168
+ mem.continues += 1
169
+
170
+ won = False
171
+ if correct:
172
+ mem.level += 1
173
+ mem.best_level = max(mem.best_level, mem.level)
174
+ if mem.level >= GOAL:
175
+ won = True
176
+ else:
177
+ if not has_anomaly and choice == "back":
178
+ mem.false_reports += 1
179
+ elif has_anomaly and choice == "continue":
180
+ mem.missed += 1
181
+ mem.level = 0
182
+
183
+ result = {
184
+ "correct": correct,
185
+ "had_anomaly": has_anomaly,
186
+ "won": won,
187
+ "confidence": confidence,
188
+ }
189
+
190
+ if won:
191
+ result["win_text"] = _hall(slot).win()
192
+ result["stats"] = mem.to_dict()
193
+ # Reset progress so a fresh run starts clean, but keep the record.
194
+ best = mem.best_level
195
+ slot["mem"] = PlayerMemory(best_level=best)
196
+ room = _next_room(slot)
197
+ return _payload(slot, room, result)
198
+
199
+ room = _next_room(slot)
200
+ return _payload(slot, room, result)
201
+
202
+
203
+ if __name__ == "__main__":
204
+ # HOST/PORT are env-driven so the same file runs locally (127.0.0.1:5000)
205
+ # and on a host like Hugging Face Spaces (0.0.0.0:7860).
206
+ host = os.environ.get("HOST", "127.0.0.1")
207
+ port = int(os.environ.get("PORT", "5000"))
208
+ app.run(host=host, port=port, debug=False)
data/arcs/coach8.json ADDED
@@ -0,0 +1,345 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "_comment": "Coach arc. Same schema and mechanic as the others, different skin and story: a night train. The conductor sent you forward to coach 8, and you walk carriage after identical carriage. Turn back if a coach has changed; keep moving forward if it hasn't. Motion is part of the unease.",
3
+
4
+ "meta": {
5
+ "id": "coach8",
6
+ "title": "Coach 8",
7
+ "tagline": "The conductor said coach 8. The train never seems to reach it.",
8
+ "skin": "coach",
9
+ "go_on": "Next coach →",
10
+ "turn_back": "Back a coach ←",
11
+ "unit": "coach",
12
+ "progress": "Coach {level} / {goal}",
13
+ "labels": {
14
+ "lighting": "lights",
15
+ "sound": "sound",
16
+ "window": "window",
17
+ "passenger": "sleeping passenger",
18
+ "seatnum": "seat numbers",
19
+ "door": "connecting door",
20
+ "luggage": "luggage rack",
21
+ "table": "table",
22
+ "sense": "feeling"
23
+ }
24
+ },
25
+
26
+ "title_variants": ["Coach 8", "Coach 8", "Coach 8", "Coach 8", "Carriage 8", "Coach Eight", "COACH 8", "Coach 8"],
27
+
28
+ "sign_variants": ["COACH 8"],
29
+
30
+ "anchor_order": ["lighting", "sound", "window", "passenger", "seatnum", "door", "luggage", "table", "sense"],
31
+
32
+ "properties": {
33
+ "lighting": {
34
+ "baseline": "dim",
35
+ "values": {
36
+ "dim": [
37
+ "The cabin lights are dimmed for night, a soft amber wash.",
38
+ "Low night-lighting glows along the luggage racks, as always.",
39
+ "The overhead lights are down to their amber night setting.",
40
+ "The coach is lit the same soft gold as the last."
41
+ ]
42
+ },
43
+ "anomalies": {
44
+ "flicker": [
45
+ "The cabin lights flutter with the motion of the train, then hold.",
46
+ "One strip of lighting stutters overhead.",
47
+ "The amber light trips off, then on again."
48
+ ],
49
+ "bright": [
50
+ "The lights are up full, harsh and white — not the night setting.",
51
+ "This coach is lit far brighter than the last.",
52
+ "The soft amber is gone; everything glares white."
53
+ ],
54
+ "off": [
55
+ "The cabin is dark but for the emergency floor strips.",
56
+ "The lights are out here; only the aisle markers glow.",
57
+ "Darkness, save a thin line of light along the floor."
58
+ ]
59
+ }
60
+ },
61
+
62
+ "sound": {
63
+ "baseline": "rhythm",
64
+ "values": {
65
+ "rhythm": [
66
+ "The rails beat their steady rhythm beneath the floor.",
67
+ "The train hums along, wheels ticking over the joints.",
68
+ "That familiar sway and clatter of a train at speed."
69
+ ]
70
+ },
71
+ "anomalies": {
72
+ "slowing": [
73
+ "The rhythm of the rails is slowing. Are you stopping?",
74
+ "The clatter drags, as if the train is losing speed.",
75
+ "The beat beneath the floor stretches longer between ticks."
76
+ ],
77
+ "silence": [
78
+ "The wheels have gone silent. You feel no motion at all.",
79
+ "The train sound has stopped. Dead still.",
80
+ "No clatter, no sway. The train might not be moving."
81
+ ],
82
+ "announce": [
83
+ "A chime sounds, then a muffled announcement you can't make out.",
84
+ "The PA crackles with a voice, then cuts off.",
85
+ "An announcement plays — backwards, almost."
86
+ ],
87
+ "faster": [
88
+ "The rails beat faster than they should, a rising whine.",
89
+ "The train is accelerating, the clatter climbing in pitch.",
90
+ "Everything rattles harder; you're speeding up."
91
+ ]
92
+ }
93
+ },
94
+
95
+ "window": {
96
+ "baseline": "night",
97
+ "values": {
98
+ "night": [
99
+ "Beyond the window, black countryside and your own faint reflection.",
100
+ "The window shows only night and the ghost of the cabin.",
101
+ "Dark fields streak past the window, far-off lights scattered."
102
+ ]
103
+ },
104
+ "anomalies": {
105
+ "station": [
106
+ "The window shows a lit platform sliding by — a station, unannounced.",
107
+ "Outside, a station platform passes, empty and bright.",
108
+ "A station slides past the glass; you never felt the train slow."
109
+ ],
110
+ "reflection": [
111
+ "Your reflection in the glass is a half-second behind you.",
112
+ "The reflection in the window doesn't quite match the cabin.",
113
+ "Someone stands in the window's reflection who isn't in the coach."
114
+ ],
115
+ "daylight": [
116
+ "The window is bright with daylight. It was night a moment ago.",
117
+ "Outside the glass it is suddenly, impossibly, day.",
118
+ "Sunlight fills the window where there was only dark."
119
+ ],
120
+ "tunnel": [
121
+ "The window is pure black — a tunnel, roaring, with no end to it.",
122
+ "You're in a tunnel now; the glass shows only rushing dark.",
123
+ "Nothing but black tunnel wall streams past the window."
124
+ ]
125
+ }
126
+ },
127
+
128
+ "passenger": {
129
+ "baseline": "sleeping",
130
+ "values": {
131
+ "sleeping": [
132
+ "A lone passenger sleeps against the far window, coat over their face.",
133
+ "One passenger dozes midway down the coach, head tipped back.",
134
+ "The same sleeping figure slumps in a window seat, as before."
135
+ ]
136
+ },
137
+ "anomalies": {
138
+ "gone": [
139
+ "The seat where the passenger slept is empty now.",
140
+ "No one is sleeping in the coach this time.",
141
+ "The dozing passenger is gone; only a folded coat remains."
142
+ ],
143
+ "awake": [
144
+ "The passenger is awake now, sitting upright, very still.",
145
+ "The sleeper's eyes are open, fixed on the aisle.",
146
+ "The passenger no longer sleeps. They watch you pass."
147
+ ],
148
+ "moved": [
149
+ "The sleeping passenger has moved to a seat nearer the aisle.",
150
+ "The dozing figure is closer than before, on your side now.",
151
+ "The passenger sleeps in a different seat than a moment ago."
152
+ ],
153
+ "two": [
154
+ "Two passengers sleep here now, where there was one.",
155
+ "There are a pair of coats-over-faces this time, not one.",
156
+ "A second sleeping figure has joined the first."
157
+ ],
158
+ "speaks": [
159
+ "The sleeping passenger murmurs, without waking: 'again?'",
160
+ "From under the coat, a muffled voice: 'you were just here.'",
161
+ "The dozing figure says the name of your stop, though you told no one."
162
+ ]
163
+ }
164
+ },
165
+
166
+ "seatnum": {
167
+ "baseline": "eight",
168
+ "values": {
169
+ "eight": [
170
+ "The seat decals read 8A, 8B, 8C down the row.",
171
+ "Little brass numbers mark the seats: row 8, as always.",
172
+ "The coach-number panel by the door glows COACH 8."
173
+ ]
174
+ },
175
+ "anomalies": {
176
+ "wrong": [
177
+ "The seat decals read row 7 now, not 8.",
178
+ "The coach-number panel reads COACH 9.",
179
+ "The brass numbers say row 6; you were sure it was 8."
180
+ ],
181
+ "missing": [
182
+ "The seat numbers have been prised off; bare screw-holes remain.",
183
+ "The coach-number panel by the door is dark and blank.",
184
+ "There are no numbers on the seats at all this time."
185
+ ],
186
+ "letters": [
187
+ "The seat decals read 8X, 8Y, 8Z — letters that don't belong.",
188
+ "The row is numbered in an alphabet you don't recognise.",
189
+ "The seat labels are just symbols now, not numbers."
190
+ ]
191
+ }
192
+ },
193
+
194
+ "door": {
195
+ "baseline": "closed",
196
+ "values": {
197
+ "closed": [
198
+ "The connecting door at the coach's end is shut, its glass dark.",
199
+ "The powered door to the next carriage stands closed, as usual.",
200
+ "The end door is closed, the vestibule beyond it dim."
201
+ ]
202
+ },
203
+ "anomalies": {
204
+ "open": [
205
+ "The connecting door is stuck open, cold air rushing through.",
206
+ "The powered door won't close; the vestibule roars beyond.",
207
+ "The end door gapes open onto the shaking vestibule."
208
+ ],
209
+ "locked": [
210
+ "A red light burns on the connecting door: LOCKED.",
211
+ "The end door shows a locked indicator that was green before.",
212
+ "The door to the next coach reads DO NOT ENTER."
213
+ ],
214
+ "glass": [
215
+ "The glass in the connecting door is fogged, a hand-print on it.",
216
+ "Something smears the window of the end door from the far side.",
217
+ "The connecting door's glass is cracked in a spider's web."
218
+ ]
219
+ }
220
+ },
221
+
222
+ "luggage": {
223
+ "baseline": "stowed",
224
+ "values": {
225
+ "stowed": [
226
+ "A single suitcase sits in the luggage rack by the door.",
227
+ "The overhead racks hold the usual scattering of bags.",
228
+ "One hard-shell case rides the luggage rack, as before."
229
+ ]
230
+ },
231
+ "anomalies": {
232
+ "gone": [
233
+ "The luggage rack is empty now.",
234
+ "The suitcase by the door is gone.",
235
+ "There are no bags in the racks this time."
236
+ ],
237
+ "more": [
238
+ "The luggage rack is crammed full, far more bags than before.",
239
+ "Cases are stacked nearly to the ceiling now.",
240
+ "The racks overflow with luggage that wasn't there."
241
+ ],
242
+ "open": [
243
+ "The suitcase in the rack lies open, empty.",
244
+ "A case has spilled open across the luggage rack.",
245
+ "The hard-shell case gapes open, its contents gone."
246
+ ]
247
+ }
248
+ },
249
+
250
+ "table": {
251
+ "baseline": "coffee",
252
+ "values": {
253
+ "coffee": [
254
+ "A fold-down table by the aisle holds a half-finished coffee.",
255
+ "The tray table is down, a paper cup cooling on it, as always.",
256
+ "On the table, someone's coffee, still steaming faintly."
257
+ ]
258
+ },
259
+ "anomalies": {
260
+ "empty": [
261
+ "The table is bare; the coffee cup is gone.",
262
+ "The tray table holds nothing now.",
263
+ "There's no cup on the table this time."
264
+ ],
265
+ "cold": [
266
+ "The coffee on the table has gone cold, a skin across the top.",
267
+ "The cup is stone cold now, long abandoned.",
268
+ "The coffee sits cold and still, no steam at all."
269
+ ],
270
+ "many": [
271
+ "The table is crowded with cups now, not one but many.",
272
+ "Several coffees crowd the tray table where there was one.",
273
+ "The table is cluttered with more cups than anyone could drink."
274
+ ],
275
+ "spilled": [
276
+ "The coffee has spilled, a dark stain spreading over the table.",
277
+ "The cup is tipped, coffee running off the table's edge.",
278
+ "Coffee pools across the table, dripping to the floor."
279
+ ]
280
+ }
281
+ },
282
+
283
+ "sense": {
284
+ "baseline": "steady",
285
+ "values": {
286
+ "steady": [
287
+ "The train feels exactly as long as it always has.",
288
+ "You carry the same tired patience up the aisle, coach after coach.",
289
+ "Everything feels as ordinary as a night train should."
290
+ ]
291
+ },
292
+ "anomalies": {
293
+ "forget": [
294
+ "For a moment you can't remember how many coaches you've walked.",
295
+ "You lose count of the carriages behind you, and it frightens you.",
296
+ "You can't recall boarding this train at all."
297
+ ],
298
+ "calm": [
299
+ "You feel suddenly, wrongly calm — as if you'd already arrived.",
300
+ "A strange contentment settles over you that has no business being here.",
301
+ "You feel no urgency at all, and that itself feels wrong."
302
+ ],
303
+ "watched": [
304
+ "You feel watched, though every passenger is asleep.",
305
+ "The certainty that someone is behind you won't leave.",
306
+ "Your neck prickles; you are sure you are not alone in the way you were."
307
+ ]
308
+ }
309
+ }
310
+ },
311
+
312
+ "adaptive": {
313
+ "repeat_inspect": [
314
+ "You look at the {THING} again. You aren't sure why.",
315
+ "Your eyes go straight to the {THING}, the way they always seem to now.",
316
+ "The {THING} draws your attention once more."
317
+ ],
318
+ "long_look": [
319
+ "You spend longer looking than usual. You don't remember deciding to stop.",
320
+ "You realise you've been standing in the aisle. For how long?",
321
+ "The train sways on, and you don't know how long you've been staring."
322
+ ]
323
+ },
324
+
325
+ "framing": {
326
+ "intro": [
327
+ "COACH 8",
328
+ "",
329
+ "The conductor clipped your ticket and nodded up the train: coach 8, that way.",
330
+ "You've been walking forward through the carriages ever since.",
331
+ "",
332
+ "Each coach looks like the last. If one differs from what you remember, TURN BACK.",
333
+ "If nothing has changed, keep moving to the NEXT COACH.",
334
+ "",
335
+ "Reach coach 8 and you can finally sit down.",
336
+ "Lose your place, and you're back where the conductor left you."
337
+ ],
338
+ "win": [
339
+ "You slide open one more door — and the seat numbers read 8.",
340
+ "Coach 8. Your seat, empty and waiting, beside a black window.",
341
+ "You sink into it at last. The train rushes on through the night.",
342
+ "You found your seat."
343
+ ]
344
+ }
345
+ }
data/arcs/hallway-eight.json ADDED
@@ -0,0 +1,315 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "_comment": "The canonical corridor. Each property has a baseline value with a pool of equivalent sentences (wording drifts while meaning stays fixed), plus anomalous variants with their own pools. Remember MEANING, not wording.",
3
+
4
+ "meta": {
5
+ "id": "hallway-eight",
6
+ "title": "Hallway 8",
7
+ "tagline": "Locked in after hours. Follow the corridor to the fire exit.",
8
+ "skin": "hallway",
9
+ "go_on": "Walk on ↑",
10
+ "turn_back": "Turn back ↓",
11
+ "unit": "corridor",
12
+ "progress": "Hallway {level} / {goal}",
13
+ "labels": {
14
+ "lighting": "lights",
15
+ "sound": "sound",
16
+ "poster": "poster",
17
+ "figure": "man down the hall",
18
+ "sign": "exit sign",
19
+ "door": "maintenance door",
20
+ "camera": "camera",
21
+ "floor": "floor",
22
+ "sense": "feeling"
23
+ }
24
+ },
25
+
26
+ "title_variants": ["Hallway 8", "Hallway 8", "Hallway 8", "Hallway 8", "Corridor 8", "Hallway Eight", "HALLWAY 8", "Hallway 8"],
27
+
28
+ "sign_variants": ["EXIT →", "EXIT →", "EXIT →", "EXIT→", "Exit →", "EXIT ⇢", "EXIT → "],
29
+
30
+ "anchor_order": ["lighting", "sound", "poster", "figure", "sign", "door", "camera", "floor", "sense"],
31
+
32
+ "properties": {
33
+ "lighting": {
34
+ "baseline": "steady",
35
+ "values": {
36
+ "steady": [
37
+ "The fluorescent lights hum softly overhead.",
38
+ "The ceiling lights give off a steady, even glow.",
39
+ "Overhead, the fluorescents buzz at their usual pitch.",
40
+ "The lighting is flat and unremarkable, the way it always is."
41
+ ]
42
+ },
43
+ "anomalies": {
44
+ "flicker": [
45
+ "One of the fluorescent tubes stutters, then holds.",
46
+ "The lights flicker — once, twice — before settling.",
47
+ "There's a faint strobe to the ceiling lights that wasn't there before."
48
+ ],
49
+ "dim": [
50
+ "The corridor seems a shade darker than you remember.",
51
+ "The lights are dimmer here, as if half of them gave up.",
52
+ "A grey dimness has settled over everything."
53
+ ],
54
+ "warm": [
55
+ "The light has a warm, yellowish cast to it.",
56
+ "The fluorescents glow oddly amber, almost like sunset.",
57
+ "The usual cold white has turned faintly golden."
58
+ ]
59
+ }
60
+ },
61
+
62
+ "sound": {
63
+ "baseline": "vent",
64
+ "values": {
65
+ "vent": [
66
+ "Somewhere, ventilation exhales a low, constant breath.",
67
+ "The air handling hums in the walls, steady as ever.",
68
+ "You hear the familiar drone of distant ventilation."
69
+ ]
70
+ },
71
+ "anomalies": {
72
+ "drip": [
73
+ "A slow drip taps somewhere out of sight.",
74
+ "Water is dripping — you can't place where.",
75
+ "Tick. Tick. Something is dripping nearby."
76
+ ],
77
+ "silence": [
78
+ "The ventilation has stopped. The silence presses on your ears.",
79
+ "It is completely quiet. Too quiet.",
80
+ "You notice the hum is gone. The stillness feels wrong."
81
+ ],
82
+ "footsteps": [
83
+ "Footsteps echo somewhere behind you, then stop.",
84
+ "You hear footsteps that don't match your own.",
85
+ "For a moment there are two sets of footsteps."
86
+ ]
87
+ }
88
+ },
89
+
90
+ "poster": {
91
+ "baseline": "travel",
92
+ "values": {
93
+ "travel": [
94
+ "A faded travel poster clings to the wall — a beach, a blue sky.",
95
+ "The old poster of a sunny coastline is still taped to the wall.",
96
+ "On the wall, that washed-out advertisement for somewhere warm.",
97
+ "The travel poster hangs at its usual slight tilt."
98
+ ]
99
+ },
100
+ "anomalies": {
101
+ "gone": [
102
+ "The wall is bare. A pale rectangle marks where a poster used to be.",
103
+ "The travel poster is gone; only the tape remains.",
104
+ "Nothing hangs on the wall now."
105
+ ],
106
+ "mountains": [
107
+ "The poster shows snow-capped mountains, not a beach.",
108
+ "The coastline poster is now a photograph of grey mountains.",
109
+ "The advertisement pictures a cold summit under heavy cloud."
110
+ ],
111
+ "upside": [
112
+ "The travel poster hangs upside down.",
113
+ "The beach on the poster is inverted, sky at the bottom.",
114
+ "Someone has flipped the poster; it hangs the wrong way up."
115
+ ]
116
+ }
117
+ },
118
+
119
+ "figure": {
120
+ "baseline": "distant",
121
+ "values": {
122
+ "distant": [
123
+ "Far down the corridor, a man in a grey suit walks away from you.",
124
+ "A suited figure moves ahead in the distance, back turned.",
125
+ "The man in grey is up ahead, as always, walking on.",
126
+ "Down the hall, a man in a suit recedes toward the vanishing point."
127
+ ]
128
+ },
129
+ "anomalies": {
130
+ "closer": [
131
+ "The man in the grey suit is much closer than he should be.",
132
+ "The suited figure has stopped halfway, closer than before.",
133
+ "The man in grey is only a few paces ahead now, still turned away."
134
+ ],
135
+ "facing": [
136
+ "The man in the grey suit has stopped. He is facing you.",
137
+ "Down the hall, the suited figure stands still, looking your way.",
138
+ "The man in grey is not walking. He is watching you."
139
+ ],
140
+ "gone": [
141
+ "The corridor ahead is empty. No one is walking it.",
142
+ "There is no figure ahead this time.",
143
+ "The man in the grey suit is nowhere to be seen."
144
+ ],
145
+ "knows": [
146
+ "The man in grey has turned and raised a hand, as if he knows you.",
147
+ "The suited figure faces you and beckons, slow and deliberate.",
148
+ "The man mouths something down the corridor. It might be 'again.'"
149
+ ]
150
+ }
151
+ },
152
+
153
+ "sign": {
154
+ "baseline": "exit",
155
+ "values": {
156
+ "exit": [
157
+ "The illuminated {SIGN} sign glows at the far end.",
158
+ "Ahead, the {SIGN} sign hangs above the corridor.",
159
+ "The green {SIGN} sign points the way, as it should.",
160
+ "You can make out the {SIGN} sign in the distance."
161
+ ]
162
+ },
163
+ "anomalies": {
164
+ "reversed": [
165
+ "The exit sign points the wrong way — EXIT ←.",
166
+ "The arrow on the exit sign now points back the way you came: ←.",
167
+ "EXIT ←. The arrow has reversed."
168
+ ],
169
+ "dark": [
170
+ "The exit sign is dark. No light behind the letters.",
171
+ "The exit sign has gone out.",
172
+ "Where the exit sign should glow, there is only a dead panel."
173
+ ],
174
+ "number": [
175
+ "The sign reads EXIT 3. There was never a number on it.",
176
+ "A number has appeared on the sign: EXIT 3.",
177
+ "The exit sign now carries a numeral you don't remember: 3."
178
+ ]
179
+ }
180
+ },
181
+
182
+ "door": {
183
+ "baseline": "closed",
184
+ "values": {
185
+ "closed": [
186
+ "A grey maintenance door sits shut in the left wall.",
187
+ "The service door on your left is closed, as usual.",
188
+ "There's the maintenance door, shut tight."
189
+ ]
190
+ },
191
+ "anomalies": {
192
+ "ajar": [
193
+ "The maintenance door stands slightly ajar. Darkness inside.",
194
+ "The service door is open a crack, though you never saw it open.",
195
+ "The grey door is ajar; a thin black gap where it should be flush."
196
+ ],
197
+ "gone": [
198
+ "There is no maintenance door in the left wall. Just smooth wall.",
199
+ "The service door is missing entirely.",
200
+ "The left wall is unbroken — no door at all."
201
+ ]
202
+ }
203
+ },
204
+
205
+ "camera": {
206
+ "baseline": "away",
207
+ "values": {
208
+ "away": [
209
+ "A small CCTV camera in the corner points down the corridor.",
210
+ "The security camera watches the far end, angled away from you.",
211
+ "Up in the corner, the camera faces ahead, as always."
212
+ ]
213
+ },
214
+ "anomalies": {
215
+ "atyou": [
216
+ "The CCTV camera has turned. It points directly at you.",
217
+ "The security camera is angled down at you now.",
218
+ "The camera in the corner faces you, its lens level with your eyes."
219
+ ],
220
+ "red": [
221
+ "A red light blinks on the camera. It never used to blink.",
222
+ "The camera's recording light glows red for the first time.",
223
+ "The little LED on the camera pulses red."
224
+ ]
225
+ }
226
+ },
227
+
228
+ "floor": {
229
+ "baseline": "dry",
230
+ "values": {
231
+ "dry": [
232
+ "The grey floor tiles stretch away, dry and dull.",
233
+ "Underfoot, the usual scuffed grey tiles.",
234
+ "The tiled floor is dry, reflecting nothing."
235
+ ]
236
+ },
237
+ "anomalies": {
238
+ "wet": [
239
+ "The tiles ahead shine with a wet, dark sheen.",
240
+ "The floor is wet, though you saw no one clean it.",
241
+ "A slick of water spreads across the tiles."
242
+ ],
243
+ "cracked": [
244
+ "A crack runs across the floor tiles that wasn't there before.",
245
+ "One of the floor tiles is split down the middle.",
246
+ "The tiling is fractured in a jagged line ahead."
247
+ ]
248
+ }
249
+ },
250
+
251
+ "sense": {
252
+ "baseline": "steady",
253
+ "values": {
254
+ "steady": [
255
+ "The corridor feels exactly as long as it always has.",
256
+ "You carry the same low dread you've had since the lobby.",
257
+ "Everything feels ordinary, in the way this place is ever ordinary."
258
+ ]
259
+ },
260
+ "anomalies": {
261
+ "longer": [
262
+ "The corridor feels longer this time, though you can't say why.",
263
+ "The far end seems further off than it should be.",
264
+ "The walls feel as if they've stretched since the last stretch."
265
+ ],
266
+ "forget": [
267
+ "For a moment you can't remember how many doors you've passed.",
268
+ "You lose your count of the corridors, and it unsettles you.",
269
+ "You can't recall why you're down here at all."
270
+ ],
271
+ "calm": [
272
+ "You feel suddenly, wrongly calm — as if you'd already escaped.",
273
+ "A strange relief washes over you that has no business being here.",
274
+ "You feel no fear at all, and that itself feels wrong."
275
+ ]
276
+ }
277
+ }
278
+ },
279
+
280
+ "adaptive": {
281
+ "repeat_inspect": [
282
+ "You glance at the {THING} again. You aren't sure why.",
283
+ "Your eyes go straight to the {THING}, the way they always seem to now.",
284
+ "The {THING} draws your attention once more."
285
+ ],
286
+ "long_look": [
287
+ "You spend longer looking than usual. You don't remember deciding to stop.",
288
+ "You realise you've been standing still. For how long?",
289
+ "Time slips. You were staring, and you don't know for how long."
290
+ ]
291
+ },
292
+
293
+ "framing": {
294
+ "intro": [
295
+ "AFTER HOURS",
296
+ "",
297
+ "You stayed too late. The lobby doors are locked, the lights half-out.",
298
+ "A handwritten sign hangs over them: FIRE EXIT — USE CORRIDOR.",
299
+ "",
300
+ "So you follow it. Each stretch of corridor looks like the last.",
301
+ "",
302
+ "If anything differs from what you remember, TURN BACK.",
303
+ "If nothing has changed, WALK ON.",
304
+ "",
305
+ "Reach the eighth fire door and you are out.",
306
+ "Lose track of what you've seen, and the corridor starts you over."
307
+ ],
308
+ "win": [
309
+ "The eighth fire door gives under your hands.",
310
+ "Cold air. A stairwell down to the street. The city, still there.",
311
+ "You were beginning to think the corridor had no end.",
312
+ "You are out."
313
+ ]
314
+ }
315
+ }
data/arcs/stairway8.json ADDED
@@ -0,0 +1,387 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "_comment": "Stairway arc. Same schema and mechanic as hallway-eight, different skin and story: the elevator is out, so you descend eight flights on foot. Each landing should be identical -- go back up if a landing has changed, keep descending if it hasn't.",
3
+
4
+ "meta": {
5
+ "id": "stairway8",
6
+ "title": "Stairway 8",
7
+ "tagline": "The elevator is out. Eight floors down, one landing at a time.",
8
+ "skin": "stairway",
9
+ "go_on": "Descend ↓",
10
+ "turn_back": "Back up ↑",
11
+ "unit": "landing",
12
+ "progress": "Floor {floor} · {level}/{goal} down",
13
+ "labels": {
14
+ "lighting": "light",
15
+ "sound": "sound",
16
+ "number": "floor number",
17
+ "door": "fire door",
18
+ "notice": "elevator notice",
19
+ "railing": "handrail",
20
+ "extinguisher": "extinguisher",
21
+ "stairs": "stairs down",
22
+ "figure": "figure on the stairs",
23
+ "sense": "feeling"
24
+ }
25
+ },
26
+
27
+ "title_variants": ["Stairway 8", "Stairway 8", "Stairway 8", "Stairway 8", "Stairwell 8", "Stairway Eight", "STAIRWAY 8", "Stairway 8"],
28
+
29
+ "sign_variants": ["OUT OF ORDER"],
30
+
31
+ "anchor_order": ["lighting", "sound", "number", "door", "notice", "figure", "railing", "extinguisher", "stairs", "sense"],
32
+
33
+ "properties": {
34
+ "lighting": {
35
+ "baseline": "steady",
36
+ "values": {
37
+ "steady": [
38
+ "A caged bulb throws steady light across the landing.",
39
+ "The stairwell light burns evenly overhead, as always.",
40
+ "The fluorescent strip on the landing glows its usual flat white.",
41
+ "Light falls here the same as every landing above."
42
+ ]
43
+ },
44
+ "anomalies": {
45
+ "flicker": [
46
+ "The caged bulb stutters, then steadies.",
47
+ "The stairwell light flickers in a way it hasn't before.",
48
+ "Overhead, the light trips once and comes back."
49
+ ],
50
+ "dim": [
51
+ "This landing is dimmer than the ones above it.",
52
+ "The light here has half given up; the corners stay dark.",
53
+ "A grey murk hangs over the landing."
54
+ ],
55
+ "red": [
56
+ "The landing is lit red — an emergency bulb you don't remember.",
57
+ "Everything wears a low red emergency glow.",
58
+ "The white light is gone; only a red bulb remains."
59
+ ]
60
+ }
61
+ },
62
+
63
+ "sound": {
64
+ "baseline": "quiet",
65
+ "values": {
66
+ "quiet": [
67
+ "Your footsteps echo up the shaft and fade.",
68
+ "The stairwell is quiet but for your own steps.",
69
+ "Far below, the building hums to itself."
70
+ ]
71
+ },
72
+ "anomalies": {
73
+ "drip": [
74
+ "Water drips somewhere down the shaft. Tick. Tick.",
75
+ "A slow drip echoes up from below.",
76
+ "Something is dripping, floors down."
77
+ ],
78
+ "slam": [
79
+ "A door slams far below, then silence.",
80
+ "Somewhere beneath you a door bangs shut.",
81
+ "A heavy door closes several floors down."
82
+ ],
83
+ "footsteps": [
84
+ "Footsteps climb toward you, then stop.",
85
+ "You hear steps that aren't yours on the flight below.",
86
+ "For a moment, someone else is on the stairs."
87
+ ],
88
+ "silence": [
89
+ "The building's hum has stopped. Dead quiet.",
90
+ "It is completely silent. Too silent.",
91
+ "Even the echo of your steps seems to vanish."
92
+ ]
93
+ }
94
+ },
95
+
96
+ "number": {
97
+ "baseline": "eight",
98
+ "values": {
99
+ "eight": [
100
+ "A large painted 8 marks the landing wall.",
101
+ "The floor number — 8 — is stencilled on the concrete.",
102
+ "'8' is painted on the wall, drips frozen beneath the strokes.",
103
+ "The wall reads 8, the same as the landing before."
104
+ ]
105
+ },
106
+ "anomalies": {
107
+ "wrong": [
108
+ "The wall reads 7. It has always read 8.",
109
+ "The painted number is a 6 now, not an 8.",
110
+ "The floor number has changed: it says 9."
111
+ ],
112
+ "basement": [
113
+ "The wall reads B. Basement. You are nowhere near it.",
114
+ "The number is gone; a painted 'B' stands in its place.",
115
+ "The stencil reads B, though you've barely started down."
116
+ ],
117
+ "faded": [
118
+ "The painted number has faded to a grey ghost.",
119
+ "Someone has scrubbed the number half away.",
120
+ "The 8 on the wall is almost gone."
121
+ ],
122
+ "reversed": [
123
+ "The 8 is painted backwards, as if seen in a mirror.",
124
+ "The floor number is mirror-reversed.",
125
+ "The stencilled number reads the wrong way round."
126
+ ]
127
+ }
128
+ },
129
+
130
+ "door": {
131
+ "baseline": "closed",
132
+ "values": {
133
+ "closed": [
134
+ "The fire door to the floor is shut, its wired glass dark.",
135
+ "The heavy grey fire door stands closed on the landing.",
136
+ "The door onto the floor is closed, the way it should be."
137
+ ]
138
+ },
139
+ "anomalies": {
140
+ "ajar": [
141
+ "The fire door hangs slightly ajar. A black gap, no light.",
142
+ "The heavy door is open a crack; it never is.",
143
+ "The fire door isn't quite shut this time."
144
+ ],
145
+ "propped": [
146
+ "The fire door is propped wide open onto a dark floor.",
147
+ "Someone has wedged the fire door open.",
148
+ "The door stands fully open, held by nothing you can see."
149
+ ],
150
+ "window": [
151
+ "The wired-glass window in the door has gone black, covered.",
152
+ "The little window in the fire door is blocked from the other side.",
153
+ "Something presses against the glass in the door."
154
+ ],
155
+ "handle": [
156
+ "The fire door has no handle now.",
157
+ "Where the door handle was, only bare holes.",
158
+ "The handle is missing from the fire door."
159
+ ]
160
+ }
161
+ },
162
+
163
+ "notice": {
164
+ "baseline": "out",
165
+ "values": {
166
+ "out": [
167
+ "A taped notice on the elevator reads OUT OF ORDER — USE STAIRS.",
168
+ "The repair notice is still there: elevator out of order.",
169
+ "OUT OF ORDER, says the sign taped across the elevator doors."
170
+ ]
171
+ },
172
+ "anomalies": {
173
+ "gone": [
174
+ "The elevator notice is gone. Just bare metal doors.",
175
+ "No sign on the elevator this time.",
176
+ "The repair notice has been taken down."
177
+ ],
178
+ "service": [
179
+ "The notice now reads ELEVATOR IN SERVICE.",
180
+ "The sign says the elevator is working again.",
181
+ "OUT OF ORDER has become BACK IN SERVICE."
182
+ ],
183
+ "wording": [
184
+ "The notice reads DO NOT USE STAIRS.",
185
+ "The sign warns STAIRS CLOSED — it never did before.",
186
+ "The notice now tells you not to take the stairs at all."
187
+ ],
188
+ "arrow": [
189
+ "The arrow on the notice points up, not down.",
190
+ "The sign's arrow has flipped to point back up the stairs.",
191
+ "The notice directs you upward now."
192
+ ]
193
+ }
194
+ },
195
+
196
+ "railing": {
197
+ "baseline": "cold",
198
+ "values": {
199
+ "cold": [
200
+ "The steel handrail runs cold and unbroken under your hand.",
201
+ "The painted railing is right where it always is.",
202
+ "Your hand finds the handrail, continuous along the flight."
203
+ ]
204
+ },
205
+ "anomalies": {
206
+ "gap": [
207
+ "A section of the handrail is missing over the drop.",
208
+ "The railing has a gap where a length has been removed.",
209
+ "Part of the handrail simply isn't there."
210
+ ],
211
+ "wet": [
212
+ "The handrail is wet, though nothing drips on it.",
213
+ "Your hand comes away damp from the rail.",
214
+ "The railing is slick under your palm."
215
+ ],
216
+ "paint": [
217
+ "The railing is a different colour than the flights above.",
218
+ "Someone has repainted this stretch of handrail.",
219
+ "The rail's paint is green here, not grey."
220
+ ]
221
+ }
222
+ },
223
+
224
+ "extinguisher": {
225
+ "baseline": "present",
226
+ "values": {
227
+ "present": [
228
+ "A fire extinguisher hangs on its bracket beside the door.",
229
+ "The red extinguisher is in its place on the wall.",
230
+ "The landing's fire extinguisher sits on its hook."
231
+ ]
232
+ },
233
+ "anomalies": {
234
+ "gone": [
235
+ "The extinguisher bracket is empty.",
236
+ "The fire extinguisher is gone; only the bracket remains.",
237
+ "No extinguisher on the wall this time."
238
+ ],
239
+ "floor": [
240
+ "The extinguisher lies on the floor, on its side.",
241
+ "The fire extinguisher has been set down on the landing.",
242
+ "The extinguisher stands loose against the wall, off its hook."
243
+ ],
244
+ "gauge": [
245
+ "The extinguisher's pressure gauge reads in the red.",
246
+ "The needle on the extinguisher sits deep in the red.",
247
+ "The extinguisher's gauge has dropped to empty."
248
+ ]
249
+ }
250
+ },
251
+
252
+ "stairs": {
253
+ "baseline": "clear",
254
+ "values": {
255
+ "clear": [
256
+ "The flight down is clear, its steps even and dry.",
257
+ "The stairs descend as always, nothing on them.",
258
+ "The next flight drops away, clean and empty."
259
+ ]
260
+ },
261
+ "anomalies": {
262
+ "wet": [
263
+ "The steps below shine wet in the light.",
264
+ "The next flight is slick with water.",
265
+ "The stairs down are wet, though you saw no spill."
266
+ ],
267
+ "missing": [
268
+ "One of the steps in the flight below is missing.",
269
+ "There's a gap in the stairs where a step should be.",
270
+ "A tread is gone from the flight down."
271
+ ],
272
+ "up": [
273
+ "The arrow stencilled on the steps points UP, not down.",
274
+ "The direction painted on the stairs has reversed.",
275
+ "The steps seem to lead up now, though you are going down."
276
+ ],
277
+ "debris": [
278
+ "Something has been left on the steps below — a bag, maybe.",
279
+ "There's debris scattered across the next flight.",
280
+ "A dark shape sits partway down the flight."
281
+ ]
282
+ }
283
+ },
284
+
285
+ "figure": {
286
+ "baseline": "below",
287
+ "values": {
288
+ "below": [
289
+ "Far below, someone else winds down the stairwell, always a flight or two ahead.",
290
+ "A figure descends the shaft ahead of you, keeping the same distance.",
291
+ "Someone is on the stairs below, going down at your pace, never closer."
292
+ ]
293
+ },
294
+ "anomalies": {
295
+ "facing": [
296
+ "The figure below has stopped. It is looking up at you.",
297
+ "Down the shaft, the figure stands still, face turned up toward you.",
298
+ "The one on the stairs isn't descending now. It's watching you."
299
+ ],
300
+ "closer": [
301
+ "The figure on the stairs is much closer than before, just a flight down.",
302
+ "The descending figure has closed the distance; it's nearly on your landing.",
303
+ "Whoever is below is only a few steps down now."
304
+ ],
305
+ "gone": [
306
+ "The stairwell below is empty. Whoever was descending is gone.",
307
+ "There is no figure on the stairs this time.",
308
+ "The one ahead of you has vanished from the shaft."
309
+ ],
310
+ "ascending": [
311
+ "The figure is climbing now — coming up the stairs toward you.",
312
+ "Footsteps rise: the figure below is ascending, not descending.",
313
+ "The one on the stairs has turned around and is coming up."
314
+ ],
315
+ "knows": [
316
+ "The figure below looks up and lifts a hand, as if it has been waiting for you.",
317
+ "The figure mouths something up the shaft. It might be 'again.'",
318
+ "It beckons you down, slow and certain, as though it knows you."
319
+ ]
320
+ }
321
+ },
322
+
323
+ "sense": {
324
+ "baseline": "steady",
325
+ "values": {
326
+ "steady": [
327
+ "The shaft feels exactly as deep as it always has.",
328
+ "You carry the same wariness down flight after flight.",
329
+ "Everything feels ordinary, as far as any of this is ordinary."
330
+ ]
331
+ },
332
+ "anomalies": {
333
+ "deeper": [
334
+ "The shaft feels deeper than it should, the ground floor further off.",
335
+ "You feel you've descended far more than eight floors.",
336
+ "The bottom seems to have moved further away."
337
+ ],
338
+ "forget": [
339
+ "For a moment you can't remember how many landings you've passed.",
340
+ "You lose count of the flights above you, and it frightens you.",
341
+ "You can't recall which floor you started on."
342
+ ],
343
+ "up": [
344
+ "For an instant you're certain you've been climbing, not descending.",
345
+ "A wrong feeling grips you: that down has been up all along.",
346
+ "You feel, briefly and coldly, that you are going the wrong way."
347
+ ]
348
+ }
349
+ }
350
+ },
351
+
352
+ "adaptive": {
353
+ "repeat_inspect": [
354
+ "You check the {THING} again. You aren't sure why.",
355
+ "Your eyes go straight to the {THING}, the way they always seem to now.",
356
+ "The {THING} draws your attention once more."
357
+ ],
358
+ "long_look": [
359
+ "You spend longer looking than usual. You don't remember deciding to stop.",
360
+ "You realise you've been standing on the landing. For how long?",
361
+ "Time slips. You were staring down the shaft, and you don't know for how long."
362
+ ]
363
+ },
364
+
365
+ "framing": {
366
+ "intro": [
367
+ "OUT OF ORDER",
368
+ "",
369
+ "You press the call button. Nothing. A notice is taped across the doors:",
370
+ "ELEVATOR OUT OF ORDER — USE STAIRS.",
371
+ "",
372
+ "Eight floors up, and only the stairwell down. Each landing looks the same.",
373
+ "",
374
+ "If a landing differs from the one in your memory, GO BACK UP.",
375
+ "If nothing has changed, keep DESCENDING.",
376
+ "",
377
+ "Reach the ground floor and step out.",
378
+ "Lose track of the landings, and you're back at the top."
379
+ ],
380
+ "win": [
381
+ "The last flight opens onto the lobby.",
382
+ "Daylight through the doors. The street. The elevator behind you, still dead.",
383
+ "Eight floors down, and your memory held every landing.",
384
+ "Ground floor."
385
+ ]
386
+ }
387
+ }
docs/ARCS.md ADDED
@@ -0,0 +1,138 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Arc roadmap
2
+
3
+ This is the living plan for the game's arcs (skins/backstories). It records the
4
+ design rules every arc must follow, what's already built, and the queue of
5
+ future arcs so we can pick up any of them later without re-deriving the idea.
6
+
7
+ Each arc is one self-contained `data/arcs/<id>.json` file plus a
8
+ `body[data-skin="<name>"]` block in `static/style.css`. See the README section
9
+ *Adding an arc* for the file schema. New arcs appear on the opening screen
10
+ automatically.
11
+
12
+ ---
13
+
14
+ ## Design rules (apply to every arc)
15
+
16
+ The mechanic is fixed: move through a place that should be identical each loop;
17
+ **turn back** if something changed, **go on** if nothing did; eight correct
18
+ calls in a row wins, one wrong call resets. What differs per arc is the story,
19
+ vocabulary, skin — and the *flavour* of the anomalies. Every arc must include:
20
+
21
+ 1. **A strong motivation ("the hook").** A concrete in-world reason you're stuck
22
+ doing this, revealed in the intro — the equivalent of Stairway 8's
23
+ out-of-order elevator. Never "you are being tested" with no diegetic cause.
24
+ 2. **An NPC.** A recurring character that is normally passive but can become the
25
+ anomaly — turning, watching, approaching, vanishing, duplicating. Every arc
26
+ needs its own recurring uncanny figure.
27
+ 3. **A loop-aware NPC twist.** At least one NPC anomaly that breaks past physical
28
+ change into the uncanny: the character *acknowledges you* or the loop itself
29
+ (waves, beckons, mouths/says "again"). Property key convention: `knows` for
30
+ silent figures, `speaks` for ones that can talk.
31
+ 4. **A psychological layer (`sense`).** A non-physical property whose baseline is
32
+ your ordinary state and whose anomalies are perceptual/emotional wrongness —
33
+ losing count, a calm that shouldn't be there, a conviction you're going the
34
+ wrong way. Anomalies beyond objects, per the "twists" requirement.
35
+ 5. **Meta drift (free).** The engine already drifts the heading, wording, pacing,
36
+ weight, spacing and background every loop — no per-arc work needed, but keep
37
+ `title_variants` themed.
38
+
39
+ Keep anomalies *plausible*, never loud. One property mutates per anomalous loop,
40
+ and it is always among the details shown that loop (the engine guarantees this).
41
+
42
+ ---
43
+
44
+ ## Built
45
+
46
+ | Arc | Hook | Loop unit | NPC (+ twist) | `sense` anomalies | Skin |
47
+ |-----|------|-----------|---------------|-------------------|------|
48
+ | **Hallway 8** (`hallway-eight`) | Locked in after hours; a sign points to the fire exit | corridor | man in grey suit (`knows`: turns/beckons/"again") | longer, forget, calm | cold teal |
49
+ | **Stairway 8** (`stairway8`) | Elevator out of order; descend on foot | landing | figure on the stairs below (`knows`: looks up, waves, ascends) | deeper, forget, wrong-way | emergency green |
50
+ | **Coach 8** (`coach8`) | Conductor sent you to coach 8; the train never reaches it | carriage | sleeping passenger (`speaks`: murmurs "again", "you were just here") | forget, calm, watched | cabin amber |
51
+
52
+ ---
53
+
54
+ ## Planned (not yet built)
55
+
56
+ Each entry is enough to implement directly. Ordered by how strong/fresh the loop
57
+ feels; pick any.
58
+
59
+ ### Platform 8 (`platform8`) — the underpass
60
+ - **Hook:** last train of the night; the turnstiles are shut and a sign routes
61
+ you through the pedestrian underpass — FOLLOW SIGNS TO PLATFORM 8.
62
+ - **Loop unit:** identical tiled underpass segments. **Go on** = walk on /
63
+ **Turn back** = double back.
64
+ - **NPC:** a busker sitting against the wall with an open case. Twist (`knows`):
65
+ stops playing to watch you; case is suddenly empty/overfull; plays the same
66
+ bar as you pass "again."
67
+ - **Physical anomalies:** tiled advert, the drip-puddle, map's "YOU ARE HERE"
68
+ dot, poster date, platform number on the sign, tile colour, a pigeon.
69
+ - **`sense`:** the tunnel feels longer; you forget which platform you wanted; a
70
+ certainty you already caught the train.
71
+ - **Skin:** cold blue-white station tile (`--hue: 205`).
72
+
73
+ ### Aisle 8 (`aisle8`) — locked in the supermarket
74
+ - **Hook:** you looked up and the shop had closed around you; the tannoy loops
75
+ "please proceed to checkout 8 to exit."
76
+ - **Loop unit:** identical grocery aisles. **Go on** = next aisle / **Turn
77
+ back** = previous aisle.
78
+ - **NPC:** a night-shift stocker with a trolley, back to you. Twist (`knows`):
79
+ turns and stares; trolley abandoned mid-aisle; softly says "again" over the
80
+ muzak.
81
+ - **Physical anomalies:** a shelf gap, a product facing the wrong way, a price
82
+ label, the end-cap promo, the muzak track, the abandoned trolley, floor sheen.
83
+ - **`sense`:** the aisle feels longer; you forget your list; a calm certainty
84
+ you've already paid.
85
+ - **Skin:** flat retail fluorescent, a touch too bright (`--hue: 60`, high L).
86
+
87
+ ### Ward 8 (`ward8`) — the hospital at night
88
+ - **Hook:** visiting hours are over; the night sign says the way out is via Ward
89
+ 8. *(Handle tone with care — dread, not gore.)*
90
+ - **Loop unit:** identical hospital corridors. **Go on** / **Turn back**.
91
+ - **NPC:** a patient in a gown standing in a doorway, or a nurse at the far
92
+ station. Twist (`knows`): the figure has moved to the next doorway; is facing
93
+ you; mouths "again."
94
+ - **Physical anomalies:** a gurney's position, the room-number plate, a
95
+ flickering "NIL BY MOUTH" sign, the hand-gel dispenser, a chart on a door,
96
+ distant monitor beeps, a wheelchair.
97
+ - **`sense`:** the corridor feels longer; you forget why you came; a wrong calm.
98
+ - **Skin:** pale clinical green-grey (`--hue: 160`, low sat).
99
+
100
+ ### Level 8 (`level8`) — the underground car park
101
+ - **Hook:** your ticket says Level 8; the lift to the street is dead, so follow
102
+ the ramp up. (Ascending mirror of Stairway 8.)
103
+ - **Loop unit:** identical parking rows. **Go on** = up a ramp / **Turn back** =
104
+ back down.
105
+ - **NPC:** a figure between the parked cars, or someone sitting in a driver's
106
+ seat. Twist (`knows`): the seated figure's headlights flick on as you pass; it
107
+ has moved cars; it waves.
108
+ - **Physical anomalies:** a parked car's colour, the level number on the pillar,
109
+ a flickering strip light, the arrow painted on the floor, an oil stain, a
110
+ distant engine, a car alarm.
111
+ - **`sense`:** the level feels deeper; you forget where you parked; certainty
112
+ you're descending, not climbing.
113
+ - **Skin:** concrete grey with sodium-orange pools (`--hue: 30`, low L).
114
+
115
+ ### Gate 8 (`gate8`) — the airport after the last flight
116
+ - **Hook:** you dozed off; the terminal's near-empty and the board shows one
117
+ route left — EXIT VIA GATE 8.
118
+ - **Loop unit:** identical concourse stretches / travelators. **Go on** /
119
+ **Turn back**.
120
+ - **NPC:** a lone cleaner with a floor-buffer, or a single seated passenger.
121
+ Twist (`knows`): stops buffing to watch; the seated passenger is gone/closer;
122
+ a gate announcement says your name.
123
+ - **Physical anomalies:** the gate number, a departures-board row, the duty-free
124
+ window display, an abandoned bag (unsettling), the announcement chime, a clock,
125
+ a potted plant.
126
+ - **`sense`:** the concourse feels longer; you forget your destination; a calm
127
+ that you've already boarded.
128
+ - **Skin:** glassy blue night-terminal (`--hue: 215`).
129
+
130
+ ---
131
+
132
+ ## Backlog ideas (thin — flesh out before building)
133
+
134
+ - **Museum after close** — galleries loop; NPC is a security guard or a statue
135
+ that isn't always in the same pose.
136
+ - **Motel corridor** — reach room 8 / the exit; NPC is someone at an ice machine.
137
+ - **Ferry decks** — climb decks to the exit; NPC a lone smoker at the rail;
138
+ motion + sea outside the windows.
hallway.py ADDED
@@ -0,0 +1,141 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
4
+ baseline value or (at most one of them) at an anomalous value. The describer
5
+ turns that into a handful of sentences drawn from rotating pools, so the same
6
+ place reads differently every time while still being the same place.
7
+
8
+ Content is organised into *arcs* (skins/backstories) under data/arcs/. Each arc
9
+ file shares the same schema; only the theme, story, and vocabulary change.
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ import glob
15
+ import json
16
+ import os
17
+ import random
18
+ from typing import Dict, List, Optional
19
+
20
+ import anomalies
21
+ from memory import PlayerMemory
22
+
23
+ ARCS_DIR = os.path.join(os.path.dirname(__file__), "data", "arcs")
24
+
25
+ DEFAULT_ARC = "hallway-eight"
26
+
27
+ # How many hallways/landings you must clear. Reaching this level wins.
28
+ GOAL = 8
29
+
30
+ # How many details are described each loop. Enough to overload memory a little,
31
+ # few enough to keep it readable.
32
+ MIN_DETAILS = 4
33
+ MAX_DETAILS = 6
34
+
35
+
36
+ def _arc_path(arc_id: str) -> str:
37
+ return os.path.join(ARCS_DIR, f"{arc_id}.json")
38
+
39
+
40
+ def list_arcs() -> List[dict]:
41
+ """Return the selectable arcs, described by their meta block.
42
+
43
+ The default arc is listed first; the rest follow alphabetically.
44
+ """
45
+ arcs = []
46
+ for path in sorted(glob.glob(os.path.join(ARCS_DIR, "*.json"))):
47
+ with open(path, "r", encoding="utf-8") as f:
48
+ meta = json.load(f).get("meta", {})
49
+ if meta.get("id"):
50
+ arcs.append({
51
+ "id": meta["id"],
52
+ "title": meta.get("title", meta["id"]),
53
+ "tagline": meta.get("tagline", ""),
54
+ "skin": meta.get("skin", "hallway"),
55
+ })
56
+ arcs.sort(key=lambda a: (a["id"] != DEFAULT_ARC, a["id"]))
57
+ return arcs
58
+
59
+
60
+ class Hallway:
61
+ def __init__(self, arc_id: str = DEFAULT_ARC):
62
+ path = _arc_path(arc_id)
63
+ if not os.path.exists(path):
64
+ path = _arc_path(DEFAULT_ARC)
65
+ with open(path, "r", encoding="utf-8") as f:
66
+ self.data = json.load(f)
67
+ self.properties: Dict[str, dict] = self.data["properties"]
68
+ self.meta: dict = self.data.get("meta", {})
69
+ self.labels: Dict[str, str] = self.meta.get("labels", {})
70
+
71
+ def build(self, mem: PlayerMemory, rng: random.Random) -> dict:
72
+ """Generate the next place for the player's current level.
73
+
74
+ The probability of an anomaly stays near a coin-flip so neither
75
+ "always continue" nor "always turn back" is a winning strategy.
76
+ """
77
+ has_anomaly = rng.random() < 0.5
78
+ anomaly_prop: Optional[str] = None
79
+ anomaly_val: Optional[str] = None
80
+ if has_anomaly:
81
+ anomaly_prop, anomaly_val = anomalies.pick_anomaly(self.properties, rng)
82
+
83
+ # Decide which properties get described this loop.
84
+ order = list(self.data["anchor_order"])
85
+ rng.shuffle(order)
86
+ n = rng.randint(MIN_DETAILS, MAX_DETAILS)
87
+ shown = order[:n]
88
+ # If there is an anomaly, it must be visible this loop -- otherwise
89
+ # the player is asked to notice something they were never shown.
90
+ if anomaly_prop and anomaly_prop not in shown:
91
+ shown[rng.randrange(len(shown))] = anomaly_prop
92
+ # Re-sort shown into the canonical order so the "camera" of the prose
93
+ # feels consistent even as content rotates.
94
+ shown = [p for p in self.data["anchor_order"] if p in shown]
95
+
96
+ sign_text = anomalies.drift_sign(self.data["sign_variants"], rng)
97
+
98
+ sentences: List[str] = []
99
+ for prop in shown:
100
+ spec = self.properties[prop]
101
+ if prop == anomaly_prop:
102
+ pool = spec["anomalies"][anomaly_val]
103
+ else:
104
+ baseline = spec["baseline"]
105
+ pool = spec["values"][baseline]
106
+ line = anomalies.render_sentence(rng.choice(pool), sign_text)
107
+ sentences.append(line)
108
+
109
+ # Adaptive touch: if the player keeps inspecting one thing, and it is
110
+ # on show this loop, let the place notice.
111
+ fav = mem.favorite
112
+ adaptive_line = None
113
+ if fav and fav in shown and rng.random() < 0.5:
114
+ tmpl = rng.choice(self.data["adaptive"]["repeat_inspect"])
115
+ adaptive_line = tmpl.replace("{THING}", self.labels.get(fav, fav))
116
+
117
+ heading = anomalies.drift_heading(
118
+ self.data["title_variants"], mem.loops, rng
119
+ )
120
+
121
+ return {
122
+ "heading": heading,
123
+ "sentences": sentences,
124
+ "adaptive": adaptive_line,
125
+ "sign_text": sign_text,
126
+ "shown": shown,
127
+ # server-authoritative truth, never sent to the client:
128
+ "_has_anomaly": has_anomaly,
129
+ "_anomaly_prop": anomaly_prop,
130
+ "_anomaly_val": anomaly_val,
131
+ }
132
+
133
+ def public(self, room: dict) -> dict:
134
+ """Strip the answer before sending a room to the browser."""
135
+ return {k: v for k, v in room.items() if not k.startswith("_")}
136
+
137
+ def intro(self) -> List[str]:
138
+ return self.data["framing"]["intro"]
139
+
140
+ def win(self) -> List[str]:
141
+ return self.data["framing"]["win"]
memory.py ADDED
@@ -0,0 +1,62 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
4
+ model of the player's habits so the narration can quietly react to them:
5
+ the corridor that "remembers you."
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ from dataclasses import dataclass, field, asdict
11
+ from typing import Dict, Optional
12
+
13
+
14
+ @dataclass
15
+ class PlayerMemory:
16
+ # progress
17
+ level: int = 0 # which hallway you are standing in (0 = the start)
18
+ best_level: int = 0 # furthest you have ever reached
19
+ loops: int = 0 # total corridors walked this run
20
+
21
+ # behaviour
22
+ turn_backs: int = 0
23
+ continues: int = 0
24
+ false_reports: int = 0 # turned back when nothing was wrong
25
+ missed: int = 0 # walked on past a real change
26
+ inspects: Dict[str, int] = field(default_factory=dict)
27
+
28
+ # confidence
29
+ confidence_sum: float = 0.0
30
+ confidence_n: int = 0
31
+
32
+ def record_inspect(self, thing: str) -> None:
33
+ self.inspects[thing] = self.inspects.get(thing, 0) + 1
34
+
35
+ def record_confidence(self, value: Optional[int]) -> None:
36
+ if value:
37
+ self.confidence_sum += value
38
+ self.confidence_n += 1
39
+
40
+ @property
41
+ def confidence(self) -> float:
42
+ if not self.confidence_n:
43
+ return 0.0
44
+ return round(self.confidence_sum / self.confidence_n, 2)
45
+
46
+ @property
47
+ def favorite(self) -> Optional[str]:
48
+ """The property the player fixates on, once a habit has formed."""
49
+ if not self.inspects:
50
+ return None
51
+ thing, count = max(self.inspects.items(), key=lambda kv: kv[1])
52
+ return thing if count >= 3 else None
53
+
54
+ def to_dict(self) -> dict:
55
+ return asdict(self)
56
+
57
+ @classmethod
58
+ def from_dict(cls, data: dict) -> "PlayerMemory":
59
+ if not data:
60
+ return cls()
61
+ known = {f: data[f] for f in cls.__dataclass_fields__ if f in data}
62
+ return cls(**known)
requirements.txt ADDED
@@ -0,0 +1,2 @@
 
 
 
1
+ Flask>=3.0
2
+ gunicorn>=21.0
scripts/deploy_hf.py ADDED
@@ -0,0 +1,63 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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,
5
+ whose network policy blocks huggingface.co):
6
+
7
+ pip install "huggingface_hub>=0.24"
8
+ export HF_TOKEN=hf_... # a *write* token from hf.co/settings/tokens
9
+ python scripts/deploy_hf.py # defaults to alvations/hallway8
10
+
11
+ Optionally pass a different target: python scripts/deploy_hf.py owner/space-name
12
+ """
13
+
14
+ import os
15
+ import secrets
16
+ import sys
17
+
18
+ from huggingface_hub import HfApi, get_token
19
+
20
+ REPO_ID = sys.argv[1] if len(sys.argv) > 1 else "alvations/hallway8"
21
+ ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
22
+
23
+
24
+ def main() -> None:
25
+ # Prefer an explicit HF_TOKEN, but fall back to a stored `hf auth login`
26
+ # credential so this works without exporting anything.
27
+ token = os.environ.get("HF_TOKEN") or get_token()
28
+ if not token:
29
+ sys.exit(
30
+ "No token found. Run `hf auth login` or set HF_TOKEN to a write token."
31
+ )
32
+
33
+ api = HfApi(token=token)
34
+ who = api.whoami()
35
+ print(f"Authenticated as {who['name']}")
36
+
37
+ url = api.create_repo(
38
+ repo_id=REPO_ID, repo_type="space", space_sdk="docker", exist_ok=True
39
+ )
40
+ print(f"Space ready: {url}")
41
+
42
+ # Stable session key so in-progress runs survive Space restarts.
43
+ try:
44
+ api.add_space_secret(REPO_ID, "SECRET_KEY", secrets.token_hex(32))
45
+ print("SECRET_KEY secret set")
46
+ except Exception as exc: # non-fatal
47
+ print(f"Could not set SECRET_KEY (set it manually if you want): {exc}")
48
+
49
+ api.upload_folder(
50
+ folder_path=ROOT,
51
+ repo_id=REPO_ID,
52
+ repo_type="space",
53
+ commit_message="Deploy Hallway 8 (multi-arc memory game)",
54
+ ignore_patterns=[
55
+ ".git*", "**/__pycache__/**", "*.pyc", ".venv/**", "venv/**", ".env",
56
+ ],
57
+ )
58
+ print(f"\nDone → https://huggingface.co/spaces/{REPO_ID}")
59
+ print("The Space will build the Docker image and go live in a couple of minutes.")
60
+
61
+
62
+ if __name__ == "__main__":
63
+ main()
static/game.js ADDED
@@ -0,0 +1,282 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /* Eight -- client.
2
+ *
3
+ * The server owns the truth (whether a place changed) and ships several arcs
4
+ * (skins/backstories). This file lets the player pick an arc, applies its skin,
5
+ * then renders, paces, and quietly drifts the presentation so no two loops can
6
+ * be compared pixel-for-pixel. The unease is in the timing and typography as
7
+ * much as in the words.
8
+ */
9
+
10
+ const $ = (sel) => document.querySelector(sel);
11
+
12
+ const el = {
13
+ select: $("#select"),
14
+ arcs: $("#arcs"),
15
+ intro: $("#intro"),
16
+ introTitle: $("#intro-title"),
17
+ introText: $("#intro-text"),
18
+ begin: $("#begin"),
19
+ introBack: $("#intro-back"),
20
+ corridor: $("#corridor"),
21
+ progress: $("#progress"),
22
+ best: $("#best"),
23
+ heading: $("#heading"),
24
+ prose: $("#prose"),
25
+ aside: $("#aside"),
26
+ controls: $("#controls"),
27
+ confidence: $("#confidence"),
28
+ win: $("#win"),
29
+ winText: $("#win-text"),
30
+ again: $("#again"),
31
+ change: $("#change"),
32
+ body: document.body,
33
+ };
34
+
35
+ let current = null; // latest room payload
36
+ let meta = null; // current arc's presentation meta
37
+ let selectedArc = null; // arc id in play
38
+ let pendingChoice = null; // choice awaiting a confidence rating
39
+ let drift = 0; // slowly accumulating presentation drift
40
+
41
+ // --- helpers ---------------------------------------------------------------
42
+
43
+ async function get(url) {
44
+ const res = await fetch(url);
45
+ return res.json();
46
+ }
47
+
48
+ async function post(url, body) {
49
+ const res = await fetch(url, {
50
+ method: "POST",
51
+ headers: { "Content-Type": "application/json" },
52
+ body: JSON.stringify(body || {}),
53
+ });
54
+ return res.json();
55
+ }
56
+
57
+ const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
58
+
59
+ // A tiny jitter so pacing feels alive but not like noise.
60
+ const jitter = (base, spread) => base + Math.floor(Math.random() * spread);
61
+
62
+ function show(section) {
63
+ [el.select, el.intro, el.corridor, el.win].forEach((s) =>
64
+ s.classList.add("hidden")
65
+ );
66
+ section.classList.remove("hidden");
67
+ }
68
+
69
+ // Nudge the presentation a fraction every loop. Individually invisible;
70
+ // cumulatively, "something feels off."
71
+ function applyDrift() {
72
+ drift += 1;
73
+ el.body.style.setProperty("--bg-shift", Math.min(drift * 0.25, 6).toFixed(2));
74
+ const weight = 340 + Math.round(Math.sin(drift / 3) * 30);
75
+ document.documentElement.style.setProperty("--weight", String(weight));
76
+ const track = (0.01 + Math.sin(drift / 5) * 0.012).toFixed(4);
77
+ document.documentElement.style.setProperty("--tracking", `${track}em`);
78
+ }
79
+
80
+ // Apply an arc's skin, title, and button labels.
81
+ function applyMeta(m) {
82
+ meta = m;
83
+ el.body.dataset.skin = m.skin || "hallway";
84
+ document.title = m.title || "8";
85
+ el.introTitle.textContent = m.title || "8";
86
+ el.controls.querySelector('[data-choice="continue"]').textContent =
87
+ m.go_on || "Go on";
88
+ el.controls.querySelector('[data-choice="back"]').textContent =
89
+ m.turn_back || "Turn back";
90
+ }
91
+
92
+ // --- rendering -------------------------------------------------------------
93
+
94
+ function setHud(p) {
95
+ const floor = p.goal - p.level;
96
+ const remaining = p.goal - p.level;
97
+ const tmpl = (meta && meta.progress) || "{level} / {goal}";
98
+ const label = tmpl
99
+ .replace("{level}", p.level)
100
+ .replace("{goal}", p.goal)
101
+ .replace("{floor}", floor)
102
+ .replace("{remaining}", remaining);
103
+ const dots = Array.from({ length: p.goal }, (_, i) =>
104
+ i < p.level ? "•" : "·"
105
+ ).join(" ");
106
+ el.progress.textContent = `${label} ${dots}`;
107
+ el.best.textContent = p.best ? `furthest ${p.best}` : "";
108
+ }
109
+
110
+ async function renderRoom(p) {
111
+ current = p;
112
+ if (p.meta) applyMeta(p.meta);
113
+ setHud(p);
114
+ applyDrift();
115
+
116
+ el.heading.textContent = p.room.heading;
117
+ el.prose.innerHTML = "";
118
+ el.aside.textContent = "";
119
+ el.aside.classList.remove("show");
120
+ el.controls.classList.add("hidden");
121
+ el.confidence.classList.add("hidden");
122
+ pendingChoice = null;
123
+
124
+ const lines = p.room.sentences;
125
+ const shown = p.room.shown || [];
126
+
127
+ lines.forEach((text, i) => {
128
+ const div = document.createElement("div");
129
+ div.className = "line";
130
+ div.textContent = text;
131
+ if (shown[i]) {
132
+ div.dataset.prop = shown[i];
133
+ attachInspect(div);
134
+ }
135
+ el.prose.appendChild(div);
136
+ });
137
+
138
+ // Reveal one line at a time with drifting pauses. One line, occasionally,
139
+ // hangs noticeably longer -- a pause the player reads as significance.
140
+ const nodes = [...el.prose.children];
141
+ for (let i = 0; i < nodes.length; i++) {
142
+ const long = Math.random() < 0.18;
143
+ await sleep(long ? jitter(1100, 500) : jitter(380, 340));
144
+ nodes[i].classList.add("show");
145
+ }
146
+
147
+ if (p.room.adaptive) {
148
+ await sleep(600);
149
+ el.aside.textContent = p.room.adaptive;
150
+ el.aside.classList.add("show");
151
+ }
152
+
153
+ await sleep(400);
154
+ el.controls.classList.remove("hidden");
155
+ }
156
+
157
+ // Lingering on a detail may earn an unsettling aside from the server.
158
+ function attachInspect(node) {
159
+ let timer = null;
160
+ node.addEventListener("mouseenter", () => {
161
+ timer = setTimeout(async () => {
162
+ const { line } = await post("/api/inspect", { prop: node.dataset.prop });
163
+ if (line && !el.aside.textContent) {
164
+ el.aside.textContent = line;
165
+ el.aside.classList.add("show");
166
+ }
167
+ }, 1300);
168
+ });
169
+ node.addEventListener("mouseleave", () => clearTimeout(timer));
170
+ }
171
+
172
+ function flash(correct) {
173
+ const cls = correct ? "flash-good" : "flash-bad";
174
+ el.corridor.classList.add(cls);
175
+ setTimeout(() => el.corridor.classList.remove(cls), 1400);
176
+ }
177
+
178
+ // --- flow ------------------------------------------------------------------
179
+
180
+ function renderArcCards(arcs) {
181
+ el.arcs.innerHTML = "";
182
+ arcs.forEach((a) => {
183
+ const card = document.createElement("button");
184
+ card.className = "arc-card";
185
+ card.dataset.arc = a.id;
186
+ card.dataset.skin = a.skin;
187
+ card.innerHTML =
188
+ `<div class="arc-title">${a.title}</div>` +
189
+ `<div class="arc-tag">${a.tagline}</div>`;
190
+ card.addEventListener("click", () => chooseArc(a));
191
+ el.arcs.appendChild(card);
192
+ });
193
+ }
194
+
195
+ function renderIntro(lines) {
196
+ el.introText.innerHTML = "";
197
+ lines.forEach((ln, i) => {
198
+ const div = document.createElement("div");
199
+ div.textContent = ln;
200
+ if (i === 0) div.className = "lead";
201
+ el.introText.appendChild(div);
202
+ });
203
+ }
204
+
205
+ async function chooseArc(a) {
206
+ selectedArc = a.id;
207
+ applyMeta(a); // skin the intro screen immediately
208
+ const p = await post("/api/new", { arc: a.id });
209
+ applyMeta(p.meta);
210
+ renderIntro(p.intro);
211
+ current = p;
212
+ drift = 0;
213
+ show(el.intro);
214
+ }
215
+
216
+ function enterCorridor() {
217
+ show(el.corridor);
218
+ renderRoom(current);
219
+ }
220
+
221
+ function showWin(p) {
222
+ el.winText.innerHTML = "";
223
+ (p.win_text || []).forEach((ln, i) => {
224
+ const div = document.createElement("div");
225
+ div.textContent = ln;
226
+ if (i === p.win_text.length - 1) div.className = "lead";
227
+ el.winText.appendChild(div);
228
+ });
229
+ show(el.win);
230
+ }
231
+
232
+ async function loadSelect() {
233
+ const { arcs } = await get("/api/arcs");
234
+ renderArcCards(arcs);
235
+ el.body.dataset.skin = "hallway";
236
+ show(el.select);
237
+ }
238
+
239
+ // choice -> ask for confidence
240
+ el.controls.addEventListener("click", (e) => {
241
+ const btn = e.target.closest(".choice");
242
+ if (!btn) return;
243
+ pendingChoice = btn.dataset.choice;
244
+ el.controls.classList.add("hidden");
245
+ el.confidence.classList.remove("hidden");
246
+ });
247
+
248
+ // confidence -> commit
249
+ el.confidence.addEventListener("click", async (e) => {
250
+ const btn = e.target.closest("[data-conf]");
251
+ if (!btn || !pendingChoice) return;
252
+ const confidence = parseInt(btn.dataset.conf, 10);
253
+ el.confidence.classList.add("hidden");
254
+
255
+ const p = await post("/api/act", { choice: pendingChoice, confidence });
256
+ flash(p.correct);
257
+ await sleep(1500);
258
+
259
+ if (p.won) {
260
+ showWin(p);
261
+ return;
262
+ }
263
+ renderRoom(p);
264
+ });
265
+
266
+ el.begin.addEventListener("click", enterCorridor);
267
+ el.introBack.addEventListener("click", loadSelect);
268
+ el.change.addEventListener("click", loadSelect);
269
+ el.again.addEventListener("click", async () => {
270
+ const a = ARCS_BY_ID[selectedArc];
271
+ if (a) await chooseArc(a);
272
+ enterCorridor();
273
+ });
274
+
275
+ // keep a lookup of arcs so "walk it again" can restart the same one
276
+ let ARCS_BY_ID = {};
277
+ (async function boot() {
278
+ const { arcs } = await get("/api/arcs");
279
+ ARCS_BY_ID = Object.fromEntries(arcs.map((a) => [a.id, a]));
280
+ renderArcCards(arcs);
281
+ show(el.select);
282
+ })();
static/style.css ADDED
@@ -0,0 +1,254 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ :root {
2
+ --bg: #0b0c0e;
3
+ --fg: #d7d7d2;
4
+ --dim: #6d7076;
5
+ --accent: #9fd0c0;
6
+ --danger: #c98b8b;
7
+ --hue: 210; /* base hue of the ambient background glow */
8
+ --panel-w: 40rem;
9
+ /* These are nudged by JS a fraction at a time -- a drift you feel
10
+ rather than see. */
11
+ --bg-shift: 0;
12
+ --weight: 340;
13
+ --tracking: 0.01em;
14
+ }
15
+
16
+ /* --- skins ---------------------------------------------------------------
17
+ Each arc sets data-skin on <body>; these override the palette. */
18
+ body[data-skin="hallway"] {
19
+ --bg: #0b0c0e;
20
+ --fg: #d7d7d2;
21
+ --dim: #6d7076;
22
+ --accent: #9fd0c0; /* cold fluorescent teal */
23
+ --hue: 210;
24
+ }
25
+ body[data-skin="stairway"] {
26
+ --bg: #0a0d0b;
27
+ --fg: #d3d8d2;
28
+ --dim: #6a726b;
29
+ --accent: #8fc79a; /* emergency-exit green */
30
+ --hue: 150;
31
+ }
32
+ body[data-skin="coach"] {
33
+ --bg: #0c0a08;
34
+ --fg: #e0d7c8;
35
+ --dim: #7a7060;
36
+ --accent: #d8b06a; /* warm cabin amber */
37
+ --hue: 35;
38
+ }
39
+
40
+ * { box-sizing: border-box; }
41
+
42
+ html, body {
43
+ margin: 0;
44
+ height: 100%;
45
+ background: var(--bg);
46
+ color: var(--fg);
47
+ font-family: "Iowan Old Style", "Palatino Linotype", Palatino, Georgia, serif;
48
+ -webkit-font-smoothing: antialiased;
49
+ }
50
+
51
+ body {
52
+ display: flex;
53
+ align-items: center;
54
+ justify-content: center;
55
+ min-height: 100vh;
56
+ /* the 1%-at-a-time background shift lives here */
57
+ background:
58
+ radial-gradient(
59
+ 120% 90% at 50% -10%,
60
+ hsl(var(--hue) 8% calc(9% + var(--bg-shift) * 1%)),
61
+ var(--bg) 70%
62
+ );
63
+ transition: background 2.4s ease;
64
+ }
65
+
66
+ #screen {
67
+ width: var(--panel-w);
68
+ max-width: 92vw;
69
+ padding: 2rem;
70
+ }
71
+
72
+ .panel { animation: fade-in 1.1s ease both; }
73
+ .hidden { display: none !important; }
74
+
75
+ /* --- title --- */
76
+ .title {
77
+ font-size: 3rem;
78
+ font-weight: 300;
79
+ letter-spacing: 0.18em;
80
+ text-align: center;
81
+ margin: 0 0 1.5rem;
82
+ color: var(--fg);
83
+ }
84
+
85
+ .select-lead {
86
+ text-align: center;
87
+ color: var(--dim);
88
+ line-height: 1.9;
89
+ margin: 0 0 1rem;
90
+ }
91
+
92
+ .brief {
93
+ white-space: pre-wrap;
94
+ line-height: 1.9;
95
+ color: var(--dim);
96
+ font-size: 1.02rem;
97
+ min-height: 12rem;
98
+ margin-bottom: 2rem;
99
+ }
100
+ .brief .lead { color: var(--fg); letter-spacing: 0.14em; }
101
+
102
+ /* --- arc cards --- */
103
+ .arcs {
104
+ display: flex;
105
+ flex-direction: column;
106
+ gap: 1rem;
107
+ margin-top: 2rem;
108
+ }
109
+ .arc-card {
110
+ text-align: left;
111
+ background: transparent;
112
+ color: var(--fg);
113
+ border: 1px solid #2a2d31;
114
+ border-left: 3px solid #2a2d31;
115
+ padding: 1.1rem 1.3rem;
116
+ font-family: inherit;
117
+ cursor: pointer;
118
+ transition: border-color 0.3s ease, transform 0.15s ease;
119
+ }
120
+ .arc-card:hover { transform: translateX(3px); }
121
+ .arc-card[data-skin="hallway"] { border-left-color: #9fd0c0; }
122
+ .arc-card[data-skin="hallway"]:hover { border-color: #9fd0c0; }
123
+ .arc-card[data-skin="stairway"] { border-left-color: #8fc79a; }
124
+ .arc-card[data-skin="stairway"]:hover { border-color: #8fc79a; }
125
+ .arc-card[data-skin="coach"] { border-left-color: #d8b06a; }
126
+ .arc-card[data-skin="coach"]:hover { border-color: #d8b06a; }
127
+ .arc-title {
128
+ font-size: 1.3rem;
129
+ font-weight: 300;
130
+ letter-spacing: 0.12em;
131
+ }
132
+ .arc-tag {
133
+ color: var(--dim);
134
+ font-size: 0.95rem;
135
+ margin-top: 0.4rem;
136
+ line-height: 1.5;
137
+ }
138
+
139
+ /* --- hud --- */
140
+ .hud {
141
+ display: flex;
142
+ justify-content: space-between;
143
+ font-size: 0.72rem;
144
+ letter-spacing: 0.24em;
145
+ text-transform: uppercase;
146
+ color: var(--dim);
147
+ margin-bottom: 2.5rem;
148
+ gap: 1rem;
149
+ }
150
+ .best { opacity: 0.6; white-space: nowrap; }
151
+
152
+ /* --- the loop --- */
153
+ .heading {
154
+ text-align: center;
155
+ font-weight: 300;
156
+ letter-spacing: 0.22em;
157
+ font-size: 1.5rem;
158
+ margin: 0 0 2.5rem;
159
+ color: var(--fg);
160
+ }
161
+
162
+ .prose {
163
+ line-height: 2.1;
164
+ font-size: 1.12rem;
165
+ font-weight: var(--weight);
166
+ letter-spacing: var(--tracking);
167
+ min-height: 11rem;
168
+ }
169
+ .prose .line {
170
+ opacity: 0;
171
+ cursor: default;
172
+ transition: opacity 0.9s ease, color 0.4s ease;
173
+ }
174
+ .prose .line.show { opacity: 1; }
175
+ .prose .line:hover { color: #fff; }
176
+
177
+ .aside {
178
+ color: var(--dim);
179
+ font-style: italic;
180
+ min-height: 1.6rem;
181
+ opacity: 0;
182
+ transition: opacity 1.2s ease;
183
+ margin-top: 0.5rem;
184
+ }
185
+ .aside.show { opacity: 0.85; }
186
+
187
+ /* --- controls --- */
188
+ .controls {
189
+ display: flex;
190
+ gap: 1rem;
191
+ margin-top: 2.5rem;
192
+ animation: fade-in 1s ease both;
193
+ }
194
+
195
+ .confidence {
196
+ margin-top: 1.6rem;
197
+ animation: fade-in 0.6s ease both;
198
+ }
199
+ .conf-q { color: var(--dim); font-size: 0.92rem; margin: 0 0 0.7rem; }
200
+ .conf-row { display: flex; gap: 0.5rem; flex-wrap: wrap; }
201
+
202
+ .row {
203
+ display: flex;
204
+ gap: 1rem;
205
+ align-items: center;
206
+ justify-content: center;
207
+ }
208
+
209
+ /* --- buttons --- */
210
+ .btn {
211
+ background: transparent;
212
+ color: var(--fg);
213
+ border: 1px solid #2a2d31;
214
+ padding: 0.8rem 1.4rem;
215
+ font-family: inherit;
216
+ font-size: 1rem;
217
+ letter-spacing: 0.06em;
218
+ cursor: pointer;
219
+ transition: border-color 0.3s ease, background 0.3s ease, color 0.3s ease;
220
+ }
221
+ .btn:hover { border-color: var(--accent); color: #fff; }
222
+ .btn:active { transform: translateY(1px); }
223
+ .choice { flex: 1; }
224
+
225
+ .btn.ghost {
226
+ border-color: #23262a;
227
+ color: var(--dim);
228
+ padding: 0.55rem 1rem;
229
+ font-size: 0.86rem;
230
+ }
231
+ .btn.ghost:hover { color: var(--fg); border-color: var(--dim); }
232
+
233
+ #begin, #again { min-width: 12rem; }
234
+
235
+ /* --- verdict flashes --- */
236
+ .flash-good { animation: good 1.4s ease; }
237
+ .flash-bad { animation: bad 1.4s ease; }
238
+
239
+ @keyframes fade-in { from { opacity: 0; } to { opacity: 1; } }
240
+ @keyframes good {
241
+ 0% { box-shadow: none; }
242
+ 30% { box-shadow: inset 0 0 120px -40px var(--accent); }
243
+ 100% { box-shadow: none; }
244
+ }
245
+ @keyframes bad {
246
+ 0% { box-shadow: none; }
247
+ 20% { box-shadow: inset 0 0 160px -30px var(--danger); }
248
+ 100% { box-shadow: none; }
249
+ }
250
+
251
+ @media (max-width: 640px) {
252
+ .title { font-size: 2.2rem; }
253
+ #screen { padding: 1.2rem; }
254
+ }
templates/index.html ADDED
@@ -0,0 +1,71 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ <!DOCTYPE html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="utf-8" />
5
+ <meta name="viewport" content="width=device-width, initial-scale=1" />
6
+ <title>8</title>
7
+ <link rel="stylesheet" href="{{ url_for('static', filename='style.css') }}" />
8
+ </head>
9
+ <body data-skin="hallway">
10
+ <main id="screen">
11
+
12
+ <!-- Scenario / arc selection -->
13
+ <section id="select" class="panel">
14
+ <h1 class="title">8</h1>
15
+ <p class="select-lead">Somewhere you can't quite leave.<br />Choose a way through.</p>
16
+ <div id="arcs" class="arcs"></div>
17
+ </section>
18
+
19
+ <!-- Title / intro (per arc) -->
20
+ <section id="intro" class="panel hidden">
21
+ <h1 id="intro-title" class="title">8</h1>
22
+ <div id="intro-text" class="brief"></div>
23
+ <div class="row">
24
+ <button id="begin" class="btn">Begin</button>
25
+ <button id="intro-back" class="btn ghost">Back</button>
26
+ </div>
27
+ </section>
28
+
29
+ <!-- The loop (corridor / landing) -->
30
+ <section id="corridor" class="panel hidden">
31
+ <header class="hud">
32
+ <span id="progress" class="progress"></span>
33
+ <span id="best" class="best"></span>
34
+ </header>
35
+
36
+ <h2 id="heading" class="heading"></h2>
37
+
38
+ <div id="prose" class="prose"></div>
39
+
40
+ <p id="aside" class="aside"></p>
41
+
42
+ <div id="controls" class="controls hidden">
43
+ <button class="btn choice" data-choice="continue">Go on</button>
44
+ <button class="btn choice" data-choice="back">Turn back</button>
45
+ </div>
46
+
47
+ <div id="confidence" class="confidence hidden">
48
+ <p class="conf-q">How certain are you?</p>
49
+ <div class="conf-row">
50
+ <button class="btn ghost" data-conf="1">Guessing</button>
51
+ <button class="btn ghost" data-conf="2">I think so</button>
52
+ <button class="btn ghost" data-conf="3">Almost sure</button>
53
+ <button class="btn ghost" data-conf="4">Certain</button>
54
+ </div>
55
+ </div>
56
+ </section>
57
+
58
+ <!-- Win -->
59
+ <section id="win" class="panel hidden">
60
+ <div id="win-text" class="brief"></div>
61
+ <div class="row">
62
+ <button id="again" class="btn">Walk it again</button>
63
+ <button id="change" class="btn ghost">Change scenario</button>
64
+ </div>
65
+ </section>
66
+
67
+ </main>
68
+
69
+ <script src="{{ url_for('static', filename='game.js') }}"></script>
70
+ </body>
71
+ </html>