This guide collects every durable thing a person working in index.html needs to know that is not derivable from the source. For everyday context (what the game is, how to play it, what it teaches) see the docs index, the Student Guide, and the Faculty Guide. The authoritative quick-reference is README.md and the CLAUDE.md at the repo root - this page is its long-form companion.

Repo layout

hostbound/
  index.html          ← the entire game (CSS + JS + Canvas, embedded)
  README.md           ← short overview / quick reference
  CLAUDE.md           ← durable context for code agents working here
  LICENSE
  git-go.bat          ← user-run add/commit/push helper (do not invoke yourself)
  images/             ← preloaded sprites and the favicon
    ducky-small-canvas-clear-ssh.png   (favicon + win/About logo)
    switch.png                          (LAN switch icon)
    client-{blue,green,orange,purple,red,yellow}.png  (host icons; picked deterministically per host via netHash)
    cloud.svg                           (currently unused; no Internet node in the model)
  docs/
    docs.css
    index.html        ← docs landing page (where you are now)
    student.html
    faculty.html
    maintainer.html

Documentation lives in docs/ by convention. README.md, LICENSE, and CLAUDE.md stay at the repo root.

Attribution is Ohio University · ECT Dept. The school is the J. Warren McClure School of Emerging Communication Technologies within the Scripps College of Communications. Not Ohio State; not ITS.

Hard constraints

Single file, no build

