2025-10-08

🚀 3D Missile Command on Colyseus

plan
#babylonjs
#yuka
#colyseus
#portfolio

🚀 3D Missile Command on Colyseus

J.Gong

2025-10-08

4.38min

🚀 3D Missile Command on Colyseus

Last year, I created a 3D Missile Command game using p5.js for my Digital Graphics class. This time, for the Game AI class, I decided to upgrade it into a 1v1 Battle version ⚔️.

However, since p5.js is essentially a subset of the Processing language, extending the project came with limitations from both Processing and JavaScript. So I decided to rebuild it entirely in Babylon.js 🧱.

Thanks to the ECS paradigm and Cursor, the transition from p5.js to Babylon.js wasn’t too painful. I started with Babylon’s webpack example and swapped the bundler to Rspack ⚙️.

The AI assistant (Sonnet 3.5) got stuck handling mouse events 🐭. In the original p5.js version, I had to implement raycasting myself, but Babylon.js provides raycasting natively — so some manual adjustment was needed.

🎮 Play the remake here: missile-command.netlify.app


🛡️ Defender Mode

The original Missile Command is all about defense — the player marks laser targets to protect cities from incoming missiles.

I kept most of the defense logic the same. The player simply long-clicks on the ground to set the target location. Simple, classic, and satisfying 💥.


☄️ Attacker Mode

Now comes the twist — the Attacker 😈.
I added a sky area in the game so the attacker can click positions to launch missiles toward the defender’s cities.

This turns the game into a tense 1v1 showdown — one defends, one destroys.


🗄️ Data & Storage

At first, I used LocalStorage for quick prototyping 🧩. Later, I refactored everything with Cursor to use Firebase Realtime Database 🔥 for proper multiplayer sync.

Here’s how it works:

  • When Player 1 starts a game, a 6-digit room hash is generated in the URL.
  • Player 2 joins using that same URL — they automatically become the Attacker.
  • Any other players joining the same link will be blocked
🛡️ Defender☄️ Attacker👀 Others

The data in the Firebase database is structured as follows:

rooms = Record<ID, Room>

Room {
  houses: Houses[]
  players: Record<FID, Players>
  Missiles: Missiles[]
}

House {
 color: Color
 isDestoryed: Boolean
 position: Position
 size: Position
}

Player {
 fid: FID,
 role: "defender" | "attacker"
}

Missile {
 color: Color,
 isActive: Boolean,
 isHit: Boolean,
 position: Position,
 target: position,
}

Color { r g b a }
Position { x, y, z }
classDiagram
    class Room {
        houses: House[]
        players: Record
        missiles: Missile[]
    }

    class House {
        color: Color
        isDestroyed: Boolean
        position: Position
        size: Position
    }

    class Player {
        fid: FID
        role: "defender" | "attacker"
    }

    class Missile {
        color: Color
        isActive: Boolean
        isHit: Boolean
        position: Position
        target: Position
    }

    class Color {
        r: float
        g: float
        b: float
        a: float
    }

    class Position {
        x: float
        y: float
        z: float
    }

    Room "1" --> "*" House : contains
    Room "1" --> "*" Missile : launches
    Room "1" --> "2" Player : has
    House "1" --> "1" Color
    House "1" --> "1" Position : position
    Missile "1" --> "1" Color
    Missile "1" --> "1" Position : position
    Missile "1" --> "1" Position : target

🔍 Collision Detection

Building placement visualization

To place the buildings on the plate, I used random numbers combined with trigonometric functions (sin, cos) to generate polar coordinates. 🎲

const angle = Math.random() * Math.PI * 2;
const distance = Math.random() * (groundRadius - 5);
const x = Math.cos(angle) * distance;
const z = Math.sin(angle) * distance;
const y = 0;

Next, I calculate whether the proposed position is already occupied. For this, I leverage Yuka’s AABB (Axis-Aligned Bounding Box) system:

function getHouseAABB(position: Vector3, size: Vector3): YukaAABB {
    const points = []
    // Push the 8 corners of the house
    points.push(new YukaVector3(position.x + size.x / 2, position.y + size.y / 2, position.z + size.z / 2));
    /* ... */

    return new YukaAABB().fromPoints(points);
}

function isValidHousePosition(ctx: SceneContext, position: Vector3, size: Vector3): boolean {
    // Keep ground boundary constraint (circular ground with radius 30)
    const distanceFromCenter = Math.sqrt(position.x * position.x + position.z * position.z);
    if (distanceFromCenter + Math.max(size.x, size.z) / 2 > 30) {
        return false;
    }

    const proposedAABB = getHouseAABB(position, size)

    // Test intersection with existing houses (box vs box)
    for (const house of ctx.gameState.houses) {
        const existingAABB = getHouseAABB(house.position, house.size)
        if (proposedAABB.intersectsAABB(existingAABB)) {
            return false;
        }
    }
}

🚀 Missile’s Gravity

The vertical gravity calculation 🌍

v=v+gravitytdy=vtv = v + gravity * t \\ dy = v * t
const gravity = -2;
m.verticalVelocity += gravity * dt;
const dy = m.verticalVelocity * dt;
...
m.position.y += dy; 

