How to Implement Procedural Generation for Tile-Based Games
Purpose
This post shows how to implement procedural generation for tile-based games with smooth terrain transitions. I will demonstrate the core techniques: noise-based terrain, vertex blending for smooth elevation changes, and dynamic building placement.
Environment
- TypeScript 5.x
- simplex-noise library
- Canvas or WebGL rendering
- Isometric or top-down tile system
Basic Terrain Generation
The foundation of procedural terrain is noise. Noise functions generate smooth, natural-looking random values. I use Simplex noise because it’s faster than Perlin noise and produces fewer artifacts.
Here’s a basic terrain generator:
import { createNoise2D } from 'simplex-noise'
interface TileMap { width: number height: number tiles: number[][]}
function generateTerrain( width: number, height: number, seed: number): TileMap { const noise2D = createNoise2D(() => seed) const tiles: number[][] = []
for (let x = 0; x < width; x++) { tiles[x] = [] for (let y = 0; y < height; y++) { // Multi-octave noise for natural terrain const elevation = noise2D(x * 0.02, y * 0.02) * 1.0 + noise2D(x * 0.05, y * 0.05) * 0.5 + noise2D(x * 0.1, y * 0.1) * 0.25
// Map elevation to tile type tiles[x][y] = elevationToTile(elevation) } }
return { width, height, tiles }}
function elevationToTile(elevation: number): number { if (elevation < -0.5) return 0 // Water if (elevation < 0) return 1 // Sand if (elevation < 0.5) return 2 // Grass return 3 // Mountain}The key part is multi-octave noise. Each octave adds detail:
- First octave (0.02): Large terrain features
- Second octave (0.05): Medium details
- Third octave (0.1): Fine details
Smaller frequency values create larger features. The amplitude decreases (1.0, 0.5, 0.25) so larger features dominate.
When I generate a 100x100 map with seed 42, I get varied terrain with water bodies, beaches, grasslands, and mountains. Changing the seed produces completely different layouts.
Smooth Terrain Transitions
Flat tiles look blocky. I think the key to good-looking terrain is blending tile heights at the vertex level.
Here’s my vertex blending approach:
interface TileVertex { x: number y: number z: number}
interface BlendedTile { corners: [TileVertex, TileVertex, TileVertex, TileVertex] type: number}
function blendTileHeights( heightMap: number[][], x: number, y: number): BlendedTile { const smooth = 0.3 // Blend factor
// Sample heights from neighboring tiles const center = heightMap[x][y] const north = heightMap[x][y - 1] ?? center const south = heightMap[x][y + 1] ?? center const east = heightMap[x + 1]?.[y] ?? center const west = heightMap[x - 1]?.[y] ?? center
// Calculate corner heights with blending const corners: TileVertex[] = [ { x: x, y: y, z: lerp(lerp(center, north, smooth), lerp(center, west, smooth), 0.5) }, { x: x + 1, y: y, z: lerp(lerp(center, north, smooth), lerp(center, east, smooth), 0.5) }, { x: x + 1, y: y + 1, z: lerp(lerp(center, south, smooth), lerp(center, east, smooth), 0.5) }, { x: x, y: y + 1, z: lerp(lerp(center, south, smooth), lerp(center, west, smooth), 0.5) } ]
return { corners: corners as [TileVertex, TileVertex, TileVertex, TileVertex], type: determineTileType(corners) }}
function lerp(a: number, b: number, t: number): number { return a + (b - a) * t}Each tile corner samples the center tile and its neighbors. The blend factor (0.3) controls how much neighbors affect the corner. Higher values create smoother transitions.
For isometric games, I use these corner heights to render tiles as 3D quads. Top-down games can use corner heights for sprite stacking or visual effects.
Placing Buildings Procedurally
Once I have terrain, I place buildings using constraint-based rules:
interface Building { x: number y: number width: number height: number type: string}
function placeBuildings( tiles: number[][], density: number): Building[] { const buildings: Building[] = [] const width = tiles.length const height = tiles[0].length
// Find valid building locations for (let x = 1; x < width - 1; x++) { for (let y = 1; y < height - 1; y++) { // Only build on flat terrain if (tiles[x][y] !== 2) continue // Grass only
// Check spacing from other buildings const tooClose = buildings.some(b => Math.abs(b.x - x) < 3 && Math.abs(b.y - y) < 3 ) if (tooClose) continue
// Random placement based on density if (Math.random() < density) { const size = Math.floor(Math.random() * 2) + 1 // 1x1 or 2x2 buildings.push({ x, y, width: size, height: size, type: selectBuildingType() }) } } }
return buildings}The constraints are:
- Only place on grass tiles (terrain type 2)
- Keep 3-tile spacing between buildings
- Use density parameter (0.0 to 1.0) to control how many buildings spawn
- Random building sizes between 1x1 and 2x2
I can extend this with more rules: avoid water edges, prefer flat areas, cluster buildings near roads, or use a secondary noise layer for city density zones.
Connecting Buildings with Roads
Buildings need connections. I use A* pathfinding to generate road networks:
interface Road { path: { x: number; y: number }[]}
function generateRoadNetwork( buildings: Building[], tiles: number[][]): Road[] { const roads: Road[] = []
// Connect buildings to nearest neighbors for (let i = 0; i < buildings.length; i++) { const nearest = findNearestBuilding(buildings, i) if (!nearest) continue
const path = findPath( buildings[i], nearest, tiles )
if (path) { roads.push({ path }) } }
return roads}
function findPath( start: Building, end: Building, tiles: number[][]): { x: number; y: number }[] | null { // A* pathfinding avoiding water and steep terrain const openSet: { x: number; y: number; f: number }[] = [] const cameFrom = new Map<string, { x: number; y: number }>() const gScore = new Map<string, number>()
const startKey = `${start.x},${start.y}` openSet.push({ x: start.x, y: start.y, f: 0 }) gScore.set(startKey, 0)
while (openSet.length > 0) { // Sort by f-score and get lowest openSet.sort((a, b) => a.f - b.f) const current = openSet.shift()!
// Reached destination if (current.x === end.x && current.y === end.y) { return reconstructPath(cameFrom, current) }
// Check neighbors const neighbors = [ { x: current.x + 1, y: current.y }, { x: current.x - 1, y: current.y }, { x: current.x, y: current.y + 1 }, { x: current.x, y: current.y - 1 } ]
for (const neighbor of neighbors) { if (!isValidTile(neighbor.x, neighbor.y, tiles)) continue
const neighborKey = `${neighbor.x},${neighbor.y}` const tentativeG = (gScore.get(`${current.x},${current.y}`) ?? 0) + 1
if (tentativeG < (gScore.get(neighborKey) ?? Infinity)) { cameFrom.set(neighborKey, current) gScore.set(neighborKey, tentativeG) const f = tentativeG + heuristic(neighbor, end) openSet.push({ ...neighbor, f }) } } }
return null // No path found}
function heuristic(a: { x: number; y: number }, b: { x: number; y: number }): number { return Math.abs(a.x - b.x) + Math.abs(a.y - b.y)}
function isValidTile(x: number, y: number, tiles: number[][]): boolean { return x >= 0 && x < tiles.length && y >= 0 && y < tiles[0].length && tiles[x][y] !== 0 // Not water}
function reconstructPath( cameFrom: Map<string, { x: number; y: number }>, current: { x: number; y: number }): { x: number; y: number }[] { const path = [current] let currentKey = `${current.x},${current.y}`
while (cameFrom.has(currentKey)) { current = cameFrom.get(currentKey)! path.unshift(current) currentKey = `${current.x},${current.y}` }
return path}A* finds the shortest path while avoiding water tiles (type 0). The heuristic uses Manhattan distance since roads run horizontally and vertically. This creates organic-looking road networks that connect buildings efficiently.
Chunk-Based World Generation
For large worlds, generating everything at once is too slow. I use chunk-based generation:
interface Chunk { x: number y: number size: number tiles: number[][]}
class ProceduralWorld { private chunks: Map<string, Chunk> = new Map() private chunkSize = 32 private seed: number
constructor(seed: number) { this.seed = seed }
getChunk(chunkX: number, chunkY: number): Chunk { const key = `${chunkX},${chunkY}`
if (!this.chunks.has(key)) { this.chunks.set(key, this.generateChunk(chunkX, chunkY)) }
return this.chunks.get(key)! }
private generateChunk(chunkX: number, chunkY: number): Chunk { const offsetX = chunkX * this.chunkSize const offsetY = chunkY * this.chunkSize
const tiles: number[][] = [] for (let x = 0; x < this.chunkSize; x++) { tiles[x] = [] for (let y = 0; y < this.chunkSize; y++) { tiles[x][y] = this.generateTile( offsetX + x, offsetY + y ) } }
return { x: chunkX, y: chunkY, size: this.chunkSize, tiles } }
private generateTile(x: number, y: number): number { // Use coordinate-based hashing for consistency const noise = this.seededNoise(x, y) return noise > 0.5 ? 2 : 1 // Grass or sand }
private seededNoise(x: number, y: number): number { // Simple seeded hash function const n = Math.sin(x * 12.9898 + y * 78.233 + this.seed) * 43758.5453 return n - Math.floor(n) }
// Unload distant chunks cleanupChunks(centerChunkX: number, centerChunkY: number, radius: number) { for (const [key, chunk] of this.chunks) { const dist = Math.sqrt( Math.pow(chunk.x - centerChunkX, 2) + Math.pow(chunk.y - centerChunkY, 2) )
if (dist > radius) { this.chunks.delete(key) } } }}The key insight is using world coordinates (offsetX + x, offsetY + y) for noise sampling. This ensures chunk boundaries align seamlessly. The same seed always produces the same terrain at the same coordinates.
When the player moves, I load nearby chunks and unload distant ones. This keeps memory usage constant regardless of world size.
Putting It All Together
Here’s the generation pipeline I use:
- Generate base terrain with multi-octave noise
- Apply vertex blending for smooth elevation
- Place buildings using constraint checks
- Generate roads with A* pathfinding
- Add decorations (trees, rocks) with scatter sampling
- Load/unload chunks based on player position
The hybrid approach is powerful. I use procedural generation for terrain and roads, but pre-rendered tiles for buildings and props. This gives infinite variety while maintaining artistic control.
Summary
In this post, I showed how to implement procedural generation for tile-based games. The key point is combining noise algorithms with vertex blending for smooth terrain transitions. Start with basic Perlin/Simplex noise terrain, then add vertex blending for elevation, buildings with constraint rules, and roads with A* pathfinding. Use chunking for large worlds and seed-based generation for reproducible results.
Final Words + More Resources
My intention with this article was to help others share my knowledge and experience. If you want to contact me, you can contact by email: Email me
Here are also the most important links from this article along with some further resources that will help you in this scope:
- 👨💻 MicroState Demo
- 👨💻 Simplex Noise Library
Oh, and if you found these resources useful, don’t forget to support me by starring the repo on GitHub!
Comments