feat(voyage): add Space Voyage 3D mini-game at /voyage by jhislop-design · Pull Request #958 · TanStack/tanstack.com · GitHub
Skip to content

feat(voyage): add Space Voyage 3D mini-game at /voyage#958

Open
jhislop-design wants to merge 1 commit into
mainfrom
jonny/reverent-ptolemy-1557c5
Open

feat(voyage): add Space Voyage 3D mini-game at /voyage#958
jhislop-design wants to merge 1 commit into
mainfrom
jonny/reverent-ptolemy-1557c5

Conversation

@jhislop-design

@jhislop-design jhislop-design commented Jun 3, 2026

Copy link
Copy Markdown
Contributor

Overview

A fun, spacefaring cousin of /explore: captain a flying star-galleon through three altitude dimensions (low / mid / high orbit) to chart every TanStack library as a glowing planet — go high, go low — while fending off space pirates and, once you've charted them all, taking on an end-game boss gauntlet.

Lives at /voyage.

What's included

Flight & discovery

  • Self-contained vanilla Three.js engine (reuses the existing ship.glb + modelLoader; independent of the Island Explorer game store).
  • Starfield + nebula backdrop, flying ship with banking/pitch + stardust trail, heading-relative chase camera.
  • Three stacked altitude bands you climb/dive between (Q/E) — the 18 libraries are split across them, so you must change dimension to find them all.
  • Planets light up in brand color on discovery; click one (or its "Visit" card) to open the library page.

Combat

  • Forward-firing cannons (hold Space) with crosshair, cooldown, and a stardust muzzle/trail.
  • Pirate enemy ships with patrol / pursue / fire AI, gated to the player's band.
  • Player hull + damage, shipwreck → respawn with brief grace, and gentle hull self-repair when out of the fight.

Rewards

  • Per-world firework + toast + doubloons (250 each), running treasure counter.
  • "Voyage Complete!" victory screen once all 18 worlds are charted.

End-game boss gauntlet

  • Three escalating bosses — Bronze Marauder → Silver Corsair → Gold Dread Admiral — each bigger, with more hull, heavier/spread fire, escorts at higher tiers, and big doubloon bounties.
  • Bosses hunt you across dimensions; a boss health bar shows name/tier/HP.
  • Clearing all three triggers a fireworks finale and a Grand Champion screen.

HUD: altitude gauge, discovery progress, hull + pirates-sunk, crosshair, nearby-world card, boss bar, victory/champion overlays, and mobile touch controls (d-pad + fire).

The route is lazy-loaded so the Three.js bundle stays out of the main chunk.

Files

  • src/routes/voyage.tsx — lazy-loaded route (mirrors explore.tsx)
  • src/components/voyage/SpaceVoyage (container), VoyageScene (canvas wrapper), planets.ts (data), store.ts (zustand HUD bridge), engine/VoyageEngine.ts (Three.js engine), ui/VoyageHUD.tsx (overlays)

Reviewer notes

  • src/routeTree.gen.ts is hand-edited to register /voyage. The router generator's file watcher didn't fire in this worktree, so the entries were added manually (mirroring /explore). A normal dev/build run will regenerate the file identically since voyage.tsx exists.
  • Engine pauses on visibilitychange (intended) — backgrounded tabs don't simulate.
  • Verified in-browser: flight/altitude bands, discovery + rewards, combat (take/deal damage, respawn), and the full gauntlet through Grand Champion. Voyage files pass tsc + oxlint clean; remaining lint warnings are pre-existing in unrelated files.

🤖 Generated with Claude Code

Summary by CodeRabbit

  • New Features
    • Launched an interactive 3D space voyage experience with explorable planets across multiple altitude bands.
    • Added ship navigation controls (keyboard and mobile touch) with altitude adjustment and combat mechanics.
    • Integrated a discovery system tracking explored worlds, rewards, and hull integrity.
    • Included an end-game boss gauntlet with tiered encounters and visual particle effects.

A spacefaring cousin of /explore: captain a flying star-galleon through
three altitude "dimensions" (low/mid/high orbit) to chart every TanStack
library as a glowing planet — go high, go low.

What's included:
- Self-contained vanilla Three.js engine (reuses the existing ship.glb +
  modelLoader; independent of the Island Explorer game store): starfield,
  nebula backdrop, flying ship with banking + stardust trail, chase camera,
  layered altitude bands, and click-to-visit planet raycasting.