🧠 Using Colyseus as Backend

At first, I used Firebase as the backend — but the network was too slow 🐢. So I switched to Colyseus ⚡ for a faster, real-time multiplayer backend.


⚙️ Project Setup

After running into some monorepo headaches 🌀 (the @colyseus/schema package didn’t play nicely), I decided to split it into two separate projects instead.

Setting things up took longer than expected ⏳ — so if you’re starting fresh, it’s better to use the official Colyseus example project. ✅


🧩 Game State

To maintain a single source of truth 🧭, all physics are calculated on the server side.

Here’s the schema definition 👇

class GameState extends Schema {
  @type([Player]) players: ArraySchema<Player> = new ArraySchema<Player>();
  @type([House]) houses: ArraySchema<House> = new ArraySchema<House>();
  @type([Missile]) missiles: ArraySchema<Missile> = new ArraySchema<Missile>();
  @type([Laser]) lasers: ArraySchema<Laser> = new ArraySchema<Laser>();
  @type([Marker]) markers: ArraySchema<Marker> = new ArraySchema<Marker>();
  @type("boolean") isGameOver: boolean = false;
}

At first, I wanted to use Babylon’s serializer to compute the scene server-side and stream it to clients. However, @colyseus/schema requires strict descriptions, so I dropped that plan 🙅‍♂️.

Colyseus provides a handy @colyseus/playground 🧪 — where you can inspect the entire game state structure visually.

On the client, use onStateChange to react to state updates:

room.onStateChange(handler);

You can also use getStateCallbacks to listen to specific value changes 🎯.


🏠 Game Room

To communicate between client and server, the server listens for the following events 🎮:

Event NameDescription
spawn_missileAttackers spawn missiles
add_markerDefenders set laser targets
assign_roleAny player chooses their role
export class GameRoom extends Room<GameState> {
  state = new GameState();

  onCreate() {
    this.onMessage("spawn_missile", (client, { x, z }) => {...});
    this.onMessage("add_marker", (client, { x, y, z }) => {...});
    this.onMessage("assign_role", (client, { role }) => {...});
    this.initializeScene();
    this.setSimulationInterval((deltaTime) => this.update(deltaTime), 50);
  }

  onJoin(client, options) {...}
  onLeave(client) {...}
  onDispose() {...}
}

Client-side event sending example 📡:

function getClient(): Client {
  if (!client) {
    const server = process.env.SERVER || "ws://localhost:2567";
    client = new Client(server);
  }
  return client;
}

async function getRoom(): Promise<Room> {
  if (!roomPromise) {
    const client = getClient();
    const roomName = "missile_command";
    const connect = (opts?: Record<string, unknown>) =>
      client.joinOrCreate(roomName, opts).catch(async () => {
        try {
          return await client.create(roomName, opts);
        } catch {
          return await client.join(roomName, opts);
        }
      });
    roomPromise = connect();
  }
  return await roomPromise;
}

// Usage
const room = await getRoom();
room.send("spawn_missile", { x, z });

🕹️ Rules

🧨 Attackers

Each missile costs 10 points 💸 to launch, but hitting a target earns 100 points 💥.

private hitHouse(h: House, attackerSessionId: string): void {
  h.isHit = true;
  h.isDestroyed = true;

  const attacker = this.findPlayer(attackerSessionId);
  if (attacker) attacker.score += 100;
}

private spawnMissile(x: number, z: number, clientSessionId: string): void {
  const attacker = this.findPlayer(clientSessionId);
  if (attacker) attacker.score -= 10;

  const m = new Missile();
  m.position.set(x, 75, z);
  m.target = new Vec3(x, 0, z);
  m.speed = 0; 
  m.verticalVelocity = 0; 
  m.isActive = true; 
  m.isHit = false;
  m.color = { r: Math.random(), g: Math.random(), b: Math.random() };
  m.id = `${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
  m.clientSessionId = clientSessionId;
  this.state.missiles.push(m);
}

🛡️ Defenders

Destroying a missile rewards 50 points ✨.

private resolveMarkerHit(laserIndex: number, x: number, z: number): void {
  const explosionRadius = 5;
  let destroyedCount = 0;

  for (const m of this.state.missiles) {
    if (!m.isActive) continue;
    const d = Math.hypot(m.position.x - x, m.position.z - z);
    if (d <= explosionRadius) {
      m.isActive = false;
      destroyedCount++;
    }
  }

  const marker = this.state.markers.find(m => !m.isDone && m.assignedLaserIndex === laserIndex);
  if (marker) {
    marker.isDone = true;
    if (destroyedCount > 0) {
      const player = this.findPlayer(marker.clientSessionId);
      if (player) player.score += destroyedCount * 50;
    }
  }
}

🚢 Deployment

🧩 Open Source Code: Missile Command on GitHub

  • Server: /apps/server — deployed on Render.com ☁️
  • Client: /apps/client — live on Netlify 🌐

🎥 Gameplay Preview:

© 2026 All rights reserved..

This website uses Astro.build, Mantine and React Bits | deployed on Vercel