Skip to content
Back to Examples

Knightmark

Sprite benchmark with animated knights, collisions, and spatial hashing.

import { WebGPURenderer } from 'three/webgpu'
import { Scene, OrthographicCamera, NearestFilter } from 'three'
import { gemClearColor } from './GemBackground'
import { GEM } from './gem'
import {
AnimatedSprite2D,
Sprite2DMaterial,
SpriteGroup,
SpriteSheetLoader,
TextureLoader,
TileMap2D,
Layers,
type AnimationSetDefinition,
type SpriteSheet,
type TileMapData,
type TilesetData,
type TileLayerData,
createDevtoolsProvider,
} from 'three-flatland'
import { createPane } from '@three-flatland/devtools'
// ============================================
// CONSTANTS
// ============================================
const SPEED_THRESHOLD = 80
const TRIP_LERP_RATE = 5
const IDLE_AFTER_TRIP_MS = 400
const VIEW_SIZE = 640
// Tilemap
const TILE_PX = 16
const TILE_SCALE = 2
// ============================================
// TWEAKPANE
// ============================================
const { pane, update: updateDevtools } = createPane({ driver: 'manual' })
const devtools = createDevtoolsProvider({ name: 'knightmark' })
// Stats monitors — explicitly refreshed each frame via knightStatsBindings
// below. The default readonly-binding MonitorBinding ticker (200ms) can
// starve when the main thread is busy (heavy allocs, GC pauses), making
// the display look frozen while underlying values are updating. Holding
// the binding refs lets the animate loop force-refresh them per frame.
const knightStats = { knights: 0, batches: 0 }
const statsFolder = pane.addFolder({ title: 'Knights', expanded: false })
const knightStatsBindings = [
statsFolder.addBinding(knightStats, 'knights', {
readonly: true,
format: (v: number) => v.toFixed(0),
}),
statsFolder.addBinding(knightStats, 'batches', {
readonly: true,
format: (v: number) => v.toFixed(0),
}),
]
// Simulation params — tweakable at runtime (at bottom, collapsed)
const sim = { speedMin: 30, speedMax: 200, hitRadius: 8, knightScale: 64 }
const simFolder = pane.addFolder({ title: 'Simulation', expanded: false })
simFolder.addBinding(sim, 'speedMin', { min: 10, max: 100, step: 5, label: 'speed min' })
simFolder.addBinding(sim, 'speedMax', { min: 100, max: 300, step: 10, label: 'speed max' })
simFolder.addBinding(sim, 'hitRadius', { min: 2, max: 20, step: 1, label: 'hit radius' })
simFolder.addBinding(sim, 'knightScale', { min: 32, max: 128, step: 8, label: 'scale' })
// ============================================
// TYPES
// ============================================
type KnightState = 'WALK' | 'ROLL' | 'TRIP' | 'TRIP_IDLE'
interface Knight {
sprite: AnimatedSprite2D
state: KnightState
baseVx: number
baseVy: number
speed: number
vx: number
vy: number
idleTimer: number
}
// ============================================
// KNIGHT ANIMATIONS
// ============================================
const knightAnimations: AnimationSetDefinition = {
fps: 10,
animations: {
idle: {
frames: ['idle_0', 'idle_1', 'idle_2', 'idle_3'],
fps: 8,
loop: true,
},
run: {
frames: [
'run_0',
'run_1',
'run_2',
'run_3',
'run_4',
'run_5',
'run_6',
'run_7',
'run_8',
'run_9',
'run_10',
'run_11',
'run_12',
'run_13',
'run_14',
'run_15',
],
fps: 16,
loop: true,
},
roll: {
frames: ['roll_0', 'roll_1', 'roll_2', 'roll_3', 'roll_4', 'roll_5', 'roll_6', 'roll_7'],
fps: 15,
loop: false,
},
death: {
frames: ['death_0', 'death_1', 'death_2', 'death_3'],
fps: 8,
loop: false,
},
},
}
// ============================================
// SPATIAL HASH
// ============================================
class SpatialHash {
private cellSize: number
private cells = new Map<number, Knight[]>()
private _bucketPool: Knight[][] = []
private _activeBuckets: Knight[][] = []
constructor(cellSize: number) {
this.cellSize = cellSize
}
private key(cx: number, cy: number): number {
const a = cx + 0x8000
const b = cy + 0x8000
return (a << 16) | (b & 0xffff)
}
clear(): void {
for (let i = 0; i < this._activeBuckets.length; i++) {
const bucket = this._activeBuckets[i]!
bucket.length = 0
this._bucketPool.push(bucket)
}
this._activeBuckets.length = 0
this.cells.clear()
}
insert(knight: Knight): void {
const cx = Math.floor(knight.sprite.position.x / this.cellSize)
const cy = Math.floor(knight.sprite.position.y / this.cellSize)
const k = this.key(cx, cy)
let bucket = this.cells.get(k)
if (!bucket) {
bucket = this._bucketPool.pop() || []
this._activeBuckets.push(bucket)
this.cells.set(k, bucket)
}
bucket.push(knight)
}
forEachNeighbor(knight: Knight, visitor: (other: Knight) => boolean): void {
const cx = Math.floor(knight.sprite.position.x / this.cellSize)
const cy = Math.floor(knight.sprite.position.y / this.cellSize)
for (let dx = -1; dx <= 1; dx++) {
for (let dy = -1; dy <= 1; dy++) {
const bucket = this.cells.get(this.key(cx + dx, cy + dy))
if (bucket) {
for (let i = 0; i < bucket.length; i++) {
if (bucket[i] !== knight) {
if (visitor(bucket[i]!)) return
}
}
}
}
}
}
}
// ============================================
// MAIN
// ============================================
/* 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() {
// 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()
// Scene
// L1-only gem clear color — knightmark's sprites fill the viewport, so
// an L2 fullscreen-quad backdrop would just be hidden. Clear color
// remains as a safety backstop and tints any margins.
const scene = new Scene()
scene.background = gemClearColor(GEM)
// Orthographic camera
const aspect = window.innerWidth / window.innerHeight
const halfW = (VIEW_SIZE * aspect) / 2
const halfH = VIEW_SIZE / 2
const camera = new OrthographicCamera(-halfW, halfW, halfH, -halfH, 0.1, 1000)
camera.position.z = 100
// SpriteGroup for batching
const spriteGroup = new SpriteGroup()
scene.add(spriteGroup)
// Load assets
const asset = (path: string) => './' + path
const [knightSheet, tilesetTexture] = await Promise.all([
SpriteSheetLoader.load(asset('sprites/knight.json')),
TextureLoader.load(asset('sprites/Dungeon_Tileset.png')),
])
// Ensure pixel-art filtering for knight sprites
knightSheet.texture.minFilter = NearestFilter
knightSheet.texture.magFilter = NearestFilter
// --- Floor tilemap ---
// Dungeon_Tileset.png is a 10×10 grid of 16px tiles
// GID = firstGid + row * columns + col (firstGid=1, columns=10)
const TS_COLS = 10
const TS_ROWS = 10
// Cover the camera view generously (support ultrawide)
const mapCols = Math.ceil((VIEW_SIZE * 3) / TILE_PX) + 4
const mapRows = Math.ceil(VIEW_SIZE / TILE_PX) + 4
// Floor tile pattern — 4×3 clean stone floor from rows 0-2, cols 6-9.
// The upper-left room tiles have wall shading baked in; these upper-right
// tiles are the standalone floor meant to be tiled freely.
const FLOOR_PATTERN = [
7,
8,
9,
10, // row 0, cols 6-9
17,
18,
19,
20, // row 1, cols 6-9
27,
28,
29,
30, // row 2, cols 6-9
]
const floorData = new Uint32Array(mapCols * mapRows)
for (let y = 0; y < mapRows; y++) {
for (let x = 0; x < mapCols; x++) {
floorData[y * mapCols + x] = FLOOR_PATTERN[(y % 3) * 4 + (x % 4)]!
}
}
const tilesetData: TilesetData = {
name: 'dungeon',
firstGid: 1,
tileWidth: TILE_PX,
tileHeight: TILE_PX,
imageWidth: TS_COLS * TILE_PX,
imageHeight: TS_ROWS * TILE_PX,
columns: TS_COLS,
tileCount: TS_COLS * TS_ROWS,
tiles: new Map(),
texture: tilesetTexture,
}
const floorLayer: TileLayerData = {
name: 'Floor',
id: 0,
width: mapCols,
height: mapRows,
data: floorData,
}
const mapData: TileMapData = {
width: mapCols,
height: mapRows,
tileWidth: TILE_PX,
tileHeight: TILE_PX,
orientation: 'orthogonal',
renderOrder: 'right-down',
infinite: false,
tilesets: [tilesetData],
tileLayers: [floorLayer],
objectLayers: [],
}
const tilemap = new TileMap2D({ data: mapData, enableCollision: false })
tilemap.scale.set(TILE_SCALE, TILE_SCALE, 1)
const mapWorldW = mapCols * TILE_PX * TILE_SCALE
const mapWorldH = mapRows * TILE_PX * TILE_SCALE
tilemap.position.set(-mapWorldW / 2, -mapWorldH / 2, -1)
scene.add(tilemap)
// --- Bounce bounds = camera view edges ---
let boundsLeft = -halfW
let boundsRight = halfW
let boundsTop = halfH
let boundsBottom = -halfH
// --- Knights ---
const knights: Knight[] = []
const spatialHash = new SpatialHash(sim.hitRadius * 4)
// Shared material with alphaTest > 0 → opaque bucket → GPU depth-test
// fast path (no CPU zIndex sort needed for batches using this material).
const material = Sprite2DMaterial.getShared({ map: knightSheet.texture, alphaTest: 0.5 })
function spawnKnight(sheet: SpriteSheet): Knight {
const margin = sim.knightScale / 2
const sprite = new AnimatedSprite2D({
spriteSheet: sheet,
animationSet: knightAnimations,
animation: 'idle',
layer: Layers.ENTITIES,
anchor: [0.5, 0.5],
material,
})
sprite.scale.set(sim.knightScale, sim.knightScale, 1)
const x = boundsLeft + margin + Math.random() * (boundsRight - boundsLeft - margin * 2)
const y = boundsBottom + margin + Math.random() * (boundsTop - boundsBottom - margin * 2)
sprite.position.set(x, y, 0)
const speed = sim.speedMin + Math.random() * (sim.speedMax - sim.speedMin)
const angle = Math.random() * Math.PI * 2
const baseVx = Math.cos(angle) * speed
const baseVy = Math.sin(angle) * speed
const animName = speed < SPEED_THRESHOLD ? 'idle' : 'run'
sprite.play(animName)
sprite.flipX = baseVx < 0
spriteGroup.add(sprite)
return {
sprite,
state: 'WALK',
baseVx,
baseVy,
speed,
vx: baseVx,
vy: baseVy,
idleTimer: 0,
}
}
function spawnBatch(count: number) {
for (let i = 0; i < count; i++) knights.push(spawnKnight(knightSheet))
}
function triggerTrip(knight: Knight) {
knight.state = 'TRIP'
knight.sprite.play('death', {
onComplete: () => {
knight.state = 'TRIP_IDLE'
knight.idleTimer = IDLE_AFTER_TRIP_MS
knight.sprite.play('idle')
},
})
}
function triggerRoll(knight: Knight) {
knight.state = 'ROLL'
knight.sprite.play('roll', {
onComplete: () => {
knight.state = 'WALK'
const animName = knight.speed < SPEED_THRESHOLD ? 'idle' : 'run'
knight.sprite.play(animName)
},
})
}
const urlSpawn = parseInt(new URLSearchParams(window.location.search).get('spawn') ?? '', 10)
spawnBatch(Number.isFinite(urlSpawn) && urlSpawn > 0 ? urlSpawn : 10)
// --- UI ---
const addBtn = document.getElementById('btn-add')!
addBtn.addEventListener('click', () => spawnBatch(100))
window.addEventListener('keydown', (e) => {
if (e.code === 'Space') {
e.preventDefault()
spawnBatch(100)
}
})
// --- Resize ---
function handleResize() {
const newAspect = window.innerWidth / window.innerHeight
const newHalfW = (VIEW_SIZE * newAspect) / 2
const newHalfH = VIEW_SIZE / 2
camera.left = -newHalfW
camera.right = newHalfW
camera.top = newHalfH
camera.bottom = -newHalfH
camera.updateProjectionMatrix()
renderer.setSize(window.innerWidth, window.innerHeight)
boundsLeft = -newHalfW
boundsRight = newHalfW
boundsTop = newHalfH
boundsBottom = -newHalfH
}
window.addEventListener('resize', handleResize)
// --- Animation loop ---
let lastTime = performance.now()
function animate() {
rafId = requestAnimationFrame(animate)
const now = performance.now()
const deltaMs = now - lastTime
lastTime = now
const dt = deltaMs / 1000
// Derive cell size from current hitRadius
const cellSize = sim.hitRadius * 4
// Update knight movement and animation
const margin = sim.knightScale / 2
for (const k of knights) {
switch (k.state) {
case 'WALK':
case 'ROLL':
k.vx = k.baseVx
k.vy = k.baseVy
break
case 'TRIP':
k.vx += (0 - k.vx) * Math.min(1, TRIP_LERP_RATE * dt)
k.vy += (0 - k.vy) * Math.min(1, TRIP_LERP_RATE * dt)
break
case 'TRIP_IDLE':
k.vx = 0
k.vy = 0
k.idleTimer -= deltaMs
if (k.idleTimer <= 0) {
k.state = 'WALK'
k.vx = k.baseVx
k.vy = k.baseVy
const animName = k.speed < SPEED_THRESHOLD ? 'idle' : 'run'
k.sprite.play(animName)
}
break
}
k.sprite.position.x += k.vx * dt
k.sprite.position.y += k.vy * dt
// Bounce off screen edges
if (k.sprite.position.x < boundsLeft + margin) {
k.sprite.position.x = boundsLeft + margin
k.baseVx = Math.abs(k.baseVx)
k.vx = Math.abs(k.vx)
} else if (k.sprite.position.x > boundsRight - margin) {
k.sprite.position.x = boundsRight - margin
k.baseVx = -Math.abs(k.baseVx)
k.vx = -Math.abs(k.vx)
}
if (k.sprite.position.y < boundsBottom + margin) {
k.sprite.position.y = boundsBottom + margin
k.baseVy = Math.abs(k.baseVy)
k.vy = Math.abs(k.vy)
} else if (k.sprite.position.y > boundsTop - margin) {
k.sprite.position.y = boundsTop - margin
k.baseVy = -Math.abs(k.baseVy)
k.vy = -Math.abs(k.vy)
}
k.sprite.flipX = k.baseVx < 0
k.sprite.zIndex = -Math.floor(k.sprite.position.y)
k.sprite.update(deltaMs)
}
// Knight-knight collisions via spatial hash
spatialHash.clear()
// Update spatial hash cell size from current hitRadius
;(spatialHash as unknown as { cellSize: number }).cellSize = cellSize
for (const k of knights) spatialHash.insert(k)
const collisionDist = sim.hitRadius * 2
const collisionDistSq = collisionDist * collisionDist
for (const k of knights) {
if (k.state !== 'WALK') continue
spatialHash.forEachNeighbor(k, (other) => {
if (other.state !== 'WALK') return false
const dx = other.sprite.position.x - k.sprite.position.x
const dy = other.sprite.position.y - k.sprite.position.y
const distSq = dx * dx + dy * dy
if (distSq < collisionDistSq) {
const tripChanceA = k.speed / (k.speed + other.speed)
if (Math.random() < tripChanceA) {
triggerTrip(k)
triggerRoll(other)
} else {
triggerTrip(other)
triggerRoll(k)
}
return true
}
return false
})
}
// Render — systems run automatically in updateMatrixWorld
devtools.beginFrame(performance.now(), renderer)
renderer.render(scene, camera)
devtools.endFrame(renderer)
updateDevtools()
// Knight batch monitors
const s = spriteGroup.stats
knightStats.knights = knights.length
knightStats.batches = s.batchCount
}
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, useRef, useMemo, useCallback, useEffect } from 'react'
import type { OrthographicCamera } from 'three'
import { Canvas, extend, useFrame, useThree, useLoader } from '@react-three/fiber/webgpu'
import {
AnimatedSprite2D,
Sprite2DMaterial,
SpriteGroup,
SpriteSheetLoader,
TextureLoader,
TileMap2D,
Layers,
type AnimationSetDefinition,
type SpriteSheet,
type TileMapData,
type TilesetData,
type TileLayerData,
} from 'three-flatland/react'
import { DevtoolsProvider, usePane, usePaneFolder, usePaneInput } from '@three-flatland/devtools/react'
// Knightmark doesn't render any gem-background layer — its sprites
// fill the viewport. The body bg (#16191e) shows through during
// initial sprite load. GEM/GemBackground imports intentionally
// omitted; the per-example `gem.ts` is still synced for consistency.
extend({ SpriteGroup, TileMap2D })
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
/>
)
}
// ============================================
// CONSTANTS
// ============================================
const SPEED_THRESHOLD = 80
const TRIP_LERP_RATE = 5
const IDLE_AFTER_TRIP_MS = 400
const VIEW_SIZE = 640
// Tilemap
const TILE_PX = 16
const TILE_SCALE = 2
// ============================================
// TYPES
// ============================================
type KnightState = 'WALK' | 'ROLL' | 'TRIP' | 'TRIP_IDLE'
interface Knight {
sprite: AnimatedSprite2D
state: KnightState
baseVx: number
baseVy: number
speed: number
vx: number
vy: number
idleTimer: number
}
// ============================================
// KNIGHT ANIMATIONS
// ============================================
const knightAnimations: AnimationSetDefinition = {
fps: 10,
animations: {
idle: {
frames: ['idle_0', 'idle_1', 'idle_2', 'idle_3'],
fps: 8,
loop: true,
},
run: {
frames: [
'run_0',
'run_1',
'run_2',
'run_3',
'run_4',
'run_5',
'run_6',
'run_7',
'run_8',
'run_9',
'run_10',
'run_11',
'run_12',
'run_13',
'run_14',
'run_15',
],
fps: 16,
loop: true,
},
roll: {
frames: ['roll_0', 'roll_1', 'roll_2', 'roll_3', 'roll_4', 'roll_5', 'roll_6', 'roll_7'],
fps: 15,
loop: false,
},
death: {
frames: ['death_0', 'death_1', 'death_2', 'death_3'],
fps: 8,
loop: false,
},
},
}
// ============================================
// SPATIAL HASH
// ============================================
class SpatialHash {
cellSize: number
private cells = new Map<number, Knight[]>()
private _bucketPool: Knight[][] = []
private _activeBuckets: Knight[][] = []
constructor(cellSize: number) {
this.cellSize = cellSize
}
private key(cx: number, cy: number): number {
const a = cx + 0x8000
const b = cy + 0x8000
return (a << 16) | (b & 0xffff)
}
clear(): void {
for (let i = 0; i < this._activeBuckets.length; i++) {
const bucket = this._activeBuckets[i]!
bucket.length = 0
this._bucketPool.push(bucket)
}
this._activeBuckets.length = 0
this.cells.clear()
}
insert(knight: Knight): void {
const cx = Math.floor(knight.sprite.position.x / this.cellSize)
const cy = Math.floor(knight.sprite.position.y / this.cellSize)
const k = this.key(cx, cy)
let bucket = this.cells.get(k)
if (!bucket) {
bucket = this._bucketPool.pop() || []
this._activeBuckets.push(bucket)
this.cells.set(k, bucket)
}
bucket.push(knight)
}
forEachNeighbor(knight: Knight, visitor: (other: Knight) => boolean): void {
const cx = Math.floor(knight.sprite.position.x / this.cellSize)
const cy = Math.floor(knight.sprite.position.y / this.cellSize)
for (let dx = -1; dx <= 1; dx++) {
for (let dy = -1; dy <= 1; dy++) {
const bucket = this.cells.get(this.key(cx + dx, cy + dy))
if (bucket) {
for (let i = 0; i < bucket.length; i++) {
if (bucket[i] !== knight) {
if (visitor(bucket[i]!)) return
}
}
}
}
}
}
}
// ============================================
// STATE TRANSITIONS
// ============================================
function triggerTrip(knight: Knight) {
knight.state = 'TRIP'
knight.sprite.play('death', {
onComplete: () => {
knight.state = 'TRIP_IDLE'
knight.idleTimer = IDLE_AFTER_TRIP_MS
knight.sprite.play('idle')
},
})
}
function triggerRoll(knight: Knight) {
knight.state = 'ROLL'
knight.sprite.play('roll', {
onComplete: () => {
knight.state = 'WALK'
const animName = knight.speed < SPEED_THRESHOLD ? 'idle' : 'run'
knight.sprite.play(animName)
},
})
}
// ============================================
// SPAWN HELPER
// ============================================
function spawnKnight(
sheet: SpriteSheet,
spriteGroup: SpriteGroup,
bounds: { left: number; right: number; top: number; bottom: number },
simParams: { speedMin: number; speedMax: number; knightScale: number }
): Knight {
const margin = simParams.knightScale / 2
// Opaque alphaTest material enables the GPU depth-test fast path:
// the y-sort (zIndex = -y) is resolved by the depth buffer, so the
// CPU batchSortSystem can skip this batch entirely.
const material = Sprite2DMaterial.getShared({
map: sheet.texture,
alphaTest: 0.5,
})
const sprite = new AnimatedSprite2D({
spriteSheet: sheet,
animationSet: knightAnimations,
animation: 'idle',
layer: Layers.ENTITIES,
anchor: [0.5, 0.5],
material,
})
sprite.scale.set(simParams.knightScale, simParams.knightScale, 1)
const x = bounds.left + margin + Math.random() * (bounds.right - bounds.left - margin * 2)
const y = bounds.bottom + margin + Math.random() * (bounds.top - bounds.bottom - margin * 2)
sprite.position.set(x, y, 0)
const speed = simParams.speedMin + Math.random() * (simParams.speedMax - simParams.speedMin)
const angle = Math.random() * Math.PI * 2
const baseVx = Math.cos(angle) * speed
const baseVy = Math.sin(angle) * speed
const animName = speed < SPEED_THRESHOLD ? 'idle' : 'run'
sprite.play(animName)
sprite.flipX = baseVx < 0
spriteGroup.add(sprite)
return {
sprite,
state: 'WALK',
baseVx,
baseVy,
speed,
vx: baseVx,
vy: baseVy,
idleTimer: 0,
}
}
// ============================================
// SCENE COMPONENT
// ============================================
interface KnightmarkSceneProps {
addKnightsRef: React.RefObject<(() => void) | null>
speedMin: number
speedMax: number
hitRadius: number
knightScale: number
knightStatsRef: React.RefObject<{ knights: number; batches: number }>
}
function KnightmarkScene({
addKnightsRef,
speedMin,
speedMax,
hitRadius,
knightScale,
knightStatsRef,
}: KnightmarkSceneProps) {
const { size } = useThree()
// Load assets (presets automatically apply NearestFilter)
const knightSheet = useLoader(SpriteSheetLoader, './sprites/knight.json')
const tilesetTex = useLoader(TextureLoader, './sprites/Dungeon_Tileset.png')
const spriteGroupRef = useRef<SpriteGroup>(null)
const knightsRef = useRef<Knight[]>([])
const spatialHashRef = useRef(new SpatialHash(hitRadius * 4))
const boundsRef = useRef({ left: 0, right: 0, top: 0, bottom: 0 })
// Store latest sim params in refs for use in useFrame
const simRef = useRef({ speedMin, speedMax, hitRadius, knightScale })
simRef.current = { speedMin, speedMax, hitRadius, knightScale }
// Track world bounds for spawn/cleanup logic (camera frustum is set by <OrthoCamera>)
useEffect(() => {
const aspect = size.width / size.height
const halfW = (VIEW_SIZE * aspect) / 2
const halfH = VIEW_SIZE / 2
boundsRef.current = { left: -halfW, right: halfW, top: halfH, bottom: -halfH }
}, [size])
// Build floor tilemap data
const { mapData, mapWorldW, mapWorldH } = useMemo(() => {
const TS_COLS = 10
const TS_ROWS = 10
// Cover the camera view generously (support ultrawide)
const mapCols = Math.ceil((VIEW_SIZE * 3) / TILE_PX) + 4
const mapRows = Math.ceil(VIEW_SIZE / TILE_PX) + 4
// Floor tile pattern — 4×3 clean stone floor from rows 0-2, cols 6-9.
const FLOOR_PATTERN = [7, 8, 9, 10, 17, 18, 19, 20, 27, 28, 29, 30]
const floorData = new Uint32Array(mapCols * mapRows)
for (let y = 0; y < mapRows; y++) {
for (let x = 0; x < mapCols; x++) {
floorData[y * mapCols + x] = FLOOR_PATTERN[(y % 3) * 4 + (x % 4)]!
}
}
const tilesetData: TilesetData = {
name: 'dungeon',
firstGid: 1,
tileWidth: TILE_PX,
tileHeight: TILE_PX,
imageWidth: TS_COLS * TILE_PX,
imageHeight: TS_ROWS * TILE_PX,
columns: TS_COLS,
tileCount: TS_COLS * TS_ROWS,
tiles: new Map(),
texture: tilesetTex,
}
const floorLayer: TileLayerData = {
name: 'Floor',
id: 0,
width: mapCols,
height: mapRows,
data: floorData,
}
const data: TileMapData = {
width: mapCols,
height: mapRows,
tileWidth: TILE_PX,
tileHeight: TILE_PX,
orientation: 'orthogonal',
renderOrder: 'right-down',
infinite: false,
tilesets: [tilesetData],
tileLayers: [floorLayer],
objectLayers: [],
}
return {
mapData: data,
mapWorldW: mapCols * TILE_PX * TILE_SCALE,
mapWorldH: mapRows * TILE_PX * TILE_SCALE,
}
}, [tilesetTex])
// Spawn batch of knights
const spawnBatch = useCallback(
(count: number) => {
const r2d = spriteGroupRef.current
if (!r2d) return
const bounds = boundsRef.current
const sim = simRef.current
for (let i = 0; i < count; i++) {
knightsRef.current.push(spawnKnight(knightSheet, r2d, bounds, sim))
}
},
[knightSheet]
)
// Initial spawn + expose add handler
useEffect(() => {
spawnBatch(10)
addKnightsRef.current = () => spawnBatch(100)
return () => {
addKnightsRef.current = null
}
}, [spawnBatch, addKnightsRef])
// Game loop
useFrame((_, delta) => {
const dt = delta
const deltaMs = delta * 1000
const knights = knightsRef.current
const spatialHash = spatialHashRef.current
const bounds = boundsRef.current
const sim = simRef.current
const margin = sim.knightScale / 2
// Update spatial hash cell size from current hitRadius
spatialHash.cellSize = sim.hitRadius * 4
// Update knight movement and animation
for (const k of knights) {
switch (k.state) {
case 'WALK':
case 'ROLL':
k.vx = k.baseVx
k.vy = k.baseVy
break
case 'TRIP':
k.vx += (0 - k.vx) * Math.min(1, TRIP_LERP_RATE * dt)
k.vy += (0 - k.vy) * Math.min(1, TRIP_LERP_RATE * dt)
break
case 'TRIP_IDLE':
k.vx = 0
k.vy = 0
k.idleTimer -= deltaMs
if (k.idleTimer <= 0) {
k.state = 'WALK'
k.vx = k.baseVx
k.vy = k.baseVy
const animName = k.speed < SPEED_THRESHOLD ? 'idle' : 'run'
k.sprite.play(animName)
}
break
}
k.sprite.position.x += k.vx * dt
k.sprite.position.y += k.vy * dt
// Bounce off screen edges
if (k.sprite.position.x < bounds.left + margin) {
k.sprite.position.x = bounds.left + margin
k.baseVx = Math.abs(k.baseVx)
k.vx = Math.abs(k.vx)
} else if (k.sprite.position.x > bounds.right - margin) {
k.sprite.position.x = bounds.right - margin
k.baseVx = -Math.abs(k.baseVx)
k.vx = -Math.abs(k.vx)
}
if (k.sprite.position.y < bounds.bottom + margin) {
k.sprite.position.y = bounds.bottom + margin
k.baseVy = Math.abs(k.baseVy)
k.vy = Math.abs(k.vy)
} else if (k.sprite.position.y > bounds.top - margin) {
k.sprite.position.y = bounds.top - margin
k.baseVy = -Math.abs(k.baseVy)
k.vy = -Math.abs(k.vy)
}
k.sprite.flipX = k.baseVx < 0
k.sprite.zIndex = -Math.floor(k.sprite.position.y)
k.sprite.update(deltaMs)
}
// Knight-knight collisions via spatial hash
spatialHash.clear()
for (const k of knights) spatialHash.insert(k)
const collisionDist = sim.hitRadius * 2
const collisionDistSq = collisionDist * collisionDist
for (const k of knights) {
if (k.state !== 'WALK') continue
spatialHash.forEachNeighbor(k, (other) => {
if (other.state !== 'WALK') return false
const dx = other.sprite.position.x - k.sprite.position.x
const dy = other.sprite.position.y - k.sprite.position.y
const distSq = dx * dx + dy * dy
if (distSq < collisionDistSq) {
const tripChanceA = k.speed / (k.speed + other.speed)
if (Math.random() < tripChanceA) {
triggerTrip(k)
triggerRoll(other)
} else {
triggerTrip(other)
triggerRoll(k)
}
return true
}
return false
})
}
// Update knight-batch monitors. Refresh bindings every frame — the
// default readonly-binding MonitorBinding ticker (200ms) can starve
// under heavy main-thread load (allocs, GC pauses), making the
// display freeze even while the underlying values keep updating.
if (spriteGroupRef.current) {
const s = spriteGroupRef.current.stats
knightStatsRef.current.knights = knights.length
knightStatsRef.current.batches = s.batchCount
}
})
return (
<>
<tileMap2D
data={mapData}
enableCollision={false}
scale={[TILE_SCALE, TILE_SCALE, 1]}
position={[-mapWorldW / 2, -mapWorldH / 2, -1]}
/>
<spriteGroup ref={spriteGroupRef} />
</>
)
}
// ============================================
// APP
// ============================================
export default function App() {
const addKnightsRef = useRef<(() => void) | null>(null)
// Tweakpane
const { pane } = usePane()
// Knights monitors (first)
const knightStatsRef = useRef({ knights: 0, batches: 0 })
const statsFolder = usePaneFolder(pane, 'Knights')
// Simulation folder (at bottom, collapsed)
const simFolder = usePaneFolder(pane, 'Simulation')
const [speedMin] = usePaneInput(simFolder, 'speedMin', 30, {
min: 10,
max: 100,
step: 5,
label: 'speed min',
})
const [speedMax] = usePaneInput(simFolder, 'speedMax', 200, {
min: 100,
max: 300,
step: 10,
label: 'speed max',
})
const [hitRadius] = usePaneInput(simFolder, 'hitRadius', 8, {
min: 2,
max: 20,
step: 1,
label: 'hit radius',
})
const [knightScale] = usePaneInput(simFolder, 'knightScale', 64, {
min: 32,
max: 128,
step: 8,
label: 'scale',
})
// Refresh callback driven from KnightmarkScene's useFrame — see
// `refreshStatsRef.current()` call in the per-frame block. Per-frame
// refresh keeps the readout current under heavy load (GC pauses can
// starve tweakpane's 200ms internal ticker, leaving the display
// frozen while underlying values keep updating).
const refreshStatsRef = useRef<() => void>(() => {})
useEffect(() => {
if (!statsFolder) return
const bKnights = statsFolder.addBinding(knightStatsRef.current, 'knights', {
readonly: true,
format: (v: number) => v.toFixed(0),
})
const bBatches = statsFolder.addBinding(knightStatsRef.current, 'batches', {
readonly: true,
format: (v: number) => v.toFixed(0),
})
refreshStatsRef.current = () => {
bKnights.refresh()
bBatches.refresh()
}
return () => {
refreshStatsRef.current = () => {}
bKnights.dispose()
bBatches.dispose()
}
}, [statsFolder])
// Keyboard: Space to add knights
useEffect(() => {
const handler = (e: KeyboardEvent) => {
if (e.code === 'Space') {
e.preventDefault()
addKnightsRef.current?.()
}
}
window.addEventListener('keydown', handler)
return () => window.removeEventListener('keydown', handler)
}, [])
return (
<>
<Canvas
dpr={1}
renderer={{ antialias: false }}
onCreated={({ gl }) => {
gl.domElement.style.imageRendering = 'pixelated'
}}
>
<OrthoCamera viewSize={VIEW_SIZE} />
<DevtoolsProvider name="knightmark" />
{/* No L1/L2/L3 — knightmark's sprites fill the viewport, so a
backdrop wouldn't be visible anyway. Body bg (#16191e) shows
through during initial sprite load, no color jump. */}
<Suspense fallback={null}>
<KnightmarkScene
addKnightsRef={addKnightsRef}
speedMin={speedMin}
speedMax={speedMax}
hitRadius={hitRadius}
knightScale={knightScale}
knightStatsRef={knightStatsRef}
/>
</Suspense>
</Canvas>
{/* TODO: migrate game UI to three-flatland events */}
<div
style={{
position: 'fixed',
bottom: 16,
left: '50%',
transform: 'translateX(-50%)',
display: 'flex',
gap: 10,
zIndex: 100,
}}
>
<button
onClick={() => addKnightsRef.current?.()}
style={{
padding: '10px 24px',
fontSize: 14,
fontFamily: 'monospace',
border: '2px solid #4a9eff',
background: 'rgba(74,158,255,0.1)',
color: '#4a9eff',
cursor: 'pointer',
borderRadius: 4,
transition: 'background 0.15s',
}}
>
+ 100 Knights (Space)
</button>
</div>
</>
)
}

Knightmark is a sprite stress test inspired by classic benchmarks like Bunnymark. It showcases:

  • Batch rendering — thousands of animated sprites in minimal draw calls
  • AnimatedSprite2D — frame-based sprite animation with multiple states
  • Spatial hashing — efficient collision detection between sprites
  • Y-sorting — depth-correct rendering via zIndex

Each knight has a state machine (walk, roll, trip, idle) driving its animation:

const sprite = new AnimatedSprite2D({
spriteSheet: knightSheet,
animationSet: knightAnimations,
animation: 'idle',
layer: Layers.ENTITIES,
});
sprite.play('run');
sprite.play('death', {
onComplete: () => sprite.play('idle'),
});

Knights render in correct depth order by setting zIndex from their Y position:

sprite.zIndex = -Math.floor(sprite.position.y);

A simple spatial hash grid enables O(n) collision checks instead of O(n^2):

spatialHash.clear();
for (const knight of knights) spatialHash.insert(knight);
spatialHash.forEachNeighbor(knight, (other) => {
// Check distance and resolve collision
});