Skip to content
Back to Examples

Hit Testing

Pick sprites and tilemaps with any three.js Raycaster — R3F pointer events and vanilla intersectObjects, both covered.

import { WebGPURenderer } from 'three/webgpu'
import { Scene, OrthographicCamera, Color, Raycaster, Vector2, Plane, Vector3 } from 'three'
import { AnimatedSprite2D, SpriteSheetLoader, createDevtoolsProvider } from 'three-flatland'
import { createPane } from '@three-flatland/devtools'
import { gemGradientNode } from './GemBackground'
import { GEM } from './gem'
// HMR cleanup — stop the old animate loop + dispose the old renderer
// when Vite reloads this module. Without this, every dev save stacks a
// fresh renderer on top of the previous one's still-running rAF.
let rafId = 0
let activeRenderer: WebGPURenderer | null = null
// Named resize handler so HMR can remove it on dispose. An anonymous
// callback can't be removed, so each dev save would stack another
// closure over the old (disposed) camera/renderer.
let onResize: (() => void) | null = null
// ── Rarity tiers ──────────────────────────────────────────────────────────
const RARITIES = [
{ name: 'Common', color: 0xaaaaaa, css: '#aaaaaa', count: 4 },
{ name: 'Uncommon', color: 0x44dd66, css: '#44dd66', count: 3 },
{ name: 'Rare', color: 0x4488ff, css: '#4488ff', count: 2 },
{ name: 'Legendary', color: 0xffaa22, css: '#ffaa22', count: 1 },
] as const
type RarityName = (typeof RARITIES)[number]['name']
// Simple seeded PRNG — same seed as the React example so layouts match.
function mulberry32(seed: number) {
let s = seed | 0
return () => {
s = (s + 0x6d2b79f5) | 0
let t = Math.imul(s ^ (s >>> 15), 1 | s)
t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t
return ((t ^ (t >>> 14)) >>> 0) / 4294967296
}
}
// ── Loot item state ───────────────────────────────────────────────────────
interface CoinItem {
sprite: AnimatedSprite2D
rarity: RarityName
name: string
baseColor: Color
alive: boolean
shrinkProgress: number | null
}
// ── HUD ───────────────────────────────────────────────────────────────────
const collectedCount: Record<string, number> = {}
for (const r of RARITIES) collectedCount[r.name] = 0
function updateHUD() {
const el = document.getElementById('collected')
if (!el) return
el.innerHTML = RARITIES.map(
(r) => `<span style="color:${r.css}">${r.name}: ${collectedCount[r.name]}</span>`
).join('')
}
// ── Main ──────────────────────────────────────────────────────────────────
async function main() {
const scene = new Scene()
// Gem-tinted radial gradient backdrop (the canonical example background).
;(scene as unknown as { backgroundNode: unknown }).backgroundNode = gemGradientNode({ gem: GEM })
const frustumSize = 400
const 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 (required for TSL materials)
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()
// ── Raycaster setup ───────────────────────────────────────────────────
const raycaster = new Raycaster()
const pointer = new Vector2()
// Z=0 world plane — where we unproject pointer clicks for knight movement.
const groundPlane = new Plane(new Vector3(0, 0, 1), 0)
const _planeHit = new Vector3()
/** Convert a DOM pointer event to NDC and load the raycaster. */
function castFromEvent(e: PointerEvent) {
const rect = renderer.domElement.getBoundingClientRect()
pointer.x = ((e.clientX - rect.left) / rect.width) * 2 - 1
pointer.y = -((e.clientY - rect.top) / rect.height) * 2 + 1
raycaster.setFromCamera(pointer, camera)
}
/** Unproject the current raycaster ray onto Z=0, returning world XY. */
function rayToGroundXY(): { x: number; y: number } | null {
const hit = raycaster.ray.intersectPlane(groundPlane, _planeHit)
if (!hit) return null
return { x: _planeHit.x, y: _planeHit.y }
}
// ── Load spritesheets ─────────────────────────────────────────────────
// Relative paths (like the other three examples) so assets resolve against
// the document URL in BOTH the per-example server and the MPA. Using
// import.meta.env.BASE_URL here breaks under the MPA, where BASE_URL is '/'
// → '/sprites/knight.json' 404s → the loader throws → the canvas stays black.
const [knightSheet, coinSheet] = await Promise.all([
SpriteSheetLoader.load('./sprites/knight.json'),
SpriteSheetLoader.load('./sprites/coin.json'),
])
// ── Knight ────────────────────────────────────────────────────────────
const KNIGHT_SCALE = 96
const knight = new AnimatedSprite2D({
spriteSheet: knightSheet,
animationSet: {
fps: 10,
animations: {
idle: { frames: ['idle_0', 'idle_1', 'idle_2', 'idle_3'], fps: 8, loop: true },
run: {
frames: Array.from({ length: 16 }, (_, i) => `run_${i}`),
fps: 12,
loop: true,
},
// Tumble — played while the knight is being dragged.
roll: {
frames: Array.from({ length: 8 }, (_, i) => `roll_${i}`),
fps: 15,
loop: true,
},
},
},
animation: 'idle',
anchor: [0.5, 0.5],
})
knight.hitTestMode = 'bounds' // full quad is grabbable for drag-and-drop
knight.scale.set(KNIGHT_SCALE, KNIGHT_SCALE, 1)
knight.position.set(0, 0, 0)
scene.add(knight)
// ── Knight movement ───────────────────────────────────────────────────
let knightTarget: { x: number; y: number } | null = null
let pendingPickup: CoinItem | null = null
const KNIGHT_SPEED = 140
const PICKUP_RANGE = 50
function moveKnightTo(x: number, y: number) {
knightTarget = { x, y }
knight.play('run')
// Face the target via flipX (a UV flip). Negating scale.x would reverse
// the quad winding and the FrontSide material culls it — the knight
// vanishes when facing left. Only flip on real horizontal travel.
if (Math.abs(x - knight.position.x) > 0.5) knight.flipX = x < knight.position.x
}
function updateKnightMovement(dt: number) {
if (!knightTarget) return
const dx = knightTarget.x - knight.position.x
const dy = knightTarget.y - knight.position.y
const dist = Math.sqrt(dx * dx + dy * dy)
if (pendingPickup && dist < PICKUP_RANGE) {
startCollect(pendingPickup)
pendingPickup = null
knightTarget = null
knight.play('idle')
return
}
if (dist < 2) {
knightTarget = null
knight.play('idle')
return
}
const step = Math.min(KNIGHT_SPEED * dt, dist)
knight.position.x += (dx / dist) * step
knight.position.y += (dy / dist) * step
}
// ── Coin items ────────────────────────────────────────────────────────
const coins: CoinItem[] = []
// The pickable array fed to raycaster.intersectObjects().
// Coins are removed from this array when collected so they stop being hit.
const pickableCoins: AnimatedSprite2D[] = []
const COIN_SCALE = 48
const rng = mulberry32(42)
// Core: build a coin sprite at a given spot and register it as pickable.
function addCoin(
rarity: (typeof RARITIES)[number],
x: number,
y: number,
z: number,
fps: number
): CoinItem {
const sprite = new AnimatedSprite2D({
spriteSheet: coinSheet,
animationSet: {
fps: 10,
animations: {
spin: {
frames: Array.from({ length: 12 }, (_, i) => `coin_${i}`),
fps,
loop: true,
},
},
},
animation: 'spin',
anchor: [0.5, 0.5],
})
sprite.position.set(x, y, z)
sprite.scale.set(COIN_SCALE, COIN_SCALE, 1)
const baseColor = new Color(rarity.color)
sprite.tint = baseColor
scene.add(sprite)
pickableCoins.push(sprite)
return {
sprite,
rarity: rarity.name,
name: `${rarity.name} Coin`,
baseColor,
alive: true,
shrinkProgress: null,
}
}
// Seeded layout coin (deterministic starting set — matches the React example).
function createCoin(rarity: (typeof RARITIES)[number], index: number): CoinItem {
const angle = (index / 14) * Math.PI * 2 + (rng() - 0.5) * 0.5
const radius = 60 + rng() * 110
return addCoin(
rarity,
Math.cos(angle) * radius,
Math.sin(angle) * radius,
index * 0.01,
8 + rng() * 4
)
}
// Genuinely random coin — used by the periodic spawner. Capped on live
// (pickable) coins so an idle session doesn't grow the scene without bound.
const MAX_LIVE_COINS = 24
function spawnRandomCoin() {
if (pickableCoins.length >= MAX_LIVE_COINS) return
const rarity = RARITIES[Math.floor(Math.random() * RARITIES.length)]!
const angle = Math.random() * Math.PI * 2
const radius = 60 + Math.random() * 110
coins.push(
addCoin(
rarity,
Math.cos(angle) * radius,
Math.sin(angle) * radius,
0.5,
8 + Math.random() * 4
)
)
}
for (const rarity of RARITIES) {
for (let i = 0; i < rarity.count; i++) {
coins.push(createCoin(rarity, coins.length))
}
}
// ── Collection ────────────────────────────────────────────────────────
function startCollect(item: CoinItem) {
if (!item.alive) return
item.alive = false
item.shrinkProgress = 0
collectedCount[item.rarity]!++
updateHUD()
// Remove from pickable array immediately so it can't be re-hit.
const idx = pickableCoins.indexOf(item.sprite)
if (idx !== -1) pickableCoins.splice(idx, 1)
renderer.domElement.style.cursor = 'default'
}
// ── Hover state ───────────────────────────────────────────────────────
let hoveredCoin: CoinItem | null = null
function setHover(item: CoinItem | null) {
if (hoveredCoin === item) return
// Reset previous hover
if (hoveredCoin) {
hoveredCoin.sprite.tint = hoveredCoin.baseColor
hoveredCoin.sprite.scale.set(COIN_SCALE, COIN_SCALE, 1)
}
hoveredCoin = item
if (item) {
// The coin atlas is neutral grayscale, so `tint` reproduces the rarity
// color exactly (highlights hit the pure hue, matching the HUD legend).
// On hover, brighten the same hue a touch rather than washing to white —
// a hard pull to white would desaturate and drop the rarity identity.
item.sprite.tint = item.baseColor.clone().multiplyScalar(1.4)
const s = COIN_SCALE * 1.2
item.sprite.scale.set(s, s, 1)
renderer.domElement.style.cursor = 'pointer'
} else {
renderer.domElement.style.cursor = 'default'
}
}
// ── Drag-and-drop the knight ──────────────────────────────────────────
// Grab the knight and fling him around — he tumbles (roll) while held and
// drops to idle on release. `engaged` marks any press that started on the
// knight so the trailing click doesn't also fire a walk.
const knightDrag = { active: false, engaged: false }
// ── Pointer events ────────────────────────────────────────────────────
renderer.domElement.addEventListener('pointerdown', (e) => {
castFromEvent(e)
// A coin under the cursor wins — let the click handler collect it.
if (raycaster.intersectObjects(pickableCoins, false).length > 0) return
if (raycaster.intersectObject(knight, false).length === 0) return
knightDrag.active = true
knightDrag.engaged = true
knightTarget = null
pendingPickup = null
knight.play('roll')
// Capture can throw on synthetic/non-active pointers — don't let it abort.
try {
renderer.domElement.setPointerCapture(e.pointerId)
} catch {
/* ignore */
}
renderer.domElement.style.cursor = 'grabbing'
})
renderer.domElement.addEventListener('pointerup', (e) => {
if (!knightDrag.active) return
knightDrag.active = false
knight.play('idle')
try {
renderer.domElement.releasePointerCapture?.(e.pointerId)
} catch {
/* ignore */
}
renderer.domElement.style.cursor = 'default'
})
renderer.domElement.addEventListener('pointermove', (e) => {
// While dragging, the pointer drives the knight's position.
if (knightDrag.active) {
castFromEvent(e)
const g = rayToGroundXY()
if (g) {
const dx = g.x - knight.position.x
if (Math.abs(dx) > 0.5) knight.flipX = dx < 0
knight.position.set(g.x, g.y, knight.position.z)
}
return
}
castFromEvent(e)
const hits = raycaster.intersectObjects(pickableCoins, false)
const hitCoin =
hits.length > 0 ? (coins.find((c) => c.sprite === hits[0]!.object) ?? null) : null
setHover(hitCoin)
// Grab cursor when hovering the knight (and not already over a coin).
if (!hitCoin && raycaster.intersectObject(knight, false).length > 0) {
renderer.domElement.style.cursor = 'grab'
}
})
renderer.domElement.addEventListener('click', (e) => {
// A press that started on the knight (tap or drag) never walks him.
if (knightDrag.engaged) {
knightDrag.engaged = false
return
}
castFromEvent(e as PointerEvent)
// 1. Try coins first.
const hits = raycaster.intersectObjects(pickableCoins, false)
if (hits.length > 0) {
const coin = coins.find((c) => c.sprite === hits[0]!.object)
if (coin && coin.alive) {
// Diablo-style: walk to coin, then pick it up.
const dx = coin.sprite.position.x - knight.position.x
const dy = coin.sprite.position.y - knight.position.y
const dist = Math.sqrt(dx * dx + dy * dy)
if (dist < PICKUP_RANGE) {
startCollect(coin)
} else {
pendingPickup = coin
moveKnightTo(coin.sprite.position.x, coin.sprite.position.y)
}
}
return
}
// 2. Click on empty ground — move the knight there.
const ground = rayToGroundXY()
if (ground) {
pendingPickup = null
moveKnightTo(ground.x, ground.y)
}
})
// ── Tweakpane UI ──────────────────────────────────────────────────────
// Default stats only; this example exposes no custom controls.
const { update: updateDevtools } = createPane({ driver: 'manual' })
const devtools = createDevtoolsProvider({ name: 'hit-test' })
// ── Resize ────────────────────────────────────────────────────────────
onResize = () => {
const a = window.innerWidth / window.innerHeight
camera.left = (-frustumSize * a) / 2
camera.right = (frustumSize * a) / 2
camera.top = frustumSize / 2
camera.bottom = -frustumSize / 2
camera.updateProjectionMatrix()
renderer.setSize(window.innerWidth, window.innerHeight)
}
window.addEventListener('resize', onResize)
// ── Render loop ───────────────────────────────────────────────────────
updateHUD()
let lastTime = performance.now()
let spawnAccum = 0
const SPAWN_INTERVAL_SEC = 3
function animate() {
rafId = requestAnimationFrame(animate)
const now = performance.now()
const deltaMs = now - lastTime
const deltaSec = deltaMs / 1000
lastTime = now
// Periodically drop a fresh random coin onto the map.
spawnAccum += deltaSec
if (spawnAccum >= SPAWN_INTERVAL_SEC) {
spawnAccum = 0
spawnRandomCoin()
}
// Animate sprites.
knight.update(deltaMs)
for (const coin of coins) {
// Skip sprites that have been fully collected and removed from the scene.
if (coin.shrinkProgress !== -1) {
coin.sprite.update(deltaMs)
}
}
// Knight movement.
updateKnightMovement(deltaSec)
// Shrink-to-collect animations.
for (const coin of coins) {
if (coin.shrinkProgress === null || coin.shrinkProgress < 0) continue
coin.shrinkProgress += deltaSec * 4
if (coin.shrinkProgress >= 1) {
scene.remove(coin.sprite)
coin.shrinkProgress = -1 // mark done
} else {
const s = (1 - coin.shrinkProgress) * COIN_SCALE
coin.sprite.scale.set(s, s, 1)
coin.sprite.position.y += deltaSec * 40 // float up
}
}
devtools.beginFrame(performance.now(), renderer)
renderer.render(scene, camera)
devtools.endFrame(renderer)
updateDevtools()
}
animate()
}
main()
if (import.meta.hot) {
import.meta.hot.dispose(() => {
if (rafId) {
cancelAnimationFrame(rafId)
rafId = 0
}
if (onResize) {
window.removeEventListener('resize', onResize)
onResize = null
}
if (activeRenderer) {
activeRenderer.dispose?.()
activeRenderer.domElement.remove()
activeRenderer = null
}
})
}
import { Suspense, useState, useRef, useCallback, useMemo, useLayoutEffect, useEffect } from 'react'
import { Canvas, extend, useFrame, useThree, useLoader } from '@react-three/fiber/webgpu'
import type { OrthographicCamera as ThreeOrthographicCamera } from 'three'
import {
AnimatedSprite2D,
Sprite2D,
SpriteSheetLoader,
SortLayers,
type AnimationSetDefinition,
} from 'three-flatland/react'
import { usePane, DevtoolsProvider } from '@three-flatland/devtools/react'
import { Color, Vector3 } from 'three'
import type { ThreeEvent } from '@react-three/fiber/webgpu'
import { GemBackground } from './GemBackground'
import { GEM } from './gem'
// Register the sprite classes with R3F (tree-shakeable)
extend({ AnimatedSprite2D, Sprite2D })
// ---------------------------------------------------------------------------
// Orthographic camera — constant world units regardless of viewport, the
// canonical 2D framing used across the examples (mirrors animation/).
// ---------------------------------------------------------------------------
function OrthoCamera({ viewSize }: { viewSize: number }) {
const camera = useThree((s) => s.camera) as ThreeOrthographicCamera
const size = useThree((s) => s.size)
useLayoutEffect(() => {
const aspect = size.width / size.height
camera.left = (-viewSize * aspect) / 2
camera.right = (viewSize * aspect) / 2
camera.top = viewSize / 2
camera.bottom = -viewSize / 2
camera.updateProjectionMatrix()
}, [camera, size, viewSize])
return null
}
// ---------------------------------------------------------------------------
// Loot layout — seeded so the React and Three.js examples share a layout.
// ---------------------------------------------------------------------------
const RARITIES = [
{ name: 'Common', color: 0xaaaaaa, css: '#aaaaaa', count: 4 },
{ name: 'Uncommon', color: 0x44dd66, css: '#44dd66', count: 3 },
{ name: 'Rare', color: 0x4488ff, css: '#4488ff', count: 2 },
{ name: 'Legendary', color: 0xffaa22, css: '#ffaa22', count: 1 },
] as const
type RarityName = (typeof RARITIES)[number]['name']
interface CoinSpec {
id: number
rarity: RarityName
name: string
color: number
x: number
y: number
z: number
fps: number
}
// Seeded PRNG — same seed/call-order as examples/three/hit-test so layouts match.
function mulberry32(seed: number) {
let s = seed | 0
return () => {
s = (s + 0x6d2b79f5) | 0
let t = Math.imul(s ^ (s >>> 15), 1 | s)
t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t
return ((t ^ (t >>> 14)) >>> 0) / 4294967296
}
}
function buildLootLayout(): CoinSpec[] {
const rng = mulberry32(42)
const coins: CoinSpec[] = []
for (const rarity of RARITIES) {
for (let i = 0; i < rarity.count; i++) {
const index = coins.length
const fps = 8 + rng() * 4
const angle = (index / 14) * Math.PI * 2 + (rng() - 0.5) * 0.5
const radius = 60 + rng() * 110
coins.push({
id: index,
rarity: rarity.name,
name: `${rarity.name} Coin`,
color: rarity.color,
x: Math.cos(angle) * radius,
y: Math.sin(angle) * radius,
z: index * 0.01,
fps,
})
}
}
return coins
}
// A fresh random coin — used by the periodic spawner. Unlike buildLootLayout
// (seeded, for the deterministic starting set) this is genuinely random.
function randomCoin(id: number): CoinSpec {
const rarity = RARITIES[Math.floor(Math.random() * RARITIES.length)]!
const angle = Math.random() * Math.PI * 2
const radius = 60 + Math.random() * 110
return {
id,
rarity: rarity.name,
name: `${rarity.name} Coin`,
color: rarity.color,
x: Math.cos(angle) * radius,
y: Math.sin(angle) * radius,
z: 0.5,
fps: 8 + Math.random() * 4,
}
}
const SPAWN_INTERVAL_MS = 3000
// Cap live coins so an idle session doesn't grow the scene without bound.
const MAX_LIVE_COINS = 24
const KNIGHT_SCALE = 96
const COIN_SCALE = 48
const KNIGHT_SPEED = 140
const PICKUP_RANGE = 50
const coinAnims = (fps: number): AnimationSetDefinition => ({
fps: 10,
animations: {
spin: { frames: Array.from({ length: 12 }, (_, i) => `coin_${i}`), fps, loop: true },
},
})
// ---------------------------------------------------------------------------
// Knight — walks toward the active target; collects the pending coin on arrival.
// ---------------------------------------------------------------------------
const knightAnims: AnimationSetDefinition = {
fps: 10,
animations: {
idle: { frames: ['idle_0', 'idle_1', 'idle_2', 'idle_3'], fps: 8, loop: true },
run: {
frames: Array.from({ length: 16 }, (_, i) => `run_${i}`),
fps: 12,
loop: true,
},
// Tumble — played while the knight is being dragged.
roll: {
frames: Array.from({ length: 8 }, (_, i) => `roll_${i}`),
fps: 15,
loop: true,
},
},
}
interface KnightProps {
target: { x: number; y: number } | null
pendingCoinId: number | null
onReachCoin: (id: number) => void
onDragStart: () => void
}
function Knight({ target, pendingCoinId, onReachCoin, onDragStart }: KnightProps) {
const ref = useRef<AnimatedSprite2D>(null)
const sheet = useLoader(SpriteSheetLoader, './sprites/knight.json')
const anim = useRef<'idle' | 'run' | 'roll'>('idle')
const camera = useThree((s) => s.camera)
const gl = useThree((s) => s.gl)
// Read the latest walk target/pending in useFrame without re-subscribing.
const targetRef = useRef(target)
targetRef.current = target
const pendingRef = useRef(pendingCoinId)
pendingRef.current = pendingCoinId
// While dragging, the pointer drives the position and the walk logic pauses.
const dragging = useRef(false)
const play = useCallback((name: 'idle' | 'run' | 'roll') => {
const k = ref.current
if (k && anim.current !== name) {
k.play(name)
anim.current = name
}
}, [])
// Unproject a DOM point to world XY on the knight's z-plane.
const toWorld = useCallback(
(clientX: number, clientY: number) => {
const rect = gl.domElement.getBoundingClientRect()
const v = new Vector3(
((clientX - rect.left) / rect.width) * 2 - 1,
-((clientY - rect.top) / rect.height) * 2 + 1,
0
)
v.unproject(camera)
return v
},
[camera, gl]
)
useFrame((_, delta) => {
const knight = ref.current
if (!knight) return
knight.update(delta * 1000)
if (dragging.current) return // pointer listeners own the position while dragging
const t = targetRef.current
if (!t) {
play('idle')
return
}
const dx = t.x - knight.position.x
const dy = t.y - knight.position.y
const dist = Math.hypot(dx, dy)
// Arrived at a coin (within pickup range) — collect it.
if (pendingRef.current !== null && dist < PICKUP_RANGE) {
onReachCoin(pendingRef.current)
return
}
// Arrived at a ground target — stop and idle.
if (dist < 2) {
play('idle')
return
}
play('run')
const step = Math.min(KNIGHT_SPEED * delta, dist)
knight.position.x += (dx / dist) * step
knight.position.y += (dy / dist) * step
// Face the direction of travel via flipX (a UV flip). Negating scale.x
// would reverse the quad's winding and the FrontSide material culls it —
// the knight vanishes whenever it faces left. Only flip on real
// horizontal travel so walking straight up/down keeps the last facing.
if (Math.abs(dx) > 0.5) knight.flipX = dx < 0
})
// Drag-and-drop: grab the knight and fling him around — he tumbles (roll)
// while held and drops to idle on release. Canvas-level listeners with
// pointer capture keep the drag alive even when the cursor outruns the
// sprite (an object-only onPointerMove would drop the moment you leave it).
const handlePointerDown = useCallback(
(e: ThreeEvent<PointerEvent>) => {
e.stopPropagation()
onDragStart() // cancel any walk-to target
dragging.current = true
play('roll')
document.body.style.cursor = 'grabbing'
const el = gl.domElement
// Capture keeps moves flowing when the cursor outruns the sprite; it can
// throw on synthetic/non-active pointers, so never let it abort the drag.
try {
el.setPointerCapture(e.pointerId)
} catch {
/* ignore — listeners below still drive the drag */
}
const onMove = (ev: PointerEvent) => {
const knight = ref.current
if (!knight) return
const w = toWorld(ev.clientX, ev.clientY)
const dx = w.x - knight.position.x
if (Math.abs(dx) > 0.5) knight.flipX = dx < 0
knight.position.set(w.x, w.y, 1)
}
const onUp = () => {
dragging.current = false
play('idle')
document.body.style.cursor = 'grab'
try {
el.releasePointerCapture?.(e.pointerId)
} catch {
/* ignore */
}
el.removeEventListener('pointermove', onMove)
el.removeEventListener('pointerup', onUp)
}
el.addEventListener('pointermove', onMove)
el.addEventListener('pointerup', onUp)
},
[gl, onDragStart, play, toWorld]
)
return (
<animatedSprite2D
ref={ref}
spriteSheet={sheet}
animationSet={knightAnims}
animation="idle"
anchor={[0.5, 0.5]}
scale={[KNIGHT_SCALE, KNIGHT_SCALE, 1]}
position={[0, 0, 1]}
sortLayer={SortLayers.ENTITIES}
// hitTestMode="bounds" — the full quad is grabbable for drag-and-drop.
hitTestMode="bounds"
onPointerDown={handlePointerDown}
onPointerOver={(e: ThreeEvent<PointerEvent>) => {
e.stopPropagation()
if (!dragging.current) document.body.style.cursor = 'grab'
}}
onPointerOut={() => {
if (!dragging.current) document.body.style.cursor = 'default'
}}
/>
)
}
// ---------------------------------------------------------------------------
// Coin — declarative R3F pointer events: hover highlights, click walks-to-collect.
// Animates a spin; plays a shrink-and-float on collect before unmounting.
// ---------------------------------------------------------------------------
interface CoinProps {
spec: CoinSpec
collecting: boolean
onClick: (spec: CoinSpec) => void
onCollected: (id: number) => void
}
function Coin({ spec, collecting, onClick, onCollected }: CoinProps) {
const ref = useRef<AnimatedSprite2D>(null)
const sheet = useLoader(SpriteSheetLoader, './sprites/coin.json')
const animSet = useMemo(() => coinAnims(spec.fps), [spec.fps])
const [hovered, setHovered] = useState(false)
const hoveredRef = useRef(false)
hoveredRef.current = hovered
// Stable color objects — mutated in useFrame, never recreated.
const baseTint = useMemo(() => new Color(spec.color), [spec.color])
// The coin atlas is neutral grayscale, so `tint` reproduces the rarity color
// exactly (highlights hit the pure hue, matching the HUD legend). Hover keeps
// the SAME hue, just brighter — a small multiply, NOT a lerp toward white,
// which would desaturate and drop the rarity identity.
const hoverTint = useMemo(() => new Color(spec.color).multiplyScalar(1.4), [spec.color])
const tint = useRef(new Color(spec.color))
const shrink = useRef(0)
// One-shot gate: `onCollected` schedules a parent re-render that unmounts us,
// but the next frame can re-enter this branch before React commits. Dispatch
// exactly once per collect so we never fire duplicate collection events.
const collectedRef = useRef(false)
useFrame((_, delta) => {
const coin = ref.current
if (!coin) return
coin.update(delta * 1000)
if (collecting) {
// Shrink and float up, then report done so the parent can unmount us.
shrink.current = Math.min(shrink.current + delta * 4, 1)
const s = (1 - shrink.current) * COIN_SCALE
coin.scale.set(s, s, 1)
coin.position.y += delta * 40
coin.tint = hoverTint
if (shrink.current >= 1 && !collectedRef.current) {
collectedRef.current = true
onCollected(spec.id)
}
return
}
// Ease tint toward base/hover and bump scale on hover.
const target = hoveredRef.current ? hoverTint : baseTint
const c = tint.current
const k = Math.min(delta * 12, 1)
c.r += (target.r - c.r) * k
c.g += (target.g - c.g) * k
c.b += (target.b - c.b) * k
coin.tint = c
const want = hoveredRef.current ? COIN_SCALE * 1.2 : COIN_SCALE
const cur = coin.scale.x
coin.scale.setScalar(cur + (want - cur) * k)
coin.scale.z = 1
})
const handleOver = useCallback(
(e: ThreeEvent<PointerEvent>) => {
e.stopPropagation()
if (collecting) return
setHovered(true)
document.body.style.cursor = 'pointer'
},
[collecting]
)
const handleOut = useCallback(() => {
setHovered(false)
document.body.style.cursor = 'default'
}, [])
const handleClick = useCallback(
(e: ThreeEvent<MouseEvent>) => {
e.stopPropagation()
if (collecting) return
onClick(spec)
},
[collecting, onClick, spec]
)
return (
<animatedSprite2D
ref={ref}
spriteSheet={sheet}
animationSet={animSet}
animation="spin"
anchor={[0.5, 0.5]}
scale={[COIN_SCALE, COIN_SCALE, 1]}
position={[spec.x, spec.y, spec.z]}
sortLayer={SortLayers.ENTITIES}
tint={`#${spec.color.toString(16).padStart(6, '0')}`}
// hitTestMode left at the default 'radius' — the inscribed circle is the
// natural pickable surface for a round coin.
onPointerOver={handleOver}
onPointerOut={handleOut}
onClick={handleClick}
/>
)
}
// ---------------------------------------------------------------------------
// Ground — catches clicks the coins don't consume, to walk the knight.
// ---------------------------------------------------------------------------
function Ground({ onWalk }: { onWalk: (x: number, y: number) => void }) {
const { viewport } = useThree()
return (
<sprite2D
anchor={[0.5, 0.5]}
scale={[viewport.width, viewport.height, 1]}
position={[0, 0, -1]}
sortLayer={SortLayers.BACKGROUND}
// Invisible (alpha 0) so the gem GemBackground shows through — we only
// need the quad as a click target, not as a visible surface. The
// geometry is still pickable: raycast is unaffected by alpha.
alpha={0}
// hitTestMode="bounds" — full-quad hit surface, no inscribed circle.
hitTestMode="bounds"
onClick={(e: ThreeEvent<MouseEvent>) => {
onWalk(e.point.x, e.point.y)
}}
/>
)
}
// ---------------------------------------------------------------------------
// Scene
// ---------------------------------------------------------------------------
function Scene({ onCounts }: { onCounts: (counts: Record<RarityName, number>) => void }) {
const initial = useMemo(buildLootLayout, [])
// The coin set grows over time as the spawner adds coins.
const [coins, setCoins] = useState<CoinSpec[]>(() => initial)
const nextId = useRef(initial.length)
const [target, setTarget] = useState<{ x: number; y: number } | null>(null)
const [pendingCoinId, setPendingCoinId] = useState<number | null>(null)
// alive: pickable + visible. collecting: playing the shrink-out.
const [alive, setAlive] = useState<Set<number>>(() => new Set(initial.map((c) => c.id)))
const [collecting, setCollecting] = useState<Set<number>>(() => new Set())
const [counts, setCounts] = useState<Record<RarityName, number>>({
Common: 0,
Uncommon: 0,
Rare: 0,
Legendary: 0,
})
const byId = useMemo(() => new Map(coins.map((c) => [c.id, c])), [coins])
// Live coin count, read by the spawner without re-subscribing the interval.
const liveCount = alive.size + collecting.size
const liveCountRef = useRef(liveCount)
liveCountRef.current = liveCount
// Periodically drop a fresh random coin onto the map (up to the cap).
useEffect(() => {
const t = setInterval(() => {
if (liveCountRef.current >= MAX_LIVE_COINS) return
const id = nextId.current++
setCoins((prev) => [...prev, randomCoin(id)])
setAlive((prev) => new Set(prev).add(id))
}, SPAWN_INTERVAL_MS)
return () => clearInterval(t)
}, [])
const walkToGround = useCallback((x: number, y: number) => {
setPendingCoinId(null)
setTarget({ x, y })
}, [])
const walkToCoin = useCallback((spec: CoinSpec) => {
setPendingCoinId(spec.id)
setTarget({ x: spec.x, y: spec.y })
}, [])
// Grabbing the knight cancels any walk-to so the drag takes over cleanly.
const cancelWalk = useCallback(() => {
setPendingCoinId(null)
setTarget(null)
}, [])
// Knight reached the pending coin → start its collect (shrink) animation.
const reachCoin = useCallback(
(id: number) => {
setPendingCoinId(null)
setTarget(null)
setCollecting((prev) => {
if (prev.has(id)) return prev
const next = new Set(prev)
next.add(id)
return next
})
setAlive((prev) => {
if (!prev.has(id)) return prev
const next = new Set(prev)
next.delete(id)
return next
})
const spec = byId.get(id)
if (spec) setCounts((c) => ({ ...c, [spec.rarity]: c[spec.rarity] + 1 }))
document.body.style.cursor = 'default'
},
[byId]
)
// Shrink-out finished → remove the coin entirely.
const removeCoin = useCallback((id: number) => {
setCollecting((prev) => {
if (!prev.has(id)) return prev
const next = new Set(prev)
next.delete(id)
return next
})
}, [])
// Mirror collect counts up to the DOM HUD (rendered outside the Canvas).
useEffect(() => onCounts(counts), [counts, onCounts])
// Devtools pane — default stats only; this example exposes no custom controls.
usePane()
const visible = coins.filter((c) => alive.has(c.id) || collecting.has(c.id))
return (
<>
<GemBackground gem={GEM} />
<OrthoCamera viewSize={400} />
{/* The pane (above) must stay mounted while assets load, so the
suspending loaders live below an inner Suspense. */}
<Suspense fallback={null}>
<Ground onWalk={walkToGround} />
<Knight
target={target}
pendingCoinId={pendingCoinId}
onReachCoin={reachCoin}
onDragStart={cancelWalk}
/>
{visible.map((spec) => (
<Coin
key={spec.id}
spec={spec}
collecting={collecting.has(spec.id)}
onClick={walkToCoin}
onCollected={removeCoin}
/>
))}
</Suspense>
</>
)
}
// ---------------------------------------------------------------------------
// App
// ---------------------------------------------------------------------------
const RARITY_CSS: Record<RarityName, string> = {
Common: '#aaaaaa',
Uncommon: '#44dd66',
Rare: '#4488ff',
Legendary: '#ffaa22',
}
export default function App() {
const [counts, setCounts] = useState<Record<RarityName, number>>({
Common: 0,
Uncommon: 0,
Rare: 0,
Legendary: 0,
})
return (
<div style={{ width: '100%', height: '100%', position: 'relative' }}>
<Canvas
orthographic
dpr={1}
camera={{ position: [0, 0, 100], near: 0.1, far: 1000 }}
renderer={{ antialias: false }}
onCreated={({ gl }) => {
gl.domElement.style.imageRendering = 'pixelated'
}}
>
<DevtoolsProvider name="hit-test" />
<Scene onCounts={setCounts} />
</Canvas>
{/* Collected-loot HUD */}
<div
style={{
position: 'fixed',
bottom: 12,
left: '50%',
transform: 'translateX(-50%)',
zIndex: 100,
padding: '8px 16px',
background: 'rgba(0,2,28,0.85)',
borderRadius: 8,
fontFamily: 'monospace',
fontSize: 13,
display: 'flex',
gap: 12,
pointerEvents: 'none',
}}
>
{(Object.keys(RARITY_CSS) as RarityName[]).map((r) => (
<span key={r} style={{ color: RARITY_CSS[r] }}>
{r}: {counts[r]}
</span>
))}
</div>
</div>
)
}

