diff --git a/assets/scripts/ash-below-lake.js b/assets/scripts/ash-below-lake.js
new file mode 100644
index 0000000..77da0bc
--- /dev/null
+++ b/assets/scripts/ash-below-lake.js
@@ -0,0 +1,955 @@
+(function () {
+ const slot = "ash-below-the-lake";
+ const fallbackKey = "play:rpg:ash-below-the-lake";
+ const tile = 32;
+ const cols = 30;
+ const rows = 15;
+ const worldH = tile * rows;
+ const uiY = worldH;
+ const actions = ["Confront", "Listen", "Forget"];
+ const areas = {
+ harbor: {
+ name: "Harbor Row",
+ act: "Act 1",
+ track: "harbor",
+ floor: ["#222c36", "#263541", "#304650"],
+ accent: "#e0a451",
+ walls: rects([[0, 0, 30, 1], [0, 14, 30, 1], [0, 0, 1, 15], [29, 0, 1, 15], [3, 3, 5, 2], [12, 2, 4, 3], [20, 3, 6, 2], [5, 11, 7, 1], [17, 10, 5, 1]]),
+ exits: [{ x: 28, y: 7, to: "red", px: 1, py: 7 }],
+ props: [{ kind: "lake", x: 1, y: 11, w: 28, h: 3 }, { kind: "stall", x: 9, y: 5 }, { kind: "flowers", x: 23, y: 9 }],
+ start: { x: 3, y: 8 }
+ },
+ red: {
+ name: "The Red Streets",
+ act: "Act 1",
+ track: "red",
+ floor: ["#2f1f2c", "#442237", "#5b283f"],
+ accent: "#db4f62",
+ walls: rects([[0, 0, 30, 1], [0, 14, 30, 1], [0, 0, 1, 6], [0, 9, 1, 6], [29, 0, 1, 15], [4, 3, 4, 3], [12, 4, 3, 8], [20, 2, 5, 3], [22, 9, 4, 2]]),
+ exits: [{ x: 0, y: 7, to: "harbor", px: 28, py: 7 }, { x: 28, y: 12, to: "roots", px: 1, py: 12 }],
+ props: [{ kind: "sign", x: 9, y: 3 }, { kind: "stage", x: 17, y: 6 }, { kind: "poster", x: 26, y: 4 }],
+ start: { x: 2, y: 7 }
+ },
+ roots: {
+ name: "The Roots",
+ act: "Act 2",
+ track: "roots",
+ floor: ["#182725", "#203832", "#2e493e"],
+ accent: "#66c7a2",
+ walls: rects([[0, 0, 30, 1], [0, 14, 30, 1], [0, 0, 1, 11], [0, 13, 1, 2], [29, 0, 1, 15], [5, 2, 2, 10], [11, 5, 7, 1], [15, 8, 2, 5], [22, 3, 3, 9]]),
+ exits: [{ x: 0, y: 12, to: "red", px: 28, py: 12 }, { x: 28, y: 4, to: "chapel", px: 1, py: 4 }],
+ props: [{ kind: "archive", x: 8, y: 8 }, { kind: "root", x: 18, y: 3 }, { kind: "home", x: 25, y: 11 }],
+ start: { x: 2, y: 12 }
+ },
+ chapel: {
+ name: "The Drowned Chapel",
+ act: "Act 3",
+ track: "chapel",
+ floor: ["#172232", "#1c3043", "#263b4f"],
+ accent: "#8bd3ff",
+ walls: rects([[0, 0, 30, 1], [0, 14, 30, 1], [0, 0, 1, 3], [0, 6, 1, 9], [29, 0, 1, 15], [6, 4, 3, 7], [13, 2, 4, 3], [19, 4, 3, 7], [10, 12, 10, 1]]),
+ exits: [{ x: 0, y: 4, to: "roots", px: 28, py: 4 }, { x: 28, y: 10, to: "shore", px: 1, py: 10 }],
+ props: [{ kind: "pew", x: 10, y: 7 }, { kind: "pew", x: 16, y: 7 }, { kind: "glass", x: 14, y: 5 }],
+ start: { x: 2, y: 4 }
+ },
+ shore: {
+ name: "The Hollow Shore",
+ act: "Final Act",
+ track: "shore",
+ floor: ["#111827", "#1b2440", "#211c33"],
+ accent: "#f1f5f9",
+ walls: rects([[0, 0, 30, 1], [0, 14, 30, 1], [0, 0, 1, 9], [0, 12, 1, 3], [29, 0, 1, 15], [7, 3, 3, 2], [16, 2, 2, 10], [22, 7, 4, 1]]),
+ exits: [{ x: 0, y: 10, to: "chapel", px: 28, py: 10 }],
+ props: [{ kind: "lake", x: 2, y: 10, w: 27, h: 4 }, { kind: "flowers", x: 5, y: 6 }, { kind: "rift", x: 24, y: 4 }],
+ start: { x: 2, y: 10 }
+ }
+ };
+ const characters = {
+ ilyas: { name: "Ilyas", colors: ["#3d2b2f", "#e3b27f", "#334b7a", "#f2f0e6"] },
+ mira: { name: "Mira", colors: ["#21191e", "#d98d70", "#b73550", "#f6d36f"] },
+ ferryman: { name: "Ferryman", colors: ["#151719", "#dad9cd", "#56616d", "#0b0d10"] },
+ cinder: { name: "Brother Cinder", colors: ["#1d1512", "#d59667", "#6b2e2f", "#f4c35c"] },
+ sibling: { name: "Your Sibling", colors: ["#1d2531", "#bec8d8", "#52616f", "#dbeafe"] },
+ vendor: { name: "Maribel", colors: ["#2a2023", "#d5a06f", "#3d7b65", "#ffd166"] },
+ performer: { name: "Velvet Noon", colors: ["#2a1724", "#f2bd93", "#8f244d", "#ffe6a8"] },
+ archivist: { name: "Memory Archivist", colors: ["#151f1d", "#b9ddc9", "#386b5c", "#dff7eb"] }
+ };
+ const npcs = {
+ mira: { character: "mira", area: "harbor", x: 10, y: 8 },
+ ferryman: { character: "ferryman", area: "harbor", x: 25, y: 10 },
+ vendor: { character: "vendor", area: "harbor", x: 9, y: 6 },
+ performer: { character: "performer", area: "red", x: 17, y: 8 },
+ archivist: { character: "archivist", area: "roots", x: 9, y: 9 },
+ cinder: { character: "cinder", area: "chapel", x: 15, y: 6 },
+ sibling: { character: "sibling", area: "shore", x: 24, y: 8 }
+ };
+ const items = {
+ letter: { name: "Wet Letter", area: "harbor", x: 5, y: 10 },
+ bell: { name: "Brass Bell", area: "red", x: 27, y: 5 },
+ photo: { name: "Blurred Photo", area: "roots", x: 13, y: 9 },
+ cinderKey: { name: "Chapel Key", area: "chapel", x: 22, y: 12 }
+ };
+ const encounters = {
+ first_erased: {
+ title: "First Erased Person",
+ area: "red",
+ x: 6,
+ y: 9,
+ hp: 18,
+ text: "A person-shaped absence blocks the alley. Their outline mouths a name no one else can hear.",
+ bullets: "drift"
+ },
+ root_husk: {
+ title: "Root Husk",
+ area: "roots",
+ x: 21,
+ y: 9,
+ hp: 24,
+ text: "A house nobody remembers learned to walk. It wants its rooms back.",
+ bullets: "rain"
+ },
+ chapel_memory: {
+ title: "Choir of Ash",
+ area: "chapel",
+ x: 11,
+ y: 10,
+ hp: 30,
+ text: "Voices rise from the drowned pews. Every note sounds like an apology.",
+ bullets: "spiral"
+ }
+ };
+
+ window.AshBelowLakeRpg = function init(root) {
+ const canvas = root.querySelector("[data-rpg-canvas]");
+ const ctx = canvas.getContext("2d");
+ const els = {
+ speaker: root.querySelector("[data-rpg-speaker]"),
+ line: root.querySelector("[data-rpg-line]"),
+ area: root.querySelector("[data-rpg-room]"),
+ route: root.querySelector("[data-rpg-route]"),
+ resolve: root.querySelector("[data-rpg-hearts]"),
+ journal: root.querySelector("[data-rpg-quests]"),
+ inventory: root.querySelector("[data-rpg-inventory]"),
+ status: root.querySelector("[data-rpg-status]")
+ };
+ const ui = buildUi(root);
+ const audio = makeAudio();
+ let state = freshState();
+ let keys = new Set();
+ let last = performance.now();
+ let saveBusy = false;
+
+ bind();
+ load();
+ requestAnimationFrame(frame);
+
+ function freshState() {
+ return {
+ started: false,
+ mode: "title",
+ area: "harbor",
+ player: { x: 3, y: 8, facing: "down", step: 0 },
+ route: "Undecided",
+ resolve: 20,
+ toldName: null,
+ flags: {},
+ inventory: [],
+ memories: [
+ { id: "letter", text: "Your sibling's letter says: Don't let the lake remember your name.", stable: true },
+ { id: "town", text: "Morrow's End smiles like a town in a postcard someone tried to burn.", stable: true }
+ ],
+ forgets: 0,
+ listens: 0,
+ confronts: 0,
+ companion: false,
+ battle: null,
+ ending: null,
+ dialogue: { speaker: "The Letter", line: "Don't let the lake remember your name." }
+ };
+ }
+
+ function buildUi(rootEl) {
+ const panel = rootEl.querySelector(".rpg-panel");
+ const actionsWrap = document.createElement("div");
+ actionsWrap.className = "ash-choicebar";
+ actionsWrap.innerHTML = actions.map((name) => `${name} `).join("");
+ panel.insertBefore(actionsWrap, panel.querySelector(".rpg-list"));
+ const meter = document.createElement("div");
+ meter.className = "ash-memory-meter";
+ meter.innerHTML = "Lake Memory ";
+ panel.insertBefore(meter, actionsWrap);
+ const nameBox = document.createElement("form");
+ nameBox.className = "ash-name-form";
+ nameBox.innerHTML = "Name told to town Tell ";
+ panel.insertBefore(nameBox, meter);
+ return {
+ actionButtons: Array.from(actionsWrap.querySelectorAll("button")),
+ memoryFill: meter.querySelector("[data-ash-memory-fill]"),
+ nameForm: nameBox,
+ nameInput: nameBox.querySelector("[data-ash-name]")
+ };
+ }
+
+ function bind() {
+ root.querySelector("[data-rpg-start]").addEventListener("click", start);
+ root.querySelector("[data-rpg-save]").addEventListener("click", save);
+ root.querySelector("[data-rpg-load]").addEventListener("click", load);
+ root.querySelector("[data-rpg-reset]").addEventListener("click", newGame);
+ root.querySelector("[data-rpg-act]").addEventListener("click", act);
+ root.querySelectorAll("[data-rpg-move]").forEach((button) => {
+ button.addEventListener("click", () => move(button.dataset.rpgMove));
+ });
+ ui.actionButtons.forEach((button) => button.addEventListener("click", () => chooseBattle(button.dataset.ashAction)));
+ ui.nameForm.addEventListener("submit", (event) => {
+ event.preventDefault();
+ tellName(ui.nameInput.value.trim() || "Ilyas");
+ });
+ window.addEventListener("keydown", (event) => {
+ if (!root.isConnected || ["INPUT", "TEXTAREA"].includes(event.target.tagName)) return;
+ if (["ArrowUp", "ArrowDown", "ArrowLeft", "ArrowRight", "w", "a", "s", "d", " ", "Enter", "Escape"].includes(event.key)) event.preventDefault();
+ keys.add(event.key.toLowerCase());
+ audio.start();
+ if (event.key === " " || event.key === "Enter") act();
+ if (event.key === "Escape" && state.mode === "battle") leaveBattle("You step back from the memory and let it breathe.");
+ });
+ window.addEventListener("keyup", (event) => keys.delete(event.key.toLowerCase()));
+ }
+
+ function start() {
+ audio.start();
+ state.started = true;
+ state.mode = "world";
+ state.dialogue = { speaker: "Ilyas", line: "The letter is damp, but the ink has not run. Morrow's End waits beyond the harbor lamps." };
+ audio.setTrack(areas[state.area].track);
+ save();
+ }
+
+ async function newGame() {
+ state = freshState();
+ localStorage.removeItem(fallbackKey);
+ try {
+ await fetch(`/api/play/rpg/save/${slot}`, { method: "DELETE" });
+ } catch (_error) {
+ /* Local reset is enough when offline. */
+ }
+ audio.setTrack("title");
+ renderHud();
+ }
+
+ async function save() {
+ localStorage.setItem(fallbackKey, JSON.stringify(state));
+ if (saveBusy) return;
+ saveBusy = true;
+ try {
+ const response = await fetch(`/api/play/rpg/save/${slot}`, {
+ method: "PUT",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({ payload: state })
+ });
+ els.status.textContent = response.ok ? "Saved to backend." : "Saved locally. Backend rejected the save.";
+ } catch (_error) {
+ els.status.textContent = "Saved locally. Backend save API was not reachable.";
+ } finally {
+ saveBusy = false;
+ }
+ }
+
+ async function load() {
+ try {
+ const response = await fetch(`/api/play/rpg/save/${slot}`);
+ if (response.ok) {
+ const data = await response.json();
+ state = normalize(data.payload);
+ els.status.textContent = "Loaded from backend.";
+ audio.setTrack(state.started ? areas[state.area].track : "title");
+ return;
+ }
+ } catch (_error) {
+ /* Fall through to localStorage. */
+ }
+ const local = localStorage.getItem(fallbackKey);
+ if (local) {
+ state = normalize(JSON.parse(local));
+ els.status.textContent = "Loaded local save.";
+ }
+ audio.setTrack(state.started ? areas[state.area].track : "title");
+ }
+
+ function normalize(candidate) {
+ const base = freshState();
+ const next = Object.assign(base, candidate || {});
+ next.player = Object.assign(base.player, (candidate && candidate.player) || {});
+ next.flags = Object.assign({}, (candidate && candidate.flags) || {});
+ next.inventory = Array.isArray(next.inventory) ? next.inventory.filter((id) => items[id]) : [];
+ next.memories = Array.isArray(next.memories) ? next.memories : base.memories;
+ next.area = areas[next.area] ? next.area : "harbor";
+ next.dialogue = Object.assign(base.dialogue, (candidate && candidate.dialogue) || {});
+ return next;
+ }
+
+ function frame(now) {
+ const dt = Math.min(48, now - last);
+ last = now;
+ update(dt);
+ draw(now);
+ renderHud();
+ requestAnimationFrame(frame);
+ }
+
+ function update(dt) {
+ if (state.mode !== "world") return;
+ const dir = keyDirection();
+ if (!dir) return;
+ state.player.step += dt;
+ if (state.player.step < 115) return;
+ state.player.step = 0;
+ move(dir);
+ }
+
+ function keyDirection() {
+ if (keys.has("arrowup") || keys.has("w")) return "up";
+ if (keys.has("arrowdown") || keys.has("s")) return "down";
+ if (keys.has("arrowleft") || keys.has("a")) return "left";
+ if (keys.has("arrowright") || keys.has("d")) return "right";
+ return null;
+ }
+
+ function move(direction) {
+ if (state.mode !== "world" || !state.started || state.ending) return;
+ const delta = { up: [0, -1], down: [0, 1], left: [-1, 0], right: [1, 0] }[direction];
+ state.player.facing = direction;
+ const nx = state.player.x + delta[0];
+ const ny = state.player.y + delta[1];
+ const area = areas[state.area];
+ const exit = area.exits.find((item) => item.x === nx && item.y === ny);
+ if (exit && canEnter(exit.to)) {
+ state.area = exit.to;
+ state.player.x = exit.px;
+ state.player.y = exit.py;
+ audio.setTrack(areas[state.area].track);
+ remember(`Entered ${areas[state.area].name}.`, true);
+ say("Morrow's End", areaEnterLine(state.area));
+ save();
+ return;
+ }
+ if (area.walls.has(`${nx},${ny}`) || npcAt(nx, ny) || nx < 0 || nx >= cols || ny < 0 || ny >= rows) return;
+ state.player.x = nx;
+ state.player.y = ny;
+ const found = itemAt(nx, ny);
+ if (found) take(found);
+ const encounter = encounterAt(nx, ny);
+ if (encounter && !state.flags[`encounter_${encounter}`]) startBattle(encounter);
+ }
+
+ function canEnter(to) {
+ if (to === "chapel" && !state.flags.rootsTruth && state.forgets < 2) {
+ say("Ferryman", "The chapel is under the lake. It only opens for truth, or for forgetting.");
+ return false;
+ }
+ if (to === "shore" && !state.flags.cinderMet) {
+ say("Brother Cinder", "The shore is not a place. It is a decision. Hear me first.");
+ return false;
+ }
+ return true;
+ }
+
+ function act() {
+ audio.start();
+ if (!state.started) return start();
+ if (state.mode === "battle") return chooseBattle("listen");
+ if (state.ending) return;
+ const front = inFront();
+ const npc = npcAt(front.x, front.y) || npcAt(state.player.x, state.player.y);
+ if (npc) return talk(npc);
+ const found = itemAt(state.player.x, state.player.y);
+ if (found) return take(found);
+ const special = propAt(front.x, front.y) || propAt(state.player.x, state.player.y);
+ if (special) return inspectProp(special);
+ if (state.area === "shore" && state.player.x > 22 && state.player.y > 8) return finalChoice();
+ say("Ilyas", "The town holds its breath. Somewhere, water knocks against stone.");
+ }
+
+ function inFront() {
+ const d = { up: [0, -1], down: [0, 1], left: [-1, 0], right: [1, 0] }[state.player.facing] || [0, 1];
+ return { x: state.player.x + d[0], y: state.player.y + d[1] };
+ }
+
+ function npcAt(x, y) {
+ return Object.keys(npcs).find((id) => {
+ const npc = npcs[id];
+ return npc.area === state.area && npc.x === x && npc.y === y && !forgottenNpc(id);
+ });
+ }
+
+ function itemAt(x, y) {
+ return Object.keys(items).find((id) => {
+ const item = items[id];
+ return item.area === state.area && item.x === x && item.y === y && !state.inventory.includes(id);
+ });
+ }
+
+ function encounterAt(x, y) {
+ return Object.keys(encounters).find((id) => {
+ const e = encounters[id];
+ return e.area === state.area && e.x === x && e.y === y;
+ });
+ }
+
+ function propAt(x, y) {
+ return areas[state.area].props.find((prop) => x >= prop.x && x < prop.x + (prop.w || 1) && y >= prop.y && y < prop.y + (prop.h || 1));
+ }
+
+ function take(id) {
+ state.inventory.push(id);
+ audio.sfx("item");
+ if (id === "letter") remember("The wet letter refuses to dry.", true);
+ if (id === "photo") remember("A photo shows you beside someone whose face keeps changing.", true);
+ if (id === "cinderKey") state.flags.chapelKey = true;
+ say("Found", `${items[id].name} added to your bag.`);
+ save();
+ }
+
+ function talk(id) {
+ audio.sfx("talk");
+ if (id === "mira") {
+ state.companion = true;
+ state.flags.metMira = true;
+ remember("Mira remembers what other people lose.", true);
+ say("Mira", state.forgets > 1 ? "You keep looking through people like windows. Don't do that to me, okay?" : "You look lost. Good. Everyone interesting does.");
+ }
+ if (id === "ferryman") {
+ remember("The Ferryman knows where you have been before you arrive.", state.forgets < 2);
+ say("Ferryman", state.toldName ? `${state.toldName} is a fine name to keep away from water.` : "Names are hooks. I would not throw yours into the lake.");
+ }
+ if (id === "vendor") say("Maribel", "Today's special: one jar of fog, two apologies, and a fish that predicts Thursday badly.");
+ if (id === "performer") {
+ remember("A performer advertised a show called YOUR MISSING YEARS.", state.forgets < 3);
+ say("Velvet Noon", "Clap if you remember me. Clap twice if you are pretending.");
+ }
+ if (id === "archivist") {
+ state.flags.rootsTruth = true;
+ remember("The lake feeds on suffering, but calls it mercy.", true);
+ say("Memory Archivist", "The archives are homes with the people removed. The lake calls that tidying.");
+ }
+ if (id === "cinder") {
+ state.flags.cinderMet = true;
+ remember("Brother Cinder believes forgetting pain is mercy.", true);
+ say("Brother Cinder", "I have seen grief make animals of gentle people. The lake did not begin as a hunger. It began as a kindness.");
+ }
+ if (id === "sibling") {
+ remember("Your sibling entered the lake willingly.", true);
+ say("Your Sibling", "I gave it my name because I was tired of carrying it. Please don't give it yours.");
+ finalChoice();
+ }
+ save();
+ }
+
+ function inspectProp(prop) {
+ audio.sfx("talk");
+ if (prop.kind === "lake") say("Black Lake", state.toldName ? `It almost says ${state.toldName}. Almost.` : "It reflects nothing. Not clouds. Not you.");
+ if (prop.kind === "stall") say("Market Sign", "Fresh eels. Older than regret. Discount if they remember your birthday.");
+ if (prop.kind === "flowers") say("Glowing Flowers", "They lean toward the water like they are listening.");
+ if (prop.kind === "sign") say("Advertisement", "TONIGHT: The Amazing Vanishing Audience. Refunds unavailable after erasure.");
+ if (prop.kind === "stage") say("Stage", "The curtain twitches though there is no wind indoors.");
+ if (prop.kind === "poster") say("Poster", "A smiling face has been scratched out. The scratch is smiling too.");
+ if (prop.kind === "archive") {
+ state.flags.rootsTruth = true;
+ remember("Some memory shelves are empty because nobody dared open them.", true);
+ say("Memory Archive", "A drawer opens by itself. Inside: houses, birthdays, funeral songs, a ticket stub from a train that never arrived.");
+ }
+ if (prop.kind === "root") say("Roots", "They pulse slowly under the town, carrying lake-water like blood.");
+ if (prop.kind === "home") say("Abandoned Home", "Every room is furnished except for the idea of a family.");
+ if (prop.kind === "pew") say("Drowned Pew", "The wood is wet. The prayer book is dry.");
+ if (prop.kind === "glass") say("Sunken Window", "Saints in blue glass lower their eyes toward the lake.");
+ if (prop.kind === "rift") finalChoice();
+ save();
+ }
+
+ function tellName(name) {
+ state.toldName = name.slice(0, 18);
+ remember(`You told Morrow's End your name is ${state.toldName}.`, false);
+ say("Morrow's End", `The town repeats ${state.toldName} very softly. Too softly.`);
+ state.route = state.route === "Undecided" ? "Named" : state.route;
+ save();
+ }
+
+ function startBattle(id) {
+ const e = encounters[id];
+ state.mode = "battle";
+ state.battle = {
+ id,
+ hp: e.hp,
+ maxHp: e.hp,
+ soul: { x: 480, y: 382 },
+ bullets: [],
+ t: 0,
+ grace: 0,
+ turn: 1,
+ message: e.text
+ };
+ audio.setTrack("battle");
+ audio.sfx("battle");
+ say(e.title, e.text);
+ save();
+ }
+
+ function chooseBattle(action) {
+ if (state.mode !== "battle" || !state.battle) return;
+ const battle = state.battle;
+ if (action === "confront") {
+ battle.hp -= 9;
+ state.confronts += 1;
+ state.route = "Confronting";
+ battle.message = "You refuse to move. The memory recoils from your shape.";
+ audio.sfx("hit");
+ }
+ if (action === "listen") {
+ battle.hp -= 7;
+ state.listens += 1;
+ state.route = state.forgets === 0 ? "Carry" : "Listening";
+ battle.message = "You listen until the ache becomes words. The attack softens.";
+ remember(`Listened to ${encounters[battle.id].title}.`, true);
+ audio.sfx("listen");
+ }
+ if (action === "forget") {
+ battle.hp -= 18;
+ state.forgets += 1;
+ state.route = state.forgets >= 3 ? "Forgotten" : "Silence";
+ battle.message = "You let the lake take the hard part. Something else goes with it.";
+ rewriteMemory();
+ audio.sfx("forget");
+ }
+ battle.turn += 1;
+ if (battle.hp <= 0) resolveBattle(action);
+ else say(encounters[battle.id].title, battle.message);
+ save();
+ }
+
+ function resolveBattle(action) {
+ const id = state.battle.id;
+ state.flags[`encounter_${id}`] = action;
+ if (id === "root_husk") state.flags.rootsTruth = true;
+ if (action === "forget" && state.forgets >= 3) return end("forgotten");
+ const line = action === "listen"
+ ? "The memory steps aside, still hurting, but no longer alone."
+ : action === "forget"
+ ? "The memory becomes easy to pass. Behind you, a street sign loses a letter."
+ : "The memory breaks apart like ash under rain.";
+ leaveBattle(line);
+ }
+
+ function leaveBattle(line) {
+ state.mode = "world";
+ state.battle = null;
+ audio.setTrack(areas[state.area].track);
+ say("Afterimage", line);
+ save();
+ }
+
+ function finalChoice() {
+ if (state.forgets >= 3) return end("forgotten");
+ if (state.forgets === 0 && state.listens >= 3 && state.flags.metMira) return end("rest");
+ if (state.route === "Silence" || state.forgets > state.listens) return end("silence");
+ end("carry");
+ }
+
+ function end(kind) {
+ state.ending = kind;
+ state.mode = "ending";
+ state.route = kind === "rest" ? "True: Rest" : kind === "carry" ? "Carry" : kind === "silence" ? "Silence" : "Forgotten";
+ audio.setTrack(kind === "forgotten" ? "void" : "ending");
+ say("Ending", endingLine(kind));
+ remember(endingLine(kind), kind !== "forgotten");
+ save();
+ }
+
+ function endingLine(kind) {
+ if (kind === "silence") return "Silence: you join the lake. Morrow's End becomes peaceful, clean, and almost empty.";
+ if (kind === "carry") return "Carry: the lake breaks. Everyone remembers. The town survives with tears in every lit window.";
+ if (kind === "forgotten") return "Forgotten: the story continues politely around the space where Ilyas used to be.";
+ return "Rest: the lake is forgiven, not destroyed. Mira laughs once, real and afraid, as the shore finally reflects the sky.";
+ }
+
+ function rewriteMemory() {
+ const mutable = state.memories.find((memory) => !memory.stable && !memory.erased) || state.memories.find((memory) => !memory.erased);
+ if (mutable) {
+ mutable.text = state.forgets > 2 ? "There was someone here. There was no one here. Both feel true." : mutable.text.replace(/[aeiou]/gi, "_");
+ mutable.erased = state.forgets > 2;
+ }
+ if (state.forgets === 2) state.flags.vendorForgotten = true;
+ if (state.forgets >= 3) state.flags.performerForgotten = true;
+ }
+
+ function forgottenNpc(id) {
+ return (id === "vendor" && state.flags.vendorForgotten) || (id === "performer" && state.flags.performerForgotten);
+ }
+
+ function remember(text, stable) {
+ if (!state.memories.some((memory) => memory.text === text)) state.memories.push({ id: `m${Date.now()}`, text, stable: Boolean(stable) });
+ if (state.memories.length > 8) state.memories.splice(2, 1);
+ }
+
+ function say(speaker, line) {
+ state.dialogue = { speaker, line };
+ }
+
+ function areaEnterLine(area) {
+ if (area === "red") return "The Red Streets glow warmly, but the laughter arrives half a second late.";
+ if (area === "roots") return "The Roots smell like wet stone and bedrooms left untouched for years.";
+ if (area === "chapel") return "The Drowned Chapel rings with a bell under water.";
+ if (area === "shore") return "The Hollow Shore bends at the edges, as if the world has been folded too many times.";
+ return "Harbor Row keeps its lamps low and its names lower.";
+ }
+
+ function draw(now) {
+ ctx.imageSmoothingEnabled = false;
+ if (!state.started || state.mode === "title") drawTitle(now);
+ else drawWorld(now);
+ if (state.mode === "battle") drawBattle(now);
+ if (state.mode === "ending") drawEnding(now);
+ drawVignette(now);
+ }
+
+ function drawTitle(now) {
+ const g = ctx.createLinearGradient(0, 0, 0, canvas.height);
+ g.addColorStop(0, "#05070c");
+ g.addColorStop(0.55, "#101b28");
+ g.addColorStop(1, "#020306");
+ ctx.fillStyle = g;
+ ctx.fillRect(0, 0, canvas.width, canvas.height);
+ drawLake(0, 345, 960, 195, now, 0.4);
+ ctx.fillStyle = "#f8efe0";
+ ctx.font = "700 50px Georgia, serif";
+ ctx.textAlign = "center";
+ ctx.fillText("Ash Below the Lake", 480, 142);
+ ctx.font = "18px Georgia, serif";
+ ctx.fillStyle = "#b9c6d4";
+ ctx.fillText("A quiet town. A black lake. A name you should not give away.", 480, 178);
+ ctx.fillStyle = "#e0a451";
+ ctx.font = "700 18px ui-monospace, monospace";
+ ctx.fillText("Press Start or Space", 480, 246);
+ drawSprite("ilyas", 424, 288, 3, now);
+ drawSprite("mira", 500, 288, 3, now);
+ }
+
+ function drawWorld(now) {
+ const area = areas[state.area];
+ for (let y = 0; y < rows; y += 1) {
+ for (let x = 0; x < cols; x += 1) {
+ const wall = area.walls.has(`${x},${y}`);
+ const c = area.floor[(x + y + Math.floor(now / 800)) % area.floor.length];
+ ctx.fillStyle = wall ? "#0b0d10" : c;
+ ctx.fillRect(x * tile, y * tile, tile, tile);
+ if (!wall) {
+ ctx.fillStyle = "rgba(255,255,255,0.025)";
+ ctx.fillRect(x * tile, y * tile + 30, tile, 2);
+ }
+ }
+ }
+ area.exits.forEach((exit) => drawDoor(exit.x, exit.y, area.accent));
+ area.props.forEach((prop) => drawProp(prop, now));
+ Object.keys(items).forEach((id) => {
+ const item = items[id];
+ if (item.area === state.area && !state.inventory.includes(id)) drawItem(id, item, now);
+ });
+ Object.keys(encounters).forEach((id) => {
+ const e = encounters[id];
+ if (e.area === state.area && !state.flags[`encounter_${id}`]) drawEncounter(e, now);
+ });
+ Object.keys(npcs).forEach((id) => {
+ const npc = npcs[id];
+ if (npc.area === state.area && !forgottenNpc(id)) drawSprite(npc.character, npc.x * tile, npc.y * tile - 12, 2, now);
+ });
+ drawSprite("ilyas", state.player.x * tile, state.player.y * tile - 12, 2, now);
+ if (state.companion && state.area !== "chapel") {
+ const offset = state.player.facing === "left" ? 1 : -1;
+ drawSprite("mira", (state.player.x + offset) * tile, state.player.y * tile - 12, 2, now + 200);
+ }
+ drawTopBar(area);
+ }
+
+ function drawTopBar(area) {
+ ctx.fillStyle = "rgba(3, 6, 10, 0.82)";
+ ctx.fillRect(0, 0, canvas.width, 42);
+ ctx.fillStyle = area.accent;
+ ctx.font = "700 14px ui-monospace, monospace";
+ ctx.textAlign = "left";
+ ctx.fillText(`${area.act} / ${area.name}`, 18, 26);
+ ctx.textAlign = "right";
+ ctx.fillStyle = "#f8efe0";
+ ctx.fillText(`Resolve ${state.resolve} Lake ${state.forgets}`, 940, 26);
+ }
+
+ function drawBattle(now) {
+ const battle = state.battle;
+ battle.t += 1;
+ updateBullets(battle, now);
+ ctx.fillStyle = "rgba(4, 5, 8, 0.88)";
+ ctx.fillRect(0, 0, canvas.width, canvas.height);
+ ctx.strokeStyle = "#f8efe0";
+ ctx.lineWidth = 3;
+ ctx.strokeRect(296, 252, 368, 190);
+ ctx.fillStyle = "#f8efe0";
+ ctx.font = "700 24px Georgia, serif";
+ ctx.textAlign = "center";
+ ctx.fillText(encounters[battle.id].title, 480, 82);
+ ctx.font = "16px ui-monospace, monospace";
+ ctx.fillText(`Memory ${Math.max(0, battle.hp)} / ${battle.maxHp}`, 480, 116);
+ drawBattleEnemy(battle.id, now);
+ ctx.fillStyle = "#f35f5f";
+ ctx.beginPath();
+ ctx.moveTo(battle.soul.x, battle.soul.y - 8);
+ ctx.bezierCurveTo(battle.soul.x - 13, battle.soul.y - 17, battle.soul.x - 20, battle.soul.y + 4, battle.soul.x, battle.soul.y + 18);
+ ctx.bezierCurveTo(battle.soul.x + 20, battle.soul.y + 4, battle.soul.x + 13, battle.soul.y - 17, battle.soul.x, battle.soul.y - 8);
+ ctx.fill();
+ ctx.fillStyle = "#dbeafe";
+ battle.bullets.forEach((b) => {
+ ctx.beginPath();
+ ctx.arc(b.x, b.y, b.r, 0, Math.PI * 2);
+ ctx.fill();
+ });
+ ctx.fillStyle = "#f8efe0";
+ ctx.font = "15px Georgia, serif";
+ wrapText(battle.message, 148, 480, 660, 22);
+ }
+
+ function updateBullets(battle) {
+ const speed = state.forgets > 0 ? 1.15 : 1.55;
+ const move = keyDirection();
+ if (move === "up") battle.soul.y -= 3.3;
+ if (move === "down") battle.soul.y += 3.3;
+ if (move === "left") battle.soul.x -= 3.3;
+ if (move === "right") battle.soul.x += 3.3;
+ battle.soul.x = clamp(battle.soul.x, 312, 648);
+ battle.soul.y = clamp(battle.soul.y, 270, 426);
+ if (battle.t % 26 === 0) {
+ if (encounters[battle.id].bullets === "rain") battle.bullets.push({ x: 315 + Math.random() * 330, y: 256, vx: 0, vy: speed * 2.4, r: 5 });
+ else if (encounters[battle.id].bullets === "spiral") {
+ const a = battle.t / 14;
+ battle.bullets.push({ x: 480, y: 346, vx: Math.cos(a) * speed * 2, vy: Math.sin(a) * speed * 2, r: 5 });
+ } else battle.bullets.push({ x: Math.random() > 0.5 ? 304 : 656, y: 275 + Math.random() * 130, vx: (Math.random() > 0.5 ? -1 : 1) * speed * 2, vy: Math.sin(battle.t) * 0.6, r: 5 });
+ }
+ battle.bullets.forEach((b) => {
+ b.x += b.vx;
+ b.y += b.vy;
+ if (battle.grace <= 0 && Math.hypot(b.x - battle.soul.x, b.y - battle.soul.y) < b.r + 10) {
+ state.resolve = Math.max(0, state.resolve - 1);
+ battle.grace = 24;
+ audio.sfx("hurt");
+ if (state.resolve <= 0) end("forgotten");
+ }
+ });
+ battle.grace -= 1;
+ battle.bullets = battle.bullets.filter((b) => b.x > 280 && b.x < 680 && b.y > 230 && b.y < 455);
+ }
+
+ function renderHud() {
+ root.classList.toggle("is-lake-awake", state.forgets > 1);
+ root.classList.toggle("is-ending", Boolean(state.ending));
+ els.speaker.textContent = state.dialogue.speaker;
+ els.line.textContent = state.dialogue.line;
+ els.area.textContent = areas[state.area].name;
+ els.route.textContent = state.route;
+ els.resolve.textContent = String(state.resolve);
+ ui.memoryFill.style.width = `${Math.min(100, state.forgets * 34)}%`;
+ els.inventory.innerHTML = state.inventory.length ? state.inventory.map((id) => `
${items[id].name} `).join("") : "Empty ";
+ els.journal.innerHTML = state.memories.map((memory) => `${memory.erased ? "[entry missing]" : memory.text} `).join("");
+ ui.actionButtons.forEach((button) => {
+ button.disabled = state.mode !== "battle";
+ });
+ }
+
+ function drawProp(prop, now) {
+ const x = prop.x * tile;
+ const y = prop.y * tile;
+ if (prop.kind === "lake") return drawLake(x, y, (prop.w || 1) * tile, (prop.h || 1) * tile, now, 1);
+ if (prop.kind === "stall") {
+ ctx.fillStyle = "#7f2d3a"; ctx.fillRect(x, y + 8, 64, 34);
+ ctx.fillStyle = "#e0a451"; ctx.fillRect(x - 4, y, 72, 12);
+ }
+ if (prop.kind === "flowers") for (let i = 0; i < 6; i += 1) { ctx.fillStyle = `rgba(133, 255, 214, ${0.45 + Math.sin(now / 400 + i) * 0.2})`; ctx.fillRect(x + i * 8, y + 12 + (i % 2) * 8, 5, 5); }
+ if (prop.kind === "sign" || prop.kind === "poster") { ctx.fillStyle = "#12151d"; ctx.fillRect(x, y, 38, 32); ctx.fillStyle = "#f3b15d"; ctx.fillRect(x + 5, y + 6, 28, 3); }
+ if (prop.kind === "stage") { ctx.fillStyle = "#210d18"; ctx.fillRect(x, y + 10, 96, 46); ctx.fillStyle = "#d64d62"; ctx.fillRect(x + 8, y, 80, 14); }
+ if (prop.kind === "archive") { ctx.fillStyle = "#4b3a25"; ctx.fillRect(x, y, 58, 52); ctx.fillStyle = "#d8cba8"; for (let i = 0; i < 4; i += 1) ctx.fillRect(x + 8, y + 8 + i * 10, 42, 3); }
+ if (prop.kind === "root") { ctx.strokeStyle = "#6aa27d"; ctx.lineWidth = 5; ctx.beginPath(); ctx.moveTo(x, y + 28); ctx.bezierCurveTo(x + 26, y - 8, x + 52, y + 48, x + 80, y + 6); ctx.stroke(); }
+ if (prop.kind === "home") { ctx.fillStyle = "#263332"; ctx.fillRect(x, y, 54, 48); ctx.fillStyle = "#080a0c"; ctx.fillRect(x + 20, y + 18, 14, 30); }
+ if (prop.kind === "pew") { ctx.fillStyle = "#33251d"; ctx.fillRect(x, y + 10, 76, 20); }
+ if (prop.kind === "glass") { ctx.fillStyle = "rgba(139,211,255,0.55)"; ctx.fillRect(x, y, 52, 72); ctx.fillStyle = "rgba(255,255,255,0.3)"; ctx.fillRect(x + 23, y, 5, 72); }
+ if (prop.kind === "rift") { ctx.strokeStyle = `rgba(248,239,224,${0.5 + Math.sin(now / 200) * 0.3})`; ctx.lineWidth = 3; ctx.beginPath(); ctx.moveTo(x + 20, y); ctx.lineTo(x + 5, y + 22); ctx.lineTo(x + 27, y + 40); ctx.lineTo(x + 10, y + 70); ctx.stroke(); }
+ }
+
+ function drawLake(x, y, w, h, now, alpha) {
+ const g = ctx.createLinearGradient(0, y, 0, y + h);
+ g.addColorStop(0, `rgba(7, 18, 30, ${0.7 * alpha})`);
+ g.addColorStop(1, `rgba(0, 0, 0, ${0.95 * alpha})`);
+ ctx.fillStyle = g;
+ ctx.fillRect(x, y, w, h);
+ ctx.strokeStyle = `rgba(114, 169, 191, ${0.2 * alpha})`;
+ for (let i = 0; i < 8; i += 1) {
+ ctx.beginPath();
+ const yy = y + 12 + i * 17 + Math.sin(now / 700 + i) * 4;
+ ctx.moveTo(x, yy);
+ for (let xx = 0; xx < w; xx += 24) ctx.lineTo(x + xx, yy + Math.sin(now / 350 + xx / 35 + i) * 3);
+ ctx.stroke();
+ }
+ }
+
+ function drawDoor(x, y, color) {
+ ctx.fillStyle = color;
+ ctx.fillRect(x * tile + 7, y * tile + 4, 18, 26);
+ ctx.fillStyle = "#05070c";
+ ctx.fillRect(x * tile + 19, y * tile + 17, 3, 3);
+ }
+
+ function drawItem(id, item, now) {
+ const x = item.x * tile;
+ const y = item.y * tile + Math.sin(now / 220) * 2;
+ ctx.fillStyle = id === "bell" ? "#d6a24a" : id === "photo" ? "#dbeafe" : id === "cinderKey" ? "#f59e0b" : "#f8efe0";
+ ctx.fillRect(x + 10, y + 10, 13, 13);
+ ctx.fillStyle = "rgba(255,255,255,0.45)";
+ ctx.fillRect(x + 13, y + 7, 7, 5);
+ }
+
+ function drawEncounter(e, now) {
+ const x = e.x * tile;
+ const y = e.y * tile;
+ ctx.fillStyle = `rgba(210, 225, 245, ${0.35 + Math.sin(now / 260) * 0.16})`;
+ ctx.fillRect(x + 8, y + 4, 16, 27);
+ ctx.clearRect(x + 12, y + 10, 3, 4);
+ ctx.clearRect(x + 19, y + 10, 3, 4);
+ }
+
+ function drawSprite(id, x, y, scale, now) {
+ const c = characters[id].colors;
+ const bob = Math.sin(now / 260 + x) * 1.5;
+ const s = scale;
+ ctx.fillStyle = "rgba(0,0,0,0.28)";
+ ctx.fillRect(x + 5 * s, y + 30 * s, 12 * s, 3 * s);
+ ctx.fillStyle = c[0]; ctx.fillRect(x + 7 * s, y + bob + 2 * s, 10 * s, 7 * s);
+ ctx.fillStyle = c[1]; ctx.fillRect(x + 6 * s, y + bob + 8 * s, 12 * s, 10 * s);
+ ctx.fillStyle = c[2]; ctx.fillRect(x + 5 * s, y + bob + 18 * s, 14 * s, 13 * s);
+ ctx.fillStyle = c[3]; ctx.fillRect(x + 8 * s, y + bob + 12 * s, 2 * s, 2 * s); ctx.fillRect(x + 14 * s, y + bob + 12 * s, 2 * s, 2 * s);
+ ctx.fillStyle = c[0]; ctx.fillRect(x + 4 * s, y + bob + 22 * s, 3 * s, 9 * s); ctx.fillRect(x + 17 * s, y + bob + 22 * s, 3 * s, 9 * s);
+ }
+
+ function drawBattleEnemy(id, now) {
+ if (id === "first_erased") drawEncounter({ x: 14, y: 4 }, now);
+ if (id === "root_husk") { ctx.strokeStyle = "#79c89d"; ctx.lineWidth = 8; ctx.beginPath(); ctx.moveTo(440, 180); ctx.bezierCurveTo(470, 120, 510, 230, 540, 160); ctx.stroke(); }
+ if (id === "chapel_memory") { ctx.fillStyle = "rgba(139,211,255,0.5)"; for (let i = 0; i < 5; i += 1) ctx.fillRect(420 + i * 28, 150 + Math.sin(now / 300 + i) * 8, 16, 54); }
+ }
+
+ function drawEnding(now) {
+ ctx.fillStyle = "rgba(0,0,0,0.72)";
+ ctx.fillRect(0, 0, canvas.width, canvas.height);
+ ctx.fillStyle = "#f8efe0";
+ ctx.font = "700 42px Georgia, serif";
+ ctx.textAlign = "center";
+ ctx.fillText(state.route, 480 + Math.sin(now / 120) * (state.ending === "forgotten" ? 3 : 0), 202);
+ ctx.font = "18px Georgia, serif";
+ wrapText(state.dialogue.line, 250, 250, 460, 28);
+ }
+
+ function drawVignette(now) {
+ if (state.forgets > 0) {
+ ctx.fillStyle = `rgba(0,0,0,${0.06 + state.forgets * 0.05 + Math.sin(now / 800) * 0.02})`;
+ ctx.fillRect(0, 0, canvas.width, canvas.height);
+ }
+ const g = ctx.createRadialGradient(480, 245, 120, 480, 245, 560);
+ g.addColorStop(0, "rgba(0,0,0,0)");
+ g.addColorStop(1, "rgba(0,0,0,0.48)");
+ ctx.fillStyle = g;
+ ctx.fillRect(0, 0, canvas.width, canvas.height);
+ }
+
+ function wrapText(text, x, y, maxWidth, lineHeight) {
+ ctx.textAlign = "left";
+ const words = text.split(" ");
+ let line = "";
+ for (const word of words) {
+ const test = `${line}${word} `;
+ if (ctx.measureText(test).width > maxWidth && line) {
+ ctx.fillText(line, x, y);
+ line = `${word} `;
+ y += lineHeight;
+ } else {
+ line = test;
+ }
+ }
+ ctx.fillText(line, x, y);
+ }
+ };
+
+ function makeAudio() {
+ let ctx = null;
+ let master = null;
+ let track = "title";
+ let timer = null;
+ const patterns = {
+ title: [220, 277, 330, 277],
+ harbor: [262, 330, 392, 330],
+ red: [196, 233, 294, 349],
+ roots: [174, 220, 261, 220],
+ chapel: [147, 196, 247, 294],
+ shore: [110, 165, 220, 165],
+ battle: [330, 311, 294, 247],
+ ending: [262, 392, 440, 523],
+ void: [110, 98, 82, 73]
+ };
+ function start() {
+ if (ctx) return;
+ ctx = new (window.AudioContext || window.webkitAudioContext)();
+ master = ctx.createGain();
+ master.gain.value = 0.055;
+ master.connect(ctx.destination);
+ loop();
+ }
+ function loop() {
+ clearInterval(timer);
+ let i = 0;
+ timer = setInterval(() => {
+ if (!ctx) return;
+ const notes = patterns[track] || patterns.title;
+ tone(notes[i % notes.length], 0.28, track === "battle" ? "sawtooth" : "sine");
+ if (track !== "battle") tone(notes[(i + 2) % notes.length] / 2, 0.7, "triangle", 0.45);
+ i += 1;
+ }, track === "battle" ? 230 : 520);
+ }
+ function tone(freq, dur, type, gainScale) {
+ const osc = ctx.createOscillator();
+ const gain = ctx.createGain();
+ osc.type = type;
+ osc.frequency.value = freq;
+ gain.gain.setValueAtTime(0, ctx.currentTime);
+ gain.gain.linearRampToValueAtTime(0.25 * (gainScale || 1), ctx.currentTime + 0.02);
+ gain.gain.exponentialRampToValueAtTime(0.001, ctx.currentTime + dur);
+ osc.connect(gain);
+ gain.connect(master);
+ osc.start();
+ osc.stop(ctx.currentTime + dur + 0.03);
+ }
+ return {
+ start,
+ setTrack(next) {
+ track = next;
+ if (ctx) loop();
+ },
+ sfx(kind) {
+ if (!ctx) return;
+ const map = { item: 660, talk: 440, battle: 130, hit: 180, listen: 523, forget: 96, hurt: 80 };
+ tone(map[kind] || 330, kind === "forget" ? 0.8 : 0.18, kind === "forget" ? "sawtooth" : "square", kind === "hurt" ? 1.3 : 0.8);
+ }
+ };
+ }
+
+ function rects(sources) {
+ const set = new Set();
+ sources.forEach(([x, y, w, h]) => {
+ for (let yy = y; yy < y + h; yy += 1) for (let xx = x; xx < x + w; xx += 1) set.add(`${xx},${yy}`);
+ });
+ return set;
+ }
+
+ function clamp(value, min, max) {
+ return Math.max(min, Math.min(max, value));
+ }
+})();
diff --git a/assets/scripts/play.js b/assets/scripts/play.js
index 442d051..90b15b1 100644
--- a/assets/scripts/play.js
+++ b/assets/scripts/play.js
@@ -493,6 +493,7 @@
}
function rpg(root) {
+ if (window.AshBelowLakeRpg) return window.AshBelowLakeRpg(root);
const canvas = $("[data-rpg-canvas]", root);
const ctx = canvas.getContext("2d");
const els = {
diff --git a/assets/styles/play.css b/assets/styles/play.css
index 5307e3a..5f16f40 100644
--- a/assets/styles/play.css
+++ b/assets/styles/play.css
@@ -497,11 +497,63 @@
.rpg-shell {
display: grid;
- grid-template-columns: minmax(0, 768px) minmax(260px, 1fr);
+ grid-template-columns: minmax(0, 960px) minmax(280px, 1fr);
gap: 1rem;
align-items: start;
}
+.ash-rpg-root {
+ --ash-black: #05070c;
+ --ash-ink: #0d1420;
+ --ash-panel: #121a27;
+ --ash-line: #344154;
+ --ash-gold: #e0a451;
+ --ash-cold: #8bd3ff;
+ --ash-red: #db4f62;
+ --ash-text: #f8efe0;
+ --ash-muted: #a8b4c2;
+ max-width: 1380px;
+}
+
+.ash-rpg-root .play-page-head {
+ padding: 1rem 0;
+ border-bottom: 1px solid rgba(139, 211, 255, 0.18);
+}
+
+.ash-rpg-root .play-page-head h1 {
+ color: var(--ash-text);
+ font-family: Georgia, "Times New Roman", serif;
+ text-shadow: 0 0 18px rgba(139, 211, 255, 0.22);
+}
+
+.ash-rpg-root .play-page-head p,
+.ash-rpg-root .play-back {
+ color: var(--ash-muted);
+}
+
+.ash-rpg-shell {
+ position: relative;
+ border-color: rgba(139, 211, 255, 0.22);
+ background:
+ linear-gradient(180deg, rgba(12, 20, 32, 0.96), rgba(4, 7, 12, 0.98)),
+ var(--ash-black);
+ box-shadow:
+ 0 0 0 1px rgba(224, 164, 81, 0.14) inset,
+ 0 28px 80px rgba(0, 0, 0, 0.42);
+}
+
+.ash-rpg-shell::before {
+ content: "";
+ position: absolute;
+ inset: 0;
+ pointer-events: none;
+ border-radius: 8px;
+ background:
+ repeating-linear-gradient(0deg, rgba(255, 255, 255, 0.025), rgba(255, 255, 255, 0.025) 1px, transparent 1px, transparent 4px);
+ mix-blend-mode: screen;
+ opacity: 0.35;
+}
+
.rpg-stage {
display: grid;
gap: 0.75rem;
@@ -510,40 +562,53 @@
.rpg-canvas {
display: block;
width: 100%;
- aspect-ratio: 1.5;
+ aspect-ratio: 16 / 9;
image-rendering: pixelated;
- border: 2px solid #18130f;
+ border: 2px solid var(--ash-line);
border-radius: 8px;
- background: #14110e;
+ background: var(--ash-black);
+ box-shadow:
+ 0 0 0 4px #05070c,
+ 0 0 34px rgba(139, 211, 255, 0.16);
+}
+
+.ash-rpg-canvas {
+ min-height: 360px;
}
.rpg-dialogue,
.rpg-panel {
- border: 1px solid var(--border);
+ border: 1px solid var(--ash-line);
border-radius: 8px;
- background: color-mix(in oklab, var(--surface) 86%, #18130f 14%);
+ background:
+ linear-gradient(180deg, rgba(21, 31, 46, 0.95), rgba(8, 12, 18, 0.96)),
+ var(--ash-panel);
+ color: var(--ash-text);
}
.rpg-dialogue {
- min-height: 118px;
- padding: 0.9rem;
+ min-height: 130px;
+ padding: 1rem;
+ box-shadow: 0 0 0 1px rgba(248, 239, 224, 0.05) inset;
}
.rpg-dialogue strong,
.rpg-list h2 {
- color: var(--heading);
+ color: var(--ash-gold);
}
.rpg-dialogue p,
.rpg-status {
margin: 0.35rem 0 0;
- color: var(--muted);
+ color: var(--ash-muted);
+ line-height: 1.55;
}
.rpg-panel {
display: grid;
gap: 1rem;
padding: 1rem;
+ align-content: start;
}
.rpg-actions,
@@ -553,6 +618,27 @@
gap: 0.5rem;
}
+.ash-rpg-root button {
+ border-color: rgba(139, 211, 255, 0.28);
+ background: linear-gradient(180deg, #1a2638, #0c121c);
+ color: var(--ash-text);
+ font-family: ui-monospace, SFMono-Regular, Consolas, monospace;
+ font-weight: 800;
+ text-transform: uppercase;
+}
+
+.ash-rpg-root button:hover:not(:disabled),
+.ash-rpg-root button:focus-visible {
+ background: linear-gradient(180deg, #31435f, #142236);
+ color: #ffffff;
+ box-shadow: 0 0 18px rgba(139, 211, 255, 0.22);
+}
+
+.ash-rpg-root button:disabled {
+ cursor: not-allowed;
+ opacity: 0.42;
+}
+
.rpg-mobile-pad {
grid-template-columns: repeat(3, minmax(0, 1fr));
}
@@ -585,13 +671,13 @@
justify-content: space-between;
gap: 1rem;
padding-bottom: 0.45rem;
- border-bottom: 1px solid var(--border);
+ border-bottom: 1px solid rgba(139, 211, 255, 0.16);
}
.rpg-stats dt,
.rpg-list h2 {
margin: 0;
- color: var(--muted);
+ color: var(--ash-muted);
font-size: 0.78rem;
font-weight: 800;
text-transform: uppercase;
@@ -612,14 +698,105 @@
.rpg-list li {
padding: 0.55rem;
- border: 1px solid var(--border);
+ border: 1px solid rgba(139, 211, 255, 0.16);
border-radius: 6px;
- background: var(--surface);
+ background: rgba(5, 7, 12, 0.62);
+ color: var(--ash-text);
+ font-size: 0.92rem;
}
.rpg-list li.is-done {
- border-color: color-mix(in oklab, var(--play-green) 70%, var(--border));
- background: color-mix(in oklab, var(--surface) 72%, var(--play-green) 28%);
+ border-color: rgba(102, 199, 162, 0.42);
+ background: rgba(45, 91, 77, 0.34);
+}
+
+.rpg-list li.is-erased {
+ border-color: rgba(219, 79, 98, 0.32);
+ color: rgba(248, 239, 224, 0.42);
+ background: repeating-linear-gradient(90deg, rgba(219, 79, 98, 0.08), rgba(219, 79, 98, 0.08) 6px, rgba(5, 7, 12, 0.6) 6px, rgba(5, 7, 12, 0.6) 12px);
+ text-decoration: line-through;
+}
+
+.ash-choicebar {
+ display: grid;
+ grid-template-columns: repeat(3, minmax(0, 1fr));
+ gap: 0.5rem;
+}
+
+.ash-choicebar button {
+ min-height: 50px;
+}
+
+.ash-memory-meter {
+ position: relative;
+ display: grid;
+ gap: 0.45rem;
+ color: var(--ash-muted);
+ font-size: 0.78rem;
+ font-weight: 800;
+ text-transform: uppercase;
+}
+
+.ash-memory-meter::after {
+ content: "";
+ display: block;
+ height: 10px;
+ border: 1px solid rgba(139, 211, 255, 0.24);
+ border-radius: 999px;
+ background: #05070c;
+}
+
+.ash-memory-meter i {
+ position: absolute;
+ left: 1px;
+ right: auto;
+ bottom: 1px;
+ width: 0;
+ height: 10px;
+ border-radius: 999px;
+ background: linear-gradient(90deg, var(--ash-cold), var(--ash-red));
+ transition: width 220ms ease;
+}
+
+.ash-name-form {
+ display: grid;
+ grid-template-columns: minmax(0, 1fr) auto;
+ gap: 0.5rem;
+ align-items: end;
+}
+
+.ash-name-form label {
+ display: grid;
+ gap: 0.35rem;
+ color: var(--ash-muted);
+ font-size: 0.78rem;
+ font-weight: 800;
+ text-transform: uppercase;
+}
+
+.ash-name-form input {
+ width: 100%;
+ border: 1px solid rgba(139, 211, 255, 0.24);
+ border-radius: 6px;
+ padding: 0.65rem;
+ background: #05070c;
+ color: var(--ash-text);
+ font: inherit;
+}
+
+.ash-rpg-root.is-lake-awake .rpg-dialogue,
+.ash-rpg-root.is-lake-awake .rpg-panel {
+ border-color: rgba(219, 79, 98, 0.36);
+}
+
+.ash-rpg-root.is-lake-awake .ash-rpg-canvas {
+ box-shadow:
+ 0 0 0 4px #05070c,
+ 0 0 38px rgba(219, 79, 98, 0.22);
+}
+
+.ash-rpg-root.is-ending .rpg-panel {
+ box-shadow: 0 0 34px rgba(224, 164, 81, 0.16);
}
@media (max-width: 760px) {
@@ -643,4 +820,13 @@
width: calc(50% - 0.4rem);
min-height: 160px;
}
+
+ .ash-choicebar,
+ .ash-name-form {
+ grid-template-columns: 1fr;
+ }
+
+ .ash-rpg-canvas {
+ min-height: auto;
+ }
}
diff --git a/build-site.el b/build-site.el
index c78ddcf..ea55e8a 100755
--- a/build-site.el
+++ b/build-site.el
@@ -225,6 +225,7 @@ FMT / ARGS are passed to `format'."
"wird-tracker.js"
"zhd.js"
"play.js"
+ "ash-below-lake.js"
)
"\n")
(format "
diff --git a/play.org b/play.org
index eb2b044..3c5d098 100644
--- a/play.org
+++ b/play.org
@@ -51,7 +51,7 @@
08 Archive Terminal
09 Study Lamp
10 Sigil Press
- 11 Hollow Archive
+ 11 Ash Below the Lake
#+END_EXPORT
diff --git a/play/rpg.org b/play/rpg.org
index d53feaa..d4cb983 100644
--- a/play/rpg.org
+++ b/play/rpg.org
@@ -1,23 +1,23 @@
-#+TITLE: Hollow Archive
+#+TITLE: Ash Below the Lake
#+OPTIONS: num:nil title:nil toc:nil
#+NO_SIDENOTES: t
#+DATE: <2026-05-09 Sat>
#+BEGIN_EXPORT html
-
+
Back to Play
11 / Role Playing Game
- Hollow Archive
- A tiny 2D RPG base: move through rooms, speak with characters, collect items, complete quests, save progress, and steer the ending.
+ Ash Below the Lake
+ A 2D story RPG set in Morrow's End: a town beside a black lake that remembers names, grief, and everyone the world tries to forget.
-
diff --git a/posts/posts-list.org b/posts/posts-list.org
index b08b4cd..5019c21 100755
--- a/posts/posts-list.org
+++ b/posts/posts-list.org
@@ -4,7 +4,7 @@
See the categories: @@html:Categories @@
* Posts:
-- [[file:career/career-list.org][Career List]] @@html:09-05-2026 22:35 @@
+- [[file:career/career-list.org][Career List]] @@html:09-05-2026 22:36 @@
- [[file:career/ha-dr.org][High Availability, Disaster Recovery and Business Continuity]] @@html:11-03-2026 17:18 @@ @@html:learning @@ @@html:notes @@
- [[file:career/javascript.org][Understands the Javascript language]] @@html:11-03-2026 16:52 @@ @@html:learning @@ @@html:notes @@
- [[file:career/restful-api.org][Restful API]] @@html:15-02-2026 23:00 @@ @@html:learning @@ @@html:notes @@
diff --git a/recently-updated.org b/recently-updated.org
index 345ba03..0316036 100755
--- a/recently-updated.org
+++ b/recently-updated.org
@@ -15,7 +15,7 @@
- [[file:play/terminal.org][Archive Terminal]] @@html:2026-05-09 00:00 @@
- [[file:play/study.org][Study Lamp]] @@html:2026-05-09 00:00 @@
- [[file:play/sigil.org][Sigil Press]] @@html:2026-05-09 00:00 @@
-- [[file:play/rpg.org][Hollow Archive]] @@html:2026-05-09 00:00 @@
+- [[file:play/rpg.org][Ash Below the Lake]] @@html:2026-05-09 00:00 @@
- [[file:play/recipe.org][Supper Wheel]] @@html:2026-05-09 00:00 @@
- [[file:play/poem.org][Marginalia Machine]] @@html:2026-05-09 00:00 @@
- [[file:play/memory.org][Memory Cabinet]] @@html:2026-05-09 00:00 @@
diff --git a/rpg.md b/rpg.md
new file mode 100644
index 0000000..0da7be2
--- /dev/null
+++ b/rpg.md
@@ -0,0 +1,320 @@
+## Title: **Ash Below the Lake**
+
+### Premise
+
+A quiet town sits beside a black lake that never reflects the sky.
+
+Every year, one person disappears into the water.
+
+Nobody talks about it.
+
+You play as a teenager named **Ilyas**, who arrives in the isolated town of **Morrow’s End** after receiving a letter from a sibling who vanished there years ago. The letter contains only one sentence:
+
+> “Don’t let the lake remember your name.”
+
+The game begins normally — odd townsfolk, quirky humor, strange shops — but slowly becomes darker as the player realizes the town itself is alive in a subtle, dreamlike way.
+
+---
+
+# Core Themes
+
+* Memory vs identity
+* Grief and denial
+* The fear of being forgotten
+* Whether pain should be erased or accepted
+
+Like *Undertale*, the game balances:
+
+* humor
+* emotional character writing
+* eerie atmosphere
+* choices that genuinely matter
+
+---
+
+# The World
+
+## Morrow’s End
+
+A sleepy lakeside town with:
+
+* crooked houses
+* underground tunnels
+* abandoned train stations
+* old flood barriers
+* glowing flowers near the shore
+
+The town is split into districts:
+
+1. **Harbor Row** – fishermen, markets, friendly NPCs
+2. **The Red Streets** – entertainment district with unsettling performers
+3. **The Roots** – underground caverns beneath the town
+4. **The Drowned Chapel** – sunken cathedral inside the lake
+5. **The Hollow Shore** – final area where reality begins collapsing
+
+---
+
+# Main Gameplay Twist
+
+The lake “remembers” people.
+
+If someone is forgotten by everybody, they slowly become erased from reality.
+
+Not dead.
+
+Erased.
+
+Photos change.
+Dialogue changes.
+Rooms disappear.
+Music distorts.
+
+As the player progresses, NPCs begin forgetting characters the player met earlier.
+
+Eventually the player questions:
+
+* Is the town cursed?
+* Or is the lake protecting people from painful memories?
+
+---
+
+# Main Characters
+
+## Ilyas (Player)
+
+Quiet protagonist searching for their sibling.
+
+The player can shape Ilyas:
+
+* compassionate
+* detached
+* manipulative
+* fearful
+
+Choices affect dialogue, endings, and combat resolution.
+
+---
+
+## Mira
+
+A sarcastic runaway girl who joins you early.
+
+She uses humor to hide panic and becomes emotionally attached to the player.
+
+She remembers things others forget.
+
+That should be impossible.
+
+---
+
+## The Ferryman
+
+A masked boatman who appears throughout the game.
+
+He speaks in riddles and always knows where the player has been.
+
+Sometimes he appears before important choices.
+
+He may not be human.
+
+---
+
+## Brother Cinder
+
+Leader of the Drowned Chapel.
+
+Believes forgetting pain is mercy.
+
+Wants to feed memories to the lake so humanity can become “peaceful.”
+
+Not purely evil — deeply tragic.
+
+---
+
+## Your Sibling
+
+You spend the whole game searching for them.
+
+Late-game reveal:
+They willingly entered the lake.
+
+And they may no longer fully exist.
+
+---
+
+# Combat System
+
+Similar vibe to *Undertale*:
+
+* turn-based encounters
+* bullet-hell dodging
+* dialogue options during fights
+
+But instead of MERCY/FIGHT:
+
+## Three Main Actions
+
+* **Confront** — direct resistance
+* **Listen** — understand enemies emotionally
+* **Forget** — erase enemies from memory
+
+Using “Forget” makes encounters easier…
+
+…but causes side effects in the world.
+
+NPCs disappear.
+Music tracks vanish.
+Areas become inaccessible.
+
+The player slowly realizes:
+They are becoming part of the lake.
+
+---
+
+# Major Story Beats
+
+## Act 1 — Strange Town
+
+* Meet Mira
+* Discover townsfolk avoid speaking certain names
+* Encounter first erased person
+* Learn your sibling stayed at the old chapel
+
+Tone:
+cozy + unsettling
+
+---
+
+## Act 2 — The Roots
+
+You descend beneath the town.
+
+Find:
+
+* memory archives
+* abandoned homes nobody remembers
+* creatures made from forgotten people
+
+The player discovers:
+The lake feeds on emotional suffering.
+
+---
+
+## Act 3 — The Drowned Chapel
+
+Brother Cinder reveals the truth:
+The lake was created long ago to contain humanity’s collective grief.
+
+Without it:
+people would drown in their pain.
+
+But now it has grown hungry.
+
+---
+
+## Final Act — The Hollow Shore
+
+Reality breaks down:
+
+* menus glitch
+* save points speak
+* NPC dialogue rewrites itself
+* your own name occasionally changes
+
+The player must choose:
+
+### Ending 1 — “Silence”
+
+Join the lake.
+All pain disappears.
+The town becomes peaceful… empty.
+
+---
+
+### Ending 2 — “Carry”
+
+Destroy the lake.
+Everyone regains painful memories.
+The town survives but suffers.
+
+Hopeful but bittersweet.
+
+---
+
+### Ending 3 — “Forgotten”
+
+Use “Forget” too often.
+
+Eventually:
+the protagonist themselves is erased.
+
+The final scene shows the game continuing without you.
+
+No one remembers the player existed.
+
+---
+
+# Tone & Style
+
+Visual Style:
+
+* pixel art
+* heavy shadows
+* warm interiors vs cold outdoor areas
+* subtle environmental animations
+
+Music:
+
+* soft piano
+* detuned music-box melodies
+* distorted ambient tracks late-game
+
+Humor:
+
+* weird NPC dialogue
+* absurd side quests
+* fake advertisements
+* awkward interactions
+
+The humor makes the emotional moments hit harder.
+
+---
+
+# Extra Mechanics
+
+## Memory Journal
+
+Instead of a quest log, the player keeps memories.
+
+Entries can:
+
+* change
+* disappear
+* rewrite themselves
+
+The player cannot always trust it.
+
+---
+
+## Name System
+
+NPCs react differently depending on whether you tell them your real name.
+
+Some characters literally gain power if they know it.
+
+---
+
+# Optional Secret Route
+
+If the player never uses “Forget” and resolves conflicts peacefully:
+
+* hidden areas appear
+* erased characters return temporarily
+* Mira reveals she died years ago and is only remembered because of you
+
+This leads to the true ending:
+The lake is not destroyed.
+
+It is forgiven.
+
+And finally allowed to rest.
diff --git a/sitemap.org b/sitemap.org
index f456950..4c8c206 100755
--- a/sitemap.org
+++ b/sitemap.org
@@ -50,18 +50,18 @@
- tags
- [[file:tags/life.sync-conflict-20260417-233423-VT6366A.org][Tag: life]]
- [[file:tags/review.sync-conflict-20260328-203248-VT6366A.org][Tag: review]]
- - [[file:tags/learning.org][Tag: learning]]
- [[file:tags/introduction.org][Tag: introduction]]
+ - [[file:tags/learning.org][Tag: learning]]
- [[file:tags/notes.org][Tag: notes]]
- [[file:tags/website.org][Tag: website]]
- [[file:tags/review.org][Tag: review]]
- - [[file:tags/life.org][Tag: life]]
- [[file:tags/emacs.org][Tag: emacs]]
- [[file:tags/education.org][Tag: education]]
- [[file:tags/update.org][Tag: update]]
+ - [[file:tags/life.org][Tag: life]]
- [[file:tags/reading.org][Tag: reading]]
- - [[file:tags/insights.org][Tag: insights]]
- [[file:tags/maths.org][Tag: maths]]
+ - [[file:tags/insights.org][Tag: insights]]
- lima
- [[file:lima/lima-list.org][Lima]]
- play
@@ -75,4 +75,4 @@
- [[file:play/timeline.org][Timeline Tangle]]
- [[file:play/recipe.org][Supper Wheel]]
- [[file:play/terminal.org][Archive Terminal]]
- - [[file:play/rpg.org][Hollow Archive]]
\ No newline at end of file
+ - [[file:play/rpg.org][Ash Below the Lake]]
\ No newline at end of file