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
Copy link
Copy Markdown
Contributor

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

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
Copy link
Copy Markdown

coderabbitai Bot commented Jun 3, 2026

Review Change Stack

📝 Walkthrough

Walkthrough

This PR introduces a complete interactive 3D voyage experience at /voyage using Three.js, featuring deterministic planet generation, real-time gameplay with space combat, NPC pirates, a multi-tier boss gauntlet, and a comprehensive in-game HUD with mobile support.

Changes

Voyage 3D Experience

Layer / File(s) Summary
Data models and planet generation
src/components/voyage/planets.ts
VoyageBand and Planet interfaces define altitude tiers and world layout; buildPlanets() round-robins libraries across bands and places them on golden-angle spokes with deterministic vertical jitter.
State management bridge
src/components/voyage/store.ts
Zustand store useVoyageStore maintains discovery tracking, combat status (health/pirates sunk/doubloons), and boss gauntlet progression; exposes actions for discovery with completion detection, damage increments, and gauntlet state transitions.
Engine initialization and world building
src/components/voyage/engine/VoyageEngine.ts (constants, types, constructor, init, world build)
VoyageEngine constructor sets up Three.js renderer/scene/camera/lighting and particle pools; init() loads ship model (with fallback), builds deterministic starfield/nebula/planets/pirates, attaches event listeners, and seeds store state.
Player input and movement
src/components/voyage/engine/VoyageEngine.ts (input, loop, ship physics, camera, trail)
Keyboard/pointer handlers drive discrete band changes and continuous thrust/yaw; render loop orchestrates frame updates with capped delta; ship physics apply velocity/drag/boundary enforcement and smooth altitude easing; chase camera interpolates position and trail particles spawn during movement.
Planet discovery and enemy AI
src/components/voyage/engine/VoyageEngine.ts (updatePlanets, updateEnemies)
updatePlanets() animates planets, detects discovery within visit range (triggers bursts and completion celebration), and highlights nearby targets for HUD. updateEnemies() manages respawn timers, band-based aggro pursuit with firing logic, patrol drift, and ramming damage with invulnerability checks.
Combat system and projectiles
src/components/voyage/engine/VoyageEngine.ts (updateCombat, updateProjectiles, firing, destroy, damage)
updateCombat() orchestrates invulnerability flash, respawn countdown, firing cooldown, and hull regeneration. updateProjectiles() handles motion, collision (player vs pirates/boss, enemy vs player), and mesh pooling. Firing primitives include travel-time lead targeting, deterministic inaccuracy, and store updates on destruction.
Boss gauntlet sequence
src/components/voyage/engine/VoyageEngine.ts (startGauntlet, spawnBoss, updateBoss, fireBoss, defeatBoss, finishGauntlet)
startGauntlet() clears roaming enemies and activates gauntlet state. spawnBoss() creates tiered boss models with escorts. updateBoss() pursues across yaw and altitude. fireBoss() shoots configurable spreads. defeatBoss()/finishGauntlet() transition tier rewards and champion state while returning pirates.
Visual effects, cleanup, and utilities
src/components/voyage/engine/VoyageEngine.ts (updateBursts, celebrateCompletion, updateEnvironment, resize, dispose, seeded, texture/sprite helpers)
updateBursts() animates particle fading and buffer updates. celebrateCompletion() stages fireworks via timeouts. updateEnvironment() interpolates fog/ambient by band. resize() and dispose() handle lifecycle. Utilities include seeded() pseudo-randomness, makeSoftCircleTexture() for particle sprites, makeLabelSprite() for rounded-pill labels.
Scene mounting and engine lifecycle
src/components/voyage/VoyageScene.tsx
VoyageScene waits for measurable container dimensions, creates and initializes VoyageEngine, wires resize observer for responsive rendering, calls lifecycle methods (init()/start()), and cleans up on unmount via observer disconnect and engine disposal.
Main component and HUD composition
src/components/voyage/SpaceVoyage.tsx, src/components/voyage/ui/VoyageHUD.tsx
SpaceVoyage manages loading state and engine wiring, rendering LoadingOverlay until engine ready. VoyageHUD composes all UI: crosshair (conditional on shipwrecked), intro overlay, altitude band selector with clickable changes, discovery progress (worlds/doubloons), hull health bar, shipwrecked overlay, nearby planet visit card, charted-world toast, victory overlay with gauntlet trigger, boss health bar, Face-the-Armada prompt, Grand-Champion overlay, and mobile touch controls with pointer event handlers for movement/band/firing.
Router integration and page mounting
src/routes/voyage.tsx, src/routeTree.gen.ts
/voyage route lazily loads SpaceVoyage with Suspense fallback (LoadingScreen), sets page metadata (title/description), and defines static Title link. routeTree.gen.ts registers VoyageRoute across FileRoutesByFullPath/To/Id, FileRouteTypes unions, RootRouteChildren, and @tanstack/react-router module augmentation for type-safe routing.

