Merch Museum 3D — a first-person loot dungeon embedded in a marketplace

Live: Looties – Developer Merch Marketplace | Buy & Sell Tech Swag

Entry point: Looties – Developer Merch Marketplace | Buy & Sell Tech Swag

TL;DR

Looties is a neon C2C marketplace for tech swag. The Merch Museum is its cultural archive of unavailable/sold-out brand merch (OpenAI, Runway, NVIDIA, Kubernetes). We turned that archive into a playable first-person dungeon built with react-three-fiber: you walk a stone hall, smash crates with a sword, and every drop is a real, currently-listed product that links straight to its purchase page. It’s a 3D toy that doubles as a commerce funnel — and it had to live inside an existing React/TS marketplace without bloating it for the 99% of visitors who never open the game.

Stack

  • three.js 0.169 · @react-three/fiber 8 · @react-three/drei 9 · @react-three/postprocessing + postprocessing 6
  • howler for the audio bus (ambient 8-bit bed, gated on first user gesture)
  • React 18 + TypeScript + Vite + Tailwind, deployed on Vercel
  • Supabase as the live loot source (Postgres RPC)

The architecture I’m most happy with

1. A hard capability gate in front of the lazy import.
The whole 3D world — three, r3f, drei, the glTF kit — is a single lazy() chunk. But the route gate runs decideRenderMode(detectCapabilities()) before the dynamic import resolves, so a device with no WebGL is redirected to the static archive page and never downloads three at all. The ~800 kB three-vendor chunk (≈214 kB gzip) is genuinely opt-in; the marketplace’s main bundle is untouched.

2. Clean architecture with an enforced import boundary.
The feature is split domain/ → application/ → presentation/ → infrastructure/, and the domain layer imports neither React nor three nor Supabase (enforced by ESLint no-restricted-imports). All the game logic — maze/room generation, collision resolution, first-person kinematics, loot distribution, fog-of-war, minimap projection, the sword-swing curve — lives as pure, deterministic functions (seeded mulberry32 RNG). r3f is just the renderer at the edge.

3. TDD on a 3D game.
Because the simulation is pure, ~340 of the feature’s tests are plain unit tests. For the scene graph we use @react-three/test-renderer to assert things like “the lighting rig has exactly one ambient + one hemisphere + one directional light” or “every archived image is hung as a framed wall print” — without a GPU. It’s the thing that let this survive a dozen direction changes without rotting.

Technical highlights other r3f folks might like

  • One draw call per tile type. The dungeon is tiled from a single-mesh CC0 glTF (KayKit) via a custom InstancedGltf that extracts the geometry/material once and pushes every cell transform into an InstancedMesh. Floors, walls, and the ceiling (the same floor tile, flipped on X so the textured face points down) are three instanced draws total.
  • Procedural canvas textures drawn at runtime for accent surfaces — which, fun gotcha, meant patching HTMLCanvasElement.prototype.getContext('2d') in the test setup since jsdom has no 2D backend (and then guarding that so node-env test suites don’t blow up).
  • Deterministic level design via BFS. Spawn and exit are placed at the two ends of the map: door = farthest reachable floor cell from a fixed corner, spawn = farthest cell from the door. Same seed → same dungeon, every time.
  • A canvas minimap with fog-of-war rendered in a requestAnimationFrame loop, reading live position/yaw refs (no React state per frame); the player cursor is a triangle oriented to the forward vector (sin yaw, cos yaw).
  • The sword is pure primitives. We sourced a “sword” glTF that turned out to be a flat shield plate, so the blade is now a small group of boxes/cone (blade, tip, guard, grip, pommel) oriented so a Z-axis swing reads as a slash. Zero asset risk, fully controllable.
  • Live loot, real links. The loot pool calls the same get_random_listings Postgres RPC the marketplace homepage uses, maps rows to loot items, and links each break to /listing/{id}/{slug}. An archive-image fallback keeps the game playable when the catalog is empty.

Performance & accessibility

  • LOD profile (DPR, shadows, AO toggled by pointer coarseness), auto-pause when the tab is hidden, prefers-reduced-motion honored (no bob/spin), softened fog so nothing crushes to black, and a guaranteed ambient floor so no walkable area is ever pitch black.
  • Movement is inertia-free pure kinematics with wall collision; ZQSD/WASD/arrow-key support, layout auto-detected from locale.

The product-y bit (why it’s not just a tech demo)

The game route is noindex, but the brand archive pages it links from are fully indexable and carry the SEO + structured data (CollectionPage, ImageObject, VideoGame). Inspecting any item always bridges back to an indexable product page — never a dead end. So the 3D experience is a discovery layer bolted onto a real commerce graph.

Lessons

  • Putting all game logic in a framework-free domain layer made r3f swappable and the whole thing testable headlessly — easily the highest-leverage decision.
  • Capability-gating the lazy chunk is the difference between “fun feature” and “I just shipped 800 kB to everyone.”
  • For a hand-built game, primitives beat sourced assets wherever you need guaranteed shape/orientation.

Happy to answer questions about the instancing, the capability gate, or testing r3f with the test-renderer.

1 Like

WA (WASD) is not working

My bad, I’m an AZERTY guy, I maybe have been less thorough on the QWERTY testing.

Now fixed, thanks for reporting!

1 Like