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:
hostbound.hivis- high-visibility flag.hostbound.theme-dark/light/auto(defaultauto).hostbound.admin- set to"1"once the Admin passphrase is entered correctly, so it is not re-prompted on later games / reloads.initAdmin()reads it at boot into_adminAuthed; admin mode (S.admin) still resets per game, only the authentication is sticky.
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:
| Constant | Stream |
|---|---|
0x9E3779B9 | Lattice embedder walk + loop placement. |
0x5BD1E995 | Address pass (assignAddresses). In dual-stack worlds this stream also draws each LAN's family before its address. |
0x27D4EB2F | Name pass (assignNames). |
0x85EBCA6B | Hint-file pass (per-host <word>.txt filename + hintBody). |
0xC2B2AE35 | Firewall placement pass. |
0xDEAD7001 | Cybersecurity placement pass (vulnerabilities, loot, broker). |
0xB1A57001 | Services 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
- No em dashes or en dashes anywhere - code, UI / terminal strings, comments, docs, chat. Use a hyphen, colon, semicolon, parens, or reword. (Middot
·, arrows→/⇒, and ellipsis…are not dashes and are out of scope of this rule.) - No ellipsis in menu / UI labels - never use
…,…, or...in menu items, buttons, or labels. Plain text only. - Canvas text must be ASCII-safe - glyphs like
←/◀render inconsistently across platforms. Use<<<,(YOU), plain words. HTML / CSS text outside the canvas is fine for non-ASCII. - Comments on non-obvious mechanics only - RNG, generator connectivity, reachability rules, map layout. Don't narrate obvious DOM code.
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.
| Prefix | Address 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:
| Bits | Field | Range |
|---|---|---|
| 0-4 | G (par hops) | 1-31 |
| 5-7 | leftArm | 0-7 |
| 8-11 | numBranch | 0-15 |
| 12-14 | numLoop | 0-7 |
| 15-16 | fam | 0 v4 / 1 v6 / 2 dual |
| 17-20 | v6t | 0-15 (dual-stack only; per-LAN IPv6 percentage) |
| 21-24 | dualt | 0-15 (dual-stack only; per-LAN true-dual percentage) |
| 25-26 | size | density 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):
- HB1 / HB2 / HB3: M1 =
diffIdx*6 + hhmIdx*2 + fwBit(0..29, base36 0..T). M2 = CS bit (0..1; 35 reserved). - HB4: M =
csBit*6 + hhmIdx*2 + fwBit(0..11). (Difficulty is not applicable.)
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.
- Each shared LAN (≥2 hosts) gets one integer cell
(c, r). A seeded walk from the START LAN over the shared-LAN graph places each LAN in a free cell adjacent to its parent. - A
FANoffset list makes child 0 continue the heading and child 1 go opposite (spine grows both ways), then perpendiculars; a small seeded wander turns the heading so the chain snakes. - Loops are not walked: their ring LANs (tagged in the generator with a perimeter
idx+ rectanglera × rb) are laid on a rectangle perimeter of cells, anchored at the nearest freera × rbblock to the originating chain LAN's cell. Consecutive ring LANs and the wrap from last to first are always in adjacent cells - the loop client straddles the closing edge. - Cells → rects: a square fills its whole cell.
colW[c]/rowH[r]= max square needed in that column / row, so orthogonally adjacent cells share a full edge exactly. The walk prefers orthogonal steps (clean shared edges); diagonals (corner touch) are a last resort only. - A multi-homed (bridge) client is placed by
touch(li1, li2)at the mid of the shared edge (orthogonal cells) or the shared corner (diagonal cells), picking the adjacent-cell pair of its shared LANs. A 3+-shared router seats at the junction - the lattice point that is a corner of the most of the host's shared-LAN cells. A hard distinctness guard skips any occupied point. - Every non-bridge host renders inside one square via a single packer in
draw()(thesingle/regionpath). Grouping is byinsideHome(ho): a host's lone shared LAN (single-homed filler, including the host you're on) or its only LAN (single-NIC dead-end). The square is sized byinsideOf(li)= all non-bridge hosts so it grows to fit them. The packer is the authority for those hosts even if the embedder originally laid them out differently.
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:
ip a- your own subnets.ping- one host.nmap- hosts on a connected subnet (one subnet at a time; the old "scan every connected LAN at once" path is gone).ssh- the host you land on.
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
- Terminal is a floating, draggable, resizable window (
#termwrap+#termbar). Minimize pulls it off-screen and shows a small#termrestoretab. The map canvas fills the work area behind it. - Top-right control cluster (
#topctl) is a flex row holding, left to right, the Cybersecurity / Hidden-Host-Mode / Firewall mode badges (each shown only while its mode is on), the Difficulty badge, the IP-mode badge (#fambadge), and the hamburger button. All six share the Difficulty badge's size. Flex layout replaced earlier per-elementright:offsets so the badges always sit flush. - Top status bar (
#scorebar) holds the Notepad button (far left), then Host / Address / Hops (par) / Claims / Time, and the Tools-and-Credits chip (#sb-cs, shown only in Cybersecurity Mode). - Footer (
#about) holds the Find-host box, the Darkweb button, the Network Options button + popup, the Gold Machine button, and the editable network-code box. (The mode badges moved to#topctl; the Notepad button and Credits chip moved to the scorebar; the old map legend and "YOU ARE HERE" caption bar were removed.) - Side-flyout submenus use the shared
.subcell{position:relative}+.subpop.open{position:absolute; right:100%; top:0}pattern, so a submenu pops left over the map and never lengthens the main menu. Admin ▸, Challenges ▸, Theme ▸, and Network Options ▸ all use this pattern. Only one flyout is open at a time. - Footer popups (Network Options, Darkweb) anchor above their button (
position: fixed;place()readsgetBoundingClientRectand sets top / left dynamically). Close on outside-click, Esc, or selection. Listed in the focus-steal exclusion so clicks between buttons inside them don't yank focus to the terminal. - Modals (About, Docs, Win, Sandbox, Admin passphrase, Admin jump, Notepad) follow the same overlay /
.open/data-act/handleAct/ backdrop-click + Esc pattern. - Canvas pan / zoom: right-button drag pans (
S.panX/S.panY, applied viactx2d.translate); mouse wheel zooms (S.zoom,ctx2d.scale), keeping the point under the cursor fixed, clamped 0.3-3.5. Both reset on new game. The host you're on is pinned every frame (S.anchor). - Minimap draws after the main map (
setTransform(dpr,...)reset to screen px). Only whenlanIds.length ≥ 6. Left-click inside the inset recenters the main view. Geometry stored in module-scope_miniMap.
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:
- Port
genWorld's call order to a scratch_gen_check.py(kept inw:\hbdev, outside the docroot) and assert per difficulty, ~300 seeds × 5 levels:- Determinism (per code + level).
- Single connected component.
dist(start, golden) == Gin that level's range (branches and loops add no shortcut).numBranch/numLoopmatch 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.
- String / regex / comment-aware brace scan of the
<script>to confirm balanced{}and well-formed HTML (single script, ends</html>, no externalhttpresources). - Delete every scratch file (the docroot is public).
- 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:
- Balanced
{}in the script. - Well-formed HTML: a single script, ends
</html>. - No external
http://orhttps://src/hrefat runtime (anchors in the About modal are not loaded; they are fine).
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:
- Run an offline encoder in a throwaway script outside the docroot (
w:\hbdev). Round-trip-prove each rung solvable. - Paste only the resulting
{ct}/{pig}values into the C1 table. - Verify the final reward string is absent from
index.html. - 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
- A 3+-shared router seated at a true junction (~57% of cases). The rest fall back to a distinct 2-LAN edge touch and still draw all three ports / links correctly; raising the rate needs a walk bias change that re-touches every world's cell layout and is left as an optional follow-up.
- A loop client also has a chain NIC whose LAN may not be cell-adjacent; that tie is logical, not a touch. Cosmetic.
- Generator still computes per-host
pos/lanPosthe new map ignores; harmless dead code, not stripped. - Long IPv6 strings make under-icon name / IP labels denser; the multi-homed-host stacked-list layout (U2 follow-up) is the workaround. Single-NIC packed hosts use compact
::iidv6.
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:
- Ego-recentering (re-layout on every
ssh): students lost place. - Bbox / hull layout: squares jumped on reveal.
- Force-directed: non-deterministic, animations confusing.
- Partition layout: required reshuffling.
- Global-BFS fixed grid: reshuffled whenever a new LAN appeared. (The current lattice walk is different - it never reshuffles.)
- Recolouring squares gold for reachability: too noisy; the small "ssh reachable" label is the replacement.
- "+N more" host tags in a too-small square: squares grow to fit instead.
- SPREAD (separating LAN squares with gaps): squares should touch. Readability comes from bigger squares + spread icons.
- Per-element
right:offsets on the top-right cluster: replaced by the#topctlflex row. - URL parameters (
?d=,?h=): removed in the code-encoding rework. All shareable state rides inside the code.