Lesson 45 - Three.js Game Development
Three.js Procedural Worlds: Seeded Terrain, Water, Sky, and JSON Presets
Procedural does not mean random noise everywhere. We will build a repeatable world pipeline where terrain, water, sky, and gameplay all read the same configuration.
1. Use a deterministic random source
The same seed must produce the same terrain and object placement. A small integer generator is enough for game presets and bug reproduction.
function mulberry32(seed) {
return () => {
seed |= 0;
seed = (seed + 0x6d2b79f5) | 0;
let t = Math.imul(seed ^ (seed >>> 15), 1 | seed);
t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t;
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
};
}
const random = mulberry32(1842);
How this code works
Execution flow
mulberry32 closes over one integer seed and updates it through bitwise mixing on each call. The returned unsigned value is divided into the zero-to-one range, producing the same sequence whenever the initial seed matches.
Key Three.js decisions
A small seeded generator is fast and adequate for level layout, testing, and reproducible bug reports. It replaces Math.random only for controlled world decisions; Three.js consumes the resulting positions and rotations without knowing their source.
Adapt it
Create separate derived streams for terrain, vegetation, loot, and weather so changing flower count does not rearrange mountains. Store the world seed in JSON and expose it in debug UI when players report an impossible layout.
Watch out
Seeded pseudo-random values are predictable and unsuitable for security or gambling. Consuming values in a different order changes all later results, so isolate subsystems instead of sharing one global stream across unrelated generation passes.
2. Shape terrain from layered signals
Combine broad hills with smaller detail and a water-level bias. Keep a heightAt function so collision, vegetation, and camera systems query the same surface.
function heightAt(x, z) {
const hills = Math.sin(x * 0.018) * 5 + Math.cos(z * 0.021) * 4;
const detail = Math.sin((x + z) * 0.11) * 0.55;
return hills + detail - 1.5;
}
const terrain = new THREE.PlaneGeometry(220, 220, 180, 180);
terrain.rotateX(-Math.PI / 2);
const p = terrain.attributes.position;
for (let i = 0; i < p.count; i++) p.setY(i, heightAt(p.getX(i), p.getZ(i)));
p.needsUpdate = true;
terrain.computeVertexNormals();
How this code works
Execution flow
heightAt combines broad sine and cosine hills with smaller detail, then every plane vertex samples that exact function for its Y coordinate. The attribute uploads once and normals are rebuilt before the terrain mesh is rendered.
Key Three.js decisions
Layered frequencies separate world-scale silhouette from close surface variation. PlaneGeometry provides a regular sampling grid, and one reusable heightAt contract can serve Three.js geometry, collisions, camera clearance, and vegetation placement.
Adapt it
Replace trigonometric layers with seeded simplex noise, flatten a track corridor, or carve rivers by subtracting distance fields. Keep collision sampling mathematically identical or derive both visuals and physics from one stored height array.
Watch out
High-frequency detail above the mesh resolution aliases into spikes and inconsistent slopes. If the player floats, visible geometry and collision are sampling different coordinates, scales, or formulas; debug their height values at the same XZ point.
3. Create biome-aware transparent water
Water color should belong to the biome, and transparency only works when depth, opacity, and render order are configured together. A shallow tint lets the floor remain visible.
const water = new THREE.Mesh(
new THREE.PlaneGeometry(240, 240),
new THREE.MeshPhysicalMaterial({
color: new THREE.Color(0x2f8f83),
transparent: true,
opacity: 0.58,
roughness: 0.18,
transmission: 0.08,
depthWrite: false,
})
);
water.rotation.x = -Math.PI / 2;
water.position.y = 0;
water.renderOrder = 2;
How this code works
Execution flow
A large horizontal mesh receives a Physical material with biome color, transparency, roughness, limited transmission, and disabled depth writing. It is placed at water level and rendered after opaque terrain using renderOrder.
Key Three.js decisions
MeshPhysicalMaterial supports believable surface response, but restrained transmission and opacity keep it affordable. depthWrite false prevents the transparent plane from incorrectly hiding later transparent objects, while terrain depth still supplies shoreline occlusion.
Adapt it
Use warmer green water in forests, deep blue in alpine valleys, and disable the mesh entirely for dry desert presets. Animate normal-map offsets or shader uniforms from the shared wind state rather than moving the whole plane.
Watch out
Transparency sorting is object-based and can fail with intersecting transparent meshes. Excessive opacity hides the riverbed, while transmission, reflections, and high-resolution normals can become expensive on mobile, so offer quality tiers.
4. Serialize the world contract
Save configuration rather than scene objects. Rebuild the scene from JSON so presets remain portable between the editor, demo, and real games.
const worldPreset = {
version: 1,
seed: 1842,
biome: "forest",
terrain: { size: 220, heightScale: 1, waterLevel: 0 },
vegetation: { grass: 0.72, trees: 0.38, debris: 0.12 },
};
localStorage.setItem("world-preset", JSON.stringify(worldPreset));
const restored = JSON.parse(localStorage.getItem("world-preset"));
How this code works
Execution flow
The preset stores version, seed, biome, terrain settings, and vegetation densities as plain data. JSON.stringify writes it to storage, and JSON.parse reconstructs the configuration that the world builder will consume on the next load.
Key Three.js decisions
Plain JSON is portable and testable, unlike serializing Three.js scene objects with GPU resources and circular references. A version field provides an explicit migration point when preset shape changes.
Adapt it
Use the same contract for an editor export, URL-based challenge seed, or server-stored world. Validate ranges and merge missing nested fields with defaults before generation so older saves remain usable.
Watch out
JSON.parse can throw on corrupted data and does not restore classes such as Vector3 automatically. Never trust imported values blindly; malformed densities or world sizes can allocate extreme geometry and freeze the page.
5. Real Supagames example
Production source: js/environment-engine/terrain/heightmap.js
Play the published example: Valley Racer
Supagames environment-engine combines seeded terrain, biome presets, water, sky, vegetation rejection, serialization, and height queries. This lesson exposes the data flow that keeps those systems in agreement.
The teaching snippets deliberately isolate one Three.js responsibility at a time. The production file also handles integration, lifecycle, quality settings, and game-specific state, so read it after the smaller examples make sense.
6. Common mistakes
- Creating geometry, materials, vectors, or arrays inside the frame loop and causing avoidable garbage collection pauses.
- Treating the Three.js scene graph as the only source of gameplay state instead of keeping explicit JavaScript state.
- Testing only on a fast desktop and leaving pixel ratio, shadows, and object counts uncapped on mobile devices.
7. Build checklist
- Serve ES modules through HTTP during development instead of opening the page through file://.
- Test resize, pause, restart, and cleanup paths as carefully as the first successful frame.
- Use browser performance tools and an on-screen FPS sample before guessing where a slowdown comes from.
- Verify keyboard, pointer, and touch controls without letting game shortcuts break forms or page navigation.