Sprite2D, AnimatedSprite2D, and TileMap2D all participate in the standard three.js raycast pipeline. That means R3F's onPointerOver, onPointerOut, and onClick props work on them without any extra wiring, and vanilla users reach for the same Raycaster they already know.

three.js ships exactly one picking primitive — Raycaster — and no event system at all. The bare-metal "did they click this sprite?" is yours to wire up by hand:

const ray = new Raycaster()
ray.setFromCamera(ndc, camera) // you convert the DOM event to NDC yourself
const hit = ray.intersectObjects(sprites)[0]
if (hit) collect(hit.object) // ...and re-do this on every pointer event

Because flatland sprites implement raycast(), R3F turns all of that into props:

<sprite2D texture={texture} onClick={() => collect()} onPointerOver={() => highlight()} />

Same raycast underneath — R3F just hands you onClick / onPointerOver / onPointerOut / onPointerMissed, with bubbling, e.stopPropagation(), e.point (world-space hit), and pointer capture for free. You only touch a Raycaster directly when you're in raw three.js with no R3F.

Every sprite carries a hitTestMode property that controls what the raycaster tests against. Choose the mode that matches your use case's tolerance for false positives and memory cost.

| Mode | How it tests | Cost | Default | | -------- | ---------------------------------------------------------------------------------------- | --------------------- | ------------------ | | radius | Distance to the inscribed circle of the sprite's bounding quad | O(1) | Sprite2D default | | bounds | Local axis-aligned bounding box (AABB) of the full quad | O(1) | — | | alpha | AABB pass first, then a CPU sample of the alpha channel against a configurable threshold | O(1) + sidecar memory | opt-in | | none | raycast() returns immediately; the object is invisible to the raycaster | zero | — |

