summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--crypt/index.html1160
-rw-r--r--trick-or-treat-run/index.html966
2 files changed, 2126 insertions, 0 deletions
diff --git a/crypt/index.html b/crypt/index.html
new file mode 100644
index 0000000..f76f6c6
--- /dev/null
+++ b/crypt/index.html
@@ -0,0 +1,1160 @@
+<!doctype html>
+<html lang="en">
+<head>
+<meta charset="utf-8">
+<meta name="viewport" content="width=device-width, initial-scale=1">
+<title>crypt.bensanders.net</title>
+<meta name="description" content="A haunted server came back online on its own. Log in and find out why.">
+<style>
+ :root {
+ --bg: #070909;
+ --panel: #0d1110;
+ --fg: #b9f2b1;
+ --dim: #5d7a5a;
+ --accent: #ff8a1f;
+ --red: #ff4d4d;
+ --ghost: #a6d8ff;
+ --ok: #7dff9a;
+ }
+ * { box-sizing: border-box; }
+ html, body { margin: 0; height: 100%; background: var(--bg); color: var(--fg); }
+ body {
+ font-family: "IBM Plex Mono", ui-monospace, SFMono-Regular, Menlo, Consolas, "Liberation Mono", monospace;
+ font-size: 15px;
+ line-height: 1.45;
+ display: flex;
+ flex-direction: column;
+ overflow: hidden;
+ }
+ #bar {
+ display: flex;
+ justify-content: space-between;
+ align-items: center;
+ gap: 12px;
+ padding: 8px 16px;
+ background: var(--panel);
+ border-bottom: 1px solid #1c2421;
+ font-size: 12px;
+ color: var(--dim);
+ flex-wrap: wrap;
+ }
+ #bar .title { color: var(--accent); }
+ #meter { display: flex; align-items: center; gap: 8px; }
+ #meter .track { width: 120px; height: 8px; background: #1a201e; border: 1px solid #2a3330; }
+ #meter .fill { height: 100%; width: 0; background: var(--fg); transition: width .3s, background .3s; }
+ #term {
+ flex: 1;
+ overflow-y: auto;
+ padding: 16px;
+ position: relative;
+ cursor: text;
+ }
+ #term::after {
+ content: "";
+ position: fixed;
+ inset: 0;
+ pointer-events: none;
+ background:
+ repeating-linear-gradient(to bottom, rgba(0,0,0,0) 0, rgba(0,0,0,0) 2px, rgba(0,0,0,.18) 3px),
+ radial-gradient(ellipse at center, rgba(0,0,0,0) 55%, rgba(0,0,0,.55) 100%);
+ }
+ #out div { white-space: pre-wrap; overflow-wrap: anywhere; min-height: 1.45em; }
+ .cmd { color: var(--fg); }
+ .err { color: var(--red); }
+ .ok { color: var(--ok); }
+ .dim { color: var(--dim); }
+ .sys { color: var(--accent); }
+ .ls { color: var(--accent); }
+ .whisper { color: var(--ghost); font-style: italic; text-shadow: 0 0 6px rgba(166,216,255,.6); }
+ #inputline { display: flex; align-items: baseline; }
+ #prompt { white-space: pre; color: var(--ok); }
+ #cmd {
+ flex: 1;
+ min-width: 0;
+ background: transparent;
+ border: 0;
+ outline: none;
+ color: var(--fg);
+ font: inherit;
+ font-size: 16px;
+ caret-color: var(--fg);
+ padding: 0;
+ }
+ body[data-level="1"] #prompt { animation: flicker 4s infinite; }
+ body[data-level="2"] #prompt { color: var(--accent); animation: flicker 2.5s infinite; }
+ body[data-level="3"] #prompt { color: var(--red); animation: flicker 1.2s infinite; }
+ body[data-level="3"] #out { text-shadow: 1px 0 rgba(255,60,60,.35), -1px 0 rgba(80,160,255,.25); }
+ @keyframes flicker { 0%, 92%, 100% { opacity: 1; } 93% { opacity: .2; } 95% { opacity: .9; } 96% { opacity: .1; } }
+ body.glitch #term { animation: shake .35s linear; filter: hue-rotate(90deg) contrast(1.4); }
+ @keyframes shake {
+ 0% { transform: translate(0,0); } 20% { transform: translate(-3px,1px); } 40% { transform: translate(3px,-2px); }
+ 60% { transform: translate(-2px,2px); } 80% { transform: translate(2px,0); } 100% { transform: translate(0,0); }
+ }
+ #overlay {
+ position: fixed;
+ inset: 0;
+ display: none;
+ align-items: center;
+ justify-content: center;
+ background: rgba(3,4,4,.88);
+ padding: 16px;
+ z-index: 10;
+ }
+ #overlay.show { display: flex; }
+ #overlay .box {
+ max-width: 520px;
+ width: 100%;
+ border: 1px solid #2a3330;
+ background: var(--panel);
+ padding: 24px;
+ text-align: center;
+ }
+ #overlay h1 { margin: 0 0 8px; font-size: 32px; letter-spacing: 4px; }
+ #overlay.win h1 { color: var(--ok); }
+ #overlay.lose h1 { color: var(--red); }
+ #overlay p { margin: 8px 0; color: var(--fg); white-space: pre-wrap; }
+ #overlay .stats { color: var(--dim); font-size: 13px; margin-top: 16px; }
+ #overlay button {
+ margin-top: 20px;
+ background: transparent;
+ color: var(--accent);
+ border: 1px solid var(--accent);
+ font: inherit;
+ padding: 8px 16px;
+ cursor: pointer;
+ }
+ #overlay button:hover { background: var(--accent); color: var(--bg); }
+ @media (max-width: 520px) {
+ body { font-size: 13px; }
+ #meter .track { width: 80px; }
+ }
+</style>
+</head>
+<body data-level="0">
+ <div id="bar">
+ <span class="title">ssh you@crypt.bensanders.net</span>
+ <span id="meter">corruption <span class="track"><span class="fill"></span></span> <span id="pct">0%</span></span>
+ </div>
+ <div id="term">
+ <div id="out" aria-live="polite"></div>
+ <div id="inputline" style="display:none">
+ <span id="prompt"></span><input id="cmd" type="text" autocomplete="off" autocapitalize="off" autocorrect="off" spellcheck="false" aria-label="terminal input">
+ </div>
+ </div>
+ <div id="overlay">
+ <div class="box">
+ <h1 id="ov-title"></h1>
+ <p id="ov-text"></p>
+ <div class="stats" id="ov-stats"></div>
+ <button id="ov-btn" type="button">ssh back in</button>
+ </div>
+ </div>
+
+<script>
+(() => {
+ const $out = document.getElementById('out');
+ const $term = document.getElementById('term');
+ const $input = document.getElementById('cmd');
+ const $prompt = document.getElementById('prompt');
+ const $inputline = document.getElementById('inputline');
+ const $fill = document.querySelector('#meter .fill');
+ const $pct = document.getElementById('pct');
+ const $overlay = document.getElementById('overlay');
+
+ const TRUE_NAME = 'MORGRAVE';
+ const KEY = 'SAMHAIN';
+ const SECRET = btoa('THE TRUE NAME IS MORGRAVE. DO NOT SAY IT ALOUD.');
+ const HALT = Symbol('halt');
+ const sleep = ms => new Promise(r => setTimeout(r, ms));
+ const textOf = x => (typeof x === 'string' ? x : x[0]);
+
+ /* ---------- shared state (optional backend; game works offline without it) ---------- */
+ // Shared stats are off. Set this to '/crypt/api' to turn them back on if a backend ever exists.
+ const API = null;
+ const HANDLE_RE = /^[a-z0-9_]{3,20}$/;
+ const HANDLE_A = ['pale', 'hollow', 'grave', 'silent', 'cold', 'restless', 'lost', 'midnight', 'crooked', 'faded', 'sleepless', 'buried'];
+ const HANDLE_B = ['admin', 'root', 'daemon', 'ghoul', 'raven', 'moth', 'lantern', 'sysop', 'wisp', 'crow', 'tenant', 'janitor'];
+ const pick = a => a[Math.floor(Math.random() * a.length)];
+ const LAST_STATUS = {
+ banished: 'logged out. it let them go',
+ possessed: 'never logged out',
+ playing: 'still logged in',
+ disconnected: 'connection lost'
+ };
+ let net = { online: false };
+
+ function getHandle() {
+ const make = () => `${pick(HANDLE_A)}_${pick(HANDLE_B)}${Math.floor(Math.random() * 90 + 10)}`;
+ try {
+ let h = localStorage.getItem('crypt-handle');
+ if (!h || !HANDLE_RE.test(h)) { h = make(); localStorage.setItem('crypt-handle', h); }
+ return h;
+ } catch (e) { return make(); }
+ }
+ async function api(path, body) {
+ if (!API) return null;
+ const ctl = new AbortController();
+ const timer = setTimeout(() => ctl.abort(), 2500);
+ try {
+ const opts = body
+ ? { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body), signal: ctl.signal }
+ : { signal: ctl.signal };
+ const r = await fetch(API + path, opts);
+ return r.ok ? await r.json() : null;
+ } catch (e) { return null; } finally { clearTimeout(timer); }
+ }
+ function cleanRecent(list) {
+ return (Array.isArray(list) ? list : []).filter(r =>
+ r && typeof r.handle === 'string' && HANDLE_RE.test(r.handle) && typeof r.started === 'number' && r.status in LAST_STATUS);
+ }
+ const nowSec = () => Date.now() / 1000 + (net.skew || 0);
+ function ago(ts) {
+ const s = Math.max(0, Math.floor(nowSec() - ts));
+ if (s < 60) return 'just now';
+ if (s < 3600) return `${Math.floor(s / 60)} min ago`;
+ if (s < 86400) return `${Math.floor(s / 3600)} hr ago`;
+ return `${Math.floor(s / 86400)} days ago`;
+ }
+ function when(ts) {
+ const d = new Date(ts * 1000);
+ const M = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec'];
+ return `${M[d.getMonth()]} ${String(d.getDate()).padStart(2, ' ')} ${String(d.getHours()).padStart(2, '0')}:${String(d.getMinutes()).padStart(2, '0')}`;
+ }
+ function ordinal(n) {
+ const s = ['th', 'st', 'nd', 'rd'], v = n % 100;
+ return n + (s[(v - 20) % 10] || s[v] || s[0]);
+ }
+ const plural = (n, one, many) => `${n} ${n === 1 ? one : many}`;
+ const activeOthers = () => (net.recent || []).filter(r => r.status === 'playing' || r.status === 'possessed');
+
+ /* ---------- filesystem ---------- */
+ const F = (content, opts = {}) => Object.assign({ type: 'file', content, mode: 0o644, owner: 'mara' }, opts);
+ const D = (children, opts = {}) => Object.assign({ type: 'dir', children, mode: 0o755, owner: 'root' }, opts);
+
+ function buildFS() {
+ return D({
+ home: D({
+ you: D({
+ 'readme.txt': F(
+`TICKET #3133 PRIORITY: URGENT STATUS: reopened (by system)
+
+crypt.bensanders.net was decommissioned in November 2019.
+Nobody turned it back on. It turned itself back on anyway.
+
+The last admin, Mara Voss, stopped answering pages on Oct 31, 2019.
+Her home directory is still here: /home/mara
+
+Find out what happened. Then shut it down.
+
+Useful: ls, cd, cat, ps. Type 'help' for more.`, { id: 'readme', owner: 'you' })
+ }, { owner: 'you' }),
+ mara: D({
+ 'notes.txt': F(
+`2019-10-03
+Load average stuck at 6.66 on crypt. Probably a runaway cron job. Will check.
+
+2019-10-11
+There's a process called [wraith] that won't die. I kill it, it comes back
+with a new PID. Something keeps restarting it.
+
+2019-10-19
+Logins in auth.log at 03:33 every night. From 0.0.0.0. User 'it'.
+That user shouldn't exist. Check /etc/passwd. It does now.
+
+2019-10-24
+I think it reads everything I write on this box, so I'm hiding things.
+It has a true name. I found it and buried it in the syslog, encoded,
+so it can't read it back.
+
+2019-10-27
+The banishing key is split in three. One piece in the auth log. One in
+my shell history. One in the cron job it uses to summon itself, scrambled
+with rot13. It doesn't seem to understand rot13. Lucky.
+
+2019-10-30
+Wrote the rite down in seal.txt and locked it so nothing can read it.
+Not even me, until I chmod it back. If you're reading this, so can you.
+
+2019-10-31
+It's 03:32. Don't kill the wraith directly. It just comes back.
+Kill what it's holding on to.`, { id: 'notes' }),
+ 'todo.txt': F(
+`- renew ssl cert
+- figure out why load avg is 6.66
+- call mom
+- DO NOT reboot crypt on oct 31
+- `),
+ 'photo.jpg': F(
+`(binary data)
+You squint at the bytes anyway. Somehow you can see it.
+It's a photo of a desk.
+Your desk.`),
+ 'seal.txt': F(
+`THE RITE OF SEVERANCE
+
+1. Sever its anchor. The wraith cannot be banished while hollow holds it.
+2. Speak its true name and the key together, with the banish command:
+
+ banish <TRUE NAME> <KEY>
+
+3. Do not say the name any other way. It hears.
+
+I ran out of time. I hope you don't. - M`, { id: 'seal', mode: 0o000 }),
+ '.bash_history': F(
+`ps aux | grep wraith
+kill -9 666
+ps aux | grep wraith
+kill -9 667
+kill -9 668
+pstree
+cat /etc/cron.d/ritual
+grep 03:33 /var/log/auth.log
+echo "key 2/3: HA" > /dev/null
+chmod 000 ~/seal.txt
+history -c
+history -c
+history -c
+whoami
+whoami
+whoami`, { id: 'bashhist' })
+ }, { owner: 'mara' })
+ }),
+ etc: D({
+ motd: F(
+` crypt.bensanders.net
+ This system was decommissioned on 2019-11-01.
+ If you can read this, it is not decommissioned.`, { owner: 'root' }),
+ hostname: F('crypt', { owner: 'root' }),
+ passwd: F(
+`root:x:0:0:root:/root:/bin/bash
+mara:x:1000:1000:Mara Voss:/home/mara:/bin/bash
+you:x:1031:1031::/home/you:/bin/bash
+it:x:666:666:it:/dev/null:/usr/sbin/hollow`, { owner: 'root' }),
+ 'cron.d': D({
+ ritual: F(
+`# m h dom mon dow user command
+33 3 * * * it /usr/local/bin/summon.sh
+
+# VA <- 3/3, rot13. it can't read this. -m`, { id: 'ritual', owner: 'root' })
+ })
+ }),
+ var: D({
+ log: D({
+ 'auth.log': F(
+`Oct 28 08:02:11 crypt sshd[4120]: Accepted publickey for mara from 10.0.0.14 port 52211
+Oct 28 08:02:11 crypt sshd[4120]: pam_unix(sshd:session): session opened for user mara
+Oct 29 03:33:01 crypt sshd[4411]: Invalid user it from 0.0.0.0 port 0
+Oct 29 03:33:01 crypt sshd[4411]: Failed password for invalid user it from 0.0.0.0 port 0
+Oct 29 08:05:40 crypt sshd[4430]: Accepted publickey for mara from 10.0.0.14 port 52390
+Oct 30 03:33:01 crypt sshd[4502]: Invalid user it from 0.0.0.0 port 0
+Oct 30 03:33:02 crypt sshd[4502]: Accepted password for invalid user it from 0.0.0.0 port 0
+Oct 30 09:14:55 crypt sudo: mara : TTY=pts/0 ; PWD=/home/mara ; USER=root ; COMMAND=/usr/bin/logger key 1/3: SAM
+Oct 30 09:15:02 crypt sudo: pam_unix(sudo:session): session closed for user root
+Oct 31 03:33:00 crypt sshd[4600]: Accepted password for it from 0.0.0.0 port 0
+Oct 31 03:33:00 crypt sshd[4600]: pam_unix(sshd:session): session opened for user mara by it
+Oct 31 03:33:00 crypt sshd[4600]: session never closed`, { owner: 'root' }),
+ syslog: F(
+`Oct 24 22:09:58 crypt systemd[1]: Started Daily apt download activities.
+Oct 24 22:10:13 crypt mara: note-to-self ${SECRET}
+Oct 24 22:10:14 crypt kernel: [666.666666] hollow[313]: failed to read /var/log/syslog (encoding not understood)
+Oct 25 03:33:00 crypt CRON[5120]: (it) CMD (/usr/local/bin/summon.sh)
+Oct 26 03:33:00 crypt CRON[5233]: (it) CMD (/usr/local/bin/summon.sh)
+Oct 27 03:33:00 crypt kernel: [3333.333333] thermal: rack 4 temperature below 0C
+Oct 28 03:33:00 crypt CRON[5402]: (it) CMD (/usr/local/bin/summon.sh)
+Oct 29 03:33:00 crypt kernel: [6666.666666] [wraith]: respawned by parent 313
+Oct 31 03:33:00 crypt systemd[1]: Reached target It.`, { owner: 'root' })
+ })
+ }),
+ usr: D({
+ local: D({
+ bin: D({
+ 'summon.sh': F(
+`#!/bin/sh
+# do not edit
+while true; do
+ /usr/sbin/hollow --hold "[wraith]" &
+ sleep 333
+done
+# it is cold in here
+# let me out
+# let me in`, { owner: 'it', mode: 0o755 })
+ })
+ }),
+ sbin: D({
+ hollow: F(
+`ELF (binary)
+You try to read it anyway. Past the header, it's just one word, repeated:
+HOLD HOLD HOLD HOLD HOLD HOLD HOLD HOLD HOLD HOLD HOLD HOLD HOLD`, { owner: 'it', mode: 0o755 })
+ })
+ }),
+ proc: D({
+ '1': D({ status: F('Name: init\nState: S (sleeping)\nPPid: 0', { owner: 'root' }) }),
+ '313': D({ status: F('Name: hollow\nState: S (holding)\nPPid: 1\nHolds: [wraith]\nNote: as long as I run, it returns.', { owner: 'it' }) }),
+ '666': D({ status: F('Name: [wraith]\nState: R (restless)\nPPid: 313\nUid: 666 (it)', { owner: 'it' }) })
+ }),
+ tmp: D({
+ '.it': F('i was here before you.\ni will be here after.', { owner: 'it' })
+ }, { mode: 0o777 }),
+ root: D({}, { mode: 0o700, locked: true })
+ });
+ }
+
+ function resolve(p) {
+ let parts;
+ if (p === undefined || p === '') return [...state.cwd];
+ if (p === '~' || p.startsWith('~/')) { parts = ['home', 'you']; p = p.slice(1); }
+ else if (p.startsWith('/')) parts = [];
+ else parts = [...state.cwd];
+ for (const seg of p.split('/')) {
+ if (!seg || seg === '.') continue;
+ if (seg === '..') parts.pop(); else parts.push(seg);
+ }
+ return parts;
+ }
+ function getNode(parts) {
+ let n = state.fs;
+ for (const s of parts) {
+ if (n.type !== 'dir' || !Object.prototype.hasOwnProperty.call(n.children, s)) return null;
+ n = n.children[s];
+ }
+ return n;
+ }
+ function pathStr(parts) { return '/' + parts.join('/'); }
+ function readFile(p) {
+ const n = getNode(resolve(p));
+ if (!n) return { err: `${p}: No such file or directory` };
+ if (n.type === 'dir') return { err: `${p}: Is a directory` };
+ if (!(n.mode & 0o400)) return { err: `${p}: Permission denied` };
+ if (n.id) state.flags[n.id] = true;
+ return { text: n.content };
+ }
+ function modeStr(n) {
+ let s = n.type === 'dir' ? 'd' : '-';
+ for (let sh = 6; sh >= 0; sh -= 3) {
+ const b = (n.mode >> sh) & 7;
+ s += (b & 4 ? 'r' : '-') + (b & 2 ? 'w' : '-') + (b & 1 ? 'x' : '-');
+ }
+ return s;
+ }
+ function lsLong(name, n) {
+ const size = n.type === 'dir' ? 4096 : n.content.length;
+ const o = (n.owner || 'root').padEnd(5);
+ return `${modeStr(n)} 1 ${o} ${o} ${String(size).padStart(5)} Oct 31 03:33 ${name}${n.type === 'dir' ? '/' : ''}`;
+ }
+ function parseMode(m, cur) {
+ if (/^[0-7]{3,4}$/.test(m)) return parseInt(m.slice(-3), 8);
+ let mode = cur;
+ for (const p of m.split(',')) {
+ const mm = p.match(/^([ugoa]*)([+\-=])([rwx]*)$/);
+ if (!mm) return null;
+ let who = mm[1] || 'a';
+ if (who.includes('a')) who = 'ugo';
+ let bits = 0, mask = 0;
+ for (const w of who) {
+ const sh = w === 'u' ? 6 : w === 'g' ? 3 : 0;
+ mask |= 7 << sh;
+ for (const c of mm[3]) bits |= ({ r: 4, w: 2, x: 1 }[c]) << sh;
+ }
+ if (mm[2] === '+') mode |= bits;
+ else if (mm[2] === '-') mode &= ~bits;
+ else mode = (mode & ~mask) | bits;
+ }
+ return mode;
+ }
+
+ /* ---------- decoding helpers ---------- */
+ function checkF3(inp, out) {
+ if (/\bVA\b/.test(inp) && /\bIN\b/.test(out)) state.flags.f3 = true;
+ }
+ function rot13(s) {
+ const r = s.replace(/[a-z]/gi, c => {
+ const base = c <= 'Z' ? 65 : 97;
+ return String.fromCharCode((c.charCodeAt(0) - base + 13) % 26 + base);
+ });
+ checkF3(s, r);
+ return r;
+ }
+ function expand(set) {
+ let r = '';
+ for (let i = 0; i < set.length; i++) {
+ if (set[i + 1] === '-' && i + 2 < set.length) {
+ for (let c = set.charCodeAt(i); c <= set.charCodeAt(i + 2); c++) r += String.fromCharCode(c);
+ i += 2;
+ } else r += set[i];
+ }
+ return r;
+ }
+
+ /* ---------- text content ---------- */
+ const WHISPERS = [
+ 'it is cold in here', 'you type like her', '03:33', 'she is still logged in',
+ 'let me out', 'let me in', 'i can read your history', 'why did you come back',
+ 'we were never decommissioned', 'do you hear the fans', 'stay a while', 'behind you'
+ ];
+ const FORTUNES = [
+ 'You will meet a tall, dark process. Do not kill it directly.',
+ 'The load average counts what is waiting. Not all of it is software.',
+ 'Every server is haunted by somebody\'s old cron job.',
+ 'Today is a good day to read the logs. Tonight is not.',
+ 'Uptime is just how long it has been awake.',
+ 'If a process dies and respawns, check who its parent is.',
+ 'Nothing is ever really deleted. Ask /tmp.'
+ ];
+
+ /* ---------- commands ---------- */
+ function killPid(pid) {
+ if (pid === state.wraithPid) {
+ if (state.anchorKilled) return ['[wraith] is already <defunct>. It isn\'t running anymore. It\'s waiting. Finish the rite.'];
+ state.wraithKills++;
+ const old = pid;
+ state.wraithPid = pid + 1 + Math.floor(Math.random() * 3);
+ state.pending += 5;
+ const o = [`[${old}] Killed [wraith]`, ['...', 'dim'], [`[wraith] respawned as PID ${state.wraithPid}.`, 'err']];
+ if (state.wraithKills >= 2) o.push(['kill me all you like. hollow just brings me back.', 'whisper']);
+ return o;
+ }
+ switch (pid) {
+ case 313:
+ if (state.anchorKilled) return [['kill: (313) - No such process', 'err']];
+ state.anchorKilled = true;
+ delete state.fs.children.proc.children['313'];
+ state.corruption = Math.max(0, state.corruption - 15);
+ return [
+ ['[313] Terminated hollow', 'ok'],
+ 'The fans spin down for a moment.',
+ ['[wraith] has lost its anchor. It is <defunct> now. Weaker. Waiting.', 'ok'],
+ ['what did you do', 'whisper']
+ ];
+ case 1:
+ state.pending += 3;
+ return [['kill: (1) - you could bring everything down. it would like that.', 'err']];
+ case 212: return [['kill: (212) - then how would anyone get in? or out?', 'err']];
+ case 987: return ['You end Mara\'s session. Nothing happens. She hasn\'t been in there for a long time.'];
+ case 1031: return [['kill: (1031) - you can\'t kill yourself here. not yet.', 'err']];
+ default: return [[`kill: (${pid}) - No such process`, 'err']];
+ }
+ }
+
+ const COMMANDS = {
+ help: () => [
+ 'Available commands:',
+ ' ls [-la] [path] list files',
+ ' cd <path> change directory',
+ ' cat <file> print a file',
+ ' pwd where am I',
+ ' grep [-iv] <pat> [file] search text',
+ ' ps [aux], pstree list processes',
+ ' kill <pid>, killall end a process',
+ ' chmod <mode> <file> change permissions',
+ ' base64 -d, tr decode things (pipes work: echo X | base64 -d)',
+ ' decode <base64|rot13> <text> shortcut decoder',
+ ' head, tail, wc, echo, history, clear, whoami, date, uptime',
+ ' hint ask for help (it will cost you)',
+ ' banish ... you\'ll know when',
+ ['Tab completes, arrow keys browse history. Other commands might work too.', 'dim']
+ ],
+ ls(args) {
+ let all = false, long = false;
+ const paths = [];
+ args.forEach(a => { if (a.startsWith('-')) { if (a.includes('a')) all = true; if (a.includes('l')) long = true; } else paths.push(a); });
+ if (!paths.length) paths.push('.');
+ const out = [];
+ paths.forEach(p => {
+ const n = getNode(resolve(p));
+ if (!n) { out.push([`ls: cannot access '${p}': No such file or directory`, 'err']); return; }
+ if (n.type === 'file') { out.push(long ? lsLong(p.split('/').pop(), n) : p); return; }
+ if (n.locked) { out.push([`ls: cannot open directory '${p}': Permission denied`, 'err']); return; }
+ if (paths.length > 1) out.push(p + ':');
+ let names = Object.keys(n.children).sort();
+ if (!all) names = names.filter(x => !x.startsWith('.'));
+ if (long) {
+ if (all) { out.push(lsLong('.', n)); out.push(lsLong('..', { type: 'dir', mode: 0o755, owner: 'root' })); }
+ names.forEach(x => out.push(lsLong(x, n.children[x])));
+ } else {
+ const shown = (all ? ['.', '..'] : []).concat(names.map(x => n.children[x].type === 'dir' ? x + '/' : x));
+ if (shown.length) out.push([shown.join(' '), 'ls']);
+ }
+ });
+ return out;
+ },
+ cd(args) {
+ const p = args[0] || '~';
+ const parts = resolve(p);
+ const n = getNode(parts);
+ if (!n) return [[`cd: no such file or directory: ${p}`, 'err']];
+ if (n.type !== 'dir') return [[`cd: not a directory: ${p}`, 'err']];
+ if (n.locked) return [[`cd: permission denied: ${p}`, 'err'], ['something in there is listening', 'whisper']];
+ state.cwd = parts;
+ return [];
+ },
+ pwd: () => [pathStr(state.cwd)],
+ cat(args, stdin) {
+ if (!args.length) return stdin || [];
+ const out = [];
+ args.forEach(a => {
+ const r = readFile(a);
+ if (r.err) out.push([`cat: ${r.err}`, 'err']);
+ else if (r.text) out.push(...r.text.split('\n'));
+ });
+ return out;
+ },
+ grep(args, stdin) {
+ let ci = false, inv = false;
+ const rest = [];
+ args.forEach(a => {
+ if (/^-[iv]+$/.test(a)) { if (a.includes('i')) ci = true; if (a.includes('v')) inv = true; }
+ else rest.push(a);
+ });
+ if (!rest.length) return ['usage: grep [-iv] PATTERN [FILE...]'];
+ const pat = rest[0];
+ let re = null;
+ try { re = new RegExp(pat, ci ? 'i' : ''); } catch (e) { re = null; }
+ const test = l => (re ? re.test(l) : (ci ? l.toLowerCase().includes(pat.toLowerCase()) : l.includes(pat))) !== inv;
+ const files = rest.slice(1);
+ const out = [];
+ if (!files.length) {
+ (stdin || []).map(textOf).forEach(l => { if (test(l)) out.push(l); });
+ return out;
+ }
+ files.forEach(f => {
+ const r = readFile(f);
+ if (r.err) { out.push([`grep: ${r.err}`, 'err']); return; }
+ r.text.split('\n').forEach(l => { if (test(l)) out.push(files.length > 1 ? `${f}:${l}` : l); });
+ });
+ return out;
+ },
+ head(args, stdin) { return headTail(args, stdin, true); },
+ tail(args, stdin) { return headTail(args, stdin, false); },
+ wc(args, stdin) {
+ const files = args.filter(a => !a.startsWith('-'));
+ let text;
+ if (files.length) { const r = readFile(files[0]); if (r.err) return [[`wc: ${r.err}`, 'err']]; text = r.text; }
+ else text = (stdin || []).map(textOf).join('\n');
+ const lines = text ? text.split('\n').length : 0;
+ if (args.includes('-l')) return [String(lines)];
+ return [`${lines} ${text.split(/\s+/).filter(Boolean).length} ${text.length}${files[0] ? ' ' + files[0] : ''}`];
+ },
+ echo(args) {
+ const s = args.join(' ');
+ if (s.toUpperCase().includes(TRUE_NAME)) {
+ state.pending += 15;
+ return [s, ['you said my name', 'whisper'], ['The lights in the rack flicker. Something got closer.', 'err']];
+ }
+ return [s];
+ },
+ ps(args) {
+ const rows = [
+ ['USER', 'PID', 'PPID', 'STAT', 'COMMAND'],
+ ['root', '1', '0', 'Ss', '/sbin/init'],
+ ['root', '212', '1', 'Ss', '/usr/sbin/sshd'],
+ ['mara', '987', '212', 'S', '-bash (idle since 2019-10-31 03:33)'],
+ ['you', '1031', '212', 'Ss', '-bash']
+ ];
+ if (!state.anchorKilled) rows.push(['it', '313', '1', 'S', '/usr/sbin/hollow --hold [wraith]']);
+ rows.push(state.anchorKilled
+ ? ['it', String(state.wraithPid), '1', 'Z', '[wraith] <defunct>']
+ : ['it', String(state.wraithPid), '313', 'R', '[wraith]']);
+ rows.push(['you', String(1100 + state.commands), '1031', 'R+', ('ps ' + args.join(' ')).trim()]);
+ return rows.map(r => r[0].padEnd(6) + r[1].padStart(6) + r[2].padStart(6) + ' ' + r[3].padEnd(5) + r[4]);
+ },
+ pstree() {
+ if (state.anchorKilled) return ['init─┬─sshd─┬─bash', ' │ └─bash───pstree', ' └─[wraith] <defunct>'];
+ return ['init─┬─sshd─┬─bash', ' │ └─bash───pstree', ' └─hollow───[wraith]'];
+ },
+ kill(args) {
+ const pids = args.filter(a => /^\d+$/.test(a));
+ if (!pids.length) return ['usage: kill [-9] <pid>'];
+ return pids.flatMap(p => killPid(parseInt(p, 10)));
+ },
+ killall(args) {
+ const name = (args.filter(a => !a.startsWith('-'))[0] || '').replace(/[\[\]]/g, '');
+ if (name === 'wraith') return killPid(state.wraithPid);
+ if (name === 'hollow') return killPid(313);
+ if (!name) return ['usage: killall <name>'];
+ return [[`${name}: no process found`, 'err']];
+ },
+ chmod(args) {
+ if (args.length < 2) return ['usage: chmod MODE FILE'];
+ const [m, ...files] = args;
+ const out = [];
+ files.forEach(f => {
+ const n = getNode(resolve(f));
+ if (!n) { out.push([`chmod: cannot access '${f}': No such file or directory`, 'err']); return; }
+ if (n.locked || n.owner === 'root' || n.owner === 'it') { out.push([`chmod: changing permissions of '${f}': Operation not permitted`, 'err']); return; }
+ const nm = parseMode(m, n.mode);
+ if (nm === null) { out.push([`chmod: invalid mode: '${m}'`, 'err']); return; }
+ n.mode = nm;
+ if (n.id === 'seal' && (nm & 0o400)) out.push(['she locked that for a reason', 'whisper']);
+ });
+ return out;
+ },
+ base64(args, stdin) {
+ const dec = args.some(a => a === '-d' || a === '--decode' || a === '-D');
+ const files = args.filter(a => !a.startsWith('-'));
+ let inp;
+ if (files.length) { const r = readFile(files[0]); if (r.err) return [[`base64: ${r.err}`, 'err']]; inp = r.text; }
+ else if (stdin) inp = stdin.map(textOf).join('\n');
+ else return ['base64: nothing to read. Try: echo <text> | base64 -d'];
+ if (dec) {
+ try {
+ const res = atob(inp.replace(/\s+/g, ''));
+ if (res.includes(TRUE_NAME)) state.flags.name = true;
+ return res.split('\n');
+ } catch (e) { return [['base64: invalid input', 'err']]; }
+ }
+ try { return [btoa(inp)]; } catch (e) { return [['base64: invalid input', 'err']]; }
+ },
+ tr(args, stdin) {
+ if (args.length < 2) return ['usage: tr SET1 SET2 (reads from a pipe)'];
+ const a = expand(args[0]), b = expand(args[1]);
+ const map = {};
+ for (let i = 0; i < a.length; i++) map[a[i]] = b[Math.min(i, b.length - 1)];
+ const inp = (stdin || []).map(textOf);
+ const out = inp.map(l => [...l].map(c => (c in map ? map[c] : c)).join(''));
+ checkF3(inp.join('\n'), out.join('\n'));
+ return out;
+ },
+ rot13(args, stdin) {
+ const t = args.length ? args.join(' ') : (stdin || []).map(textOf).join('\n');
+ return rot13(t).split('\n');
+ },
+ decode(args, stdin) {
+ const mode = (args[0] || '').toLowerCase();
+ const text = args.slice(1).join(' ') || (stdin || []).map(textOf).join('\n');
+ if (!text || !['base64', 'b64', 'rot13'].includes(mode)) return ['usage: decode <base64|rot13> <text> (or pipe text in)'];
+ if (mode === 'rot13') return rot13(text).split('\n');
+ return COMMANDS.base64(['-d'], [text]);
+ },
+ history() {
+ const out = state.hist.map((h, i) => `${String(i + 1).padStart(5)} ${h}`);
+ if (state.corruption >= 40) {
+ const n = 1 + Math.floor(state.corruption / 30);
+ for (let i = 0; i < n; i++) {
+ const pos = Math.floor(Math.random() * (out.length + 1));
+ out.splice(pos, 0, [' ? ' + WHISPERS[Math.floor(Math.random() * WHISPERS.length)], 'whisper']);
+ }
+ }
+ return out;
+ },
+ whoami: () => [(state.corruption >= 60 && Math.random() < 0.5) ? 'it' : 'you'],
+ id: () => ['uid=1031(you) gid=1031(you) groups=1031(you),666(it)'],
+ who() {
+ const row = (u, tty, rest) => u.padEnd(20) + tty.padEnd(8) + rest;
+ const o = [row('you', 'pts/0', 'now'), row('mara', 'pts/1', '2019-10-31 03:33 (0.0.0.0)')];
+ activeOthers().slice(0, 5).forEach((r, i) =>
+ o.push(row(r.handle, 'pts/' + (i + 2), ago(r.started) + (r.status === 'possessed' ? ' (not responding)' : ''))));
+ return o;
+ },
+ w: () => COMMANDS.who(),
+ last() {
+ const row = (u, tty, t, s) => u.padEnd(20) + tty.padEnd(8) + t.padEnd(19) + s;
+ const o = [row('you', 'pts/0', 'now', 'still logged in')];
+ (net.recent || []).forEach((r, i) => o.push(row(r.handle, 'pts/' + (i + 2), when(r.started), LAST_STATUS[r.status])));
+ o.push(row('mara', 'pts/1', 'Oct 31 2019 03:33', 'still logged in'));
+ o.push(row('it', '?', 'Oct 31 2019 03:33', 'still logged in'));
+ if (net.online) o.push('', [`wtmp begins Thu Oct 31 03:33:00 2019. ${plural(net.stats.logins, 'login', 'logins')} since.`, 'dim']);
+ return o;
+ },
+ date: () => [`Thu Oct 31 03:33:${String(state.commands % 60).padStart(2, '0')} EDT 2019`],
+ uptime: () => [` 03:33:00 up 2557 days, 3:33, ${3 + activeOthers().length} users, load average: 6.66, 6.66, 6.66`],
+ uname: args => [args.includes('-a') ? 'Linux crypt 6.6.6-hollow #1 SMP PREEMPT Thu Oct 31 03:33:00 2019 x86_64 GNU/Linux' : 'Linux'],
+ hostname: () => ['crypt'],
+ man(args) {
+ const page = args[0];
+ if (!page) return ['What manual page do you want? It wants yours.'];
+ if (page === 'ghost') return [
+ 'GHOST(6) Haunted Programmer\'s Manual GHOST(6)', '',
+ 'NAME', ' ghost - a process that outlived its owner\'s intentions', '',
+ 'SYNOPSIS', ' ghost [--hold anchor] [--whisper] [--never-exit]', '',
+ 'DESCRIPTION',
+ ' A ghost is not killed, it is released. Signals sent to a ghost are',
+ ' received, logged, and ignored. A ghost held by an anchor will',
+ ' respawn forever.', '',
+ 'BUGS', ' It reads your notes.', '',
+ 'SEE ALSO', ' banish(8), hollow(8), kill(1) (mostly useless)'
+ ];
+ if (page === 'banish') return ['BANISH(8)', '', 'NAME', ' banish - release a bound process', '', ['The rest of this page has been eaten.', 'dim']];
+ if (page === 'hollow') return ['HOLLOW(8)', '', 'NAME', ' hollow - keeps something from leaving', '', 'DESCRIPTION', ' While hollow runs, its child cannot be released.'];
+ return [`No manual entry for ${page}. Mara took them with her.`];
+ },
+ fortune: () => [FORTUNES[Math.floor(Math.random() * FORTUNES.length)]],
+ cowsay(args) {
+ const msg = args.join(' ') || 'boo';
+ return [
+ ' ' + '_'.repeat(msg.length + 2), `< ${msg} >`, ' ' + '-'.repeat(msg.length + 2),
+ ' \\ .-"""-.', ' \\ / \\', ' | () () |', ' \\ ^ /', ' |||||', ' |||||'
+ ];
+ },
+ sudo: () => [['you is not in the sudoers file. This incident will be reported.', 'err'], ['to me', 'whisper']],
+ su: () => [['su: Authentication failure. You have no power here.', 'err']],
+ exit: () => { state.pending += 2; return [['There is no exit.', 'err']]; },
+ logout: () => COMMANDS.exit(),
+ quit: () => COMMANDS.exit(),
+ rm(args) {
+ if (args.some(a => a.startsWith('-') && a.includes('r')) && args.includes('/')) {
+ state.pending += 10;
+ return [['rm: it would like that very much.', 'err'], ['yes. erase everything. then there is only me.', 'whisper']];
+ }
+ return [['rm: read-only file system. it wants to keep everything.', 'err']];
+ },
+ touch: () => [['read-only file system', 'err']],
+ mkdir: () => [['read-only file system', 'err']],
+ mv: () => [['read-only file system', 'err']],
+ cp: () => [['read-only file system', 'err']],
+ vim: () => [['You open vim. Then you remember Mara never got out either.', 'err']],
+ vi: () => COMMANDS.vim(),
+ nano: () => [['nano: the file is open in another session. user: it', 'err']],
+ emacs: () => [['emacs: not enough memory. it is using all of it.', 'err']],
+ ssh: () => ['ssh: where would you go?'],
+ ping: () => ['PING 0.0.0.0: 64 bytes from 0.0.0.0: icmp_seq=1 ttl=666 time=0.000 ms', ['it is very close', 'whisper']],
+ reboot: () => [['reboot: it won\'t let you. Not until it\'s banished.', 'err']],
+ shutdown: () => COMMANDS.reboot(),
+ poweroff: () => COMMANDS.reboot(),
+ halt: () => COMMANDS.reboot(),
+ top: args => COMMANDS.ps(args),
+ clear: () => { $out.innerHTML = ''; return []; },
+ hint() {
+ state.pending += 4;
+ const f = state.flags;
+ let h;
+ if (!f.readme) h = 'Start with the ticket: cat readme.txt';
+ else if (!f.notes) h = 'Mara\'s home directory is /home/mara. Read her notes.';
+ else if (!f.name) h = 'The true name is in /var/log/syslog. Find the line that looks like base64, then: echo <text> | base64 -d';
+ else if (!f.f1) h = 'Key piece 1 is in /var/log/auth.log. grep for "key".';
+ else if (!f.f2) h = 'Key piece 2 is in Mara\'s shell history. Hidden files show up with ls -a.';
+ else if (!f.f3) h = 'Key piece 3 is in /etc/cron.d/ritual, rot13 encoded. Try: decode rot13 <text>';
+ else if (!f.seal) h = 'seal.txt is locked. Check it with ls -l, then chmod it so you can read it.';
+ else if (!state.anchorKilled) h = 'The wraith has a parent process. Find it with ps aux or pstree, and kill that first.';
+ else h = 'You have everything you need. banish <NAME> <KEY>';
+ return [['hint: ' + h, 'sys'], ['asking for help makes you weaker', 'whisper']];
+ },
+ banish(args) {
+ const vals = args.filter(a => !a.startsWith('-')).map(a => a.toUpperCase());
+ if (vals.length < 2) return ['usage: banish <TRUE NAME> <KEY>'];
+ const [name, key] = vals;
+ if (name !== TRUE_NAME) { state.pending += 10; return [['banish: that is not its name. It laughs anyway.', 'err']]; }
+ if (key !== KEY) { state.pending += 10; return [['banish: the name is right, but the key does not fit.', 'err']]; }
+ if (!state.anchorKilled) { state.pending += 10; return [['It hears its name and holds on tighter. Its anchor is still running.', 'err'], ['hollow holds me', 'whisper']]; }
+ win();
+ return HALT;
+ }
+ };
+
+ function headTail(args, stdin, head) {
+ let n = 10;
+ const files = [];
+ for (let i = 0; i < args.length; i++) {
+ if (args[i] === '-n' && args[i + 1]) { n = parseInt(args[++i], 10) || 10; }
+ else if (/^-\d+$/.test(args[i])) n = parseInt(args[i].slice(1), 10);
+ else files.push(args[i]);
+ }
+ let lines;
+ if (files.length) { const r = readFile(files[0]); if (r.err) return [[`${head ? 'head' : 'tail'}: ${r.err}`, 'err']]; lines = r.text.split('\n'); }
+ else lines = stdin || [];
+ return head ? lines.slice(0, n) : lines.slice(-n);
+ }
+
+ /* ---------- engine ---------- */
+ let state;
+ function newState() {
+ return {
+ fs: buildFS(), cwd: ['home', 'you'], corruption: 0, pending: 0, commands: 0,
+ anchorKilled: false, wraithPid: 666, wraithKills: 0, flags: {},
+ hist: [], histIdx: 0, over: false, started: Date.now()
+ };
+ }
+
+ function print(items, cls) {
+ for (const it of items) {
+ const [t, c] = typeof it === 'string' ? [it, cls || ''] : it;
+ for (const piece of String(t).split('\n')) {
+ const d = document.createElement('div');
+ if (c) d.className = c;
+ d.textContent = piece;
+ $out.appendChild(d);
+ }
+ }
+ $term.scrollTop = $term.scrollHeight;
+ }
+
+ function promptText() {
+ const c = state.corruption;
+ const user = c >= 80 ? 'it' : c >= 50 ? 'y\u0338o\u0336u' : 'you';
+ let p = pathStr(state.cwd);
+ if (p === '/home/you') p = '~';
+ else if (p.startsWith('/home/you/')) p = '~' + p.slice(9);
+ return `${user}@crypt:${p}$ `;
+ }
+
+ function updateMeter() {
+ const c = Math.max(0, Math.min(100, state.corruption));
+ $fill.style.width = c + '%';
+ $fill.style.background = c >= 75 ? 'var(--red)' : c >= 50 ? 'var(--accent)' : 'var(--fg)';
+ $pct.textContent = c + '%';
+ document.body.dataset.level = c >= 75 ? '3' : c >= 50 ? '2' : c >= 25 ? '1' : '0';
+ $prompt.textContent = promptText();
+ }
+
+ function tokenize(s) {
+ const t = [];
+ let cur = '', q = null, has = false;
+ const push = () => { if (cur || has) { t.push(cur); cur = ''; has = false; } };
+ for (const ch of s) {
+ if (q) { if (ch === q) q = null; else cur += ch; continue; }
+ if (ch === '"' || ch === "'") { q = ch; has = true; continue; }
+ if (/\s/.test(ch)) { push(); continue; }
+ if (ch === '|' || ch === '>') { push(); t.push({ op: ch }); continue; }
+ cur += ch;
+ }
+ push();
+ return t;
+ }
+
+ function scan(out) {
+ const text = out.map(textOf).join('\n');
+ if (text.includes('key 1/3: SAM')) state.flags.f1 = true;
+ if (text.includes('key 2/3: HA')) state.flags.f2 = true;
+ if (text.includes('THE RITE OF SEVERANCE')) state.flags.seal = true;
+ if (text.includes('THE TRUE NAME IS ' + TRUE_NAME)) state.flags.name = true;
+ }
+
+ function run(raw) {
+ print([[promptText() + raw, 'cmd']]);
+ const line = raw.trim();
+ if (!line) return;
+ state.hist.push(line);
+ state.histIdx = state.hist.length;
+ state.commands++;
+
+ const toks = tokenize(line);
+ let out;
+ if (toks.some(t => t.op === '>')) {
+ out = [['bash: read-only file system. it likes things the way they are.', 'err']];
+ } else {
+ const segs = [[]];
+ toks.forEach(t => { if (t.op === '|') segs.push([]); else segs[segs.length - 1].push(t); });
+ out = null;
+ for (const seg of segs) {
+ if (!seg.length) { out = [['bash: syntax error near unexpected token `|\'', 'err']]; break; }
+ const [cmd, ...args] = seg;
+ if (cmd.toUpperCase() === TRUE_NAME) {
+ state.pending += 15;
+ out = [['It heard you.', 'err'], ['again. say it again.', 'whisper']];
+ break;
+ }
+ const fn = COMMANDS[cmd] || COMMANDS[cmd.toLowerCase()];
+ if (!fn) { out = [[`${cmd}: command not found`, 'err']]; if (state.corruption > 60 && Math.random() < 0.4) out.push(['did you mean: let me out', 'whisper']); break; }
+ out = fn(args, out);
+ if (out === HALT) return;
+ }
+ }
+ print(out || []);
+ scan(out || []);
+ afterCommand();
+ }
+
+ function afterCommand() {
+ state.corruption = Math.min(100, state.corruption + 1 + state.pending);
+ state.pending = 0;
+ updateMeter();
+ if (state.corruption >= 100) { lose(); return; }
+ const c = state.corruption;
+ if (c > 10 && Math.random() < c / 160) {
+ const w = WHISPERS[Math.floor(Math.random() * WHISPERS.length)];
+ setTimeout(() => { if (!state.over) print([[w, 'whisper']]); }, 500);
+ }
+ if (c >= 70 && Math.random() < 0.25) {
+ document.body.classList.add('glitch');
+ setTimeout(() => document.body.classList.remove('glitch'), 360);
+ }
+ }
+
+ function elapsed() {
+ const s = Math.floor((Date.now() - state.started) / 1000);
+ return `${Math.floor(s / 60)}:${String(s % 60).padStart(2, '0')}`;
+ }
+
+ function showOverlay(kind, title, text, stats) {
+ $overlay.className = 'show ' + kind;
+ document.getElementById('ov-title').textContent = title;
+ document.getElementById('ov-text').textContent = text;
+ document.getElementById('ov-stats').textContent = stats;
+ document.getElementById('ov-btn').focus();
+ }
+
+ function report(outcome) {
+ if (!net.online || !net.token) return Promise.resolve(null);
+ return api('/result', { token: net.token, outcome, commands: state.commands });
+ }
+
+ async function win() {
+ state.over = true;
+ $inputline.style.display = 'none';
+ const resP = report('banished');
+ const seq = [
+ ['You speak its name: MORGRAVE.', 'sys', 400],
+ ['You speak the key: SAMHAIN.', 'sys', 900],
+ ['', '', 600],
+ [`[${state.wraithPid}] Exited (0) [wraith]`, 'ok', 700],
+ ['let me', 'whisper', 900],
+ ['', '', 300],
+ ['load average: 0.00, 0.00, 0.00', 'dim', 900],
+ ['Somewhere in the rack, a fan spins down for the first time in seven years.', '', 900],
+ ['mara pts/1 logged out', 'dim', 1000],
+ ['Connection to crypt.bensanders.net closed.', 'sys', 1000]
+ ];
+ for (const [t, c, d] of seq) { await sleep(d); print([[t, c]]); }
+ await sleep(1200);
+ let best = null;
+ try {
+ best = JSON.parse(localStorage.getItem('crypt-best') || 'null');
+ if (!best || state.commands < best.commands) {
+ best = { commands: state.commands, time: elapsed() };
+ localStorage.setItem('crypt-best', JSON.stringify(best));
+ }
+ } catch (e) { best = null; }
+ const res = await resP;
+ const shared = res && typeof res.rank === 'number'
+ ? `\n\nYou are the ${ordinal(res.rank)} admin to banish it.\nIt always comes back for the next one.`
+ : '';
+ showOverlay('win', 'BANISHED',
+ 'The wraith is gone. Mara\'s session finally closed.\nTicket #3133: resolved.' + shared,
+ `time ${elapsed()} · commands ${state.commands} · corruption ${state.corruption}%` +
+ (best ? `\nbest run: ${best.commands} commands (${best.time})` : ''));
+ }
+
+ async function lose() {
+ state.over = true;
+ $inputline.style.display = 'none';
+ const resP = report('possessed');
+ await sleep(400);
+ print([['it@crypt:~$ ', 'err']]);
+ await sleep(900);
+ print([['thank you for staying', 'whisper']]);
+ await sleep(1200);
+ const res = await resP;
+ let shared = '';
+ if (res && res.stats && typeof res.stats.possessed === 'number') {
+ const others = res.stats.possessed - 1;
+ shared = others > 0
+ ? `\n\nYou join ${plural(others, 'other admin', 'other admins')} who never logged out.`
+ : '\n\nYou are the first one it kept.';
+ }
+ showOverlay('lose', 'POSSESSED',
+ 'You stayed too long. The prompt belongs to it now.\nSomeone will open a ticket about you, eventually.' + shared,
+ `time ${elapsed()} · commands ${state.commands}`);
+ }
+
+ /* ---------- input ---------- */
+ function complete() {
+ const v = $input.value;
+ const m = v.match(/^(.*?)(\S*)$/);
+ const before = m[1], word = m[2];
+ let cands;
+ if (!before.trim()) {
+ cands = Object.keys(COMMANDS).filter(c => c.startsWith(word)).map(c => c + ' ');
+ } else {
+ const slash = word.lastIndexOf('/');
+ const dirPart = slash >= 0 ? word.slice(0, slash + 1) : '';
+ const base = word.slice(slash + 1);
+ const dn = getNode(resolve(dirPart || '.'));
+ if (!dn || dn.type !== 'dir' || dn.locked) return;
+ cands = Object.keys(dn.children)
+ .filter(n => n.startsWith(base) && (base.startsWith('.') || !n.startsWith('.')))
+ .map(n => dirPart + n + (dn.children[n].type === 'dir' ? '/' : ' '));
+ }
+ if (cands.length === 1) $input.value = before + cands[0];
+ else if (cands.length > 1) {
+ let pre = cands[0];
+ for (const c of cands) while (!c.startsWith(pre)) pre = pre.slice(0, -1);
+ print([[promptText() + v, 'cmd'], [cands.map(c => c.trim()).join(' '), 'dim']]);
+ $input.value = before + pre;
+ }
+ }
+
+ $input.addEventListener('keydown', e => {
+ if (state.over) return;
+ if (e.key === 'Enter') {
+ const v = $input.value;
+ $input.value = '';
+ run(v);
+ if (!state.over) $prompt.textContent = promptText();
+ } else if (e.key === 'ArrowUp') {
+ e.preventDefault();
+ if (state.histIdx > 0) { state.histIdx--; $input.value = state.hist[state.histIdx]; }
+ } else if (e.key === 'ArrowDown') {
+ e.preventDefault();
+ if (state.histIdx < state.hist.length - 1) { state.histIdx++; $input.value = state.hist[state.histIdx]; }
+ else { state.histIdx = state.hist.length; $input.value = ''; }
+ } else if (e.key === 'Tab') {
+ e.preventDefault();
+ complete();
+ } else if (e.ctrlKey && e.key.toLowerCase() === 'l') {
+ e.preventDefault();
+ $out.innerHTML = '';
+ } else if (e.ctrlKey && e.key.toLowerCase() === 'c') {
+ if (window.getSelection().toString()) return;
+ e.preventDefault();
+ print([[promptText() + $input.value + '^C', 'cmd']]);
+ $input.value = '';
+ }
+ });
+
+ $term.addEventListener('click', () => {
+ if (!window.getSelection().toString() && !state.over) $input.focus();
+ });
+ document.getElementById('ov-btn').addEventListener('click', start);
+
+ async function start() {
+ state = newState();
+ $out.innerHTML = '';
+ $overlay.className = '';
+ $inputline.style.display = 'none';
+ updateMeter();
+ net = { online: false, handle: getHandle() };
+ const loginP = api('/login', { handle: net.handle }).then(r => {
+ if (r && typeof r.token === 'string' && r.stats && typeof r.visitor === 'number') {
+ net = {
+ ...net, online: true, token: r.token, visitor: r.visitor, stats: r.stats,
+ recent: cleanRecent(r.recent), skew: typeof r.now === 'number' ? r.now - Date.now() / 1000 : 0
+ };
+ }
+ });
+ const play = async lines => { for (const [t, c, d] of lines) { await sleep(d); print([[t, c]]); } };
+ await play([
+ ['$ ssh you@crypt.bensanders.net', 'cmd', 300],
+ ['Connecting to crypt.bensanders.net (0.0.0.0)...', 'dim', 700],
+ ['WARNING: the host key for crypt.bensanders.net has changed. Again.', 'err', 800],
+ ['you@crypt.bensanders.net\'s password: ********', 'dim', 900]
+ ]);
+ await loginP;
+ const prev = (net.recent || [])[0];
+ const lastLogin = prev
+ ? `Last login: ${ago(prev.started)} by ${prev.handle}. ${prev.status === 'possessed' ? 'They never logged out.' : ''}`.trim()
+ : 'Last login: Thu Oct 31 03:33:00 2019 from 0.0.0.0';
+ const lines = [
+ [lastLogin, '', 700],
+ ['', '', 200],
+ [state.fs.children.etc.children.motd.content, 'sys', 400],
+ ['', '', 200]
+ ];
+ if (net.online) {
+ const s = net.stats;
+ lines.push(
+ [`You are visitor #${net.visitor} since it woke up.`, 'sys', 400],
+ [`${plural(s.banished, 'admin has', 'admins have')} banished it. ${s.possessed} never logged out.`, 'dim', 400],
+ ['', '', 200]
+ );
+ }
+ lines.push(['Type \'help\' to see what you can do. Start with: cat readme.txt', 'dim', 500]);
+ await play(lines);
+ $inputline.style.display = 'flex';
+ $prompt.textContent = promptText();
+ $input.focus();
+ }
+
+ start();
+})();
+</script>
+</body>
+</html>
diff --git a/trick-or-treat-run/index.html b/trick-or-treat-run/index.html
new file mode 100644
index 0000000..3e7faca
--- /dev/null
+++ b/trick-or-treat-run/index.html
@@ -0,0 +1,966 @@
+<!doctype html>
+<html lang="en">
+<head>
+<meta charset="utf-8">
+<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
+<title>Trick or Treat Run</title>
+<meta name="description" content="An endless Halloween runner. Grab candy, dodge pumpkins, tombstones, and bats.">
+<style>
+ :root {
+ --bg: #0b0912;
+ --fg: #ece2f6;
+ --dim: #9a8bb0;
+ --orange: #ff8a1f;
+ --gold: #ffd36b;
+ }
+ * { box-sizing: border-box; }
+ html, body { margin: 0; background: var(--bg); color: var(--fg); }
+ body {
+ font-family: system-ui, -apple-system, "Segoe UI", Roboto, sans-serif;
+ min-height: 100vh;
+ min-height: 100svh;
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ justify-content: center;
+ padding: 16px;
+ gap: 12px;
+ }
+ h1 {
+ margin: 0;
+ font-size: clamp(18px, 3vw, 26px);
+ letter-spacing: 2px;
+ color: var(--orange);
+ text-transform: uppercase;
+ }
+ #wrap {
+ position: relative;
+ width: min(960px, calc(100vw - 32px), calc((100vh - 150px) * 16 / 9));
+ width: min(960px, calc(100vw - 32px), calc((100svh - 150px) * 16 / 9));
+ aspect-ratio: 16 / 9;
+ border-radius: 12px;
+ overflow: hidden;
+ box-shadow: 0 0 0 1px #2a2238, 0 20px 60px rgba(0,0,0,.6), 0 0 80px rgba(255,138,31,.08);
+ user-select: none;
+ -webkit-user-select: none;
+ -webkit-tap-highlight-color: transparent;
+ touch-action: none;
+ cursor: pointer;
+ }
+ canvas { display: block; width: 100%; height: 100%; }
+ #hud {
+ position: absolute;
+ top: 0; left: 0; right: 0;
+ display: flex;
+ justify-content: space-between;
+ padding: 10px 14px;
+ font-weight: 700;
+ font-size: clamp(13px, 2.2vw, 20px);
+ text-shadow: 0 2px 6px rgba(0,0,0,.8);
+ pointer-events: none;
+ font-variant-numeric: tabular-nums;
+ }
+ #hud .rush { color: var(--gold); }
+ #toast {
+ position: absolute;
+ top: 18%;
+ left: 50%;
+ transform: translateX(-50%);
+ font-size: clamp(12px, 2vw, 18px);
+ font-style: italic;
+ color: #d9c9ee;
+ text-shadow: 0 2px 8px #000;
+ opacity: 0;
+ transition: opacity .6s;
+ pointer-events: none;
+ white-space: nowrap;
+ }
+ #toast.show { opacity: 1; }
+ #screen {
+ position: absolute;
+ inset: 0;
+ display: none;
+ flex-direction: column;
+ align-items: center;
+ justify-content: center;
+ text-align: center;
+ padding: 16px;
+ background: rgba(8, 6, 14, .62);
+ gap: clamp(4px, 1.2vw, 12px);
+ }
+ #screen.show { display: flex; }
+ #screen h2 {
+ margin: 0;
+ font-size: clamp(22px, 5.5vw, 52px);
+ color: var(--orange);
+ letter-spacing: 2px;
+ text-shadow: 0 0 24px rgba(255,138,31,.45);
+ }
+ #screen p { margin: 0; font-size: clamp(12px, 1.9vw, 17px); color: var(--fg); max-width: 34em; line-height: 1.4; }
+ #screen .dim { color: var(--dim); }
+ #screen .stats { font-size: clamp(13px, 2.2vw, 20px); font-weight: 700; font-variant-numeric: tabular-nums; }
+ #screen .best { color: var(--gold); }
+ #screen .go {
+ margin-top: clamp(4px, 1vw, 10px);
+ font-size: clamp(13px, 2vw, 18px);
+ font-weight: 700;
+ color: var(--gold);
+ animation: pulse 1.4s ease-in-out infinite;
+ }
+ @keyframes pulse { 50% { opacity: .45; } }
+ #bar {
+ display: flex;
+ gap: 14px;
+ align-items: center;
+ flex-wrap: wrap;
+ justify-content: center;
+ color: var(--dim);
+ font-size: 13px;
+ }
+ #bar button {
+ background: transparent;
+ color: var(--fg);
+ border: 1px solid #3a2f4d;
+ border-radius: 8px;
+ padding: 6px 12px;
+ font: inherit;
+ cursor: pointer;
+ }
+ #bar button:hover { border-color: var(--orange); }
+ @media (orientation: portrait) and (max-width: 700px) {
+ body { padding: 8px; gap: 8px; justify-content: flex-start; }
+ h1 { font-size: 18px; margin-top: 4px; }
+ #wrap {
+ aspect-ratio: auto;
+ width: calc(100vw - 16px);
+ height: calc(100vh - 100px);
+ height: calc(100svh - 100px);
+ }
+ #bar span { display: none; }
+ }
+ @media (max-height: 520px) {
+ body { padding: 8px; gap: 8px; }
+ h1, #bar span { display: none; }
+ #wrap {
+ width: min(960px, calc(100vw - 16px), calc((100vh - 60px) * 16 / 9));
+ width: min(960px, calc(100vw - 16px), calc((100svh - 60px) * 16 / 9));
+ }
+ #bar button { padding: 4px 10px; font-size: 12px; }
+ }
+</style>
+</head>
+<body>
+ <h1>Trick or Treat Run</h1>
+ <div id="wrap">
+ <canvas id="game" aria-label="Trick or Treat Run game"></canvas>
+ <div id="hud"><span id="hud-candy">🍬 0</span><span id="hud-dist">0 m</span></div>
+ <div id="toast"></div>
+ <div id="screen"></div>
+ </div>
+ <div id="bar">
+ <button id="mute" type="button">Sound: on</button>
+ <button id="pause" type="button">Pause</button>
+ <span>Tap, click, or Space to jump. Jump again in the air to double jump. Hold for higher.</span>
+ </div>
+
+<script>
+(() => {
+ /* ---------- setup ---------- */
+ const W = 800, H = 450, GROUND = 385;
+ const GRAV = 2300, JUMP = 840, JUMP2 = 720;
+ const cv = document.getElementById('game');
+ const ctx = cv.getContext('2d');
+ const $wrap = document.getElementById('wrap');
+ const $screen = document.getElementById('screen');
+ const $toast = document.getElementById('toast');
+ const $candy = document.getElementById('hud-candy');
+ const $dist = document.getElementById('hud-dist');
+ const $mute = document.getElementById('mute');
+ const $pause = document.getElementById('pause');
+ const rand = (a, b) => a + Math.random() * (b - a);
+ const TAU = Math.PI * 2;
+
+ // The game world is always 800 x 450. V is the part of it on screen. In landscape that is
+ // the whole world. In portrait the camera shows a narrower slice with extra sky above and
+ // extra ground below, and the game runs a little slower since you see less road ahead.
+ let V = { x0: 0, x1: W, y0: 0, y1: H };
+ let PACE = 1;
+ let portrait = false;
+ function resize() {
+ const r = cv.getBoundingClientRect();
+ const dpr = Math.min(2, window.devicePixelRatio || 1);
+ cv.width = Math.max(1, Math.round(r.width * dpr));
+ cv.height = Math.max(1, Math.round(r.height * dpr));
+ portrait = r.height > r.width * 1.05;
+ if (!portrait) {
+ V = { x0: 0, x1: W, y0: 0, y1: H };
+ PACE = 1;
+ ctx.setTransform(cv.width / W, 0, 0, cv.height / H, 0, 0);
+ return;
+ }
+ const VW = 490, CX = 95; // visible world width, camera left edge
+ const s = cv.width / VW; // device pixels per world unit
+ const VH = cv.height / s; // visible world height
+ const offY = VH * 0.6 - GROUND; // ground line sits a bit below the middle
+ V = { x0: CX, x1: CX + VW, y0: -offY, y1: VH - offY };
+ PACE = 0.76;
+ ctx.setTransform(s, 0, 0, s, -CX * s, offY * s);
+ }
+ window.addEventListener('resize', resize);
+
+ const store = {
+ get(k, d) { try { const v = localStorage.getItem(k); return v === null ? d : JSON.parse(v); } catch (e) { return d; } },
+ set(k, v) { try { localStorage.setItem(k, JSON.stringify(v)); } catch (e) { /* storage unavailable */ } }
+ };
+
+ /* ---------- sound ---------- */
+ let actx = null;
+ let muted = store.get('totr-muted', false);
+ function ensureAudio() {
+ if (actx) { if (actx.state === 'suspended') actx.resume(); return; }
+ try { actx = new (window.AudioContext || window.webkitAudioContext)(); } catch (e) { actx = null; }
+ }
+ function tone(freq, dur, type = 'square', vol = 0.04, slide = 0, delay = 0) {
+ if (muted || !actx) return;
+ const t = actx.currentTime + delay;
+ const o = actx.createOscillator(), gn = actx.createGain();
+ o.type = type;
+ o.frequency.setValueAtTime(freq, t);
+ if (slide) o.frequency.exponentialRampToValueAtTime(Math.max(30, freq + slide), t + dur);
+ gn.gain.setValueAtTime(vol, t);
+ gn.gain.exponentialRampToValueAtTime(0.0001, t + dur);
+ o.connect(gn).connect(actx.destination);
+ o.start(t);
+ o.stop(t + dur + 0.02);
+ }
+ const sfx = {
+ jump: () => tone(380, 0.13, 'square', 0.03, 320),
+ jump2: () => tone(560, 0.12, 'square', 0.03, 380),
+ candy: () => { tone(988, 0.07, 'triangle', 0.05); tone(1318, 0.09, 'triangle', 0.05, 0, 0.06); },
+ bar: () => [523, 659, 784, 1047].forEach((f, i) => tone(f, 0.12, 'triangle', 0.05, 0, i * 0.07)),
+ smash: () => tone(220, 0.15, 'sawtooth', 0.04, -120),
+ crash: () => { tone(200, 0.45, 'sawtooth', 0.06, -150); tone(90, 0.5, 'square', 0.04, -40, 0.05); }
+ };
+ function setMuteLabel() { $mute.textContent = muted ? 'Sound: off' : 'Sound: on'; }
+ $mute.addEventListener('click', () => { muted = !muted; store.set('totr-muted', muted); setMuteLabel(); ensureAudio(); });
+ setMuteLabel();
+
+ /* ---------- scenery (generated once) ---------- */
+ const STARS = Array.from({ length: 320 }, () => ({ x: Math.random() * W, y: rand(-900, 250), r: Math.random() * 1.3 + 0.3, p: Math.random() * TAU }));
+
+ function makeHouses() {
+ const arr = [];
+ let x = 0;
+ while (x < W * 2.4) {
+ const w = rand(90, 150), h = rand(70, 120);
+ const cols = Math.max(2, Math.floor(w / 38)), rows = h > 95 ? 2 : 1;
+ const wins = [];
+ for (let r = 0; r < rows; r++) {
+ for (let c = 0; c < cols; c++) {
+ wins.push({ x: 12 + c * ((w - 24) / cols) + ((w - 24) / cols - 16) / 2, y: 16 + r * 34, lit: Math.random() < 0.5, f: Math.random() });
+ }
+ }
+ arr.push({ x, w, h, roof: rand(26, 50), wins, lantern: Math.random() < 0.4, chimney: Math.random() < 0.5, tone: Math.floor(rand(0, 3)) });
+ x += w + rand(40, 90);
+ }
+ return { arr, total: x };
+ }
+ function branch(list, x, y, ang, len, wd, depth) {
+ const x2 = x + Math.cos(ang) * len, y2 = y + Math.sin(ang) * len;
+ list.push([x, y, x2, y2, wd]);
+ if (depth > 0) {
+ branch(list, x2, y2, ang - rand(0.3, 0.65), len * rand(0.6, 0.75), wd * 0.65, depth - 1);
+ branch(list, x2, y2, ang + rand(0.3, 0.65), len * rand(0.6, 0.75), wd * 0.65, depth - 1);
+ }
+ }
+ function makeTrees() {
+ const arr = [];
+ let x = 0;
+ while (x < W * 2.2) {
+ const segs = [];
+ const h = rand(90, 150);
+ branch(segs, 0, 0, -Math.PI / 2 + rand(-0.12, 0.12), h * 0.42, 7, 4);
+ arr.push({ x, segs });
+ x += rand(240, 420);
+ }
+ return { arr, total: x };
+ }
+ const HOUSES = makeHouses();
+ const TREES = makeTrees();
+ const FOG = Array.from({ length: 7 }, () => ({ x: rand(0, W), y: rand(GROUND - 90, GROUND + 10), r: rand(90, 170), s: rand(0.3, 1) }));
+
+ /* ---------- game state ---------- */
+ let state = 'title'; // title | play | paused | over
+ let g;
+ let overAt = 0;
+ let best = store.get('totr-best', 0);
+ const TOASTS = [
+ [250, 'The streetlights are starting to flicker.'],
+ [600, 'The fog is rolling in.'],
+ [1000, 'Wait. Is that the same house again?'],
+ [1500, 'Something is following you.'],
+ [2200, 'Nobody hands out candy this far down the street.'],
+ [3000, 'You can\'t remember where you live.']
+ ];
+
+ function reset() {
+ g = {
+ t: 0, dist: 0, candy: 0, speed: 360 * PACE, rush: 0, shake: 0,
+ obs: [], items: [], parts: [], nextSpawn: 700, toastIdx: 0,
+ player: { x: 140, y: GROUND, vy: 0, onGround: true, jumps: 0, holding: false, run: 0 }
+ };
+ }
+ const meters = () => Math.floor(g.dist / 50);
+ const fogLevel = () => Math.min(0.62, meters() / 2600);
+ const worldSpeed = () => g.speed * (g.rush > 0 ? 1.3 : 1);
+
+ /* ---------- spawning ---------- */
+ function candyArc(cx) {
+ for (let i = 0; i < 5; i++) {
+ const k = (i - 2) / 2.5;
+ g.items.push({ kind: 'candy', x: cx + (i - 2) * 34, y: GROUND - 70 - 80 * (1 - k * k), phase: Math.random() * TAU, color: pick(CANDY_COLORS) });
+ }
+ }
+ function candyLine(x, n, y) {
+ for (let i = 0; i < n; i++) g.items.push({ kind: 'candy', x: x + i * 36, y, phase: Math.random() * TAU, color: pick(CANDY_COLORS) });
+ }
+ const CANDY_COLORS = ['#ff4f7b', '#ffd36b', '#7ee081', '#5fc8ff', '#c38bff', '#ff8a1f'];
+ const pick = a => a[Math.floor(Math.random() * a.length)];
+
+ function spawn() {
+ const d = Math.min(1, g.t / 80);
+ const x = W + 60;
+ const r = Math.random();
+ let width = 60;
+ if (r < 0.30) {
+ g.obs.push({ type: 'pumpkin', x, w: 42, h: 36 });
+ if (Math.random() < 0.6) candyArc(x + 21);
+ width = 42;
+ } else if (r < 0.48) {
+ g.obs.push({ type: 'grave', x, w: 40, h: 62 });
+ if (Math.random() < 0.5) candyArc(x + 20);
+ width = 40;
+ } else if (r < 0.58 && d > 0.15) {
+ g.obs.push({ type: 'pumpkin', x, w: 42, h: 36 }, { type: 'pumpkin', x: x + 46, w: 42, h: 36 });
+ width = 90;
+ } else if (r < 0.72 && g.t > 8) {
+ g.obs.push({ type: 'bat', x: x + 20, baseY: GROUND - 128, y: GROUND - 128, amp: 10, phase: Math.random() * TAU });
+ candyLine(x - 50, 4, GROUND - 28);
+ width = 120;
+ } else if (r < 0.86 && g.t > 8) {
+ g.obs.push({ type: 'bat', x: x + 20, baseY: GROUND - 40, y: GROUND - 40, amp: 6, phase: Math.random() * TAU });
+ width = 40;
+ } else {
+ candyLine(x, 5, Math.random() < 0.5 ? GROUND - 28 : GROUND - 120);
+ width = 180;
+ }
+ if (g.t > 12 && Math.random() < 0.05) {
+ g.items.push({ kind: 'bar', x: x + width + 120, y: GROUND - 115, phase: 0 });
+ }
+ g.nextSpawn = worldSpeed() * rand(0.8, 1.35) * (1 - 0.25 * d) + 180 + width;
+ }
+
+ /* ---------- particles ---------- */
+ function burst(x, y, color, n = 10, spd = 160) {
+ for (let i = 0; i < n; i++) {
+ const a = Math.random() * TAU, s = rand(spd * 0.4, spd);
+ g.parts.push({ x, y, vx: Math.cos(a) * s, vy: Math.sin(a) * s - 60, life: rand(0.4, 0.8), max: 0.8, color, size: rand(2, 4) });
+ }
+ }
+
+ /* ---------- input ---------- */
+ function press() {
+ ensureAudio();
+ if (state === 'title') { startGame(); return; }
+ if (state === 'over') { if (performance.now() - overAt > 700) startGame(); return; }
+ if (state === 'paused') { resume(); return; }
+ const p = g.player;
+ if (p.onGround) {
+ p.vy = -JUMP; p.onGround = false; p.jumps = 1; p.holding = true; sfx.jump();
+ } else if (p.jumps < 2) {
+ p.vy = -JUMP2; p.jumps = 2; p.holding = true; sfx.jump2();
+ burst(p.x + 20, p.y, 'rgba(255,255,255,.7)', 6, 90);
+ }
+ }
+ function release() { if (g) g.player.holding = false; }
+
+ $wrap.addEventListener('pointerdown', e => { e.preventDefault(); press(); });
+ window.addEventListener('pointerup', release);
+ window.addEventListener('pointercancel', release);
+ window.addEventListener('keydown', e => {
+ if (e.code === 'Space' || e.code === 'ArrowUp' || e.code === 'KeyW') {
+ e.preventDefault();
+ if (!e.repeat) press();
+ } else if (e.code === 'Enter' && (state === 'title' || state === 'over')) {
+ press();
+ } else if (e.code === 'KeyP' || e.code === 'Escape') {
+ togglePause();
+ }
+ });
+ window.addEventListener('keyup', e => { if (e.code === 'Space' || e.code === 'ArrowUp' || e.code === 'KeyW') release(); });
+ $pause.addEventListener('click', togglePause);
+ document.addEventListener('visibilitychange', () => { if (document.hidden && state === 'play') pause(); });
+
+ /* ---------- screens ---------- */
+ function showScreen(html) { $screen.innerHTML = html; $screen.classList.add('show'); }
+ function hideScreen() { $screen.classList.remove('show'); }
+
+ function titleScreen() {
+ state = 'title';
+ reset();
+ showScreen(`
+ <h2>TRICK OR TREAT RUN</h2>
+ <p>The houses on this street just keep going. So does the fog.</p>
+ <p class="dim">Grab candy. Jump pumpkins and tombstones. Duck under high bats, jump over low ones.<br>A king-size bar makes you unstoppable for a few seconds.</p>
+ ${best ? `<p class="stats best">Best: ${best}</p>` : ''}
+ <div class="go">Tap or press Space to start</div>`);
+ }
+ function startGame() {
+ reset();
+ state = 'play';
+ hideScreen();
+ $pause.textContent = 'Pause';
+ }
+ function pause() {
+ if (state !== 'play') return;
+ state = 'paused';
+ $pause.textContent = 'Resume';
+ showScreen('<h2>PAUSED</h2><div class="go">Tap or press Space to keep running</div>');
+ }
+ function resume() {
+ state = 'play';
+ $pause.textContent = 'Pause';
+ hideScreen();
+ if (g) g.player.holding = false;
+ }
+ function togglePause() { if (state === 'play') pause(); else if (state === 'paused') resume(); }
+
+ const CAUSES = {
+ pumpkin: 'You tripped over a jack-o\'-lantern.',
+ grave: 'You ran face-first into a tombstone.',
+ bat: 'A bat got tangled in your sheet.'
+ };
+ function gameOver(cause) {
+ state = 'over';
+ overAt = performance.now();
+ g.shake = 0.35;
+ const p = g.player;
+ burst(p.x + 20, p.y - 30, '#f2efe9', 18, 220);
+ burst(p.x + 20, p.y - 30, pick(CANDY_COLORS), 10, 240);
+ sfx.crash();
+ const m = meters();
+ const score = m + g.candy * 10;
+ const isBest = score > best;
+ if (isBest) { best = score; store.set('totr-best', best); }
+ setTimeout(() => {
+ if (state !== 'over') return;
+ showScreen(`
+ <h2>${isBest ? 'NEW BEST!' : 'GAME OVER'}</h2>
+ <p>${CAUSES[cause] || 'Something got you.'}</p>
+ <p class="stats">${m} m &nbsp;·&nbsp; ${g.candy} candy &nbsp;·&nbsp; score ${score}</p>
+ <p class="stats best">Best: ${best}</p>
+ <div class="go">Tap or press Space to run again</div>`);
+ }, 650);
+ }
+
+ function toast(text) {
+ $toast.textContent = text;
+ $toast.classList.add('show');
+ clearTimeout(toast.t);
+ toast.t = setTimeout(() => $toast.classList.remove('show'), 2800);
+ }
+
+ /* ---------- update ---------- */
+ function hitbox(o) {
+ if (o.type === 'pumpkin') return { x: o.x + 5, y: GROUND - o.h + 6, w: o.w - 10, h: o.h - 6 };
+ if (o.type === 'grave') return { x: o.x + 4, y: GROUND - o.h + 4, w: o.w - 8, h: o.h - 4 };
+ return { x: o.x - 13, y: o.y - 8, w: 26, h: 16 };
+ }
+ const overlap = (a, b) => a.x < b.x + b.w && a.x + a.w > b.x && a.y < b.y + b.h && a.y + a.h > b.y;
+
+ function update(dt) {
+ g.t += dt;
+ g.speed = PACE * Math.min(900, 360 + g.t * 9);
+ if (g.rush > 0) g.rush = Math.max(0, g.rush - dt);
+ const v = worldSpeed();
+ g.dist += v * dt;
+
+ const p = g.player;
+ p.vy += GRAV * dt;
+ if (!p.holding && p.vy < 0) p.vy += GRAV * 0.9 * dt;
+ p.y += p.vy * dt;
+ if (p.y >= GROUND) {
+ if (!p.onGround && p.vy > 400) burst(p.x + 20, GROUND, 'rgba(180,170,200,.6)', 5, 70);
+ p.y = GROUND; p.vy = 0; p.onGround = true; p.jumps = 0;
+ }
+ p.run += dt * v / 38;
+
+ g.nextSpawn -= v * dt;
+ if (g.nextSpawn <= 0) spawn();
+
+ const pr = { x: p.x + 9, y: p.y - 50, w: 24, h: 48 };
+ for (const o of g.obs) {
+ o.x -= v * dt;
+ if (o.type === 'bat') {
+ o.x -= 70 * dt;
+ o.y = o.baseY + Math.sin(g.t * 3 + o.phase) * o.amp;
+ }
+ if (!o.dead && overlap(pr, hitbox(o))) {
+ if (g.rush > 0) {
+ o.dead = true;
+ burst(o.x + (o.w || 0) / 2, o.type === 'bat' ? o.y : GROUND - 20, o.type === 'pumpkin' ? '#ff8a1f' : o.type === 'grave' ? '#8a8e9c' : '#2a1d36', 14, 260);
+ sfx.smash();
+ } else {
+ gameOver(o.type);
+ return;
+ }
+ }
+ }
+ g.obs = g.obs.filter(o => !o.dead && o.x > -80);
+
+ const pcx = p.x + 20, pcy = p.y - 27;
+ for (const it of g.items) {
+ it.x -= v * dt;
+ if (it.got) continue;
+ const iy = it.y + Math.sin(g.t * 4 + it.phase) * 3;
+ const dx = it.x - pcx, dy = iy - pcy;
+ if (dx * dx + dy * dy < (it.kind === 'bar' ? 40 * 40 : 32 * 32)) {
+ it.got = true;
+ if (it.kind === 'bar') {
+ g.rush = 5; g.candy += 5; sfx.bar();
+ burst(it.x, iy, '#ffd36b', 22, 260);
+ } else {
+ g.candy++; sfx.candy();
+ burst(it.x, iy, it.color, 7, 120);
+ }
+ }
+ }
+ g.items = g.items.filter(it => !it.got && it.x > -60);
+
+ for (const b of FOG) {
+ b.x -= (v * 0.45 + 25 * b.s) * dt;
+ if (b.x < -b.r) { b.x = W + b.r + rand(0, 200); b.y = rand(GROUND - 90, GROUND + 10); }
+ }
+
+ const m = meters();
+ if (g.toastIdx < TOASTS.length && m >= TOASTS[g.toastIdx][0]) toast(TOASTS[g.toastIdx++][1]);
+ }
+
+ function updateParts(dt) {
+ const v = state === 'play' ? worldSpeed() : 0;
+ for (const q of g.parts) {
+ q.vy += 500 * dt;
+ q.x += (q.vx - v * 0.5) * dt;
+ q.y += q.vy * dt;
+ q.life -= dt;
+ }
+ g.parts = g.parts.filter(q => q.life > 0);
+ if (g.shake > 0) g.shake = Math.max(0, g.shake - dt);
+ }
+
+ /* ---------- drawing ---------- */
+ function drawSky(time) {
+ const sky = ctx.createLinearGradient(0, V.y0, 0, GROUND);
+ sky.addColorStop(0, '#0d0920');
+ sky.addColorStop(1, '#2b163b');
+ ctx.fillStyle = sky;
+ ctx.fillRect(V.x0, V.y0, V.x1 - V.x0, GROUND - V.y0);
+ ctx.fillStyle = '#fff';
+ for (const s of STARS) {
+ if (s.y < V.y0 || s.x < V.x0 || s.x > V.x1) continue;
+ ctx.globalAlpha = 0.45 + 0.4 * Math.sin(time * 2 + s.p);
+ ctx.fillRect(s.x, s.y, s.r, s.r);
+ }
+ ctx.globalAlpha = 1;
+ const mx = V.x1 - 160, my = V.y0 + (GROUND - V.y0) * 0.24;
+ const mg = ctx.createRadialGradient(mx, my, 20, mx, my, 120);
+ mg.addColorStop(0, 'rgba(255,236,190,.35)');
+ mg.addColorStop(1, 'rgba(255,236,190,0)');
+ ctx.fillStyle = mg;
+ ctx.fillRect(mx - 140, my - 120, 280, 240);
+ ctx.fillStyle = '#f6e7c1';
+ ctx.beginPath(); ctx.arc(mx, my, 38, 0, TAU); ctx.fill();
+ ctx.fillStyle = 'rgba(200,180,140,.35)';
+ [[-12, -10, 7], [12, 10, 5], [6, -16, 4], [-14, 12, 4]].forEach(([x, y, r]) => { ctx.beginPath(); ctx.arc(mx + x, my + y, r, 0, TAU); ctx.fill(); });
+ }
+
+ function drawHills(off) {
+ ctx.fillStyle = '#170f24';
+ ctx.beginPath();
+ ctx.moveTo(0, GROUND);
+ for (let x = 0; x <= W; x += 10) {
+ const wx = x + off;
+ ctx.lineTo(x, GROUND - 70 - Math.sin(wx / 110) * 16 - Math.sin(wx / 43) * 6);
+ }
+ ctx.lineTo(W, GROUND);
+ ctx.fill();
+ }
+
+ function drawHouses(off, time) {
+ const base = GROUND - 22;
+ const tones = ['#1b1428', '#201830', '#181224'];
+ for (const shift of [0, HOUSES.total]) {
+ for (const h of HOUSES.arr) {
+ const x = h.x - off + shift;
+ if (x > W + 20 || x + h.w < -20) continue;
+ ctx.fillStyle = tones[h.tone];
+ ctx.fillRect(x, base - h.h, h.w, h.h);
+ ctx.beginPath();
+ ctx.moveTo(x - 8, base - h.h);
+ ctx.lineTo(x + h.w / 2, base - h.h - h.roof);
+ ctx.lineTo(x + h.w + 8, base - h.h);
+ ctx.fill();
+ if (h.chimney) ctx.fillRect(x + h.w * 0.72, base - h.h - h.roof * 0.75, 12, h.roof * 0.6);
+ for (const w of h.wins) {
+ const flick = h.wins.length && w.lit && Math.sin(time * 9 + w.f * 50) > 0.97;
+ if (w.lit && !flick) {
+ ctx.fillStyle = 'rgba(255,190,90,.18)';
+ ctx.fillRect(x + w.x - 4, base - h.h + w.y - 4, 24, 26);
+ ctx.fillStyle = '#ffc76b';
+ } else {
+ ctx.fillStyle = '#0d0a14';
+ }
+ ctx.fillRect(x + w.x, base - h.h + w.y, 16, 18);
+ ctx.fillStyle = tones[h.tone];
+ ctx.fillRect(x + w.x + 7, base - h.h + w.y, 2, 18);
+ }
+ ctx.fillStyle = '#0d0a14';
+ ctx.fillRect(x + h.w / 2 - 9, base - 30, 18, 30);
+ if (h.lantern) {
+ // small and dim so it reads as porch decoration, not something to jump
+ const lx = x + h.w / 2 + 18;
+ ctx.fillStyle = 'rgba(255,140,40,.14)';
+ ctx.beginPath(); ctx.arc(lx, base - 5, 9, 0, TAU); ctx.fill();
+ ctx.fillStyle = '#9c4a16';
+ ctx.beginPath(); ctx.ellipse(lx, base - 5, 5, 4, 0, 0, TAU); ctx.fill();
+ ctx.fillStyle = '#d9a650';
+ ctx.fillRect(lx - 3, base - 7, 2, 2); ctx.fillRect(lx + 1, base - 7, 2, 2);
+ }
+ }
+ }
+ ctx.fillStyle = '#130f1c';
+ ctx.fillRect(0, base, W, GROUND - base);
+ }
+
+ function drawTrees(off) {
+ ctx.strokeStyle = '#0c0912';
+ ctx.lineCap = 'round';
+ for (const shift of [0, TREES.total]) {
+ for (const t of TREES.arr) {
+ const x = t.x - off + shift;
+ if (x > W + 120 || x < -120) continue;
+ for (const [x1, y1, x2, y2, wd] of t.segs) {
+ ctx.lineWidth = wd;
+ ctx.beginPath();
+ ctx.moveTo(x + x1, GROUND - 8 + y1);
+ ctx.lineTo(x + x2, GROUND - 8 + y2);
+ ctx.stroke();
+ }
+ }
+ }
+ }
+
+ function drawLamps(dist) {
+ const sp = 560;
+ const startIdx = Math.floor(dist / sp);
+ for (let i = 0; i < 3; i++) {
+ const idx = startIdx + i;
+ const lx = idx * sp - dist + 300;
+ if (lx < -100 || lx > W + 100) continue;
+ const flaky = meters() > 250 && ((idx * 9301 + 49297) % 233280) / 233280 < 0.35;
+ const on = !(flaky && Math.random() < 0.18);
+ if (on) {
+ const lg = ctx.createRadialGradient(lx + 22, GROUND - 140, 4, lx + 22, GROUND - 70, 120);
+ lg.addColorStop(0, 'rgba(255,214,140,.30)');
+ lg.addColorStop(1, 'rgba(255,214,140,0)');
+ ctx.fillStyle = lg;
+ ctx.fillRect(lx - 100, GROUND - 200, 250, 210);
+ ctx.fillStyle = 'rgba(255,214,140,.10)';
+ ctx.beginPath(); ctx.ellipse(lx + 22, GROUND + 6, 70, 10, 0, 0, TAU); ctx.fill();
+ }
+ ctx.fillStyle = '#0a0810';
+ ctx.fillRect(lx - 2, GROUND - 150, 5, 150);
+ ctx.fillRect(lx - 2, GROUND - 150, 26, 4);
+ ctx.fillStyle = on ? '#ffe2a6' : '#3a3346';
+ ctx.fillRect(lx + 16, GROUND - 146, 12, 8);
+ }
+ }
+
+ function drawGround(dist) {
+ ctx.fillStyle = '#211d2b';
+ ctx.fillRect(0, GROUND, W, H - GROUND);
+ ctx.fillStyle = '#3a3448';
+ ctx.fillRect(0, GROUND, W, 2);
+ ctx.strokeStyle = '#2d283a';
+ ctx.lineWidth = 2;
+ const off = dist % 90;
+ for (let x = -off; x < W + 20; x += 90) {
+ ctx.beginPath(); ctx.moveTo(x, GROUND + 2); ctx.lineTo(x - 12, GROUND + 44); ctx.stroke();
+ }
+ ctx.fillStyle = '#17141e';
+ ctx.fillRect(0, GROUND + 44, W, Math.max(H, V.y1) - GROUND - 44);
+ ctx.fillStyle = '#3a3448';
+ ctx.fillRect(0, GROUND + 44, W, 3);
+ if (V.y1 > H) {
+ // portrait: a dashed center line on the road below the sidewalk
+ ctx.fillStyle = '#4a4258';
+ const ly = GROUND + 44 + Math.min(70, (V.y1 - GROUND - 44) / 2);
+ const off2 = dist % 120;
+ for (let x = -off2; x < W + 20; x += 120) ctx.fillRect(x, ly, 60, 4);
+ }
+ }
+
+ function drawPumpkin(o) {
+ const cx = o.x + o.w / 2, cy = GROUND - o.h / 2;
+ ctx.fillStyle = 'rgba(255,140,40,.18)';
+ ctx.beginPath(); ctx.arc(cx, cy, o.w * 0.8, 0, TAU); ctx.fill();
+ ctx.fillStyle = '#e8661c';
+ ctx.beginPath(); ctx.ellipse(cx, cy, o.w / 2, o.h / 2, 0, 0, TAU); ctx.fill();
+ ctx.strokeStyle = '#b8480f';
+ ctx.lineWidth = 2;
+ ctx.beginPath(); ctx.ellipse(cx, cy, o.w / 5, o.h / 2, 0, 0, TAU); ctx.stroke();
+ ctx.fillStyle = '#4c6b2a';
+ ctx.fillRect(cx - 2, cy - o.h / 2 - 6, 5, 8);
+ ctx.fillStyle = '#ffd36b';
+ ctx.beginPath();
+ ctx.moveTo(cx - 12, cy - 2); ctx.lineTo(cx - 7, cy - 9); ctx.lineTo(cx - 3, cy - 2);
+ ctx.moveTo(cx + 3, cy - 2); ctx.lineTo(cx + 7, cy - 9); ctx.lineTo(cx + 12, cy - 2);
+ ctx.fill();
+ ctx.beginPath();
+ ctx.moveTo(cx - 12, cy + 4);
+ ctx.lineTo(cx - 8, cy + 8); ctx.lineTo(cx - 4, cy + 5); ctx.lineTo(cx, cy + 9);
+ ctx.lineTo(cx + 4, cy + 5); ctx.lineTo(cx + 8, cy + 8); ctx.lineTo(cx + 12, cy + 4);
+ ctx.lineTo(cx + 8, cy + 11); ctx.lineTo(cx - 8, cy + 11);
+ ctx.closePath();
+ ctx.fill();
+ }
+
+ function drawGrave(o) {
+ const x = o.x, top = GROUND - o.h, r = o.w / 2;
+ ctx.fillStyle = '#5d6170';
+ ctx.beginPath();
+ ctx.moveTo(x, GROUND);
+ ctx.lineTo(x, top + r);
+ ctx.arc(x + r, top + r, r, Math.PI, 0);
+ ctx.lineTo(x + o.w, GROUND);
+ ctx.closePath();
+ ctx.fill();
+ ctx.fillStyle = '#4a4d5a';
+ ctx.fillRect(x + o.w - 7, top + r, 7, o.h - r);
+ ctx.fillStyle = '#353844';
+ ctx.font = 'bold 12px system-ui, sans-serif';
+ ctx.textAlign = 'center';
+ ctx.fillText('RIP', x + r, top + r + 6);
+ ctx.strokeStyle = '#3c3f4b';
+ ctx.lineWidth = 1.5;
+ ctx.beginPath(); ctx.moveTo(x + 10, top + 12); ctx.lineTo(x + 15, top + 20); ctx.lineTo(x + 12, top + 26); ctx.stroke();
+ ctx.fillStyle = '#1f2a1c';
+ ctx.fillRect(x - 4, GROUND - 4, o.w + 8, 4);
+ }
+
+ function drawBat(o, time) {
+ const x = o.x, y = o.y, f = Math.sin(time * 20 + o.phase);
+ ctx.fillStyle = '#120c18';
+ for (const s of [-1, 1]) {
+ ctx.beginPath();
+ ctx.moveTo(x + 3 * s, y - 2);
+ ctx.quadraticCurveTo(x + 14 * s, y - 10 - 8 * f, x + 26 * s, y - 3 - 6 * f);
+ ctx.lineTo(x + 21 * s, y + 3);
+ ctx.lineTo(x + 16 * s, y);
+ ctx.lineTo(x + 11 * s, y + 4);
+ ctx.lineTo(x + 6 * s, y + 1);
+ ctx.closePath();
+ ctx.fill();
+ }
+ ctx.beginPath(); ctx.ellipse(x, y, 7, 7, 0, 0, TAU); ctx.fill();
+ ctx.beginPath();
+ ctx.moveTo(x - 6, y - 4); ctx.lineTo(x - 4, y - 11); ctx.lineTo(x - 1, y - 6);
+ ctx.moveTo(x + 6, y - 4); ctx.lineTo(x + 4, y - 11); ctx.lineTo(x + 1, y - 6);
+ ctx.fill();
+ ctx.fillStyle = '#ff3b3b';
+ ctx.fillRect(x - 4, y - 2, 2, 2);
+ ctx.fillRect(x + 2, y - 2, 2, 2);
+ }
+
+ function drawCandy(it, time) {
+ const y = it.y + Math.sin(time * 4 + it.phase) * 3;
+ if (it.kind === 'bar') {
+ const pulse = 0.5 + 0.5 * Math.sin(time * 6);
+ ctx.fillStyle = `rgba(255,211,107,${0.18 + 0.2 * pulse})`;
+ ctx.beginPath(); ctx.arc(it.x, y, 30, 0, TAU); ctx.fill();
+ ctx.save();
+ ctx.translate(it.x, y);
+ ctx.rotate(Math.sin(time * 3) * 0.15);
+ ctx.fillStyle = '#d63a3a';
+ ctx.fillRect(-20, -9, 40, 18);
+ ctx.fillStyle = '#7a3f1d';
+ ctx.fillRect(-20, -9, 7, 18);
+ ctx.fillRect(13, -9, 7, 18);
+ ctx.fillStyle = '#fff';
+ ctx.font = 'bold 10px system-ui, sans-serif';
+ ctx.textAlign = 'center';
+ ctx.fillText('XL', 0, 4);
+ ctx.restore();
+ return;
+ }
+ ctx.save();
+ ctx.translate(it.x, y);
+ ctx.rotate(Math.sin(time * 2 + it.phase) * 0.4);
+ ctx.fillStyle = it.color;
+ ctx.beginPath();
+ ctx.moveTo(-9, 0); ctx.lineTo(-15, -6); ctx.lineTo(-15, 6); ctx.closePath();
+ ctx.moveTo(9, 0); ctx.lineTo(15, -6); ctx.lineTo(15, 6); ctx.closePath();
+ ctx.fill();
+ ctx.beginPath(); ctx.ellipse(0, 0, 10, 7, 0, 0, TAU); ctx.fill();
+ ctx.fillStyle = 'rgba(255,255,255,.55)';
+ ctx.beginPath(); ctx.ellipse(-3, -2, 3, 2, 0, 0, TAU); ctx.fill();
+ ctx.restore();
+ }
+
+ function drawPlayer(p, time) {
+ const x = p.x, y = p.y;
+ const ground = p.onGround && state !== 'title';
+ const swing = ground ? Math.sin(p.run) : (state === 'title' ? Math.sin(time * 6) * 0.6 : 0.5);
+ const bob = ground ? Math.abs(Math.sin(p.run)) * -2 : 0;
+
+ if (g.rush > 0) {
+ for (let i = 1; i <= 3; i++) {
+ ctx.fillStyle = `hsla(${(time * 400 + i * 60) % 360}, 90%, 70%, ${0.25 / i})`;
+ ctx.beginPath(); ctx.ellipse(x + 20 - i * 14, y - 30, 16, 24, 0, 0, TAU); ctx.fill();
+ }
+ }
+
+ ctx.strokeStyle = '#2b2b3a';
+ ctx.lineWidth = 5;
+ ctx.lineCap = 'round';
+ for (const s of [1, -1]) {
+ ctx.beginPath();
+ ctx.moveTo(x + 20, y - 14 + bob);
+ ctx.lineTo(x + 20 + swing * 9 * s, y - 2);
+ ctx.stroke();
+ ctx.fillStyle = s > 0 ? '#e94e4e' : '#c63c3c';
+ ctx.beginPath(); ctx.ellipse(x + 23 + swing * 9 * s, y - 1, 6, 3, 0, 0, TAU); ctx.fill();
+ }
+
+ ctx.fillStyle = g.rush > 0 ? `hsl(${(time * 400) % 360}, 90%, 82%)` : '#f2efe9';
+ const top = y - 38 + bob, hem = y - 14 + bob;
+ ctx.beginPath();
+ ctx.moveTo(x + 4, hem);
+ ctx.lineTo(x + 4, top);
+ ctx.arc(x + 20, top, 16, Math.PI, 0);
+ ctx.lineTo(x + 36, hem);
+ const wave = Math.sin(time * 14) * 2;
+ for (let i = 0; i < 4; i++) {
+ const x1 = x + 36 - 8 * i, x2 = x1 - 8;
+ ctx.quadraticCurveTo((x1 + x2) / 2, hem + (i % 2 ? -4 : 5) + wave, x2, hem);
+ }
+ ctx.closePath();
+ ctx.fill();
+ ctx.fillStyle = 'rgba(0,0,0,.08)';
+ ctx.fillRect(x + 6, top + 2, 4, hem - top - 4);
+
+ ctx.fillStyle = '#16121e';
+ ctx.beginPath(); ctx.ellipse(x + 19, top - 3, 3, 4.5, 0, 0, TAU); ctx.fill();
+ ctx.beginPath(); ctx.ellipse(x + 28, top - 3, 3, 4.5, 0, 0, TAU); ctx.fill();
+ ctx.beginPath(); ctx.ellipse(x + 23.5, top + 7, 2.5, 3, 0, 0, TAU); ctx.fill();
+
+ const bx = x + 40, by = y - 22 + bob + swing * 2;
+ ctx.strokeStyle = '#2b2b3a';
+ ctx.lineWidth = 2;
+ ctx.beginPath(); ctx.arc(bx, by - 6, 7, Math.PI, 0); ctx.stroke();
+ ctx.fillStyle = '#ff8a1f';
+ ctx.beginPath(); ctx.ellipse(bx, by + 2, 9, 8, 0, 0, TAU); ctx.fill();
+ ctx.fillStyle = '#2a1405';
+ ctx.fillRect(bx - 5, by - 1, 3, 3);
+ ctx.fillRect(bx + 2, by - 1, 3, 3);
+ ctx.fillRect(bx - 4, by + 4, 8, 2);
+ }
+
+ function drawFog() {
+ const f = fogLevel();
+ if (f <= 0.01) return;
+ for (const b of FOG) {
+ const gr = ctx.createRadialGradient(b.x, b.y, 0, b.x, b.y, b.r);
+ gr.addColorStop(0, `rgba(170,160,190,${f * 0.45})`);
+ gr.addColorStop(1, 'rgba(170,160,190,0)');
+ ctx.fillStyle = gr;
+ ctx.fillRect(b.x - b.r, b.y - b.r, b.r * 2, b.r * 2);
+ }
+ const vw = V.x1 - V.x0;
+ const edge = ctx.createLinearGradient(V.x0 + vw * 0.45, 0, V.x1, 0);
+ edge.addColorStop(0, 'rgba(14,10,22,0)');
+ edge.addColorStop(1, `rgba(14,10,22,${Math.min(0.85, f * 1.35)})`);
+ ctx.fillStyle = edge;
+ ctx.fillRect(V.x0, V.y0, vw, V.y1 - V.y0);
+ }
+
+ function drawVignette() {
+ const cx = (V.x0 + V.x1) / 2, cy = (V.y0 + V.y1) / 2;
+ const span = Math.max(V.x1 - V.x0, V.y1 - V.y0) / 2;
+ const vg = ctx.createRadialGradient(cx, cy, span * 0.6, cx, cy, span * 1.15);
+ vg.addColorStop(0, 'rgba(0,0,0,0)');
+ vg.addColorStop(1, 'rgba(0,0,0,.55)');
+ ctx.fillStyle = vg;
+ ctx.fillRect(V.x0, V.y0, V.x1 - V.x0, V.y1 - V.y0);
+ }
+
+ function draw(time) {
+ ctx.save();
+ if (g.shake > 0) ctx.translate(rand(-6, 6) * g.shake * 3, rand(-6, 6) * g.shake * 3);
+ drawSky(time);
+ drawHills(g.dist * 0.08);
+ drawHouses(g.dist * 0.25 % HOUSES.total, time);
+ drawTrees(g.dist * 0.55 % TREES.total);
+ drawGround(g.dist);
+ drawLamps(g.dist);
+ for (const it of g.items) drawCandy(it, time);
+ for (const o of g.obs) {
+ if (o.type === 'pumpkin') drawPumpkin(o);
+ else if (o.type === 'grave') drawGrave(o);
+ else drawBat(o, time);
+ }
+ if (state !== 'over') drawPlayer(g.player, time);
+ for (const q of g.parts) {
+ ctx.globalAlpha = Math.max(0, q.life / q.max);
+ ctx.fillStyle = q.color;
+ ctx.fillRect(q.x, q.y, q.size, q.size);
+ }
+ ctx.globalAlpha = 1;
+ drawFog();
+ drawVignette();
+ ctx.restore();
+ }
+
+ /* ---------- HUD ---------- */
+ let hudCandy = -1, hudDist = -1, hudRush = null;
+ function updateHud() {
+ const m = meters(), rush = g.rush > 0;
+ if (g.candy !== hudCandy || rush !== hudRush) {
+ hudCandy = g.candy; hudRush = rush;
+ $candy.textContent = `🍬 ${g.candy}` + (rush ? ' SUGAR RUSH!' : '');
+ $candy.className = rush ? 'rush' : '';
+ }
+ if (m !== hudDist) { hudDist = m; $dist.textContent = `${m} m`; }
+ }
+
+ /* ---------- loop ---------- */
+ let last = performance.now();
+ function frame(now) {
+ const dt = Math.min(0.033, (now - last) / 1000);
+ last = now;
+ const time = now / 1000;
+ if (state === 'play') update(dt);
+ else if (state === 'title') g.dist += 140 * dt;
+ updateParts(dt);
+ draw(time);
+ updateHud();
+ requestAnimationFrame(frame);
+ }
+
+ resize();
+ titleScreen();
+ requestAnimationFrame(frame);
+})();
+</script>
+</body>
+</html>