/* SPDX-License-Identifier: Apache-2.0
* Copyright 2026 alvations (Melon Lab)
*/
/* Eight -- client.
*
* The server owns the truth (whether a place changed) and ships several arcs
* (skins/backstories). This file lets the player pick an arc, applies its skin,
* then renders, paces, and quietly drifts the presentation so no two loops can
* be compared pixel-for-pixel. The unease is in the timing and typography as
* much as in the words.
*/
const $ = (sel) => document.querySelector(sel);
const el = {
select: $("#select"),
arcs: $("#arcs"),
intro: $("#intro"),
introTitle: $("#intro-title"),
introText: $("#intro-text"),
begin: $("#begin"),
introBack: $("#intro-back"),
corridor: $("#corridor"),
progress: $("#progress"),
best: $("#best"),
heading: $("#heading"),
prose: $("#prose"),
aside: $("#aside"),
controls: $("#controls"),
confidence: $("#confidence"),
touchRow: $("#touch-row"),
touchLine: $("#touch-line"),
exitOffer: $("#exit-offer"),
exitTempt: $("#exit-tempt"),
exitTake: $("#exit-take"),
win: $("#win"),
winText: $("#win-text"),
again: $("#again"),
giveup: $("#giveup"),
ending: $("#ending"),
endingText: $("#ending-text"),
endingBack: $("#ending-back"),
selectLead: $("#select-lead"),
selectReveal: $("#select-reveal"),
langLabel: $("#lang-label"),
langSelect: $("#lang-select"),
langCorner: $("#lang-corner"),
learn: $("#learn"),
learnTitle: $("#learn-title"),
learnLines: $("#learn-lines"),
learnGo: $("#learn-go"),
settingsToggle: $("#settings-toggle"),
settings: $("#settings"),
settingsTitle: $("#settings-title"),
setTextLabel: $("#set-text-label"),
setBrightLabel: $("#set-bright-label"),
setText: $("#set-text"),
setBright: $("#set-bright"),
setMotion: $("#set-motion"),
setMotionLabel: $("#set-motion-label"),
setDiffLabel: $("#set-diff-label"),
setDiffHint: $("#set-diff-hint"),
setDiffHard: $("#set-diff-hard"),
setDiffInsane: $("#set-diff-insane"),
setAgeLabel: $("#set-age-label"),
settingsGo: $("#settings-go"),
achOpen: $("#ach-open"),
achievements: $("#achievements"),
achTitle: $("#ach-title"),
achClose: $("#ach-close"),
achGrid: $("#ach-grid"),
colSectionLabel: $("#col-section-label"),
achFlash: $("#ach-flash"),
colRecap: $("#col-recap"),
npcUtterance: $("#npc-utterance"),
achSave: $("#ach-save"),
achLoad: $("#ach-load"),
achErase: $("#ach-erase"),
achFile: $("#ach-file"),
eraseConfirm: $("#erase-confirm"),
eraseTitle: $("#erase-title"),
eraseBody: $("#erase-body"),
eraseOk: $("#erase-ok"),
eraseCancel: $("#erase-cancel"),
cutscene: $("#cutscene"),
cutsceneText: $("#cutscene-text"),
credits: $("#credits"),
creditsScroller: $("#credits-scroller"),
creditsRoll: $("#credits-roll"),
creditsDone: $("#credits-done"),
creditsRow: $("#credits-row"),
viewCredits: $("#view-credits"),
nudge: $("#nudge"),
fold: $("#fold"),
foldTitle: $("#fold-title"),
foldLines: $("#fold-lines"),
foldHideRow: $("#fold-hide-row"),
foldHide: $("#fold-hide"),
foldHideLabel: $("#fold-hide-label"),
foldGo: $("#fold-go"),
look: $("#look"),
lookBack: $("#look-back"),
lookLine: $("#look-line"),
share: $("#share"),
shareOpen: $("#share-open"),
sharePanel: $("#share-panel"),
shareLine: $("#share-line"),
shareNative: $("#share-native"),
sharePlatforms: $("#share-platforms"),
shareX: $("#share-x"),
shareFb: $("#share-fb"),
shareLi: $("#share-li"),
shareCopy: $("#share-copy"),
body: document.body,
};
let current = null; // latest room payload
let meta = null; // current arc's presentation meta
let selectedArc = null; // arc id in play
let firstArcRun = false; // the player's very first run of this arc: no Act 2
let onboarding = false; // first-reset instructions are up; gate the loop controls
let pendingChoice = null; // choice awaiting a confidence rating
let touchLineTimer = null; // auto-fade timer for the Act 2 flavour line
let winIsFirst = false; // this win is the first escape of the arc (roll credits)
let againMode = "replay"; // the win button: "replay" (Walk it again) or "credits"
let creditsAutoTimer = null; // first-win: auto-roll the credits if the player lingers
// Long enough to read the benediction and take in the look-back / share triggers
// (~18s), plus 10%. The win screen returns after the roll, so this only ever
// gives the player MORE, never takes the moment away.
const CREDITS_DWELL = 20000;
let hesitated = false; // player already admitted doubt this turn (one re-decide)
let decideStart = 0; // when the controls appeared, to time the player's dwell
let renderGen = 0; // bumped each loop; stale inspect asides are dropped
let drift = 0; // slowly accumulating presentation drift
// Quality of the current unbroken climb (level 0 -> win), used to reflect *how*
// a run was won, not just that it was. Reset whenever a climb starts fresh.
function freshRun() { return { touches: 0, guessed: false }; }
let run = freshRun();
let readRecordText = ""; // the fleeting "how you did" line for the win screen
let introLines = []; // the current arc's localized intro, reused on first reset
let cutsceneLines = []; // the arc's rule-reminder, shown before the first-reset instructions
// Respect reduced motion (OS setting or the in-app toggle). The game leans on
// slow fades and line-by-line reveals; for these users we show text at once (JS)
// and drop the transitions/animations (CSS). Recomputed by applySettings().
function osReduceMotion() {
try { return window.matchMedia("(prefers-reduced-motion: reduce)").matches; }
catch (e) { return false; }
}
let reduceMotion = osReduceMotion();
// Reveal the arc cards + language row a beat after the title paints. Idempotent;
// the first paint is just "8" and the two lead lines (the mystery, on purpose).
function revealSelect() {
// The ways-through fade in, but their space was reserved from first paint (the
// .pre state keeps layout), so the title and lead never shift position.
if (el.selectReveal) el.selectReveal.classList.remove("pre");
}
// --- helpers ---------------------------------------------------------------
// The server hands us a session id on /api/new; we echo it on every request so
// state survives even where cookies don't (e.g. a cross-site iframe on Spaces).
let sid = null;
// Chosen language; echoed on every request so the server localizes content.
let lang = localStorage.getItem("h8_lang") || "en_US";
let ui = {}; // current UI-string catalog {name: text}
// Chosen difficulty (easy/normal/hard); echoed so the server scales the loop.
// Hard is available to everyone from the start; Normal is the default.
let difficulty = localStorage.getItem("h8_diff") || "normal";
// Reading register (simple/normal): simple loads a plainer-language UI overlay,
// for younger players. Default normal. Does not change any mechanic.
let register = localStorage.getItem("h8_register") || "normal";
// Hard is available to everyone from the start; Normal stays the default. Insane
// is honoured only once it is unlocked (all achievements bar the held/date-locked
// ones), so a stale saved "insane" never takes effect before it is earned.
function effectiveDifficulty() {
if (difficulty === "insane" && !insaneUnlocked()) return "normal";
return difficulty;
}
function headers(extra) {
const h = Object.assign({}, extra || {});
if (sid) h["X-Sid"] = sid;
if (lang) h["X-Lang"] = lang;
h["X-Diff"] = effectiveDifficulty();
return h;
}
// Fetch and apply the UI-string catalog for the current language.
async function applyUI() {
try {
const res = await fetch(`/static/i18n/${lang}.json`);
ui = res.ok ? await res.json() : {};
} catch (e) {
ui = {};
}
// Simple register: overlay a plainer-language catalog on top of the normal
// one, per key, for younger players. Missing keys fall back to normal.
if (register === "simple") {
try {
const r = await fetch(`/static/i18n/${lang}.simple.json`);
if (r.ok) ui = Object.assign({}, ui, await r.json());
} catch (e) {}
}
const t = (k, fallback) => ui[k] || fallback;
if (el.selectLead) {
el.selectLead.innerHTML =
`${t("select_lead_1", "Somewhere you can't quite leave.")}
` +
`${t("select_lead_2", "Choose a way through.")}`;
}
el.begin.textContent = t("begin", "Begin");
el.introBack.textContent = t("back", "Back");
el.again.textContent = t("again", "Walk it again");
el.giveup.textContent = t("give_up", "Give up · back to start");
if (el.endingBack) el.endingBack.textContent = t("ending_back", "Back to the start");
// Accessibility labels for the icon-only controls.
if (el.settingsToggle) el.settingsToggle.setAttribute("aria-label", t("a11y_settings", "Display settings"));
if (el.achClose) el.achClose.setAttribute("aria-label", t("a11y_close", "Close"));
// The sound toggle repaints its own aria-label (on mute state) from audio.js;
// hand it the localized strings to use.
if (window.ambience && window.ambience.setLabels) {
window.ambience.setLabels(t("a11y_sound_on", "Sound on"), t("a11y_sound_off", "Sound off"));
}
if (el.langLabel) el.langLabel.textContent = t("language", "Language");
const cq = document.querySelector(".conf-q");
if (cq) cq.textContent = t("conf_q", "How certain are you?");
[1, 2, 3, 4].forEach((n) => {
const b = document.querySelector(`[data-conf="${n}"]`);
const d = { 1: "Guessing", 2: "I think so", 3: "Almost sure", 4: "Certain" };
if (b) b.textContent = t(`conf_${n}`, d[n]);
});
// The first-reset onboarding panel reuses the arc's own (already localized)
// intro; only its dismiss button is a shared UI string.
if (el.learnGo) el.learnGo.textContent = t("begin", "Begin");
// Win-screen share labels (the prompt is per-arc, set in buildShare; the
// invitation line itself is chosen per win).
if (el.shareNative) el.shareNative.textContent = t("share_native", "Share");
if (el.shareCopy) el.shareCopy.textContent = t("share_copy", "Copy link");
if (el.lookBack) el.lookBack.textContent = t("look_back", "Look back.");
if (el.foldGo) el.foldGo.textContent = t("begin", "Go on");
if (el.foldHideLabel) el.foldHideLabel.textContent = t("fold_hide", "Don't show this again.");
if (el.settingsTitle) el.settingsTitle.textContent = t("settings_title", "Display");
if (el.setTextLabel) el.setTextLabel.textContent = t("set_text", "Text size");
if (el.setBrightLabel) el.setBrightLabel.textContent = t("set_bright", "Brightness");
if (el.setMotionLabel) el.setMotionLabel.textContent = t("set_motion", "Reduce motion");
if (el.setDiffLabel) el.setDiffLabel.textContent = t("set_diff", "Difficulty");
if (el.setAgeLabel) el.setAgeLabel.textContent = t("set_age", "Reading");
document.querySelectorAll('.set-opts[data-set="diff"] button').forEach((b) => {
const d = { easy: "Easy", normal: "Normal", hard: "Hard", insane: "Insane" };
b.textContent = t("diff_" + b.dataset.diff, d[b.dataset.diff]);
});
document.querySelectorAll('.set-opts[data-set="register"] button').forEach((b) => {
const d = { simple: "Simple", normal: "Normal" };
b.textContent = t("age_" + b.dataset.age, d[b.dataset.age]);
});
if (el.settingsGo) el.settingsGo.textContent = t("set_done", "Done");
if (el.achOpen) el.achOpen.setAttribute("aria-label", t("ach_open", "Achievements"));
if (el.achTitle) el.achTitle.textContent = t("mem_title", "Memory");
if (el.achSave) el.achSave.textContent = t("ach_save", "Save");
if (el.achLoad) el.achLoad.textContent = t("ach_load", "Load");
if (el.achErase) el.achErase.textContent = t("erase", "Erase");
if (el.eraseTitle) el.eraseTitle.textContent = t("erase_title", "Erase your memory?");
if (el.eraseBody) el.eraseBody.textContent = t("erase_body",
"Everything this place remembers of you goes: the fragments you have recollected, the marks you have earned, every run. This cannot be undone.");
if (el.eraseOk) el.eraseOk.textContent = t("erase", "Erase");
if (el.eraseCancel) el.eraseCancel.textContent = t("erase_cancel", "Keep it");
if (el.colSectionLabel) el.colSectionLabel.textContent = t("col_section", "Recollections");
if (el.viewCredits) el.viewCredits.textContent = t("view_credits", "View credits");
if (el.langSelect) el.langSelect.setAttribute("aria-label", t("language", "Language"));
}
// --- display / accessibility settings --------------------------------------
// Defaults leave the look exactly as it is; these only change on opt-in, and
// persist to localStorage.
function markOpts(set, val) {
document.querySelectorAll(`.set-opts[data-set="${set}"] button`).forEach((b) =>
b.classList.toggle("on", b.dataset.val === String(val))
);
}
function applySettings() {
// Ship defaults (a fresh visitor, and so every redeploy): text "0" = smallest,
// bright "0" = most dim. Difficulty and register default to "normal" above.
// Keep these as the resting state; the sliders/words only move on opt-in.
let text = "0", bright = "0", motionPref = null;
try {
text = localStorage.getItem("h8_text") || "0";
bright = localStorage.getItem("h8_bright") || "0";
motionPref = localStorage.getItem("h8_motion"); // null | "0" | "1"
} catch (e) {}
document.documentElement.dataset.text = text;
document.body.dataset.bright = bright;
// Tri-state so the in-app toggle can OVERRIDE the OS, not merely add to it:
// "1" -> reduced (user asked), "0" -> full motion (user asked, beats the OS),
// unset -> follow the OS (prefers-reduced-motion / Low Power Mode).
// Without this a phone in Low Power Mode forced reduced motion even after the
// player turned the toggle off, so "full motion" looked broken on mobile.
reduceMotion = motionPref === "1" ? true
: motionPref === "0" ? false
: osReduceMotion();
document.documentElement.classList.toggle("motion-reduce", reduceMotion);
if (el.setText) el.setText.value = text;
if (el.setBright) el.setBright.value = bright;
// The checkbox reflects the effective state, so with OS reduce-motion on it
// shows checked until the player explicitly clears it.
if (el.setMotion) el.setMotion.checked = reduceMotion;
applyDifficultyUI();
markOpts("register", register);
}
// Reflect the chosen difficulty. Hard is available to everyone from the start;
// the default is still Normal.
function applyDifficultyUI() {
markOpts("diff", difficulty);
// Reveal the Insane option only once it is unlocked (a completionist reward);
// it ships hidden. If a stale save selected it while still locked, fall back to
// the effective (clamped) difficulty for the highlight.
const unlocked = insaneUnlocked();
if (el.setDiffInsane) el.setDiffInsane.classList.toggle("hidden", !unlocked);
if (!unlocked && difficulty === "insane") markOpts("diff", "normal");
if (el.setDiffHint) {
el.setDiffHint.textContent = (difficulty === "insane" && unlocked)
? (ui["diff_hint_insane"] || "Insane remakes what normal is, every run.")
: (ui["diff_hint_hard"] || "Hard adds new kinds of change.");
}
}
applySettings();
if (el.settingsToggle) {
el.settingsToggle.addEventListener("click", () => {
// Refresh the difficulty row against the live unlock state: applySettings()
// first runs at load, before `mem` exists (temporal dead zone), so Insane is
// hidden then even for a completionist. Recompute on open so an earned Insane
// actually appears without needing to toggle another setting first.
applyDifficultyUI();
if (el.settings) { el.settings.classList.remove("hidden"); }
if (el.settingsGo) el.settingsGo.focus();
});
}
const closeSettings = () => { if (el.settings) el.settings.classList.add("hidden"); };
if (el.settingsGo) el.settingsGo.addEventListener("click", closeSettings);
if (el.settings) {
el.settings.addEventListener("click", (e) => { if (e.target === el.settings) closeSettings(); });
document.addEventListener("keydown", (e) => {
if (e.key === "Escape" && !el.settings.classList.contains("hidden")) closeSettings();
});
}
document.querySelectorAll(".set-opts").forEach((group) => {
group.addEventListener("click", (e) => {
const b = e.target.closest("button[data-val]");
if (!b || b.disabled) return;
const set = group.dataset.set;
const val = b.dataset.val;
if (set === "diff") {
if (val === "insane" && !insaneUnlocked()) return; // locked until earned
difficulty = val; // hard is available to everyone
}
if (set === "register") register = val;
try { localStorage.setItem("h8_" + set, val); } catch (e) {}
applySettings();
if (set === "register") applyUI().then(() => { if (current) renderRoom(current); });
});
});
if (el.setMotion) {
el.setMotion.addEventListener("change", () => {
try { localStorage.setItem("h8_motion", el.setMotion.checked ? "1" : "0"); } catch (e) {}
applySettings();
});
}
// Text-size and brightness are 3-stop sliders (0/1/2).
[["setText", "h8_text"], ["setBright", "h8_bright"]].forEach(([ref, key]) => {
if (!el[ref]) return;
el[ref].addEventListener("input", () => {
try { localStorage.setItem(key, el[ref].value); } catch (e) {}
applySettings();
});
});
let EXPOSED_LANGS = []; // the picker's set (from the server)
let LANG_DEFAULT = "en_US";
// The picker's options: only the exposed core set. Other locales are fully
// translated and compiled on disk but stay hidden (not exposed) for now.
function langOptions() {
const seen = new Set();
const list = [];
EXPOSED_LANGS.forEach((l) => { if (!seen.has(l.code)) { seen.add(l.code); list.push(l); } });
return list;
}
function rebuildLangPicker() {
if (!el.langSelect) return;
const list = langOptions();
el.langSelect.innerHTML = "";
list.forEach((l) => {
const o = document.createElement("option");
o.value = l.code;
o.textContent = l.native;
if (l.code === lang) o.selected = true;
el.langSelect.appendChild(o);
});
}
async function setLang(code) {
lang = code;
try { localStorage.setItem("h8_lang", lang); } catch (e) {}
document.documentElement.lang = lang.split("_")[0];
rebuildLangPicker();
await applyUI();
loadSelect(); // re-render arc cards with localized titles/taglines
}
async function initLangPicker() {
const data = await get("/api/langs");
if (!el.langSelect || !data.langs) return;
EXPOSED_LANGS = data.langs;
LANG_DEFAULT = data.default || "en_US";
// A previously-picked locale may no longer be exposed; fall back so the
// picker and `lang` stay in sync.
if (!langOptions().some((l) => l.code === lang)) {
lang = LANG_DEFAULT;
localStorage.setItem("h8_lang", lang);
}
rebuildLangPicker();
el.langSelect.addEventListener("change", () => setLang(el.langSelect.value));
}
async function get(url) {
const res = await fetch(url, { headers: headers() });
const data = await res.json();
if (data && data.sid) sid = data.sid;
return data;
}
async function post(url, body) {
const res = await fetch(url, {
method: "POST",
headers: headers({ "Content-Type": "application/json" }),
body: JSON.stringify(body || {}),
});
const data = await res.json();
if (data && data.sid) sid = data.sid;
return data;
}
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
// A tiny jitter so pacing feels alive but not like noise.
const jitter = (base, spread) => base + Math.floor(Math.random() * spread);
function show(section) {
[el.select, el.intro, el.corridor, el.win, el.ending].forEach((s) =>
s.classList.add("hidden")
);
section.classList.remove("hidden");
// The language corner and the collection button belong to the selection screen
// only; both are chosen before a run, not mid-loop, so the loop stays clean.
const onSelect = section === el.select;
if (el.langCorner) el.langCorner.classList.toggle("hidden", !onSelect);
if (el.achOpen) el.achOpen.classList.toggle("hidden", !onSelect);
}
// Nudge the presentation a fraction every loop. Individually invisible;
// cumulatively, "something feels off."
function applyDrift() {
drift += 1;
el.body.style.setProperty("--bg-shift", Math.min(drift * 0.25, 6).toFixed(2));
const weight = 340 + Math.round(Math.sin(drift / 3) * 30);
document.documentElement.style.setProperty("--weight", String(weight));
const track = (0.01 + Math.sin(drift / 5) * 0.012).toFixed(4);
document.documentElement.style.setProperty("--tracking", `${track}em`);
}
// Strip any directional glyphs an arc baked into its labels, so the UI can
// impose a single consistent convention.
function stripArrows(s) {
return (s || "").replace(/[↑↓←→⇠⇢]/g, "").trim();
}
// Apply an arc's skin, title, and button labels. Convention, always: back sits
// on the LEFT, forward on the RIGHT. The arrow glyph follows the arc's axis of
// travel -- horizontal (←/→) by default, vertical (↑ back / ↓ forward) for
// stairs and the like.
// The last arc the player entered, remembered so the landing chrome (and the
// settings panel opened from it) keep that arc's colour instead of snapping
// back to a default. First-ever visit falls back to the canonical hallway blue.
function lastSkin() {
try { return localStorage.getItem("h8_last_skin") || "hallway"; }
catch (e) { return "hallway"; }
}
function applyMeta(m) {
meta = m;
el.body.dataset.skin = m.skin || "hallway";
try { localStorage.setItem("h8_last_skin", m.skin || "hallway"); } catch (e) {}
document.title = m.title || "8";
el.introTitle.textContent = m.title || "8";
const fwd = stripArrows(m.go_on) || "Go on";
const back = stripArrows(m.turn_back) || "Turn back";
const vertical = m.axis === "vertical";
const backArrow = vertical ? "↑" : "←";
const fwdArrow = vertical ? "↓" : "→";
el.controls.querySelector('[data-choice="continue"]').textContent =
`${fwd} ${fwdArrow}`;
el.controls.querySelector('[data-choice="back"]').textContent =
`${backArrow} ${back}`;
}
// --- rendering -------------------------------------------------------------
function setHud(p) {
const floor = p.goal - p.level;
const remaining = p.goal - p.level;
const tmpl = (meta && meta.progress) || "{level} / {goal}";
const label = tmpl
.replace("{level}", p.level)
.replace("{goal}", p.goal)
.replace("{floor}", floor)
.replace("{remaining}", remaining);
const dots = Array.from({ length: p.goal }, (_, i) =>
i < p.level ? "•" : "·"
).join(" ");
el.progress.textContent = `${label} ${dots}`;
el.best.textContent = p.best ? (ui["furthest"] || "furthest {n}").replace("{n}", p.best) : "";
}
async function renderRoom(p) {
current = p;
renderGen++; // invalidate any inspect aside pending from a past loop
if (p.meta) applyMeta(p.meta);
setHud(p);
applyDrift();
el.heading.textContent = p.room.heading;
el.prose.innerHTML = "";
el.aside.textContent = "";
el.aside.classList.remove("show");
el.controls.classList.add("hidden");
el.confidence.classList.add("hidden");
clearTouch();
pendingChoice = null;
hesitated = false;
const lines = p.room.sentences;
const shown = p.room.shown || [];
lines.forEach((text, i) => {
const div = document.createElement("div");
div.className = "line";
div.textContent = text;
if (shown[i]) {
div.dataset.prop = shown[i];
attachInspect(div);
}
el.prose.appendChild(div);
});
// Reveal one line at a time with drifting pauses. The closing line (the
// sense/atmosphere is always last in the server's canonical order) gets a
// deliberate longer beat so it always lands on its own, after the rest have
// settled, rather than washing in together with them (the line fade is ~0.9s,
// so a short stagger would overlap the finish of the previous line). Under
// reduced motion, show them all at once.
const nodes = [...el.prose.children];
if (reduceMotion) {
nodes.forEach((n) => n.classList.add("show"));
} else {
for (let i = 0; i < nodes.length; i++) {
const isLast = i === nodes.length - 1;
if (isLast && nodes.length > 1) {
// Let the preceding lines finish arriving, then a clear pause, so the
// closing sense reads as the end and never appears mid-load.
await sleep(jitter(1050, 400));
} else {
const long = Math.random() < 0.16;
await sleep(long ? jitter(1000, 450) : jitter(360, 320));
}
nodes[i].classList.add("show");
}
}
if (p.room.adaptive) {
await sleep(reduceMotion ? 0 : 600);
el.aside.textContent = p.room.adaptive;
el.aside.classList.add("show");
}
await sleep(reduceMotion ? 0 : 400);
// Do not reveal the loop controls while the first-reset instructions are up (or
// pending behind the rule cutscene): otherwise the level-0 controls go live
// under the overlay and the player can slip a call through and cross into
// level 1 before ever seeing the instructions. hideOnboard() shows them.
if (!onboarding) el.controls.classList.remove("hidden");
buildTouch(p); // Act 2: the touchable details this loop
decideStart = performance.now(); // start timing the player's deliberation
}
// --- Act 2: touch the room -------------------------------------------------
function clearTouch() {
clearTimeout(touchLineTimer);
if (el.touchRow) el.touchRow.innerHTML = "";
// Fade the flavour line out (keep its text so the CSS opacity transition can
// run) instead of blanking it instantly; otherwise a fast commit right after a
// touch makes the line vanish with no fade. The text is left in place, now
// invisible, and the next showTouchLine replaces it.
if (el.touchLine) el.touchLine.classList.remove("show");
if (el.exitOffer) el.exitOffer.classList.add("hidden");
}
// Build the contextual verbs for whatever details are shown, plus the rare
// false-way-out affordance. Touching never decides the forward/back call.
function buildTouch(p) {
clearTouch();
const room = p.room || {};
const level = p.level || 0;
// Act 2 (the touchable details) holds back through the opening loops so a
// player first learns the core turn-back/go-on cleanly, then appears reliably
// from the middle of a climb on. Onset is one loop later on the very first
// climb of an arc (a gentler introduction), otherwise from level 3 (~the
// fourth loop). Once past the onset it is usually offered, a couple of details
// at a time, so the layer is actually met instead of being a rare surprise.
const onset = firstArcRun ? 4 : 3;
const touch = (room.touch || []).slice();
const chance = level >= 5 ? 0.72 : 0.55;
if (level >= onset && touch.length && Math.random() < chance) {
for (let i = touch.length - 1; i > 0; i--) { // shuffle
const j = Math.floor(Math.random() * (i + 1));
[touch[i], touch[j]] = [touch[j], touch[i]];
}
touch.slice(0, 2).forEach((t) => { // at most two items
const b = document.createElement("button");
b.className = "touch-btn link-btn";
b.textContent = t.verb;
b.addEventListener("click", () => interactTouch(t.prop, b));
el.touchRow.appendChild(b);
});
}
if (room.exit_offer && el.exitOffer) {
el.exitTempt.textContent = room.exit_offer.tempt || "";
el.exitTake.textContent = room.exit_offer.action || "";
el.exitOffer.classList.remove("hidden");
}
// The passenger's fixed utterance (coach). Passive and skippable; rendered
// louder in easy mode so a younger player notices the trail.
if (el.npcUtterance) {
const u = room.npc_utterance || "";
el.npcUtterance.textContent = u;
el.npcUtterance.classList.toggle("show", !!u);
el.npcUtterance.classList.toggle("prominent",
!!u && effectiveDifficulty() === "easy");
}
}
async function interactTouch(prop, btn) {
if (btn) btn.disabled = true;
const res = await post("/api/interact", { prop });
if (!res || res.kind === "none") return;
if (res.kind === "fold") {
// Curiosity folded the run back to the start. Hold the fold line long enough
// to actually read (it was flashing past before), then drop into the fresh
// level-0 loop the server handed back and explain that the reset was this
// touch, so it never reads as a glitch. The explainer gates the controls
// (below), so the scene does not visibly rush to level 0 underneath it.
showTouchLine(res.line);
await sleep(reduceMotion ? 1200 : 2600);
run = freshRun(); // the fold sent the climb back to the start
renderRoom(res);
maybeFoldExplainer();
return;
}
// flashback / neutral reaction / red herring: flavour only. It still counts as
// a second look, so an unaided ("cold") escape is one made without any touch.
run.touches++;
if (res.kind === "flashback") recordFlashback(selectedArc, res.idx, res.line);
showTouchLine(res.line);
}
function showTouchLine(text) {
if (!el.touchLine) return;
clearTimeout(touchLineTimer);
el.touchLine.textContent = text || "";
el.touchLine.classList.remove("show");
requestAnimationFrame(() => el.touchLine.classList.add("show"));
// Let it hold long enough to read, then fade on its own, so a flavour line
// never lingers until the next commit. The fade is a plain opacity change
// (not motion), so it stays visible even under reduced motion (see the CSS
// exemption for .touch-line), where other animations are suppressed.
touchLineTimer = setTimeout(() => {
el.touchLine.classList.remove("show");
}, 4500);
}
async function takeExit() {
const res = await post("/api/interact", { action: "exit" });
// Only record the alternate ending (and unlock its achievement) once the
// server has actually resolved the exit for this arc. Guarding on the
// server's own kind == "exit" means the badge can never appear without the
// ending truly being reached.
if (!res || res.kind !== "exit") return;
recordEnding(selectedArc);
showEnding(res.ending || []);
}
function showEnding(lines) {
el.endingText.innerHTML = "";
lines.forEach((ln, i) => {
const div = document.createElement("div");
div.textContent = ln;
if (i === lines.length - 1) div.className = "lead";
el.endingText.appendChild(div);
});
if (window.ambience) window.ambience.play((meta && meta.skin) || "hallway");
show(el.ending);
}
// After a touch folds the run, explain that the reset was the player's own doing
// (not a bug). The "don't show this again" opt-out only appears from the 2nd
// time on; once chosen, the explainer never shows again.
function maybeFoldExplainer() {
if (!el.fold) return;
let hide = false, seen = 0;
try {
hide = localStorage.getItem("h8_fold_hide") === "1";
seen = parseInt(localStorage.getItem("h8_fold_seen") || "0", 10) || 0;
} catch (e) {}
if (hide) return;
seen += 1;
try { localStorage.setItem("h8_fold_seen", String(seen)); } catch (e) {}
el.foldTitle.textContent = ui["fold_title"] || "Back to the start.";
el.foldLines.innerHTML = "";
[ui["fold_1"], ui["fold_2"]].forEach((tx) => {
if (!tx) return;
const p = document.createElement("p");
p.textContent = tx;
el.foldLines.appendChild(p);
});
if (el.foldHideRow) el.foldHideRow.classList.toggle("hidden", seen < 2);
if (el.foldHide) el.foldHide.checked = false;
// Gate the loop controls while the explainer is up (shares the onboarding
// gate): the level-0 controls must not go live under the modal, so the reset
// reads as a deliberate beat, not a scene that rushed past. hideFoldExplainer
// releases it.
onboarding = true;
el.controls.classList.add("hidden");
el.fold.classList.remove("hidden");
if (el.foldGo) el.foldGo.focus();
}
function hideFoldExplainer() {
if (el.foldHide && el.foldHide.checked) {
try { localStorage.setItem("h8_fold_hide", "1"); } catch (e) {}
}
if (el.fold) el.fold.classList.add("hidden");
// Release the control gate and hand the loop back at level 0.
onboarding = false;
if (el.corridor && !el.corridor.classList.contains("hidden")) {
el.controls.classList.remove("hidden");
decideStart = performance.now();
}
}
if (el.foldGo) el.foldGo.addEventListener("click", hideFoldExplainer);
if (el.fold) {
el.fold.addEventListener("click", (e) => { if (e.target === el.fold) hideFoldExplainer(); });
document.addEventListener("keydown", (e) => {
if (e.key === "Escape" && !el.fold.classList.contains("hidden")) hideFoldExplainer();
});
}
// Lingering on a detail may earn an unsettling aside from the server.
function attachInspect(node) {
let timer = null;
node.addEventListener("mouseenter", () => {
const gen = renderGen; // the loop this hover belongs to
timer = setTimeout(async () => {
// Bail if the loop moved on (or the line left the screen) before the
// dwell elapsed, so a stale aside never lands on the next loop, appearing
// "before" its prose has rendered.
if (gen !== renderGen || !node.isConnected) return;
const { line } = await post("/api/inspect", { prop: node.dataset.prop });
if (gen !== renderGen || !node.isConnected) return; // loop changed mid-request
if (line && !el.aside.textContent) {
el.aside.textContent = line;
el.aside.classList.add("show");
}
}, 1300);
});
node.addEventListener("mouseleave", () => clearTimeout(timer));
}
function flash(correct) {
const cls = correct ? "flash-good" : "flash-bad";
el.corridor.classList.add(cls);
setTimeout(() => el.corridor.classList.remove(cls), 1400);
}
// Hard mode's "keep scanning" lesson: pulse the prose lines whose details had
// changed, so a correct turn-back on a two-change loop reveals both. Silent, no
// text; the two lines briefly glow the arc's accent.
function flareLines(props) {
(props || []).forEach((prop) => {
const line = el.prose.querySelector(`.line[data-prop="${prop}"]`);
if (line) {
line.classList.remove("flare");
// reflow so the animation restarts even if the class lingered
void line.offsetWidth;
line.classList.add("flare");
}
});
}
// The rule is taught by failing, not up front. The very first time the place
// resets the player, re-show the arc's own backstory intro (already localized),
// line by line, as a plain reminder of how it works. Once only.
function buildOnboard() {
// Heading is a neutral "Instructions" label (localized), not the arc's scene
// banner (intro[0], e.g. a time-of-day framing) which could read as part of
// the how-to. The body keeps the backstory's own wording verbatim, minus that
// banner line. Empty spacer lines are skipped.
const lines = introLines.filter((s) => (s || "").trim() !== "");
if (el.learnTitle) el.learnTitle.textContent = ui["instructions"] || "Instructions";
if (el.learnLines) {
el.learnLines.innerHTML = "";
// Simple (younger-reader) register: show ONLY the short plain rule, not the
// arc's atmospheric backstory, so the how-to is short and unmistakable.
if (register === "simple" && ui["rules_plain"]) {
const plain = document.createElement("p");
plain.className = "learn-plain";
plain.textContent = ui["rules_plain"];
el.learnLines.appendChild(plain);
return;
}
lines.slice(1).forEach((text) => {
const p = document.createElement("p");
p.textContent = text;
el.learnLines.appendChild(p);
});
}
}
// A brief, arc-specific rule cutscene that fades into the instructions. Click,
// key, or a beat's pause advances it. Under reduced motion it waits for input
// (no timed disappearance).
function playCutscene(lines, then) {
const clean = (lines || []).filter((s) => s && s.trim());
if (!el.cutscene || !clean.length) { then(); return; }
el.cutsceneText.innerHTML = "";
clean.forEach((tx) => {
const p = document.createElement("p");
p.textContent = tx;
el.cutsceneText.appendChild(p);
});
el.cutscene.classList.remove("hidden");
let done = false;
let timer = null;
const advance = () => {
if (done) return;
done = true;
clearTimeout(timer);
document.removeEventListener("keydown", onKey);
el.cutscene.classList.add("hidden");
then();
};
function onKey() { advance(); }
el.cutscene.onclick = advance;
document.addEventListener("keydown", onKey);
if (!reduceMotion) timer = setTimeout(advance, 7200);
}
// --- ending credits ---------------------------------------------------------
// Authored, English-only content (a personal dedication in the maker's voice;
// only the "View credits" trigger is localized). Rendered in the landing's
// quiet style. Each entry is [cssClass, text]; "gap" is a spacer.
const CREDITS = [
{ presenter: "a Melon Lab game", title: "EIGHT" },
{ cast: [["Game Developer, Designer & Writer", ["alvations", { n: "Claude Code", sm: true }]]] },
{ head: "PRODUCTION", cast: [
["Executive Producer", ["alvations"]],
["Administrative Support", [{ n: "Claire (AI Secretary)", sm: true }]],
] },
// Single-role departments: shown directly, no section header.
{ cast: [
["Art Director", ["alvations"]],
["BGM & SFX Director", [{ n: "Claude Code", sm: true }]],
] },
{ head: "SPECIAL THANKS", notes: ["The AI critics who never pulled their punches"] },
{ head: "DEDICATED TO", ded: [
["the eldest", "“Ask your robot to make me a game”"],
["the youngest", "the ewe of my life"],
["the support", "who always believe my craziest ideas"],
] },
// The closing studio splash: the wordmark alone, a copyright beneath.
{ splash: "MELON LAB", copy: "© 2026 alvations", solo: true },
];
let creditsTimer = null;
function renderCredits() {
if (!el.creditsRoll) return;
const cline = (cls, text) => {
const d = document.createElement("div");
d.className = cls;
d.textContent = text;
return d;
};
el.creditsRoll.innerHTML = "";
CREDITS.forEach((b) => {
const block = document.createElement("div");
block.className = "credits-block" + (b.solo ? " credits-solo" : "");
if (b.presenter) block.appendChild(cline("credits-presenter", b.presenter));
if (b.splash) block.appendChild(cline("credits-splash", b.splash));
if (b.title) block.appendChild(cline("credits-title", b.title));
if (b.head) block.appendChild(cline("credits-head", b.head));
(b.cast || []).forEach(([role, names]) => {
const c = document.createElement("div");
c.className = "credits-cast";
c.appendChild(cline("credits-role", role));
// A name is a string (default size) or { n, sm } for a smaller line.
names.forEach((nm) => {
const text = typeof nm === "string" ? nm : nm.n;
const small = typeof nm === "object" && nm.sm;
c.appendChild(cline("credits-name" + (small ? " credits-name-sm" : ""), text));
});
block.appendChild(c);
});
(b.notes || []).forEach((n) => block.appendChild(cline("credits-note", n)));
(b.ded || []).forEach(([who, note]) => {
const d = document.createElement("div");
d.className = "credits-ded";
d.appendChild(cline("credits-role", who));
d.appendChild(cline("credits-note", note));
block.appendChild(d);
});
(b.quote || []).forEach((q) => block.appendChild(cline("credits-quote", q)));
if (b.by) block.appendChild(cline("credits-by", b.by));
if (b.copy) block.appendChild(cline("credits-copy", b.copy));
el.creditsRoll.appendChild(block);
});
}
let creditsAfter = null; // run once when the roll closes (win-screen transition)
function closeCredits() {
if (!el.credits) return;
clearTimeout(creditsTimer);
el.credits.classList.add("hidden");
el.creditsRoll.style.animation = "";
const cb = creditsAfter; creditsAfter = null;
if (typeof cb === "function") cb();
}
function openCredits(after) {
if (!el.credits) return;
creditsAfter = typeof after === "function" ? after : null;
renderCredits();
el.credits.classList.remove("hidden");
if (window.ambience) window.ambience.play("landing");
// Restart the roll animation from the top each time it opens.
el.creditsRoll.style.animation = "none";
// eslint-disable-next-line no-unused-expressions
el.creditsRoll.offsetHeight; // reflow so the restart takes
el.creditsRoll.style.animation = "";
clearTimeout(creditsTimer);
if (reduceMotion) {
// No roll: it reads statically, closed by the player (or after a long dwell).
el.creditsScroller.scrollTop = 0;
} else {
// Auto-close a beat after the roll finishes (kept in JS so it survives even
// if the animationend event is missed).
creditsTimer = setTimeout(closeCredits, 54000);
}
}
if (el.creditsDone) el.creditsDone.addEventListener("click", closeCredits);
if (el.credits) el.credits.addEventListener("click", (e) => {
// A tap anywhere but the scrollbar skips/closes.
if (e.target === el.credits || e.target === el.creditsScroller) closeCredits();
});
if (el.creditsRoll) el.creditsRoll.addEventListener("animationend", closeCredits);
if (el.viewCredits) el.viewCredits.addEventListener("click", openCredits);
function maybeOnboard(reset) {
if (!reset || !el.learn || !introLines.length) return;
if (localStorage.getItem("h8_onboarded")) return;
localStorage.setItem("h8_onboarded", "1");
// Gate the loop controls until the instructions are dismissed: the rule
// cutscene runs first (several seconds), and the level-0 controls must not go
// live underneath it. renderRoom() checks this flag; hideOnboard() releases it.
onboarding = true;
el.controls.classList.add("hidden");
// First reset: the rule cutscene, then the instructions it fades into.
playCutscene(cutsceneLines, () => {
buildOnboard();
el.learn.classList.remove("hidden");
if (el.learnGo) el.learnGo.focus();
});
}
function hideOnboard() {
if (el.learn) el.learn.classList.add("hidden");
// Release the gate and hand the loop back: reveal the controls (only if we are
// actually in the corridor) and restart the deliberation timer so the first
// post-instructions call is timed from now, not from when the room rendered.
onboarding = false;
if (el.corridor && !el.corridor.classList.contains("hidden")) {
el.controls.classList.remove("hidden");
decideStart = performance.now();
}
}
if (el.learnGo) el.learnGo.addEventListener("click", hideOnboard);
if (el.learn) {
// dismiss on backdrop click or Escape, like any quiet modal
el.learn.addEventListener("click", (e) => {
if (e.target === el.learn) hideOnboard();
});
document.addEventListener("keydown", (e) => {
if (e.key === "Escape" && !el.learn.classList.contains("hidden")) hideOnboard();
});
}
// --- flow ------------------------------------------------------------------
function renderArcCards(arcs) {
el.arcs.innerHTML = "";
arcs.forEach((a) => {
const card = document.createElement("button");
card.className = "arc-card";
card.dataset.arc = a.id;
card.dataset.skin = a.skin;
card.innerHTML =
`