Show all 30 aliases
r3f pointer events, onClick mesh, onPointerEnter onPointerLeave, click through object, stopPropagation raycaster, cursor pointer on hover 3d, onPointerMissed, meshBounds, Bvh raycast performance, click events on gltf model, PresentationControls, drei Html iframe, website on laptop screen, how do I make a game in r3f, kinematic obstacle, setNextKinematicRotation, KeyboardControls, useKeyboardControls, WASD controls, jump only when grounded, raycast ground check rapier, zustand store game state, game phases state machine, subscribeWithSelector, procedural level generation, camera follow player lerp, timer scoring Date.now, restart reset rigid body, addEffect outside canvas, marble race
Pointer events: the core model
- R3F replaces manual Raycaster plumbing: put
onClick (or any pointer handler) directly on a <mesh> and it fires on hit. Works for touch too
- The event object mixes native and R3F data:
event.point (3D hit coordinates), event.uv, event.distance, event.object (the mesh actually hit), event.eventObject (the object the listener is on), event.x / event.y (screen), plus shiftKey / ctrlKey / metaKey for modifier-aware selection
- A click means down and up both on the object; R3F handles that bookkeeping
<mesh ref={cube} onClick={() => {
cube.current.material.color.set(`hsl(${Math.random() * 360}, 100%, 75%)`)
}}>
<boxGeometry />
<meshStandardMaterial color="mediumpurple" />
</mesh>
- Available handlers:
onClick, onContextMenu, onDoubleClick, onPointerDown, onPointerUp, onPointerOver / onPointerEnter (identical in R3F), onPointerOut / onPointerLeave, onPointerMove
onPointerMissed on the <Canvas> fires when a click hits nothing that listens, the natural place for deselect-all
<Canvas onPointerMissed={() => deselectAll()}>
Occlusion is opt-in
- The raycaster ignores objects in front by default: clicking "through" a sphere still triggers the cube behind it. Objects are ordered by distance and every listener fires unless one stops it
- Add a handler on the occluder that calls
event.stopPropagation(); everything behind it stays untriggered. Do it per event type: an onClick stop does not shield hover, add it to onPointerEnter too
<mesh position-x={-2}
onClick={(event) => event.stopPropagation()}
onPointerEnter={(event) => event.stopPropagation()}>
<sphereGeometry />
<meshStandardMaterial color="orange" />
</mesh>
Cursor feedback
- Signal clickability by swapping the body cursor on enter and leave. drei's
useCursor does the same thing if you prefer the helper
<mesh
onClick={eventHandler}
onPointerEnter={() => { document.body.style.cursor = 'pointer' }}
onPointerLeave={() => { document.body.style.cursor = 'default' }}>
Events on models
<primitive> accepts pointer handlers like any object, but the raycast tests all descendant meshes, so one click on a multi-part model fires the handler once per mesh the ray passes through. event.object tells you which part
event.stopPropagation() inside the handler keeps only the first (closest) hit
<primitive object={hamburger.scene} scale={0.25}
onClick={(event) => {
event.stopPropagation()
console.log(event.object.name)
}} />
- Pointer events cost CPU. The per-frame ones are the expensive family:
onPointerOver, onPointerEnter, onPointerOut, onPointerLeave, onPointerMove. Avoid them where a click works, minimize listening objects, avoid raycasting complex geometries
meshBounds from drei raycasts against a bounding sphere instead of the geometry: cheap, imprecise, single meshes only (not multi-mesh models)
<mesh raycast={meshBounds} onClick={eventHandler}>
- For accurate picking on complex geometry, wrap the app in
<Bvh> (in index, around <Experience>). One-time boundsTree generation per mesh (possible short freeze), then every raycast is fast
Portfolio scene pattern
- The recipe for a "website on a laptop screen" scene: dark
<color attach="background">, <Environment preset="city" /> for lighting (no manual lights), a CDN or public GLTF via useGLTF, <Float> for idle motion, <PresentationControls> instead of OrbitControls, <ContactShadows> for grounding, <Html> for the live iframe
Float with reduced rotationIntensity keeps embedded HTML readable while still feeling alive
<PresentationControls global rotation={[0.13, 0.1, 0]}
polar={[-0.4, 0.2]} azimuth={[-1, 0.75]}
config={{ mass: 2, tension: 400 }} snap>
<Float rotationIntensity={0.4}>
<primitive object={computer.scene} position-y={-1.2} />
</Float>
</PresentationControls>
<ContactShadows position-y={-1.4} opacity={0.4} scale={5} blur={2.4} />
PresentationControls
- Rotates the model, not the camera, and can spring back on release. Remove OrbitControls first; two control systems fight
global lets drags start anywhere, not just on the model. polar limits vertical rotation, azimuth horizontal, both as [min, max]. rotation sets the resting pose. config is the spring (mass, tension); snap returns to rest on release (and accepts its own spring config). damping trades reactivity for smoothness
- It uses use-gesture, which requires
touch-action: none on the element handling pointer events or mobile swipes fight browser gestures. Give the Canvas wrapper a class and set it in CSS
<Canvas className="r3f">
.r3f { touch-action: none; }
- Keep
ContactShadows outside the controls so the shadow does not rotate with the model
Html in 3D: the iframe screen
<Html> renders DOM that tracks a 3D point. transform makes it scale and skew with perspective as if part of the scene. Nest it inside the model's <primitive> so it inherits the model transform, then position it against the screen
<primitive object={computer.scene} position-y={-1.2}>
<Html transform wrapperClass="htmlScreen" distanceFactor={1.17}
position={[0, 1.56, -1.4]} rotation-x={-0.256}>
<iframe src="https://example.com/" />
</Html>
</primitive>
- Size in two steps: give the iframe a real CSS resolution so its content lays out correctly, then shrink the whole element in-scene with
distanceFactor. wrapperClass is your CSS hook
.htmlScreen iframe {
width: 1024px; height: 670px;
border: none; border-radius: 20px; background: #000000;
}
- The iframe sits on top of the canvas: WebGL cannot draw over it, so screen reflections would have to be HTML inside the
<Html>
- Sell the glow with an emissive-colored
<rectAreaLight> at the screen position, facing the keyboard
<Text> (SDF text, takes font as woff, fontSize, maxWidth, textAlign) adds labels; it lives in the 3D scene so the iframe will cover it where they overlap
Game architecture overview
- The marble-race stack:
@react-three/rapier for physics, drei KeyboardControls for input, zustand for global game state, useFrame for per-frame forces and camera, HTML overlay for UI
- Wrap everything physical in one
<Physics debug> at the Experience level; flip debug off when tuning is done
Level building
- Share one module-scope
boxGeometry (unit cube) and a few MeshStandardMaterials across all blocks; shape each mesh with scale, not per-block geometries
const boxGeometry = new THREE.BoxGeometry(1, 1, 1)
const floor1Material = new THREE.MeshStandardMaterial({ color: 'limegreen' })
const obstacleMaterial = new THREE.MeshStandardMaterial({ color: 'orangered' })
<mesh geometry={boxGeometry} material={floor1Material}
position={[0, -0.1, 0]} scale={[4, 0.2, 4]} receiveShadow />
- One component per block type (
BlockStart, BlockSpinner, BlockLimbo, BlockAxe, BlockEnd), each taking a position prop applied to a wrapping <group>
Kinematic obstacles
- Moving traps are
<RigidBody type="kinematicPosition">: they follow scripted motion, push the player, and are never pushed back. Drive them each frame with setNextKinematicRotation (takes a quaternion, build it from a Euler) or setNextKinematicTranslation
const obstacle = useRef()
const [speed] = useState(() => (Math.random() + 0.2) * (Math.random() < 0.5 ? -1 : 1))
useFrame((state) => {
const time = state.clock.getElapsedTime()
const rotation = new THREE.Quaternion()
rotation.setFromEuler(new THREE.Euler(0, time * speed, 0))
obstacle.current.setNextKinematicRotation(rotation)
})
setNextKinematicTranslation uses absolute world coordinates: it ignores the parent group's position. Add the block's position prop back in yourself
const [timeOffset] = useState(() => Math.random() * Math.PI * 2)
useFrame((state) => {
const time = state.clock.getElapsedTime()
const y = Math.sin(time + timeOffset) + 1.15
obstacle.current.setNextKinematicTranslation(
{ x: position[0], y: position[1] + y, z: position[2] })
})
- Per-instance variety comes from a
useState initializer function: a random speed (sign-flipped half the time) or a random timeOffset in [0, 2 PI], computed once and stable across re-renders
Procedural level generation
- Pick
count random block components from a types array inside useMemo, keyed by [count, types, seed]. Render them with map, position by index
export function Level({ count = 5, types = [BlockSpinner, BlockAxe, BlockLimbo], seed = 0 }) {
const blocks = useMemo(() => {
const blocks = []
for (let i = 0; i < count; i++)
blocks.push(types[Math.floor(Math.random() * types.length)])
return blocks
}, [count, types, seed])
return <>
<BlockStart position={[0, 0, 0]} />
{blocks.map((Block, index) =>
<Block key={index} position={[0, 0, -(index + 1) * 4]} />)}
<BlockEnd position={[0, 0, -(count + 1) * 4]} />
</>
}
- Capitalize the mapped variable (
Block) so JSX treats it as a component. The seed dependency exists only to force regeneration on restart: bump it to a new Math.random() in the store and the memo reruns
- Bounds: one
type="fixed" RigidBody containing both side walls, the end wall, and a manual <CuboidCollider> for the entire floor (auto colliders only cover meshes; the floor tiles are visual meshes outside this body). Floor friction={1} so torque rolls the ball; walls and obstacles friction={0} so it never sticks
- Loading a model into a block:
colliders="hull" on a fixed RigidBody fits a convex collider to the mesh; set castShadow on the model's meshes with a forEach over scene.children
Player body
- The player is a small icosahedron in a
<RigidBody colliders="ball">. flatShading makes the rotation visible
- Critical attributes:
canSleep={false} (a sleeping body ignores your impulses after a few idle seconds), linearDamping and angularDamping around 0.5 so it slows on its own, restitution={0.2}, friction={1}
<RigidBody ref={body} canSleep={false} colliders="ball"
restitution={0.2} friction={1}
linearDamping={0.5} angularDamping={0.5} position={[0, 1, 0]}>
<mesh castShadow>
<icosahedronGeometry args={[0.3, 1]} />
<meshStandardMaterial flatShading color="mediumpurple" />
</mesh>
</RigidBody>
KeyboardControls
- Wrap at the top level (around Canvas AND the HTML interface) so every consumer sees the keys.
map names logical actions and lists the physical keys, using key codes (KeyW) not characters (w) so AZERTY and friends work
<KeyboardControls map={[
{ name: 'forward', keys: ['ArrowUp', 'KeyW'] },
{ name: 'backward', keys: ['ArrowDown', 'KeyS'] },
{ name: 'leftward', keys: ['ArrowLeft', 'KeyA'] },
{ name: 'rightward', keys: ['ArrowRight', 'KeyD'] },
{ name: 'jump', keys: ['Space'] },
]}>
<Canvas>...</Canvas>
<Interface />
</KeyboardControls>
useKeyboardControls() returns [subscribeKeys, getKeys]. Poll getKeys() in useFrame for continuous movement; subscribeKeys(selector, handler) for edge-triggered actions like jump
- Apply both a push (
applyImpulse) and a roll (applyTorqueImpulse), scaled by delta so frame rate does not change speed
useFrame((state, delta) => {
const { forward, backward, leftward, rightward } = getKeys()
const impulse = { x: 0, y: 0, z: 0 }
const torque = { x: 0, y: 0, z: 0 }
const impulseStrength = 0.6 * delta
const torqueStrength = 0.2 * delta
if (forward) { impulse.z -= impulseStrength; torque.x -= torqueStrength }
if (backward) { impulse.z += impulseStrength; torque.x += torqueStrength }
if (leftward) { impulse.x -= impulseStrength; torque.z += torqueStrength }
if (rightward) { impulse.x += impulseStrength; torque.z -= torqueStrength }
body.current.applyImpulse(impulse)
body.current.applyTorqueImpulse(torque)
})
Jump with a ground check
- Gate the jump on distance to the ground or holding Space flies to infinity. Cast a rapier ray straight down from just under the sphere and read
timeOfImpact
- Get rapier through
useRapier() (never import @dimforge/rapier3d-compat directly, it is a transitive dependency you do not own). Pass true as the solid flag to castRay or a ray starting inside the floor collider reports the far side
const { rapier, world } = useRapier()
const jump = () => {
const origin = body.current.translation()
origin.y -= 0.31
const ray = new rapier.Ray(origin, { x: 0, y: -1, z: 0 })
const hit = world.castRay(ray, 10, true)
if (hit.timeOfImpact < 0.15)
body.current.applyImpulse({ x: 0, y: 0.5, z: 0 })
}
useEffect(() => {
const unsubscribeJump = subscribeKeys(
(state) => state.jump,
(value) => { if (value) jump() })
return () => unsubscribeJump()
}, [])
- Always return the unsubscribe from the effect. A hot-reloaded component that never unsubscribed stacks handlers and jumps twice as high, the classic symptom
Camera follow
- Animate the camera in the player's
useFrame: build target position and lookAt from the body translation, then lerp persistent vectors toward them so the camera trails smoothly. Multiply the lerp factor by delta for frame-rate independence
- Keep the smoothed vectors in
useState(() => new THREE.Vector3()) so they persist per component instance; seed the position far away for a nice fly-in on load
const [smoothedCameraPosition] = useState(() => new THREE.Vector3(10, 10, 10))
const [smoothedCameraTarget] = useState(() => new THREE.Vector3())
useFrame((state, delta) => {
const bodyPosition = body.current.translation()
const cameraPosition = new THREE.Vector3()
cameraPosition.copy(bodyPosition)
cameraPosition.z += 2.25
cameraPosition.y += 0.65
const cameraTarget = new THREE.Vector3()
cameraTarget.copy(bodyPosition)
cameraTarget.y += 0.25
smoothedCameraPosition.lerp(cameraPosition, 5 * delta)
smoothedCameraTarget.lerp(cameraTarget, 5 * delta)
state.camera.position.copy(smoothedCameraPosition)
state.camera.lookAt(smoothedCameraTarget)
})
- Shadows die past the shadow camera's box on a long level. Instead of a giant blurry shadow map, move the directional light with the camera each frame, and move its
target too, then call target.updateMatrixWorld() because the target is not in the scene so nothing updates its matrix for you. Bias both forward so no map is wasted behind the camera
useFrame((state) => {
light.current.position.z = state.camera.position.z + 1 - 4
light.current.target.position.z = state.camera.position.z - 4
light.current.target.updateMatrixWorld()
})
Zustand store and game phases
- Cross-component game state (interface button resets the player's body) lives in a zustand store, created in
stores/useGame.jsx. Wrap the creator in subscribeWithSelector so components can subscribe to individual fields
import { create } from 'zustand'
import { subscribeWithSelector } from 'zustand/middleware'
export default create(subscribeWithSelector((set) => ({
blocksCount: 10,
blocksSeed: 0,
startTime: 0,
endTime: 0,
phase: 'ready',
start: () => set((state) =>
state.phase === 'ready' ? { phase: 'playing', startTime: Date.now() } : {}),
restart: () => set((state) =>
state.phase === 'playing' || state.phase === 'ended'
? { phase: 'ready', blocksSeed: Math.random() } : {}),
end: () => set((state) =>
state.phase === 'playing' ? { phase: 'ended', endTime: Date.now() } : {}),
})))
- The phase machine is
ready -> playing -> ended -> ready. Guard every transition inside set against the current phase, because the triggers fire repeatedly (any keydown calls start, the end check runs every frame)
- Read with narrow selectors,
useGame((state) => state.phase), never the whole state; a broad selector re-renders on every change. KeyboardControls is zustand under the hood, same selector rules
- Trigger transitions from gameplay:
subscribeKeys(() => start()) for any key, and in useFrame compare bodyPosition.z against the level length for end() and bodyPosition.y < -4 for falling into restart()
- React to a phase change with
useGame.subscribe(selector, handler) in a useEffect, unsubscribed in cleanup. On 'ready', reset the body fully: position AND both velocities
const reset = () => {
body.current.setTranslation({ x: 0, y: 1, z: 0 })
body.current.setLinvel({ x: 0, y: 0, z: 0 })
body.current.setAngvel({ x: 0, y: 0, z: 0 })
}
useEffect(() => {
const unsubscribeReset = useGame.subscribe(
(state) => state.phase,
(value) => { if (value === 'ready') reset() })
return () => unsubscribeReset()
}, [])
Time-based scoring
- Never write elapsed time into the store per frame; store only
startTime (set in start) and endTime (set in end) via Date.now(), and derive the display
- The HTML interface cannot use
useFrame (outside the Canvas). Use R3F's addEffect in a useEffect, which ticks in sync with the frame loop, read the store non-reactively with useGame.getState(), and write the DOM through a ref so nothing re-renders
useEffect(() => {
const unsubscribeEffect = addEffect(() => {
const state = useGame.getState()
let elapsedTime = 0
if (state.phase === 'playing') elapsedTime = Date.now() - state.startTime
else if (state.phase === 'ended') elapsedTime = state.endTime - state.startTime
if (time.current) time.current.textContent = (elapsedTime / 1000).toFixed(2)
})
return () => unsubscribeEffect()
}, [])
Interface overlay
- Fixed full-viewport
<div> over the canvas with pointer-events: none, re-enabled (pointer-events: auto) only on the restart button
- Restart button renders conditionally and calls the store action straight from
onClick
{phase === 'ended' && <div className="restart" onClick={restart}>Restart</div>}
- On-screen key hints use one
useKeyboardControls selector per key so only the touched key's element re-renders, toggling an active class
const forward = useKeyboardControls((state) => state.forward)
<div className={`key ${forward ? 'active' : ''}`}></div>
Common mistakes
- Expecting front objects to block clicks automatically; occlusion needs an explicit
stopPropagation handler per event type
- Handling one hit per click on a model without
stopPropagation, so the handler fires once per intersected mesh
- Using
meshBounds on a multi-mesh model; it only works on single meshes
- Leaving OrbitControls mounted alongside PresentationControls or a custom camera
- Skipping
touch-action: none, making mobile drags trigger browser gestures
- Character key names (
'w') in the KeyboardControls map, breaking non-QWERTY layouts
- Forces not scaled by
delta, so speed varies with frame rate; same for un-delta'd lerp factors
- Forgetting
canSleep={false}, then debugging why the ball stops responding after sitting still
- Jump without a ground-distance check, or
castRay without the solid flag so the ray tunnels through the floor
- Not unsubscribing
subscribeKeys, store subscriptions, or addEffect in effect cleanup, stacking handlers on hot reload
setNextKinematicTranslation with local coordinates, leaving the obstacle at the world origin when the block moves
- Unguarded store transitions, letting a per-frame
end() or repeated keydown start() rewrite state constantly
- Selecting the whole zustand state in a component, re-rendering on every store change
- Writing per-frame elapsed time into the store instead of deriving it from start and end timestamps
- Resetting the player position but not linear and angular velocity, so it respawns still moving