Version: 4.0 Stable
Author: Claude (Anthropic)
Target Format: Quake MAP (Valve 220)
Purpose: Procedural generation of geometrically valid convex brush clusters for level design
- Overview
- Core Concepts
- Valve 220 Format Primer
- Critical Design Constraints
- Architecture
- Geometry Generation Algorithm
- Validation Pipeline
- User Interface Specification
- Implementation Guide
- Common Pitfalls & Solutions
- Preset Configurations
The Rock Cluster Generator is a browser-based tool that generates procedural rock formations exported in the Valve 220 MAP format for use in Trenchbroom and other Quake-based level editors. It produces geometrically valid convex brushes through careful vertex generation, validation, and plane equation construction.
- Real-time 2D preview with visual feedback
- 8 preset configurations (stalagmites, boulders, spires, etc.)
- Guaranteed geometric validity through multi-stage validation
- Consistent height across all rocks in a cluster
- Proper convex hull generation with correct plane normals
- Zero external dependencies (pure JavaScript + Canvas)
In Quake map format, a brush is a convex polyhedron defined by the intersection of half-spaces. Each half-space is defined by a plane equation, represented by 3 non-colinear points.
Brush = Intersection of all half-spaces
Half-space = Region on one side of a plane
Plane = Defined by 3 points (P1, P2, P3)
Critical: All brushes MUST be convex. Non-convex brushes will be rejected by the map compiler.
A polyhedron is convex if:
- All interior angles are < 180°
- Any line segment between two points inside the shape stays entirely inside
- It can be represented as the intersection of half-spaces
The pinch parameter (0.15 - 0.85) controls rock taper:
pinch = top_radius / base_radius
pinch = 0.15 → Sharp spire (top is 15% of base)
pinch = 0.50 → Moderate taper (top is 50% of base)
pinch = 0.85 → Gentle slope (top is 85% of base)
{
( x1 y1 z1 ) ( x2 y2 z2 ) ( x3 y3 z3 ) TEXTURE [ 1 0 0 0 ] [ 0 -1 0 0 ] 0 1 1
( x1 y1 z1 ) ( x2 y2 z2 ) ( x3 y3 z3 ) TEXTURE [ 1 0 0 0 ] [ 0 -1 0 0 ] 0 1 1
...
}
Each line represents a plane:
- 3 points define the plane
- TEXTURE specifies surface material
- Alignment vectors control UV mapping (we use default)
All brushes must be wrapped in a worldspawn entity:
{
"classname" "worldspawn"
"mapversion" "220"
{brush1}
{brush2}
...
}
CRITICAL: The order of the 3 points determines the plane normal direction.
- Points ordered counter-clockwise when viewed from outside
- Normal points outward (away from solid)
- The brush occupies the space where all normals point away from
Right-hand rule:
Vector1 = P2 - P1
Vector2 = P3 - P1
Normal = Vector1 × Vector2 (cross product)
A convex rock with N-sided base (pentagon, hexagon, heptagon) requires:
- 1 bottom plane (not N triangulated planes)
- 1 top plane (not N triangulated planes)
- N side planes (one per edge)
- Total: N + 2 planes
Example: Pentagon rock = 5 sides + 2 caps = 7 planes
For bottom/top planes:
- Must select 3 non-colinear, non-duplicate points
- Points should form the largest area triangle possible
- Cross product magnitude must be > 0.1
Each side plane connects:
bottom[i]→top[i]→bottom[i+1]
This creates the correct winding order for outward-facing normals.
- All coordinates must be integers or high-precision floats
- Rounding to integers is safest:
Math.round(value) - Floating point errors can cause invalid brushes
Must avoid:
- Duplicate vertices (same XY coordinates)
- Colinear points (cross product ≈ 0)
- Vertical edges (top vertex directly above bottom vertex)
- Collapsed caps (< 3 unique vertices)
┌─────────────────────────────────────┐
│ User Interface (HTML/CSS) │
│ - Sliders, Presets, Canvas │
└───────────┬─────────────────────────┘
│
┌───────────▼─────────────────────────┐
│ Geometry Generator (JS) │
│ - Vertex creation │
│ - Jitter application │
│ - Pinch calculation │
└───────────┬─────────────────────────┘
│
┌───────────▼─────────────────────────┐
│ Validation Pipeline (JS) │
│ - Uniqueness check │
│ - Vertical edge detection │
│ - Geometry spread verification │
└───────────┬─────────────────────────┘
│
┌───────────▼─────────────────────────┐
│ Brush Constructor (Valve220) │
│ - Plane selection (best area) │
│ - Colinearity testing │
│ - MAP format output │
└───────────┬─────────────────────────┘
│
┌───────────▼─────────────────────────┐
│ Canvas Renderer (2D Preview) │
│ - Bottom outline (black) │
│ - Top outline (blue) │
│ - Edge connections (gray) │
└─────────────────────────────────────┘
User Input → Parameters → Vertex Generation → Validation
↓
Validation Pass? → YES → Plane Construction → MAP String
↓ ↓
NO Canvas Render
↓ ↓
Skip Brush Display to User
const count = parseInt(countSlider.value); // Number of rocks
const h = parseInt(heightSlider.value); // Height (all rocks same)
const r = parseInt(radiusSlider.value); // Base radius
const pinch = parseFloat(pinchSlider.value); // Taper (0.15-0.85)
const rvar = parseInt(radiusVarianceSlider.value); // % variance
const jitter = parseInt(jitterSlider.value); // Vertex randomness
const spread = parseInt(spreadSlider.value); // Cluster spacingFor each rock in the cluster:
for (let i = 0; i < count; i++) {
// Random position within spread area
const offX = (Math.random() - 0.5) * spread * 2;
const offY = (Math.random() - 0.5) * spread * 2;
// Apply radius variance
const localR = r * (1 + (Math.random() - 0.5) * 2 * (rvar / 100));
// Random number of sides (5-7)
const sides = 5 + Math.floor(Math.random() * 3);
// Generate vertices...
}Bottom Vertices:
for (let s = 0; s < sides; s++) {
const ang = (s / sides) * Math.PI * 2; // Evenly spaced angles
// Apply jitter to radius
const bottomJitter = (Math.random() - 0.5) * jitter;
const bx = offX + Math.cos(ang) * (localR + bottomJitter);
const by = offY + Math.sin(ang) * (localR + bottomJitter);
bottomVerts.push([
Math.round(bx),
Math.round(by),
0 // Bottom is always at z=0
]);
}Top Vertices:
for (let s = 0; s < sides; s++) {
const ang = (s / sides) * Math.PI * 2;
// Apply pinch from center, then add smaller jitter
const topJitter = (Math.random() - 0.5) * jitter * 0.5;
const pinchedR = localR * pinch;
// Ensure minimum top radius (20% of base or 8 units)
const minTopRadius = Math.max(8, localR * 0.20);
const effectiveTopR = Math.max(minTopRadius, pinchedR);
const tx = offX + Math.cos(ang) * (effectiveTopR + topJitter);
const ty = offY + Math.sin(ang) * (effectiveTopR + topJitter);
topVerts.push([
Math.round(tx),
Math.round(ty),
Math.round(h) // All rocks same height
]);
}Check 1: Unique Vertices
const bottomUnique = new Set(bottomVerts.map(v => `${v[0]},${v[1]}`));
const topUnique = new Set(topVerts.map(v => `${v[0]},${v[1]}`));
if (bottomUnique.size < 3 || topUnique.size < 3) {
continue; // Skip this brush
}Check 2: Geometric Spread
const isValidGeometry = (verts) => {
const xs = verts.map(v => v[0]);
const ys = verts.map(v => v[1]);
const xSpread = Math.max(...xs) - Math.min(...xs);
const ySpread = Math.max(...ys) - Math.min(...ys);
// Need at least 3 units of spread
return xSpread >= 3 || ySpread >= 3;
};Check 3: No Vertical Edges
const hasVerticalEdge = () => {
const bottomXY = new Set(bottomVerts.map(v => `${v[0]},${v[1]}`));
const topXY = new Set(topVerts.map(v => `${v[0]},${v[1]}`));
for (let xy of topXY) {
if (bottomXY.has(xy)) return true; // Vertical edge found!
}
return false;
};if (!isValidGeometry(bottomVerts) ||
!isValidGeometry(topVerts) ||
hasVerticalEdge()) {
continue; // Skip this brush
}const areValidPlanePoints = (p1, p2, p3) => {
// Check distinct points
if (p1 === p2 || p2 === p3 || p1 === p3) return false;
// Calculate cross product
const v1 = [p2[0]-p1[0], p2[1]-p1[1], p2[2]-p1[2]];
const v2 = [p3[0]-p1[0], p3[1]-p1[1], p3[2]-p1[2]];
const cross = [
v1[1]*v2[2] - v1[2]*v2[1],
v1[2]*v2[0] - v1[0]*v2[2],
v1[0]*v2[1] - v1[1]*v2[0]
];
const magnitude = Math.sqrt(
cross[0]*cross[0] +
cross[1]*cross[1] +
cross[2]*cross[2]
);
return magnitude > 0.1; // Threshold for valid plane
};For bottom and top planes, select the triangle with maximum area:
let bestBottomArea = 0;
let bottomPlanePoints = null;
for (let i = 0; i < n-2; i++) {
for (let j = i+1; j < n-1; j++) {
for (let k = j+1; k < n; k++) {
if (areValidPlanePoints(
bottomVerts[i],
bottomVerts[j],
bottomVerts[k]
)) {
// 2D cross product for area
const v1x = bottomVerts[j][0] - bottomVerts[i][0];
const v1y = bottomVerts[j][1] - bottomVerts[i][1];
const v2x = bottomVerts[k][0] - bottomVerts[i][0];
const v2y = bottomVerts[k][1] - bottomVerts[i][1];
const area = Math.abs(v1x * v2y - v1y * v2x);
if (area > bestBottomArea) {
bestBottomArea = area;
bottomPlanePoints = [
bottomVerts[i],
bottomVerts[j],
bottomVerts[k]
];
}
}
}
}
}Group 1: Preset Logic
- Preset selector (dropdown)
- Count slider (1-24 rocks)
- Spread slider (0-512 units)
Group 2: Geometry Morphology
- Height slider (16-1024 units)
- Radius slider (16-256 units)
- Pinch slider (0.15-0.85)
Group 3: Noise Variance
- R_VAR slider (0-100%)
- Jitter slider (0-128 units)
- Regenerate button
PIXELIT Design Spec:
- Zero border radius (
border-radius: 0) - Monospace font (
JetBrains Mono) - Black and white color scheme
- Uppercase labels with letter-spacing
- Technical grid background
Rendering:
- Black strokes (2px) for bottom outlines
- Blue strokes (1.5px) for top outlines
- Gray connecting lines (0.5px) showing 3D structure
- Alpha compositing for depth perception
Coordinate System:
- Canvas centered at (width/2, height/2)
- Coordinates scaled by 0.5 for viewport fit
- Z-axis vertical (not shown in 2D preview)
<div class="trenchgen">
<header>
<h1>ROCK_CLUSTER // VERTEX_V4_STABLE</h1>
<button id="btn-copy">COPY_VALVE_220</button>
</header>
<section class="viewport">
<canvas id="mainCanvas" width="512" height="384"></canvas>
</section>
<section class="controls">
<!-- Sliders and inputs -->
</section>
<footer>
<textarea id="output" readonly></textarea>
</footer>
</div>const Valve220 = {
DEFAULT_ALIGN: "[ 1 0 0 0 ] [ 0 -1 0 0 ] 0 1 1",
createConvexBrush(vertices, texture = "COMMON/GRID") {
// Implementation as described above
},
makePlane(p1, p2, p3, texture) {
return `( ${p1[0]} ${p1[1]} ${p1[2]} ) ` +
`( ${p2[0]} ${p2[1]} ${p2[2]} ) ` +
`( ${p3[0]} ${p3[1]} ${p3[2]} ) ` +
`${texture} ${this.DEFAULT_ALIGN}\n`;
},
packEntity(brushes) {
return `{\n"classname" "worldspawn"\n` +
`"mapversion" "220"\n` +
`${brushes.join('')}}`;
}
};function render() {
const brushStrings = [];
for (let i = 0; i < count; i++) {
// 1. Generate vertices
const { bottomVerts, topVerts } = generateVertices(params);
// 2. Validate
if (!validate(bottomVerts, topVerts)) {
continue;
}
// 3. Create brush
const brush = Valve220.createConvexBrush({
bottom: bottomVerts,
top: topVerts
});
if (brush !== "") {
brushStrings.push(brush);
}
// 4. Render to canvas
renderToCanvas(bottomVerts, topVerts);
}
// 5. Output MAP string
output.value = Valve220.packEntity(brushStrings);
}// Slider inputs
['inp-count', 'inp-h', 'inp-r', 'inp-pinch',
'inp-hvar', 'inp-jitter', 'inp-spread'].forEach(id => {
document.getElementById(id).addEventListener('input', render);
});
// Preset selector
document.getElementById('preset-select')
.addEventListener('change', render);
// Regenerate button
document.getElementById('btn-regen')
.addEventListener('click', render);
// Copy button
document.getElementById('btn-copy').addEventListener('click', () => {
document.getElementById('output').select();
document.execCommand('copy');
});❌ Wrong:
// Creating 3 planes for bottom face
for (let i = 2; i < sides; i++) {
brushDef += makePlane(
bottomVerts[0],
bottomVerts[i-1],
bottomVerts[i]
);
}✅ Correct:
// One plane using best 3 points
const points = selectBestTriangle(bottomVerts);
brushDef += makePlane(points[0], points[1], points[2]);❌ Wrong:
// Side plane using wrong pattern
makePlane(bottom[i], bottom[next], top[next])✅ Correct:
// Bottom-top-bottom pattern for outward normal
makePlane(bottom[i], top[i], bottom[next])❌ Wrong:
if (p1 !== p2 && p2 !== p3 && p1 !== p3) {
return true; // Distinct, but could be colinear!
}✅ Correct:
const crossMag = calculateCrossProductMagnitude(p1, p2, p3);
return crossMag > 0.1;❌ Problem:
// Top vertex at same XY as bottom
topVerts[i] = [bottomVerts[i][0], bottomVerts[i][1], height];✅ Solution:
// Check for vertical edges and skip
const bottomXY = new Set(bottomVerts.map(v => `${v[0]},${v[1]}`));
const topXY = new Set(topVerts.map(v => `${v[0]},${v[1]}`));
const hasVerticalEdge = [...topXY].some(xy => bottomXY.has(xy));❌ Wrong:
topVerts.push([tx, ty, height]); // Floats can cause issues✅ Correct:
topVerts.push([Math.round(tx), Math.round(ty), Math.round(height)]);{
count: 12, // Many formations
h: 320, // Tall
r: 28, // Narrow base
pinch: 0.20, // Sharp taper
hvar: 30, // Moderate size variance
jitter: 20, // Some irregularity
spread: 180 // Medium spacing
}{
count: 5, // Few large rocks
h: 96, // Short
r: 112, // Wide base
pinch: 0.65, // Gentle taper
hvar: 25, // Some variance
jitter: 64, // Very irregular
spread: 100 // Tight cluster
}{
count: 18, // Many formations
h: 512, // Very tall
r: 32, // Medium base
pinch: 0.25, // Sharp taper
hvar: 35, // High variance
jitter: 24, // Moderate irregularity
spread: 256 // Wide spacing
}{
count: 20, // Many pieces
h: 16, // Very short
r: 64, // Wide base
pinch: 0.80, // Minimal taper
hvar: 60, // High variance
jitter: 48, // Irregular shapes
spread: 220 // Scattered
}{
count: 8, // Medium cluster
h: 256, // Tall
r: 20, // Thin base
pinch: 0.15, // Very sharp taper
hvar: 40, // High variance
jitter: 16, // Some irregularity
spread: 120 // Medium spacing
}{
count: 6, // Few columns
h: 384, // Very tall
r: 48, // Medium base
pinch: 0.50, // Moderate taper
hvar: 20, // Low variance
jitter: 32, // Moderate irregularity
spread: 160 // Medium spacing
}{
count: 24, // Many rocks
h: 64, // Medium height
r: 40, // Medium base
pinch: 0.70, // Gentle taper
hvar: 50, // High variance
jitter: 40, // Irregular shapes
spread: 280 // Very scattered
}{
count: 3, // Few massive rocks
h: 640, // Extremely tall
r: 64, // Wide base
pinch: 0.40, // Moderate taper
hvar: 15, // Low variance
jitter: 48, // Very irregular
spread: 80 // Tight cluster
}Validate geometry BEFORE attempting brush construction:
if (!isValidGeometry(bottomVerts) ||
!isValidGeometry(topVerts) ||
hasVerticalEdge()) {
continue; // Skip expensive plane calculations
}Render all rocks to canvas in a single pass:
ctx.save();
// ... render all rocks ...
ctx.restore();
// Single canvas updateUse array joining instead of repeated string concatenation:
const brushStrings = [];
// ... generate brushes ...
return brushStrings.join(''); // Faster than += in loop- All brushes load without errors in Trenchbroom
- No "Brush is empty" errors
- No "Invalid face" warnings
- All rocks have consistent height
- Rocks don't overlap excessively
- Count: 1-24 all work
- Height: 16-1024 all work
- Radius: 16-256 all work
- Pinch: 0.15-0.85 all work
- Extreme combinations don't crash
- All 8 presets load correctly
- Each preset produces expected visual result
- Switching presets updates all sliders
- "User Defined" preserves custom values
- Count = 1 works (single rock)
- Count = 24 works (maximum cluster)
- Very small rocks (r=16, h=16)
- Very large rocks (r=256, h=1024)
- Extreme pinch values (0.15, 0.85)
For vectors v1 and v2:
v1 = (a, b, c)
v2 = (d, e, f)
v1 × v2 = (bf - ce, cd - af, ae - bd)
magnitude = √((bf-ce)² + (cd-af)² + (ae-bd)²)
For points P1(x1,y1), P2(x2,y2), P3(x3,y3):
Area = |((x2-x1)(y3-y1) - (x3-x1)(y2-y1))| / 2
We use the numerator (2× area) for comparisons.
A set of points defines a convex hull if:
For all points Pi and Pj inside the hull:
All points on line segment PiPj are also inside
( x1 y1 z1 ) ( x2 y2 z2 ) ( x3 y3 z3 ) TEXTURE [ Ux Uy Uz Uoffset ] [ Vx Vy Vz Voffset ] rotation scaleU scaleV
Components:
- (x y z): Three points defining the plane
- TEXTURE: Material name (e.g., "COMMON/GRID")
- U vector: Texture mapping in U direction
- V vector: Texture mapping in V direction
- rotation: Texture rotation in degrees
- scaleU/V: Texture scale factors
Default Alignment:
[ 1 0 0 0 ] [ 0 -1 0 0 ] 0 1 1
{
"key" "value"
"key2" "value2"
{brush}
{brush}
}
For worldspawn:
{
"classname" "worldspawn"
"mapversion" "220"
{brushes...}
}
Cause: Invalid plane definitions (colinear points, duplicates, or wrong winding)
Solutions:
- Check colinearity threshold (should be > 0.1)
- Verify plane point selection uses best area
- Ensure no vertical edges
- Check for duplicate vertices
Cause: Plane has colinear or near-colinear points
Solutions:
- Increase cross product threshold
- Select triangle with maximum area
- Verify vertex spacing is adequate (≥3 units)
Cause: One plane in the brush is problematic
Solutions:
- Check side plane winding order
- Verify bottom-top-bottom pattern
- Ensure vertices are properly rounded
Cause: Canvas preview issue, not actual geometry
Solutions:
- Check if brushes load correctly in Trenchbroom
- Verify top.z > bottom.z (should always be true)
- Update canvas rendering to show proper perspective
This specification provides a complete blueprint for implementing a procedural rock cluster generator that produces geometrically valid Valve 220 MAP format brushes. The key to success is:
- Careful geometry generation with proper validation
- Correct plane construction using best-area triangles
- Proper winding order for outward-facing normals
- Multi-stage validation to catch all edge cases
By following these guidelines, you can create a robust tool that generates professional-quality level design assets for Quake-based engines.