- Combat: forward-firing cannons (hold Space), pirate enemy ships with
  patrol/pursue/fire AI, projectiles, player hull + damage + shipwreck/
  respawn with grace, gentle hull regen.
- Rewards: per-world firework + toast + doubloons, and a "Voyage Complete"
  victory screen once all worlds are charted.
- End-game boss gauntlet: three escalating bosses (Bronze/Silver/Gold) that
  hunt you across dimensions, with a boss health bar, escorts, doubloon
  bounties, and a Grand Champion finale.
- HUD: altitude gauge, discovery progress, hull + pirates-sunk, crosshair,
  nearby-world card, and mobile touch controls.

Lazy-loaded route keeps the Three.js bundle out of the main chunk.

Note: src/routeTree.gen.ts was hand-edited to register /voyage (the router
generator's watcher didn't fire in this worktree); a normal dev/build run
will regenerate it identically.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@coderabbitai

coderabbitai Bot commented Jun 3, 2026

Copy link
Copy Markdown

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 2

🧹 Nitpick comments (4)
src/components/voyage/engine/VoyageEngine.ts (1)

301-342: 💤 Low value

Consider sharing the soft-circle texture between trail and burst materials.

makeSoftCircleTexture() is called twice (lines 308 and 341), creating two identical CanvasTextures. Sharing a single texture instance would reduce GPU memory usage.

