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 contract —
createXRStore()/<XR>/<XROrigin>, theVrStory/VrChaptershapes (grapheneChapters.ts), and thedynamic(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.tsxalready loads a real compressed model:useGLTF('/models/dome.glb', true)+useGLTF.preload(...), Draco+WebP. Same pattern loads a forest GLB frompublic/models/. This de-risks the single scariest piece. - Locomotion — built into our pinned XR.
@react-three/xrv6.6.9 ships acontroller-locomotionmodule (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(migration158_drift.sql). An author already plants a verbatim passage (text_anchor) keyed by(season_id, episode_number)withreward(coins),hint,description; a reader "finds" it via server-validated match → wallet credit; per-reader claims indrift_treasure_claims (unique(treasure_id, user_id)). Load-bearing privacy rule: the public endpoint stripstext_anchor— the client only ever gets the hint until a validated find, so a VR client cannot pre-fetch marble contents. Client libslib/drift.ts,routes/drift.ts,services/drift.ts. The only gap: no spatial coordinate (see Phase 2). - Wind-blown pages ≈ StreamNote drift.
StreamXRScene.tsxis literally "notes drift past on the current" — floating planes animated inuseFrame. ReskinStreamNoteas a journal page tumbling through the forest; catching one = a Drift find. - Narration —
useAudioFirstPlayer+applyChapterMediaSessionplay 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) → (WebXR detect + StoryWalkRoom.tsxfetchVrStory, scene loaded dynamic(ssr:false)) → . Locomotion: teleport (StoryWalkScene.tsxTeleportTarget 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), scenedynamic(ssr:false), newStoryWalkScene.tsxcloned from the room's scaffolding. - Environment: a low-poly forest GLB in
public/models/loaded via the DomeSceneuseGLTFpattern (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-locomotionmodule. 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 ). Proximity works identically in VR (headset camera) and on desktop (WASD), and dodges pointer-lock click issues. Data via a new anonymous-safe StoryWalkScene.tsxfetchDriftTreasures(seasonId) () hitting the privacy-stripped public read (lib/drift.tsGET /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 (): open StoryWalkScene.tsx/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, optionaly) todrift_treasures(migration) so an author can place a marble deliberately; fallback = deterministic scatter (hashtreasure.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
StreamNoteuseFramedrift 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
| Piece | Status |
|---|---|
| Walkable environment (GLB load) | reuse — DomeScene useGLTF pattern |
| Locomotion (teleport / smooth) | reuse — @react-three/xr v6 controller-locomotion |
| Marble find / claim / coins / privacy | reuse — drift_treasures + lib/drift.ts |
| Wind-page drift motion | reuse — StreamNote in StreamXRScene |
| Narration + now-playing card | reuse — useAudioFirstPlayer |
| Spatial coordinates for treasures | new — small schema add (vr_position) + scatter fallback |
| "Dig" interaction + page spawn/catch | new-ish — mostly wiring existing find flow to a 3D gesture |
| The walkable scene itself + comfort tuning | new — the genuinely new surface |
Landmines / tradeoffs (eyes open)
- 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.
- 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.
- Licensing. Sketchfab GLBs carry per-model licenses — verify before shipping, prefer CC0/CC-BY, keep an attribution page.
- 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.
- Built blind. Same screenshot-iterate loop as the Dome — locomotion feel + marble/page placement especially need on-device tuning.
Open decisions
- Mode boundary — separate
/vr/walkroute (recommended: distinct comfort/perf/scene) vs. a toggle inside the existing room. - Environment source — licensed low-poly pack now vs. commissioned/generated later. Recommend: start with one licensed low-poly forest to prove the loop.
- Marble placement — author-set coords vs. deterministic scatter. Recommend: optional coords + scatter fallback (both).
- Locomotion — teleport-only (max comfort + AVP-compatible) vs. teleport + optional smooth. Recommend: teleport-first, smooth behind a comfort toggle.
- Which story first — the same demo season as the room, or a Graphene-lore-native "forest" story authored for walk mode.
Related
- GRAPHENE_VR_STORY_PLAYER.md — the standing listening room this is a sibling of (shared stack + data contract)
- IMMERSIVE_3D_HANDOFF.md — the WebXR stack + landmines; the Dome is the GLB-loading precedent