product

Graphene VR — Walk Mode (walkable story exploration)

Status: plan / not built (2026-07-10). Sibling to the standing Listening Room — a second VR mode where, instead of standing inside a photosphere, you walk through a 3D environment and physically discover the story. Grew out of the Graphene lore: memory marbles buried at coordinates and journal pages blown by the wind through a forest (from a garbage truck full of journals).

The bet

The listening room proved the URL-distributable WebXR surface. Walk mode is the next axis of immersion: locomotion + a real 3D scene + spatial discovery. The Graphene lore already describes it — marbles you dig up, pages you chase — and, crucially, those mechanics map almost 1:1 onto systems we already shipped. We build it on our own stack (Sketchfab is an asset source + a prototype tool, never the product surface — a viewer we can't wire to Drift/Timelines/coins/branding). Distribution stays a URL; no App Store gate.

Sketchfab's role (decided)

  • Prototype (yes): annotate a forest scene with the story beats + share the VR link for a same-day "does walk-to-uncover work?" gut-check. Zero code.
  • Asset source (yes): download low-poly environment GLBs (forest, road, props) and load them into our player. Check each model's license first (many are CC-BY w/ attribution; some are paid/store-only) and keep an attribution page.
  • Product surface (no): it's a viewer, not an engine we can extend — can't touch drift_treasures, record a Timelines choice, brand it, or own the URL. Re-introduces the gatekeeper we escaped.

What already exists (reuse, don't rebuild)

  • WebXR stack + the renderer-agnostic contractcreateXRStore()/<XR>/<XROrigin>, the VrStory/VrChapter shapes (grapheneChapters.ts), and the dynamic(ssr:false) page pattern. Walk mode is a new scene over the same data + stack. Inherits all the fiber-v9 / xr-v6 landmines (IMMERSIVE_3D_HANDOFF.md).
  • GLB environment loading — PROVEN. DomeScene.tsx already loads a real compressed model: useGLTF('/models/dome.glb', true) + useGLTF.preload(...), Draco+WebP. Same pattern loads a forest GLB from public/models/. This de-risks the single scariest piece.
  • Locomotion — built into our pinned XR. @react-three/xr v6.6.9 ships a controller-locomotion module (dist: controller-locomotion.js) for smooth stick movement, plus teleport support. No new dep. (Confirm exact hook/component name — useXRControllerLocomotion / TeleportTarget — at build.)
  • Memory marbles ≈ drift_treasures (migration 158_drift.sql). An author already plants a verbatim passage (text_anchor) keyed by (season_id, episode_number) with reward (coins), hint, description; a reader "finds" it via server-validated match → wallet credit; per-reader claims in drift_treasure_claims (unique(treasure_id, user_id)). Load-bearing privacy rule: the public endpoint strips text_anchor — the client only ever gets the hint until a validated find, so a VR client cannot pre-fetch marble contents. Client libs lib/drift.ts, routes/drift.ts, services/drift.ts. The only gap: no spatial coordinate (see Phase 2).
  • Wind-blown pages ≈ StreamNote drift. StreamXRScene.tsx is literally "notes drift past on the current" — floating planes animated in useFrame. Reskin StreamNote as a journal page tumbling through the forest; catching one = a Drift find.
  • NarrationuseAudioFirstPlayer + applyChapterMediaSession play the chapter audio as you explore (same as the room).

Phased plan

Phase 0 — Sketchfab feel-test (no code) ⟵ do this first

Upload/annotate a forest scene with the 5 story beats, open it in the Quest browser's VR mode, share the link. One question: does "walk a space to uncover a story" land? Go/no-go before any build.

Phase 1 — The walkable shell ✅ SHIPPED (2026-07-10)

The demoable core: put on a Quest, open a URL, walk through a forest with the narration playing.

Shipped: route /seasons/[id]/vr/walk (page) → StoryWalkRoom.tsx (WebXR detect + fetchVrStory, scene loaded dynamic(ssr:false)) → StoryWalkScene.tsx. Locomotion: teleport (TeleportTarget on the ground → moves the player-origin group) + smooth thumbstick walk with snap-turn (useXRControllerLocomotion, comfort) in VR; pointer-lock + WASD on desktop (gated to non-session via useXR(s=>s.session)). Environment: a procedural low-poly forest (two instanced meshes — trunks + foliage — on a ground plane) as a placeholder so the loop works with no asset; swapping in a real forest GLB is a one-component change (<Trees/>useGLTF('/models/forest.glb'), the DomeScene pattern). Minimal narration (chapter-1 audio) plays as ambience; entry cross-links from the listening room ("walk it →") and back. Reachable + discoverable. Positions/scales/speeds are first-guesses to tune on-device.

Original Phase-1 spec below.

  • New mode + route /seasons/[id]/vr/walk (kept separate from the room — different comfort + perf profile), scene dynamic(ssr:false), new StoryWalkScene.tsx cloned from the room's scaffolding.
  • Environment: a low-poly forest GLB in public/models/ loaded via the DomeScene useGLTF pattern (Draco/meshopt-compressed). Instanced trees where possible.
  • Locomotion: teleport-first (max comfort + the only model that works on Apple Vision Pro's gaze-pinch — no thumbstick). Optional smooth stick locomotion behind a comfort toggle for controller users, via the controller-locomotion module. Add a comfort vignette on smooth move. The room's "viewer never moves" rule is deliberately replaced here by explicit, comfort-gated locomotion.
  • Desktop fallback: pointer-lock WASD walk (or OrbitControls dolly) so it's testable without a headset.
  • Ground: teleport targets / simple collision on the forest floor so you can't walk off the world.

Phase 2 — Memory marbles (buried Drift finds) ✅ v1 SHIPPED (2026-07-11, pure discovery)

Place drift_treasures as marbles in the scene; walk up + dig/gaze-dwell to uncover.

Shipped v1 (reuse-only, no backend/migration): each active Drift treasure for the season becomes a glowing memory marble buried at a deterministic spot (hash01(treasure.id) → angle+radius — no authoring UI yet), its hint floating above. The "dig" is proximity: walk within ~2.3m and the memory surfaces — the marble turns ember-gold and its description is revealed (Marble/Marbles in StoryWalkScene.tsx). Proximity works identically in VR (headset camera) and on desktop (WASD), and dodges pointer-lock click issues. Data via a new anonymous-safe fetchDriftTreasures(seasonId) (lib/drift.ts) hitting the privacy-stripped public read (GET /api/drift/seasons/:id/treasures) — the client never receives text_anchor, so this is pure spatial discovery. A 2D "Memories found: N/M" counter (pre-VR/desktop).

Phase 2b — coin-crediting ✅ SHIPPED (2026-07-11): signed-in walkers now earn the treasure's coins on a find (reveal-only stays the anonymous default). A new POST /api/drift/treasures/:id/vr-claim (authenticateUser) → claimTreasureSpatial (services/drift.ts) claims by id — the find is proximity, not a prose selection — crediting the reward once per user (idempotent, honors max_claims, mutateBalance treasure_claim). Client claimVrTreasure(id) (lib/drift.ts) is fired from the marble's onFound; anonymous callers 401 and the error is swallowed (reveal-only), so no auth check is needed in-scene. A "+N 🪙" appears on the desktop/pre-VR counter, and it persists to the Drift wallet (invalidateWalletCache on award). Accepted tradeoff: weaker anti-cheat than the text-anchor flow (a walker can see marble ids), bounded by the once-per-treasure claim — fine for a soft currency. Phase 2c — authored coordinates ✅ SHIPPED (2026-07-11): an owner can bury a marble deliberately instead of relying on scatter. Migration 469 adds a nullable vr_position jsonb ({x,z}) to drift_treasures; the public read returns it (safe — a coordinate isn't the answer); the Marble honors it (else scatter). Authoring is an in-scene "author drop" mode (StoryWalkScene.tsx): open /seasons/[id]/vr/walk?author=1 on desktop, walk (WASD) to a spot, and "📍 Drop next memory here" places the next un-positioned marble at your feet via PATCH /api/drift/treasures/:id/vr-position (owner-gated by the existing requireTreasureOwner; non-owners 403). Author on desktop → experience in VR. Remaining deferred: revealing the verbatim passage (text_anchor) rather than description would need a claim or a dedicated stripped reveal column.

Original design notes:

  • Coordinates: add an optional vr_position jsonb ({x,z} on the walkable plane, optional y) to drift_treasures (migration) so an author can place a marble deliberately; fallback = deterministic scatter (hash treasure.id → a position within the walkable area) so unplaced marbles still appear consistently.
  • Find flow: approach → a faint glow/marble at the spot showing only the hint; "dig" (gaze/point-dwell or a grab) submits to the existing server-validated discover endpoint → on match, the marble surfaces, the passage is revealed, coins credited. Respects anchor-privacy by construction (never ship raw anchors to the client). Reuse lib/drift.ts + a small VR-claim path if needed.

Phase 3 — Wind-blown journal pages ✅ v1 SHIPPED (2026-07-11, atmosphere)

Ambient pages tumbling through the forest.

Shipped v1 (atmosphere only): ~20 parchment WindPage planes drift along a wind vector through the forest (flutter rotation + recycle upwind), distilling StreamXRScene's drifting-notes idea to cheap planes — the "journals blown from the garbage truck" image from the lore. Textless by design: fluttering text is unreadable and the legible, interactive story fragments live on the memory marbles; these set mood. Follow-ups: a garbage-truck source GLB (needs a model) and optional catch-to-read pages (would overlap the marble mechanic — deliberately kept distinct for now).

Original design notes:

  • Spawn drifting page planes (reuse the StreamNote useFrame drift motion) from a spawn point — optionally a garbage-truck GLB prop as the source, straight from the lore.
  • Catch/gaze a page → reveal a Drift passage (or a chapter excerpt). Pages are atmosphere and discovery; density + wind speed tuned on-device.

Phase 4 — Wire to the story

  • Chapter narration plays as you explore (useAudioFirstPlayer + MediaSession), optionally gating which marbles/pages are live per chapter.
  • Same renderer-agnostic posture as the room: walk mode is one more renderer over VrStory — a future native visionOS/RealityKit renderer reuses the data + Drift/choice logic (kept server-side).

Net-new work vs. reuse

PieceStatus
Walkable environment (GLB load)reuse — DomeScene useGLTF pattern
Locomotion (teleport / smooth)reuse@react-three/xr v6 controller-locomotion
Marble find / claim / coins / privacyreusedrift_treasures + lib/drift.ts
Wind-page drift motionreuseStreamNote in StreamXRScene
Narration + now-playing cardreuseuseAudioFirstPlayer
Spatial coordinates for treasuresnew — small schema add (vr_position) + scatter fallback
"Dig" interaction + page spawn/catchnew-ish — mostly wiring existing find flow to a 3D gesture
The walkable scene itself + comfort tuningnew — the genuinely new surface

Landmines / tradeoffs (eyes open)

  1. Comfort. Locomotion is the #1 nausea risk. Teleport-first, vignette on any smooth move, fixed horizon, no forced camera motion. This is a real design shift from the room's stand-still rule.
  2. Performance. A dense photogrammetry forest = frame-rate death on a standalone Quest — the opposite of the room's near-zero-GPU thesis. Use low-poly + instancing + frustum culling + a texture budget + Draco/meshopt compression (dome.glb is already Draco+WebP). Target 72fps on Quest 2/3.
  3. Licensing. Sketchfab GLBs carry per-model licenses — verify before shipping, prefer CC0/CC-BY, keep an attribution page.
  4. AVP. Gaze-pinch has no thumbstick → teleport via a gaze-pinch target; smooth locomotion is controller-only. Design teleport-first so both platforms work from one build.
  5. Built blind. Same screenshot-iterate loop as the Dome — locomotion feel + marble/page placement especially need on-device tuning.

Open decisions

  1. Mode boundary — separate /vr/walk route (recommended: distinct comfort/perf/scene) vs. a toggle inside the existing room.
  2. Environment source — licensed low-poly pack now vs. commissioned/generated later. Recommend: start with one licensed low-poly forest to prove the loop.
  3. Marble placement — author-set coords vs. deterministic scatter. Recommend: optional coords + scatter fallback (both).
  4. Locomotion — teleport-only (max comfort + AVP-compatible) vs. teleport + optional smooth. Recommend: teleport-first, smooth behind a comfort toggle.
  5. Which story first — the same demo season as the room, or a Graphene-lore-native "forest" story authored for walk mode.
GRAPHENE VR WALK MODE — Docs | HiveJournal