Everything that runs in the browser lives in index.html - embedded CSS, embedded vanilla JS, embedded Canvas drawing. It must run by double-clicking it (file://) and as a static file. There are no external resources: no CDN, no fonts, no libraries. A "no http src/href" check verifies this. The only HTTP references in the file are to the school's own pages (in the About modal), which are not loaded at runtime - they're <a> hyperlinks for the user.

Public deploy

This directory is the docroot served at https://www.its.ohio.edu/tools/hostbound/. Anything committed here is public. Never leave scratch files behind - _*.py, generator port test scripts, headless screenshots. Delete them before finishing. The convention used during development is to keep scratch work in w:\hbdev\ (outside the served root) and delete it on commit.

Persistence

Game state hard-resets on every reload. Three keys persist in localStorage - two accessibility preferences plus the sticky Admin authentication:

That is it. There is no save file, no progress, no leaderboard, no daily streak. Everything else - the current network code, the difficulty selection, the family selection, Firewalls, Hidden Host Mode, Cybersecurity Mode, inventory, credits, admin mode, the entire game in flight - is in-memory only. clearLocalSettings() (the Admin ▸ Clear Local Settings item) removes all three keys and re-derives the UI defaults via initHiVis() / initTheme().

The code itself is the single source of truth for shareable state: family, difficulty, Hidden Host Mode, Firewalls, Cybersecurity, and the topology seed all ride inside it (see Code format). There are no URL parameters - earlier ?d= / ?h= hacks were removed in the rework.

Determinism

A network code plus difficulty maps to one mulberry32 stream:

seed = seedFromCode(code) ^ Math.imul(diffIdx + 1, 0x9E3779B9)

The same code at the same difficulty must regenerate a byte-identical world. All randomness flows from that single seeded RNG in a fixed call order. Several derived streams are spun off it by XORing a distinct constant into the seed, so they iterate the final host / LAN arrays without perturbing the main topology draw order:

ConstantStream
0x9E3779B9Lattice embedder walk + loop placement.
0x5BD1E995Address pass (assignAddresses). In dual-stack worlds this stream also draws each LAN's family before its address.
0x27D4EB2FName pass (assignNames).
0x85EBCA6BHint-file pass (per-host <word>.txt filename + hintBody).
0xC2B2AE35Firewall placement pass.
0xDEAD7001Cybersecurity placement pass (vulnerabilities, loot, broker).
0xB1A57001Services pass (per-host services[] for nmap -sV).

The topology-shaping draws on the main stream (G, leftArm, fillers, branches, loops, routers) happen before any IP is assigned, so subnet sizing and addressing can change freely without altering world shape. Family is not mixed into the seed - HB1-X and HB2-X at the same difficulty share the exact same topology and differ only in addressing.

Style rules

Code format

HB1 / HB2 / HB3

Body shape: HB<family>-<M1><M2><SEED> - 9 base36 characters total after the dash. M1 + M2 are two mode chars; SEED is seven base36 chars of topology seed.

PrefixAddress family
HB1-Pure IPv4.
HB2-Pure IPv6 (ULA /64s).
HB3-Dual-stack (per-LAN family).

Legacy 8-char bodies (one mode char) parse with the Cybersecurity bit forced off, so old shared codes still work.

HB4 sandbox

Body shape: HB4-<PACKED-6><M><NONCE> - 6 packed-param base36 chars + 1 mode char + base36 nonce. The mode char sits between packed and nonce on purpose, so seedFromCode can strip it and keep the topology invariant under HHM / Firewalls toggles.

The packed-6 encodes a 27-bit int with these fields:

BitsFieldRange
0-4G (par hops)1-31
5-7leftArm0-7
8-11numBranch0-15
12-14numLoop0-7
15-16fam0 v4 / 1 v6 / 2 dual
17-20v6t0-15 (dual-stack only; per-LAN IPv6 percentage)
21-24dualt0-15 (dual-stack only; per-LAN true-dual percentage)
25-26sizedensity preset 0-3

Difficulty does not apply to a sandbox world; the generator runs with a fixed di = 0 so the world depends on the code alone. The diff badge shows Custom; cycling it regenerates the identical world. The CODE_RE pattern is widened to /^HB[1234]-/i at the boot, hash-change, and Load paths.

Mode-char encoding

Inside the body, the difficulty / HHM / Firewall / Cybersecurity bits are packed into the mode char(s):

seedFromCode strips the trailing 7 chars regardless of body length so the topology seed is byte-stable as mode bits change. parseModeChar / parseModeChar2 read those mode chars; applyModeFromCode(code) runs at the top of startGame and syncs gDiff / gHHM / gFW / gCS from the code's bits before genWorld, so the world is built at exactly the pinned mode.

The three toggles (cycleDiff, toggleHHM, toggleFW, toggleCS) each rebuild W.code via rebuildCodeWithMode() (same seed body, fresh mode char) and call startGame. Difficulty / Firewalls / Cybersecurity regenerate the world; toggleHHM is render-only and skips genWorld - it just updates W.code + location.hash + redraws. Family is derived from the prefix via familyOfCode(); the menu's IP-mode toggle only steers new codes.

Generator

Topology

The world is a guaranteed chain of LANs joined by dedicated bridge hosts, plus filler hosts, dead-end stubs, branch trees, loop rings, and 3+-NIC routers. The START is a filler on an interior chain LAN (chain[leftArm]): the spine runs both ways from it - chain[0..leftArm-1] is a dead-end direction, chain[leftArm..C-1] leads to gold. C = G + leftArm, G is the real start-to-gold hop distance (the score floor and the displayed par).

G, leftArm, numFill, numDead, numBranch, numLoop, and numRouter are all rng.int draws over per-difficulty ranges in DIFF_CFG (Casual through Extreme). Casual is intentionally boring: G=2, no branches, no loops.

Branches are trees of shared LANs hanging off an interior chain LAN - no shortcut. Loops are rings of three or more shared LANs that leave a client and return to that same client - no shortcut. The router pass (last topology step) upgrades a per-difficulty number of true bridges (hosts with ≥2 shared LANs and a private leaf) by adding a single decoy to that bridge's private leaf - the stub becomes shared and the bridge renders as a real 3-connected router. A purely random share-a-LAN graph collapses to diameter 2 and must not be used.

Addressing

LANs are created address-less. After the whole topology is built, assignAddresses() runs on its own RNG stream and gives each LAN a prefix drawn from the per-difficulty cfg.prefixes pool, clamped larger if the LAN has more NICs than the subnet holds (a /30 can never overflow a busy LAN). Then a non-overlapping prefix-aligned base is drawn from the entire routable IPv4 space (first octet 1..223), excluding reserved / special ranges via the RESV set (0/8, 127/8, 169.254/16, 224/4, 240/4 including the broadcast 255.255.255.255). RFC1918 still occurs naturally; the lo NIC keeps 127.0.0.1/8.

Every NIC IP and every lan.cidr is set in this single pass; nothing in the generator reads an IP before it. Reachability keys off each NIC's own prefix (ipReachesFrom, lanFromArg match by containment) - never assume /24.

IPv6 (HB2): assignAddresses takes a separate branch - every LAN is assigned a unique ULA fd00::/8 /64 (N3's variable prefixes are v4-only), host IID a small int (e.g. ::a3); lo is ::1/128. Address math is a parallel BigInt path (v6ToBig / bigToV6 / v6Net); sameLan short-circuits on family so v4 and v6 never compare.

Dual-stack (HB3): inside the v4 / dual branch, each LAN's family is picked on the same address RNG stream - v6pct = (fam === 'dual') ? cfg.v6pct : 0, so HB1 stays all-v4 and only HB3 mixes in v6. The START LAN is forced v4; the first chain LAN past start on the win path (chain[leftArm+1]) is forced single-v6 so winning always crosses a v4↔v6 boundary one hop in. True dual-stack LANs (per-LAN, dualPct per level) carry both lan.cidr and lan.cidr6; every host NIC there gets both nic.ip and nic.ip6. ipOwner maps both addresses; ipReachesFrom and the ping same-LAN guard test every [ip, ip6]; lanFromArg matches a LAN by either CIDR; ip a prints an inet and an inet6 line; nmap reports the scanned family's network and host addresses.

Hints, names, and intel

Every non-gold host gets a <word>.txt recon file in its home: the exact ssh hop distance to gold plus one true octet / group of gold's IP. The gold machine gets no recon file (hintFile / hintBody are null) - its empty home is the tell that you have found it. hintWord() is still drawn for gold in id order so the RNG stream stays byte-stable and every other filename is unchanged; cmd_ls and the cat / nano TAB-autocomplete are guarded against the null filename. Filenames are drawn from the hint-file pass (0x85EBCA6B) - 1-8 letter pronounceable words. HB1-X and HB2-X share filenames at the same difficulty because family is not mixed into the seed. (In CS mode gold still carries its sellable .intel, so its home is empty only outside CS.)

Names are drawn from the name pass (0x27D4EB2F) - hostname per host plus a TLD. The .xyz TLD was dropped from the pool. In v6 worlds, hostnames are unique and resolvable by bare ssh hostname.

In Cybersecurity Mode, every host also gets a .intel file with a seeded credit value of $10000-$200000, sellable at The Darkweb. The file disappears from ls after being sold.

Firewall pass

Runs after the router pass, on its own RNG stream (0xC2B2AE35). Walks a deterministic candidate list (spine bridges + off-spine hosts + off-spine LANs) and tentatively applies each filter; a spine-bridge filter always synthesises an unfiltered twin bridge on the same two chain LANs first. The tentative filter is kept only if filtered forward-reachability still has dist(start → golden) == G AND every non-golden host keeps a finite forward distance (every hint stays truthful). Otherwise reverted.

Invariants therefore hold by construction. Verified across 1500/1500 worlds (300 seeds × 5 levels): determinism, gFW-off baseline equivalence, Casual numFilter = 0, gold never filtered, no ACL on spine LANs, every filtered spine bridge has a clean twin, filtered dist == G, greedy-descent solvable, every non-golden host finite-distance.

Runtime touch points: cmd_ssh refuses entry with a "Connection refused" line and no hop if the target is filtered; cmd_ping emits a realistic "Request timeout for icmp_seq 1" with 100% packet loss and no reveal; cmd_nmap is unchanged - the host still shows up. That contrast is the lesson.

Cybersecurity pass

Runs after the Firewall pass, on its own RNG stream (0xDEAD7001). Distinct constant so the topology draw order remains byte-stable.

Per-difficulty caps in DIFF_CFG (numVuln / numLoot / startInv): Casual 0/0/0 (no-op); Normal 2-4 / 1-2 / 1; Hard 4-7 / 2-4 / 1; Brutal 7-11 / 3-6 / 0; Extreme 10-15 / 5-8 / 0.

Constructive validate-or-revert: loot is placed freely (only adds keys); vulnerabilities are placed one-by-one and kept only if a key-door BFS from start still has dist(start → golden) == G AND every host that was baseline-reachable in the Firewall-forward graph stays reachable with achievable inventory. The cumulative key-door BFS iterates to a fixed point. Reachability uses the Firewall-baseline set (not all hosts), because Firewalls may legitimately leave some hosts forward-unreachable from start; CS must not require those to become reachable. Verified across 50 worlds per difficulty.

W.brokerId is chosen from the baseline-reachable set (never start, never gold). Once the broker is chosen, the CS pass appends a broker clue to every non-gold host's hintBody - the same hop-distance + rotating-IP-fragment shape as the gold clue (hosts that cannot reach the broker in the F1-filtered forward graph, dist === -1, are skipped so the clue stays truthful; the broker's own file states it is the broker). This appends only - no RNG draws - so every determinism stream stays byte-stable. HB4 sandbox skips the entire CS pass.

Lattice embedder

The map embedder is a deterministic lattice walk computed once per world and cached on W._emb = {lr, hp}. Seeded from the network code via the separate 0x9E3779B9 stream so it never perturbs the topology draw order.

Invariants kept: squares never overlap (distinct cells); a cell once assigned never moves; the map only grows (column / row widen); fog-of-war draws only the discovered subset at these fixed points; the ducky travels; no ellipsis in UI labels. Branches and loops add no shortcut.

Bridge placement on a shared edge uses touchAt(li1, li2, frac) - a parameterised version of touch(). A lone bridge gets frac = 0.5 (byte-identical original midpoint); siblings sharing the same exact LAN set are grouped first and given evenly spaced fractions (k+1)/(N+1) so a firewalled bridge and its unfiltered twin do not overlap.

Runtime model

Reachability

Same-LAN only. You can ping, nmap, or ssh a host only if it shares one of your current host's directly-connected subnets. There is no routing. ssh accepts an IP in any world and additionally a bare hostname in IPv6 / dual-stack worlds (resolveSshTarget) - name resolution still enforces same-LAN; it is not a routing bypass.

Only interfaces with an active connection (LAN size ≥ 2 hosts) list an address (U1); ip a omits the rest. connNics(h) filters to NICs whose LAN has ≥2 hosts; primaryNic(h) is the first connected, else the first eth (so prompt / banner always have an address).

Fog of war

Subnets and hosts appear only as discovered. The four reveal triggers:

LANs are numbered LAN1, LAN2 ... in discovery order (S.lanNum / S.lanSeq in revealLan); labels show "LAN n" + the bare CIDR + "Local Network" when ssh-reachable. Colours come from the fixed 8-entry pastel palette (LAN_PALETTE) indexed by discovery number - not random. Per-LAN colour does not change for reachability (that experiment was rejected); a small "ssh reachable" label is shown instead.

Hidden Host Mode is a pure display filter on top of fog-of-war (gHHM = off / hhm / hard, in-memory). HHM hides only the hop-trail breadcrumb; HHM Hard additionally hides every discovered host icon, NIC port, the current host's switch-link line, and the switch icons - only the LAN squares, their labels, and the current host render. swPos is still computed (positions / label-slot collision) so the layout is byte-identical across states; only the draw is gated on hardHide. The win screen shows everything regardless of mode.

Admin tools

Hamburger menu → Admin ▸. Gated by a passphrase modal (#adminpw) the first time it's opened; default passphrase is ducky, hashed with a salted FNV-1a (adminHash, ADMIN_SALT). Wrong passphrase keeps the modal open with an inline error; Cancel / Esc / backdrop drop the pending action. A correct passphrase sets S.admin for the session and is remembered in localStorage (hostbound.admin, read at boot by initAdmin() into _adminAuthed), so the modal is never shown again on later games / reloads. startGame still clears S.admin (admin mode is per-game), but adminUnlock short-circuits when _adminAuthed is set, so the passphrase stays sticky until Clear Local Settings forgets it.

Client-side obscurity, not real authentication - because Hostbound ships as a static file, anyone reading the source can find the passphrase. Masking just stops a casual shoulder-surf or on-screen reveal during a live demo.

Six items in the popout: Reveal Path, Reveal Network, Jump to host, Show Firewalls, Show Darkweb, Clear Local Settings. Reveal Path walks goldenPath() (greedy descent on W.dist / W.adj) and reveals the route's hosts and LANs; Reveal Network adds every host and LAN to the discovered set. Jump to host teleports without counting an ssh hop or triggering loot / broker discovery (so admin teleports don't pollute the recorded path). Show Firewalls overlays an orange flame on every filtered host. Show Darkweb overlays a purple $ on the CS broker and auto-reveals it. Clear Local Settings (clearLocalSettings()) removes the three localStorage keys, re-locks Admin, and resets Theme / High-Visibility to defaults.

UI architecture

Verification

Headless visual harness

Preferred for canvas / UI work: w:\hbdev\shot.py (Playwright Chromium, installed for /c/Python314/python; kept outside the served w:\tools\hostbound).

cd /w/tools/hbdev && /c/Python314/python shot.py HB1-ABC123 "ip a" "nmap"

Loads the app at the given code, types each command into #cmdline, writes w:\tools\hbdev\hbshot.png, and prints console / page errors. Then Read the PNG to actually see the rendered map and self-check layout instead of relying on user screenshots. Use a fixed code for repeatable diffs.

Python topology harness

For generator-logic changes, no JS runtime is needed. Python 3.14 at /c/Python314/python. The pattern that works:

  1. Port genWorld's call order to a scratch _gen_check.py (kept in w:\hbdev, outside the docroot) and assert per difficulty, ~300 seeds × 5 levels:
    • Determinism (per code + level).
    • Single connected component.
    • dist(start, golden) == G in that level's range (branches and loops add no shortcut).
    • numBranch / numLoop match the level spec.
    • Casual is boring (leftArm 0, no branch / loop).
    • Left arm is a true dead-end direction (cut start's chain LAN and chain[0] severs from golden).
    • Every branch / ring LAN renders (≥2 hosts).
    • Each loop is a real ≥3 cycle reachable only via its client.
    • Golden is never in a ring.
    • Hints are truthful.
    • Greedy hop-distance descent is solvable.
    Structural invariants hold for any RNG of the same call structure, so the check may use a stand-in deterministic RNG.
  2. String / regex / comment-aware brace scan of the <script> to confirm balanced {} and well-formed HTML (single script, ends </html>, no external http resources).
  3. Delete every scratch file (the docroot is public).
  4. State clearly that browser rendering / UI is written but not runtime-tested here; ask the user to load it and eyeball the map / menu.

Brace scan

Part of the verification step above. A string- and comment-aware scan of the embedded <script> tag that asserts:

Deploy

The directory is the docroot. Commit anything you want public; do not commit anything you don't. The user runs git-go.bat (add / commit / push); do not run it yourself.

The deploy target is https://www.its.ohio.edu/tools/hostbound/. Because the entire app is a single static file, there is no server-side rollout, no health check, and no rollback procedure beyond a previous-commit checkout.

Changing the Admin passphrase

The hash is computed once with the salt and stored as ADMIN_HASH near seedFromCode. To change it: re-run the salted FNV-1a (adminHash(ADMIN_SALT + newPass)) in the JS console of a throwaway page, then update ADMIN_HASH to the new value. A comment in the source documents this. The salt itself does not need to change.

Remember the obscurity model: a determined student reading the source can find or recompute the hash. Treat this as a barrier to casual menu-poking, not as authentication.

Changing the cipher ladder

The C1 table is ciphertext-only ({ct} for text, {pig} for the Pigpen letter-index array). Plaintext is never stored, computed, or DOM-written; no decoder ships in the page. To change ciphertexts:

  1. Run an offline encoder in a throwaway script outside the docroot (w:\hbdev). Round-trip-prove each rung solvable.
  2. Paste only the resulting {ct} / {pig} values into the C1 table.
  3. Verify the final reward string is absent from index.html.
  4. Delete the encoder script.

No scheme name, no keyword, and no decoded plaintext should appear anywhere in the source - including comments. The only intentional residual token is the drawPigpen function name (two call sites; kept for maintainability).

Known minor issues

What not to reintroduce

Many models were tried and rejected on the way to the current lattice embedder. Do not put these back without a strong reason: