Skip to content

R3F Interaction, Portfolio Patterns, and Games

A Three.js guide for coding agents. Also covers r3f pointer events, onClick mesh, onPointerEnter onPointerLeave, click through object, stopPropagation raycaster, cursor pointer on hover 3d, and 24 more.

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)
  }} />

Event performance

  • 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

Use this guidance in your coding agent

Install the Better Design MCP once. Your agent then loads this page with one call.

get-three-js-guide({ topic: "r3f-interaction-and-games" })
claude mcp add --scope user better-design --transport http https://better-design.com/api/mcp --header "Authorization: Bearer <YOUR_API_KEY>"
Browse related design systems