Movement & Camera System Refactor for Multiplayer
This document explains the architectural refactor of the movement and camera systems, and why these changes were necessary to support future multiplayer functionality.
This document explains the architectural refactor of the movement and camera systems, and why these changes were necessary to support future multiplayer functionality.
The original system used point-and-click pathfinding for character movement:
flowchart LR
A[User clicks on ground] --> B[Character calculates path] --> C[Character walks to destination]
While this worked well for single-player, it presents major challenges for multiplayer:
Non-deterministic state: Pathfinding algorithms can produce different results on different clients due to floating-point precision, timing, or state differences.
Complex state synchronization: Syncing a path (array of waypoints) is more complex and bandwidth-intensive than syncing simple position/velocity.
Latency issues: Click → pathfind → move creates delays that feel unresponsive in networked play.
Prediction difficulty: Hard to implement client-side prediction when movement depends on pathfinding results.
The original CameraSystem in first-person mode handled both camera AND player movement:
// Old approach - camera system does movement
class CameraSystem {
updateFirstPerson() {
// Handle WASD input
// Apply movement with collision
// Update camera position
}
}
This coupling made it difficult to:
We split the system into distinct, single-responsibility components:
flowchart TB
PC[PlayerController<br/>movement] --> PS[PlayerState<br/>sync data]
PS --> CH[Character<br/>visual]
PS -->|network sync| CS[CameraSystem<br/>view only]
// src/core/PlayerState.ts
export interface PlayerState {
position: { x: number; y: number; z: number };
rotation: number; // Y-axis facing direction
velocity: { x: number; y: number; z: number };
isMoving: boolean;
isGrounded: boolean;
}
This interface is:
// src/core/PlayerController.ts
class PlayerController {
update(deltaTime: number, cameraYaw: number): void {
// Read input state
// Calculate movement relative to camera direction
// Apply physics (gravity, collision)
// Update PlayerState
}
getState(): PlayerState { ... }
setState(state: PlayerState): void { ... } // For network sync
}
Key design decisions:
Input-based, not click-based: WASD movement is deterministic - same keys pressed = same movement.
Camera-relative movement: Movement direction comes from cameraYaw parameter, keeping movement logic separate from camera logic.
setState() for sync: Remote players' states can be applied directly without re-running movement logic.
// src/entities/Character.ts
class Character {
setPositionFromVector(position: Vector3): void { ... }
setRotation(yaw: number): void { ... }
setVisible(visible: boolean): void { ... }
}
The Character class is now purely visual:
// src/systems/CameraSystem.ts
type CameraMode = "first-person" | "third-person" | "build";
| Mode | Camera Behavior | Player Movement |
|---|---|---|
| First-person | At player eye level, mouse look | PlayerController handles |
| Third-person | Orbits behind player | PlayerController handles |
| Build | Orbits around ghost block | Camera moves independently |
The camera no longer handles movement - it only handles view.
// Send local player state to server
const state = playerController.getState();
socket.emit('playerState', state);
// Receive remote player state
socket.on('remotePlayer', (state) => {
remoteCharacter.setPositionFromVector(state.position);
remoteCharacter.setRotation(state.rotation);
});
// Local player: predict immediately
playerController.update(deltaTime, cameraYaw);
character.setPositionFromVector(playerController.getPosition());
// When server confirms, reconcile if needed
socket.on('serverState', (state) => {
if (statesDiffer(playerController.getState(), state)) {
playerController.setState(state); // Server is authoritative
}
});
// Remote players: interpolate between received states
class RemotePlayer {
previousState: PlayerState;
targetState: PlayerState;
update(t: number) {
position = lerp(previousState.position, targetState.position, t);
character.setPositionFromVector(position);
}
}
The server can run the same PlayerController logic to validate client inputs:
// Server-side
playerController.update(deltaTime, clientInput.cameraYaw);
const serverState = playerController.getState();
// Broadcast authoritative state to all clients
broadcast('playerState', { id: playerId, state: serverState });
| File | Change |
|---|---|
src/core/PlayerState.ts | New - Serializable state interface |
src/core/PlayerController.ts | New - Unified movement controller |
src/core/StateManager.ts | Added CameraMode, unified mode management |
src/systems/CameraSystem.ts | Rewritten - view only, three modes |
src/entities/Character.ts | Simplified - visual only, no pathfinding |
src/main.ts | Updated game loop integration |
With this architecture in place, adding multiplayer requires:
The core game logic (PlayerController, collision detection, etc.) remains unchanged.