Building Darkmoon: 3D Combat in the Browser
Why a Browser Game?
Native games are great, but the browser offers zero-friction distribution. No installs, no updates, works on any device with a GPU. Darkmoon proves you can hit 60fps on mobile with React + Three.js.
Tech Stack
| Layer | Technology | |-------|------------| | Rendering | Three.js (WebGL2) + React Three Fiber | | State | Zustand + ECS (Entity Component System) | | Networking | Socket.io (WebSocket + fallback) | | Physics | Rapier.js (WASM) | | Build | Vite + TypeScript | | Deploy | Netlify (static) + Railway (game server) |
Architecture: ECS in React
Traditional React components don't map well to game loops. ECS solves this:
// src/ecs/World.ts
export class World {
private entities = new Map<number, Entity>();
private components = new Map<ComponentType, Map<number, Component>>();
private systems: System[] = [];
// Entity = just an ID
createEntity(): Entity { /* ... */ }
// Components = pure data
addComponent<T>(entity: Entity, type: ComponentType<T>, data: T): void { /* ... */ }
getComponent<T>(entity: Entity, type: ComponentType<T>): T | undefined { /* ... */ }
// Systems = logic (run each frame)
addSystem(system: System): void { /* ... */ }
update(deltaTime: number): void {
for (const system of this.systems) {
system.update(this, deltaTime);
}
}
}
Core Components
// src/ecs/components.ts
export const Components = {
Transform: defineComponent<{ position: Vec3; rotation: Quat; scale: Vec3 }>(),
Velocity: defineComponent<{ linear: Vec3; angular: Vec3 }>(),
Health: defineComponent<{ current: number; max: number; armor: number }>(),
Weapon: defineComponent<{ type: WeaponType; cooldown: number; damage: number }>(),
PlayerControlled: defineComponent<{ input: InputState }>(),
AI: defineComponent<{ behavior: AIBehavior; target: Entity | null }>(),
Renderable: defineComponent<{ mesh: THREE.Mesh; material: THREE.Material }>(),
Collider: defineComponent<{ shape: ColliderShape; body: RAPIER.RigidBody }>(),
};
Systems (60fps Game Loop)
// src/ecs/systems/MovementSystem.ts
export class MovementSystem implements System {
update(world: World, dt: number): void {
for (const [entity, velocity] of world.query(Velocity)) {
const transform = world.getComponent(entity, Transform);
if (!transform) continue;
// Physics integration
transform.position.addScaledVector(velocity.linear, dt);
transform.rotation.multiply(velocity.angular.clone().multiplyScalar(dt));
}
}
}
// src/ecs/systems/CombatSystem.ts
export class CombatSystem implements System {
update(world: World, dt: number): void {
for (const [attacker, weapon] of world.query(Weapon)) {
if (weapon.cooldown > 0) {
weapon.cooldown -= dt;
continue;
}
const target = this.findTarget(world, attacker);
if (target) {
this.attack(world, attacker, target, weapon);
weapon.cooldown = weapon.fireRate;
}
}
}
}
React Three Fiber Integration
The render system bridges ECS → Three.js:
// src/ecs/systems/RenderSystem.tsx
export function RenderSystem({ world }: { world: World }) {
const entities = world.query(Renderable, Transform);
return (
<>
{entities.map(([entity, renderable, transform]) => (
<primitive
key={entity.id}
object={renderable.mesh}
position={transform.position.toArray()}
rotation={transform.rotation.toArray()}
scale={transform.scale.toArray()}
/>
))}
</>
);
}
Networking: Authoritative Server
// server/game-server.ts
const io = new Server(3001, { cors: { origin: '*' } });
io.on('connection', (socket) => {
const player = world.createEntity();
world.addComponent(player, Components.PlayerControlled, { input: emptyInput });
world.addComponent(player, Components.Networked, { socketId: socket.id });
socket.on('input', (input: InputState) => {
const controlled = world.getComponent(player, PlayerControlled);
if (controlled) controlled.input = input;
});
socket.on('disconnect', () => world.destroyEntity(player));
});
// 60Hz server tick
setInterval(() => {
world.update(1/60);
broadcastState(io, world);
}, 1000/60);
Client-Side Prediction
// src/client/prediction.ts
export function applyPrediction(world: World, localPlayer: Entity, serverState: WorldSnapshot) {
// 1. Store predicted entities
const predicted = world.query(PlayerControlled).filter(e => e.isLocal);
// 2. Apply authoritative server state
world.reconcile(serverState);
// 3. Re-apply local predictions (rewind/replay)
for (const entity of predicted) {
const inputHistory = getInputHistory(entity);
replayInputs(world, entity, inputHistory);
}
}
Performance Results
| Metric | Desktop | Mobile (iPhone 15) | |--------|---------|-------------------| | FPS (solo) | 144 | 60 | | FPS (4 players) | 120 | 55 | | Frame time | 6.9ms | 16.2ms | | GPU memory | 240MB | 180MB | | Bundle size | 2.1MB | 2.1MB |
Key Takeaways
- ECS + React Three Fiber = clean separation of game logic and rendering
- WASM physics (Rapier) runs at native speed in browser
- Client-side prediction + server reconciliation = smooth multiplayer
- 60fps on mobile is achievable with careful optimization
- Netlify + Railway = zero-config global deployment
Code References
Further Reading
Conclusion
Darkmoon started as a weekend experiment: "Can React do 3D games?" The answer is yes — with ECS for logic, Three.js for rendering, and WASM for physics. Solo combat is live at darkmoon.netlify.app. Multiplayer is in beta (join the Discord for access). Mobile touch controls ship next month. The browser is a viable game platform, and the tooling is only getting better.
Thanks for reading!
Read More Articles