Show HN: Turning a Gaussian Splat into a videogame

Gaussian Splatting offers photorealistic environments but lacks physics and lighting for games. This guide walks through adding colliders, navmeshes, and light probes to create a browser-based FPS.
Gaussian Splatting gives you photorealistic environments for free. The catch: a splat is just a cloud of oriented blobs - no triangles, no colliders, no navmesh, no lights. Drop a character in and they'll float through walls looking like they belong in a different universe.
This post walks through the demo I built to fix all of that:
- 👉 Play it in your browser - WASD, mouse to aim, left-click to fire.
- 👉 Check the project - the full PlayCanvas project is public. Every script mentioned in this post lives inside it, ready to read, fork, or remix.
The scene is a gorgeous indoor scan of a real abandoned place by Christoph Schindelar. On top of that splat I bolted a physics collider, a grid of baked lighting probes, a Recast navmesh, eight personality-driven NPCs and a classic FPS loop. Everything runs in a browser tab.
🏗️ The Build
Here's how I built it, step by step.
📥 Step 1: Download a Splat from SuperSplat
Before any code, you need a scene. Any splat on SuperSplat tagged Downloadable has been published under Creative Commons by its author - grab the .ply or .sog and drop it straight into your own PlayCanvas project. The lighting, clutter and scale of the scan I picked were already cinematic, so I didn't have to art-direct anything.
📡 Step 2: Convert the Splat to Streamed SOG Format
The Swiss Army knife for everything that follows is splat-transform - PlayCanvas's open-source CLI for converting splats. We'll lean on it for streamed LOD here and for a collision mesh in the next step.
My scene is a few million Gaussians - big enough that shipping it as a single .sog asset would punish anyone on a phone or a slow connection. The fix is Streamed LOD: instead of one monolithic file, SuperSplat (and splat-transform) write out a folder of SOG chunks plus a manifest. The runtime loads chunks on demand based on the camera's viewpoint and the device's capability - high-end desktop pulls full detail around the player, a phone pulls a lighter subset, and neither of them stalls waiting for the whole file.
Scripts/streaming-lod.mjs hooks into the camera and asks the runtime to keep the chunks around the player fully loaded before the game starts - so you never see pop-in mid-firefight.
If your splat is over a few million Gaussians, export it as streamed LOD (the easiest way is from SuperSplat's export dialog - see the Streamed LOD docs) and let the viewer stream it. Your mobile players will thank you.
🧱 Step 3: Generate a Collision Mesh
This used to be the hard part. A splat has no surfaces, so physics is blind to it. You can't walk on it, shoot through it, or path around it. That's where splat-transform earns its keep again - the flag you want is -K / --collision-mesh. It voxelizes the splat, flood-fills the navigable interior from a seed position, and writes out a watertight .collision.glb that you can import straight into PlayCanvas as a mesh collider.
That one command gives me two outputs:
scene.sog- a single-file compressed splat for quick iteration; the shipped build uses the streamed folder from Step 2.scene.collision.glb- a voxel-derived mesh that hugs the real geometry.
I dropped both into the PlayCanvas project and attached the GLB to an invisible entity with a Collision component (mesh) and a Rigid Body component (static). Suddenly the player has a floor, the bullets can collide with walls, and the NPCs have something to walk on. No modelling, no clean-up.
💡 Step 4: Bake a Lightness Grid from the Splat
Splats carry their lighting baked into every Gaussian. That means the scene looks amazing and unchanging. But my player's weapon model, the NPC soldiers and the pickups are ordinary lit PBR meshes - they'd stand out like cardboard cutouts under gym lighting unless they somehow inherited the splat's lighting.
I didn't want to re-light the splat. I wanted a cheap way to ask "how bright is it here?" at any point in the map, at runtime, for my regular meshes.
How Scripts/probes.js works:
- Grab the AABB of a designated floor entity and build a 1-metre grid of probe positions 1 metre above the ground.
- Create a tiny 16×16 offscreen
RenderTargetand a 90° FOV camera that renders only theWorldlayer. - For each probe, render 6 faces of a cube,
readPixelsthe 16×16 RGBA output, and compute luminance. - Stash the value in a
gridDepth × gridWidth2D array and save it aslightness.json.
At runtime, every dynamic character script loads lightness.json, bilinearly samples the grid at its world position, and adjusts the exposure. Step from a bright atrium into a dim corridor and your hands darken smoothly.
🛠️ Step 5: Vibe Code with the PlayCanvas VS Code Extension
I didn't write any of this in the PlayCanvas web editor's code panel. I used the PlayCanvas extension for VS Code - which also works inside Cursor, so I could pair-program with Claude while editing. Save the file → the editor picks up the change → reload the launch tab → test.
🔄 Step 6: Version Your Project with PlayCanvas + GitHub
PlayCanvas ships a first-party version control. You can also use GitHub at the root of your locally synced PlayCanvas project. This is essential when working with AI coding agents to ensure you can always revert bad refactors.
🧭 Step 7: Generate a Navmesh from the Collision Mesh
NPCs can't path on a splat either - they need a navmesh. For the runtime, I use recast-navigation, loaded straight from esm.sh with dynamic import - zero bundler.
Source: Hacker News
















