Skip to content

Instantly share code, notes, and snippets.

@rndmcnlly
Created January 15, 2026 05:02
Show Gist options
  • Select an option

  • Save rndmcnlly/3dafb104d6fa655a46fd929fea19ad31 to your computer and use it in GitHub Desktop.

Select an option

Save rndmcnlly/3dafb104d6fa655a46fd929fea19ad31 to your computer and use it in GitHub Desktop.

Unity as a JavaScript Game Engine

The Idea

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.

Why?

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 API

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.

Architecture

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.

Example: A Complete Mini-Game

<!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.

Tradeoffs vs Three.js / Babylon

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.

The Bet

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.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment