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<FID, Player>
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

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 ๐
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 Name | Description |
|---|---|
spawn_missile | Attackers spawn missiles |
add_marker | Defenders set laser targets |
assign_role | Any 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:


