// main.js — the router. 64 hops, one TTL, no way back up except through. import * as THREE from '../vendor/three.module.js'; import * as A from './audio.js'; import * as In from './input.js'; import * as HUD from './hud.js'; import { Link } from './link.js'; import { World } from './kit.js'; import { lookup as phileLookup, TOTAL as PHILE_TOTAL } from './philes.js'; import coldboot from './levels/coldboot.js'; import stack from './levels/stack.js'; import timewait from './levels/timewait.js'; import war from './levels/war.js'; import smash from './levels/smash.js'; import worm from './levels/worm.js'; import cuckoo from './levels/cuckoo.js'; import board from './levels/board.js'; import shell from './levels/shell.js'; import handshake from './levels/handshake.js'; import copper from './levels/copper.js'; import bluebox from './levels/bluebox.js'; import climb from './levels/climb.js'; import root from './levels/root.js'; export const ORDER = [ coldboot, stack, timewait, war, smash, worm, cuckoo, board, shell, handshake, copper, bluebox, climb, root, ]; const BY_ID = Object.fromEntries(ORDER.map(l => [l.id, l])); const canvas = document.getElementById('gl'); const termCanvas = document.getElementById('term'); const renderer = new THREE.WebGLRenderer({ canvas, antialias: false, powerPreference: 'high-performance' }); renderer.setClearColor(0x000000, 1); renderer.autoClear = true; const link = new Link(renderer); In.attach(canvas); HUD.boot(); // ───────────────────────────────────────────────────────────── game state export const game = { ttl: 64, hop: 1, level: null, flags: Object.create(null), // what you learned, what you carry, who you met philes: new Set(), // the collectibles. the writing lives here. cargo: [], // BBS files. affects MTU. mtu: 1500, deaths: 0, reached: new Set(['coldboot']), paused: false, time: 0, }; // dev handle. HS.run(n) drives n frames by hand when rAF is asleep. window.HS = { game, link, A, In, HUD, THREE, goto: (id) => goto(id), run(n = 60, dt = 1 / 60) { for (let i = 0; i < n; i++) step(dt); }, }; function refreshHud() { HUD.status({ ttl: game.ttl, hop: game.hop, hops: 64, link: link.label, extra: game.cargo.length ? `MTU ${game.mtu} · ${game.cargo.length} file${game.cargo.length > 1 ? 's' : ''}` : '', }); } // How many pieces you arrive in. Two files is free; after that the network starts // charging you for the privilege of being interesting. export function fragments() { game.mtu = Math.max(400, 1500 - game.cargo.length * 180); if (game.cargo.length <= 2) return 1; return 1 + Math.ceil((game.cargo.length - 2) / 3); } let fragNote = null; // TTL only ever goes down. There is no item for this. export function spendTTL(n = 1, why = '') { game.ttl -= n; refreshHud(); if (why) HUD.say(`${why}  −${n} TTL`, 1800); if (game.ttl <= 0) expire(); return game.ttl; } let expiring = false; async function expire() { if (expiring) return; expiring = true; game.deaths++; A.noCarrier(); link.glitch = 0.9; await HUD.flash('NO CARRIER', 'nc', 2400); link.glitch = 0; // ICMP Time Exceeded is sent to where you came from. It always is. HUD.say('icmp time exceeded — in transit', 2600); game.ttl = Math.max(12, 65 - game.hop); expiring = false; await goto(game.level.id, { restart: true }); } export { expire }; // ────────────────────────────────────────────────────────────── the loop const ctx = { THREE, A, In, HUD, link, game, renderer, scene: null, camera: null, world: null, term: null, termCanvas, spendTTL, refreshHud: () => refreshHud(), setBaud(i) { link.setBaud(i); refreshHud(); return link.spec; }, goto: (id, o) => goto(id, o), next: () => { const i = ORDER.indexOf(game.level); return goto(ORDER[Math.min(ORDER.length - 1, i + 1)].id); }, say: HUD.say, speak: HUD.speak, hint: HUD.hint, card: HUD.card, sleep: HUD.sleep, hush: HUD.hush, flash: HUD.flash, expire: () => expire(), phile(id, title, body) { if (game.philes.has(id)) return false; game.philes.add(id); const p = phileLookup(id, title, body); HUD.say(`phile ${p.title} [${game.philes.size}/${PHILE_TOTAL}]   P to read`, 3000); A.blip(1800, 0.05, 0.05); setTimeout(() => A.blip(2400, 0.06, 0.05), 70); phileLog.push(p); return true; }, }; const phileLog = []; window.HS.philes = phileLog; window.HS.ctx = ctx; function disposeScene(s) { if (!s) return; s.traverse(o => { if (o.geometry) o.geometry.dispose(); const mats = o.material ? (Array.isArray(o.material) ? o.material : [o.material]) : []; for (const m of mats) { for (const k of ['map', 'alphaMap', 'emissiveMap']) if (m[k] && m[k].dispose && !m[k].__keep) m[k].dispose(); m.dispose(); } }); } let switching = false; export async function goto(id, { restart = false } = {}) { if (switching) return; switching = true; const nextLevel = BY_ID[id]; if (!nextLevel) { console.warn('no level', id); switching = false; return; } HUD.hint(''); HUD.hush(); await HUD.veil(1, 420); A.stopPad(); if (game.level && game.level.unmount) { try { await game.level.unmount(ctx); } catch (e) { console.error(e); } } In.releaseText(); In.clearRemap(); In.unlock(); ctx.splitCam = null; termCanvas.style.display = 'none'; disposeScene(ctx.scene); ctx.scene = new THREE.Scene(); ctx.camera = new THREE.PerspectiveCamera(72, link.aspect, 0.05, 4000); ctx.world = new World(); game.level = nextLevel; game.reached.add(id); if (!restart) game.hop = nextLevel.hop; game.ttl = Math.max(1, Math.min(game.ttl, 65 - game.hop)); // ── MTU. Carry too much and you do not fit down the next hop in one piece. // Everything you took off the board is lore, and lore is paid for in distance. if (!restart) { const frags = fragments(); if (frags > 1) { game.ttl -= (frags - 1); fragNote = `fragmented ${game.mtu} byte MTU · ${frags} pieces · −${frags - 1} TTL`; if (game.ttl <= 0) { expire(); switching = false; return; } } else fragNote = null; } if (nextLevel.baud !== undefined && nextLevel.baud !== null) link.setBaud(nextLevel.baud); link.noTint(); if (nextLevel.tint) link.tint(nextLevel.tint[0], nextLevel.tint[1]); document.body.classList.toggle('ink-dark', nextLevel.ink === 'dark'); refreshHud(); HUD.showHud(nextLevel.hud !== false); // A level builds its scene synchronously and only then awaits its intro card, so // start lifting the veil as soon as mount is under way — otherwise the player // stares at a black screen for the length of the card that is playing behind it. const mounting = (async () => { try { await nextLevel.mount(ctx); } catch (e) { console.error('mount failed:', id, e); } })(); HUD.veil(0, 520); await mounting; refreshHud(); saveGame(); if (fragNote) { HUD.say(fragNote, 3600); fragNote = null; } switching = false; } let last = performance.now(); function frame(now) { requestAnimationFrame(frame); const dt = Math.min(0.05, (now - last) / 1000); last = now; step(dt); } // One tick, separated from rAF so it can be driven by hand — rAF is throttled to // zero in a hidden tab, which makes the game look broken when it is only asleep. export function step(dt) { game.time += dt; if (In.hit('Escape')) { In.unlock(); if (readerOpen) toggleReader(); } if (In.hit('KeyM') && !In.hasTextCapture()) toggleMap(); if (In.hit('KeyP') && !In.hasTextCapture()) toggleReader(); if (In.hit('Backquote')) toggleDev(); if (!game.paused && game.level && game.level.update) { try { game.level.update(dt, game.time, ctx); } catch (e) { console.error(e); game.level.update = null; } } if (ctx.term && termCanvas.style.display !== 'none') ctx.term.draw(game.time); if (ctx.scene && ctx.camera) { // a level can ask for two viewports by setting ctx.splitCam if (ctx.splitCam) link.renderSplit(ctx.scene, ctx.camera, ctx.splitCam, game.time); else link.render(ctx.scene, ctx.camera, game.time); } In.endFrame(); } addEventListener('resize', () => { link.resize(); if (ctx.camera && ctx.camera.isPerspectiveCamera) { ctx.camera.aspect = link.aspect; ctx.camera.updateProjectionMatrix(); } if (ctx.term) ctx.term.resize(); }); // ─────────────────────────────────────────────────── traceroute level select const mapEl = document.getElementById('map'); let mapOpen = false; function toggleMap() { mapOpen = !mapOpen; game.paused = mapOpen; mapEl.style.display = mapOpen ? 'flex' : 'none'; if (mapOpen) { In.unlock(); const rows = ORDER.map((l, i) => { const seen = game.reached.has(l.id); const here = game.level === l; const hopn = String(l.hop).padStart(2, '0'); const name = seen ? l.title : '* * *'; const via = seen ? l.via : ''; return `
${hopn} ${name} ${via || ''} ${seen ? (l.ms || (12 + i * 17) + ' ms') : ''}
`; }).join(''); const frags = fragments(); const cargo = game.cargo.length ? `
carrying ${game.cargo.length} · MTU ${game.mtu} · ${frags > 1 ? `fragmenting into ${frags}, −${frags - 1} TTL every hop` : 'fits in one piece'}
` + game.cargo.map((f, i) => `${f} ✕`).join(' ') + '
' : ''; mapEl.innerHTML = `
traceroute to root (0.0.0.0), 64 hops max, ${game.mtu} byte packets
${rows} ${cargo}
TTL ${game.ttl} remaining · ${game.philes.size} philes · ${game.deaths} expiries
click a reached hop to return · P for philes · M to close
`; mapEl.querySelectorAll('.row.seen').forEach(r => r.addEventListener('click', () => { const id = r.dataset.id; toggleMap(); goto(id); })); // you can put something down. that is the whole trade. mapEl.querySelectorAll('.drop').forEach(el => el.addEventListener('click', () => { game.cargo.splice(+el.dataset.c, 1); fragments(); refreshHud(); saveGame(); toggleMap(); toggleMap(); })); } } // ────────────────────────────────────────────────────── the phile reader // You spend the game collecting text files. There has to be somewhere to read them. const readerEl = document.getElementById('reader'); let readerOpen = false, readerAt = 0; function toggleReader() { readerOpen = !readerOpen; game.paused = readerOpen; readerEl.style.display = readerOpen ? 'flex' : 'none'; if (readerOpen) { In.unlock(); drawReader(); } } function drawReader() { if (!phileLog.length) { readerEl.innerHTML = `
philes 0/${PHILE_TOTAL}
you have not picked anything up yet.