♻️ Suggested refactor
+  private softCircleTexture: THREE.CanvasTexture

   constructor(canvas: HTMLCanvasElement) {
     // ... earlier code ...
+    this.softCircleTexture = makeSoftCircleTexture()
     
     this.trailMat = new THREE.PointsMaterial({
       size: 1.4,
       transparent: true,
       opacity: 0.9,
       vertexColors: true,
       depthWrite: false,
       blending: THREE.AdditiveBlending,
-      map: makeSoftCircleTexture(),
+      map: this.softCircleTexture,
     })
     // ...
     this.burstMat = new THREE.PointsMaterial({
       size: 2.4,
       transparent: true,
       opacity: 1,
       vertexColors: true,
       depthWrite: false,
       blending: THREE.AdditiveBlending,
-      map: makeSoftCircleTexture(),
+      map: this.softCircleTexture,
     })

Then dispose it once in dispose():

+    this.softCircleTexture.dispose()
     this.burstMat.map?.dispose()
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/components/voyage/engine/VoyageEngine.ts` around lines 301 - 342, The two
calls to makeSoftCircleTexture() create duplicate CanvasTexture instances for
this.trailMat and this.burstMat; instead, create a single shared texture (e.g.,
this.softCircleTexture = makeSoftCircleTexture()) and pass that same texture to
both this.trailMat and this.burstMat; update the class dispose() method to call
dispose() on this.softCircleTexture when cleaning up. Ensure references to
trailMat, burstMat, makeSoftCircleTexture, and dispose() are used to locate the
changes.
src/components/voyage/planets.ts (2)

90-128: 💤 Low value

Optional: replace the magic 3 with RING_RADII.length for consistency.

radius hardcodes (j % 3) while ringRadius already uses j % RING_RADII.length. Keeping both tied to RING_RADII.length avoids divergence if the ring count changes later.

♻️ Suggested tweak
-        radius: 5.5 + (j % 3) * 1.6,
+        radius: 5.5 + (j % RING_RADII.length) * 1.6,
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/components/voyage/planets.ts` around lines 90 - 128, In buildPlanets(),
the radius computation uses a hardcoded 3 while ringRadius uses
RING_RADII.length; change the radius expression from (j % 3) to (j %
RING_RADII.length) so both place/ring calculations remain consistent if
RING_RADII changes—update the radius line in the planets.push object accordingly
(referencing buildPlanets, radius, and RING_RADII).

60-80: 💤 Low value

Reduce drift by deriving PLANET_COLORS from library brand colors

PLANET_COLORS hardcodes per-library hex values, but each library already carries brand styling in ~/libraries via Tailwind colorFrom/colorTo. src/utils/npm-packages.ts includes getLibraryColor(library) to convert colorFrom → hex, but its Tailwind→hex map covers only some from-* classes and otherwise falls back to defaultColors[0], so a direct swap may not preserve current planet colors. Extend/adjust that conversion so voyage planets can use the same ~/libraries source of truth instead of maintaining a parallel PLANET_COLORS map.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/components/voyage/planets.ts` around lines 60 - 80, Replace the hardcoded
PLANET_COLORS map with values derived from the libraries' Tailwind brand colors
by calling getLibraryColor(library) (which converts colorFrom/colorTo into hex);
update getLibraryColor in npm-packages (and its fallback/defaultColors logic) to
include all used from-* Tailwind classes (or at least the specific classes
referenced by your libraries) and ensure it returns the exact current hex values
for those classes (or falls back to the original hex values currently in
PLANET_COLORS) so voyage planets use the ~/libraries source of truth without
visual drift.
src/components/voyage/ui/VoyageHUD.tsx (1)

293-296: ⚡ Quick win

Open the "Visit" link in a new tab.

Same-tab navigation drops the player out of the in-progress voyage (charted worlds, doubloons, gauntlet state are all client-only and lost). Opening externally preserves the game.

♻️ Proposed change
     <a
       href={nearby.url}
+      target="_blank"
+      rel="noopener noreferrer"
       className="absolute bottom-28 md:bottom-6 left-1/2 -translate-x-1/2 z-20 pointer-events-auto group"
     >
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/components/voyage/ui/VoyageHUD.tsx` around lines 293 - 296, The "Visit"
anchor using nearby.url in VoyageHUD.tsx currently opens in the same tab and
must open in a new tab to preserve in-memory voyage state; update the <a>
element that references nearby.url (the anchor in the VoyageHUD component) to
include target="_blank" and rel="noopener noreferrer" attributes so the link
opens externally and avoids security issues.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@src/components/voyage/ui/VoyageHUD.tsx`:
- Around line 570-577: The two icon-only controls using TouchBtn with labels
ChevronUp and ChevronDown lack accessible names; update these TouchBtn usages to
include aria-label attributes (e.g., aria-label="Climb" for the ChevronUp button
and aria-label="Dive" for the ChevronDown button) so screen readers can announce
their purpose; you can still keep the onClick handlers calling
engine.changeBand(1) and engine.changeBand(-1) and the visual icons (ChevronUp,
ChevronDown) unchanged.

In `@src/components/voyage/VoyageScene.tsx`:
- Around line 64-82: The continuation after engine.init() must be guarded so it
doesn't act on a disposed/unmounted engine: add a cancellation flag (e.g., let
cancelled = false) or use an AbortController inside the effect and check it
before calling engine.start(), onLoadingChange(false) and onEngineReady(engine)
in the .then() and .catch() handlers; set cancelled = true (or abort) in the
cleanup before calling engine.dispose() and clearing engineRef/current and
disconnecting resizeObserver so any in-flight promise bails instead of touching
the torn-down engine instance.

---

Nitpick comments:
In `@src/components/voyage/engine/VoyageEngine.ts`:
- Around line 301-342: The two calls to makeSoftCircleTexture() create duplicate
CanvasTexture instances for this.trailMat and this.burstMat; instead, create a
single shared texture (e.g., this.softCircleTexture = makeSoftCircleTexture())
and pass that same texture to both this.trailMat and this.burstMat; update the
class dispose() method to call dispose() on this.softCircleTexture when cleaning
up. Ensure references to trailMat, burstMat, makeSoftCircleTexture, and
dispose() are used to locate the changes.

In `@src/components/voyage/planets.ts`:
- Around line 90-128: In buildPlanets(), the radius computation uses a hardcoded
3 while ringRadius uses RING_RADII.length; change the radius expression from (j
% 3) to (j % RING_RADII.length) so both place/ring calculations remain
consistent if RING_RADII changes—update the radius line in the planets.push
object accordingly (referencing buildPlanets, radius, and RING_RADII).
- Around line 60-80: Replace the hardcoded PLANET_COLORS map with values derived
from the libraries' Tailwind brand colors by calling getLibraryColor(library)
(which converts colorFrom/colorTo into hex); update getLibraryColor in
npm-packages (and its fallback/defaultColors logic) to include all used from-*
Tailwind classes (or at least the specific classes referenced by your libraries)
and ensure it returns the exact current hex values for those classes (or falls
back to the original hex values currently in PLANET_COLORS) so voyage planets
use the ~/libraries source of truth without visual drift.

In `@src/components/voyage/ui/VoyageHUD.tsx`:
- Around line 293-296: The "Visit" anchor using nearby.url in VoyageHUD.tsx
currently opens in the same tab and must open in a new tab to preserve in-memory
voyage state; update the <a> element that references nearby.url (the anchor in
the VoyageHUD component) to include target="_blank" and rel="noopener
noreferrer" attributes so the link opens externally and avoids security issues.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 1e792992-d334-4164-938e-a20d13bda97b

📥 Commits

Reviewing files that changed from the base of the PR and between 4363ad0 and 26c416f.

📒 Files selected for processing (8)
  • src/components/voyage/SpaceVoyage.tsx
  • src/components/voyage/VoyageScene.tsx
  • src/components/voyage/engine/VoyageEngine.ts
  • src/components/voyage/planets.ts
  • src/components/voyage/store.ts
  • src/components/voyage/ui/VoyageHUD.tsx
  • src/routeTree.gen.ts
  • src/routes/voyage.tsx

Comment on lines +570 to +577
<TouchBtn
label={<ChevronUp className="w-5 h-5" />}
onClick={() => engine.changeBand(1)}
/>
<TouchBtn
label={<ChevronDown className="w-5 h-5" />}
onClick={() => engine.changeBand(-1)}
/>

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

Add accessible names to icon-only controls.

The climb/dive buttons render only ChevronUp/ChevronDown icons with no text, so they expose no accessible name to screen readers. Add aria-labels.

♿ Proposed change
             <TouchBtn
               label={<ChevronUp className="w-5 h-5" />}
+              aria-label="Climb"
               onClick={() => engine.changeBand(1)}
             />
             <TouchBtn
               label={<ChevronDown className="w-5 h-5" />}
+              aria-label="Dive"
               onClick={() => engine.changeBand(-1)}
             />
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
<TouchBtn
label={<ChevronUp className="w-5 h-5" />}
onClick={() => engine.changeBand(1)}
/>
<TouchBtn
label={<ChevronDown className="w-5 h-5" />}
onClick={() => engine.changeBand(-1)}
/>
<TouchBtn
label={<ChevronUp className="w-5 h-5" />}
aria-label="Climb"
onClick={() => engine.changeBand(1)}
/>
<TouchBtn
label={<ChevronDown className="w-5 h-5" />}
aria-label="Dive"
onClick={() => engine.changeBand(-1)}
/>
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/components/voyage/ui/VoyageHUD.tsx` around lines 570 - 577, The two
icon-only controls using TouchBtn with labels ChevronUp and ChevronDown lack
accessible names; update these TouchBtn usages to include aria-label attributes
(e.g., aria-label="Climb" for the ChevronUp button and aria-label="Dive" for the
ChevronDown button) so screen readers can announce their purpose; you can still
keep the onClick handlers calling engine.changeBand(1) and engine.changeBand(-1)
and the visual icons (ChevronUp, ChevronDown) unchanged.

Comment on lines +64 to +82

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Guard the async init() continuation against unmount/dispose.

If the component unmounts (or the effect re-runs) while init() is still pending, the cleanup at Line 76 disposes the engine, but the in-flight promise still resolves and calls engine.start() on an already-disposed engine, plus onEngineReady(engine)/onLoadingChange(false) with a dead instance. dispose() sets disposed = true and stops the loop, so start() re-entering here restarts the render loop against torn-down resources.

Track cancellation and bail out in both the then and catch.

🛠️ Proposed fix
     const engine = new VoyageEngine(canvas)
     engineRef.current = engine
+    let cancelled = false

     const resizeObserver = new ResizeObserver((entries) => {
       const entry = entries[0]
       if (entry && engineRef.current) {
         const { width, height } = entry.contentRect
         engineRef.current.resize(width, height)
       }
     })
     resizeObserver.observe(container)

     onLoadingChange?.(true)
     engine
       .init()
       .then(() => {
+        if (cancelled) return
         engine.start()
         onLoadingChange?.(false)
         onEngineReady?.(engine)
       })
       .catch((err) => {
         console.error('VoyageEngine init failed:', err)
+        if (cancelled) return
         onLoadingChange?.(false)
       })

     return () => {
+      cancelled = true
       resizeObserver.disconnect()
       onEngineReady?.(null)
       engine.dispose()
       engineRef.current = null
     }
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/components/voyage/VoyageScene.tsx` around lines 64 - 82, The continuation
after engine.init() must be guarded so it doesn't act on a disposed/unmounted
engine: add a cancellation flag (e.g., let cancelled = false) or use an
AbortController inside the effect and check it before calling engine.start(),
onLoadingChange(false) and onEngineReady(engine) in the .then() and .catch()
handlers; set cancelled = true (or abort) in the cleanup before calling
engine.dispose() and clearing engineRef/current and disconnecting resizeObserver
so any in-flight promise bails instead of touching the torn-down engine
instance.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant