Procedural geometry: three.js plus geo.js

game_open puts geo.js in the project root. It holds Manifold (exact booleans, hull, extrude, revolve, level sets, smoothing) with three.js helpers, and a seeded scatter. Never read or edit it.

import * as THREE from "three";
import { geo, scatter } from "./geo.js";        // "../geo.js" from src/ in a vite project
const { Manifold, CrossSection, toManifold, toGeometry, boolean } = await geo();   // once, before building

After each step, name the mesh and check it with game_eval: return stats("wall") gives triangles, closed, volume and genus (a hole through the part adds 1). Try a step in game_eval first when unsure, then write it into the source. Once the numbers are right, game_look with target: "wall" shows it alone from four sides in one picture, which catches a shape that only looks right from the game's camera.

Scatter on a surface

const ground = new THREE.Mesh(new THREE.PlaneGeometry(20, 20).rotateX(-Math.PI / 2), groundMat);
const cone = new THREE.ConeGeometry(0.4, 1.5).translate(0, 0.75, 0);   // base at the origin, so it stands on the surface
const trees = scatter(ground, cone, treeMat, 80, { seed: 1, scale: [0.7, 1.3], align: false });   // align: false keeps trees upright
trees.name = "trees"; ground.add(trees);

Parametric array

One InstancedMesh draws every copy in one call; a loop of Meshes sharing one geometry and material is fine for a few.

const N = 12, posts = new THREE.InstancedMesh(new THREE.BoxGeometry(0.2, 1, 0.2), postMat, N), m = new THREE.Matrix4();
for (let i = 0; i < N; i++) {
  const a = (i / N) * Math.PI * 2;
  posts.setMatrixAt(i, m.makeRotationY(-a).setPosition(Math.cos(a) * 5, 0.5, Math.sin(a) * 5));
}
posts.instanceMatrix.needsUpdate = true;

Curve to mesh

const path = new THREE.CatmullRomCurve3([new THREE.Vector3(-4, 0, 0), new THREE.Vector3(0, 2, 2), new THREE.Vector3(4, 0, 0)]);
const pipe = new THREE.TubeGeometry(path, 64, 0.2, 12, false);           // round section; its ends are open
const profile = new THREE.Shape().moveTo(-0.3, -0.1).lineTo(0.3, -0.1).lineTo(0.3, 0.1).lineTo(-0.3, 0.1);
const rail = new THREE.ExtrudeGeometry(profile, { steps: 64, extrudePath: path, bevelEnabled: false });   // any section, capped

Boolean carve

Make cutters overshoot the faces they cut through; a cut flush with a face leaves a sliver.

const wallGeo = boolean("difference",
  new THREE.BoxGeometry(4, 3, 0.4),
  new THREE.BoxGeometry(1, 2.1, 1).translate(-1, -0.55, 0),                    // door, open at the bottom
  new THREE.CylinderGeometry(0.4, 0.4, 1, 24).rotateX(Math.PI / 2).translate(1, 0.5, 0));   // round window: genus 1
const wall = new THREE.Mesh(wallGeo, wallMat); wall.name = "wall";

Lathe or revolve

Start and end the profile on the axis (x = 0), or the shape is open at the poles.

const pts = [[0, 0], [0.5, 0], [0.6, 0.2], [0.3, 0.9], [0.35, 1.2], [0, 1.2]];
const vase = new THREE.LatheGeometry(pts.map(([x, y]) => new THREE.Vector2(x, y)), 32);   // three.js

const section = CrossSection.ofPolygons([pts]), spun = section.revolve(32), upright = spun.rotate([-90, 0, 0]);   // Manifold, for booleans next
const vase2 = toGeometry(upright);
section.delete(); spun.delete(); upright.delete();

Three-lane pebble scatter

Three lanes over one surface, each with its own seed and scale range, so large, medium and small stones mix without one repeating pattern.

const pebble = new THREE.IcosahedronGeometry(1, 1).scale(1, 0.55, 1);
for (const [lane, count, scale, seed] of [["large", 12, [0.25, 0.60], 11], ["medium", 40, [0.25, 0.45], 12], ["small", 120, [0.10, 0.35], 13]]) {
  const stones = scatter(ground, pebble, stoneMat, count, { seed, scale, align: true });
  stones.name = `pebbles-${lane}`; ground.add(stones);
}

Hull and level sets

const hull = Manifold.hull([[0, 0, 0], [1.2, 0.1, 0.2], [0.2, 1, 0.3], [0.5, 0.3, 1], [0.9, 0.8, 0.7], [-0.2, 0.4, 0.6]]);
const rock = toGeometry(hull, { sharpAngle: 0 }); hull.delete();          // faceted rock

// A signed-distance function, positive inside: two spheres blended (smooth min), inside the bounds' box.
const k = 0.4, d = (p, c, r) => Math.hypot(p[0] - c[0], p[1] - c[1], p[2] - c[2]) - r;
const sdf = p => { const a = d(p, [-0.6, 0, 0], 0.7), b = d(p, [0.6, 0, 0], 0.5), h = Math.max(k - Math.abs(a - b), 0) / k;
  return -(Math.min(a, b) - h * h * k / 4); };
const blob = Manifold.levelSet(sdf, { min: [-1.5, -1, -1], max: [1.5, 1, 1] }, 0.08);
const blobGeo = toGeometry(blob); blob.delete();

Rounding a shape

smoothOut(180) curves every edge and refine(n) splits each edge into n, moving the new vertices onto that curve. The surface passes through the old vertices and bulges out between them, so it suits a low-poly shape turning into a pebble or blob; check its size with stats. Flat faces of three or more triangles stay flat, so it will not round a box's edges: hull spheres at the corners instead.

const facets = Manifold.hull([[0, 0, 0], [1, 0.1, 0.2], [0.2, 0.6, 0.3], [0.5, 0.2, 0.9], [0.8, 0.5, 0.6]]);
const smooth = facets.smoothOut(180), fine = smooth.refine(6);
const pebbleGeo = toGeometry(fine);
facets.delete(); smooth.delete(); fine.delete();

const r = 0.15, ball = Manifold.sphere(r, 16), corners = [];   // a 2 x 1 x 1 box with edges rounded to r
for (const x of [-1, 1]) for (const y of [-0.5, 0.5]) for (const z of [-0.5, 0.5]) corners.push(ball.translate([x - Math.sign(x) * r, y - Math.sign(y) * r, z - Math.sign(z) * r]));
const rounded = Manifold.hull(corners), boxGeo = toGeometry(rounded);
for (const c of [ball, rounded, ...corners]) c.delete();

Pitfalls