How to properly integrate WASM-based physics into modern web games without falling into common pitfalls
Introduction
When building RedactedProjectName, a game with TypeScript and Three.js, I encountered significant challenges integrating Rapier physics. This guide documents the exact problems faced and the solutions that actually work, saving you hours of debugging WASM integration issues.
The Challenge: WASM in Modern Web Development
Rapier is a powerful physics engine that compiles to WebAssembly (WASM) for performance. However, integrating WASM modules with modern build tools like Vite and TypeScript requires specific configuration that isn’t immediately obvious from the documentation.
What We’re Building
- Game Engine: TypeScript + Three.js + Rapier Physics
- Build Tool: Vite 4.x
- Target: Both development and production builds
- Requirements: Dynamic imports, proper WASM loading, TypeScript support
Problem 1: The Fallback Trap
❌ Wrong Approach: Creating placeholder/fallback systems
When I first encountered WASM loading issues, my instinct was to create a placeholder physics system and defer the “real” integration. This is a common anti-pattern that obscures the root cause.
// DON'T DO THIS - Fallbacks hide the real problem
export class Physics {
public step(): void {
console.log('Physics placeholder - will implement later');
// This never gets properly implemented
}
}
✅ Right Approach: Address the root cause immediately
The real issue wasn’t complexity—it was missing Vite configuration for WASM handling.
Problem 2: Incorrect Import Patterns
❌ Wrong Approach: Static imports
// This fails in Vite with WASM modules
import RAPIER from '@dimforge/rapier3d';
export class Physics {
constructor() {
// This will throw errors about WASM loading
const world = new RAPIER.World({ x: 0, y: -9.81, z: 0 });
}
}
✅ Right Approach: Dynamic imports with proper async handling
// This works correctly
export class Physics {
private RAPIER: typeof import('@dimforge/rapier3d') | null = null;
private world: import('@dimforge/rapier3d').World | null = null;
constructor() {
this.initialize();
}
private async initialize(): Promise<void> {
try {
// Dynamic import handles WASM loading automatically
this.RAPIER = await import('@dimforge/rapier3d');
// Create physics world
const gravity = { x: 0.0, y: -9.81, z: 0.0 };
this.world = new this.RAPIER.World(gravity);
console.log('⚡ Physics initialized successfully');
} catch (error) {
console.error('Failed to initialize physics:', error);
}
}
}
Problem 3: Missing Vite Configuration
The critical missing piece was proper Vite configuration for WASM handling.
Required Dependencies
npm install --save-dev vite-plugin-wasm vite-plugin-top-level-await
Vite Configuration
// vite.config.ts
import { defineConfig } from 'vite';
import wasm from 'vite-plugin-wasm';
import topLevelAwait from 'vite-plugin-top-level-await';
export default defineConfig({
plugins: [
wasm(), // Handles WASM file loading
topLevelAwait() // Enables top-level await for WASM
],
build: {
rollupOptions: {
output: {
manualChunks: {
'physics': ['@dimforge/rapier3d'], // Separate chunk for physics
}
}
}
},
optimizeDeps: {
exclude: [
'@dimforge/rapier3d' // Don't pre-bundle WASM modules
]
}
});
Problem 4: TypeScript Type Handling
❌ Wrong Approach: Using any
types everywhere
// Loses all type safety
private world: any = null;
private bodies: Map<string, any> = new Map();
✅ Right Approach: Proper TypeScript integration
// Maintains full type safety
type RAPIER = typeof import('@dimforge/rapier3d');
type World = import('@dimforge/rapier3d').World;
type RigidBody = import('@dimforge/rapier3d').RigidBody;
export class Physics {
private RAPIER: RAPIER | null = null;
private world: World | null = null;
private bodies: Map<string, RigidBody> = new Map();
public createDynamicBody(
id: string,
position: THREE.Vector3,
shape: 'box' | 'sphere',
size: THREE.Vector3 | number
): RigidBody | null {
if (!this.world || !this.RAPIER) return null;
const bodyDesc = this.RAPIER.RigidBodyDesc.dynamic()
.setTranslation(position.x, position.y, position.z);
let colliderDesc: import('@dimforge/rapier3d').ColliderDesc;
switch (shape) {
case 'box':
const boxSize = size as THREE.Vector3;
colliderDesc = this.RAPIER.ColliderDesc.cuboid(
boxSize.x / 2, boxSize.y / 2, boxSize.z / 2
);
break;
case 'sphere':
const radius = size as number;
colliderDesc = this.RAPIER.ColliderDesc.ball(radius);
break;
}
const rigidBody = this.world.createRigidBody(bodyDesc);
this.world.createCollider(colliderDesc, rigidBody);
this.bodies.set(id, rigidBody);
return rigidBody;
}
}
Problem 5: Development vs Production Differences
One of the most frustrating aspects of WASM integration is that development and production builds behave differently.
Development Build Behavior
- WASM files are served directly by Vite dev server
- Hot reload can break WASM module state
- Console may show WASM loading warnings (usually safe to ignore)
- Slower initial load due to non-optimized WASM
Production Build Behavior
- WASM files are properly bundled and optimized
- Faster loading and execution
- More reliable WASM module initialization
- Better error handling
Testing Both Environments
# Test development
npm run dev
# Test production build
npm run build
npm run preview
Important: Always test your WASM integration in production mode before deploying!
Problem 6: Initialization Timing
❌ Wrong Approach: Assuming synchronous initialization
// This fails because physics isn't ready yet
constructor() {
this.physics = new Physics();
this.createPhysicsObjects(); // ERROR: Physics not initialized
}
✅ Right Approach: Proper async initialization handling
export class Engine {
private setupPhysicsDemo(): void {
const checkPhysics = () => {
if (this.physics.isReady()) {
this.scene.createPhysicsCube(this.physics);
console.log('Physics demo ready!');
} else {
// Check again in 100ms
setTimeout(checkPhysics, 100);
}
};
checkPhysics();
}
}
export class Physics {
public isReady(): boolean {
return this.isInitialized && this.world !== null;
}
}
Complete Working Example
Here’s a minimal but complete example that demonstrates all the concepts:
package.json dependencies
{
"dependencies": {
"@dimforge/rapier3d": "^0.11.2",
"three": "^0.158.0"
},
"devDependencies": {
"vite": "^4.4.5",
"vite-plugin-wasm": "^3.5.0",
"vite-plugin-top-level-await": "^1.6.0",
"typescript": "^5.0.2"
}
}
Physics.ts
import * as THREE from 'three';
type RAPIER = typeof import('@dimforge/rapier3d');
type World = import('@dimforge/rapier3d').World;
type RigidBody = import('@dimforge/rapier3d').RigidBody;
export class Physics {
private RAPIER: RAPIER | null = null;
private world: World | null = null;
private isInitialized = false;
constructor() {
this.initialize();
}
private async initialize(): Promise<void> {
try {
this.RAPIER = await import('@dimforge/rapier3d');
const gravity = { x: 0.0, y: -9.81, z: 0.0 };
this.world = new this.RAPIER.World(gravity);
this.isInitialized = true;
console.log('⚡ Physics initialized');
} catch (error) {
console.error('Physics initialization failed:', error);
}
}
public isReady(): boolean {
return this.isInitialized && this.world !== null;
}
public step(): void {
if (this.world) {
this.world.step();
}
}
public createDynamicBox(
position: THREE.Vector3,
size: THREE.Vector3
): RigidBody | null {
if (!this.world || !this.RAPIER) return null;
const bodyDesc = this.RAPIER.RigidBodyDesc.dynamic()
.setTranslation(position.x, position.y, position.z);
const colliderDesc = this.RAPIER.ColliderDesc.cuboid(
size.x / 2, size.y / 2, size.z / 2
);
const rigidBody = this.world.createRigidBody(bodyDesc);
this.world.createCollider(colliderDesc, rigidBody);
return rigidBody;
}
}
Build Results
When properly configured, you should see output like this:
✓ 280 modules transformed.
dist/assets/rapier_wasm3d_bg-a8e9a6c4.wasm 1,409.61 kB
dist/assets/physics-8c074953.js 145.88 kB │ gzip: 23.97 kB
dist/assets/three-f2ff3508.js 543.34 kB │ gzip: 121.41 kB
✓ built in 1.32s
The key indicators of success:
- ✅ WASM file is included in build output
- ✅ Physics code is in separate chunk
- ✅ No build errors or warnings
- ✅ Reasonable file sizes with gzip compression
Common Pitfalls to Avoid
- Don’t use fallback/placeholder systems – Fix the root cause
- Don’t use static imports – Always use dynamic imports for WASM
- Don’t forget Vite plugins –
vite-plugin-wasm
is essential - Don’t assume sync initialization – WASM loading is always async
- Don’t skip production testing – Dev and prod behave differently
Debugging Tips
Check WASM Loading
// Add this to verify WASM is loading
private async initialize(): Promise<void> {
console.log('Starting Rapier initialization...');
try {
const start = performance.now();
this.RAPIER = await import('@dimforge/rapier3d');
const loadTime = performance.now() - start;
console.log(`Rapier loaded in ${loadTime.toFixed(2)}ms`);
this.world = new this.RAPIER.World({ x: 0, y: -9.81, z: 0 });
console.log('Physics world created successfully');
} catch (error) {
console.error('Detailed error:', error);
console.error('Error stack:', error.stack);
}
}
Network Tab Verification
In browser dev tools, check that:
- WASM file loads without 404 errors
- File size is reasonable (~1.4MB for Rapier)
- Loading time is acceptable for your use case
Conclusion
Integrating Rapier physics with TypeScript and Vite requires specific configuration, but once properly set up, it provides excellent performance and developer experience. The key is avoiding fallback patterns and addressing WASM integration directly with the right tools.
Key Takeaways
- Use dynamic imports for all WASM modules
- Configure Vite properly with WASM plugins
- Handle async initialization correctly
- Test both dev and production builds
- Maintain TypeScript safety throughout
With these patterns, you can confidently integrate Rapier physics into any TypeScript web project without the common pitfalls that plague WASM integration.
Have questions or improvements? Email me at andy@greenrobot.com