React Three Fiber
IgnitionAI is, above all, a framework for creative JavaScript developers. The majority of those developers work in React Three Fiber (R3F) — it’s the de-facto standard for building 3D scenes in the React ecosystem. This page exists because the integration is not an afterthought. It’s the first-class use case.
Why R3F-first?
Three reasons:
- R3F users already think in components and props. Your game world is already a tree of React components with local state. A
TrainingEnvclass is just one more component-adjacent concept — easy to add, easy to reason about. - The render loop is sacred. Dropping a single frame in a 3D scene looks bad. IgnitionAI’s training loop is decoupled from
requestAnimationFrameso your<Canvas>stays smooth even when the agent is crunching through a thousand training steps per second. - R3F is a gateway to Three.js internals. Your agent can observe arbitrary scene state — positions, velocities, raycasts, physics bodies — via the same
useRef/useFramehooks you already use for animation. There’s no second copy of the world state, no serialization layer.
If you’re building a creative 3D experience in React and you want a learning agent in it, this is the fast path.
The minimum viable example
Here’s a self-contained component that wires a TrainingEnv into an R3F <Canvas>:
import { Canvas } from '@react-three/fiber'
import { IgnitionEnvTFJS } from '@ignitionai/backend-tfjs'
import type { TrainingEnv } from '@ignitionai/core'
import { useEffect, useRef } from 'react'
import * as THREE from 'three'
// 1. Describe your world
class MyGameEnv implements TrainingEnv {
actions = ['left', 'right', 'noop']
// Shared state with the scene — owned by the env
playerX = 0
playerVx = 0
goalX = 0.8
observe(): number[] {
return [this.playerX, this.playerVx, this.goalX - this.playerX]
}
step(action: number): void {
const force = action === 0 ? -0.01 : action === 1 ? 0.01 : 0
this.playerVx += force
this.playerVx *= 0.95 // friction
this.playerX += this.playerVx
}
reward(): number {
return -Math.abs(this.goalX - this.playerX) // dense reward
}
done(): boolean {
return Math.abs(this.goalX - this.playerX) < 0.02 || Math.abs(this.playerX) > 1
}
reset(): void {
this.playerX = 0
this.playerVx = 0
this.goalX = Math.random() * 1.6 - 0.8
}
}
// 2. The scene reads from the env
function PlayerMesh({ env }: { env: MyGameEnv }) {
const ref = useRef<THREE.Mesh>(null!)
useFrame(() => {
// Every render, read the env state and update the mesh
ref.current.position.x = env.playerX
})
return (
<mesh ref={ref}>
<sphereGeometry args={[0.05]} />
<meshStandardMaterial color="#818CF8" />
</mesh>
)
}
function GoalMesh({ env }: { env: MyGameEnv }) {
const ref = useRef<THREE.Mesh>(null!)
useFrame(() => {
ref.current.position.x = env.goalX
})
return (
<mesh ref={ref}>
<sphereGeometry args={[0.04]} />
<meshStandardMaterial color="#F8FAFC" />
</mesh>
)
}
// 3. The training loop lives outside useFrame
function Game() {
const envRef = useRef<MyGameEnv>(new MyGameEnv())
const trainerRef = useRef<IgnitionEnvTFJS | null>(null)
useEffect(() => {
const trainer = new IgnitionEnvTFJS(envRef.current)
trainer.train('dqn')
trainer.setSpeed(10) // turbo: 10× faster than real-time
trainerRef.current = trainer
return () => trainer.stop()
}, [])
return (
<Canvas>
<ambientLight intensity={0.4} />
<pointLight position={[2, 2, 2]} />
<PlayerMesh env={envRef.current} />
<GoalMesh env={envRef.current} />
</Canvas>
)
}
import { useFrame } from '@react-three/fiber'
export default GameRun it and you’ll see the player sphere start moving randomly, then gradually lock onto the goal as the DQN agent learns the dense reward gradient.
Training loop vs render loop — the split
This is the single most important thing to understand about the R3F integration.
There are two loops running concurrently, on the same main thread, completely independent of each other:
The render loop (R3F)
- Driven by
requestAnimationFrame. - Runs at 60 fps (or whatever the display refresh rate is).
- Every frame,
useFramehooks execute, mesh positions update, React reconciles, Three.js renders. - Knows nothing about training.
The training loop (IgnitionAI)
- Driven by
setTimeoutwith a configurable interval (stepIntervalMs, default 50 ms). - Runs at its own pace.
- Every tick, calls
env.step()→env.observe()→agent.remember()→agent.train(). - Knows nothing about rendering.
They communicate through shared mutable state on the TrainingEnv instance. The step() method of your env mutates fields on this (this.playerX, this.playerVx, etc.). The useFrame hooks in your meshes read those same fields. Nothing fancier than that.
Why this works in practice:
- No race conditions. JavaScript is single-threaded. The training loop’s
setTimeoutcallback and the render loop’srequestAnimationFramecallback can never run simultaneously. One finishes before the other starts. - No frame drops. The training loop explicitly yields via
setTimeoutbetween batches of steps. The browser’s event loop then has a chance to runrequestAnimationFrameand render a frame. - Tunable tradeoff.
env.setSpeed(50)says “do 25 training steps per yield instead of 1.” Rendering slows down a bit but training runs 50× faster.env.setSpeed(1)restores smooth 60 fps rendering.
The mental model: your env is the source of truth. The scene is a view of it. The agent is a brain that mutates it.
Common pitfalls
Pitfall 1 — Creating a new env on every render
function Game() {
const env = new MyGameEnv() // ❌ New instance every render!
// ...
}Put it in a useRef or useMemo so it persists across renders:
const envRef = useRef<MyGameEnv>(new MyGameEnv()) // ✅ stable referencePitfall 2 — Mutating env state inside useFrame
useFrame(() => {
env.playerX += 0.01 // ❌ Your render loop is now "doing physics"
})useFrame is for reading env state and updating visuals. All mutation belongs in TrainingEnv.step(). Keep the responsibilities clean.
Pitfall 3 — Forgetting to stop() on unmount
useEffect(() => {
const trainer = new IgnitionEnvTFJS(envRef.current)
trainer.train('dqn')
// ❌ No cleanup — training loop leaks when component unmounts
}, [])Always return a cleanup that calls trainer.stop():
useEffect(() => {
const trainer = new IgnitionEnvTFJS(envRef.current)
trainer.train('dqn')
return () => trainer.stop() // ✅
}, [])Without this, navigating away from the page or hot-reloading the component will leave zombie training loops running in the background.
Live 3D demos
Two full R3F demos ship with the monorepo and show the pattern in production:
- CartPole 3D — classic cart-pole rendered in React Three Fiber with metallic materials, contact shadows, and a sunset environment. Same DQN agent as the 2D version, just prettier.
- Car Circuit — a 3D car learns to drive an oval circuit. Chase camera, track with lane markings and curbs, minimap, fading trail, 1×–50× speed slider. This is the closest thing IgnitionAI has to a “hero demo.”
Clone the repo and run either with:
pnpm --filter demo-cartpole-3d dev # http://localhost:3010
pnpm --filter demo-car-circuit dev # http://localhost:3020Both demos are open-source reference code. If you’re starting your own R3F project, copy the scene structure from whichever one is closest to what you want to build.
Previous: ← @ignitionai/storage · Next: Tutorials →