DEV Community

Cover image for I thought Vite broke my 3D physics. It actually taught me how tree-shaking really works.

I thought Vite broke my 3D physics. It actually taught me how tree-shaking really works.

In modern browser-based game development, every kilobyte matters. Larger JavaScript payloads increase download, parse, compile, and execution times, which can delay interactivity—especially on slower devices or networks.

To enforce a strict, lightweight architecture for our latest project at techaaroorian-games, we utilized Babylon's ES module packages (@babylonjs/core). For a modular application, this structure gives the bundler as much opportunity as possible to remove code the application doesn't actively use.

We set up Vite, TypeScript, Babylon.js, and the Havok WebAssembly physics engine.

Then, the engine crashed.
Error: `No Physics Engine available.`

Here is the debugging story of why our physics vanished, and what it teaches us about modern web bundlers.

1. The Simple Tree-Shaking Model

To understand why my game crashed, I had to look at how modern bundlers view our code. Vite uses Rolldown for its production builds—it doesn't read our minds; it reads the module graph.

  • Is the code reachable and needed? → Keep it.
  • Can the bundler safely determine it's unnecessary? → Remove it.

But what happens when a module doesn't export an API you need to call, but instead contains module-level registration code you need to run?

2. The Wasm Trap

My first hurdle wasn't even tree-shaking; it was dependency optimization. My terminal completely froze when I tried to start the development server.

@babylonjs/havok ships JavaScript alongside WebAssembly assets, and in my setup, Vite's dependency optimization stalled while processing the package.

I fixed it by telling Vite not to pre-bundle that dependency:

// vite.config.ts
export default defineConfig({
  optimizeDeps: { 
    exclude: ["@babylonjs/havok"] 
  },
});

Enter fullscreen mode Exit fullscreen mode

This lets Vite handle the package outside the dependency pre-bundling step.

With the server finally running, I expected to see my 3D sphere bouncing with gravity. Instead, I got a blank canvas and the fatal error: No Physics Engine available.

3. The "Aha!" Moment: Vite Didn't Break My Physics

Vite didn't break my physics. It exposed an architectural design I didn't know existed.

To keep base payloads lightweight, highly optimized frameworks separate their pure implementation logic from their runtime registration.

Because I only imported the core Scene and Mesh builders, I never directly referenced the physics module's exported API. My import graph didn't establish a need for that registration module to execute. Because its runtime registration wasn't reachable through the imports I had written, the production build could treat it as unnecessary.

The physics code wasn't missing because it was broken. It was missing because its only purpose was to execute registration code, and I didn't tell my bundler that mattered!

4. The Fix: Side-Effect Imports

To fix this, I had to learn the difference between asking a bundler for an API versus asking it for an action.

The Normal Import:

import { Mesh } from "@babylonjs/core";
// Translation: "I need to use the Mesh API."
// Bundler trace: API is used, preserve the module.

Enter fullscreen mode Exit fullscreen mode

The Side-Effect Import:

import "@babylonjs/core/Physics/physicsEngineComponent";
// Translation: "I don't need an exported API from this module, but I do need its module-level registration code to execute."

Enter fullscreen mode Exit fullscreen mode

By adding that single side-effect import, the module executes immediately upon loading. It performs the registration necessary for Babylon's physics functionality to be available at runtime, while still allowing the bundler to aggressively tree-shake everything else.

The Result

Marble in Canvas

The Big Lesson

As I continue building out techaaroorian-games, this completely changed how I look at build tools.

  • Static dependency → Bundler can trace it → Keep what's reachable
  • Module-level side effect → Execution itself matters → Make that dependency explicit

Tree-shaking isn't magic. It's static analysis. And once I understood that, "unused code" became a much more interesting concept. The bundler isn't trying to understand what my game means; it is analyzing what my module graph tells it.

(I am documenting my whole journey learning WebGPU and game dev. You can clone the full working engine setup we built today in the techaaroorian-games GitHub repository!)

Top comments (0)