radius avoids false corner hits on small sprites and is the right default for round or square icons. bounds fits rectangular sprites without wasted margin. alpha delivers pixel-perfect picking at the cost of uploading an alpha sidecar texture to CPU-accessible memory — worth it for large irregular sprites like characters, not worth it for small coins. none is the right choice for objects you want rendered but not interactive; the knight in this example sets hitTestMode='none' so ground clicks pass straight through it.

R3F — attach pointer callbacks directly to the JSX element. R3F manages the raycaster internally, dispatches synthetic ThreeEvents, and handles propagation through the scene graph.

<sprite2D
texture={sheet.texture}
frame={frame}
anchor={[0.5, 0.5]}
scale={[48, 48, 1]}
hitTestMode="bounds"
onPointerOver={(e) => {
e.stopPropagation()
setHovered(true)
}}
onPointerOut={() => setHovered(false)}
onClick={(e) => {
e.stopPropagation()
handleCollect(id)
}}
/>

Vanilla — build NDC coordinates from the pointer event, load the raycaster, then call intersectObjects against the array of sprites you want to be pickable. Keep a parallel array of pickable objects and remove sprites from it when they are collected so they stop receiving hits.

const raycaster = new Raycaster()
const pointer = new Vector2()
renderer.domElement.addEventListener('pointermove', (e) => {
const rect = renderer.domElement.getBoundingClientRect()
pointer.x = ((e.clientX - rect.left) / rect.width) * 2 - 1
pointer.y = -((e.clientY - rect.top) / rect.height) * 2 + 1
raycaster.setFromCamera(pointer, camera)
const hits = raycaster.intersectObjects(pickableCoins, false)
setHover(hits.length > 0 ? hits[0].object : null)
})

