'],\n })\n }\n\n await browser.close()\n return { sitemap, authFlows: this.authFlows, formResults: this.results }\n },\n})","language":"typescript"},"narration":"Fort Laramie is where Shannon gets hands-on. Playwright automates a real browser — navigating pages, filling forms, testing login flows. It's not just crawling links; it's interacting like a real user would. Every form submission, every auth flow gets mapped as potential attack surface."},{"name":"Independence Rock","subtitle":"Attack Surface Analysis","concept":"Exploitation","landmarkType":"mountain","code":{"file":"src/agents/analyzer.ts","content":"// src/agents/analyzer.ts\nimport { Agent } from '@shannon/agent-sdk'\n\nexport const attackSurfaceAgent = new Agent({\n name: 'attack-surface-analyzer',\n model: 'claude-sonnet-4-6',\n\n async execute({ target, recon, exploration }) {\n // Run specialized analysis agents in parallel\n const [sqlResults, xssResults, authResults, ssrfResults] =\n await Promise.all([\n this.spawn('sql-injection-analyzer', {\n codeRoutes: recon.entryPoints,\n formResults: exploration.formResults,\n ormType: recon.techStack.orm,\n }),\n this.spawn('xss-analyzer', {\n templates: recon.architecture.views,\n formResults: exploration.formResults,\n framework: recon.techStack.frontend,\n }),\n this.spawn('auth-bypass-analyzer', {\n authFlows: exploration.authFlows,\n jwtConfig: recon.architecture.auth,\n sessionMgmt: recon.architecture.sessions,\n }),\n this.spawn('ssrf-analyzer', {\n urlParams: exploration.sitemap.urlParams,\n serverCode: recon.architecture.httpClients,\n }),\n ])\n\n return this.prioritize([...sqlResults, ...xssResults,\n ...authResults, ...ssrfResults])\n },\n})","language":"typescript"},"narration":"Independence Rock is where insights converge. Parallel agents analyze the combined results — one for SQL injection, one for XSS, one for auth bypass, one for SSRF. Each agent combines code analysis with exploration data. Static tells you where the code is vulnerable; dynamic tells you if you can reach it."},{"name":"South Pass","subtitle":"Injection Testing","concept":"Exploitation","landmarkType":"river","code":{"file":"src/agents/injector.ts","content":"// src/agents/injector.ts\nimport { Agent } from '@shannon/agent-sdk'\n\nexport const injectionAgent = new Agent({\n name: 'injection-tester',\n model: 'claude-sonnet-4-6',\n\n async execute({ target, hypotheses }) {\n const findings = []\n\n for (const hypothesis of hypotheses.sqlInjection) {\n // Code-aware payload crafting\n const payload = this.craftPayload({\n ormType: hypothesis.orm, // Sequelize? Raw SQL?\n paramType: hypothesis.paramType, // query string? body?\n dbType: hypothesis.database, // PostgreSQL? MySQL?\n escaping: hypothesis.escaping, // What sanitization exists?\n })\n\n const result = await this.inject(target.appUrl, {\n endpoint: hypothesis.endpoint,\n method: hypothesis.method,\n param: hypothesis.param,\n payload: payload,\n })\n\n if (result.indicates.sqli) {\n findings.push({\n type: 'SQL_INJECTION',\n severity: 'CRITICAL',\n endpoint: hypothesis.endpoint,\n payload: payload,\n evidence: result.response,\n codeRef: hypothesis.sourceFile + ':' + hypothesis.sourceLine,\n })\n }\n }\n\n return findings\n },\n})","language":"typescript"},"narration":"South Pass — the narrow gap where payloads squeeze through. Shannon doesn't spray generic payloads blindly. It reads the source code to understand which ORM is used, what sanitization exists, and crafts targeted payloads. Knowing the code means knowing exactly where raw SQL meets user input."},{"name":"Fort Bridger","subtitle":"Auth Bypass","concept":"Proof-by-Exploit","landmarkType":"forest","code":{"file":"src/agents/auth-bypass.ts","content":"// src/agents/auth-bypass.ts\nimport { Agent } from '@shannon/agent-sdk'\nimport { decode, sign, verify } from 'jsonwebtoken'\n\nexport const authBypassAgent = new Agent({\n name: 'auth-bypass',\n model: 'claude-sonnet-4-6',\n\n async execute({ target, jwtConfig }) {\n const findings = []\n const token = await this.obtainValidToken(target)\n const decoded = decode(token, { complete: true })\n\n // Test alg:none attack\n const noneToken = sign(\n { ...decoded.payload, role: 'admin' },\n '',\n { algorithm: 'none' }\n )\n const noneResult = await this.testToken(target, noneToken)\n if (noneResult.accepted) {\n findings.push({\n type: 'JWT_ALG_NONE',\n severity: 'CRITICAL',\n description: 'Server accepts unsigned JWT tokens',\n exploit: `curl -H \"Authorization: Bearer ${noneToken}\" ${target.appUrl}/api/admin`,\n })\n }\n\n // Test algorithm confusion (RS256 -> HS256)\n if (decoded.header.alg === 'RS256' && jwtConfig.publicKey) {\n const confusedToken = sign(\n { ...decoded.payload, role: 'admin' },\n jwtConfig.publicKey,\n { algorithm: 'HS256' }\n )\n const confusedResult = await this.testToken(target, confusedToken)\n if (confusedResult.accepted) {\n findings.push({\n type: 'JWT_ALG_CONFUSION',\n severity: 'CRITICAL',\n description: 'RS256/HS256 algorithm confusion',\n })\n }\n }\n\n return findings\n },\n})","language":"typescript"},"narration":"Fort Bridger guards the authentication frontier. JWT attacks are Shannon's specialty here — the alg:none attack sets the algorithm to nothing, bypassing signature checks on misconfigured servers. Algorithm confusion tricks RS256 verification into accepting HS256 signed with the public key. Authentication is the crown jewels of security."},{"name":"The Dalles","subtitle":"Exploit Validation","concept":"Proof-by-Exploit","landmarkType":"river","code":{"file":"src/agents/validator.ts","content":"// src/agents/validator.ts\nimport { Agent } from '@shannon/agent-sdk'\n\nexport const exploitValidator = new Agent({\n name: 'exploit-validator',\n model: 'claude-sonnet-4-6',\n\n async execute({ target, findings }) {\n const validated = []\n\n for (const finding of findings) {\n // Actually execute the exploit — not just flag it\n const exploitResult = await this.exploit(target, finding, {\n timeout: 30_000,\n maxRetries: 3,\n })\n\n if (exploitResult.success) {\n // Reproduce it twice more to confirm\n const repro1 = await this.exploit(target, finding)\n const repro2 = await this.exploit(target, finding)\n\n if (repro1.success && repro2.success) {\n validated.push({\n ...finding,\n status: 'CONFIRMED',\n reproducible: true,\n exploitSteps: exploitResult.steps,\n curlCommand: this.generateCurl(finding),\n evidence: {\n request: exploitResult.request,\n response: exploitResult.response,\n screenshot: exploitResult.screenshot,\n },\n })\n }\n }\n }\n\n return validated\n },\n})","language":"typescript"},"narration":"The Dalles is where theory meets reality. Shannon doesn't just flag potential vulnerabilities — it exploits them. Three successful reproductions confirm a finding. No exploit, no report. This eliminates the false positives that plague traditional scanners and gives defenders reproducible proof."},{"name":"Oregon City","subtitle":"Report Generation","concept":"Browser Automation","landmarkType":"town","code":{"file":"src/report/generator.ts","content":"// src/report/generator.ts\nimport { ValidatedFinding } from '@shannon/types'\n\nexport function generateReport(\n findings: ValidatedFinding[],\n target: PentestTarget,\n): SecurityReport {\n return {\n executive_summary: summarize(findings),\n target: {\n name: target.name,\n url: target.appUrl,\n scope: target.scope,\n },\n findings: findings.map(f => ({\n title: f.type,\n severity: f.severity,\n description: f.description,\n // Copy-paste ready exploit command\n reproduction: {\n steps: f.exploitSteps,\n curl: f.curlCommand,\n evidence: f.evidence,\n },\n remediation: generateRemediation(f),\n references: getCVEReferences(f),\n })),\n methodology: 'Autonomous white-box penetration test',\n tools: ['Shannon AI', 'Playwright', 'Nmap', 'WhatWeb'],\n }\n}","language":"typescript"},"narration":"You made it to Oregon City! The final report is what separates Shannon from noise-generating scanners. Every finding has copy-paste exploit commands, screenshots of successful exploitation, and specific remediation steps. An actionable security report, not a 500-page dump of theoretical alerts."}],"events":[{"type":"weather","trigger":"after_stop","triggerStop":1,"title":"Scope Storm!","text":"A storm of targets hits your terminal — production servers, staging environments, third-party APIs, internal tools. Your scope definition is being tested. Why does Shannon require source code access for white-box testing?","choices":[{"text":"Code analysis guides attack strategy and reveals vulnerabilities invisible to blind testing","correct":true,"explanation":"White-box testing means Shannon reads the source code to find where raw SQL meets user input, where auth is misconfigured, where secrets are hardcoded. Black-box testing can only guess."},{"text":"Shannon can't use a browser without source code","correct":false,"explanation":"Shannon uses Playwright for browser automation regardless of code access. Source code enables targeted testing, not browser control."},{"text":"Source code is easier to read than HTTP responses","correct":false,"explanation":"It's not about ease of reading — it's about finding vulnerabilities that are invisible from the outside, like unsafe ORM usage or hardcoded secrets."}],"concept":"Reconnaissance","difficulty":"easy"},{"type":"encounter","trigger":"after_stop","triggerStop":2,"title":"A Security Scout Appears!","text":"A grizzled security researcher approaches your camp. 'I see you're running Nmap and WhatWeb against the target. Good start.' What do these tools discover about the target?","choices":[{"text":"Open ports, services, technology stack, and framework versions","correct":true,"explanation":"Nmap discovers open ports and running services. WhatWeb fingerprints the technology stack — web server, framework, programming language, CMS versions. Together they map the attack surface."},{"text":"Passwords stored in the application database","correct":false,"explanation":"Nmap and WhatWeb are reconnaissance tools, not exploitation tools. They discover services and technologies, not credentials."},{"text":"Source code vulnerabilities like SQL injection","correct":false,"explanation":"These are network and technology fingerprinting tools. Source code analysis is a separate phase that Shannon handles with its code analysis agent."}],"concept":"Reconnaissance","difficulty":"easy"},{"type":"river","trigger":"after_stop","triggerStop":4,"title":"The Vulnerability River Crossing!","text":"You've reached the widest river on the trail — the gap between hypothesis and proof. Your analysis agents have generated dozens of potential vulnerabilities. Why does Shannon run analysis agents in parallel?","choices":[{"text":"Each vulnerability type can be analyzed independently, reducing total time","correct":true,"explanation":"SQL injection analysis doesn't depend on XSS analysis. Auth bypass doesn't need SSRF results. Running them in parallel via Promise.all cuts wall-clock time dramatically."},{"text":"One agent isn't smart enough to find all vulnerability types","correct":false,"explanation":"It's about efficiency, not intelligence. A single agent could analyze all types sequentially — parallelism is an optimization, not a capability requirement."},{"text":"Parallel execution is always faster than sequential","correct":false,"explanation":"Parallel is only faster when tasks are independent. If one analysis depended on another's output, you'd need sequential execution. Shannon's architecture ensures independence."}],"concept":"Exploitation","difficulty":"medium"},{"type":"weather","trigger":"after_stop","triggerStop":5,"title":"Injection Storm!","text":"Payloads are flying everywhere — SQL fragments, XSS vectors, template injections. The storm tests your understanding. How does code-aware testing improve SQL injection detection compared to blind fuzzing?","choices":[{"text":"Knowing the ORM and query builder reveals which inputs reach raw SQL vs parameterized queries","correct":true,"explanation":"If the code uses parameterized queries everywhere, Shannon skips those endpoints. If it finds raw string concatenation in SQL, it knows exactly which parameter to target and what database dialect to exploit."},{"text":"It makes the payloads longer and more complex","correct":false,"explanation":"Code-aware testing makes payloads more targeted, not longer. Understanding the ORM means fewer, smarter payloads instead of brute-force lists."},{"text":"It bypasses Web Application Firewalls automatically","correct":false,"explanation":"WAF bypass is a separate technique. Code-aware testing helps identify truly vulnerable code paths, not evade security controls."}],"concept":"Exploitation","difficulty":"medium"},{"type":"misfortune","trigger":"after_stop","triggerStop":6,"title":"Auth Bypass Gone Wrong!","text":"Your JWT manipulation backfired — the server returned a cryptic error and locked your test account. Before trying again, you need to understand the attack. What is the JWT alg:none attack?","choices":[{"text":"Setting the JWT algorithm to \"none\" to bypass signature verification on misconfigured servers","correct":true,"explanation":"The JWT spec technically allows algorithm \"none\" for trusted contexts. Misconfigured servers that don't validate the algorithm field will accept unsigned tokens, letting attackers forge any claims."},{"text":"Using no JWT at all and accessing endpoints directly","correct":false,"explanation":"The alg:none attack still sends a JWT — it just sets the algorithm header to \"none\" so the signature is empty. The token structure is preserved."},{"text":"Encrypting the JWT with a null cipher so nobody can read it","correct":false,"explanation":"JWTs are signed, not encrypted (unless using JWE). The alg:none attack removes the signature, it doesn't add encryption."}],"concept":"Proof-by-Exploit","difficulty":"hard"},{"type":"encounter","trigger":"after_stop","triggerStop":7,"title":"Proof-by-Exploitation Pioneer!","text":"A veteran pentester at The Dalles checkpoint asks the critical question: 'Why does Shannon require actual exploitation instead of just flagging potential issues?'","choices":[{"text":"Eliminates false positives — proves real-world exploitability with reproducible steps","correct":true,"explanation":"Traditional scanners flag thousands of 'potential' issues. Shannon exploits each finding three times to confirm it's real and reproducible. Defenders get actionable proof, not noise."},{"text":"Actual exploitation is more dramatic and impressive in reports","correct":false,"explanation":"It's not about drama — it's about accuracy. A finding with a working exploit and curl command is infinitely more useful than a 'possible vulnerability' warning."},{"text":"Static analysis is always wrong so you must exploit everything","correct":false,"explanation":"Static analysis is valuable for finding potential issues. But without exploitation, you don't know which findings are actually exploitable in the running application."}],"concept":"Proof-by-Exploit","difficulty":"medium"}],"partyMembers":[{"name":"Reconnaissance","icon":"server","maxHealth":3},{"name":"Exploitation","icon":"router","maxHealth":3},{"name":"Browser Automation","icon":"data","maxHealth":3},{"name":"Proof-by-Exploit","icon":"layout","maxHealth":3}],"deathMessages":["Died of false positives. Your report was 500 pages of noise.","Lost in the authentication maze \u2014 2FA was too strong.","Drowned in rate limiting. The WAF blocked all your payloads.","Killed by your own SQL injection. The database dropped itself.","Your Playwright session crashed. The browser ate all the RAM.","Starved waiting for Temporal workflow to resume. Durable execution wasn't so durable.","Died of scope creep. You started pentesting the wrong application.","Buried under a mountain of unexploitable CVEs."]};
// =====================================================================
// GAME STATE
// =====================================================================
const STATES = { TITLE: 'TITLE', SETUP: 'SETUP', TRAVEL: 'TRAVEL', STOP: 'STOP', EVENT: 'EVENT', RIVER: 'RIVER', DEATH: 'DEATH', WIN: 'WIN' };
const PROFESSIONS = [
{ name: 'Ralph Wiggum', desc: '"I\'m learnding!"', health: 999, supplies: 999, hintFree: true, forgiving: true },
{ name: 'Vibe Coder', desc: '"It works on my machine"', health: 100, supplies: 5, hintFree: false, forgiving: false },
{ name: 'Engineer', desc: '"Let me check the docs"', health: 80, supplies: 2, hintFree: false, forgiving: false },
{ name: 'Staff Architect', desc: '"I designed this system"', health: 50, supplies: 0, hintFree: false, forgiving: false }
];
let gameState = STATES.TITLE;
let difficulty = 1;
let health = 100;
let maxHealthForGame = 100;
let supplies = 5;
let currentStop = 0;
let day = 1;
let score = 0;
let totalQuestions = 0;
let streak = 0;
let bestStreak = 0;
let hintsUsed = 0;
let scrollX = 0;
let travelTimer = null;
let animFrame = null;
let partyHealth = [];
let pendingEvents = [];
let currentEventIndex = 0;
let eventAnswered = false;
let eventHinted = false;
let dimmedChoice = -1;
let selectedEventChoice = 0;
let selectedStopChoice = 0;
let shuffledIndices = []; // maps display position -> original choice index
let selectedDifficulty = 1;
let musicPlaying = false;
let audioCtx = null;
let musicInitialized = false;
let typewriterTimer = null;
let typewriterText = '';
let typewriterIndex = 0;
let showRiver = false;
let wagonWheelAngle = 0;
let travelFlavorIndex = 0;
let deathPending = false;
let currentEventType = ''; // event type for canvas overlay
let currentEventTitle = ''; // event title for canvas overlay
const travelFlavors = [
"The Playwright session is stable today...",
"Payloads flow smoothly through the request pipeline...",
"Your Nmap scans return clean results...",
"The target's WAF seems asleep...",
"A gentle breeze carries HTTP responses...",
"The vulnerability scanner hums along quietly...",
"Exploit proofs reproduce without issues...",
"The pentesting framework handles traffic with grace...",
"Your TypeScript agents coordinate perfectly...",
"The Temporal workflow is calm and responsive...",
"A hawk circles overhead, watching your network traffic...",
"The attack surface is well-mapped ahead...",
"Your JWT tokens are freshly forged...",
"The dependency tree reveals interesting packages...",
"Claude analyzes the code with focused precision..."
];
// =====================================================================
// CANVAS RENDERING (320x200 internal)
// =====================================================================
const canvas = document.getElementById('game-canvas');
const ctx = canvas.getContext('2d');
const W = 320, H = 200;
// Seeded PRNG for consistent landscape
function seededRandom(seed) {
let s = seed;
return function() {
s = (s * 1664525 + 1013904223) & 0xFFFFFFFF;
return (s >>> 0) / 0xFFFFFFFF;
};
}
const rng = seededRandom(42);
const cloudPositions = [];
for (let i = 0; i < 5; i++) {
cloudPositions.push({ x: rng() * 400, y: 10 + rng() * 30, w: 25 + rng() * 30, h: 8 + rng() * 6 });
}
const farMountains = [];
for (let i = 0; i < 6; i++) {
farMountains.push({ x: i * 70 - 20 + rng() * 30, h: 40 + rng() * 35, w: 50 + rng() * 40 });
}
const nearMountains = [];
for (let i = 0; i < 8; i++) {
nearMountains.push({ x: i * 55 - 30 + rng() * 20, h: 25 + rng() * 25, w: 30 + rng() * 25, snow: rng() > 0.3 });
}
const treesData = [];
for (let i = 0; i < 25; i++) {
treesData.push({ x: rng() * 600, h: 12 + rng() * 16 });
}
function getSkyColors() {
const progress = currentStop / Math.max(TRAIL_DATA.stops.length, 1);
if (progress < 0.15) {
return { top: '#5500AA', bottom: '#FF5555' };
} else if (progress < 0.7) {
return { top: '#5555FF', bottom: '#55FFFF' };
} else {
return { top: '#5500AA', bottom: '#FFAA00' };
}
}
function drawPixelCloud(x, y, w, h) {
ctx.fillStyle = '#FFFFFF';
const ix = Math.floor(x), iy = Math.floor(y), iw = Math.floor(w), ih = Math.floor(h);
ctx.fillRect(ix, iy, iw, ih);
ctx.fillRect(ix + Math.floor(iw * 0.15), iy - Math.floor(ih * 0.5), Math.floor(iw * 0.3), Math.floor(ih * 0.6));
ctx.fillRect(ix + Math.floor(iw * 0.4), iy - Math.floor(ih * 0.7), Math.floor(iw * 0.35), Math.floor(ih * 0.8));
ctx.fillRect(ix + Math.floor(iw * 0.65), iy - Math.floor(ih * 0.3), Math.floor(iw * 0.2), Math.floor(ih * 0.4));
}
function drawMountain(x, h, w, color, snowCap) {
const baseY = 110;
ctx.fillStyle = color;
ctx.beginPath();
ctx.moveTo(Math.floor(x), baseY);
ctx.lineTo(Math.floor(x + w / 2), Math.floor(baseY - h));
ctx.lineTo(Math.floor(x + w), baseY);
ctx.closePath();
ctx.fill();
if (snowCap) {
ctx.fillStyle = '#FFFFFF';
const peakX = x + w / 2;
const peakY = baseY - h;
ctx.beginPath();
ctx.moveTo(Math.floor(peakX - w * 0.1), Math.floor(peakY + h * 0.18));
ctx.lineTo(Math.floor(peakX), Math.floor(peakY));
ctx.lineTo(Math.floor(peakX + w * 0.1), Math.floor(peakY + h * 0.18));
ctx.closePath();
ctx.fill();
}
}
function drawTree(x, baseY, h) {
const trunkW = 3, trunkH = Math.floor(h * 0.3);
ctx.fillStyle = '#AA5500';
ctx.fillRect(Math.floor(x - 1), Math.floor(baseY - trunkH), trunkW, trunkH);
for (let i = 0; i < 3; i++) {
const layerW = (h * 0.4) * (1 - i * 0.15);
const layerY = baseY - trunkH - (i * h * 0.2);
ctx.fillStyle = i === 1 ? '#00AA00' : '#005500';
ctx.beginPath();
ctx.moveTo(Math.floor(x - layerW / 2), Math.floor(layerY));
ctx.lineTo(Math.floor(x + 0.5), Math.floor(layerY - h * 0.25));
ctx.lineTo(Math.floor(x + layerW / 2), Math.floor(layerY));
ctx.closePath();
ctx.fill();
}
}
function drawWagon(wx, wy) {
// Oxen (two brown lumps)
ctx.fillStyle = '#AA5500';
ctx.fillRect(Math.floor(wx - 24), Math.floor(wy - 6), 8, 6);
ctx.fillRect(Math.floor(wx - 14), Math.floor(wy - 6), 8, 6);
// Oxen legs
ctx.fillStyle = '#553300';
ctx.fillRect(Math.floor(wx - 22), Math.floor(wy), 2, 4);
ctx.fillRect(Math.floor(wx - 18), Math.floor(wy), 2, 4);
ctx.fillRect(Math.floor(wx - 12), Math.floor(wy), 2, 4);
ctx.fillRect(Math.floor(wx - 8), Math.floor(wy), 2, 4);
// Oxen heads
ctx.fillStyle = '#885500';
ctx.fillRect(Math.floor(wx - 26), Math.floor(wy - 7), 3, 3);
ctx.fillRect(Math.floor(wx - 16), Math.floor(wy - 7), 3, 3);
// Yoke
ctx.fillStyle = '#AA5500';
ctx.fillRect(Math.floor(wx - 4), Math.floor(wy - 4), 6, 2);
// Wagon body
ctx.fillStyle = '#AA5500';
ctx.fillRect(Math.floor(wx + 2), Math.floor(wy - 8), 24, 10);
// Wagon sides darker
ctx.fillStyle = '#885500';
ctx.fillRect(Math.floor(wx + 2), Math.floor(wy - 8), 24, 1);
ctx.fillRect(Math.floor(wx + 2), Math.floor(wy + 1), 24, 1);
// Canvas cover (white curved top)
ctx.fillStyle = '#FFFFFF';
ctx.beginPath();
ctx.moveTo(Math.floor(wx + 3), Math.floor(wy - 8));
ctx.quadraticCurveTo(Math.floor(wx + 14), Math.floor(wy - 22), Math.floor(wx + 25), Math.floor(wy - 8));
ctx.closePath();
ctx.fill();
// Canvas cover outline
ctx.strokeStyle = '#AAAAAA';
ctx.lineWidth = 1;
ctx.beginPath();
ctx.moveTo(Math.floor(wx + 3), Math.floor(wy - 8));
ctx.quadraticCurveTo(Math.floor(wx + 14), Math.floor(wy - 22), Math.floor(wx + 25), Math.floor(wy - 8));
ctx.stroke();
// Canvas ribs
ctx.strokeStyle = '#AAAAAA';
for (let ri = 0; ri < 3; ri++) {
const rx = wx + 8 + ri * 5;
ctx.beginPath();
ctx.moveTo(Math.floor(rx), Math.floor(wy - 8));
ctx.lineTo(Math.floor(rx), Math.floor(wy - 14 - ri % 2 * 2));
ctx.stroke();
}
// Wheels
const wheelR = 5;
for (let wi = 0; wi < 2; wi++) {
const wcx = wx + 7 + wi * 14;
const wcy = wy + 4;
// Outer rim
ctx.fillStyle = '#553300';
ctx.beginPath();
ctx.arc(Math.floor(wcx), Math.floor(wcy), wheelR, 0, Math.PI * 2);
ctx.fill();
// Inner
ctx.fillStyle = '#AA5500';
ctx.beginPath();
ctx.arc(Math.floor(wcx), Math.floor(wcy), wheelR - 1.5, 0, Math.PI * 2);
ctx.fill();
// Spokes
ctx.strokeStyle = '#553300';
ctx.lineWidth = 1;
for (let si = 0; si < 6; si++) {
const angle = wagonWheelAngle + (si * Math.PI / 3);
ctx.beginPath();
ctx.moveTo(Math.floor(wcx), Math.floor(wcy));
ctx.lineTo(Math.floor(wcx + Math.cos(angle) * (wheelR - 1)), Math.floor(wcy + Math.sin(angle) * (wheelR - 1)));
ctx.stroke();
}
// Hub
ctx.fillStyle = '#553300';
ctx.fillRect(Math.floor(wcx - 1), Math.floor(wcy - 1), 2, 2);
}
// Red flag (security/hacker theme)
ctx.fillStyle = '#FF0000';
ctx.fillRect(Math.floor(wx + 14), Math.floor(wy - 22), 1, -7);
ctx.fillStyle = '#FF5555';
ctx.fillRect(Math.floor(wx + 15), Math.floor(wy - 29), 6, 4);
}
function drawLandmark(type, x, baseY) {
switch (type) {
case 'town':
ctx.fillStyle = '#AA5500';
ctx.fillRect(Math.floor(x), Math.floor(baseY - 18), 14, 18);
ctx.fillRect(Math.floor(x + 18), Math.floor(baseY - 14), 12, 14);
// Roofs
ctx.fillStyle = '#555555';
ctx.beginPath();
ctx.moveTo(Math.floor(x - 2), Math.floor(baseY - 18));
ctx.lineTo(Math.floor(x + 7), Math.floor(baseY - 25));
ctx.lineTo(Math.floor(x + 16), Math.floor(baseY - 18));
ctx.closePath();
ctx.fill();
ctx.beginPath();
ctx.moveTo(Math.floor(x + 16), Math.floor(baseY - 14));
ctx.lineTo(Math.floor(x + 24), Math.floor(baseY - 20));
ctx.lineTo(Math.floor(x + 32), Math.floor(baseY - 14));
ctx.closePath();
ctx.fill();
// Windows
ctx.fillStyle = '#FFFF55';
ctx.fillRect(Math.floor(x + 3), Math.floor(baseY - 13), 3, 3);
ctx.fillRect(Math.floor(x + 9), Math.floor(baseY - 13), 3, 3);
ctx.fillRect(Math.floor(x + 21), Math.floor(baseY - 10), 3, 3);
// Door
ctx.fillStyle = '#553300';
ctx.fillRect(Math.floor(x + 5), Math.floor(baseY - 7), 4, 7);
break;
case 'camp':
// Tent
ctx.fillStyle = '#AAAAAA';
ctx.beginPath();
ctx.moveTo(Math.floor(x - 6), Math.floor(baseY));
ctx.lineTo(Math.floor(x + 4), Math.floor(baseY - 14));
ctx.lineTo(Math.floor(x + 14), Math.floor(baseY));
ctx.closePath();
ctx.fill();
ctx.fillStyle = '#555555';
ctx.beginPath();
ctx.moveTo(Math.floor(x + 4), Math.floor(baseY));
ctx.lineTo(Math.floor(x + 4), Math.floor(baseY - 14));
ctx.lineTo(Math.floor(x + 14), Math.floor(baseY));
ctx.closePath();
ctx.fill();
// Campfire
ctx.fillStyle = '#FF5555';
ctx.fillRect(Math.floor(x + 20), Math.floor(baseY - 5), 4, 3);
ctx.fillStyle = '#FFFF55';
ctx.fillRect(Math.floor(x + 21), Math.floor(baseY - 8), 2, 3);
ctx.fillStyle = '#FFAA00';
ctx.fillRect(Math.floor(x + 18), Math.floor(baseY - 2), 8, 2);
break;
case 'mountain':
drawMountain(x - 20, 60, 50, '#AA00AA', true);
drawMountain(x + 10, 45, 40, '#FF55FF', true);
break;
case 'river':
ctx.fillStyle = '#AA5500';
ctx.fillRect(Math.floor(x), Math.floor(baseY - 20), 3, 20);
ctx.fillRect(Math.floor(x + 24), Math.floor(baseY - 20), 3, 20);
ctx.strokeStyle = '#AAAAAA';
ctx.lineWidth = 1;
ctx.beginPath();
ctx.moveTo(Math.floor(x + 1), Math.floor(baseY - 18));
ctx.lineTo(Math.floor(x + 25), Math.floor(baseY - 18));
ctx.stroke();
// Sign
ctx.fillStyle = '#AA5500';
ctx.fillRect(Math.floor(x + 8), Math.floor(baseY - 25), 12, 8);
ctx.fillStyle = '#FFFFFF';
ctx.fillRect(Math.floor(x + 10), Math.floor(baseY - 23), 2, 1);
ctx.fillRect(Math.floor(x + 13), Math.floor(baseY - 23), 2, 1);
ctx.fillRect(Math.floor(x + 16), Math.floor(baseY - 23), 2, 1);
break;
case 'forest':
for (let i = 0; i < 6; i++) {
drawTree(x + i * 7, baseY, 14 + (i % 3) * 3);
}
break;
}
}
function drawRiver(time) {
const riverY = 155;
const riverH = 18;
ctx.fillStyle = '#0000AA';
ctx.fillRect(0, riverY, W, riverH);
ctx.fillStyle = '#00AAAA';
for (let wx = 0; wx < W; wx += 3) {
const waveY = riverY + 3 + Math.sin((wx + time * 0.05) * 0.08) * 3;
ctx.fillRect(wx, Math.floor(waveY), 2, 1);
const waveY2 = riverY + 10 + Math.sin((wx + time * 0.03 + 50) * 0.1) * 2;
ctx.fillRect(wx, Math.floor(waveY2), 2, 1);
}
// White foam
ctx.fillStyle = '#55FFFF';
for (let wx = 0; wx < W; wx += 12) {
const foamY = riverY + 1 + Math.sin((wx + time * 0.04) * 0.06) * 1;
ctx.fillRect(wx + 2, Math.floor(foamY), 3, 1);
}
}
// =====================================================================
// EVENT CANVAS OVERLAYS — unique visuals per quiz event
// =====================================================================
function drawEventOverlay(time) {
if (!currentEventType && !currentEventTitle) return;
var t = (time || 0) * 0.001; // seconds
// --- SCOPE STORM: dark sky, lightning, rain ---
if (currentEventTitle.indexOf('Scope Storm') !== -1) {
// Darken sky
ctx.fillStyle = 'rgba(0,0,50,0.45)';
ctx.fillRect(0, 0, W, 110);
// Lightning flash (periodic)
var flashPhase = Math.sin(t * 1.7) + Math.sin(t * 3.1);
if (flashPhase > 1.6) {
ctx.fillStyle = 'rgba(255,255,255,0.4)';
ctx.fillRect(0, 0, W, H);
// Lightning bolt
ctx.strokeStyle = '#FFFF55';
ctx.lineWidth = 2;
ctx.beginPath();
var lx = 80 + Math.sin(t * 2.3) * 60;
ctx.moveTo(lx, 10);
ctx.lineTo(lx - 5, 35);
ctx.lineTo(lx + 8, 35);
ctx.lineTo(lx - 3, 65);
ctx.lineTo(lx + 10, 65);
ctx.lineTo(lx + 2, 95);
ctx.stroke();
}
// Rain
ctx.strokeStyle = '#5555FF';
ctx.lineWidth = 1;
for (var i = 0; i < 40; i++) {
var rx = (i * 8.3 + t * 80) % W;
var ry = (i * 13.7 + t * 120) % 110;
ctx.beginPath();
ctx.moveTo(Math.floor(rx), Math.floor(ry));
ctx.lineTo(Math.floor(rx - 1), Math.floor(ry + 5));
ctx.stroke();
}
return;
}
// --- SECURITY SCOUT: person standing by campfire ---
if (currentEventTitle.indexOf('Security Scout') !== -1) {
var px = 200, py = 142;
// Person body
ctx.fillStyle = '#AA5500';
ctx.fillRect(px, py - 12, 4, 8); // torso
ctx.fillStyle = '#FFAA00';
ctx.fillRect(px, py - 15, 4, 3); // head
ctx.fillRect(px - 1, py - 4, 2, 5); // left leg
ctx.fillRect(px + 3, py - 4, 2, 5); // right leg
// Hat
ctx.fillStyle = '#555555';
ctx.fillRect(px - 1, py - 17, 6, 2);
ctx.fillRect(px, py - 19, 4, 2);
// Campfire
var fx = 212, fy = 148;
ctx.fillStyle = '#AA5500';
ctx.fillRect(fx - 3, fy, 2, 3);
ctx.fillRect(fx + 3, fy, 2, 3);
ctx.fillStyle = '#FF5555';
var flicker = Math.sin(t * 8) * 2;
ctx.fillRect(fx - 1, fy - 3 + Math.floor(flicker * 0.3), 4, 3);
ctx.fillStyle = '#FFFF55';
ctx.fillRect(fx, fy - 5 + Math.floor(flicker * 0.5), 2, 2);
ctx.fillStyle = '#FFAA00';
ctx.fillRect(fx - 1, fy - 2, 4, 2);
// Firelight glow (subtle)
ctx.fillStyle = 'rgba(255,170,0,0.08)';
ctx.beginPath();
ctx.arc(fx + 1, fy - 2, 15, 0, Math.PI * 2);
ctx.fill();
return;
}
// --- VULNERABILITY RIVER: rough rapids with white water ---
if (currentEventTitle.indexOf('Vulnerability River') !== -1) {
// Extra turbulence on the river
ctx.fillStyle = '#55FFFF';
for (var i = 0; i < 20; i++) {
var sx = (i * 17 + t * 40) % W;
var sy = 157 + Math.sin((i * 3 + t * 5)) * 3;
ctx.fillRect(Math.floor(sx), Math.floor(sy), 4, 2);
}
// Floating logs / debris
ctx.fillStyle = '#AA5500';
for (var i = 0; i < 3; i++) {
var logX = (i * 110 + t * 25) % W;
var logY = 159 + Math.sin(t * 2 + i) * 2;
ctx.fillRect(Math.floor(logX), Math.floor(logY), 8, 2);
}
return;
}
// --- INJECTION STORM: dark sky, red-tinged rain of payloads ---
if (currentEventTitle.indexOf('Injection Storm') !== -1) {
// Darken sky with red tint
ctx.fillStyle = 'rgba(50,0,0,0.35)';
ctx.fillRect(0, 0, W, 110);
// Lightning flash (periodic)
var flashPhase = Math.sin(t * 2.1) + Math.sin(t * 2.7);
if (flashPhase > 1.6) {
ctx.fillStyle = 'rgba(255,100,100,0.3)';
ctx.fillRect(0, 0, W, H);
ctx.strokeStyle = '#FF5555';
ctx.lineWidth = 2;
ctx.beginPath();
var lx = 120 + Math.sin(t * 1.8) * 80;
ctx.moveTo(lx, 10);
ctx.lineTo(lx - 8, 40);
ctx.lineTo(lx + 5, 40);
ctx.lineTo(lx - 2, 70);
ctx.stroke();
}
// Red-tinted rain (payloads)
ctx.strokeStyle = '#FF5555';
ctx.lineWidth = 1;
for (var i = 0; i < 35; i++) {
var rx = (i * 9.1 + t * 70) % W;
var ry = (i * 12.3 + t * 100) % 110;
ctx.beginPath();
ctx.moveTo(Math.floor(rx), Math.floor(ry));
ctx.lineTo(Math.floor(rx - 1), Math.floor(ry + 4));
ctx.stroke();
}
return;
}
// --- AUTH BYPASS GONE WRONG: red sky, smoke, warning ---
if (currentEventTitle.indexOf('Auth Bypass Gone Wrong') !== -1) {
// Red danger tint
ctx.fillStyle = 'rgba(170,0,0,0.25)';
ctx.fillRect(0, 0, W, H);
// Smoke particles rising
ctx.fillStyle = '#555555';
for (var i = 0; i < 15; i++) {
var sx = 140 + Math.sin(i * 2.7 + t) * 30;
var sy = 130 - ((t * 20 + i * 15) % 90);
var size = 2 + (i % 3);
ctx.globalAlpha = 0.3 + Math.sin(t + i) * 0.15;
ctx.fillRect(Math.floor(sx), Math.floor(sy), size, size);
}
ctx.globalAlpha = 1.0;
// Fire at base
ctx.fillStyle = '#FF5555';
for (var i = 0; i < 8; i++) {
var fx = 130 + i * 5;
var fh = 4 + Math.sin(t * 6 + i * 1.5) * 3;
ctx.fillRect(fx, Math.floor(140 - fh), 3, Math.floor(fh));
}
ctx.fillStyle = '#FFFF55';
for (var i = 0; i < 5; i++) {
var fx = 135 + i * 6;
var fh = 2 + Math.sin(t * 8 + i * 2) * 2;
ctx.fillRect(fx, Math.floor(139 - fh), 2, Math.floor(fh));
}
// Pulsing red border
var pulse = (Math.sin(t * 4) + 1) * 0.15;
ctx.strokeStyle = 'rgba(255,0,0,' + pulse.toFixed(2) + ')';
ctx.lineWidth = 3;
ctx.strokeRect(1, 1, W - 2, H - 2);
ctx.lineWidth = 1;
return;
}
// --- PROOF-BY-EXPLOITATION PIONEER: person at checkpoint ---
if (currentEventTitle.indexOf('Proof-by-Exploitation') !== -1) {
var px = 200, py = 142;
// Person body
ctx.fillStyle = '#AA5500';
ctx.fillRect(px, py - 12, 4, 8);
ctx.fillStyle = '#FFAA00';
ctx.fillRect(px, py - 15, 4, 3);
ctx.fillRect(px - 1, py - 4, 2, 5);
ctx.fillRect(px + 3, py - 4, 2, 5);
// Hat
ctx.fillStyle = '#555555';
ctx.fillRect(px - 1, py - 17, 6, 2);
ctx.fillRect(px, py - 19, 4, 2);
// Terminal/laptop nearby
ctx.fillStyle = '#333333';
ctx.fillRect(px + 10, py - 8, 10, 7);
ctx.fillStyle = '#55FF55';
ctx.fillRect(px + 11, py - 7, 8, 5);
// Blinking cursor on screen
if (Math.sin(t * 4) > 0) {
ctx.fillStyle = '#55FF55';
ctx.fillRect(px + 13, py - 5, 2, 1);
}
return;
}
}
// =====================================================================
// DEATH SCREEN — Apple II green phosphor style
// =====================================================================
function drawDeathScene(time) {
var t = (time || 0) * 0.001;
var G = '#33FF33'; // Apple II green
var GD = '#1a8a1a'; // darker green for details
// Black background
ctx.fillStyle = '#000000';
ctx.fillRect(0, 0, W, H);
// Ground line
ctx.fillStyle = GD;
ctx.fillRect(0, 148, W, 1);
// --- OX (left side) ---
var ox = 100, oy = 128;
// Body
ctx.fillStyle = G;
ctx.fillRect(ox, oy, 24, 14);
// Head
ctx.fillRect(ox - 10, oy - 2, 12, 10);
// Horns
ctx.fillRect(ox - 12, oy - 6, 3, 5);
ctx.fillRect(ox - 4, oy - 6, 3, 5);
// Snout
ctx.fillStyle = GD;
ctx.fillRect(ox - 10, oy + 4, 6, 3);
// Eye
ctx.fillStyle = '#000';
ctx.fillRect(ox - 7, oy, 2, 2);
// Legs
ctx.fillStyle = G;
ctx.fillRect(ox + 2, oy + 14, 4, 8);
ctx.fillRect(ox + 10, oy + 14, 4, 8);
ctx.fillRect(ox + 16, oy + 14, 4, 8);
// Tail
ctx.fillRect(ox + 24, oy + 2, 6, 2);
ctx.fillRect(ox + 29, oy, 2, 4);
// --- HITCH ---
ctx.fillStyle = G;
ctx.fillRect(ox + 28, oy + 8, 14, 2);
// --- WAGON ---
var wx = 142, wy = 108;
// Wagon bed
ctx.fillStyle = G;
ctx.fillRect(wx, wy + 20, 50, 16);
// Bed slats (dark lines)
ctx.fillStyle = GD;
ctx.fillRect(wx, wy + 24, 50, 1);
ctx.fillRect(wx, wy + 28, 50, 1);
ctx.fillRect(wx, wy + 32, 50, 1);
// Side rails
ctx.fillStyle = G;
ctx.fillRect(wx, wy + 18, 2, 18);
ctx.fillRect(wx + 48, wy + 18, 2, 18);
// Canvas cover (the bonnet)
ctx.fillStyle = G;
// Left arch
ctx.fillRect(wx + 4, wy + 4, 2, 16);
// Right arch
ctx.fillRect(wx + 44, wy + 4, 2, 16);
// Top cover
ctx.fillRect(wx + 4, wy + 2, 42, 4);
// Cover fill
ctx.fillRect(wx + 6, wy + 6, 38, 12);
// Cover shading
ctx.fillStyle = GD;
ctx.fillRect(wx + 8, wy + 8, 34, 2);
ctx.fillRect(wx + 8, wy + 12, 34, 2);
ctx.fillRect(wx + 8, wy + 16, 34, 1);
// Wheels
var wheelR = 8;
var wheels = [wx + 8, wx + 42];
var wa = t * 0.5; // slow rotation
ctx.strokeStyle = G;
ctx.lineWidth = 1.5;
for (var wi = 0; wi < wheels.length; wi++) {
var cx = wheels[wi], cy = wy + 38;
// Rim
ctx.beginPath();
ctx.arc(cx, cy, wheelR, 0, Math.PI * 2);
ctx.stroke();
// Spokes
for (var sp = 0; sp < 6; sp++) {
var angle = wa + sp * Math.PI / 3;
ctx.beginPath();
ctx.moveTo(cx, cy);
ctx.lineTo(cx + Math.cos(angle) * wheelR, cy + Math.sin(angle) * wheelR);
ctx.stroke();
}
// Hub
ctx.fillStyle = G;
ctx.beginPath();
ctx.arc(cx, cy, 2, 0, Math.PI * 2);
ctx.fill();
}
ctx.lineWidth = 1;
// Subtle scanline effect
ctx.fillStyle = 'rgba(0,0,0,0.08)';
for (var sl = 0; sl < H; sl += 3) {
ctx.fillRect(0, sl, W, 1);
}
// Subtle green glow/vignette
var grd = ctx.createRadialGradient(W/2, H/2, 40, W/2, H/2, W * 0.7);
grd.addColorStop(0, 'rgba(0,0,0,0)');
grd.addColorStop(1, 'rgba(0,0,0,0.4)');
ctx.fillStyle = grd;
ctx.fillRect(0, 0, W, H);
}
function renderCanvas(time) {
// Death screen: green phosphor Apple II style
if (gameState === STATES.DEATH) {
drawDeathScene(time);
return;
}
const skyColors = getSkyColors();
// Sky gradient (banded for EGA feel)
const bands = 10;
const skyBottom = 110;
for (let i = 0; i < bands; i++) {
const t = i / (bands - 1);
const r1 = parseInt(skyColors.top.substr(1, 2), 16);
const g1 = parseInt(skyColors.top.substr(3, 2), 16);
const b1 = parseInt(skyColors.top.substr(5, 2), 16);
const r2 = parseInt(skyColors.bottom.substr(1, 2), 16);
const g2 = parseInt(skyColors.bottom.substr(3, 2), 16);
const b2 = parseInt(skyColors.bottom.substr(5, 2), 16);
const r = Math.floor(r1 + (r2 - r1) * t);
const g = Math.floor(g1 + (g2 - g1) * t);
const b = Math.floor(b1 + (b2 - b1) * t);
ctx.fillStyle = `rgb(${r},${g},${b})`;
const bandH = Math.ceil(skyBottom / bands) + 1;
ctx.fillRect(0, Math.floor(i * (skyBottom / bands)), W, bandH);
}
// Clouds (parallax 0.2x)
const cloudOffset = scrollX * 0.2;
for (const c of cloudPositions) {
let cx = ((c.x - cloudOffset) % (W + 80));
if (cx < -40) cx += W + 80;
drawPixelCloud(cx - 20, c.y, c.w, c.h);
}
// Far mountains (parallax 0.3x) - DARK MAGENTA signature Oregon Trail
const farOffset = scrollX * 0.3;
for (const m of farMountains) {
let mx = ((m.x - farOffset) % (W + 100));
if (mx < -50) mx += W + 100;
drawMountain(mx - 25, m.h, m.w, '#AA00AA', false);
}
// Near mountains (parallax 0.4x) - LIGHT MAGENTA with snow caps
const nearOffset = scrollX * 0.4;
for (const m of nearMountains) {
let mx = ((m.x - nearOffset) % (W + 80));
if (mx < -40) mx += W + 80;
drawMountain(mx - 20, m.h, m.w, '#FF55FF', m.snow);
}
// Grass
ctx.fillStyle = '#00AA00';
ctx.fillRect(0, 110, W, H - 110);
// Bright grass highlights
ctx.fillStyle = '#55FF55';
const grassOffset = scrollX * 1.0;
for (let i = 0; i < 50; i++) {
let gx = ((i * 11 + 5 - grassOffset) % (W + 20));
if (gx < -5) gx += W + 20;
const gy = 113 + (i * 7 + i * i * 3) % 55;
ctx.fillRect(Math.floor(gx), Math.floor(gy), 2, 2);
}
// Trees (parallax 0.6x)
const treeOffset = scrollX * 0.6;
const treeBaseY = 128;
for (const t of treesData) {
let tx = ((t.x - treeOffset) % (W + 60));
if (tx < -20) tx += W + 60;
drawTree(tx - 10, treeBaseY, t.h);
}
// Landmark for current stop (only at stops/events)
if (gameState === STATES.STOP || gameState === STATES.EVENT || gameState === STATES.RIVER) {
const stop = TRAIL_DATA.stops[currentStop];
if (stop) {
drawLandmark(stop.landmarkType, 240, 150);
}
}
// Trail/ground strip
if (!showRiver) {
ctx.fillStyle = '#AA5500';
ctx.fillRect(0, 152, W, 15);
// Trail texture dots
ctx.fillStyle = '#885500';
for (let i = 0; i < 35; i++) {
let dotX = ((i * 10 - scrollX * 1.0) % (W + 10));
if (dotX < -3) dotX += W + 10;
ctx.fillRect(Math.floor(dotX), 155 + (i % 4) * 2, 3, 1);
}
// Ruts
ctx.fillStyle = '#775500';
ctx.fillRect(0, 157, W, 1);
ctx.fillRect(0, 162, W, 1);
}
// River (when active)
if (showRiver) {
drawRiver(time || 0);
}
// Wagon
const wagonX = 65;
const wagonY = showRiver ? 148 : 149;
drawWagon(wagonX, wagonY);
// Event-specific visual overlay
if (gameState === STATES.EVENT || gameState === STATES.RIVER) {
drawEventOverlay(time);
}
// Ground below trail
ctx.fillStyle = '#885500';
ctx.fillRect(0, showRiver ? 173 : 167, W, 15);
ctx.fillStyle = '#553300';
ctx.fillRect(0, showRiver ? 188 : 182, W, H - 182);
}
// =====================================================================
// MUSIC SYSTEM (Web Audio API Chiptune)
// =====================================================================
const NOTE_FREQ = {
'C3': 130.81, 'D3': 146.83, 'G2': 98.00, 'A2': 110.00,
'C4': 261.63, 'D4': 293.66, 'E4': 329.63, 'G4': 392.00,
'A4': 440.00, 'C5': 523.25, 'REST': 0
};
const melody = [
{ note: 'E4', dur: 1 }, { note: 'G4', dur: 1 }, { note: 'A4', dur: 1 }, { note: 'G4', dur: 1 },
{ note: 'E4', dur: 1 }, { note: 'D4', dur: 1 }, { note: 'C4', dur: 1 }, { note: 'REST', dur: 1 },
{ note: 'E4', dur: 1 }, { note: 'G4', dur: 1 }, { note: 'A4', dur: 1 }, { note: 'C5', dur: 1 },
{ note: 'A4', dur: 2 }, { note: 'G4', dur: 2 },
{ note: 'C4', dur: 1 }, { note: 'D4', dur: 1 }, { note: 'E4', dur: 1 }, { note: 'G4', dur: 1 },
{ note: 'A4', dur: 1 }, { note: 'G4', dur: 1 }, { note: 'E4', dur: 1 }, { note: 'D4', dur: 1 },
{ note: 'C4', dur: 1 }, { note: 'E4', dur: 1 }, { note: 'G4', dur: 1 }, { note: 'E4', dur: 1 },
{ note: 'D4', dur: 1 }, { note: 'C4', dur: 3 }
];
const bassLine = [
{ note: 'C3', dur: 4 }, { note: 'A2', dur: 4 }, { note: 'C3', dur: 4 }, { note: 'A2', dur: 4 },
{ note: 'C3', dur: 4 }, { note: 'G2', dur: 4 }, { note: 'D3', dur: 4 }, { note: 'C3', dur: 4 }
];
let musicScheduleTimer = null;
let nextMelodyTime = 0;
let nextBassTime = 0;
var masterGain = null;
function initAudio() {
if (audioCtx) return;
try {
audioCtx = new (window.AudioContext || window.webkitAudioContext)();
masterGain = audioCtx.createGain();
masterGain.gain.value = 1.0;
masterGain.connect(audioCtx.destination);
musicInitialized = true;
} catch (e) {
console.warn('Web Audio not available:', e);
}
}
function playNote(freq, startTime, duration, type, gainVal) {
if (!audioCtx || freq === 0) return;
const osc = audioCtx.createOscillator();
const gain = audioCtx.createGain();
osc.type = type;
osc.frequency.setValueAtTime(freq, startTime);
// Envelope: attack then decay
gain.gain.setValueAtTime(0.001, startTime);
gain.gain.linearRampToValueAtTime(gainVal, startTime + 0.02);
gain.gain.setValueAtTime(gainVal, startTime + duration * 0.75);
gain.gain.linearRampToValueAtTime(0.001, startTime + duration * 0.95);
osc.connect(gain);
gain.connect(masterGain || audioCtx.destination);
osc.start(startTime);
osc.stop(startTime + duration);
}
function scheduleLoop() {
if (!musicPlaying || !audioCtx) return;
const bpm = 110;
const quarterDur = 60 / bpm;
const now = audioCtx.currentTime;
// Schedule melody
let t = now + 0.05;
for (const note of melody) {
const dur = note.dur * quarterDur;
if (note.note !== 'REST') {
playNote(NOTE_FREQ[note.note], t, dur, 'square', 0.06);
}
t += dur;
}
// Schedule bass
let tb = now + 0.05;
for (const note of bassLine) {
const dur = note.dur * quarterDur;
playNote(NOTE_FREQ[note.note], tb, dur, 'triangle', 0.08);
tb += dur;
}
// Total loop duration in beats: melody has 32 quarter beats = 32 * quarterDur
const totalBeats = 32;
const loopDurationMs = totalBeats * quarterDur * 1000;
musicScheduleTimer = setTimeout(scheduleLoop, loopDurationMs - 200);
}
function startMusic() {
if (!musicInitialized) initAudio();
if (!audioCtx) return;
if (audioCtx.state === 'suspended') audioCtx.resume();
if (masterGain) masterGain.gain.setValueAtTime(1.0, audioCtx.currentTime);
musicPlaying = true;
scheduleLoop();
updateMusicIndicator();
}
function stopMusic() {
musicPlaying = false;
if (musicScheduleTimer) {
clearTimeout(musicScheduleTimer);
musicScheduleTimer = null;
}
if (masterGain) masterGain.gain.setValueAtTime(0, audioCtx.currentTime);
updateMusicIndicator();
}
function toggleMusic() {
if (!musicInitialized) initAudio();
if (musicPlaying) {
stopMusic();
} else {
startMusic();
}
renderStatusBar();
}
function updateMusicIndicator() {
const el = document.getElementById('music-indicator');
if (!el) return;
el.textContent = musicPlaying ? 'M: Music ON' : 'M: Music OFF';
el.style.color = musicPlaying ? '#55FF55' : '#555555';
}
// =====================================================================
// SYNTAX HIGHLIGHTING (EGA Colors)
// =====================================================================
const TS_KEYWORDS = new Set([
'import', 'export', 'from', 'const', 'let', 'var', 'function', 'async',
'await', 'return', 'if', 'else', 'for', 'while', 'new', 'typeof', 'instanceof',
'interface', 'type', 'class', 'extends', 'implements', 'try', 'catch', 'throw',
'default', 'switch', 'case', 'break', 'continue', 'true', 'false', 'null',
'undefined', 'void', 'of', 'in'
]);
function escHtml(s) {
return s.replace(/&/g, '&').replace(//g, '>').replace(/"/g, '"');
}
function highlightCode(code) {
return code.split('\n').map(function(line) {
if (line.trim().startsWith('//')) {
return '' + escHtml(line) + '';
}
var result = '';
var i = 0;
while (i < line.length) {
// String literal
if (line[i] === "'" || line[i] === '"' || line[i] === '`') {
var quote = line[i];
var end = i + 1;
while (end < line.length && line[end] !== quote) {
if (line[end] === '\\') end++;
end++;
}
end = Math.min(end + 1, line.length);
result += '' + escHtml(line.substring(i, end)) + '';
i = end;
continue;
}
// Inline comment
if (line[i] === '/' && i + 1 < line.length && line[i + 1] === '/') {
result += '' + escHtml(line.substring(i)) + '';
break;
}
// Number
if (/\d/.test(line[i]) && (i === 0 || !/\w/.test(line[i - 1]))) {
var end2 = i;
while (end2 < line.length && /[\d._]/.test(line[end2])) end2++;
result += '' + escHtml(line.substring(i, end2)) + '';
i = end2;
continue;
}
// Identifier or keyword
if (/[a-zA-Z_$]/.test(line[i])) {
var end3 = i;
while (end3 < line.length && /[\w$]/.test(line[end3])) end3++;
var word = line.substring(i, end3);
if (TS_KEYWORDS.has(word)) {
result += '' + escHtml(word) + '';
} else {
result += '' + escHtml(word) + '';
}
i = end3;
continue;
}
// Punctuation / operators
result += '' + escHtml(line[i]) + '';
i++;
}
return result;
}).join('\n');
}
// =====================================================================
// TEXT PANEL RENDERING
// =====================================================================
var textPanel = document.getElementById('text-panel');
var statusBar = document.getElementById('status-bar');
function setTextPanel(html) {
textPanel.innerHTML = html;
textPanel.scrollTop = 0;
}
function renderTitleScreen() {
setTextPanel(
'' +
'
THE CODEREGON TRAIL
' +
'
The Exploit Trail - A Shannon Learning Adventure
' +
'
Press SPACE BAR to begin
' +
'
'
);
}
function renderSetupScreen() {
var profsHtml = '';
for (var i = 0; i < PROFESSIONS.length; i++) {
var p = PROFESSIONS[i];
var sel = (i === selectedDifficulty);
var color = sel ? '#FFFF55' : '#AAAAAA';
var marker = sel ? '>' : ' ';
var perks = 'HP:' + p.health + ' Hints:' + p.supplies;
if (p.hintFree) perks += ' (free hints!)';
profsHtml += '' +
marker + ' [' + (i + 1) + '] ' + p.name + '
';
profsHtml += '' + p.desc + ' ' + perks + '
';
}
var hearts = '\u2665\u2665\u2665';
setTextPanel(
'' +
'
Your party of key concepts:
' +
'
' +
'
1. Reconnaissance ' + hearts + '
' +
'
2. Exploitation ' + hearts + '
' +
'
3. Browser Automation ' + hearts + '
' +
'
4. Proof-by-Exploit ' + hearts + '
' +
'
' +
'
What is your profession?
' +
'
' + profsHtml + '
' +
'
Press ENTER to hit the trail!
' +
'
'
);
}
function renderTravelScreen() {
var flavor = travelFlavors[travelFlavorIndex % travelFlavors.length];
setTextPanel(
'' +
'
Day ' + day + '...
' +
'
' + escHtml(flavor) + '
' +
'
Press SPACE BAR to continue
' +
'
'
);
}
function renderStopScreen() {
var stop = TRAIL_DATA.stops[currentStop];
if (!stop) return;
var codeHtml = highlightCode(stop.code.content);
setTextPanel(
'' +
'
\u2550\u2550 ' + escHtml(stop.name) + ' \u2014 ' + escHtml(stop.subtitle) + ' \u2550\u2550
' +
'
[' + escHtml(stop.concept) + ']
' +
'
\u250C\u2500 ' + escHtml(stop.code.file) + ' \u2500\u2500\u2500
' +
'
' + codeHtml + '
' +
'
' + escHtml(stop.narration) + '
' +
'
' +
'[1] Continue' +
'[2] Examine code' +
'
' +
'
'
);
}
function renderEventScreen(event) {
var isRiver = event.type === 'river';
var isFortune = event.type === 'fortune';
var typeLabel, headerColor;
if (isRiver) {
typeLabel = '\uD83C\uDF0A RIVER CROSSING';
headerColor = '#5555FF';
} else if (event.type === 'weather') {
typeLabel = '\u26A1 WEATHER';
headerColor = '#FFFF55';
} else if (event.type === 'encounter') {
typeLabel = '\uD83D\uDC64 ENCOUNTER';
headerColor = '#FFFF55';
} else if (event.type === 'misfortune') {
typeLabel = '\u2620 MISFORTUNE';
headerColor = '#FF5555';
} else {
typeLabel = '\u2728 FORTUNE';
headerColor = '#55FF55';
}
var choicesHtml = '';
if (event.choices.length > 0 && !eventAnswered) {
var labels = ['A', 'B', 'C', 'D'];
for (var i = 0; i < shuffledIndices.length; i++) {
var origIdx = shuffledIndices[i];
var dimmedClass = (eventHinted && dimmedChoice === i) ? ' choice-dimmed' : '';
var selClass = (selectedEventChoice === i) ? ' choice-selected' : '';
choicesHtml += ' ' + labels[i] + '. ' + escHtml(event.choices[origIdx].text) + '
';
}
var hintAvail = (PROFESSIONS[difficulty] && PROFESSIONS[difficulty].hintFree) || supplies > 0;
var hintLabel = (PROFESSIONS[difficulty] && PROFESSIONS[difficulty].hintFree) ? 'H = Free Hint' : 'H = Hint (-1 supply)';
choicesHtml += 'Arrow keys + Enter to choose' + (hintAvail ? ' | ' + hintLabel : '') + '
';
} else if (isFortune) {
choicesHtml = '+20 Health restored!
' +
'Press SPACE BAR to continue
';
}
setTextPanel(
'' +
'
' + typeLabel + ' \u2014 ' + escHtml(event.title) + '
' +
'
' + escHtml(event.text) + '
' +
'
' + choicesHtml + '
' +
'
' +
'
'
);
}
function showEventResult(event, displayIdx) {
var origIdx = shuffledIndices[displayIdx];
var choice = event.choices[origIdx];
var correct = choice.correct;
var isRiver = event.type === 'river';
var resultHtml = '';
if (correct) {
var bonus = streak >= 3 ? ' (+5 streak bonus!)' : '';
resultHtml = 'CORRECT! +10 Health' + bonus + '
';
} else {
var dmg = isRiver ? 20 : 15;
resultHtml = 'WRONG! -' + dmg + ' Health
';
document.getElementById('game-container').classList.add('shake');
setTimeout(function() { document.getElementById('game-container').classList.remove('shake'); }, 500);
}
resultHtml += '' + escHtml(choice.explanation) + '
';
resultHtml += 'Press SPACE BAR to continue
';
// Update choices display in shuffled order
var labels = ['A', 'B', 'C', 'D'];
var choicesHtml = '';
for (var i = 0; i < shuffledIndices.length; i++) {
var oi = shuffledIndices[i];
var c = event.choices[oi];
var cls = c.correct ? 'choice-correct' : (i === displayIdx ? 'choice-wrong' : 'text-gray');
var marker = c.correct ? '\u2713' : (i === displayIdx ? '\u2717' : ' ');
choicesHtml += '' + marker + ' ' + labels[i] + '. ' + escHtml(c.text) + '
';
}
var choicesEl = document.getElementById('event-choices');
if (choicesEl) choicesEl.innerHTML = choicesHtml;
var resultEl = document.getElementById('event-result');
if (resultEl) resultEl.innerHTML = resultHtml;
}
function renderDeathScreen() {
var deathMsg;
if (techDebtDeath) {
var techDebtMsgs = [
'You have died of tech debt dysentery.',
];
deathMsg = techDebtMsgs[Math.floor(Math.random() * techDebtMsgs.length)];
techDebtDeath = false;
} else {
deathMsg = TRAIL_DATA.deathMessages[Math.floor(Math.random() * TRAIL_DATA.deathMessages.length)];
}
setTextPanel(
'' +
'
' +
'' +
'
' +
'
Stops: ' + currentStop + '/' + TRAIL_DATA.stops.length + ' Score: ' + score + '/' + totalQuestions + ' Streak: ' + bestStreak + '
' +
'
Press SPACE BAR to try again
' +
'
'
);
// Typewriter animation
typewriterText = deathMsg;
typewriterIndex = 0;
if (typewriterTimer) clearInterval(typewriterTimer);
typewriterTimer = setInterval(function() {
var el = document.getElementById('death-text');
if (!el) { clearInterval(typewriterTimer); return; }
typewriterIndex++;
el.textContent = typewriterText.substring(0, typewriterIndex);
if (typewriterIndex >= typewriterText.length) clearInterval(typewriterTimer);
}, 50);
}
function renderWinScreen() {
var survivors = partyHealth.filter(function(h) { return h > 0; }).length;
var pctScore = totalQuestions > 0 ? Math.round((score / totalQuestions) * 100) : 100;
var conceptNames = ['Reconnaissance', 'Exploitation', 'Browser Automation', 'Proof-by-Exploit'];
var masteryHtml = '';
var weakestName = '';
var weakestHp = 999;
for (var i = 0; i < conceptNames.length; i++) {
var hp = partyHealth[i];
var maxHp = TRAIL_DATA.partyMembers[i].maxHealth;
var stars, color;
if (hp <= 0) {
stars = '\u2606\u2606\u2606 (died)';
color = '#555555';
} else {
stars = '';
for (var s = 0; s < hp; s++) stars += '\u2605';
for (var s2 = 0; s2 < maxHp - hp; s2++) stars += '\u2606';
color = '#FFFF55';
}
if (hp < weakestHp) { weakestHp = hp; weakestName = conceptNames[i]; }
masteryHtml += ' ' + conceptNames[i] + ' ' + stars + '
';
}
var tip = weakestHp < TRAIL_DATA.partyMembers[0].maxHealth ? '"Study up on ' + weakestName + '!"' : '"Perfect understanding!"';
setTextPanel(
'' +
'
\u2605 YOU MADE IT TO RESPONSE FRONTIER! \u2605
' +
'
' +
'
Framework: Shannon (The Exploit Trail)
' +
'
Survivors: ' + survivors + '/' + partyHealth.length + '
' +
'
Quiz Score: ' + score + '/' + totalQuestions + ' (' + pctScore + '%)
' +
'
Best Streak: ' + bestStreak + '
' +
'
Hints Used: ' + hintsUsed + '
' +
'
' +
'
Concept Mastery:
' +
'
' + masteryHtml + '
' +
'
' + tip + '
' +
'
Press SPACE BAR to play again
' +
'
'
);
}
// =====================================================================
// STATUS BAR
// =====================================================================
function renderStatusBar() {
if (gameState === STATES.TITLE) {
var titleMusic = musicPlaying
? 'Music: On (M to toggle off)'
: 'Music: Off (M to toggle on)';
statusBar.innerHTML = 'The Coderegon Trail v1.0 | ' + titleMusic;
return;
}
var maxH = maxHealthForGame;
var healthPct = Math.max(0, health / maxH);
var barLen = 10;
var filled = Math.round(healthPct * barLen);
var healthColor = health > maxH * 0.6 ? '#00AA00' : (health > maxH * 0.3 ? '#FFFF55' : '#FF5555');
var healthBar = '';
for (var i = 0; i < filled; i++) healthBar += '\u2588';
healthBar += '';
for (var i2 = 0; i2 < barLen - filled; i2++) healthBar += '\u2591';
healthBar += '';
var streakStars = '';
for (var s = 0; s < Math.min(streak, 5); s++) streakStars += '\u2605';
streakStars += '';
var musicHint = musicPlaying
? 'Music: On (M to toggle off)'
: 'Music: Off (M to toggle on)';
statusBar.innerHTML = 'HP: ' + healthBar + ' ' + Math.max(0, health) +
' Hints: ' + supplies + ' ' + streakStars +
' | Stop ' + (currentStop + 1) + '/' + TRAIL_DATA.stops.length +
' | ' + musicHint;
}
// =====================================================================
// GAME LOGIC
// =====================================================================
function resetGame() {
gameState = STATES.TITLE;
difficulty = 1;
selectedDifficulty = 1;
health = 100;
maxHealthForGame = 100;
supplies = 5;
currentStop = 0;
day = 1;
score = 0;
totalQuestions = 0;
streak = 0;
bestStreak = 0;
hintsUsed = 0;
scrollX = 0;
showRiver = false;
wagonWheelAngle = 0;
travelFlavorIndex = 0;
deathPending = false;
partyHealth = TRAIL_DATA.partyMembers.map(function(p) { return p.maxHealth; });
pendingEvents = [];
currentEventIndex = 0;
eventAnswered = false;
eventHinted = false;
dimmedChoice = -1;
selectedEventChoice = 0;
selectedStopChoice = 0;
shuffledIndices = [];
currentEventType = '';
currentEventTitle = '';
drainAccumulator = 0;
techDebtDeath = false;
if (travelTimer) { clearTimeout(travelTimer); travelTimer = null; }
if (typewriterTimer) { clearInterval(typewriterTimer); typewriterTimer = null; }
}
function selectDifficulty(idx) {
selectedDifficulty = Math.max(0, Math.min(PROFESSIONS.length - 1, idx));
renderSetupScreen();
}
function startGame() {
difficulty = selectedDifficulty;
var prof = PROFESSIONS[difficulty];
health = prof.health;
maxHealthForGame = prof.health;
supplies = prof.supplies;
partyHealth = TRAIL_DATA.partyMembers.map(function(p) { return p.maxHealth; });
currentStop = 0;
day = 1;
score = 0;
totalQuestions = 0;
streak = 0;
bestStreak = 0;
hintsUsed = 0;
scrollX = 0;
deathPending = false;
enterTravel();
}
function enterTravel() {
gameState = STATES.TRAVEL;
showRiver = false;
currentEventType = '';
currentEventTitle = '';
day += Math.floor(Math.random() * 7) + 1;
travelFlavorIndex = Math.floor(Math.random() * travelFlavors.length);
renderTravelScreen();
renderStatusBar();
if (travelTimer) clearTimeout(travelTimer);
travelTimer = setTimeout(function() {
if (gameState === STATES.TRAVEL) advanceFromTravel();
}, 2500);
}
function advanceFromTravel() {
if (travelTimer) { clearTimeout(travelTimer); travelTimer = null; }
// Collect events for the current stop (triggerStop is 1-indexed relative to stop we just visited)
// Events trigger BEFORE showing the stop
pendingEvents = TRAIL_DATA.events.filter(function(e) { return e.triggerStop === currentStop; });
currentEventIndex = 0;
if (pendingEvents.length > 0) {
showNextEvent();
} else {
enterStop();
}
}
function showNextEvent() {
if (currentEventIndex >= pendingEvents.length) {
// After all events, check if we should die
if (checkDeath()) return;
enterStop();
return;
}
var event = pendingEvents[currentEventIndex];
eventAnswered = false;
eventHinted = false;
dimmedChoice = -1;
selectedEventChoice = 0;
currentEventType = event.type;
currentEventTitle = event.title;
// Shuffle choice order so correct answer isn't always first
shuffledIndices = [];
for (var si = 0; si < event.choices.length; si++) shuffledIndices.push(si);
for (var si = shuffledIndices.length - 1; si > 0; si--) {
var sj = Math.floor(Math.random() * (si + 1));
var tmp = shuffledIndices[si]; shuffledIndices[si] = shuffledIndices[sj]; shuffledIndices[sj] = tmp;
}
if (event.type === 'river') {
gameState = STATES.RIVER;
showRiver = true;
} else {
gameState = STATES.EVENT;
showRiver = false;
}
// Fortune events auto-apply health
if (event.type === 'fortune') {
health = Math.min(health + 20, maxHealthForGame);
eventAnswered = true;
}
renderEventScreen(event);
renderStatusBar();
}
function handleEventChoice(displayIdx) {
if (eventAnswered) return;
var event = pendingEvents[currentEventIndex];
if (!event || event.choices.length === 0) return;
if (displayIdx < 0 || displayIdx >= shuffledIndices.length) return;
if (eventHinted && dimmedChoice === displayIdx) return;
var prof = PROFESSIONS[difficulty];
var forgiving = prof && prof.forgiving;
var origIdx = shuffledIndices[displayIdx];
var choice = event.choices[origIdx];
var isRiver = event.type === 'river';
if (forgiving && !choice.correct) {
// Ralph Wiggum mode: dim the wrong answer, let them try again
var items = document.querySelectorAll('.choice-item');
if (items[displayIdx]) {
items[displayIdx].classList.add('choice-dimmed');
items[displayIdx].style.pointerEvents = 'none';
}
// Move selection to next available choice
for (var ni = 0; ni < shuffledIndices.length; ni++) {
var nextIdx = (displayIdx + 1 + ni) % shuffledIndices.length;
if (!(eventHinted && dimmedChoice === nextIdx) && !items[nextIdx].classList.contains('choice-dimmed')) {
selectedEventChoice = nextIdx;
updateChoiceHighlight();
break;
}
}
return;
}
eventAnswered = true;
totalQuestions++;
if (choice.correct) {
var bonus = streak >= 3 ? 5 : 0;
health = Math.min(health + 10 + bonus, maxHealthForGame);
score++;
streak++;
if (streak > bestStreak) bestStreak = streak;
} else {
var dmg = isRiver ? 20 : 15;
health -= dmg;
streak = 0;
// Damage related party member
var concept = event.concept;
var memberIdx = -1;
for (var mi = 0; mi < TRAIL_DATA.partyMembers.length; mi++) {
if (TRAIL_DATA.partyMembers[mi].name === concept) { memberIdx = mi; break; }
}
if (memberIdx >= 0 && partyHealth[memberIdx] > 0) {
partyHealth[memberIdx]--;
}
}
showEventResult(event, displayIdx);
renderStatusBar();
}
function handleEventHint() {
var prof = PROFESSIONS[difficulty];
var hintFree = prof && prof.hintFree;
if (eventAnswered || eventHinted) return;
if (!hintFree && supplies <= 0) return;
var event = pendingEvents[currentEventIndex];
if (!event || event.choices.length === 0) return;
if (!hintFree) supplies--;
hintsUsed++;
eventHinted = true;
// Find a wrong answer in display order (dimmedChoice is a display index)
var wrongDisplayIndices = [];
for (var i = 0; i < shuffledIndices.length; i++) {
if (!event.choices[shuffledIndices[i]].correct) wrongDisplayIndices.push(i);
}
dimmedChoice = wrongDisplayIndices[Math.floor(Math.random() * wrongDisplayIndices.length)];
renderEventScreen(event);
renderStatusBar();
}
function updateChoiceHighlight() {
var items = document.querySelectorAll('.choice-item');
for (var i = 0; i < items.length; i++) {
items[i].classList.toggle('choice-selected', i === selectedEventChoice);
}
}
function updateStopHighlight() {
var items = document.querySelectorAll('.stop-opt');
for (var i = 0; i < items.length; i++) {
items[i].classList.toggle('choice-selected', i === selectedStopChoice);
}
}
function checkDeath() {
if (health <= 0) {
enterDeath();
return true;
}
var allDead = true;
for (var i = 0; i < partyHealth.length; i++) {
if (partyHealth[i] > 0) { allDead = false; break; }
}
if (allDead) {
enterDeath();
return true;
}
return false;
}
function advanceFromEvent() {
currentEventIndex++;
if (currentEventIndex < pendingEvents.length) {
if (checkDeath()) return;
showNextEvent();
} else {
if (checkDeath()) return;
enterStop();
}
}
function enterStop() {
gameState = STATES.STOP;
showRiver = false;
selectedStopChoice = 0;
var stop = TRAIL_DATA.stops[currentStop];
if (stop && stop.landmarkType === 'river') {
showRiver = true;
}
renderStopScreen();
renderStatusBar();
}
function handleStopChoice(choice) {
if (choice === 2) {
var codeBox = textPanel.querySelector('.code-box');
if (codeBox) {
codeBox.style.maxHeight = '280px';
codeBox.scrollIntoView({ behavior: 'smooth' });
}
return;
}
currentStop++;
if (currentStop >= TRAIL_DATA.stops.length) {
enterWin();
} else {
enterTravel();
}
}
function enterDeath() {
gameState = STATES.DEATH;
showRiver = false;
renderDeathScreen();
renderStatusBar();
}
function enterWin() {
gameState = STATES.WIN;
showRiver = false;
renderWinScreen();
renderStatusBar();
}
// =====================================================================
// INPUT HANDLING
// =====================================================================
function ensureAudio() {
if (!musicInitialized) {
initAudio();
startMusic();
}
}
document.addEventListener('keydown', function(e) {
var key = e.key;
// Music toggle always works
if (key === 'm' || key === 'M') {
if (!musicInitialized) initAudio();
toggleMusic();
return;
}
// Start audio on first interaction
if (!musicInitialized) ensureAudio();
switch (gameState) {
case STATES.TITLE:
if (key === ' ' || key === 'Enter') {
e.preventDefault();
gameState = STATES.SETUP;
renderSetupScreen();
renderStatusBar();
}
break;
case STATES.SETUP:
if (key === 'ArrowDown' || key === 'ArrowRight') {
e.preventDefault();
selectDifficulty((selectedDifficulty + 1) % PROFESSIONS.length);
} else if (key === 'ArrowUp' || key === 'ArrowLeft') {
e.preventDefault();
selectDifficulty((selectedDifficulty - 1 + PROFESSIONS.length) % PROFESSIONS.length);
} else if (key === '1') selectDifficulty(0);
else if (key === '2') selectDifficulty(1);
else if (key === '3') selectDifficulty(2);
else if (key === '4') selectDifficulty(3);
else if (key === 'Enter' || key === ' ') {
e.preventDefault();
startGame();
}
break;
case STATES.TRAVEL:
if (key === ' ' || key === 'Enter') {
e.preventDefault();
advanceFromTravel();
}
break;
case STATES.STOP:
if (key === 'ArrowLeft' || key === 'ArrowRight') {
e.preventDefault();
selectedStopChoice = (key === 'ArrowLeft') ? 0 : 1;
updateStopHighlight();
} else if (key === 'ArrowUp' || key === 'ArrowDown') {
e.preventDefault();
selectedStopChoice = selectedStopChoice === 0 ? 1 : 0;
updateStopHighlight();
} else if (key === 'Enter' || key === ' ') {
e.preventDefault();
handleStopChoice(selectedStopChoice + 1);
} else if (key === '1') {
handleStopChoice(1);
} else if (key === '2') {
handleStopChoice(2);
}
break;
case STATES.EVENT:
case STATES.RIVER:
if (!eventAnswered) {
var ev = pendingEvents[currentEventIndex];
if (ev && ev.choices.length > 0) {
var numChoices = ev.choices.length;
if (key === 'ArrowDown' || key === 'ArrowRight') {
e.preventDefault();
selectedEventChoice = (selectedEventChoice + 1) % numChoices;
// Skip dimmed choice
if (eventHinted && dimmedChoice === selectedEventChoice) selectedEventChoice = (selectedEventChoice + 1) % numChoices;
updateChoiceHighlight();
} else if (key === 'ArrowUp' || key === 'ArrowLeft') {
e.preventDefault();
selectedEventChoice = (selectedEventChoice - 1 + numChoices) % numChoices;
if (eventHinted && dimmedChoice === selectedEventChoice) selectedEventChoice = (selectedEventChoice - 1 + numChoices) % numChoices;
updateChoiceHighlight();
} else if (key === 'Enter' || key === ' ') {
e.preventDefault();
if (!(eventHinted && dimmedChoice === selectedEventChoice)) {
handleEventChoice(selectedEventChoice);
}
} else if (key === '1' || key === 'a' || key === 'A') handleEventChoice(0);
else if (key === '2' || key === 'b' || key === 'B') handleEventChoice(1);
else if (key === '3' || key === 'c' || key === 'C') handleEventChoice(2);
else if (key === 'h' || key === 'H') handleEventHint();
}
// Fortune: space/enter to continue
if (ev && ev.type === 'fortune' && (key === ' ' || key === 'Enter')) {
e.preventDefault();
advanceFromEvent();
}
} else {
if (key === ' ' || key === 'Enter') {
e.preventDefault();
if (checkDeath()) return;
advanceFromEvent();
}
}
break;
case STATES.DEATH:
if (key === ' ' || key === 'Enter') {
e.preventDefault();
resetGame();
renderTitleScreen();
renderStatusBar();
}
break;
case STATES.WIN:
if (key === ' ' || key === 'Enter') {
e.preventDefault();
resetGame();
renderTitleScreen();
renderStatusBar();
}
break;
}
});
// =====================================================================
// ANIMATION LOOP + TECH DEBT DRAIN
// =====================================================================
var lastTime = 0;
var drainAccumulator = 0;
var DRAIN_INTERVAL = 3000; // drain 1 HP every 3 seconds
var techDebtDeath = false;
function gameLoop(timestamp) {
var dt = timestamp - lastTime;
lastTime = timestamp;
// Clamp dt to avoid huge jumps
if (dt > 100) dt = 16;
if (gameState === STATES.TRAVEL) {
scrollX += dt * 0.03;
wagonWheelAngle += dt * 0.004;
} else if (gameState === STATES.TITLE) {
scrollX += dt * 0.01;
} else {
// Gentle idle scroll
scrollX += dt * 0.002;
}
// Tech debt drain: HP ticks down during active gameplay (disabled for Ralph Wiggum)
var isForgiving = PROFESSIONS[difficulty] && PROFESSIONS[difficulty].forgiving;
var isActive = gameState === STATES.STOP || gameState === STATES.EVENT || gameState === STATES.RIVER;
if (isActive && !deathPending && !isForgiving) {
drainAccumulator += dt;
while (drainAccumulator >= DRAIN_INTERVAL) {
drainAccumulator -= DRAIN_INTERVAL;
health--;
renderStatusBar();
if (health <= 0) {
techDebtDeath = true;
enterDeath();
break;
}
}
}
renderCanvas(timestamp);
animFrame = requestAnimationFrame(gameLoop);
}
// =====================================================================
// INITIALIZATION
// =====================================================================
resetGame();
renderTitleScreen();
renderStatusBar();
updateMusicIndicator();
animFrame = requestAnimationFrame(gameLoop);