Skip to content
Back to Examples

Tilemap

Render tile-based maps efficiently with three-flatland.

import { WebGPURenderer } from 'three/webgpu'
import {
Scene,
OrthographicCamera,
DataTexture,
RGBAFormat,
NearestFilter,
SRGBColorSpace,
Raycaster,
Vector2,
Plane,
Vector3,
} from 'three'
import { TileMap2D, type TileMapData, type TilesetData, type TileLayerData, createDevtoolsProvider } from 'three-flatland'
import { createPane } from '@three-flatland/devtools'
import { gemGradientNode } from './GemBackground'
import { GEM } from './gem'
// Tile IDs for our procedural tileset
const TILES = {
EMPTY: 0,
// Ground tiles (row 0)
FLOOR_1: 1,
FLOOR_2: 2,
FLOOR_3: 3,
FLOOR_4: 4,
// Wall tiles (row 1)
WALL_TOP: 5,
WALL_LEFT: 6,
WALL_RIGHT: 7,
WALL_BOTTOM: 8,
// Corner tiles (row 2)
CORNER_TL: 9,
CORNER_TR: 10,
CORNER_BL: 11,
CORNER_BR: 12,
// Decoration tiles (row 3)
TORCH: 13,
CHEST: 14,
SKULL: 15,
BONES: 16,
} as const
// Tile colors (RGBA) for our procedural tileset
const TILE_COLORS: Record<number, [number, number, number, number]> = {
[TILES.EMPTY]: [0, 0, 0, 0],
[TILES.FLOOR_1]: [80, 70, 60, 255],
[TILES.FLOOR_2]: [90, 80, 70, 255],
[TILES.FLOOR_3]: [85, 75, 65, 255],
[TILES.FLOOR_4]: [75, 65, 55, 255],
[TILES.WALL_TOP]: [50, 50, 60, 255],
[TILES.WALL_LEFT]: [45, 45, 55, 255],
[TILES.WALL_RIGHT]: [55, 55, 65, 255],
[TILES.WALL_BOTTOM]: [40, 40, 50, 255],
[TILES.CORNER_TL]: [60, 60, 70, 255],
[TILES.CORNER_TR]: [60, 60, 70, 255],
[TILES.CORNER_BL]: [50, 50, 60, 255],
[TILES.CORNER_BR]: [50, 50, 60, 255],
[TILES.TORCH]: [255, 200, 100, 255],
[TILES.CHEST]: [200, 150, 50, 255],
[TILES.SKULL]: [200, 200, 200, 255],
[TILES.BONES]: [180, 180, 170, 255],
}
/**
* Generate a procedural tileset texture.
*/
function createProceduralTileset(tileSize: number, columns: number, rows: number): DataTexture {
const width = columns * tileSize
const height = rows * tileSize
const data = new Uint8Array(width * height * 4)
// Fill with tile colors
for (let tileId = 0; tileId < columns * rows; tileId++) {
const col = tileId % columns
const row = Math.floor(tileId / columns)
const color = TILE_COLORS[tileId + 1] ?? [128, 128, 128, 255]
// Draw tile with slight variation for visual interest
for (let py = 0; py < tileSize; py++) {
for (let px = 0; px < tileSize; px++) {
const x = col * tileSize + px
const y = row * tileSize + py
const i = (y * width + x) * 4
// Add slight noise
const noise = Math.floor(Math.random() * 20) - 10
// Add border (1px darker edge)
const isBorder = px === 0 || py === 0 || px === tileSize - 1 || py === tileSize - 1
const borderDarken = isBorder ? 20 : 0
data[i] = Math.max(0, Math.min(255, color[0] + noise - borderDarken))
data[i + 1] = Math.max(0, Math.min(255, color[1] + noise - borderDarken))
data[i + 2] = Math.max(0, Math.min(255, color[2] + noise - borderDarken))
data[i + 3] = color[3]
}
}
}
const texture = new DataTexture(data, width, height, RGBAFormat)
texture.minFilter = NearestFilter
texture.magFilter = NearestFilter
texture.colorSpace = SRGBColorSpace
texture.needsUpdate = true
return texture
}
// Density presets for BSP dungeon generation
const DENSITY_PRESETS: Record<string, { minPartition: number; roomPadding: number; minRoom: number }> = {
sparse: { minPartition: 28, roomPadding: 4, minRoom: 10 },
normal: { minPartition: 20, roomPadding: 3, minRoom: 8 },
dense: { minPartition: 14, roomPadding: 2, minRoom: 6 },
packed: { minPartition: 10, roomPadding: 1, minRoom: 4 },
}
/**
* BSP Node for dungeon generation
*/
interface BSPNode {
x: number
y: number
w: number
h: number
left?: BSPNode
right?: BSPNode
room?: { x: number; y: number; w: number; h: number }
}
/**
* Generate a procedural dungeon using BSP (Binary Space Partitioning).
*/
function generateDungeon(width: number, height: number, density: string): {
ground: Uint32Array
walls: Uint32Array
decor: Uint32Array
} {
const ground = new Uint32Array(width * height)
const walls = new Uint32Array(width * height)
const decor = new Uint32Array(width * height)
const preset = DENSITY_PRESETS[density] ?? DENSITY_PRESETS['normal']!
const MIN_PARTITION_SIZE = preset.minPartition
const MIN_ROOM_SIZE = preset.minRoom
const ROOM_PADDING = preset.roomPadding
const CORRIDOR_WIDTH = 2
// BSP split function
function splitNode(node: BSPNode, depth: number): void {
if (depth <= 0) return
if (node.w < MIN_PARTITION_SIZE * 2 && node.h < MIN_PARTITION_SIZE * 2) return
// Decide split direction based on aspect ratio
let splitHorizontal: boolean
if (node.w > node.h * 1.25) {
splitHorizontal = false // split vertically
} else if (node.h > node.w * 1.25) {
splitHorizontal = true // split horizontally
} else {
splitHorizontal = Math.random() > 0.5
}
// Check if we can split in the chosen direction
if (splitHorizontal && node.h < MIN_PARTITION_SIZE * 2) splitHorizontal = false
if (!splitHorizontal && node.w < MIN_PARTITION_SIZE * 2) splitHorizontal = true
// Final check
if (splitHorizontal && node.h < MIN_PARTITION_SIZE * 2) return
if (!splitHorizontal && node.w < MIN_PARTITION_SIZE * 2) return
if (splitHorizontal) {
const splitY = node.y + MIN_PARTITION_SIZE + Math.floor(Math.random() * (node.h - MIN_PARTITION_SIZE * 2))
node.left = { x: node.x, y: node.y, w: node.w, h: splitY - node.y }
node.right = { x: node.x, y: splitY, w: node.w, h: node.y + node.h - splitY }
} else {
const splitX = node.x + MIN_PARTITION_SIZE + Math.floor(Math.random() * (node.w - MIN_PARTITION_SIZE * 2))
node.left = { x: node.x, y: node.y, w: splitX - node.x, h: node.h }
node.right = { x: splitX, y: node.y, w: node.x + node.w - splitX, h: node.h }
}
splitNode(node.left, depth - 1)
splitNode(node.right, depth - 1)
}
// Create rooms in leaf nodes
function createRooms(node: BSPNode): void {
if (node.left && node.right) {
createRooms(node.left)
createRooms(node.right)
return
}
// Leaf node - create a room
const maxRoomW = node.w - ROOM_PADDING * 2
const maxRoomH = node.h - ROOM_PADDING * 2
if (maxRoomW < MIN_ROOM_SIZE || maxRoomH < MIN_ROOM_SIZE) return
const roomW = MIN_ROOM_SIZE + Math.floor(Math.random() * (maxRoomW - MIN_ROOM_SIZE + 1))
const roomH = MIN_ROOM_SIZE + Math.floor(Math.random() * (maxRoomH - MIN_ROOM_SIZE + 1))
const roomX = node.x + ROOM_PADDING + Math.floor(Math.random() * (maxRoomW - roomW + 1))
const roomY = node.y + ROOM_PADDING + Math.floor(Math.random() * (maxRoomH - roomH + 1))
node.room = { x: roomX, y: roomY, w: roomW, h: roomH }
}
// Get a room from a node (descends into children)
function getRoom(node: BSPNode): { x: number; y: number; w: number; h: number } | undefined {
if (node.room) return node.room
if (node.left && node.right) {
return Math.random() > 0.5 ? getRoom(node.left) : getRoom(node.right)
}
if (node.left) return getRoom(node.left)
if (node.right) return getRoom(node.right)
return undefined
}
// Connect sibling nodes with corridors
function connectNodes(node: BSPNode): void {
if (!node.left || !node.right) return
connectNodes(node.left)
connectNodes(node.right)
const roomA = getRoom(node.left)
const roomB = getRoom(node.right)
if (!roomA || !roomB) return
// Get center points
const ax = roomA.x + Math.floor(roomA.w / 2)
const ay = roomA.y + Math.floor(roomA.h / 2)
const bx = roomB.x + Math.floor(roomB.w / 2)
const by = roomB.y + Math.floor(roomB.h / 2)
// Create L-shaped corridor with width
const midX = Math.random() > 0.5 ? ax : bx
// Horizontal segment
const startX = Math.min(ax, midX)
const endX = Math.max(ax, midX)
for (let x = startX; x <= endX; x++) {
for (let dy = -Math.floor(CORRIDOR_WIDTH / 2); dy <= Math.floor(CORRIDOR_WIDTH / 2); dy++) {
const ty = ay + dy
if (ty >= 0 && ty < height && x >= 0 && x < width) {
ground[ty * width + x] = TILES.FLOOR_1 + Math.floor(Math.random() * 4)
}
}
}
// Vertical segment from ay to by at midX
const startY = Math.min(ay, by)
const endY = Math.max(ay, by)
for (let y = startY; y <= endY; y++) {
for (let dx = -Math.floor(CORRIDOR_WIDTH / 2); dx <= Math.floor(CORRIDOR_WIDTH / 2); dx++) {
const tx = midX + dx
if (y >= 0 && y < height && tx >= 0 && tx < width) {
ground[y * width + tx] = TILES.FLOOR_1 + Math.floor(Math.random() * 4)
}
}
}
// Horizontal segment from midX to bx at by
const startX2 = Math.min(midX, bx)
const endX2 = Math.max(midX, bx)
for (let x = startX2; x <= endX2; x++) {
for (let dy = -Math.floor(CORRIDOR_WIDTH / 2); dy <= Math.floor(CORRIDOR_WIDTH / 2); dy++) {
const ty = by + dy
if (ty >= 0 && ty < height && x >= 0 && x < width) {
ground[ty * width + x] = TILES.FLOOR_1 + Math.floor(Math.random() * 4)
}
}
}
}
// Collect all rooms for decoration
function collectRooms(node: BSPNode, rooms: Array<{ x: number; y: number; w: number; h: number }>): void {
if (node.room) rooms.push(node.room)
if (node.left) collectRooms(node.left, rooms)
if (node.right) collectRooms(node.right, rooms)
}
// Build BSP tree
const root: BSPNode = { x: 1, y: 1, w: width - 2, h: height - 2 }
const depth = Math.floor(Math.log2(Math.min(width, height) / MIN_PARTITION_SIZE)) + 1
splitNode(root, depth)
createRooms(root)
// Draw rooms
const rooms: Array<{ x: number; y: number; w: number; h: number }> = []
collectRooms(root, rooms)
for (const room of rooms) {
for (let y = room.y; y < room.y + room.h; y++) {
for (let x = room.x; x < room.x + room.w; x++) {
if (x >= 0 && x < width && y >= 0 && y < height) {
ground[y * width + x] = TILES.FLOOR_1 + Math.floor(Math.random() * 4)
}
}
}
}
// Connect rooms
connectNodes(root)
// Add walls around floor tiles
for (let y = 1; y < height - 1; y++) {
for (let x = 1; x < width - 1; x++) {
const idx = y * width + x
if (ground[idx] === 0) continue
// Check all 8 neighbors for wall placement
for (let dy = -1; dy <= 1; dy++) {
for (let dx = -1; dx <= 1; dx++) {
if (dx === 0 && dy === 0) continue
const nx = x + dx
const ny = y + dy
const nidx = ny * width + nx
if (ground[nidx] === 0 && walls[nidx] === 0) {
// Choose wall type based on direction
if (dy === -1) walls[nidx] = TILES.WALL_TOP
else if (dy === 1) walls[nidx] = TILES.WALL_BOTTOM
else if (dx === -1) walls[nidx] = TILES.WALL_LEFT
else walls[nidx] = TILES.WALL_RIGHT
}
}
}
}
}
// Add decorations
for (const room of rooms) {
// Torches in all 4 corners
const corners = [
{ x: room.x + 1, y: room.y + 1 },
{ x: room.x + room.w - 2, y: room.y + 1 },
{ x: room.x + 1, y: room.y + room.h - 2 },
{ x: room.x + room.w - 2, y: room.y + room.h - 2 },
]
for (const corner of corners) {
if (Math.random() > 0.6) {
const idx = corner.y * width + corner.x
if (ground[idx] !== 0 && decor[idx] === 0) {
decor[idx] = TILES.TORCH
}
}
}
// Random decorations in room interior
const numDecorations = Math.floor(room.w * room.h / 40) + 1
for (let i = 0; i < numDecorations; i++) {
if (room.w <= 4 || room.h <= 4) continue
const rx = room.x + 2 + Math.floor(Math.random() * (room.w - 4))
const ry = room.y + 2 + Math.floor(Math.random() * (room.h - 4))
const idx = ry * width + rx
if (ground[idx] !== 0 && decor[idx] === 0) {
const roll = Math.random()
if (roll < 0.3) decor[idx] = TILES.CHEST
else if (roll < 0.6) decor[idx] = TILES.SKULL
else decor[idx] = TILES.BONES
}
}
}
return { ground, walls, decor }
}
/**
* Create tilemap data from generated layers.
*/
function createTileMapData(
width: number,
height: number,
tileSize: number,
tileset: TilesetData,
layers: { ground: Uint32Array; walls: Uint32Array; decor: Uint32Array }
): TileMapData {
const tileLayers: TileLayerData[] = [
{
name: 'Ground',
id: 0,
width,
height,
data: layers.ground,
visible: true,
},
{
name: 'Walls',
id: 1,
width,
height,
data: layers.walls,
visible: true,
},
{
name: 'Decor',
id: 2,
width,
height,
data: layers.decor,
visible: true,
},
]
return {
width,
height,
tileWidth: tileSize,
tileHeight: tileSize,
orientation: 'orthogonal',
renderOrder: 'right-down',
infinite: false,
tilesets: [tileset],
tileLayers,
objectLayers: [],
}
}
// Map size presets (in tiles)
const MAP_SIZE_PRESETS: Record<string, number> = {
sm: 64,
md: 128,
lg: 256,
xl: 512,
}
/* HMR-tracked teardown state. Without this, every dev save accumulates
* a fresh renderer + animate() loop while the previous one keeps
* RAFing forever. Dev-only — `import.meta.hot` is undefined in prod. */
let rafId = 0
let activeRenderer: WebGPURenderer | null = null
async function main() {
const TILE_SIZE = 16
const TILESET_COLUMNS = 4
const TILESET_ROWS = 4
// Scene setup
const scene = new Scene()
;(scene as any).backgroundNode = gemGradientNode({ gem: GEM })
// Orthographic camera
const frustumSize = 800
let aspect = window.innerWidth / window.innerHeight
const camera = new OrthographicCamera(
(-frustumSize * aspect) / 2,
(frustumSize * aspect) / 2,
frustumSize / 2,
-frustumSize / 2,
0.1,
1000
)
camera.position.z = 100
// WebGPU Renderer
const renderer = new WebGPURenderer({ antialias: false })
activeRenderer = renderer
renderer.setSize(window.innerWidth, window.innerHeight)
renderer.setPixelRatio(1) // Pixel-perfect for pixel art
renderer.domElement.style.imageRendering = 'pixelated'
document.body.appendChild(renderer.domElement)
await renderer.init()
// Create procedural tileset
const tilesetTexture = createProceduralTileset(TILE_SIZE, TILESET_COLUMNS, TILESET_ROWS)
const tilesetData: TilesetData = {
name: 'dungeon',
firstGid: 1,
tileWidth: TILE_SIZE,
tileHeight: TILE_SIZE,
imageWidth: TILESET_COLUMNS * TILE_SIZE,
imageHeight: TILESET_ROWS * TILE_SIZE,
columns: TILESET_COLUMNS,
tileCount: TILESET_COLUMNS * TILESET_ROWS,
tiles: new Map(),
texture: tilesetTexture,
}
// GUI state
const gen = { mapSize: 'md' as string, chunkSize: 512, density: 'normal' as string, seed: 42 }
const layers = { showGround: true, showWalls: true, showDecor: true }
const cam = { zoom: 0 }
const tileStats = { tiles: 0, chunks: 0, layers: 0 }
// Zoom range computed from map extent
let mapSize = MAP_SIZE_PRESETS[gen.mapSize]!
let mapExtent = mapSize * TILE_SIZE
let zoomOut = mapExtent / (frustumSize * Math.min(1, aspect))
let zoomIn = 0.1
// Start fully zoomed out to frame the whole map
let zoom = zoomOut
let targetZoom = zoom
// Build tilemap from current state
function buildTilemap(): TileMap2D {
const dungeonLayers = generateDungeon(mapSize, mapSize, gen.density)
const mapData = createTileMapData(mapSize, mapSize, TILE_SIZE, tilesetData, dungeonLayers)
const tm = new TileMap2D({ data: mapData, chunkSize: gen.chunkSize })
// Apply current layer visibility
const groundLayer = tm.getLayerAt(0)
const wallsLayer = tm.getLayerAt(1)
const decorLayer = tm.getLayerAt(2)
if (groundLayer) groundLayer.visible = layers.showGround
if (wallsLayer) wallsLayer.visible = layers.showWalls
if (decorLayer) decorLayer.visible = layers.showDecor
return tm
}
// Initial build
let tilemap = buildTilemap()
scene.add(tilemap)
// Center camera on map
camera.position.x = (mapSize * TILE_SIZE) / 2
camera.position.y = (mapSize * TILE_SIZE) / 2
function updateStats() {
tileStats.tiles = tilemap.totalTileCount
tileStats.chunks = tilemap.totalChunkCount
tileStats.layers = tilemap.layerCount
}
updateStats()
// Rebuild tilemap (on map size, density, or seed change)
function rebuildTilemap() {
scene.remove(tilemap)
tilemap.dispose()
mapSize = MAP_SIZE_PRESETS[gen.mapSize] ?? 128
tilemap = buildTilemap()
scene.add(tilemap)
// Re-center camera
camera.position.x = (mapSize * TILE_SIZE) / 2
camera.position.y = (mapSize * TILE_SIZE) / 2
// Recalculate zoom range and auto-fit to new map
mapExtent = mapSize * TILE_SIZE
zoomOut = mapExtent / (frustumSize * Math.min(1, aspect))
zoomIn = 0.1
cam.zoom = 0
zoom = zoomOut
targetZoom = zoomOut
updateStats()
pane.refresh()
}
// Tweakpane UI
const { pane, update: updateDevtools } = createPane({ driver: 'manual' })
const devtools = createDevtoolsProvider({ name: 'tilemap' })
// Layers folder
const layerFolder = pane.addFolder({ title: 'Layers', expanded: false })
layerFolder.addBinding(layers, 'showGround', { label: 'ground' })
.on('change', (ev) => {
const layer = tilemap.getLayerAt(0)
if (layer) layer.visible = ev.value
})
layerFolder.addBinding(layers, 'showWalls', { label: 'walls' })
.on('change', (ev) => {
const layer = tilemap.getLayerAt(1)
if (layer) layer.visible = ev.value
})
layerFolder.addBinding(layers, 'showDecor', { label: 'decor' })
.on('change', (ev) => {
const layer = tilemap.getLayerAt(2)
if (layer) layer.visible = ev.value
})
// Tiles folder (monitors)
const tilesFolder = pane.addFolder({ title: 'Tiles', expanded: false })
tilesFolder.addBinding(tileStats, 'tiles', { readonly: true, format: (v: number) => v.toFixed(0) })
tilesFolder.addBinding(tileStats, 'chunks', { readonly: true, format: (v: number) => v.toFixed(0) })
tilesFolder.addBinding(tileStats, 'layers', { readonly: true, format: (v: number) => v.toFixed(0) })
// Generation folder (at bottom, expanded)
const genFolder = pane.addFolder({ title: 'Tilemap' })
genFolder.addBinding(gen, 'mapSize', {
options: { SM: 'sm', MD: 'md', LG: 'lg', XL: 'xl' },
label: 'map size',
}).on('change', () => rebuildTilemap())
genFolder.addBinding(gen, 'chunkSize', {
options: { '256': 256, '512': 512, '1024': 1024, '2048': 2048 },
label: 'chunk',
}).on('change', () => rebuildTilemap())
genFolder.addBinding(gen, 'density', {
options: { Sparse: 'sparse', Normal: 'normal', Dense: 'dense', Packed: 'packed' },
}).on('change', () => rebuildTilemap())
genFolder.addBinding(gen, 'seed', { min: 0, max: 999999, step: 1 })
.on('change', () => rebuildTilemap())
genFolder.addButton({ title: 'Regenerate' }).on('click', () => {
gen.seed = Math.floor(Math.random() * 1000000)
pane.refresh()
rebuildTilemap()
})
// Camera controls
const keys = new Set<string>()
const PAN_KEYS = new Set(['w', 'a', 's', 'd', 'arrowup', 'arrowdown', 'arrowleft', 'arrowright'])
window.addEventListener('keydown', (e) => {
const key = e.key.toLowerCase()
if (PAN_KEYS.has(key)) e.preventDefault()
keys.add(key)
})
window.addEventListener('keyup', (e) => keys.delete(e.key.toLowerCase()))
// Drag to pan (pointer events, with two-finger touch support)
let isDragging = false
let dragStart = { x: 0, y: 0 }
let cameraStart = { x: 0, y: 0 }
let dragDistance = 0
const activePointers = new Map<number, PointerEvent>()
renderer.domElement.addEventListener('pointerdown', (e) => {
activePointers.set(e.pointerId, e)
// Mouse or pen: start drag immediately
// Touch: only start drag when two fingers are down
if (e.pointerType !== 'touch' || activePointers.size >= 2) {
isDragging = true
dragStart = { x: e.clientX, y: e.clientY }
cameraStart = { x: camera.position.x, y: camera.position.y }
dragDistance = 0
}
})
window.addEventListener('pointermove', (e) => {
activePointers.set(e.pointerId, e)
// Start dragging if second finger arrived
if (e.pointerType === 'touch' && activePointers.size >= 2 && !isDragging) {
isDragging = true
dragStart = { x: e.clientX, y: e.clientY }
cameraStart = { x: camera.position.x, y: camera.position.y }
dragDistance = 0
}
if (!isDragging) return
const dx = e.clientX - dragStart.x
const dy = e.clientY - dragStart.y
dragDistance = Math.sqrt(dx * dx + dy * dy)
if (dragDistance > 3) {
renderer.domElement.style.cursor = 'move'
}
const worldPerPixel = (frustumSize * zoom) / window.innerHeight
camera.position.x = cameraStart.x - dx * worldPerPixel
camera.position.y = cameraStart.y + dy * worldPerPixel
})
window.addEventListener('pointerup', (e) => {
activePointers.delete(e.pointerId)
if (activePointers.size < 2) {
isDragging = false
renderer.domElement.style.cursor = ''
}
})
window.addEventListener('pointercancel', (e) => {
activePointers.delete(e.pointerId)
if (activePointers.size < 2) {
isDragging = false
renderer.domElement.style.cursor = ''
}
})
// Ctrl+wheel to zoom
function applyZoomSlider(newVal: number) {
cam.zoom = Math.max(0, Math.min(100, newVal))
const t = cam.zoom / 100
targetZoom = zoomOut * Math.pow(zoomIn / zoomOut, t)
pane.refresh()
}
renderer.domElement.addEventListener('wheel', (e) => {
if (!e.ctrlKey && !e.metaKey) return
e.preventDefault()
const delta = e.deltaY > 0 ? -2 : 2
applyZoomSlider(cam.zoom + delta)
}, { passive: false })
// Pinch-to-zoom via touch
let lastPinchDist = 0
function getPinchDist(): number {
const pts = [...activePointers.values()]
if (pts.length < 2) return 0
const dx = pts[0]!.clientX - pts[1]!.clientX
const dy = pts[0]!.clientY - pts[1]!.clientY
return Math.sqrt(dx * dx + dy * dy)
}
renderer.domElement.addEventListener('touchstart', () => {
if (activePointers.size >= 2) lastPinchDist = getPinchDist()
}, { passive: true })
window.addEventListener('pointermove', () => {
if (activePointers.size >= 2) {
const dist = getPinchDist()
if (lastPinchDist > 0 && dist > 0) {
const scale = dist / lastPinchDist
const delta = (scale - 1) * 15
applyZoomSlider(cam.zoom + delta)
}
lastPinchDist = dist
}
})
// Click to toggle tile (only if not dragging)
const raycaster = new Raycaster()
const mouse = new Vector2()
const plane = new Plane(new Vector3(0, 0, 1), 0)
const intersection = new Vector3()
renderer.domElement.addEventListener('click', (e) => {
if (dragDistance > 5) return
mouse.x = (e.clientX / window.innerWidth) * 2 - 1
mouse.y = -(e.clientY / window.innerHeight) * 2 + 1
raycaster.setFromCamera(mouse, camera)
raycaster.ray.intersectPlane(plane, intersection)
const tilePos = tilemap.worldToTile(intersection.x, intersection.y)
const decorLayer = tilemap.getLayerAt(2)
if (decorLayer) {
const currentTile = decorLayer.getTileAt(tilePos.x, tilePos.y)
const newTile = currentTile === 0 ? TILES.TORCH : 0
decorLayer.setTileAt(tilePos.x, tilePos.y, newTile)
}
})
// Handle resize
window.addEventListener('resize', () => {
aspect = window.innerWidth / window.innerHeight
camera.left = (-frustumSize * aspect) / 2
camera.right = (frustumSize * aspect) / 2
camera.top = frustumSize / 2
camera.bottom = -frustumSize / 2
camera.updateProjectionMatrix()
renderer.setSize(window.innerWidth, window.innerHeight)
})
// Animation loop
let lastTime = performance.now()
let statsTime = 0
function animate() {
rafId = requestAnimationFrame(animate)
const now = performance.now()
const deltaMs = now - lastTime
lastTime = now
// Lerp zoom toward target
const lerpRate = 1 - Math.pow(0.001, deltaMs / 1000) // ~6x per second smoothing
zoom += (targetZoom - zoom) * lerpRate
// Camera movement
const speed = 200 * (deltaMs / 1000) * zoom
if (keys.has('w') || keys.has('arrowup')) camera.position.y += speed
if (keys.has('s') || keys.has('arrowdown')) camera.position.y -= speed
if (keys.has('a') || keys.has('arrowleft')) camera.position.x -= speed
if (keys.has('d') || keys.has('arrowright')) camera.position.x += speed
// Apply zoom
camera.left = (-frustumSize * aspect * zoom) / 2
camera.right = (frustumSize * aspect * zoom) / 2
camera.top = (frustumSize * zoom) / 2
camera.bottom = (-frustumSize * zoom) / 2
camera.updateProjectionMatrix()
// Update animated tiles
tilemap.update(deltaMs)
devtools.beginFrame(performance.now(), renderer)
renderer.render(scene, camera)
devtools.endFrame(renderer)
updateDevtools()
// Update tile stats periodically
statsTime += deltaMs
if (statsTime >= 1000) {
updateStats()
pane.refresh()
statsTime = 0
}
}
animate()
}
main()
if (import.meta.hot) {
import.meta.hot.dispose(() => {
if (rafId) {
cancelAnimationFrame(rafId)
rafId = 0
}
if (activeRenderer) {
activeRenderer.dispose?.()
activeRenderer.domElement.remove()
activeRenderer = null
}
})
}
import { Suspense, useState, useRef, useMemo, useEffect, useCallback } from 'react'
import { Canvas, extend, useFrame, useThree } from '@react-three/fiber/webgpu'
import {
DataTexture,
RGBAFormat,
NearestFilter,
SRGBColorSpace,
type OrthographicCamera,
} from 'three'
import {
TileMap2D,
type TileMapData,
type TilesetData,
type TileLayerData,
} from 'three-flatland/react'
import { DevtoolsProvider, usePane, usePaneFolder, usePaneInput, usePaneButton } from '@three-flatland/devtools/react'
import { GemBackground } from './GemBackground'
import { GEM } from './gem'
// Register TileMap2D with R3F
extend({ TileMap2D })
// Tile IDs for our procedural tileset
const TILES = {
EMPTY: 0,
FLOOR_1: 1,
FLOOR_2: 2,
FLOOR_3: 3,
FLOOR_4: 4,
WALL_TOP: 5,
WALL_LEFT: 6,
WALL_RIGHT: 7,
WALL_BOTTOM: 8,
CORNER_TL: 9,
CORNER_TR: 10,
CORNER_BL: 11,
CORNER_BR: 12,
TORCH: 13,
CHEST: 14,
SKULL: 15,
BONES: 16,
} as const
// Tile colors (RGBA)
const TILE_COLORS: Record<number, [number, number, number, number]> = {
[TILES.EMPTY]: [0, 0, 0, 0],
[TILES.FLOOR_1]: [80, 70, 60, 255],
[TILES.FLOOR_2]: [90, 80, 70, 255],
[TILES.FLOOR_3]: [85, 75, 65, 255],
[TILES.FLOOR_4]: [75, 65, 55, 255],
[TILES.WALL_TOP]: [50, 50, 60, 255],
[TILES.WALL_LEFT]: [45, 45, 55, 255],
[TILES.WALL_RIGHT]: [55, 55, 65, 255],
[TILES.WALL_BOTTOM]: [40, 40, 50, 255],
[TILES.CORNER_TL]: [60, 60, 70, 255],
[TILES.CORNER_TR]: [60, 60, 70, 255],
[TILES.CORNER_BL]: [50, 50, 60, 255],
[TILES.CORNER_BR]: [50, 50, 60, 255],
[TILES.TORCH]: [255, 200, 100, 255],
[TILES.CHEST]: [200, 150, 50, 255],
[TILES.SKULL]: [200, 200, 200, 255],
[TILES.BONES]: [180, 180, 170, 255],
}
const TILE_SIZE = 16
const TILESET_COLUMNS = 4
const TILESET_ROWS = 4
function createProceduralTileset(): DataTexture {
const width = TILESET_COLUMNS * TILE_SIZE
const height = TILESET_ROWS * TILE_SIZE
const data = new Uint8Array(width * height * 4)
for (let tileId = 0; tileId < TILESET_COLUMNS * TILESET_ROWS; tileId++) {
const col = tileId % TILESET_COLUMNS
const row = Math.floor(tileId / TILESET_COLUMNS)
const color = TILE_COLORS[tileId + 1] ?? [128, 128, 128, 255]
for (let py = 0; py < TILE_SIZE; py++) {
for (let px = 0; px < TILE_SIZE; px++) {
const x = col * TILE_SIZE + px
const y = row * TILE_SIZE + py
const i = (y * width + x) * 4
const noise = Math.floor(Math.random() * 20) - 10
const isBorder = px === 0 || py === 0 || px === TILE_SIZE - 1 || py === TILE_SIZE - 1
const borderDarken = isBorder ? 20 : 0
data[i] = Math.max(0, Math.min(255, color[0] + noise - borderDarken))
data[i + 1] = Math.max(0, Math.min(255, color[1] + noise - borderDarken))
data[i + 2] = Math.max(0, Math.min(255, color[2] + noise - borderDarken))
data[i + 3] = color[3]
}
}
}
const texture = new DataTexture(data, width, height, RGBAFormat)
texture.minFilter = NearestFilter
texture.magFilter = NearestFilter
texture.colorSpace = SRGBColorSpace
texture.needsUpdate = true
return texture
}
// Density presets for BSP dungeon generation
const DENSITY_PRESETS: Record<string, { minPartition: number; roomPadding: number; minRoom: number }> = {
sparse: { minPartition: 28, roomPadding: 4, minRoom: 10 },
normal: { minPartition: 20, roomPadding: 3, minRoom: 8 },
dense: { minPartition: 14, roomPadding: 2, minRoom: 6 },
packed: { minPartition: 10, roomPadding: 1, minRoom: 4 },
}
interface BSPNode {
x: number
y: number
w: number
h: number
left?: BSPNode
right?: BSPNode
room?: { x: number; y: number; w: number; h: number }
}
function generateDungeon(width: number, height: number, density: string): {
ground: Uint32Array
walls: Uint32Array
decor: Uint32Array
} {
const ground = new Uint32Array(width * height)
const walls = new Uint32Array(width * height)
const decor = new Uint32Array(width * height)
const preset = DENSITY_PRESETS[density] ?? DENSITY_PRESETS['normal']!
const MIN_PARTITION_SIZE = preset.minPartition
const MIN_ROOM_SIZE = preset.minRoom
const ROOM_PADDING = preset.roomPadding
const CORRIDOR_WIDTH = 2
function splitNode(node: BSPNode, depth: number): void {
if (depth <= 0) return
if (node.w < MIN_PARTITION_SIZE * 2 && node.h < MIN_PARTITION_SIZE * 2) return
let splitHorizontal: boolean
if (node.w > node.h * 1.25) splitHorizontal = false
else if (node.h > node.w * 1.25) splitHorizontal = true
else splitHorizontal = Math.random() > 0.5
if (splitHorizontal && node.h < MIN_PARTITION_SIZE * 2) splitHorizontal = false
if (!splitHorizontal && node.w < MIN_PARTITION_SIZE * 2) splitHorizontal = true
if (splitHorizontal && node.h < MIN_PARTITION_SIZE * 2) return
if (!splitHorizontal && node.w < MIN_PARTITION_SIZE * 2) return
if (splitHorizontal) {
const splitY = node.y + MIN_PARTITION_SIZE + Math.floor(Math.random() * (node.h - MIN_PARTITION_SIZE * 2))
node.left = { x: node.x, y: node.y, w: node.w, h: splitY - node.y }
node.right = { x: node.x, y: splitY, w: node.w, h: node.y + node.h - splitY }
} else {
const splitX = node.x + MIN_PARTITION_SIZE + Math.floor(Math.random() * (node.w - MIN_PARTITION_SIZE * 2))
node.left = { x: node.x, y: node.y, w: splitX - node.x, h: node.h }
node.right = { x: splitX, y: node.y, w: node.x + node.w - splitX, h: node.h }
}
splitNode(node.left, depth - 1)
splitNode(node.right, depth - 1)
}
function createRooms(node: BSPNode): void {
if (node.left && node.right) {
createRooms(node.left)
createRooms(node.right)
return
}
const maxRoomW = node.w - ROOM_PADDING * 2
const maxRoomH = node.h - ROOM_PADDING * 2
if (maxRoomW < MIN_ROOM_SIZE || maxRoomH < MIN_ROOM_SIZE) return
const roomW = MIN_ROOM_SIZE + Math.floor(Math.random() * (maxRoomW - MIN_ROOM_SIZE + 1))
const roomH = MIN_ROOM_SIZE + Math.floor(Math.random() * (maxRoomH - MIN_ROOM_SIZE + 1))
const roomX = node.x + ROOM_PADDING + Math.floor(Math.random() * (maxRoomW - roomW + 1))
const roomY = node.y + ROOM_PADDING + Math.floor(Math.random() * (maxRoomH - roomH + 1))
node.room = { x: roomX, y: roomY, w: roomW, h: roomH }
}
function getRoom(node: BSPNode): { x: number; y: number; w: number; h: number } | undefined {
if (node.room) return node.room
if (node.left && node.right) return Math.random() > 0.5 ? getRoom(node.left) : getRoom(node.right)
if (node.left) return getRoom(node.left)
if (node.right) return getRoom(node.right)
return undefined
}
function connectNodes(node: BSPNode): void {
if (!node.left || !node.right) return
connectNodes(node.left)
connectNodes(node.right)
const roomA = getRoom(node.left)
const roomB = getRoom(node.right)
if (!roomA || !roomB) return
const ax = roomA.x + Math.floor(roomA.w / 2)
const ay = roomA.y + Math.floor(roomA.h / 2)
const bx = roomB.x + Math.floor(roomB.w / 2)
const by = roomB.y + Math.floor(roomB.h / 2)
const midX = Math.random() > 0.5 ? ax : bx
for (let x = Math.min(ax, midX); x <= Math.max(ax, midX); x++) {
for (let dy = -Math.floor(CORRIDOR_WIDTH / 2); dy <= Math.floor(CORRIDOR_WIDTH / 2); dy++) {
const ty = ay + dy
if (ty >= 0 && ty < height && x >= 0 && x < width) {
ground[ty * width + x] = TILES.FLOOR_1 + Math.floor(Math.random() * 4)
}
}
}
for (let y = Math.min(ay, by); y <= Math.max(ay, by); y++) {
for (let dx = -Math.floor(CORRIDOR_WIDTH / 2); dx <= Math.floor(CORRIDOR_WIDTH / 2); dx++) {
const tx = midX + dx
if (y >= 0 && y < height && tx >= 0 && tx < width) {
ground[y * width + tx] = TILES.FLOOR_1 + Math.floor(Math.random() * 4)
}
}
}
for (let x = Math.min(midX, bx); x <= Math.max(midX, bx); x++) {
for (let dy = -Math.floor(CORRIDOR_WIDTH / 2); dy <= Math.floor(CORRIDOR_WIDTH / 2); dy++) {
const ty = by + dy
if (ty >= 0 && ty < height && x >= 0 && x < width) {
ground[ty * width + x] = TILES.FLOOR_1 + Math.floor(Math.random() * 4)
}
}
}
}
function collectRooms(node: BSPNode, rooms: Array<{ x: number; y: number; w: number; h: number }>): void {
if (node.room) rooms.push(node.room)
if (node.left) collectRooms(node.left, rooms)
if (node.right) collectRooms(node.right, rooms)
}
const root: BSPNode = { x: 1, y: 1, w: width - 2, h: height - 2 }
const depth = Math.floor(Math.log2(Math.min(width, height) / MIN_PARTITION_SIZE)) + 1
splitNode(root, depth)
createRooms(root)
const rooms: Array<{ x: number; y: number; w: number; h: number }> = []
collectRooms(root, rooms)
for (const room of rooms) {
for (let y = room.y; y < room.y + room.h; y++) {
for (let x = room.x; x < room.x + room.w; x++) {
if (x >= 0 && x < width && y >= 0 && y < height) {
ground[y * width + x] = TILES.FLOOR_1 + Math.floor(Math.random() * 4)
}
}
}
}
connectNodes(root)
for (let y = 1; y < height - 1; y++) {
for (let x = 1; x < width - 1; x++) {
const idx = y * width + x
if (ground[idx] === 0) continue
for (let dy = -1; dy <= 1; dy++) {
for (let dx = -1; dx <= 1; dx++) {
if (dx === 0 && dy === 0) continue
const nx = x + dx
const ny = y + dy
const nidx = ny * width + nx
if (ground[nidx] === 0 && walls[nidx] === 0) {
if (dy === -1) walls[nidx] = TILES.WALL_TOP
else if (dy === 1) walls[nidx] = TILES.WALL_BOTTOM
else if (dx === -1) walls[nidx] = TILES.WALL_LEFT
else walls[nidx] = TILES.WALL_RIGHT
}
}
}
}
}
for (const room of rooms) {
const corners = [
{ x: room.x + 1, y: room.y + 1 },
{ x: room.x + room.w - 2, y: room.y + 1 },
{ x: room.x + 1, y: room.y + room.h - 2 },
{ x: room.x + room.w - 2, y: room.y + room.h - 2 },
]
for (const corner of corners) {
if (Math.random() > 0.6) {
const idx = corner.y * width + corner.x
if (ground[idx] !== 0 && decor[idx] === 0) decor[idx] = TILES.TORCH
}
}
const numDecorations = Math.floor(room.w * room.h / 40) + 1
for (let i = 0; i < numDecorations; i++) {
if (room.w <= 4 || room.h <= 4) continue
const rx = room.x + 2 + Math.floor(Math.random() * (room.w - 4))
const ry = room.y + 2 + Math.floor(Math.random() * (room.h - 4))
const idx = ry * width + rx
if (ground[idx] !== 0 && decor[idx] === 0) {
const roll = Math.random()
if (roll < 0.3) decor[idx] = TILES.CHEST
else if (roll < 0.6) decor[idx] = TILES.SKULL
else decor[idx] = TILES.BONES
}
}
}
return { ground, walls, decor }
}
function createTileMapData(
width: number,
height: number,
tileset: TilesetData,
layers: { ground: Uint32Array; walls: Uint32Array; decor: Uint32Array }
): TileMapData {
const tileLayers: TileLayerData[] = [
{ name: 'Ground', id: 0, width, height, data: layers.ground, visible: true },
{ name: 'Walls', id: 1, width, height, data: layers.walls, visible: true },
{ name: 'Decor', id: 2, width, height, data: layers.decor, visible: true },
]
return {
width,
height,
tileWidth: TILE_SIZE,
tileHeight: TILE_SIZE,
orientation: 'orthogonal',
renderOrder: 'right-down',
infinite: false,
tilesets: [tileset],
tileLayers,
objectLayers: [],
}
}
// Map size presets (in tiles)
const MAP_SIZE_PRESETS: Record<string, number> = {
sm: 64,
md: 128,
lg: 256,
xl: 512,
}
interface TilemapSceneProps {
mapData: TileMapData
chunkSize: number
showGround: boolean
showWalls: boolean
showDecor: boolean
onStats?: (tiles: number, chunks: number, layers: number) => void
}
function TilemapScene({ mapData, chunkSize, showGround, showWalls, showDecor, onStats }: TilemapSceneProps) {
const tilemapRef = useRef<TileMap2D>(null)
// Report stats when tilemap data or chunk size changes
useEffect(() => {
if (!tilemapRef.current || !onStats) return
onStats(
tilemapRef.current.totalTileCount,
tilemapRef.current.totalChunkCount,
tilemapRef.current.layerCount,
)
}, [mapData, chunkSize, onStats])
// Update layer visibility
useEffect(() => {
if (!tilemapRef.current) return
const ground = tilemapRef.current.getLayerAt(0)
const walls = tilemapRef.current.getLayerAt(1)
const decor = tilemapRef.current.getLayerAt(2)
if (ground) ground.visible = showGround
if (walls) walls.visible = showWalls
if (decor) decor.visible = showDecor
}, [showGround, showWalls, showDecor])
useFrame((_, delta) => {
tilemapRef.current?.update(delta * 1000)
})
return (
<tileMap2D
ref={tilemapRef}
data={mapData}
chunkSize={chunkSize}
position={[0, 0, 0]}
/>
)
}
function OrthoCamera({ viewSize }: { viewSize: number }) {
const set = useThree((s) => s.set)
const size = useThree((s) => s.size)
const aspect = size.width / size.height
return (
<orthographicCamera
ref={(cam: OrthographicCamera | null) => {
if (!cam) return
cam.left = (-viewSize * aspect) / 2
cam.right = (viewSize * aspect) / 2
cam.top = viewSize / 2
cam.bottom = -viewSize / 2
cam.updateProjectionMatrix()
set({ camera: cam })
}}
position={[0, 0, 100]}
near={0.1}
far={1000}
manual
/>
)
}
function CameraController({ mapSize, zoomRef, zoomSlider, setZoomSlider }: {
mapSize: number
zoomRef: React.RefObject<number>
zoomSlider: number
setZoomSlider: (v: number) => void
}) {
const zoomSliderRef = useRef(zoomSlider)
zoomSliderRef.current = zoomSlider
const { camera, gl } = useThree()
const keys = useRef(new Set<string>())
const isDragging = useRef(false)
const dragStart = useRef({ x: 0, y: 0 })
const cameraStart = useRef({ x: 0, y: 0 })
const activePointers = useRef(new Map<number, PointerEvent>())
useEffect(() => {
camera.position.x = (mapSize * TILE_SIZE) / 2
camera.position.y = (mapSize * TILE_SIZE) / 2
}, [camera, mapSize])
useEffect(() => {
const canvas = gl.domElement
const panKeys = new Set(['w', 'a', 's', 'd', 'arrowup', 'arrowdown', 'arrowleft', 'arrowright'])
const handleKeyDown = (e: KeyboardEvent) => {
const key = e.key.toLowerCase()
if (panKeys.has(key)) e.preventDefault()
keys.current.add(key)
}
const handleKeyUp = (e: KeyboardEvent) => keys.current.delete(e.key.toLowerCase())
const handlePointerDown = (e: PointerEvent) => {
activePointers.current.set(e.pointerId, e)
if (e.pointerType !== 'touch' || activePointers.current.size >= 2) {
isDragging.current = true
dragStart.current = { x: e.clientX, y: e.clientY }
cameraStart.current = { x: camera.position.x, y: camera.position.y }
}
}
const handlePointerMove = (e: PointerEvent) => {
activePointers.current.set(e.pointerId, e)
if (e.pointerType === 'touch' && activePointers.current.size >= 2 && !isDragging.current) {
isDragging.current = true
dragStart.current = { x: e.clientX, y: e.clientY }
cameraStart.current = { x: camera.position.x, y: camera.position.y }
}
if (!isDragging.current) return
const dx = e.clientX - dragStart.current.x
const dy = e.clientY - dragStart.current.y
const dragDistance = Math.sqrt(dx * dx + dy * dy)
if (dragDistance > 3) {
document.body.style.cursor = 'move'
}
// @ts-expect-error - ortho camera has top/bottom
const visibleHeight = camera.top - camera.bottom
const worldPerPixel = visibleHeight / window.innerHeight
camera.position.x = cameraStart.current.x - dx * worldPerPixel
camera.position.y = cameraStart.current.y + dy * worldPerPixel
}
const handlePointerUp = (e: PointerEvent) => {
activePointers.current.delete(e.pointerId)
if (activePointers.current.size < 2) {
isDragging.current = false
document.body.style.cursor = ''
}
}
const handlePointerCancel = (e: PointerEvent) => {
activePointers.current.delete(e.pointerId)
if (activePointers.current.size < 2) {
isDragging.current = false
document.body.style.cursor = ''
}
}
// Ctrl+wheel to zoom
const handleWheel = (e: WheelEvent) => {
if (!e.ctrlKey && !e.metaKey) return
e.preventDefault()
const delta = e.deltaY > 0 ? -2 : 2
const next = Math.max(0, Math.min(100, zoomSliderRef.current + delta))
zoomSliderRef.current = next
setZoomSlider(next)
}
// Pinch-to-zoom
let lastPinchDist = 0
const getPinchDist = (): number => {
const pts = [...activePointers.current.values()]
if (pts.length < 2) return 0
const dx = pts[0]!.clientX - pts[1]!.clientX
const dy = pts[0]!.clientY - pts[1]!.clientY
return Math.sqrt(dx * dx + dy * dy)
}
const handleTouchStart = () => {
if (activePointers.current.size >= 2) lastPinchDist = getPinchDist()
}
const handlePinchMove = () => {
if (activePointers.current.size >= 2) {
const dist = getPinchDist()
if (lastPinchDist > 0 && dist > 0) {
const scale = dist / lastPinchDist
const delta = (scale - 1) * 30
const next = Math.max(0, Math.min(100, zoomSliderRef.current + delta))
zoomSliderRef.current = next
setZoomSlider(next)
}
lastPinchDist = dist
}
}
window.addEventListener('keydown', handleKeyDown)
window.addEventListener('keyup', handleKeyUp)
canvas.addEventListener('pointerdown', handlePointerDown)
canvas.addEventListener('wheel', handleWheel, { passive: false })
canvas.addEventListener('touchstart', handleTouchStart, { passive: true })
window.addEventListener('pointermove', handlePointerMove)
window.addEventListener('pointermove', handlePinchMove)
window.addEventListener('pointerup', handlePointerUp)
window.addEventListener('pointercancel', handlePointerCancel)
return () => {
window.removeEventListener('keydown', handleKeyDown)
window.removeEventListener('keyup', handleKeyUp)
canvas.removeEventListener('pointerdown', handlePointerDown)
canvas.removeEventListener('wheel', handleWheel)
canvas.removeEventListener('touchstart', handleTouchStart)
window.removeEventListener('pointermove', handlePointerMove)
window.removeEventListener('pointermove', handlePinchMove)
window.removeEventListener('pointerup', handlePointerUp)
window.removeEventListener('pointercancel', handlePointerCancel)
}
}, [camera, gl, setZoomSlider])
const currentZoom = useRef(zoomRef.current)
useFrame((_, delta) => {
// Lerp zoom toward target
const lerpRate = 1 - Math.pow(0.001, delta)
currentZoom.current += (zoomRef.current - currentZoom.current) * lerpRate
const z = currentZoom.current
const speed = 200 * delta * z
if (keys.current.has('w') || keys.current.has('arrowup')) camera.position.y += speed
if (keys.current.has('s') || keys.current.has('arrowdown')) camera.position.y -= speed
if (keys.current.has('a') || keys.current.has('arrowleft')) camera.position.x -= speed
if (keys.current.has('d') || keys.current.has('arrowright')) camera.position.x += speed
camera.zoom = 1 / z
camera.updateProjectionMatrix()
})
return null
}
export default function App() {
// Tweakpane
const { pane } = usePane()
// Layers folder
const layerFolder = usePaneFolder(pane, 'Layers')
const [showGround] = usePaneInput<boolean>(layerFolder, 'showGround', true, { label: 'ground' })
const [showWalls] = usePaneInput<boolean>(layerFolder, 'showWalls', true, { label: 'walls' })
const [showDecor] = usePaneInput<boolean>(layerFolder, 'showDecor', true, { label: 'decor' })
// Zoom state (controlled by pinch/ctrl+wheel, no pane slider)
const [zoomSlider, setZoomSlider] = useState(0)
// Tiles folder (readonly monitors)
const tilesFolder = usePaneFolder(pane, 'Tiles')
// Tilemap folder (at bottom, expanded)
const genFolder = usePaneFolder(pane, 'Tilemap', { expanded: true })
const [mapSizePreset] = usePaneInput<string>(genFolder, 'mapSize', 'md', {
options: { SM: 'sm', MD: 'md', LG: 'lg', XL: 'xl' },
label: 'map size',
})
const [chunkSize] = usePaneInput<number>(genFolder, 'chunkSize', 512, {
options: { '256': 256, '512': 512, '1024': 1024, '2048': 2048 },
label: 'chunk',
})
const [density] = usePaneInput<string>(genFolder, 'density', 'normal', {
options: { Sparse: 'sparse', Normal: 'normal', Dense: 'dense', Packed: 'packed' },
})
const [seed, setSeed] = usePaneInput<number>(genFolder, 'seed', 42, {
min: 0, max: 999999, step: 1,
})
usePaneButton(genFolder, 'Regenerate', () => {
setSeed(Math.floor(Math.random() * 1000000))
})
const tileStatsRef = useRef({ tiles: 0, chunks: 0, layers: 0 })
// Add readonly bindings directly to the pane
useEffect(() => {
if (!tilesFolder) return
const b1 = tilesFolder.addBinding(tileStatsRef.current, 'tiles', { readonly: true, format: (v: number) => v.toFixed(0) })
const b2 = tilesFolder.addBinding(tileStatsRef.current, 'chunks', { readonly: true, format: (v: number) => v.toFixed(0) })
const b3 = tilesFolder.addBinding(tileStatsRef.current, 'layers', { readonly: true, format: (v: number) => v.toFixed(0) })
return () => {
b1.dispose()
b2.dispose()
b3.dispose()
}
}, [tilesFolder])
const mapSize = MAP_SIZE_PRESETS[mapSizePreset] ?? 128
// Compute zoom range from map extent
const mapExtent = mapSize * TILE_SIZE
const zoomOut = mapExtent / 800
const zoomIn = 0.1
// Start fully zoomed out to frame the whole map
const zoomRef = useRef(zoomOut)
// Update zoom ref when slider or map extent changes
useEffect(() => {
const t = zoomSlider / 100
zoomRef.current = zoomOut * Math.pow(zoomIn / zoomOut, t)
}, [zoomSlider, zoomOut, zoomIn])
const handleStats = useCallback((tiles: number, chunks: number, layers: number) => {
tileStatsRef.current.tiles = tiles
tileStatsRef.current.chunks = chunks
tileStatsRef.current.layers = layers
pane.refresh()
}, [pane])
// Create tileset (memoized, never changes)
const tileset = useMemo<TilesetData>(() => ({
name: 'dungeon',
firstGid: 1,
tileWidth: TILE_SIZE,
tileHeight: TILE_SIZE,
imageWidth: TILESET_COLUMNS * TILE_SIZE,
imageHeight: TILESET_ROWS * TILE_SIZE,
columns: TILESET_COLUMNS,
tileCount: TILESET_COLUMNS * TILESET_ROWS,
tiles: new Map(),
texture: createProceduralTileset(),
}), [])
// Generate map data (regenerates when mapSize, density, or seed changes)
const mapData = useMemo(() => {
const layers = generateDungeon(mapSize, mapSize, density)
return createTileMapData(mapSize, mapSize, tileset, layers)
}, [tileset, mapSize, density, seed])
return (
<Canvas
dpr={1}
renderer={{ antialias: false }}
style={{ touchAction: 'none' }}
onCreated={({ gl }) => {
gl.domElement.style.imageRendering = 'pixelated'
}}
>
<OrthoCamera viewSize={800} />
<DevtoolsProvider name="tilemap" />
<GemBackground gem={GEM} />
<CameraController mapSize={mapSize} zoomRef={zoomRef} zoomSlider={zoomSlider} setZoomSlider={setZoomSlider} />
<Suspense fallback={null}>
<TilemapScene
mapData={mapData}
chunkSize={chunkSize}
showGround={showGround}
showWalls={showWalls}
showDecor={showDecor}
onStats={handleStats}
/>
</Suspense>
</Canvas>
)
}

Load a map file and create a TileMap2D. Both TiledLoader and LDtkLoader return a TileMapData structure that TileMap2D consumes:

import { TileMap2D, TiledLoader } from 'three-flatland';
const mapData = await TiledLoader.load('/maps/level1.json');
const tilemap = new TileMap2D({
data: mapData,
chunkSize: 16, // Tiles per chunk (default: 16)
});
scene.add(tilemap);

This example procedurally generates its own TileMapData at runtime to show how to build a dungeon from scratch without a Tiled file — see main.ts for the BSP generator.

Access individual layers by index to toggle visibility or read/write tiles:

const groundLayer = tilemap.getLayerAt(0);
const wallsLayer = tilemap.getLayerAt(1);
// Toggle visibility
groundLayer.visible = false;
// Read/write tiles at a position
const gid = groundLayer.getTileAt(x, y);
wallsLayer.setTileAt(x, y, newGid);

Call update(deltaMs) each frame to advance tile animations defined in the source map:

function animate() {
const deltaMs = /* time since last frame */;
tilemap.update(deltaMs);
renderer.render(scene, camera);
}