Client web architecture patterns. Use when adding features, refactoring, or deciding code placement. Covers engine vs domain separation, event co-location, and module organization.
client/web/src/
├── engine/ # Infrastructure (reusable)
│ ├── events.ts # EventDispatcher, EventSubscriptions
│ ├── game-system.ts # System interface
│ ├── render-context.ts # THREE.js rendering
│ ├── debug-panel.ts # lil-gui debug panel
│ └── game.ts # Orchestrator
├── camera/ # Domain: Camera + controls
├── systems/ # Domain: Game systems (orchestrators)
└── world/ # Domain: Chunks, hex math, WASM
Rule: Infrastructure in engine/, game logic outside.
Events live with the module that dispatches them.
// systems/world-reference-system.ts
export const WORLD_REFERENCE_CHANGED = 'worldreferencechanged';
export type WorldReferenceChangedEvent = {
oldChunkId: ChunkId;
newChunkId: ChunkId;
getDeltaPosition(): [number, number];
};
export class WorldReferenceSystem implements GameSystem {
private readonly dispatcher: EventDispatcher;
constructor(..., events: EventTarget, ...) {
this.dispatcher = new EventDispatcher(events);
}
update() {
if (shouldReposition) {
this.dispatcher.dispatch<WorldReferenceChangedEvent>(
WORLD_REFERENCE_CHANGED,
{ oldChunkId, newChunkId, ... }
);
}
}
}
Subscribing (arrow function handlers):
// world/world.ts
export class World {
private readonly subscriptions: EventSubscriptions;
constructor(events: EventTarget) {
this.subscriptions = new EventSubscriptions(events);
this.subscriptions.on<WorldReferenceChangedEvent>(
WORLD_REFERENCE_CHANGED,
this.handleWorldReferenceChanged
);
}
dispose() {
this.subscriptions.dispose();
}
private handleWorldReferenceChanged = (event: WorldReferenceChangedEvent): void => {
// Handle event
};
}
Why arrow functions: Handlers passed directly to on() must bind this. Arrow function properties capture this automatically.
EventSubscriptions - One subscription per event name (replaces on re-registration):
on<T>(eventName, handler) - Subscribe to custom eventslistenWindow<K>(eventName, handler) - Subscribe to window eventsremove(eventName) - Remove subscriptiondispose() - Cleanup allEventDispatcher - Dispatches events:
dispatch<T>(eventName, detail) - Type-safe dispatchEvent naming: 'lowercase' (e.g., 'worldreferencechanged', 'viewportresize')
Event constants: Export from dispatcher module
export const WORLD_REFERENCE_CHANGED = 'worldreferencechanged';
export class Example {
// 1. Properties (state)
readonly publicProp: string;
private privateProp: number;
// 2. Constructor (initialization)
constructor(events: EventTarget) {
this.subscriptions.on(EVENT, this.handleEvent);
}
// 3. Public functions (API)
public doSomething(): void { }
// 4. Handlers (arrow properties for callbacks)
private handleEvent = (event: EventType): void => {
// Event handling
};
// 5. Private functions (implementation)
private helperMethod(): void { }
}
GameSystem interface (update(deltaTime), dispose())systems/WorldReferenceSystem monitors camera, dispatches repositioning eventscamera/, world/Camera, World, Chunkengine/EventDispatcher, RenderContext, GameSystem interfaceAdding new code?
engine/systems/ (GameSystem)Adding a system:
systems/my-system.tsGameSystem interfaceEventDispatcher instance for dispatchingengine/game.ts constructorAdding a resource:
resource-name/resource-name.tsEventSubscriptions instance for listeningEventDispatcherdispose() methodexport class MySystem implements GameSystem {
private readonly SCOPE = 'My System';
constructor(..., debugPanel: DebugPanel) {
// Values updated each frame
debugPanel.set(this.SCOPE, 'Key', 'value');
// Toggles (controls)
debugPanel.addToggle('Controls', 'Toggle Name', object, 'property');
}
dispose() {
debugPanel.removeScope(this.SCOPE);
}
}
DebugPanel API:
set(scope, key, value) - Update valueaddToggle(scope, name, object, property) - Add boolean toggleremoveScope(scope) - Cleanup scopeSystem pattern:
export class MySystem implements GameSystem {
private readonly dispatcher: EventDispatcher;
private readonly SCOPE = 'My System';
constructor(
resource1: Resource1,
resource2: Resource2,
events: EventTarget,
debugPanel: DebugPanel
) {
this.dispatcher = new EventDispatcher(events);
}
update(deltaTime: number) {
// Read from resources, dispatch events
this.debugPanel.set(this.SCOPE, 'Key', 'value');
}
dispose() {
this.debugPanel.removeScope(this.SCOPE);
}
}
Resource pattern:
export class MyResource {
private readonly subscriptions: EventSubscriptions;
private readonly dispatcher?: EventDispatcher; // If dispatches events
constructor(events: EventTarget) {
this.subscriptions = new EventSubscriptions(events);
this.subscriptions.on<SomeEvent>(SOME_EVENT, this.handleSomeEvent);
// If dispatches events:
this.dispatcher = new EventDispatcher(events);
}
dispose() {
this.subscriptions.dispose();
}
private handleSomeEvent = (event: SomeEvent): void => {
// Handle event
};
}
Window event subscription:
constructor(renderContext: RenderContext, events: EventTarget) {
this.subscriptions = new EventSubscriptions(events);
// Window events
this.subscriptions.listenWindow('keydown', (e) => {
if (e.key === 'Tab') {
e.preventDefault();
// Handle
}
});
}
❌ Centralized events file with all event types
❌ Game logic in engine/
❌ Missing dispose() cleanup
❌ Direct method calls between resources (use events)
❌ Multiple EventTarget instances (use single bus from Game)
| What | Where | Type |
|---|---|---|
| Event infrastructure | engine/events.ts | Infrastructure |
| GameSystem interface | engine/game-system.ts | Infrastructure |
| THREE.js rendering | engine/render-context.ts | Infrastructure |
| Debug panel | engine/debug-panel.ts | Infrastructure |
| Game orchestrator | engine/game.ts | Infrastructure |
| Camera + controls | camera/ | Domain resource |
| World/chunks | world/ | Domain resource |
| Orchestrators | systems/ | Domain system |
| Event types | With triggering module | Co-located |