diff --git a/README.md b/README.md
index 3a57190..d281fb9 100644
--- a/README.md
+++ b/README.md
@@ -197,6 +197,7 @@ Two important conventions:
| `motion.js` | `prefers-reduced-motion` matchMedia gate, live-updates | `prefersReducedMotion` |
| `analytics.js` | Thin wrapper over Umami, with once-per-session helper | `trackEvent`, `trackOnce` |
| `stats.js` | Collective-distance API client (Turso-backed via /api/distance) | `fetchTotalDistance`, `reportSessionDistance` |
+| `milestones.js` | Distance threshold table + once-per-session edge detection | `MILESTONES`, `checkMilestone` |
---
@@ -323,6 +324,7 @@ Tracked events:
| `max-speed` | First time speed reaches MAX_SPEED, once per session | `{ distance }` |
| `first-near-miss` | First NEAR MISS trigger, once per session | `{ distance }` |
| `music-mute` / `music-unmute` | Each click on the ♪ button | – |
+| `milestone-reached` | Each time the rocket crosses a new distance threshold | `{ au }` |
Distances are integer AU. Nothing user-identifying is sent. Remove the Umami script tag (or fork without it) if you'd rather not be counted.
diff --git a/index.html b/index.html
index abf5001..a4779e2 100644
--- a/index.html
+++ b/index.html
@@ -68,6 +68,11 @@
LITTLE ROCKET
NEAR MISS
+
+
+
+
+
diff --git a/src/main.js b/src/main.js
index d970b01..1ab253b 100644
--- a/src/main.js
+++ b/src/main.js
@@ -31,6 +31,7 @@ import { initMusic, startMusic, toggleMusic, isMuted, hasMusic } from './music.j
import { startStory } from './story.js';
import { trackOnce, trackEvent } from './analytics.js';
import { fetchTotalDistance, reportSessionDistance } from './stats.js';
+import { checkMilestone } from './milestones.js';
const ROT_SPEED = 0.02; // radians per 60fps-frame
const ACCEL = 0.05; // speed delta per 60fps-frame
@@ -56,6 +57,8 @@ fetchTotalDistance().then((stats) => {
const HINT_VISIBLE_MS = 6000;
const HINT_FADE_MS = 400; // matches --ij-duration-slow in CSS
const NEAR_MISS_VISIBLE_MS = 700;
+const MILESTONE_VISIBLE_MS = 3200;
+const MILESTONE_FADE_MS = 600; // matches CSS transition
const INTRO_DURATION = 1.6; // seconds of cinematic intro before player takes control
const INTRO_FOV_START = 50; // narrow FOV → opens up to FOV_IDLE
const INTRO_CAM_DISTANCE = 60; // how far back the camera starts behind the rocket
@@ -168,6 +171,26 @@ function run() {
}, NEAR_MISS_VISIBLE_MS);
trackOnce('first-near-miss', { distance: Math.floor(distanceAU) });
}
+
+ const milestoneEl = document.getElementById('milestone-toast');
+ const milestoneDistanceEl = document.getElementById('milestone-distance');
+ const milestoneLabelEl = document.getElementById('milestone-label');
+ let milestoneHideTimer = null;
+ let milestoneRemoveTimer = null;
+ function flashMilestone(milestone) {
+ if (milestoneHideTimer) clearTimeout(milestoneHideTimer);
+ if (milestoneRemoveTimer) clearTimeout(milestoneRemoveTimer);
+ milestoneDistanceEl.textContent = `${milestone.au.toLocaleString()} AU`;
+ milestoneLabelEl.textContent = milestone.label;
+ milestoneEl.hidden = false;
+ void milestoneEl.offsetWidth;
+ milestoneEl.classList.add('visible');
+ milestoneHideTimer = setTimeout(() => {
+ milestoneEl.classList.remove('visible');
+ milestoneRemoveTimer = setTimeout(() => { milestoneEl.hidden = true; }, MILESTONE_FADE_MS);
+ }, MILESTONE_VISIBLE_MS);
+ trackEvent('milestone-reached', { au: milestone.au });
+ }
const clock = new THREE.Clock();
const forward = new THREE.Vector3();
const camOffset = new THREE.Vector3();
@@ -255,6 +278,7 @@ function run() {
speedEl.textContent = speed.toFixed(1);
distanceAU += speed * realDt;
distanceEl.textContent = `${Math.floor(distanceAU)} AU`;
+ checkMilestone(distanceAU, flashMilestone);
if (isTouch && introDone) {
throttleFill.style.height = `${(speed / MAX_SPEED) * 100}%`;
}
diff --git a/src/milestones.js b/src/milestones.js
new file mode 100644
index 0000000..41aba26
--- /dev/null
+++ b/src/milestones.js
@@ -0,0 +1,31 @@
+/**
+ * Distance milestones — fire celebratory HUD toasts at round-number AU
+ * thresholds. Each milestone fires at most once per session; reload to see
+ * them again.
+ *
+ * Tuned to feel like genuine landmarks rather than every other minute:
+ * the first one rewards a few minutes of flying, the rest space out
+ * exponentially so a long flight always has a next thing to chase.
+ */
+
+export const MILESTONES = [
+ { au: 100, label: 'Past Pluto' },
+ { au: 1000, label: 'Through the heliosphere' },
+ { au: 10000, label: 'Into the Oort cloud' },
+ { au: 100000, label: 'One light-year out' },
+];
+
+const fired = new Set();
+
+/**
+ * Call once per frame. If the rocket has just crossed any unfired milestone
+ * threshold, invokes onTrigger(milestone) — once per threshold per session.
+ */
+export function checkMilestone(distanceAU, onTrigger) {
+ for (const m of MILESTONES) {
+ if (distanceAU >= m.au && !fired.has(m.au)) {
+ fired.add(m.au);
+ onTrigger(m);
+ }
+ }
+}
diff --git a/styles/main.css b/styles/main.css
index 7224af5..ff6df25 100644
--- a/styles/main.css
+++ b/styles/main.css
@@ -274,6 +274,52 @@ h1 {
#near-miss[hidden] { display: none; }
#near-miss.visible { opacity: 1; }
+/* Milestone toast — fires once per session when the rocket crosses a round
+ AU threshold. Bigger and more present than the near-miss flash, but still
+ non-blocking and pointer-event-free. */
+#milestone-toast {
+ position: fixed;
+ top: 28%;
+ left: 50%;
+ transform: translateX(-50%);
+ padding: var(--ij-spacing-lg, 16px) var(--ij-spacing-xxxl, 32px);
+ background: rgba(0, 0, 0, 0.78);
+ border: 2px solid var(--ij-color-primary, #D0FF00);
+ border-radius: var(--ij-radius-md, 12px);
+ box-shadow: 0 0 32px rgba(208, 255, 0, 0.28);
+ text-align: center;
+ pointer-events: none;
+ opacity: 0;
+ transition: opacity 600ms var(--ij-easing-emphasized,
+ cubic-bezier(0.2, 0, 0, 1));
+}
+#milestone-toast[hidden] { display: none; }
+#milestone-toast.visible { opacity: 1; }
+
+.milestone-distance {
+ display: block;
+ font-size: var(--ij-font-size-xl, 24px);
+ font-weight: var(--ij-font-weight-bold, 700);
+ color: var(--ij-color-primary, #D0FF00);
+ letter-spacing: 2px;
+ font-variant-numeric: tabular-nums;
+}
+
+.milestone-label {
+ display: block;
+ margin-top: var(--ij-spacing-xs, 4px);
+ font-size: var(--ij-font-size-sm, 14px);
+ color: var(--ij-color-text-secondary, rgba(255, 255, 255, 0.78));
+ letter-spacing: 1.5px;
+ text-transform: uppercase;
+}
+
+@media (max-width: 480px) {
+ .milestone-distance { font-size: var(--ij-font-size-lg, 18px); }
+ .milestone-label { font-size: var(--ij-font-size-xs, 12px); }
+ #milestone-toast { padding: var(--ij-spacing-md, 12px) var(--ij-spacing-xl, 20px); }
+}
+
/* Throttle bar: vertical fill on the right edge that mirrors current speed.
Visible only on touch devices (no benefit when keyboard handles throttle). */
#throttle-bar {