Sequence Diagram

sequenceDiagram
  participant VoyagePage as Route: /voyage
  participant SpaceVoyage
  participant VoyageScene
  participant VoyageEngine
  participant useVoyageStore as Store
  participant VoyageHUD
  participant Input as Keyboard/Pointer
  
  VoyagePage->>SpaceVoyage: render with Suspense
  SpaceVoyage->>SpaceVoyage: useState(isLoading, engine)
  SpaceVoyage->>VoyageScene: render unmounted with callbacks
  VoyageScene->>VoyageEngine: new VoyageEngine(canvas)
  VoyageEngine->>VoyageEngine: constructor: setup Three.js scene
  VoyageScene->>VoyageEngine: init()
  VoyageEngine->>VoyageEngine: load ship, build world, attach listeners
  VoyageEngine->>Store: setBand(0) seed state
  VoyageEngine-->>VoyageScene: init resolved
  VoyageScene->>VoyageScene: onEngineReady(engine)
  SpaceVoyage->>SpaceVoyage: setEngine(engine), setIsLoading(false)
  SpaceVoyage->>VoyageHUD: render with engine
  SpaceVoyage->>VoyageEngine: start()
  loop game loop every frame
    Input->>VoyageEngine: setKey(code, pressed) / changeBand(dir)
    VoyageEngine->>VoyageEngine: updateShip/Camera/Planets/Enemies/Combat/Bursts
    VoyageEngine->>Store: setBand/addDovered/addPirate/damageHealth/etc
    VoyageEngine->>VoyageEngine: render scene
    VoyageHUD->>Store: read band/nearby/health/doubloons/boss state
    VoyageHUD->>VoyageHUD: render UI panels
  end
  Note over VoyageHUD,VoyageEngine: Player visits planet
  VoyageEngine->>Store: addDiscovered(planetId)
  Store->>Store: compute completed flag, increment lastCharted.tick
  VoyageHUD->>VoyageHUD: charted toast appears
  Note over VoyageHUD,VoyageEngine: Combat with pirate
  Input->>VoyageEngine: firePlayer()
  VoyageEngine->>Store: incrementPirateSunk()
  VoyageHUD->>VoyageHUD: update pirates sunk counter
Loading

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~60 minutes

Poem

🚀 A Voyage Through the Stars

In Three.js skies, the planets gleam,
A pirate gauntlet, an epic dream!
Round golden spokes the worlds do spin,
Combat coursing: let the adventure begin,
Touch controls guide your starship's flight,
Charting worlds in the endless night! 🌟

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 57.14% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title 'feat(voyage): add Space Voyage 3D mini-game at /voyage' clearly and concisely describes the main change—adding a new 3D mini-game feature at the /voyage route.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch jonny/reverent-ptolemy-1557c5

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

Copy link
Copy Markdown

@coderabbitai coderabbitai Bot left a comment

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
engine
.init()
.then(() => {
engine.start()
onLoadingChange?.(false)
onEngineReady?.(engine)
})
.catch((err) => {
console.error('VoyageEngine init failed:', err)
onLoadingChange?.(false)
})

return () => {
resizeObserver.disconnect()
onEngineReady?.(null)
engine.dispose()
engineRef.current = null
}
}, [isReady, onLoadingChange, onEngineReady])
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