What if you could write games in plain HTML/JavaScript, but with Unity's physics, particles, navigation, and audio working under the hood?
A single pre-compiled Unity WebGL build sits on a CDN. Your game is just a <script> tag. You write JavaScript that feels like Unity's API, and a bridge layer translates your calls into the real thing.
<script src="https://cdn.example.com/unity-bridge.js"></script>
<script>
const engine = new UnityBridge("canvas", "https://cdn.example.com/unity-shell.wasm");
engine.onReady(() => {
const player = engine.createPrimitive("Capsule", { position: [0, 1, 0] });
player.addComponent("Rigidbody", { mass: 1 });
player.addComponent("CapsuleCollider");
engine.input.onKeyDown("Space", () => {
player.getComponent("Rigidbody").addForce([0, 10, 0], "impulse");
});
});
</script>The Unity build is generic—a "shell" that exposes its runtime capabilities to JavaScript. Your game logic lives entirely in JS.
Batteries included. Unity bundles PhysX, spatial audio, a particle system, navmesh pathfinding, animation state machines. In Three.js or Babylon, you're integrating separate libraries for each of these and hoping they play nice together.
The component model. Unity's GameObject/Component pattern is a proven way to organize game code. Thousands of tutorials teach it. "Add a Rigidbody" is one line that does a lot.
LLM-friendly. This is the interesting part. For vibe-coding game prototypes, LLMs have seen vastly more Unity code than Three.js game code. The API mirrors Unity's mental model closely enough that an LLM could riff on a prototype with minimal instruction—and a working prototype could translate smoothly into a "real" Unity project later.
The bridge preserves Unity's core concepts:
GameObjects and Components
const enemy = engine.createGameObject("Enemy");
enemy.addComponent("Rigidbody", { mass: 1, useGravity: true });
enemy.addComponent("BoxCollider", { size: [1, 2, 1] });
enemy.addComponent("MeshRenderer", { mesh: "capsule", material: "red" });
enemy.transform.position = [0, 5, 0];
enemy.transform.parent = spawner.transform;Finding objects
const player = engine.find("Player");
const enemies = engine.findWithTag("Enemy");
const rb = player.getComponent("Rigidbody");Custom components in pure JS
engine.defineComponent("Spin", {
speed: 50,
update(dt) {
this.transform.rotate([0, this.speed * dt, 0]);
},
onCollisionEnter(collision) {
if (collision.gameObject.tag === "Bullet") {
this.gameObject.destroy();
}
}
});
cube.addComponent("Spin", { speed: 100 });The JS component definition looks like a Unity MonoBehaviour. this.transform, this.gameObject, and this.getComponent() work as expected.
On the Unity side, custom JS components are realized as a constellation of small MonoBehaviours:
GameObject "Enemy"
├── JSCore (lifecycle: awake, start, onDestroy)
├── JSUpdate (only if update() defined)
└── JSCollision (only if collision handlers defined)
Each MonoBehaviour is minimal—it just shuttles callbacks to JavaScript:
public class JSUpdate : MonoBehaviour
{
public int jsId;
void Update() => JSBridge.Invoke(jsId, "update", Time.deltaTime);
}
public class JSCollision : MonoBehaviour
{
public int jsId;
void OnCollisionEnter(Collision c) => JSBridge.InvokeCollision(jsId, "onCollisionEnter", c);
void OnCollisionStay(Collision c) => JSBridge.InvokeCollision(jsId, "onCollisionStay", c);
void OnCollisionExit(Collision c) => JSBridge.InvokeCollision(jsId, "onCollisionExit", c);
}If your JS component doesn't define update(), the JSUpdate MonoBehaviour isn't attached. No overhead for unused callbacks.
The JS bridge inspects component definitions at registration time and tells Unity which satellite behaviours to create:
engine.defineComponent = function(name, definition) {
const callbacks = {
hasUpdate: typeof definition.update === 'function',
hasFixedUpdate: typeof definition.fixedUpdate === 'function',
hasCollision: ['onCollisionEnter', 'onCollisionStay', 'onCollisionExit']
.some(m => typeof definition[m] === 'function'),
// ...
};
componentRegistry[name] = { definition, callbacks };
};Built-in Unity components (Rigidbody, Collider, AudioSource, Light, etc.) are exposed through reflection, with link.xml or [Preserve] attributes preventing IL2CPP from stripping them.
<!DOCTYPE html>
<html>
<head>
<style>
body { margin: 0; }
#score {
position: absolute;
top: 20px;
left: 20px;
font: bold 48px system-ui;
color: white;
text-shadow: 0 2px 4px rgba(0,0,0,0.5);
}
</style>
</head>
<body>
<div id="score">0</div>
<canvas id="game"></canvas>
<script src="unity-bridge.js"></script>
<script>
let score = 0;
const engine = new UnityBridge("game", "unity-shell.wasm");
engine.onReady(() => {
// Scene
engine.setGravity([0, -20, 0]);
engine.setSkybox("gradient", { top: "#4a90d9", bottom: "#1a1a2e" });
const camera = engine.find("Main Camera");
camera.transform.position = [0, 8, -12];
camera.transform.lookAt([0, 2, 0]);
// Platform
const platform = engine.createPrimitive("Cube", {
position: [0, 0, 0],
scale: [10, 0.5, 10],
material: { color: "#2a2a3a", metallic: 0.8 }
});
platform.addComponent("BoxCollider");
// Spawn blocks
for (let x = -2; x <= 2; x++) {
for (let z = -2; z <= 2; z++) {
for (let y = 0; y < 3; y++) {
const block = engine.createPrimitive("Cube", {
position: [x * 1.1, 1 + y * 1.1, z * 1.1],
material: { color: `hsl(${Math.random() * 360}, 70%, 60%)` }
});
block.addComponent("Rigidbody", { mass: 1 });
block.addComponent("BoxCollider");
block.addComponent("Block");
}
}
}
// Click to shoot
engine.input.onMouseDown(0, (mousePos) => {
const ray = camera.getComponent("Camera").screenPointToRay(mousePos);
const ball = engine.createPrimitive("Sphere", {
position: ray.origin,
scale: [0.5, 0.5, 0.5],
material: { color: "#ffaa00", emissive: "#ff6600" }
});
ball.addComponent("Rigidbody", { mass: 2 });
ball.addComponent("SphereCollider");
ball.getComponent("Rigidbody").velocity = ray.direction.multiply(40);
engine.playSound("shoot");
});
});
engine.defineComponent("Block", {
start() {
this.fallen = false;
},
update(dt) {
if (!this.fallen && this.transform.position[1] < -2) {
this.fallen = true;
score += 100;
document.getElementById("score").textContent = score;
engine.spawnParticles("confetti", this.transform.position);
engine.playSound("score");
}
}
});
</script>
</body>
</html>This is ~80 lines for a complete physics-based game with particles and audio. The Three.js equivalent would require integrating a physics library, setting up a game loop, and significantly more boilerplate.
| Unity Bridge | Three.js / Babylon | |
|---|---|---|
| Bundle size | 8-15MB (cached on CDN) | 150KB - 1MB |
| First load | Slow (Wasm compile + init) | Fast |
| Physics | Built-in, battle-tested PhysX | BYO (Cannon, Rapier, Ammo) |
| Pathfinding | Built-in NavMesh | BYO, limited options |
| Particles | Full-featured Shuriken | Basic or custom shaders |
| Debugging | Mixed JS/Wasm, harder | Pure JS, browser devtools |
| Custom shaders | Limited by what shell exposes | Full GLSL access |
| Mobile | Historically rough | Better graceful degradation |
| LLM training data | Abundant Unity tutorials | Less game-specific code |
The Unity approach makes sense when you're building something game-shaped—physics, collisions, AI, audio, effects all working together. If you need a product configurator or data visualization, Three.js is the right tool.
If a pre-compiled Unity shell can be cached once and reused across projects, the bundle size concern fades. What remains is a genuinely higher-level abstraction for game development in the browser—one that LLMs can leverage effectively because it maps onto a huge corpus of existing Unity knowledge.
The open question is whether the abstraction holds or leaks badly in practice. But it seems worth prototyping.