Integrating Rapier Physics with TypeScript and Vite: A Complete Guide

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

  1. Don’t use fallback/placeholder systems – Fix the root cause
  2. Don’t use static imports – Always use dynamic imports for WASM
  3. Don’t forget Vite pluginsvite-plugin-wasm is essential
  4. Don’t assume sync initialization – WASM loading is always async
  5. 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

  1. Use dynamic imports for all WASM modules
  2. Configure Vite properly with WASM plugins
  3. Handle async initialization correctly
  4. Test both dev and production builds
  5. 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

Leave a Comment