The examples above show both paths in full.

When a ray hits a TileMap2D, tileFromIntersection converts the Intersection result into layer and tile coordinates:

const hits = raycaster.intersectObject(tilemap, true)
if (hits.length > 0) {
const cell = tilemap.tileFromIntersection(hits[0])
// cell → { sortLayer: TileLayer2D, tileX: number, tileY: number, gid: number }
console.log(`hit gid ${cell.gid} at (${cell.tileX}, ${cell.tileY})`)
}

When you animate the camera while the pointer is stationary, R3F does not re-fire the last pointer event automatically. Call state.events.update() from useFrame to force a re-evaluation each frame:

useFrame((state) => {
// Advance camera, pan, zoom…
state.events.update()
})

Without this, a hoverable sprite that slides under a stationary cursor will not receive onPointerOver until the user moves the mouse.

For irregular sprites — characters, foliage, large items with transparent cutouts — hitTestMode: 'alpha' rejects rays that land on fully transparent pixels. The mode does an AABB pre-check first, then samples the CPU-side alpha map only when the bounds test passes, so the extra cost is proportional to how often the outer box is hit rather than every frame.

Getting there is a three-step process: bake a sidecar at build time, tell the loader to load it, then assign it to the sprite and flip the mode.

The flatland-bake alpha command (from @three-flatland/alphamap) extracts the alpha channel of your sprite PNG into a companion file. Run it once as part of your asset pipeline, typically in a package.json prebuild script or a Makefile rule:

