Installation
npm install @react-three/jolt jolt-physics
That is the whole library. The character controller, camera rig, vehicles and input helpers ship in the same package, behind subpath imports — there is nothing extra to install:
import { Physics, RigidBody } from '@react-three/jolt';
import { useCommand } from '@react-three/jolt/addons';
import { CharacterController } from '@react-three/jolt/controllers';
Each subpath is its own bundle, so importing the root pulls no controller or addon code into your build.
These used to be the separate packages @react-three/jolt-addons and
@react-three/jolt-controllers. They were always released in lockstep with core, and keeping
them apart risked a second copy of core in the dependency tree — which breaks badly, because
core owns a single global handle on the WASM module. Both npm packages are deprecated; change
the import path and delete them from your package.json.
Peer dependencies
Nothing here is bundled — every one of these has to exist in your app, at these ranges:
| package | range | why |
|---|---|---|
react, react-dom | >=19.0.0 | @react-three/fiber 10 is currently tested against 19.2.x; that is the safest choice today. |
@react-three/fiber | >=10.0.0-0 | currently 10.0.0-alpha.5 |
three | >=0.185 | |
jolt-physics | >=1.1.0 | the engine itself |
npm install react@19.2.8 react-dom@19.2.8 \
three@^0.186.0 \
@react-three/fiber@10.0.0-alpha.5 \
jolt-physics@^1.1.0 \
@react-three/jolt
jolt-physics is a peer, not a dependency, on purpose: it keeps a second copy of a
multi-megabyte WASM module out of your bundle, and it lets you choose the
build variant yourself.
Your first scene
import { Canvas } from '@react-three/fiber';
import { Physics, RigidBody } from '@react-three/jolt';
import { Suspense } from 'react';
export function App() {
return (
<Canvas shadows camera={{ position: [0, 5, 12] }}>
<Suspense fallback={null}>
<Physics gravity={[0, -9.81, 0]}>
<RigidBody position={[0, 8, 0]}>
<mesh castShadow>
<boxGeometry args={[1, 1, 1]} />
<meshStandardMaterial color="hotpink" />
</mesh>
</RigidBody>
<RigidBody type="static" position={[0, -1, 0]}>
<mesh receiveShadow>
<boxGeometry args={[20, 1, 20]} />
<meshStandardMaterial color="#444" />
</mesh>
</RigidBody>
</Physics>
</Suspense>
<directionalLight castShadow position={[5, 10, 5]} />
<ambientLight intensity={0.4} />
</Canvas>
);
}
<Physics> suspends while the WASM module loads, so it needs a <Suspense> boundary above it —
see SSR & Suspense for the details and the failure modes.
Bundlers
By default the library imports jolt-physics, whose main entry is the wasm-compat build:
the WASM binary is base64-encoded inside the JavaScript, so there is no second file to serve and
no asset URL to configure. That is why most setups need nothing at all.
Vite
Works out of the box.
Next.js with Turbopack
Works out of the box with the default bundler, in both next dev and next build, App Router
included. Remember that a <Canvas> is client-only:
'use client';
import { Canvas } from '@react-three/fiber';
import { Physics } from '@react-three/jolt';
See SSR & Suspense for the full server-rendering story.
The webpack workaround
Only needed on the legacy webpack bundler (next dev --webpack / next build --webpack).
Turbopack, the default since Next 16, does not need any of this.
jolt-physics does a Node-only await import("node:module") that webpack still tries to resolve
statically for the browser target, which fails the build with:
UnhandledSchemeError: Reading from "node:module" is not handled by plugins (Unhandled scheme).
Import trace: node:module → ./node_modules/jolt-physics/dist/jolt-physics.wasm-compat.js
Rewrite the node: scheme away and stub the Node builtins for the browser bundle
(next.config.ts):
import type { NextConfig } from 'next';
const nextConfig: NextConfig = {
webpack: (config, { isServer, webpack }) => {
if (!isServer) {
config.plugins.push(
new webpack.NormalModuleReplacementPlugin(/^node:/, (resource: { request: string }) => {
resource.request = resource.request.replace(/^node:/, '');
})
);
config.resolve.fallback = {
...config.resolve.fallback,
module: false,
fs: false,
path: false,
url: false
};
}
return config;
}
};
export default nextConfig;
No experiments.asyncWebAssembly flag is needed: the .wasm is never imported as a module, it
is decoded at runtime. (#111)
Choosing a Jolt build
jolt-physics ships several flavours. Pass an initializer to <Physics module> to use one other
than the default:
| entry point | what it is |
|---|---|
jolt-physics / jolt-physics/wasm-compat | default — WASM embedded as base64 in the JS bundle |
jolt-physics/wasm | WASM as a separate .wasm file — smaller JS, one more asset to serve |
jolt-physics/debug-wasm-compat | the same, with Jolt's assertions and debug renderer enabled |
jolt-physics/wasm-multithread | multi-threaded; needs cross-origin isolation (COOP/COEP headers) |
jolt-physics/wasm-compat-multithread | multi-threaded, embedded |
jolt-physics/debug-wasm-compat-multithread | multi-threaded, embedded, debug |
jolt-physics/asm | asm.js fallback for environments without WASM |
import { Physics } from '@react-three/jolt';
import InitJolt from 'jolt-physics/wasm';
export function MultithreadedWorld({ children }: { children: React.ReactNode }) {
return <Physics module={InitJolt}>{children}</Physics>;
}
The /wasm recipe
The wasm flavour fetches jolt-physics.wasm.wasm at runtime, so it has to be able to find it.
With Vite, import the file as a URL and hand Jolt a locateFile:
import { Physics } from '@react-three/jolt';
import initJolt from 'jolt-physics/wasm';
import joltWasmUrl from 'jolt-physics/jolt-physics.wasm.wasm?url';
// `module` is called with no arguments, so wrap the initializer to pass options through
const loadJolt = () => initJolt({ locateFile: () => joltWasmUrl });
export function World({ children }: { children: React.ReactNode }) {
return <Physics module={loadJolt}>{children}</Physics>;
}
Define the wrapper outside the component (or in a useConst/useMemo). <Physics> keys
its suspend() cache on the value you pass, so a new function identity every render
re-initialises the engine.
Other bundlers have their own asset-URL syntax; the requirement is only that locateFile
returns a URL the browser can fetch. If you would rather not think about it, stay on the default
wasm-compat build.
Next steps
- Physics — the world and its props
- RigidBody — bodies and
BodyState - Contributing — running this repo locally