Skip to content

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:

"terrain.ts
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:

"vertex-blend.ts
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:

"buildings.ts
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:

"roads.ts
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:

"chunks.ts
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:

  1. Generate base terrain with multi-octave noise
  2. Apply vertex blending for smooth elevation
  3. Place buildings using constraint checks
  4. Generate roads with A* pathfinding
  5. Add decorations (trees, rocks) with scatter sampling
  6. 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:

Oh, and if you found these resources useful, don’t forget to support me by starring the repo on GitHub!

Comments