Terminal window
flatland-bake alpha sprites.png
# writes sprites.alpha.png

The output is a grayscale PNG with alpha stored in the R channel, stamped with a version hash so the runtime can detect stale bakes. The sibling is named by replacing the source extension: sprites.pngsprites.alpha.png, player/knight.pngplayer/knight.alpha.png.

The sidecar is optional at runtime. If the loader cannot find it — or the hash doesn't match the current format version — it falls back to a synchronous canvas readback of the already-loaded texture and logs a console.warn in non-production builds. The fallback is correct but blocks the main thread on load; the sidecar is worth the asset-pipeline step for anything interactive.

Pass alpha: true to the loader. The loader probes for the sprites.alpha.png sibling, loads it if the hash matches, and attaches the result to sheet.alphaMap.

const sheet = await SpriteSheetLoader.load('/sprites/sprites.json', { alpha: true })
// sheet.alphaMap is now populated (AlphaMap instance)

For a Sprite2D, assign the map and switch the mode. For AnimatedSprite2D, the constructor and the spriteSheet setter both adopt sheet.alphaMap automatically — you only need to set hitTestMode.

const sprite = new Sprite2D({ texture: sheet.texture, frame: sheet.getFrame('knight_idle') })
sprite.alphaMap = sheet.alphaMap // wire up the CPU map
sprite.hitTestMode = 'alpha' // enable pixel-perfect rejection
sprite.alphaThreshold = 0.5 // optional — default is 0.5 (0–1)

