import { WebGPURenderer } from 'three/webgpu'
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
// Decoration tiles (row 3)
// 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)
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
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))
const texture = new DataTexture(data, width, height, RGBAFormat)
texture.minFilter = NearestFilter
texture.magFilter = NearestFilter
texture.colorSpace = SRGBColorSpace
texture.needsUpdate = true
// 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
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): {
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
function splitNode(node: BSPNode, depth: number): void {
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
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
if (splitHorizontal && node.h < MIN_PARTITION_SIZE * 2) return
if (!splitHorizontal && node.w < MIN_PARTITION_SIZE * 2) return
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 }
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) {
// 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)
// Connect sibling nodes with corridors
function connectNodes(node: BSPNode): void {
if (!node.left || !node.right) return
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)
// Create L-shaped corridor with width
const midX = Math.random() > 0.5 ? ax : bx
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++) {
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++) {
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++) {
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)
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
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)
// 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 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
for (const room of rooms) {
// Torches in all 4 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) {
// 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(
layers: { ground: Uint32Array; walls: Uint32Array; decor: Uint32Array }
const tileLayers: TileLayerData[] = [
orientation: 'orthogonal',
renderOrder: 'right-down',
// Map size presets (in tiles)
const MAP_SIZE_PRESETS: Record<string, number> = {
/* 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 activeRenderer: WebGPURenderer | null = null
const TILESET_COLUMNS = 4
const scene = new Scene()
;(scene as any).backgroundNode = gemGradientNode({ gem: GEM })
let aspect = window.innerWidth / window.innerHeight
const camera = new OrthographicCamera(
(-frustumSize * aspect) / 2,
(frustumSize * aspect) / 2,
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)
// Create procedural tileset
const tilesetTexture = createProceduralTileset(TILE_SIZE, TILESET_COLUMNS, TILESET_ROWS)
const tilesetData: TilesetData = {
imageWidth: TILESET_COLUMNS * TILE_SIZE,
imageHeight: TILESET_ROWS * TILE_SIZE,
columns: TILESET_COLUMNS,
tileCount: TILESET_COLUMNS * TILESET_ROWS,
const gen = { mapSize: 'md' as string, chunkSize: 512, density: 'normal' as string, seed: 42 }
const layers = { showGround: true, showWalls: true, showDecor: true }
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))
// Start fully zoomed out to frame the whole map
// 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
let tilemap = buildTilemap()
camera.position.x = (mapSize * TILE_SIZE) / 2
camera.position.y = (mapSize * TILE_SIZE) / 2
tileStats.tiles = tilemap.totalTileCount
tileStats.chunks = tilemap.totalChunkCount
tileStats.layers = tilemap.layerCount
// Rebuild tilemap (on map size, density, or seed change)
function rebuildTilemap() {
mapSize = MAP_SIZE_PRESETS[gen.mapSize] ?? 128
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))
const { pane, update: updateDevtools } = createPane({ driver: 'manual' })
const devtools = createDevtoolsProvider({ name: 'tilemap' })
const layerFolder = pane.addFolder({ title: 'Layers', expanded: false })
layerFolder.addBinding(layers, 'showGround', { label: 'ground' })
const layer = tilemap.getLayerAt(0)
if (layer) layer.visible = ev.value
layerFolder.addBinding(layers, 'showWalls', { label: 'walls' })
const layer = tilemap.getLayerAt(1)
if (layer) layer.visible = ev.value
layerFolder.addBinding(layers, 'showDecor', { label: 'decor' })
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' },
}).on('change', () => rebuildTilemap())
genFolder.addBinding(gen, 'chunkSize', {
options: { '256': 256, '512': 512, '1024': 1024, '2048': 2048 },
}).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)
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()
window.addEventListener('keyup', (e) => keys.delete(e.key.toLowerCase()))
// Drag to pan (pointer events, with two-finger touch support)
let dragStart = { x: 0, y: 0 }
let cameraStart = { x: 0, y: 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) {
dragStart = { x: e.clientX, y: e.clientY }
cameraStart = { x: camera.position.x, y: camera.position.y }
window.addEventListener('pointermove', (e) => {
activePointers.set(e.pointerId, e)
// Start dragging if second finger arrived
if (e.pointerType === 'touch' && activePointers.size >= 2 && !isDragging) {
dragStart = { x: e.clientX, y: e.clientY }
cameraStart = { x: camera.position.x, y: camera.position.y }
const dx = e.clientX - dragStart.x
const dy = e.clientY - dragStart.y
dragDistance = Math.sqrt(dx * dx + dy * dy)
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) {
renderer.domElement.style.cursor = ''
window.addEventListener('pointercancel', (e) => {
activePointers.delete(e.pointerId)
if (activePointers.size < 2) {
renderer.domElement.style.cursor = ''
function applyZoomSlider(newVal: number) {
cam.zoom = Math.max(0, Math.min(100, newVal))
targetZoom = zoomOut * Math.pow(zoomIn / zoomOut, t)
renderer.domElement.addEventListener('wheel', (e) => {
if (!e.ctrlKey && !e.metaKey) return
const delta = e.deltaY > 0 ? -2 : 2
applyZoomSlider(cam.zoom + delta)
// Pinch-to-zoom via touch
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()
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)
// 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)
const currentTile = decorLayer.getTileAt(tilePos.x, tilePos.y)
const newTile = currentTile === 0 ? TILES.TORCH : 0
decorLayer.setTileAt(tilePos.x, tilePos.y, newTile)
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)
let lastTime = performance.now()
rafId = requestAnimationFrame(animate)
const now = performance.now()
const deltaMs = now - lastTime
// Lerp zoom toward target
const lerpRate = 1 - Math.pow(0.001, deltaMs / 1000) // ~6x per second smoothing
zoom += (targetZoom - zoom) * lerpRate
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
camera.left = (-frustumSize * aspect * zoom) / 2
camera.right = (frustumSize * aspect * zoom) / 2
camera.top = (frustumSize * zoom) / 2
camera.bottom = (-frustumSize * zoom) / 2
camera.updateProjectionMatrix()
devtools.beginFrame(performance.now(), renderer)
renderer.render(scene, camera)
devtools.endFrame(renderer)
// Update tile stats periodically
import.meta.hot.dispose(() => {
cancelAnimationFrame(rafId)
activeRenderer.dispose?.()
activeRenderer.domElement.remove()