· jaryd krishnan · tutorials · 10 min read
Procedural City Growth with Monte Carlo Methods
Explore how to generate urban layouts by sampling random configurations and selecting the best based on defined metrics using Monte Carlo methods and React.

Introduction
Procedural city generation automates the creation of urban layouts by sampling many random configurations and selecting the best based on defined metrics. Using Monte Carlo sampling, we generate multiple candidate maps, score each on connectivity and block distribution, and render the top layouts in React.
This approach provides a powerful way to create realistic-looking city layouts without manually designing each street and building. Whether you’re building a game, a visualization, or just exploring procedural generation techniques, the methods described in this tutorial offer a flexible foundation you can extend for your own projects.
Let’s start by looking at what we’re building:
// pages/procedural-city-growth.jsx
import React from 'react';
import CitySimulator from '@/components/CitySimulator';
export default function ProceduralCityGrowthPage() {
return (
<main>
<h1>Procedural City Growth with Monte Carlo Methods</h1>
<p>
Explore how to generate and visualize procedural city layouts in React using Monte Carlo sampling.
We'll walk through creating a reusable <code><CitySimulator /></code> component,
wiring up sampling logic, rendering to canvas, and adding interactive controls.
</p>
<CitySimulator />
</main>
);
}
Prerequisites
Before diving into the implementation, make sure you have:
- Next.js project scaffolded (see https://nextjs.org/docs/getting-started).
- React knowledge, particularly hooks like useState and useEffect.
- Familiarity with HTML5 Canvas or SVG for rendering graphics.
- Optional: a CSS framework (e.g., Tailwind) for styling controls and layout.
If you’re new to Next.js, I recommend setting up a new project using:
npx create-next-app@latest procedural-city-demo
cd procedural-city-demo
This will create a new Next.js application with all the necessary configurations for you to follow along with this tutorial.
Monte Carlo Algorithm Overview
At the heart of our procedural city generator is a Monte Carlo sampling approach. This technique allows us to explore a vast space of possible city layouts without having to manually evaluate each one. Here’s how it works:
Sampling: Randomly generate N city layouts by placing roads and blocks on a grid.
Scoring: Compute a score for each layout based on:
- Road network connectivity (graph reachability).
- Even distribution of building cells.
- Total road coverage vs. open space balance.
Selection: Choose the top K layouts with the highest scores to display.
This brute-force technique explores many possibilities quickly; later you can enhance it with Monte Carlo Tree Search for iterative improvement.
The beauty of this approach is its simplicity - by generating many random possibilities and selecting the best ones, we can discover layouts that follow our desired patterns without having to encode complex rules.
Component Organization
Our procedural city generator is organized into three main components:
CitySimulator.jsx
: Top-level component managing state and orchestrating sampling and rendering.MapCanvas.jsx
: Renders a layout as colored grid cells on an HTML5 Canvas.ControlsPanel.jsx
: Offers sliders and buttons to adjust sample count, scoring weights, and rerun simulations.
File Structure
/components
├── CitySimulator.jsx
├── MapCanvas.jsx
└── ControlsPanel.jsx
/pages
└── procedural-city-growth.jsx
/lib
└── sampleLayouts.js
This modular structure allows us to separate concerns and make our code more maintainable. The CitySimulator
component manages the overall state and orchestrates the generation and display of layouts, while the MapCanvas
component handles the rendering details, and the ControlsPanel
component provides the user interface for adjusting parameters.
Implementing the Sampler (sampleLayouts.js
)
Let’s start by creating the heart of our system - the layout sampler that generates and evaluates city layouts:
// src/lib/sampleLayouts.js
export function generateLayouts({ rows, cols, numSamples }) {
const layouts = [];
for (let i = 0; i < numSamples; i++) {
const grid = createRandomGrid(rows, cols);
const score = evaluateGrid(grid);
layouts.push({ grid, score });
}
return layouts.sort((a, b) => b.score - a.score);
}
function createRandomGrid(rows, cols) {
// generate random roads (0) and blocks (1)
return Array.from({ length: rows }, () =>
Array.from({ length: cols }, () => (Math.random() < 0.2 ? 0 : 1))
);
}
function evaluateGrid(grid) {
// example: connectivity + block uniformity
return connectivityScore(grid) * 0.7 + uniformityScore(grid) * 0.3;
}
function connectivityScore(grid) {
// A simple proxy for connectivity: count road cells with neighboring roads
let score = 0;
const rows = grid.length;
const cols = grid[0].length;
for (let y = 0; y < rows; y++) {
for (let x = 0; x < cols; x++) {
if (grid[y][x] === 0) { // If this is a road
let connectedNeighbors = 0;
// Check all 4 neighbors
if (y > 0 && grid[y-1][x] === 0) connectedNeighbors++;
if (y < rows-1 && grid[y+1][x] === 0) connectedNeighbors++;
if (x > 0 && grid[y][x-1] === 0) connectedNeighbors++;
if (x < cols-1 && grid[y][x+1] === 0) connectedNeighbors++;
// Score higher for roads with 1-3 connections (intersections and streets)
if (connectedNeighbors > 0 && connectedNeighbors < 4) {
score += connectedNeighbors;
}
}
}
}
return score / (rows * cols);
}
function uniformityScore(grid) {
// Count blocks in each quadrant of the grid
const rows = grid.length;
const cols = grid[0].length;
const halfRow = Math.floor(rows/2);
const halfCol = Math.floor(cols/2);
const quadrants = [
{ rowStart: 0, rowEnd: halfRow, colStart: 0, colEnd: halfCol },
{ rowStart: 0, rowEnd: halfRow, colStart: halfCol, colEnd: cols },
{ rowStart: halfRow, rowEnd: rows, colStart: 0, colEnd: halfCol },
{ rowStart: halfRow, rowEnd: rows, colStart: halfCol, colEnd: cols }
];
const quadrantCounts = quadrants.map(q => {
let blockCount = 0;
for (let y = q.rowStart; y < q.rowEnd; y++) {
for (let x = q.colStart; x < q.colEnd; x++) {
if (grid[y][x] === 1) blockCount++;
}
}
return blockCount;
});
// Calculate standard deviation of block counts
const mean = quadrantCounts.reduce((a, b) => a + b, 0) / 4;
const variance = quadrantCounts.reduce((a, b) => a + Math.pow(b - mean, 2), 0) / 4;
const stdDev = Math.sqrt(variance);
// Lower standard deviation means more uniform distribution
// Scale to a 0-1 score (0 = high variance, 1 = perfect uniformity)
const maxPossibleStdDev = (rows * cols) / 4; // Theoretical max
return 1 - (stdDev / maxPossibleStdDev);
}
Our sampling logic creates random grids where 0 represents a road and 1 represents a building block. The evaluation function assigns a score based on two key aspects:
- Connectivity: We want roads to connect to each other to form a network
- Uniformity: We want building blocks to be relatively evenly distributed
CitySimulator.jsx
Next, let’s implement our main component that manages the simulation state and renders the top layouts:
// components/CitySimulator.jsx
import React, { useState, useEffect } from 'react';
import { generateLayouts } from '@/lib/sampleLayouts';
import MapCanvas from '@/components/MapCanvas';
import ControlsPanel from '@/components/ControlsPanel';
export default function CitySimulator() {
const [params, setParams] = useState({
rows: 50,
cols: 50,
numSamples: 100,
roadProb: 0.2,
connectivityWeight: 0.7,
uniformityWeight: 0.3
});
const [topLayouts, setTopLayouts] = useState([]);
const [isLoading, setIsLoading] = useState(false);
const runSimulation = () => {
setIsLoading(true);
// Use setTimeout to avoid blocking the UI
setTimeout(() => {
const layouts = generateLayouts(params);
setTopLayouts(layouts.slice(0, 3));
setIsLoading(false);
}, 10);
};
// Run simulation on initial load or parameter changes
useEffect(() => {
runSimulation();
}, []); // Empty dependency array means run once on mount
return (
<div className="city-simulator">
<ControlsPanel
params={params}
onChange={setParams}
onRun={runSimulation}
isLoading={isLoading}
/>
<div className="layout-grid">
{isLoading ? (
<div className="loading">Generating city layouts...</div>
) : (
topLayouts.map((layout, idx) => (
<div key={idx} className="layout-card">
<h3>Layout #{idx+1} - Score: {layout.score.toFixed(3)}</h3>
<MapCanvas grid={layout.grid} />
</div>
))
)}
</div>
<style jsx>{`
.city-simulator {
max-width: 1200px;
margin: 0 auto;
}
.layout-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(300px, 1fr));
gap: 20px;
margin-top: 20px;
}
.layout-card {
border: 1px solid #ddd;
border-radius: 8px;
padding: 15px;
background: white;
box-shadow: 0 2px 4px rgba(0,0,0,0.1);
}
.loading {
grid-column: 1 / -1;
text-align: center;
padding: 40px;
font-size: 1.2rem;
color: #666;
}
`}</style>
</div>
);
}
Our CitySimulator
component manages the state of the simulation parameters and the generated layouts. It uses the useEffect
hook to run the simulation when the component mounts, and provides a function to rerun the simulation when the parameters change.
MapCanvas.jsx
Now let’s implement the canvas renderer that will visualize our city layouts:
// components/MapCanvas.jsx
import React, { useRef, useEffect } from 'react';
export default function MapCanvas({ grid }) {
const canvasRef = useRef();
useEffect(() => {
if (!grid || !canvasRef.current) return;
const canvas = canvasRef.current;
const ctx = canvas.getContext('2d');
const rows = grid.length;
const cols = grid[0].length;
const cellSize = Math.min(canvas.width / cols, canvas.height / rows);
// Clear canvas
ctx.clearRect(0, 0, canvas.width, canvas.height);
// Calculate offset to center the grid
const offsetX = (canvas.width - cols * cellSize) / 2;
const offsetY = (canvas.height - rows * cellSize) / 2;
// Draw grid
grid.forEach((row, y) => {
row.forEach((cell, x) => {
// Set the fill color based on cell type
if (cell === 0) {
// Road
ctx.fillStyle = '#333';
} else {
// Building block
ctx.fillStyle = '#8BC34A'; // green for buildings
}
// Draw the cell
ctx.fillRect(
offsetX + x * cellSize,
offsetY + y * cellSize,
cellSize,
cellSize
);
// Add a subtle grid line
ctx.strokeStyle = '#00000022';
ctx.lineWidth = 0.5;
ctx.strokeRect(
offsetX + x * cellSize,
offsetY + y * cellSize,
cellSize,
cellSize
);
});
});
}, [grid]);
return (
<div className="map-canvas-container">
<canvas
ref={canvasRef}
width={300}
height={300}
className="map-canvas"
/>
<style jsx>{`
.map-canvas-container {
display: flex;
justify-content: center;
align-items: center;
width: 100%;
height: 100%;
}
.map-canvas {
border: 1px solid #eee;
background: #f9f9f9;
max-width: 100%;
}
`}</style>
</div>
);
}
The MapCanvas
component uses the HTML5 Canvas API to render our city layout as a grid of colored cells. Roads are rendered in dark gray, while building blocks are rendered in green, with subtle grid lines to help visualize the structure.
ControlsPanel.jsx
Finally, let’s implement the controls panel that allows users to adjust the simulation parameters:
// components/ControlsPanel.jsx
import React from 'react';
export default function ControlsPanel({ params, onChange, onRun, isLoading }) {
const updateParam = (field) => (e) => {
const value = field === 'roadProb' || field.includes('Weight')
? parseFloat(e.target.value)
: parseInt(e.target.value, 10);
onChange({ ...params, [field]: value });
};
return (
<div className="controls-panel">
<div className="controls-grid">
<div className="control-group">
<label htmlFor="numSamples">
Number of Samples: {params.numSamples}
</label>
<input
id="numSamples"
type="range"
min={10}
max={1000}
step={10}
value={params.numSamples}
onChange={updateParam('numSamples')}
/>
</div>
<div className="control-group">
<label htmlFor="gridSize">
Grid Size: {params.rows}x{params.cols}
</label>
<input
id="gridSize"
type="range"
min={10}
max={100}
value={params.rows}
onChange={(e) => {
const value = parseInt(e.target.value, 10);
onChange({ ...params, rows: value, cols: value });
}}
/>
</div>
<div className="control-group">
<label htmlFor="roadProb">
Road Probability: {params.roadProb.toFixed(2)}
</label>
<input
id="roadProb"
type="range"
min={0.05}
max={0.5}
step={0.01}
value={params.roadProb}
onChange={updateParam('roadProb')}
/>
</div>
<div className="control-group">
<label htmlFor="connectivityWeight">
Connectivity Weight: {params.connectivityWeight.toFixed(2)}
</label>
<input
id="connectivityWeight"
type="range"
min={0}
max={1}
step={0.05}
value={params.connectivityWeight}
onChange={(e) => {
const connectivity = parseFloat(e.target.value);
const uniformity = (1 - connectivity).toFixed(2);
onChange({
...params,
connectivityWeight: connectivity,
uniformityWeight: parseFloat(uniformity)
});
}}
/>
</div>
</div>
<button
onClick={onRun}
disabled={isLoading}
className="run-button"
>
{isLoading ? 'Generating...' : 'Generate City Layouts'}
</button>
<style jsx>{`
.controls-panel {
background: #f5f5f5;
border-radius: 8px;
padding: 20px;
margin-bottom: 20px;
}
.controls-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(250px, 1fr));
gap: 15px;
margin-bottom: 20px;
}
.control-group {
display: flex;
flex-direction: column;
}
label {
margin-bottom: 8px;
font-weight: 500;
}
input[type="range"] {
width: 100%;
}
.run-button {
background: #2196F3;
color: white;
border: none;
border-radius: 4px;
padding: 10px 20px;
font-size: 16px;
cursor: pointer;
font-weight: 500;
transition: background 0.2s;
}
.run-button:hover {
background: #1976D2;
}
.run-button:disabled {
background: #BBDEFB;
cursor: not-allowed;
}
`}</style>
</div>
);
}
The ControlsPanel
component provides a user-friendly interface for adjusting the simulation parameters. It includes sliders for the number of samples, grid size, road probability, and scoring weights, as well as a button to run the simulation with the current parameters.
Conclusion
In this tutorial, we’ve built a procedural city generator using Monte Carlo methods and React. By randomly sampling many possible layouts and scoring them based on road connectivity and building block distribution, we can generate a variety of interesting and realistic-looking city layouts.
This approach demonstrates the power of combining simple rules with randomness to create complex, emergent patterns. The beauty of procedural generation is that it can create endless variations, making it perfect for games, simulations, and visual demos.
You can extend this system in many ways:
- Enhance the scoring function to consider factors like distance to city center, natural barriers, or population density
- Add more cell types like parks, water features, or different building densities
- Implement an iterative approach using Monte Carlo Tree Search to refine promising layouts
- Add 3D visualization using Three.js for a more immersive representation
- Implement more sophisticated road network algorithms, like L-systems or agent-based approaches
The complete working example of this procedural city generator is shown below:
Procedural City Generator
I hope this tutorial has inspired you to explore procedural generation techniques in your own projects. Happy coding!