alphaThreshold is a 0–1 value; pixels with alpha below it are treated as transparent for hit testing. The default of 0.5 works well for sprites with hard edges. Softer, anti-aliased outlines may benefit from a lower value like 0.1.

  • Rotated / trimmed atlas frames vs. alpha hit-testing — the renderer now honors both frame.rotated (Sprite2DMaterial/OcclusionPass unrotate 90°-packed frames per instance) and frame.trimmed (the trim rect is baked into the sprite's matrix), so rotated/trimmed atlases render correctly. The alpha sampler (AlphaMap.sampleFrame) hasn't caught up yet — it still maps sprite-local UV linearly through the frame's atlas rect with no 90° transpose and no trim-offset correction. So hitTestMode: 'alpha' can now sample the wrong atlas region for a rotated or trimmed frame even though it renders correctly. Full atlas-aware alpha sampling lands with the atlas overhaul (PR #117); until then, prefer hitTestMode: 'bounds' or disable rotation/trimming in your packer for sprites using alpha hit-testing.
  • Missing alphaMap fallback — if you set hitTestMode: 'alpha' without assigning an alphaMap, the sprite falls back to 'bounds' behaviour and logs a one-shot warning in development.

The raycast contract is the foundation; these build on it and are tracked separately:

  • Batched-sprite picking — interactivity for SpriteGroup/Flatland-batched sprites, once the orchestration overhaul makes them scene-graph citizens (#127, depends on #85).
  • FlatlandTexture — render flatland to a texture with pointer events through the portal compute seam (#126).
  • GPU ID-buffer picking — the high-instance-count Phase 2 path behind the same pick() signature (#128).
  • Drag helper — an ergonomic drag primitive composed from the pointer events you already get (#129).
  • Skia node hit-testing — path-accurate picking inside Skia surfaces via SkPath::contains in our WASM C API (#130).
  • Baked alpha sidecar schemameta.alpha atlas-JSON discovery, landing with the atlas schema work (#124).
  • Hit Testing Guide — the task recipes outside this example's context, including @pmndrs/pointer-events and the open-work roadmap
  • Sprites Guide — Sprite2D properties and layers
  • Loaders Guide — Spritesheet format and alpha sidecar
  • Tilemap — TileMap2D setup and layer access