diff --git a/assets/scripts/play.js b/assets/scripts/play.js
new file mode 100644
index 0000000..442d051
--- /dev/null
+++ b/assets/scripts/play.js
@@ -0,0 +1,865 @@
+(function () {
+ const $ = (sel, root = document) => root.querySelector(sel);
+ const $$ = (sel, root = document) => Array.from(root.querySelectorAll(sel));
+ const shuffle = (items) => items.map((value) => ({ value, sort: Math.random() })).sort((a, b) => a.sort - b.sort).map((item) => item.value);
+ const quips = [
+ "A margin note blinks, then pretends it did not.",
+ "Somewhere, a bookmark changes its mind.",
+ "The archive is pleased by unnecessary curiosity.",
+ "A tiny bell rings in a room you have not built yet.",
+ "The page remembers that you came here."
+ ];
+
+ document.addEventListener("DOMContentLoaded", () => {
+ const root = $(".play-root[data-play-page]");
+ if (!root) return;
+ const page = root.dataset.playPage;
+ const inits = { hub, memory, constellation, poem, bookshelf, recipe, timeline, ink, terminal, study, sigil, rpg };
+ if (inits[page]) inits[page](root);
+ });
+
+ function hub(root) {
+ const links = $$(".play-grid a", root);
+ const nodes = $$(".play-orbit__node", root);
+ const title = $("#play-hub-title", root);
+ const kind = $("#play-hub-kind", root);
+ const desc = $("#play-hub-desc", root);
+ const cta = $("#play-hub-link", root);
+ const whisper = document.createElement("p");
+ let active = 0;
+
+ whisper.className = "play-whisper";
+ whisper.setAttribute("aria-live", "polite");
+ $(".play-console__screen", root).appendChild(whisper);
+
+ nodes.forEach((node, index) => {
+ const angle = (index / nodes.length) * Math.PI * 2 - Math.PI / 2;
+ node.style.left = `${50 + Math.cos(angle) * 43}%`;
+ node.style.top = `${50 + Math.sin(angle) * 43}%`;
+ node.addEventListener("click", () => setActive(index));
+ });
+
+ function setActive(index) {
+ active = (index + links.length) % links.length;
+ const link = links[active];
+ links.forEach((item, i) => item.classList.toggle("is-active", i === active));
+ nodes.forEach((item, i) => item.classList.toggle("is-active", i === active));
+ title.textContent = link.querySelector("strong").textContent;
+ kind.textContent = link.dataset.kind;
+ desc.textContent = link.dataset.desc;
+ cta.href = link.href;
+ whisper.textContent = quips[active % quips.length];
+ }
+
+ links.forEach((link, index) => {
+ link.addEventListener("mouseenter", () => setActive(index));
+ link.addEventListener("focus", () => setActive(index));
+ });
+ $("[data-play-prev]", root).addEventListener("click", () => setActive(active - 1));
+ $("[data-play-next]", root).addEventListener("click", () => setActive(active + 1));
+ $("[data-play-random]", root).addEventListener("click", () => setActive(Math.floor(Math.random() * links.length)));
+ $(".play-orbit", root).addEventListener("dblclick", () => {
+ whisper.textContent = "You knocked twice. The cabinet knocked once back.";
+ root.classList.toggle("is-odd");
+ });
+ root.addEventListener("keydown", (event) => {
+ if (event.key === "ArrowLeft") setActive(active - 1);
+ if (event.key === "ArrowRight") setActive(active + 1);
+ });
+ setActive(0);
+ }
+
+ function memory(root) {
+ const symbols = ["Ink", "Lamp", "Tea", "Book", "Map", "Key"];
+ const board = $("[data-memory-board]", root);
+ const turnsEl = $("[data-memory-turns]", root);
+ const matchesEl = $("[data-memory-matches]", root);
+ const messageEl = $("[data-memory-message]", root);
+ let open = [];
+ let turns = 0;
+ let matches = 0;
+ let deck = [];
+
+ function render() {
+ board.innerHTML = "";
+ open = [];
+ turns = 0;
+ matches = 0;
+ deck = shuffle([...symbols, ...symbols]);
+ turnsEl.textContent = "0";
+ matchesEl.textContent = "0";
+ messageEl.textContent = "Cards are shuffled.";
+ deck.forEach((symbol, index) => {
+ const card = document.createElement("button");
+ card.type = "button";
+ card.className = "memory-card";
+ card.dataset.symbol = symbol;
+ card.textContent = "?";
+ card.style.setProperty("--tilt", `${(index % 5) - 2}deg`);
+ card.setAttribute("aria-label", "Hidden card");
+ card.addEventListener("click", () => flip(card));
+ board.appendChild(card);
+ });
+ }
+
+ function flip(card) {
+ if (card.classList.contains("is-open") || card.classList.contains("is-matched") || open.length === 2) return;
+ card.classList.add("is-open");
+ card.textContent = card.dataset.symbol;
+ card.setAttribute("aria-label", card.dataset.symbol);
+ open.push(card);
+ if (open.length !== 2) return;
+ turns += 1;
+ turnsEl.textContent = String(turns);
+ const [a, b] = open;
+ if (a.dataset.symbol === b.dataset.symbol) {
+ a.classList.add("is-matched");
+ b.classList.add("is-matched");
+ open = [];
+ matches += 1;
+ matchesEl.textContent = String(matches);
+ messageEl.textContent = matches === symbols.length ? `Cabinet solved in ${turns} turns.` : "A drawer clicks open.";
+ } else {
+ messageEl.textContent = "No match. The cabinet quietly re-files them.";
+ setTimeout(() => {
+ a.classList.remove("is-open");
+ b.classList.remove("is-open");
+ a.textContent = "?";
+ b.textContent = "?";
+ a.setAttribute("aria-label", "Hidden card");
+ b.setAttribute("aria-label", "Hidden card");
+ open = [];
+ }, 650);
+ }
+ }
+
+ $("[data-memory-reset]", root).addEventListener("click", render);
+ $("[data-memory-peek]", root).addEventListener("click", () => {
+ const hidden = $$(".memory-card:not(.is-open):not(.is-matched)", root);
+ hidden.forEach((card) => {
+ card.textContent = card.dataset.symbol;
+ card.classList.add("is-peeking");
+ });
+ messageEl.textContent = "A very brief lapse in scholarly discipline.";
+ setTimeout(() => {
+ hidden.forEach((card) => {
+ if (!card.classList.contains("is-open") && !card.classList.contains("is-matched")) card.textContent = "?";
+ card.classList.remove("is-peeking");
+ });
+ }, 900);
+ });
+ render();
+ }
+
+ function constellation(root) {
+ const canvas = $("[data-star-canvas]", root);
+ const ctx = canvas.getContext("2d");
+ const linesEl = $("[data-star-lines]", root);
+ const nameEl = document.createElement("strong");
+ const stars = Array.from({ length: 18 }, () => ({ x: 50 + Math.random() * 800, y: 45 + Math.random() * 430 }));
+ const lines = [];
+ let selected = null;
+ nameEl.className = "play-live-note";
+ linesEl.closest(".play-scorebar").appendChild(nameEl);
+
+ function draw() {
+ ctx.clearRect(0, 0, canvas.width, canvas.height);
+ ctx.fillStyle = "#18130f";
+ ctx.fillRect(0, 0, canvas.width, canvas.height);
+ ctx.strokeStyle = "rgba(219, 184, 104, 0.65)";
+ ctx.lineWidth = 2;
+ lines.forEach(([a, b]) => {
+ ctx.beginPath();
+ ctx.moveTo(stars[a].x, stars[a].y);
+ ctx.lineTo(stars[b].x, stars[b].y);
+ ctx.stroke();
+ });
+ stars.forEach((star, index) => {
+ ctx.beginPath();
+ ctx.fillStyle = index === selected ? "#f6d889" : "#fff8e8";
+ ctx.arc(star.x, star.y, index === selected ? 7 : 4, 0, Math.PI * 2);
+ ctx.fill();
+ });
+ linesEl.textContent = String(lines.length);
+ if (lines.length >= 3) nameEl.textContent = `Named: ${pick(["The Patient Spoon", "The South Window", "The Tired Comet", "The Fifth Errand"])}`;
+ }
+
+ canvas.addEventListener("click", (event) => {
+ const rect = canvas.getBoundingClientRect();
+ const x = ((event.clientX - rect.left) / rect.width) * canvas.width;
+ const y = ((event.clientY - rect.top) / rect.height) * canvas.height;
+ const hit = stars.findIndex((star) => Math.hypot(star.x - x, star.y - y) < 22);
+ if (hit < 0) return;
+ if (selected === null) selected = hit;
+ else if (selected !== hit) {
+ lines.push([selected, hit]);
+ selected = hit;
+ }
+ draw();
+ });
+ $("[data-star-reset]", root).addEventListener("click", () => {
+ lines.length = 0;
+ selected = null;
+ nameEl.textContent = "";
+ draw();
+ });
+ draw();
+ }
+
+ function poem(root) {
+ const output = $("[data-poem-output]", root);
+ const a = ["In the margin", "By the kettle", "Under the desk lamp", "Between two errands", "After the house quiets"];
+ const b = ["a careful thought", "an old joke", "a stubborn question", "a useful mistake", "a bright scrap"];
+ const c = ["learns to wait.", "asks for a second reading.", "finds its proper shelf.", "becomes tomorrow's note.", "keeps the page warm."];
+ let count = 0;
+ function make() {
+ count += 1;
+ output.innerHTML = `${pick(a)}
${pick(b)}
${pick(c)}`;
+ output.dataset.stamp = count % 5 === 0 ? "approved by the margin" : "";
+ }
+ $("[data-poem-generate]", root).addEventListener("click", make);
+ make();
+ }
+
+ function bookshelf(root) {
+ const titles = ["Algebra at Breakfast", "Cabinet of Weather", "Domestic Orbits", "Evening Margins", "Household Engines", "Zettels and Tea"];
+ const shelf = $("[data-bookshelf]", root);
+ const status = $("[data-bookshelf-status]", root);
+ let sortedOnce = false;
+ setupSortable(shelf, render, check);
+ $("[data-bookshelf-shuffle]", root).addEventListener("click", () => render(shuffle(titles)));
+ render(shuffle(titles));
+
+ function render(items) {
+ shelf.innerHTML = "";
+ items.forEach((title) => {
+ const book = document.createElement("div");
+ book.className = "book-spine";
+ book.draggable = true;
+ book.dataset.value = title;
+ book.textContent = title;
+ shelf.appendChild(book);
+ });
+ check();
+ }
+ function check() {
+ const current = $$(".book-spine", shelf).map((book) => book.dataset.value);
+ const sorted = current.join("|") === titles.join("|");
+ status.textContent = sorted ? "Shelf sorted. A secret pamphlet slides out." : "Unsorted";
+ if (sorted && !sortedOnce) {
+ sortedOnce = true;
+ const pamphlet = document.createElement("div");
+ pamphlet.className = "booklet";
+ pamphlet.textContent = "Pamphlet: On the Correct Order of Small Things";
+ shelf.appendChild(pamphlet);
+ }
+ }
+ }
+
+ function recipe(root) {
+ const wheel = $("[data-recipe-spin]", root);
+ const result = $("[data-recipe-result]", root);
+ const groups = [
+ ["Rice", "Flatbread", "Roast potatoes", "Noodles"],
+ ["Lemon chicken", "Spiced lentils", "Tomato eggs", "Pepper stew"],
+ ["Cucumber salad", "Mint yoghurt", "Pickled onions", "Charred greens"],
+ ["Serve with stories", "Eat outside", "Use the blue plates", "Make extra tea"]
+ ];
+ let spin = 0;
+ let feast = 0;
+ wheel.addEventListener("click", () => {
+ spin += 540 + Math.floor(Math.random() * 540);
+ feast += 1;
+ wheel.style.transform = `rotate(${spin}deg)`;
+ result.innerHTML = groups.map((group) => `
${pick(group)}`).join("") + (feast % 4 === 0 ? "Bonus: someone gets the last crispy bit." : "");
+ });
+ wheel.click();
+ }
+
+ function timeline(root) {
+ const items = [
+ { year: "2022", text: "The personal web habit begins." },
+ { year: "2025", text: "Org publishing becomes the main site engine." },
+ { year: "2025", text: "Weekly reviews and career notes grow into a library." },
+ { year: "2026", text: "Dashboards, services, and authoring tools join the site." },
+ { year: "2026", text: "The play wing opens." }
+ ];
+ const list = $("[data-timeline-list]", root);
+ const status = $("[data-timeline-status]", root);
+ const paradox = document.createElement("button");
+ paradox.type = "button";
+ paradox.textContent = "Paradox";
+ $("[data-timeline-shuffle]", root).insertAdjacentElement("afterend", paradox);
+ setupSortable(list, render, check);
+ $("[data-timeline-shuffle]", root).addEventListener("click", () => render(shuffle(items)));
+ paradox.addEventListener("click", () => render([...items].reverse()));
+ render(shuffle(items));
+
+ function render(source) {
+ list.innerHTML = "";
+ source.forEach((item, index) => {
+ const card = document.createElement("div");
+ card.className = "timeline-card";
+ card.draggable = true;
+ card.dataset.value = String(items.indexOf(item));
+ card.innerHTML = `${item.year}${item.text}`;
+ list.appendChild(card);
+ });
+ check();
+ }
+ function check() {
+ const current = $$(".timeline-card", list).map((card) => Number(card.dataset.value));
+ status.textContent = current.every((value, index) => value === index) ? "Timeline restored" : "Arrange the cards";
+ }
+ }
+
+ function ink(root) {
+ const canvas = $("[data-ink-canvas]", root);
+ const ctx = canvas.getContext("2d");
+ const drops = [];
+ let moon = false;
+ function addDrop(x, y, heavy) {
+ drops.push({ x, y, r: heavy ? 18 : 8, life: heavy ? 1.8 : 1, hue: 28 + Math.random() * 35 });
+ }
+ function pointer(event, heavy) {
+ const rect = canvas.getBoundingClientRect();
+ addDrop(((event.clientX - rect.left) / rect.width) * canvas.width, ((event.clientY - rect.top) / rect.height) * canvas.height, heavy);
+ }
+ canvas.addEventListener("pointermove", (event) => pointer(event, false));
+ canvas.addEventListener("click", (event) => pointer(event, true));
+ canvas.addEventListener("dblclick", () => {
+ moon = !moon;
+ });
+ function frame() {
+ ctx.fillStyle = "rgba(24, 19, 15, 0.16)";
+ ctx.fillRect(0, 0, canvas.width, canvas.height);
+ if (moon) {
+ ctx.beginPath();
+ ctx.fillStyle = "rgba(246, 216, 137, 0.72)";
+ ctx.arc(760, 88, 34, 0, Math.PI * 2);
+ ctx.fill();
+ }
+ drops.forEach((drop) => {
+ drop.r += 0.7;
+ drop.life -= 0.012;
+ ctx.beginPath();
+ ctx.strokeStyle = `hsla(${drop.hue}, 65%, 68%, ${Math.max(drop.life, 0)})`;
+ ctx.lineWidth = 2;
+ ctx.arc(drop.x, drop.y, drop.r, 0, Math.PI * 2);
+ ctx.stroke();
+ });
+ for (let i = drops.length - 1; i >= 0; i -= 1) if (drops[i].life <= 0) drops.splice(i, 1);
+ requestAnimationFrame(frame);
+ }
+ frame();
+ }
+
+ function terminal(root) {
+ const log = $("[data-terminal-log]", root);
+ const form = $("[data-terminal-form]", root);
+ const input = $("[data-terminal-input]", root);
+ let kindness = 0;
+ const responses = {
+ help: "Commands: help, look, map, open drawer, read note, brew tea, hum, knock, save, inventory, clear",
+ look: "A narrow archive room. A lamp hums. A drawer is labelled MAYBE IMPORTANT.",
+ map: "You are between the reading desk, the family shelf, and the door back to Play.",
+ "open drawer": "Inside: a brass key, a receipt, and a note folded twice.",
+ "read note": "The note says: keep the site useful, but leave a few doors ajar.",
+ "brew tea": "The room smells briefly of cardamom. Nothing else changes, which is enough.",
+ hum: "You hum four careful notes. Something behind the wall hums five back.",
+ knock: "Knock. Knock. ... A polite pause. Knock.",
+ save: "You save your place in the archive. The archive saves its place in you.",
+ inventory: "You are carrying: a brass key, a warm cup, and one unreasonable hope."
+ };
+ function write(text) {
+ log.textContent += `${text}\n`;
+ log.scrollTop = log.scrollHeight;
+ }
+ form.addEventListener("submit", (event) => {
+ event.preventDefault();
+ const cmd = input.value.trim().toLowerCase();
+ if (!cmd) return;
+ input.value = "";
+ if (cmd === "clear") {
+ log.textContent = "";
+ return;
+ }
+ write(`> ${cmd}`);
+ if (cmd === "pet book") {
+ kindness += 1;
+ write(kindness > 2 ? "The book follows you for exactly three pages." : "The book accepts this with suspicious dignity.");
+ } else {
+ write(responses[cmd] || "The archive declines to understand that command.");
+ }
+ });
+ write("Archive terminal ready. Type help. The cursor is listening.");
+ input.focus();
+ }
+
+ function study(root) {
+ const time = $("[data-study-time]", root);
+ const scene = $("[data-study-scene]", root);
+ let total = 300;
+ let left = total;
+ let timer = null;
+ const note = document.createElement("div");
+ note.className = "play-live-note";
+ note.textContent = "The lamp is cold.";
+ time.insertAdjacentElement("afterend", note);
+ function draw() {
+ const done = 1 - left / total;
+ scene.style.setProperty("--lamp", String(0.18 + done * 0.72));
+ scene.style.setProperty("--glow", `${done * 70}px`);
+ const m = String(Math.floor(left / 60)).padStart(2, "0");
+ const s = String(left % 60).padStart(2, "0");
+ time.textContent = `${m}:${s}`;
+ if (left === 0) note.textContent = "Focus complete. The desk looks proud.";
+ else if (done > 0.66) note.textContent = "The page has warmed through.";
+ else if (done > 0.33) note.textContent = "The lamp has settled into its work.";
+ }
+ function start() {
+ if (timer) return;
+ timer = setInterval(() => {
+ left = Math.max(0, left - 1);
+ draw();
+ if (left === 0) pause();
+ }, 1000);
+ }
+ function pause() {
+ clearInterval(timer);
+ timer = null;
+ }
+ $("[data-study-start]", root).addEventListener("click", start);
+ $("[data-study-pause]", root).addEventListener("click", pause);
+ $("[data-study-reset]", root).addEventListener("click", () => {
+ pause();
+ left = total;
+ draw();
+ });
+ draw();
+ }
+
+ function sigil(root) {
+ const canvas = $("[data-sigil-canvas]", root);
+ const ctx = canvas.getContext("2d");
+ const initials = $("[data-sigil-initials]", root);
+ const motto = $("[data-sigil-motto]", root);
+ let wax = "#9c6b2f";
+ const waxButton = document.createElement("button");
+ waxButton.type = "button";
+ waxButton.textContent = "New wax";
+ $("[data-sigil-form]", root).appendChild(waxButton);
+ function draw() {
+ const text = (initials.value || "ZXH").toUpperCase();
+ const phrase = motto.value || "Learn, make, remember";
+ ctx.clearRect(0, 0, canvas.width, canvas.height);
+ ctx.fillStyle = "#fbf7ec";
+ ctx.fillRect(0, 0, canvas.width, canvas.height);
+ ctx.translate(260, 260);
+ ctx.strokeStyle = wax;
+ ctx.lineWidth = 10;
+ ctx.beginPath();
+ ctx.arc(0, 0, 190, 0, Math.PI * 2);
+ ctx.stroke();
+ ctx.lineWidth = 2;
+ for (let i = 0; i < 12; i += 1) {
+ ctx.rotate(Math.PI / 6);
+ ctx.beginPath();
+ ctx.moveTo(0, -150);
+ ctx.lineTo(0, -185);
+ ctx.stroke();
+ }
+ ctx.rotate(-Math.PI * 2);
+ ctx.fillStyle = "#2a2118";
+ ctx.textAlign = "center";
+ ctx.textBaseline = "middle";
+ ctx.font = "900 94px Georgia, serif";
+ ctx.fillText(text.slice(0, 4), 0, -10);
+ ctx.font = "700 22px Georgia, serif";
+ ctx.fillText(phrase.slice(0, 34), 0, 88);
+ ctx.setTransform(1, 0, 0, 1, 0, 0);
+ }
+ waxButton.addEventListener("click", () => {
+ wax = pick(["#9c6b2f", "#8a3f3f", "#486b57", "#3e5f8a", "#5f4b8b"]);
+ draw();
+ });
+ $("[data-sigil-form]", root).addEventListener("submit", (event) => {
+ event.preventDefault();
+ draw();
+ });
+ initials.addEventListener("input", draw);
+ motto.addEventListener("input", draw);
+ draw();
+ }
+
+ function rpg(root) {
+ const canvas = $("[data-rpg-canvas]", root);
+ const ctx = canvas.getContext("2d");
+ const els = {
+ speaker: $("[data-rpg-speaker]", root),
+ line: $("[data-rpg-line]", root),
+ room: $("[data-rpg-room]", root),
+ route: $("[data-rpg-route]", root),
+ hearts: $("[data-rpg-hearts]", root),
+ quests: $("[data-rpg-quests]", root),
+ inventory: $("[data-rpg-inventory]", root),
+ status: $("[data-rpg-status]", root)
+ };
+ const tile = 32;
+ const saveSlot = "hollow-archive";
+ const fallbackKey = "play:rpg:hollow-archive";
+ const items = {
+ lamp: { label: "Desk Lamp", room: "entrance", x: 10, y: 8, color: "#f2c94c" },
+ page: { label: "Loose Page", room: "stacks", x: 18, y: 5, color: "#f7efe0" },
+ key: { label: "Basement Key", room: "garden", x: 4, y: 11, color: "#d59b45" }
+ };
+ const npcs = {
+ archivist: { name: "Archivist", room: "entrance", x: 6, y: 6, color: "#b98bff" },
+ shade: { name: "Shy Shade", room: "stacks", x: 17, y: 9, color: "#6ed0d4" },
+ gate: { name: "Iron Door", room: "garden", x: 19, y: 10, color: "#8a8f98" }
+ };
+ const rooms = {
+ entrance: {
+ name: "Entrance",
+ floor: "#353029",
+ exits: [{ x: 22, y: 7, to: "stacks", px: 1, py: 7 }],
+ walls: rects([[0, 0, 24, 1], [0, 15, 24, 1], [0, 0, 1, 16], [23, 0, 1, 6], [23, 9, 1, 7], [8, 3, 1, 8], [14, 7, 5, 1]])
+ },
+ stacks: {
+ name: "Stacks",
+ floor: "#242d35",
+ exits: [{ x: 0, y: 7, to: "entrance", px: 22, py: 7 }, { x: 23, y: 12, to: "garden", px: 1, py: 12 }],
+ walls: rects([[0, 0, 24, 1], [0, 15, 24, 1], [0, 0, 1, 6], [0, 9, 1, 7], [23, 0, 1, 11], [23, 14, 1, 2], [4, 3, 2, 10], [10, 2, 2, 11], [16, 3, 2, 5]])
+ },
+ garden: {
+ name: "Moon Garden",
+ floor: "#21362e",
+ exits: [{ x: 0, y: 12, to: "stacks", px: 22, py: 12 }],
+ walls: rects([[0, 0, 24, 1], [0, 15, 24, 1], [0, 0, 1, 11], [0, 14, 1, 2], [23, 0, 1, 16], [7, 4, 10, 1], [7, 10, 1, 4], [13, 10, 1, 4]])
+ }
+ };
+ let state = freshState();
+ let running = false;
+
+ function freshState() {
+ return {
+ room: "entrance",
+ player: { x: 3, y: 7, facing: "down" },
+ inventory: [],
+ flags: {},
+ route: "Undecided",
+ hearts: 3,
+ ending: null,
+ message: { speaker: "Archivist", line: "The archive waits. Find the lamp, help the shade, then decide what to do with the locked door." }
+ };
+ }
+
+ 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 draw() {
+ const room = rooms[state.room];
+ ctx.fillStyle = "#14110e";
+ ctx.fillRect(0, 0, canvas.width, canvas.height);
+ for (let y = 0; y < 16; y += 1) {
+ for (let x = 0; x < 24; x += 1) {
+ ctx.fillStyle = room.walls.has(`${x},${y}`) ? "#181716" : room.floor;
+ ctx.fillRect(x * tile, y * tile, tile, tile);
+ ctx.strokeStyle = "rgba(255,255,255,0.035)";
+ ctx.strokeRect(x * tile, y * tile, tile, tile);
+ }
+ }
+ room.exits.forEach((exit) => drawGlyph(exit.x, exit.y, "#c48a41", "door"));
+ Object.entries(items).forEach(([id, item]) => {
+ if (item.room === state.room && !state.inventory.includes(id)) drawGlyph(item.x, item.y, item.color, "item");
+ });
+ Object.values(npcs).forEach((npc) => {
+ if (npc.room === state.room) drawGlyph(npc.x, npc.y, npc.color, npc.name === "Iron Door" ? "doorNpc" : "npc");
+ });
+ drawPlayer();
+ renderHud();
+ requestAnimationFrame(draw);
+ }
+
+ function drawGlyph(x, y, color, kind) {
+ const px = x * tile;
+ const py = y * tile;
+ ctx.fillStyle = color;
+ if (kind === "item") {
+ ctx.fillRect(px + 10, py + 10, 12, 12);
+ ctx.fillStyle = "rgba(255,255,255,0.45)";
+ ctx.fillRect(px + 13, py + 7, 6, 6);
+ } else if (kind === "door" || kind === "doorNpc") {
+ ctx.fillRect(px + 7, py + 4, 18, 25);
+ ctx.fillStyle = "#211812";
+ ctx.fillRect(px + 20, py + 16, 3, 3);
+ } else {
+ ctx.beginPath();
+ ctx.arc(px + 16, py + 12, 9, 0, Math.PI * 2);
+ ctx.fill();
+ ctx.fillRect(px + 8, py + 20, 16, 8);
+ }
+ }
+
+ function drawPlayer() {
+ const px = state.player.x * tile;
+ const py = state.player.y * tile;
+ ctx.fillStyle = "#f35f5f";
+ ctx.beginPath();
+ ctx.moveTo(px + 16, py + 7);
+ ctx.bezierCurveTo(px + 2, py + 2, px + 1, py + 22, px + 16, py + 28);
+ ctx.bezierCurveTo(px + 31, py + 22, px + 30, py + 2, px + 16, py + 7);
+ ctx.fill();
+ ctx.fillStyle = "#fff8e8";
+ ctx.fillRect(px + 11, py + 13, 4, 4);
+ ctx.fillRect(px + 18, py + 13, 4, 4);
+ }
+
+ function renderHud() {
+ els.speaker.textContent = state.message.speaker;
+ els.line.textContent = state.ending ? endingLine() : state.message.line;
+ els.room.textContent = rooms[state.room].name;
+ els.route.textContent = state.route;
+ els.hearts.textContent = String(state.hearts);
+ els.inventory.innerHTML = state.inventory.length
+ ? state.inventory.map((id) => `${items[id].label}`).join("")
+ : "Empty";
+ const questRows = [
+ ["Find a light", state.inventory.includes("lamp")],
+ ["Return the loose page to the shade", state.flags.shadeHelped],
+ ["Open, force, or leave the iron door", Boolean(state.ending)]
+ ];
+ els.quests.innerHTML = questRows.map(([text, done]) => `${done ? "Done: " : ""}${text}`).join("");
+ }
+
+ function move(dx, dy, facing) {
+ if (!running || state.ending) return;
+ state.player.facing = facing;
+ const nx = state.player.x + dx;
+ const ny = state.player.y + dy;
+ const room = rooms[state.room];
+ const exit = room.exits.find((candidate) => candidate.x === nx && candidate.y === ny);
+ if (exit) {
+ state.room = exit.to;
+ state.player.x = exit.px;
+ state.player.y = exit.py;
+ say("Narrator", `You enter ${rooms[state.room].name}.`);
+ autosave();
+ return;
+ }
+ if (nx < 0 || ny < 0 || nx > 23 || ny > 15 || room.walls.has(`${nx},${ny}`) || npcAt(nx, ny)) return;
+ state.player.x = nx;
+ state.player.y = ny;
+ const item = itemAt(nx, ny);
+ if (item) take(item);
+ }
+
+ function act() {
+ if (!running) return start();
+ if (state.ending) return;
+ const front = inFront();
+ const npc = npcAt(front.x, front.y);
+ if (npc) talk(npc);
+ else {
+ const here = itemAt(state.player.x, state.player.y);
+ if (here) take(here);
+ else say("Narrator", "Dust moves in the light. Nothing asks to be changed here.");
+ }
+ autosave();
+ }
+
+ function inFront() {
+ const delta = { up: [0, -1], down: [0, 1], left: [-1, 0], right: [1, 0] }[state.player.facing] || [0, 1];
+ return { x: state.player.x + delta[0], y: state.player.y + delta[1] };
+ }
+
+ function itemAt(x, y) {
+ return Object.keys(items).find((id) => {
+ const item = items[id];
+ return item.room === state.room && item.x === x && item.y === y && !state.inventory.includes(id);
+ });
+ }
+
+ function npcAt(x, y) {
+ return Object.keys(npcs).find((id) => {
+ const npc = npcs[id];
+ return npc.room === state.room && npc.x === x && npc.y === y;
+ });
+ }
+
+ function take(id) {
+ state.inventory.push(id);
+ if (id === "lamp") state.route = "Gentle";
+ if (id === "key" && !state.flags.shadeHelped) state.route = "Power";
+ say("Found", `${items[id].label} joined your inventory.`);
+ }
+
+ function talk(id) {
+ if (id === "archivist") {
+ if (!state.inventory.includes("lamp")) say("Archivist", "Take the lamp from the lower desk. The stacks dislike being crossed in the dark.");
+ else if (!state.flags.shadeHelped) say("Archivist", "A loose page has gone missing. The quiet reader in the stacks knows where it belongs.");
+ else say("Archivist", "You have been kind to a forgotten page. The garden door will remember that.");
+ }
+ if (id === "shade") {
+ if (!state.inventory.includes("page")) say("Shy Shade", "I lost the page with my name on it. It fell somewhere nearby.");
+ else {
+ state.flags.shadeHelped = true;
+ state.inventory = state.inventory.filter((item) => item !== "page");
+ state.route = "Mercy";
+ say("Shy Shade", "You returned my page instead of keeping it. Take the honest route through the garden.");
+ }
+ }
+ if (id === "gate") {
+ if (state.flags.shadeHelped) end("mercy");
+ else if (state.inventory.includes("key")) end("power");
+ else end("quiet");
+ }
+ }
+
+ function end(kind) {
+ state.ending = kind;
+ state.route = kind === "mercy" ? "Mercy" : kind === "power" ? "Power" : "Quiet";
+ say("Ending", endingLine());
+ save();
+ }
+
+ function endingLine() {
+ if (state.ending === "mercy") return "Mercy ending: the iron door opens without a sound, and every returned page remembers your name.";
+ if (state.ending === "power") return "Power ending: the key turns, but the archive grows colder around the missing page.";
+ if (state.ending === "quiet") return "Quiet ending: you leave the locked door alone. Some mysteries stay intact.";
+ return state.message.line;
+ }
+
+ function say(speaker, line) {
+ state.message = { speaker, line };
+ }
+
+ function start() {
+ running = true;
+ say("Archivist", "Walk the archive. Speak gently, or take what you need. The route will notice.");
+ els.status.textContent = "Started. Progress autosaves after room changes and actions.";
+ }
+
+ async function save() {
+ localStorage.setItem(fallbackKey, JSON.stringify(state));
+ try {
+ const response = await fetch(`/api/play/rpg/save/${saveSlot}`, {
+ method: "PUT",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({ payload: state })
+ });
+ if (!response.ok) throw new Error(`Save failed: ${response.status}`);
+ els.status.textContent = "Saved to backend.";
+ } catch (_error) {
+ els.status.textContent = "Saved locally. Backend save API was not reachable.";
+ }
+ }
+
+ async function load() {
+ try {
+ const response = await fetch(`/api/play/rpg/save/${saveSlot}`);
+ if (response.ok) {
+ const data = await response.json();
+ state = normalizeState(data.payload);
+ running = true;
+ els.status.textContent = "Loaded from backend.";
+ return;
+ }
+ } catch (_error) {
+ /* Fall through to local save. */
+ }
+ const local = localStorage.getItem(fallbackKey);
+ if (local) {
+ state = normalizeState(JSON.parse(local));
+ running = true;
+ els.status.textContent = "Loaded local save.";
+ } else {
+ els.status.textContent = "No save found.";
+ }
+ }
+
+ function normalizeState(candidate) {
+ return Object.assign(freshState(), candidate || {}, {
+ player: Object.assign(freshState().player, (candidate && candidate.player) || {}),
+ inventory: Array.isArray(candidate && candidate.inventory) ? candidate.inventory.filter((id) => items[id]) : [],
+ flags: Object.assign({}, (candidate && candidate.flags) || {}),
+ message: Object.assign(freshState().message, (candidate && candidate.message) || {})
+ });
+ }
+
+ function autosave() {
+ save();
+ }
+
+ $("[data-rpg-start]", root).addEventListener("click", start);
+ $("[data-rpg-save]", root).addEventListener("click", save);
+ $("[data-rpg-load]", root).addEventListener("click", load);
+ $("[data-rpg-reset]", root).addEventListener("click", async () => {
+ state = freshState();
+ running = false;
+ localStorage.removeItem(fallbackKey);
+ try {
+ await fetch(`/api/play/rpg/save/${saveSlot}`, { method: "DELETE" });
+ els.status.textContent = "Reset and cleared backend save.";
+ } catch (_error) {
+ els.status.textContent = "Reset locally. Backend save API was not reachable.";
+ }
+ });
+ $("[data-rpg-act]", root).addEventListener("click", act);
+ $$("[data-rpg-move]", root).forEach((button) => {
+ const moves = { up: [0, -1, "up"], down: [0, 1, "down"], left: [-1, 0, "left"], right: [1, 0, "right"] };
+ button.addEventListener("click", () => move(...moves[button.dataset.rpgMove]));
+ });
+ window.addEventListener("keydown", (event) => {
+ if (!root.isConnected) return;
+ const tag = event.target.tagName;
+ if (tag === "INPUT" || tag === "TEXTAREA") return;
+ const keys = {
+ ArrowUp: [0, -1, "up"], w: [0, -1, "up"],
+ ArrowDown: [0, 1, "down"], s: [0, 1, "down"],
+ ArrowLeft: [-1, 0, "left"], a: [-1, 0, "left"],
+ ArrowRight: [1, 0, "right"], d: [1, 0, "right"]
+ };
+ if (keys[event.key]) {
+ event.preventDefault();
+ move(...keys[event.key]);
+ }
+ if (event.key === " " || event.key === "Enter") {
+ event.preventDefault();
+ act();
+ }
+ });
+ draw();
+ load();
+ }
+
+ function setupSortable(container, _render, after) {
+ let dragged = null;
+ container.addEventListener("dragstart", (event) => {
+ dragged = event.target.closest("[draggable='true']");
+ if (dragged) event.dataTransfer.effectAllowed = "move";
+ });
+ container.addEventListener("dragover", (event) => {
+ event.preventDefault();
+ const target = event.target.closest("[draggable='true']");
+ if (!dragged || !target || target === dragged) return;
+ const rect = target.getBoundingClientRect();
+ const before = event.clientY < rect.top + rect.height / 2 || event.clientX < rect.left + rect.width / 2;
+ container.insertBefore(dragged, before ? target : target.nextSibling);
+ after();
+ });
+ container.addEventListener("dragend", () => {
+ dragged = null;
+ after();
+ });
+ }
+
+ function pick(items) {
+ return items[Math.floor(Math.random() * items.length)];
+ }
+})();
diff --git a/assets/styles/play.css b/assets/styles/play.css
new file mode 100644
index 0000000..5307e3a
--- /dev/null
+++ b/assets/styles/play.css
@@ -0,0 +1,646 @@
+.play-root {
+ --play-ink: #2a2118;
+ --play-paper: color-mix(in oklab, var(--surface) 86%, #f4ead2 14%);
+ --play-brass: #9c6b2f;
+ --play-green: #486b57;
+ --play-red: #8a3f3f;
+ --play-blue: #3e5f8a;
+ max-width: 1100px;
+ margin: 2rem auto 4rem;
+ padding: 0 1rem;
+}
+
+.play-root * {
+ box-sizing: border-box;
+}
+
+.play-root.is-odd {
+ filter: saturate(1.08) contrast(1.02);
+}
+
+.play-hero,
+.play-console,
+.play-tool {
+ border: 1px solid var(--border);
+ border-radius: 8px;
+ background: var(--play-paper);
+ box-shadow: 0 18px 45px rgba(35, 26, 16, 0.08);
+}
+
+.play-hero {
+ display: grid;
+ grid-template-columns: minmax(0, 1.2fr) minmax(280px, 0.8fr);
+ align-items: center;
+ gap: 2rem;
+ padding: clamp(1.25rem, 4vw, 3rem);
+ overflow: hidden;
+}
+
+.play-kicker {
+ margin: 0 0 0.45rem;
+ color: var(--play-brass);
+ font-size: 0.78rem;
+ font-weight: 700;
+ letter-spacing: 0.12em;
+ text-transform: uppercase;
+}
+
+.play-hero h1,
+.play-page-head h1 {
+ margin: 0;
+ color: var(--heading);
+}
+
+.play-lede,
+.play-page-head p {
+ max-width: 62ch;
+ margin-top: 0.75rem;
+ color: var(--muted);
+}
+
+.play-orbit {
+ position: relative;
+ aspect-ratio: 1;
+ min-height: 300px;
+ border: 1px dashed color-mix(in oklab, var(--border) 70%, var(--play-brass));
+ border-radius: 50%;
+ background:
+ linear-gradient(90deg, transparent 49.8%, color-mix(in oklab, var(--border) 70%, transparent) 50%, transparent 50.2%),
+ linear-gradient(0deg, transparent 49.8%, color-mix(in oklab, var(--border) 70%, transparent) 50%, transparent 50.2%);
+}
+
+.play-orbit::after {
+ content: "PLAY";
+ position: absolute;
+ inset: 35%;
+ display: grid;
+ place-items: center;
+ border-radius: 50%;
+ border: 1px solid var(--border);
+ color: var(--muted);
+ font-weight: 800;
+ letter-spacing: 0.16em;
+}
+
+.play-orbit__node {
+ position: absolute;
+ width: 42px;
+ height: 42px;
+ border: 1px solid var(--border);
+ border-radius: 50%;
+ background: var(--surface);
+ color: var(--fg);
+ cursor: pointer;
+ transform: translate(-50%, -50%);
+}
+
+.play-orbit__node.is-active,
+.play-orbit__node:hover {
+ background: var(--play-brass);
+ color: #fff8e8;
+}
+
+.play-console {
+ display: grid;
+ grid-template-columns: minmax(0, 1fr) auto;
+ gap: 1rem;
+ margin: 1rem 0;
+ padding: 1rem;
+}
+
+.play-console__screen {
+ min-height: 190px;
+ padding: 1.25rem;
+ border-radius: 6px;
+ background:
+ repeating-linear-gradient(0deg, rgba(0, 0, 0, 0.025), rgba(0, 0, 0, 0.025) 1px, transparent 1px, transparent 7px),
+ color-mix(in oklab, var(--surface) 92%, var(--play-green) 8%);
+}
+
+.play-console__screen h2 {
+ margin: 0.2rem 0 0.5rem;
+}
+
+.play-whisper,
+.play-live-note {
+ margin: 0.7rem 0 0;
+ color: var(--muted);
+ font-size: 0.92rem;
+ font-style: italic;
+}
+
+.play-console__eyebrow {
+ margin: 0;
+ color: var(--play-green);
+ font-weight: 800;
+ text-transform: uppercase;
+}
+
+.play-console__controls {
+ display: flex;
+ flex-wrap: wrap;
+ gap: 0.6rem;
+ align-content: start;
+}
+
+.play-root button,
+.play-primary,
+.sigil-form button {
+ min-height: 42px;
+ border: 1px solid color-mix(in oklab, var(--border) 70%, var(--play-brass));
+ border-radius: 6px;
+ padding: 0.55rem 0.85rem;
+ background: var(--surface);
+ color: var(--fg);
+ font: inherit;
+ cursor: pointer;
+}
+
+.play-root button:hover,
+.play-primary:hover,
+.sigil-form button:hover {
+ background: var(--play-ink);
+ color: #fff8e8;
+ text-decoration: none;
+}
+
+.play-primary {
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ margin-top: 0.5rem;
+ background: var(--play-brass);
+ color: #fff8e8;
+ font-weight: 800;
+}
+
+.play-grid {
+ display: grid;
+ grid-template-columns: repeat(auto-fit, minmax(210px, 1fr));
+ gap: 0.8rem;
+}
+
+.play-grid a {
+ min-height: 135px;
+ padding: 1rem;
+ border: 1px solid var(--border);
+ border-radius: 8px;
+ background: color-mix(in oklab, var(--surface) 88%, var(--play-paper) 12%);
+ color: var(--fg);
+ transition: transform 160ms ease, border-color 160ms ease, background 160ms ease;
+}
+
+.play-grid a:hover,
+.play-grid a.is-active {
+ transform: translateY(-3px);
+ border-color: var(--play-brass);
+ background: color-mix(in oklab, var(--surface) 76%, var(--play-brass) 24%);
+ text-decoration: none;
+}
+
+.play-grid span {
+ display: block;
+ color: var(--muted);
+ font-size: 0.8rem;
+ font-weight: 800;
+}
+
+.play-grid strong {
+ display: block;
+ margin-top: 2rem;
+ color: var(--heading);
+ font-size: 1.05rem;
+}
+
+.play-back {
+ display: inline-flex;
+ margin-bottom: 1rem;
+ color: var(--muted);
+}
+
+.play-page-head {
+ margin-bottom: 1rem;
+}
+
+.play-tool {
+ padding: 1rem;
+}
+
+.play-scorebar {
+ display: flex;
+ justify-content: space-between;
+ align-items: center;
+ flex-wrap: wrap;
+ gap: 0.75rem;
+ margin-bottom: 1rem;
+ color: var(--muted);
+}
+
+.memory-board {
+ display: grid;
+ grid-template-columns: repeat(4, minmax(72px, 1fr));
+ gap: 0.7rem;
+}
+
+.memory-card {
+ aspect-ratio: 1.08;
+ border: 1px solid var(--border);
+ border-radius: 8px;
+ background:
+ radial-gradient(circle at 50% 38%, rgba(246, 216, 137, 0.15), transparent 28%),
+ linear-gradient(135deg, #211812, var(--play-ink));
+ color: #e7c978;
+ font-size: clamp(1.3rem, 4vw, 2.2rem);
+ font-weight: 900;
+ transform: rotate(var(--tilt, 0deg));
+ transition: transform 180ms ease, background 180ms ease, color 180ms ease;
+}
+
+.memory-card.is-open,
+.memory-card.is-matched,
+.memory-card.is-peeking {
+ background: color-mix(in oklab, var(--surface) 72%, var(--play-green) 28%);
+ color: var(--fg);
+ transform: rotate(0deg) translateY(-2px);
+}
+
+.memory-card.is-matched {
+ outline: 2px solid color-mix(in oklab, var(--play-brass) 70%, transparent);
+}
+
+.play-canvas,
+.sigil-canvas {
+ display: block;
+ width: 100%;
+ max-height: 70vh;
+ border: 1px solid var(--border);
+ border-radius: 8px;
+ background: #18130f;
+}
+
+.poem-tool {
+ display: grid;
+ gap: 1rem;
+}
+
+.poem-slip {
+ min-height: 220px;
+ padding: 1.2rem;
+ border: 1px solid var(--border);
+ border-radius: 8px;
+ background: var(--surface);
+ font-family: Georgia, "Times New Roman", serif;
+ font-size: 1.25rem;
+ line-height: 1.7;
+}
+
+.poem-slip[data-stamp]:not([data-stamp=""])::after {
+ content: attr(data-stamp);
+ display: inline-block;
+ margin-top: 1rem;
+ padding: 0.2rem 0.5rem;
+ border: 2px solid var(--play-red);
+ color: var(--play-red);
+ font-size: 0.75rem;
+ font-weight: 800;
+ text-transform: uppercase;
+ transform: rotate(-4deg);
+}
+
+.bookshelf,
+.timeline-list {
+ display: flex;
+ flex-wrap: wrap;
+ gap: 0.75rem;
+ align-items: stretch;
+}
+
+.book-spine,
+.timeline-card {
+ touch-action: none;
+ border: 1px solid var(--border);
+ border-radius: 7px;
+ padding: 0.8rem;
+ background: var(--surface);
+ cursor: grab;
+ user-select: none;
+}
+
+.book-spine {
+ width: 120px;
+ min-height: 230px;
+ writing-mode: vertical-rl;
+ text-orientation: mixed;
+ color: #fff8e8;
+ font-weight: 800;
+}
+
+.book-spine:nth-child(3n+1) { background: var(--play-red); }
+.book-spine:nth-child(3n+2) { background: var(--play-green); }
+.book-spine:nth-child(3n+3) { background: var(--play-blue); }
+
+.booklet {
+ flex: 1 1 220px;
+ min-height: 110px;
+ padding: 0.9rem;
+ border: 1px dashed var(--play-brass);
+ border-radius: 7px;
+ background: color-mix(in oklab, var(--surface) 74%, var(--play-brass) 26%);
+ font-family: Georgia, "Times New Roman", serif;
+ font-weight: 700;
+}
+
+.timeline-list {
+ flex-direction: column;
+}
+
+.timeline-card {
+ display: grid;
+ grid-template-columns: 5rem 1fr;
+ gap: 1rem;
+}
+
+.timeline-card span {
+ color: var(--play-brass);
+ font-weight: 900;
+}
+
+.recipe-tool {
+ display: grid;
+ grid-template-columns: minmax(180px, 280px) minmax(0, 1fr);
+ gap: 1rem;
+ align-items: center;
+}
+
+.recipe-wheel {
+ aspect-ratio: 1;
+ width: 100%;
+ border-radius: 50% !important;
+ background:
+ conic-gradient(from 0deg, var(--play-red), var(--play-brass), var(--play-green), var(--play-blue), var(--play-red));
+ color: #fff8e8 !important;
+ font-weight: 900;
+ transition: transform 900ms cubic-bezier(.18, .88, .2, 1.15);
+}
+
+.recipe-result {
+ display: grid;
+ gap: 0.7rem;
+ padding: 0;
+ margin: 0;
+ list-style: none;
+}
+
+.recipe-result li {
+ padding: 0.85rem;
+ border: 1px solid var(--border);
+ border-radius: 7px;
+ background: var(--surface);
+}
+
+.terminal-tool {
+ background: #15120e;
+ color: #f2e3bd;
+}
+
+.terminal-log {
+ min-height: 310px;
+ max-height: 460px;
+ overflow: auto;
+ padding: 1rem;
+ border: 1px solid rgba(242, 227, 189, 0.2);
+ border-radius: 7px;
+ font-family: ui-monospace, SFMono-Regular, Consolas, monospace;
+ white-space: pre-wrap;
+}
+
+.terminal-form {
+ display: flex;
+ gap: 0.6rem;
+ margin-top: 0.8rem;
+}
+
+.terminal-form label {
+ display: flex;
+ flex: 1;
+ align-items: center;
+ gap: 0.5rem;
+}
+
+.terminal-form input,
+.sigil-form input {
+ width: 100%;
+ border: 1px solid var(--border);
+ border-radius: 6px;
+ padding: 0.65rem;
+ background: var(--surface);
+ color: var(--fg);
+ font: inherit;
+}
+
+.study-tool {
+ display: grid;
+ justify-items: center;
+ gap: 1rem;
+}
+
+.study-scene {
+ position: relative;
+ width: min(100%, 520px);
+ aspect-ratio: 1.6;
+ border: 1px solid var(--border);
+ border-radius: 8px;
+ background:
+ radial-gradient(circle at 50% 22%, rgba(244, 210, 113, var(--lamp, 0.18)), transparent 30%),
+ linear-gradient(#27211b 0 62%, #4b3425 62% 100%);
+ overflow: hidden;
+}
+
+.study-scene span {
+ position: absolute;
+ left: 42%;
+ bottom: 30%;
+ width: 16%;
+ height: 38%;
+ border-radius: 50% 50% 6px 6px;
+ background: #b88639;
+ box-shadow: 0 0 calc(20px + var(--glow, 0px)) rgba(244, 210, 113, 0.7);
+}
+
+.study-time {
+ font-size: clamp(2.4rem, 8vw, 5rem);
+ font-weight: 900;
+ font-variant-numeric: tabular-nums;
+}
+
+.sigil-tool {
+ display: grid;
+ grid-template-columns: minmax(240px, 320px) minmax(0, 520px);
+ gap: 1rem;
+ align-items: start;
+}
+
+.sigil-form {
+ display: grid;
+ gap: 0.85rem;
+}
+
+.sigil-form label {
+ display: grid;
+ gap: 0.35rem;
+ color: var(--muted);
+}
+
+.sigil-canvas {
+ background: var(--surface);
+}
+
+.rpg-shell {
+ display: grid;
+ grid-template-columns: minmax(0, 768px) minmax(260px, 1fr);
+ gap: 1rem;
+ align-items: start;
+}
+
+.rpg-stage {
+ display: grid;
+ gap: 0.75rem;
+}
+
+.rpg-canvas {
+ display: block;
+ width: 100%;
+ aspect-ratio: 1.5;
+ image-rendering: pixelated;
+ border: 2px solid #18130f;
+ border-radius: 8px;
+ background: #14110e;
+}
+
+.rpg-dialogue,
+.rpg-panel {
+ border: 1px solid var(--border);
+ border-radius: 8px;
+ background: color-mix(in oklab, var(--surface) 86%, #18130f 14%);
+}
+
+.rpg-dialogue {
+ min-height: 118px;
+ padding: 0.9rem;
+}
+
+.rpg-dialogue strong,
+.rpg-list h2 {
+ color: var(--heading);
+}
+
+.rpg-dialogue p,
+.rpg-status {
+ margin: 0.35rem 0 0;
+ color: var(--muted);
+}
+
+.rpg-panel {
+ display: grid;
+ gap: 1rem;
+ padding: 1rem;
+}
+
+.rpg-actions,
+.rpg-mobile-pad {
+ display: grid;
+ grid-template-columns: repeat(2, minmax(0, 1fr));
+ gap: 0.5rem;
+}
+
+.rpg-mobile-pad {
+ grid-template-columns: repeat(3, minmax(0, 1fr));
+}
+
+.rpg-mobile-pad [data-rpg-move="up"],
+.rpg-mobile-pad [data-rpg-move="down"] {
+ grid-column: 2;
+}
+
+.rpg-mobile-pad [data-rpg-move="left"] {
+ grid-column: 1;
+}
+
+.rpg-mobile-pad [data-rpg-act] {
+ grid-column: 2;
+}
+
+.rpg-mobile-pad [data-rpg-move="right"] {
+ grid-column: 3;
+}
+
+.rpg-stats {
+ display: grid;
+ gap: 0.55rem;
+ margin: 0;
+}
+
+.rpg-stats div {
+ display: flex;
+ justify-content: space-between;
+ gap: 1rem;
+ padding-bottom: 0.45rem;
+ border-bottom: 1px solid var(--border);
+}
+
+.rpg-stats dt,
+.rpg-list h2 {
+ margin: 0;
+ color: var(--muted);
+ font-size: 0.78rem;
+ font-weight: 800;
+ text-transform: uppercase;
+}
+
+.rpg-stats dd {
+ margin: 0;
+ font-weight: 800;
+}
+
+.rpg-list ul {
+ display: grid;
+ gap: 0.45rem;
+ margin: 0.45rem 0 0;
+ padding: 0;
+ list-style: none;
+}
+
+.rpg-list li {
+ padding: 0.55rem;
+ border: 1px solid var(--border);
+ border-radius: 6px;
+ background: var(--surface);
+}
+
+.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%);
+}
+
+@media (max-width: 760px) {
+ .play-hero,
+ .play-console,
+ .recipe-tool,
+ .sigil-tool,
+ .rpg-shell {
+ grid-template-columns: 1fr;
+ }
+
+ .play-orbit {
+ min-height: 260px;
+ }
+
+ .memory-board {
+ grid-template-columns: repeat(3, minmax(64px, 1fr));
+ }
+
+ .book-spine {
+ width: calc(50% - 0.4rem);
+ min-height: 160px;
+ }
+}
diff --git a/build-site.el b/build-site.el
index 368a529..c78ddcf 100755
--- a/build-site.el
+++ b/build-site.el
@@ -204,6 +204,7 @@ FMT / ARGS are passed to `format'."
"media.css"
"wird-tracker.css"
"zhd.css"
+ "play.css"
)
"\n")
"\n"
@@ -223,6 +224,7 @@ FMT / ARGS are passed to `format'."
"sitemap-interactive.js"
"wird-tracker.js"
"zhd.js"
+ "play.js"
)
"\n")
(format "
@@ -282,6 +284,7 @@ FMT / ARGS are passed to `format'."
Home
Blogs
Career
+ Play
Dashboard
diff --git a/index.org b/index.org
index c0544fe..c024a8b 100755
--- a/index.org
+++ b/index.org
@@ -67,6 +67,11 @@
Sitemap
map
+
+ ◇
+ Play
+ lab
+
◉
Status
diff --git a/play.org b/play.org
new file mode 100644
index 0000000..eb2b044
--- /dev/null
+++ b/play.org
@@ -0,0 +1,57 @@
+#+TITLE: Play
+#+OPTIONS: num:nil title:nil toc:nil
+#+NO_SIDENOTES: t
+#+DATE: <2026-05-09 Sat>
+
+#+BEGIN_EXPORT html
+
+
+
+
Interactive wing
+
Play
+
A small cabinet of browser toys, puzzles, sketch surfaces, and quiet experiments.
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
Game
+
Memory Cabinet
+
Turn over cards and match small fragments from the family archive.
+
Enter
+
+
+
+
+
+
+
+
+
+
+#+END_EXPORT
diff --git a/play/bookshelf.org b/play/bookshelf.org
new file mode 100644
index 0000000..3f34919
--- /dev/null
+++ b/play/bookshelf.org
@@ -0,0 +1,22 @@
+#+TITLE: Bookshelf Sort
+#+OPTIONS: num:nil title:nil toc:nil
+#+NO_SIDENOTES: t
+#+DATE: <2026-05-09 Sat>
+
+#+BEGIN_EXPORT html
+
+ Back to Play
+
+ 04 / Puzzle
+ Bookshelf Sort
+ Drag the books into alphabetical order by title.
+
+
+
+#+END_EXPORT
diff --git a/play/constellation.org b/play/constellation.org
new file mode 100644
index 0000000..0883e16
--- /dev/null
+++ b/play/constellation.org
@@ -0,0 +1,22 @@
+#+TITLE: Constellation Desk
+#+OPTIONS: num:nil title:nil toc:nil
+#+NO_SIDENOTES: t
+#+DATE: <2026-05-09 Sat>
+
+#+BEGIN_EXPORT html
+
+ Back to Play
+
+ 02 / Sketch
+ Constellation Desk
+ Click stars to connect them. Reset the plate when the sky becomes too busy.
+
+
+
+#+END_EXPORT
diff --git a/play/ink.org b/play/ink.org
new file mode 100644
index 0000000..dad155b
--- /dev/null
+++ b/play/ink.org
@@ -0,0 +1,18 @@
+#+TITLE: Ink Pond
+#+OPTIONS: num:nil title:nil toc:nil
+#+NO_SIDENOTES: t
+#+DATE: <2026-05-09 Sat>
+
+#+BEGIN_EXPORT html
+
+ Back to Play
+
+ 07 / Canvas
+ Ink Pond
+ Move across the page to stir the pond. Click to drop a darker blot.
+
+
+
+#+END_EXPORT
diff --git a/play/memory.org b/play/memory.org
new file mode 100644
index 0000000..6819bcb
--- /dev/null
+++ b/play/memory.org
@@ -0,0 +1,25 @@
+#+TITLE: Memory Cabinet
+#+OPTIONS: num:nil title:nil toc:nil
+#+NO_SIDENOTES: t
+#+DATE: <2026-05-09 Sat>
+
+#+BEGIN_EXPORT html
+
+ Back to Play
+
+ 01 / Game
+ Memory Cabinet
+ Match six pairs of archive cards. Fewer turns is better.
+
+
+
+#+END_EXPORT
diff --git a/play/poem.org b/play/poem.org
new file mode 100644
index 0000000..5514c12
--- /dev/null
+++ b/play/poem.org
@@ -0,0 +1,19 @@
+#+TITLE: Marginalia Machine
+#+OPTIONS: num:nil title:nil toc:nil
+#+NO_SIDENOTES: t
+#+DATE: <2026-05-09 Sat>
+
+#+BEGIN_EXPORT html
+
+ Back to Play
+
+ 03 / Generator
+ Marginalia Machine
+ Press the lever to assemble tiny notes from the edge of an imaginary notebook.
+
+
+
+#+END_EXPORT
diff --git a/play/recipe.org b/play/recipe.org
new file mode 100644
index 0000000..a384968
--- /dev/null
+++ b/play/recipe.org
@@ -0,0 +1,19 @@
+#+TITLE: Supper Wheel
+#+OPTIONS: num:nil title:nil toc:nil
+#+NO_SIDENOTES: t
+#+DATE: <2026-05-09 Sat>
+
+#+BEGIN_EXPORT html
+
+ Back to Play
+
+ 05 / Spinner
+ Supper Wheel
+ Spin for a cheerful menu: base, main, side, and table note.
+
+
+
+#+END_EXPORT
diff --git a/play/rpg.org b/play/rpg.org
new file mode 100644
index 0000000..d53feaa
--- /dev/null
+++ b/play/rpg.org
@@ -0,0 +1,60 @@
+#+TITLE: Hollow Archive
+#+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.
+
+
+
+
+#+END_EXPORT
diff --git a/play/sigil.org b/play/sigil.org
new file mode 100644
index 0000000..3823391
--- /dev/null
+++ b/play/sigil.org
@@ -0,0 +1,23 @@
+#+TITLE: Sigil Press
+#+OPTIONS: num:nil title:nil toc:nil
+#+NO_SIDENOTES: t
+#+DATE: <2026-05-09 Sat>
+
+#+BEGIN_EXPORT html
+
+ Back to Play
+
+ 10 / Maker
+ Sigil Press
+ Enter initials and a motto to stamp a little academic seal.
+
+
+
+#+END_EXPORT
diff --git a/play/study.org b/play/study.org
new file mode 100644
index 0000000..e6d9ab5
--- /dev/null
+++ b/play/study.org
@@ -0,0 +1,24 @@
+#+TITLE: Study Lamp
+#+OPTIONS: num:nil title:nil toc:nil
+#+NO_SIDENOTES: t
+#+DATE: <2026-05-09 Sat>
+
+#+BEGIN_EXPORT html
+
+ Back to Play
+
+ 09 / Timer
+ Study Lamp
+ Start a short focus session and let the desk scene warm up.
+
+
+
+#+END_EXPORT
diff --git a/play/terminal.org b/play/terminal.org
new file mode 100644
index 0000000..32ffd76
--- /dev/null
+++ b/play/terminal.org
@@ -0,0 +1,22 @@
+#+TITLE: Archive Terminal
+#+OPTIONS: num:nil title:nil toc:nil
+#+NO_SIDENOTES: t
+#+DATE: <2026-05-09 Sat>
+
+#+BEGIN_EXPORT html
+
+ Back to Play
+
+ 08 / Story
+ Archive Terminal
+ Try commands such as help, look, open drawer, read note, map, hum, save, and clear.
+
+
+
+#+END_EXPORT
diff --git a/play/timeline.org b/play/timeline.org
new file mode 100644
index 0000000..a3e81e7
--- /dev/null
+++ b/play/timeline.org
@@ -0,0 +1,22 @@
+#+TITLE: Timeline Tangle
+#+OPTIONS: num:nil title:nil toc:nil
+#+NO_SIDENOTES: t
+#+DATE: <2026-05-09 Sat>
+
+#+BEGIN_EXPORT html
+
+ Back to Play
+
+ 06 / Puzzle
+ Timeline Tangle
+ Drag the cards from earliest to latest.
+
+
+
+#+END_EXPORT
diff --git a/posts/posts-list.org b/posts/posts-list.org
index a1cadfb..b08b4cd 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 20:13@@
+- [[file:career/career-list.org][Career List]] @@html:
09-05-2026 22:35@@
- [[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 a5f2f7f..345ba03 100755
--- a/recently-updated.org
+++ b/recently-updated.org
@@ -10,21 +10,21 @@
- [[file:home/guide/setup.org][Setup]] @@html:
2026-05-09 20:13@@
- [[file:home/contact.org][Contact]] @@html:
2026-05-09 20:13@@
- [[file:blogs/2026/05-may/authoring-service-09-05-26.org][Authoring Service]] @@html:
2026-05-09 17:49@@
+- [[file:play.org][Play]] @@html:
2026-05-09 00:00@@
+- [[file:play/timeline.org][Timeline Tangle]] @@html:
2026-05-09 00:00@@
+- [[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/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@@
+- [[file:play/ink.org][Ink Pond]] @@html:
2026-05-09 00:00@@
+- [[file:play/constellation.org][Constellation Desk]] @@html:
2026-05-09 00:00@@
+- [[file:play/bookshelf.org][Bookshelf Sort]] @@html:
2026-05-09 00:00@@
- [[file:blogs/2026/05-may/ai-datacamp-08-05.org][AI Datacamp]] @@html:
2026-05-08 12:49@@
- [[file:blogs/2026/05-may/using-codex-07-05-26.org][Using Codex]] @@html:
2026-05-07 16:12@@
- [[file:blogs/2026/05-may/03-05-week-review.org][[03-05-2026] - Weekly Review]] @@html:
2026-05-03 12:00@@
- [[file:blogs/2026/05-may/sun-soak-01-05.org][Soaking in the sun]] @@html:
2026-05-01 12:27@@
- [[file:blogs/2026/04-april/wise-words-29-04.org][Wise Words I need to engrain]] @@html:
2026-04-29 11:31@@
- [[file:blogs/2026/04-april/april-almost-over-28-04.org][April is almost over...]] @@html:
2026-04-28 15:33@@
-- [[file:blogs/2026/04-april/26-04-week-review.org][[26-04-2026] - Weekly Review]] @@html:
2026-04-26 12:00@@
-- [[file:blogs/2026/04-april/adding-more-wird-22-04-26.org][Adding another Wird]] @@html:
2026-04-22 16:04@@
-- [[file:blogs/2026/04-april/sitting-outside-in-the-sun-21-04-26.org][Sitting in the sun]] @@html:
2026-04-21 13:01@@
-- [[file:blogs/2026/04-april/19-04-week-review.org][[19-04-2026] - Weekly Review]] @@html:
2026-04-19 12:00@@
-- [[file:blogs/2026/04-april/ending-the-week-17-04-26.org][End of week thoughts]] @@html:
2026-04-17 16:17@@
-- [[file:blogs/2026/04-april/16-04-26.org][Rambles]] @@html:
2026-04-16 16:02@@
-- [[file:blogs/2026/04-april/comparison-14-04.org][Comparison is the thief of joy]] @@html:
2026-04-14 09:55@@
-- [[file:blogs/2026/04-april/12-04-week-review.org][[12-04-2026] - Weekly Review]] @@html:
2026-04-12 12:00@@
-- [[file:blogs/2026/04-april/starting-new-rotation-08-04-26.org][Starting new rotation]] @@html:
2026-04-08 16:15@@
-- [[file:blogs/2026/04-april/action-plan-07-04-26.org][Action plan to change teams]] @@html:
2026-04-07 10:04@@
-- [[file:blogs/2026/04-april/weekly-target-06-04-26.org][[06-04-2026] - Weekly target]] @@html:
2026-04-06 22:53@@
-- [[file:blogs/2026/04-april/making-notes-through-org-noter-06-05.org][Making Notes through Org Noter]] @@html:
2026-04-06 14:30@@
diff --git a/sitemap.org b/sitemap.org
index 94407d0..f456950 100755
--- a/sitemap.org
+++ b/sitemap.org
@@ -1,6 +1,7 @@
#+TITLE: Sitemap
- [[file:index.org][Home]]
+- [[file:play.org][Play]]
- [[file:wip.org][Work in progress]]
- [[file:recently-updated.org][Recently Updated]]
- blogs
@@ -14,12 +15,12 @@
- home
- [[file:home/countdown.org][Countdown]]
- [[file:home/backlog.org][Backlog]]
- - [[file:home/categories.org][Categories]]
- [[file:home/contact.org][Contact]]
- [[file:home/notes.org][Notes]]
- [[file:home/services.org][Service]]
- [[file:home/status.org][Competency Status Board]]
- [[file:home/wird-tracker.org][Wird Tracker]]
+ - [[file:home/categories.org][Categories]]
- guide
- [[file:home/guide/setup.org][Setup]]
- [[file:home/guide/wird-tracker-guide.org][Wird Tracker — Technical Guide]]
@@ -49,17 +50,29 @@
- 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/introduction.org][Tag: introduction]]
- [[file:tags/learning.org][Tag: learning]]
+ - [[file:tags/introduction.org][Tag: introduction]]
- [[file:tags/notes.org][Tag: notes]]
- [[file:tags/website.org][Tag: website]]
- [[file:tags/review.org][Tag: review]]
- - [[file:tags/update.org][Tag: update]]
- [[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/reading.org][Tag: reading]]
- - [[file:tags/maths.org][Tag: maths]]
- [[file:tags/insights.org][Tag: insights]]
+ - [[file:tags/maths.org][Tag: maths]]
- lima
- - [[file:lima/lima-list.org][Lima]]
\ No newline at end of file
+ - [[file:lima/lima-list.org][Lima]]
+- play
+ - [[file:play/bookshelf.org][Bookshelf Sort]]
+ - [[file:play/sigil.org][Sigil Press]]
+ - [[file:play/memory.org][Memory Cabinet]]
+ - [[file:play/constellation.org][Constellation Desk]]
+ - [[file:play/study.org][Study Lamp]]
+ - [[file:play/poem.org][Marginalia Machine]]
+ - [[file:play/ink.org][Ink Pond]]
+ - [[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