Development

Building Darkmoon: 3D Combat in the Browser

Austin H.•September 17, 2026•15 min read
#threejs#react#game-dev#webgl#socketio#ecs

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