- Remove retired _archive/ and scripts/archive/ trees (site, book filters, games, vault); vault CHANGELOG points to git history for old scripts.
- CONTRIBUTING: site project row, site/ in area map, root vs TinyTorch pre-commit, vault schema drift wording.
- Newsletter CLI: path-agnostic news alias; tinytorch pre-commit comments; add tools/ and staffml-vault-types READMEs for maintainers.
The textbook bootstrap theme paints `blockquote` with a near-white
background and dark muted text, which made the community Spotlight
quotes nearly invisible against the dark card in dark mode. Scope a
neutralization of background/padding/border to `.spotlight-quote` and
add explicit dark-mode color overrides so the rule still wins when the
theme toggles via either `.quarto-dark` or `[data-bs-theme="dark"]`.
Co-authored-by: Vijay Janapa Reddi <vj@eecs.harvard.edu>
`scroll-snap-type: y mandatory` was set on <html> globally (landing.css)
and re-asserted with !important when the v3 landing class was present
(landing-v3.css). The intended mobile escape hatch — disabling
scroll-snap-align inside @media (max-width: 768px) — never worked:
1. Specificity mismatch. Enable rules used `.mls-landing-v3
.mls-section-richcards` (0,2,0); the disable used `.mls-section-
richcards` (0,1,0). Media queries don't bump specificity, so the
enable rule won and snap stayed active on phones for two of three
sections.
2. The mandatory `scroll-snap-type` on <html> was never disabled, so
even with all snap-aligns removed, mandatory snap remained active
on iOS — fighting Safari's momentum scroll.
Both bugs combined to produce the jittery, half-snap behavior on phones.
Even fixed, mandatory snap on a content-rich landing page locks readers
into one section at a time on small viewports — the polish wasn't worth
the friction. Removed entirely.
Kept: scroll-behavior: smooth and scroll-padding-top: 60px on <html> for
in-page anchor smoothness.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The about and community pages on mlsysbook.ai were rendering unstyled
because they referenced bootstrap-{HASH}.css filenames that were never
deployed to /site_libs/ on gh-pages. Two structural bugs in the deploy
workflows let this state happen:
site-publish-live.yml never copied site_libs/ to gh-pages — the
landing-page deploy loop explicitly skipped it. Now copied alongside
the subsite HTML so the bootstrap hashes referenced in about/community/
newsletter resolve.
sync-newsletter.yml ran a parallel partial deploy that overwrote
site_libs/ daily without updating about/community HTML, leaving them
pointing at hashes that no longer existed. Replaced with a content-only
workflow: pull Buttondown, commit posts to dev, cherry-pick the same
commit onto main (the stable release branch from which gh-pages is
published), dispatch site-publish-live on main. Single deploy path now
owns gh-pages, atomicity restored by construction.
Supporting changes to make the cherry-pick conflict-free:
- _stats.yml gitignored (auto-regenerated on every `news pull` run).
The unused raw subscriber_count is no longer written to the file —
only issue_count and the bucketed subscriber_display, both of which
change at meaningful boundaries rather than drifting daily.
- pull.py preserves the previous bucketed display on Buttondown API
failure rather than writing 0 → "0 subscribers" on the page.
- index.qmd static fallback updated from 11 issues / 900+ to 14 / 1000+
to match current reality.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
User: 'whatever we did for the LLM and how you arrived at it, that's exactly
the same record that we should apply to these other ones.' — applying the
same press-ENTER-to-start pattern from Lander to every other game so the
READY-screen UX is consistent across the catalog.
Each game now:
- imports mountReadyOverlay from ./runtime.mjs
- declares a 'started' flag (false at init)
- mounts a per-game READY overlay with title, goal, controls, ENTER prompt
- gates ticker / onTick on started
- gates input handlers (keydown / pointerdown / cell handlers) on started
Per-game launch copy:
- batch: 'Push batch size up for throughput — but don't OOM.'
- allreduce: 'Tap each GPU on the beat. Keep gradients flowing.'
- moe: 'Route each colored token to the matching expert.'
- kvcache: 'Pack incoming requests. Beat fragmentation.'
- topology: 'Wire 8 GPUs. Maximize bandwidth, avoid bottlenecks.'
- checkpoint: 'Train fast. Checkpoint before a node failure strikes.'
- loader: 'Type each letter as it enters the zone — feed the GPU.'
- roofline: 'Stay under the cyan ceiling — that's your hardware roof.'
- prune: 'Cut faint weights to 60% sparsity. Keep accuracy above 50%.'
(also overrides prune's existing first-click-to-start in favor of READY)
- oom: 'Pack tensors into HBM. Activations dominate — that's why you'll OOM.'
- quantization: 'Drop weights below the bit budget. Hit the red center 7+ of 10.'
Verified: 14/14 games pass Playwright sweep — canvas mounted, runtime ready,
no JS errors, no 4xx network requests. Each game shows the READY overlay on
load and dismisses on Enter / Space / Up / tap.
User reported: 'cluster commander just doesn't do anything and the game is
broken, so it doesn't work. I click around, but nothing happens.'
Two layered bugs found via Playwright instrumentation:
1. Pixi v8 per-Graphics hit testing failed silently for the grid cells.
Hover (pointerover) worked but pointerdown never fired despite
eventMode='static' + explicit hitArea. handleGridClick was never reached.
Fix: bypass Pixi events for cells. Route pointermove/pointerdown through
canvas.addEventListener and translate (clientX, clientY) → grid (r, c).
Robust against any Pixi event-system quirks.
2. The new shared mountReadyOverlay was swallowing every click after dismissal.
In Pixi v8, visible=false does NOT stop event capture. The full-canvas
overlay's eventMode='static' kept catching pointerdowns even when invisible.
Fix in runtime.mjs: launch() now sets root.eventMode='none' AND removes
root from its parent, ensuring no further event capture.
Cluster also gets the standard READY overlay (press ENTER to launch) so the
player can read the goal before the timer starts.
Verified locally: Playwright headless, 3 mid-canvas clicks → Scheduled: 8,
multiple Fine-tune 2x2 blocks placed on grid, 'scheduled Fine-tune' float
text visible. Pre-fix: Scheduled stayed at 0 forever.
Iterates on the post-merge 404 redesign across all 8 sub-sites:
- SVG roofline-plot fonts bumped for readability without changing
layout: axis labels 9.5pt to 14pt, region labels 9.5pt to 14pt,
title and "your page (404)" annotation 11pt to 16pt.
- Random-joke font shrunk from 1.6rem to 1.3rem (1.1rem on mobile)
so the joke no longer dominates the SVG above it.
- Removed the static "Looks like this page slipped past our load
balancer" subline from 6 sub-sites — it read as a competing static
joke alongside the random rotation. Slides and instructors keep
their informational sublines.
- Joke pool tightened 97 to 79 via a strict ML-systems-centric test:
if you could swap "page" for any generic web/ops resource and the
joke still works, cut. Cuts removed the H&P canonical material
(Amdahl's Law, TLB miss, Dennard scaling, false sharing, fat-tree
topology) that is general computer architecture rather than ML
systems specifically. 19 borderline jokes were rewritten to anchor
punchlines in concepts only ML practitioners decode (KV cache,
gradient AllReduce, prefill, ZeRO, speculative-decode acceptance,
1F1B schedule, ridge point, BPE merges).
Three changes addressing user feedback on Pipeline Pacer:
1. Wider GPU stages (82px → 116px). Was leaving ~175px of dead space on the
right of the canvas; now spans 464px wide with ~50px margins matching the
left, filling the canvas evenly.
2. Smooth U-turn at GPU 3 instead of instant blue→brown color swap. New
600ms 'turning' state on each block: outCubic-eased RGB lerp from
compute-blue to routing-orange, plus a vertical dip (sin curve) that
visually traces the forward→backward U-turn. Stroke color also lerps
from $2f6e9d to $9a6620.
3. Pre-game READY overlay using the new shared mountReadyOverlay helper.
Game starts paused; player presses ENTER (or Space, or ↑, or taps) to
launch. Spawn function and ticker now gate on `started` so nothing fires
before the player commits.
Also: new mountReadyOverlay() helper added to runtime.mjs. Drop-in for any
game that wants the READY-press-ENTER pattern. Same dim overlay + title +
goal + controls + pulsing CTA + 'take your time' hint as Lander, with all
fields optional. Exported on window.MLSP.runtime.mountReadyOverlay.
User: 'the surface can be a bit more curved or more challenging - maybe levels?
and make hte global minium not always appear right in the middle'
Three changes to createTerrain + lossY:
1. Global pad spawns anywhere across the playable width (was: rand(0.42*W,
0.58*W) — always near center). Now: rand(0.18*W, 0.82*W). Forces actual
steering instead of drop-and-tap.
2. Local pads always flank global with a 110px center-to-center gap. If only
one half of the canvas has room (global near edge), both locals go there
separated within that half. Wells never bleed together.
3. New third sine harmonic in lossY (period ~Pi*7.3) adds finer surface
ripples that weren't in the original. Plus all three harmonic amplitudes
scale with day-based level (Day 1 = level 0 mild, Day 11+ = level 8 max).
4. dayChip now shows 'LVL N' so the player can read why today's terrain
feels harder than yesterday's.
Implicit progression: each day's puzzle is harder than the previous, capping
at level 8 so it never becomes impossible. The daily seed still produces
identical terrain for everyone playing on the same day.
The legacy .js game files (oom, prune, quantization, _archive/roofline)
displayed "alltime best" in their HUD text. Codespell flags this as a
misspelling of "all-time". The newer .mjs rewrites already use
"all-time best"; this aligns the older files with that convention.
Variable names (alltimeBest) and onGameOver/onScoreChange payload keys
({ alltimeBest: ... }) are unchanged — they're camelCase and already
ignored by the regex in pyproject.toml. Only display string literals
were touched, so no consumer breaks.
Unblocks codespell CI on dev, which has been red since the games-polish
loop landed.
The pixi-filters.min.mjs fix from 51ee2baf9 didn't propagate to the dev
preview deploy (deployed file still has the absolute path). This appends
a one-line comment to force git to track a content change so the next
deploy commits the file.
If this still doesn't propagate, the issue is in Quarto's resource-copy
step, not in the deploy script.
Replace the inline-game 404 across all 8 sub-sites with a unified
design: roofline-plot illustration, random pick from a 97-joke
editorial-curated pool, dual CTA (home + playground), and a
GitHub-issue-backed contribute link for community submissions.
The joke pool was curated through a multi-agent editorial process:
4 generators (each with a different ML systems lens) produced 240
candidates; 5 reviewer personas (architect, NLP researcher, production
engineer, undergrad, copy editor) scored each; the synthesizer kept 92
surviving jokes plus 5 new canonical-architecture additions (Amdahl,
TLB, Dennard, false sharing, fat-tree topology) the architect flagged
as missing from the H&P-tradition canon.
Self-contained CSS with prefers-color-scheme: dark adapts to host
themes without coupling to each sub-site's bespoke styling. SVG
follows the book's semantic palette (blue memory-bound slope, green
compute roof, MIT red error annotation). Per-site navigation
preserved across all sub-sites.
Adds .github/ISSUE_TEMPLATE/404_joke.yml so contributions arrive
structured (joke text, theme dropdown, license checkbox) and
auto-tagged (site, 404-joke, good-first-issue) for triage.
Post-merge Playwright sweep against the live dev preview revealed 4 games
still 404'ing on /assets/games/vendor/pixi.min.mjs (no path prefix). Trace:
the vendored pixi-filters.min.mjs ships with the absolute path
import {...} from "/assets/games/vendor/pixi.min.mjs"
baked into its bundle as the peer-dependency reference. This was hidden
behind the lazy import (only oom/prune/quantization/roofline call into
filters), which is why Lander and 9 other games passed.
Fix: sed-replace the absolute string with a sibling-relative path
'./pixi.min.mjs', which resolves correctly regardless of deploy base
since both vendor files live in the same directory.
Verified with full Playwright sweep against locally-rendered _build:
14/14 games now pass — canvas mounted, runtime ready, no errors, no
404s, including the four previously-broken filter-using games.
Aligns the actual deploy path with the project's source-code URL
convention. Volumes now deploy to top-level /vol1/ and /vol2/ on both
dev preview and live publish, matching navbar/footer/announcement
references that have always pointed there. The /book/vol1/ nesting and
its dev-rewriter special case are removed.
The legacy /book/ single-volume textbook on live stays untouched —
that's the user-facing front door until an explicit Cloudflare-redirect
cutover.
Old BOOK_DEPLOY_PATH variable will be deleted after the dev preview
rebuild confirms the rename is live.
The project's source convention (navbar, footer, announcements,
instructor course-map, etc.) already treats mlsysbook.ai/vol1/ and
mlsysbook.ai/vol2/ as the canonical URLs. The /book/vol1/ nesting was
an artifact of the legacy single-volume textbook still occupying /book/
on live, kept alive by a special-case mapping in the dev URL rewriter.
This refactor aligns the actual deploy paths with the source convention
before Monday's release locks in citation-grade URLs.
Public URL change:
was: https://mlsysbook.ai/book/vol1/ + .../book/vol2/
is: https://mlsysbook.ai/vol1/ + .../vol2/
Variable change (set on harvard-edge/cs249r_book):
VOL1_DEPLOY_PATH=vol1 (new)
VOL2_DEPLOY_PATH=vol2 (new)
BOOK_DEPLOY_PATH=book (will be deleted post-merge)
Workflow changes:
book-publish-live.yml
- Reads VOL1_DEPLOY_PATH + VOL2_DEPLOY_PATH (fail-fast on empty).
- Each volume deploys to its own top-level path on gh-pages.
- Skip-list regex now includes both top-level paths plus 'book' so
the legacy single-volume textbook at /book/ stays untouched
(the user-facing front door remains the OLD textbook until an
explicit cutover via the Cloudflare redirect).
- Root index.html redirect now targets /$VOL1_PATH/ instead of
/$BOOK_PATH/vol1/. The CF redirect is what users see; this is
a fallback only.
- Validation, commit message, and step summary lines log both
volume paths separately rather than a single book parent.
book-preview-dev.yml
- Same VOL1/VOL2 read with fail-fast guard.
- Cleans /vol1/, /vol2/, AND legacy /book/ on the dev preview repo
to avoid zombie content from the previous nested deploy.
- Copies preview-site/vol1 → /vol1/ and preview-site/vol2 → /vol2/
separately (no longer wraps both under /book/).
- Drops the /book/ chooser page on dev (the dev landing page
already has volume cards). The chooser file stays in the repo
for the eventual live cutover.
publish-all-live.yml
- Step summary now lists Volume I and Volume II as separate links.
rewrite-dev-urls.sh
- vol1 → vol1, vol2 → vol2 (identity mapping; matches every other
subsite). The PREFIX depth math stays generic for any future
nested subsite.
site/index.qmd
- Volume cards link to vol1/ and vol2/ (top-level relative). On
live this resolves to mlsysbook.ai/vol1/ and mlsysbook.ai/vol2/.
Result: zero asymmetry between the volume URL convention used in source
and the actual deploy paths. The dev URL rewriter no longer needs a
special case. Citations made against Monday's release URLs will be
permanent and aligned with the project's everyday URL vocabulary.
Brings the games subsite to working order on dev preview and ships the
10-iteration polish loop on Gradient Lander (the 404-headliner game).
Lander (10 iterations + playtest hotfix):
iter 1 pre-game READY card with ENTER prompt + goal-pad pulse
iter 2 thrust puffs, impact-velocity-scaled shake, win celebration
iter 3 difficulty curve — slower rotation, capped slope, trajectory marker
iter 4 in-canvas VRAM + descent-speed bars (replaces DOM HUD)
iter 5 per-failure aha cards (diverged / local-min / OOM / etc.)
iter 6 layered ship, altitude line, symmetric pad labels
iter 7 daily seed, best-score, retry pill, emoji-grid share + Copy
iter 8 refreshed landing page copy + screen-reader announce
iter 9 mobile touch zones, prefers-reduced-motion, aria-live
iter 10 final tone + win-pill recolor
hotfix module-relative imports + ENTER-to-launch UX feedback
Catalog (all 14 games):
Module-relative imports across .qmd + .mjs files. Same path-prefix bug
that broke Lander on dev preview affected every game; one batched fix.
Verification: all 14 games swept with Playwright (headless Chromium) on
locally-rendered _build/. Pass: canvas mounted, MLSP runtime ready, game
registered, no JS errors, no 4xx network requests.
The same path-prefix bug that broke Lander on dev preview affected the other
13 games too. Fixing all of them in one batch so the entire catalog works
on /cs249r_book_dev/, mlsysbook.ai/, and localhost equally.
Pattern applied:
.qmd include-in-header script:
import "/assets/games/X.mjs" → import "../assets/games/X.mjs"
.mjs ES imports:
from "/assets/games/runtime.mjs" → from "./runtime.mjs"
from "/assets/games/vendor/pixi.min.mjs" → from "./vendor/pixi.min.mjs"
Files touched (10 .mjs + 13 .qmd):
.mjs: allreduce, batch, cluster, kvcache, moe, oom, pipeline, prune,
quantization, topology
.qmd: allreduce, batch, checkpoint, cluster, kvcache, loader, moe, oom,
pipeline, prune, quantization, roofline, topology
(checkpoint, loader, roofline .mjs already used 'import * as runtime from
./runtime.mjs' — only their qmd files needed updating)
Verification: all 14 games rendered locally (quarto render games/), served
via python3 -m http.server, swept with Playwright headless Chromium.
Result: 14/14 pass — canvas mounted, MLSP runtime ready, game registered,
no JS errors, no 4xx network requests. Visual screenshots confirm each
game's HUD/title/content paints correctly.
User reported the live dev preview was broken (blank canvas, 'doesn't do anything').
Playwright probe confirmed all .mjs imports 404'd:
[http 404] https://harvard-edge.github.io/assets/games/runtime.mjs
[http 404] https://harvard-edge.github.io/assets/games/lander.mjs
Root cause: dev preview lives at /cs249r_book_dev/ but every game imported
its modules via root-absolute paths (/assets/games/...). The dev-URL rewrite
script only handles https://mlsysbook.ai/... — not root-relative paths.
All 14 games have this bug; Lander is fixed here.
Path-prefix fix:
- lander.qmd: /assets/games/X.mjs → ../assets/games/X.mjs
- lander.mjs: /assets/games/runtime.mjs → ./runtime.mjs (sibling)
- lander.mjs: /assets/games/vendor/pixi.min.mjs → ./vendor/pixi.min.mjs
- runtime.mjs: /assets/games/vendor/pixi.min.mjs → ./vendor/pixi.min.mjs
- runtime.mjs: pixi-filters dynamic import → ./vendor/pixi-filters.min.mjs
UX feedback (bundled): user asked 'say hit enter to start so people don't
feel rushed and then they can read what's expected':
- READY CTA 'press UP to launch' → 'press ENTER to launch'
- Added italic 'Take your time — read the controls.' hint above the CTA
- Keydown accepts Enter, Space, OR ↑ as launch — any of the three works
- Center touch zone calls new shared launch() helper
- 'How to play' instructions updated to match
Verification: rendered locally (quarto render games/lander.qmd), served via
python3 -m http.server, probed with Playwright (headless Chromium). Page
loads, READY shows new CTA, Enter dismisses overlay, ↑ thrusts, crash
triggers per-failure aha card with correct share text. Zero console errors.
Outstanding: other 13 games still have the same path-prefix bug. Either
apply the same per-file fix, or extend rewrite-dev-urls.sh to also rewrite
/assets/... paths.
Cold re-read surfaced three small but real issues:
- dayChip claimed 'softest landing today: X m/s' — but X is screen-velocity
in pixels-per-frame, not m/s. Misleading scientific units.
- Retry button stayed MIT-red regardless of outcome — wins missed a small
visual reward.
- A few stale 'fuel' comments left over from the Lunar Lander origin.
Changes:
- dayChip: 'softest landing today: v=1.42' (dimensionless, matches share format).
Empty-state: 'land softer than yesterday' (implies cross-day comparison).
- Retry pill recolors green ('↺ PLAY AGAIN') on win, stays MIT-red ('↺ TRY AGAIN')
on any loss
- Comment sweep
Loop complete. 10/10 iterations on feat/games-polish-loop. See
.claude/_reviews/games-polish-loop-lander.md for the full log + reusable
template for the other 13 games.
The game was unplayable on a phone (keyboard-only controls). Animations
ignored OS reduce-motion preference. No aria announcement for game-over.
- Three invisible Pixi touch zones: left ⅓ steer-left, right ⅓ steer-right,
center ⅓ thrust + tap-to-launch from READY screen
- Pointer fallbacks (pointerupoutside, pointercancel) prevent stuck-key state
- Z-order re-pinned after touch-zone creation so retry pill stays clickable
- reduceMotion flag from matchMedia('(prefers-reduced-motion: reduce)')
- safeShake + safeBurst wrappers gate all 5 shakes and 4 big crash bursts
- Goal-pad pulse and CTA pulse held static when reduce-motion is set
- New aria-live='assertive' span; onGameOver writes per-reason announcement
- 'How to play' gains a mobile/tablet bullet
Lens-bounded change. Iter 10 (final ship-readiness pass) is next.
The landing page lagged the in-canvas state machine. DOM HUD duplicated VRAM
and Speed values now drawn in-canvas. 'How to play' didn't mention READY
screen, RETRY button, daily seed, trajectory marker, or altitude line.
'The Systems Concept' only framed the win path.
- DOM HUD trimmed to a key-cap controls row; VRAM/Speed values moved to
.mlsp-sr-only aria-live span (kept for AT, hidden visually)
- 'How to play' rewritten to match current state (READY-to-launch, all six
affordances called out)
- 'The Systems Concept' lists all five failure modes mapped to real training:
diverged, local-min, missed-basin, off-course, OOM
- Tail line invites the other 13 games
- New shared CSS: .mlsp-controls-line (key-cap row), .mlsp-sr-only (standard
visually-hidden pattern); reusable across the catalog
Lens-bounded change. Iter 9 (mobile/touch/a11y) is next.
Lander was missing every replay-loop affordance the other 13 games have:
no daily seed, no best-score persistence, no visible retry button, no share
artifact, dead-static game-over state.
- dailySeed('lander') → terrain RNG; same loss surface worldwide today
- bestScore integration: lowest impact speed stored per-day; top-center chip
shows 'Day #N · your softest landing today: X m/s'
- In-canvas RETRY pill (Pixi Container, eventMode=static), MIT-red background,
visible only after gameOverFired
- buildShareText(state) per outcome: emoji-grid lines for win + 5 failure modes,
with ⭐ new personal best marker
- attachShareRow in lander.qmd appends share text + 📋 copy button to aha card,
with success feedback (✅ copied → reverts)
- New shared CSS in common.css: .mlsp-share-row, .mlsp-share-text, .mlsp-share-btn
(reusable by every other game)
Lens-bounded change. Iter 8 (landing page) is next.
The game functioned but didn't feel presentation-bar. Ship was a tiny unstroked
triangle (protagonist of the screen, visually forgettable). Only the left
local pad was labeled. No altitude cue. The 'stochastic gradient noise' label
floated orphaned in the top-left.
- Ship rebuilt as layered Graphics: halo + body + crisp stroke + interior highlight
- Flame layered into outer glow + brighter core ($0xffd28a)
- Right local pad now labeled symmetrically with the left
- New altitude-reference dashed line from ship to the surface beneath
(only drawn when headroom > 18px so it doesn't crowd touchdown)
- 'stochastic gradient noise' → 'loss landscape', repositioned at the basin
wash so it reads as chart annotation, not free-floating decoration
Lens-bounded change. Iter 7 (replay loop & shareability) is next.
Six possible outcomes, but all produced the identical aha card. The OOM event
didn't even end the game — flashed text once and let play continue. And
'GRADIENT EXPLOSION' misuses ML terminology (gradient explosion is NaN
propagation, not landing in the wrong place).
- state.reason tracks which failure occurred ('win'|'diverged'|'local-min'|
'off-course'|'missed-basin'|'oom')
- 'GRADIENT EXPLOSION' → 'MISSED THE BASIN' (accurate to loss-surface metaphor)
- OOM now properly ends the game with full juice (matches real training)
- New AHA[reason] map: six distinct messages, each mapping the failure to its
real ML systems counterpart
- api.aha(reason) returns the right card; lander.qmd attachAha consumes it
- Bug fix: state.gameOverFired guard so onGameOver fires once, not every frame
Lens-bounded change. Iter 6 (visual polish) is next.
The HUD lived in a DOM strip below the canvas. Player couldn't read VRAM
and watch the ship simultaneously without an eye-flick worth ~200ms — long
enough to crash. Both VRAM and speed were text-only.
- VRAM vertical bar (top-right), color shifts blue → orange → red as memory depletes
- Descent-speed horizontal bar (bottom-left) with explicit green safe-zone,
red danger-zone, and 'soft-landing limit ↑' threshold marker
- Both bars drawn every frame at top of ticker (never stale during pre-game / post-crash)
- DOM HUD retained for a11y / screen readers; iter 8 will trim its copy
Lens-bounded change. Iter 5 (failure-state pedagogy) is next.
Brutal first-experience. Rotation was 0.08 rad/frame (~4.8 rad/s — a 0.3s tap
rotates ~80°). Terrain slope randomized over [-8, +8] producing run-to-run
variance unrelated to player skill. No predictive feedback whatsoever.
- rotSpeed 0.08 → 0.055 (precision without sluggishness)
- terrain.slope range [-8, +8] → [-4, +4] (less luck, same lesson)
- Soft-landing thresholds: speed 2.0 → 2.4, angle 0.5 → 0.6 rad
- New translucent trajectory marker (30 frames coast-ahead) + faint trail line
- Promoted maxSafeSpeed and maxSafeAngle to named constants for future tuning
Lens-bounded change. Iter 4 (HUD legibility) is next.