they are lying around. nothing tells you where.
P to close
`; return; } readerAt = Math.max(0, Math.min(phileLog.length - 1, readerAt)); const p = phileLog[readerAt]; const list = phileLog.map((q, i) => `
${q.title}
`).join(''); readerEl.innerHTML = `
philes ${phileLog.length}/${PHILE_TOTAL}
${list}

${p.title}

${p.from ? `
${p.from}
` : ''}
${p.body || '(no text)'}
click a title · P to close
`; readerEl.querySelectorAll('.it').forEach(el => el.addEventListener('click', () => { readerAt = +el.dataset.i; drawReader(); })); } // ─────────────────────────────────────────────── ESTABLISHED — the save // You do not save. You connect. Sitting still long enough to complete a handshake // with the world is your checkpoint — which in practice means every hop, because // arriving somewhere is the only moment the state is worth anything. const SAVE_KEY = 'handshake.save.v1'; function saveGame() { try { localStorage.setItem(SAVE_KEY, JSON.stringify({ level: game.level ? game.level.id : 'coldboot', ttl: game.ttl, hop: game.hop, baud: link.index, philes: [...game.philes], phileLog, cargo: game.cargo, mtu: game.mtu, flags: game.flags, deaths: game.deaths, })); } catch (e) { /* private browsing, a full disk, somebody's kiosk. not worth dying over. */ } } export function loadSave() { try { return JSON.parse(localStorage.getItem(SAVE_KEY) || 'null'); } catch (e) { return null; } } function applySave(s) { game.ttl = s.ttl; game.hop = s.hop; game.philes = new Set(s.philes || []); phileLog.length = 0; (s.phileLog || []).forEach(p => phileLog.push(p)); game.cargo = s.cargo || []; game.mtu = s.mtu ?? 1500; game.flags = s.flags || Object.create(null); game.deaths = s.deaths || 0; link.setBaud(s.baud ?? 6); ORDER.forEach(l => { if (l.hop <= s.hop) game.reached.add(l.id); }); } export function wipeSave() { try { localStorage.removeItem(SAVE_KEY); } catch (e) {} } // ─────────────────────────────────────────────────────────────── dev const devEl = document.getElementById('dev'); let devOpen = false; function toggleDev() { devOpen = !devOpen; devEl.style.display = devOpen ? 'block' : 'none'; if (devOpen) { devEl.innerHTML = ORDER.map((l, i) => ``).join('') + `
baud: ${[0,1,2,3,4,5,6].map(b => ``).join('')}
`; devEl.querySelectorAll('[data-id]').forEach(b => b.onclick = () => { toggleDev(); goto(b.dataset.id); }); devEl.querySelectorAll('[data-baud]').forEach(b => b.onclick = () => { link.setBaud(+b.dataset.baud); refreshHud(); }); } } // ─────────────────────────────────────────────────────────────── boot const bootEl = document.getElementById('boot'); const save = loadSave(); if (save) { const l = ORDER.find(x => x.id === save.level); document.getElementById('resume').innerHTML = `R — RESUME   hop ${String(save.hop).padStart(2, '0')} · ${l ? l.title : save.level} · TTL ${save.ttl} · ${(save.philes || []).length} philes`; document.getElementById('resume').style.display = 'block'; } let started = false; async function start(resume) { if (started) return; started = true; bootEl.style.display = 'none'; A.boot(); A.resume(); requestAnimationFrame(frame); if (resume && save) { applySave(save); await goto(save.level, { restart: true }); } else { wipeSave(); await goto('coldboot'); } } bootEl.addEventListener('click', () => start(false)); addEventListener('keydown', function once(e) { if (started) { removeEventListener('keydown', once); return; } if (e.key === 'Tab') return; removeEventListener('keydown', once); start(save && e.key.toLowerCase() === 'r'); }); // carrier detect blinking on the boot screen, because of course { const cd = document.getElementById('cd'); setInterval(() => { if (cd) cd.style.opacity = cd.style.opacity === '0.15' ? '1' : '0.15'; }, 620); }