Skip to content

Instantly share code, notes, and snippets.

@Stuyk
Created February 3, 2026 00:27
Show Gist options
  • Select an option

  • Save Stuyk/a6c29f8c8f6ecf8651560db0a11fe080 to your computer and use it in GitHub Desktop.

Select an option

Save Stuyk/a6c29f8c8f6ecf8651560db0a11fe080 to your computer and use it in GitHub Desktop.
Valve 220 Trenchbroom Map Spec Doc for Tool Generation

Rock Cluster Generator - Technical Specification

VALVE 220 Trenchbroom Map Format Export

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


Table of Contents

  1. Overview
  2. Core Concepts
  3. Valve 220 Format Primer
  4. Critical Design Constraints
  5. Architecture
  6. Geometry Generation Algorithm
  7. Validation Pipeline
  8. User Interface Specification
  9. Implementation Guide
  10. Common Pitfalls & Solutions
  11. Preset Configurations

Overview

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.

Key Features

  • 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)

Core Concepts

What is a Brush?

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)

Convexity Requirement

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

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)

Valve 220 Format Primer

Brush Structure

{
( 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)

Entity Wrapper

All brushes must be wrapped in a worldspawn entity:

{
"classname" "worldspawn"
"mapversion" "220"
{brush1}
{brush2}
...
}

Plane Normal Direction

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)

Critical Design Constraints

1. Minimum Plane Count

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

2. Point Selection for 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

3. Vertex Correspondence

Each side plane connects:

  • bottom[i]top[i]bottom[i+1]

This creates the correct winding order for outward-facing normals.

4. Coordinate Precision

  • All coordinates must be integers or high-precision floats
  • Rounding to integers is safest: Math.round(value)
  • Floating point errors can cause invalid brushes

5. Degenerate Geometry Prevention

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)

Architecture

Component Hierarchy

┌─────────────────────────────────────┐
│     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)          │
└─────────────────────────────────────┘

Data Flow

User Input → Parameters → Vertex Generation → Validation
    ↓
Validation Pass? → YES → Plane Construction → MAP String
    ↓                     ↓
    NO                    Canvas Render
    ↓                     ↓
Skip Brush              Display to User

Geometry Generation Algorithm

Step 1: Parameter Extraction

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 spacing

Step 2: Rock Loop

For 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...
}

Step 3: Vertex Generation

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
    ]);
}

Step 4: Validation Checks

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;
};

Validation Pipeline

Stage 1: Vertex-Level Validation

if (!isValidGeometry(bottomVerts) || 
    !isValidGeometry(topVerts) || 
    hasVerticalEdge()) {
    continue; // Skip this brush
}

Stage 2: Plane Selection

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
};

Stage 3: Best Triangle Selection

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]
                    ];
                }
            }
        }
    }
}

User Interface Specification

Control Groups

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

Visual Design Language

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

Canvas Preview

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)

Implementation Guide

Phase 1: HTML Structure

<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>

Phase 2: Valve220 Library

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('')}}`;
    }
};

Phase 3: Generation Loop

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);
}

Phase 4: Event Binding

// 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');
});

Common Pitfalls & Solutions

Pitfall 1: Triangulating Caps

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]);

Pitfall 2: Incorrect Winding

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])

Pitfall 3: Accepting Colinear Points

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;

Pitfall 4: Vertical Edges

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));

Pitfall 5: Floating Point Precision

Wrong:

topVerts.push([tx, ty, height]); // Floats can cause issues

Correct:

topVerts.push([Math.round(tx), Math.round(ty), Math.round(height)]);

Preset Configurations

Stalagmites

{
    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
}

Boulders

{
    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
}

Spires

{
    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
}

Debris

{
    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
}

Crystals

{
    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
}

Pillars

{
    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
}

Scatter

{
    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
}

Monoliths

{
    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
}

Performance Considerations

Optimization 1: Early Rejection

Validate geometry BEFORE attempting brush construction:

if (!isValidGeometry(bottomVerts) || 
    !isValidGeometry(topVerts) || 
    hasVerticalEdge()) {
    continue; // Skip expensive plane calculations
}

Optimization 2: Canvas Batching

Render all rocks to canvas in a single pass:

ctx.save();
// ... render all rocks ...
ctx.restore();
// Single canvas update

Optimization 3: String Concatenation

Use array joining instead of repeated string concatenation:

const brushStrings = [];
// ... generate brushes ...
return brushStrings.join(''); // Faster than += in loop

Testing Checklist

Geometric Validity Tests

  • 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

Parameter Range Tests

  • 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

Preset Tests

  • All 8 presets load correctly
  • Each preset produces expected visual result
  • Switching presets updates all sliders
  • "User Defined" preserves custom values

Edge Cases

  • 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)

Appendix A: Mathematical Foundations

Cross Product Formula

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)²)

Triangle Area (2D)

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.

Convex Hull Property

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

Appendix B: Valve 220 Reference

Plane Definition

( 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

Entity Format

{
"key" "value"
"key2" "value2"
{brush}
{brush}
}

For worldspawn:

{
"classname" "worldspawn"
"mapversion" "220"
{brushes...}
}

Appendix C: Troubleshooting Guide

Error: "Brush is empty"

Cause: Invalid plane definitions (colinear points, duplicates, or wrong winding)

Solutions:

  1. Check colinearity threshold (should be > 0.1)
  2. Verify plane point selection uses best area
  3. Ensure no vertical edges
  4. Check for duplicate vertices

Error: "Invalid face"

Cause: Plane has colinear or near-colinear points

Solutions:

  1. Increase cross product threshold
  2. Select triangle with maximum area
  3. Verify vertex spacing is adequate (≥3 units)

Warning: "Brush has invalid face"

Cause: One plane in the brush is problematic

Solutions:

  1. Check side plane winding order
  2. Verify bottom-top-bottom pattern
  3. Ensure vertices are properly rounded

Visual: Rocks appear upside-down

Cause: Canvas preview issue, not actual geometry

Solutions:

  1. Check if brushes load correctly in Trenchbroom
  2. Verify top.z > bottom.z (should always be true)
  3. Update canvas rendering to show proper perspective

Conclusion

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:

  1. Careful geometry generation with proper validation
  2. Correct plane construction using best-area triangles
  3. Proper winding order for outward-facing normals
  4. 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.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment