Real-Time Gaming Concepts
Real-time multiplayer gaming requires specialized networking techniques to provide smooth, responsive gameplay despite network latency. In this lesson, we'll explore essential concepts for building multiplayer games.
Game Loop Architecture
The game loop is the core of any real-time game, coordinating updates, rendering, and network synchronization.
// Client-side game loop
class GameClient {
constructor() {
this.lastUpdateTime = Date.now();
this.targetFPS = 60;
this.frameTime = 1000 / this.targetFPS;
this.isRunning = false;
this.gameState = {};
}
start() {
this.isRunning = true;
this.lastUpdateTime = Date.now();
this.loop();
}
loop() {
if (!this.isRunning) return;
const now = Date.now();
const deltaTime = now - this.lastUpdateTime;
// Fixed timestep for physics
if (deltaTime >= this.frameTime) {
this.update(deltaTime / 1000); // Convert to seconds
this.render();
this.lastUpdateTime = now - (deltaTime % this.frameTime);
}
requestAnimationFrame(() => this.loop());
}
update(deltaTime) {
// Update game logic
this.updatePlayerInput(deltaTime);
this.updatePhysics(deltaTime);
this.interpolateEntities(deltaTime);
this.sendInputToServer();
}
render() {
// Render current game state
this.clearCanvas();
this.renderEntities();
this.renderUI();
}
stop() {
this.isRunning = false;
}
}
// Server-side game loop
class GameServer {
constructor() {
this.tickRate = 30; // Server updates per second
this.tickInterval = 1000 / this.tickRate;
this.lastTick = Date.now();
this.gameState = {
players: new Map(),
projectiles: [],
timestamp: Date.now()
};
}
start() {
setInterval(() => this.tick(), this.tickInterval);
}
tick() {
const now = Date.now();
const deltaTime = (now - this.lastTick) / 1000;
// Process player inputs
this.processInputs();
// Update game state
this.updatePhysics(deltaTime);
this.detectCollisions();
this.updateGameLogic(deltaTime);
// Broadcast state to all clients
this.broadcastState();
this.lastTick = now;
}
processInputs() {
for (const [playerId, player] of this.gameState.players) {
while (player.inputQueue.length > 0) {
const input = player.inputQueue.shift();
this.applyInput(player, input);
}
}
}
applyInput(player, input) {
// Validate and apply player input
if (input.action === 'move') {
player.velocity.x = input.direction.x * player.speed;
player.velocity.y = input.direction.y * player.speed;
player.lastProcessedInput = input.sequenceNumber;
}
}
broadcastState() {
const state = {
timestamp: Date.now(),
players: Array.from(this.gameState.players.entries()).map(
([id, player]) => ({
id,
position: player.position,
rotation: player.rotation,
lastProcessedInput: player.lastProcessedInput
})
)
};
io.emit('game:state', state);
}
}
Client-Side Prediction
Client-side prediction allows players to see immediate feedback for their actions, reducing perceived latency.
// Client-side prediction implementation
class PredictivePlayer {
constructor(socket) {
this.socket = socket;
this.position = { x: 0, y: 0 };
this.velocity = { x: 0, y: 0 };
this.speed = 200;
// Input tracking
this.inputSequenceNumber = 0;
this.pendingInputs = [];
this.lastProcessedInput = 0;
this.setupNetworkHandlers();
}
setupNetworkHandlers() {
// Receive authoritative state from server
this.socket.on('game:state', (state) => {
this.reconcile(state);
});
}
handleInput(input) {
// Assign sequence number
input.sequenceNumber = ++this.inputSequenceNumber;
input.timestamp = Date.now();
// Store for reconciliation
this.pendingInputs.push(input);
// Apply input immediately (prediction)
this.applyInput(input);
// Send to server
this.socket.emit('game:input', input);
}
applyInput(input) {
if (input.action === 'move') {
const deltaTime = 1 / 60; // Assume 60 FPS
this.velocity.x = input.direction.x * this.speed;
this.velocity.y = input.direction.y * this.speed;
this.position.x += this.velocity.x * deltaTime;
this.position.y += this.velocity.y * deltaTime;
}
}
reconcile(serverState) {
// Find our player in server state
const serverPlayer = serverState.players.find(
p => p.id === this.socket.id
);
if (!serverPlayer) return;
// Update last processed input
this.lastProcessedInput = serverPlayer.lastProcessedInput;
// Set position to server's authoritative position
this.position = { ...serverPlayer.position };
// Remove processed inputs
this.pendingInputs = this.pendingInputs.filter(
input => input.sequenceNumber > this.lastProcessedInput
);
// Re-apply pending inputs
for (const input of this.pendingInputs) {
this.applyInput(input);
}
}
update(deltaTime) {
// Update position based on velocity
this.position.x += this.velocity.x * deltaTime;
this.position.y += this.velocity.y * deltaTime;
}
}
// Usage
const player = new PredictivePlayer(socket);
// Handle keyboard input
document.addEventListener('keydown', (e) => {
const input = {
action: 'move',
direction: getDirectionFromKey(e.key),
timestamp: Date.now()
};
player.handleInput(input);
});
Prediction Benefits: Client-side prediction eliminates input lag, making games feel responsive even with 100-150ms latency. Players see their actions immediately while the server validates them.
Server Reconciliation
Server reconciliation corrects client predictions when they diverge from the authoritative server state.
// Advanced reconciliation with smoothing
class ReconciledEntity {
constructor() {
this.position = { x: 0, y: 0 };
this.serverPosition = { x: 0, y: 0 };
this.renderPosition = { x: 0, y: 0 };
this.reconciliationThreshold = 10; // pixels
this.smoothingFactor = 0.2;
}
reconcileWithServer(serverPosition) {
const distance = Math.hypot(
serverPosition.x - this.position.x,
serverPosition.y - this.position.y
);
if (distance > this.reconciliationThreshold) {
// Significant mismatch - snap to server position
console.warn(`Reconciliation: distance=${distance.toFixed(2)}px`);
// Smooth correction over several frames
this.serverPosition = { ...serverPosition };
}
}
update(deltaTime) {
// Smoothly interpolate towards server position
if (this.serverPosition) {
this.position.x += (this.serverPosition.x - this.position.x) *
this.smoothingFactor;
this.position.y += (this.serverPosition.y - this.position.y) *
this.smoothingFactor;
// Clear server position when close enough
const distance = Math.hypot(
this.serverPosition.x - this.position.x,
this.serverPosition.y - this.position.y
);
if (distance < 1) {
this.serverPosition = null;
}
}
}
getRenderPosition() {
return this.position;
}
}
Lag Compensation
Lag compensation techniques ensure fair gameplay by accounting for network latency in hit detection and interactions.
// Server-side lag compensation for shooting
class LagCompensation {
constructor() {
this.historyDuration = 1000; // 1 second of history
this.playerHistory = new Map(); // playerId -> position history
}
recordPlayerPosition(playerId, position, timestamp) {
if (!this.playerHistory.has(playerId)) {
this.playerHistory.set(playerId, []);
}
const history = this.playerHistory.get(playerId);
history.push({ position, timestamp });
// Keep only recent history
const cutoff = timestamp - this.historyDuration;
const validHistory = history.filter(h => h.timestamp > cutoff);
this.playerHistory.set(playerId, validHistory);
}
getHistoricalPosition(playerId, timestamp) {
const history = this.playerHistory.get(playerId);
if (!history || history.length === 0) return null;
// Find position at requested timestamp
for (let i = 0; i < history.length - 1; i++) {
const current = history[i];
const next = history[i + 1];
if (timestamp >= current.timestamp &&
timestamp <= next.timestamp) {
// Interpolate between positions
const t = (timestamp - current.timestamp) /
(next.timestamp - current.timestamp);
return {
x: current.position.x + (next.position.x - current.position.x) * t,
y: current.position.y + (next.position.y - current.position.y) * t
};
}
}
return history[history.length - 1].position;
}
handleShot(shooterId, targetId, clientTimestamp, shotPosition) {
const shooterLatency = this.getPlayerLatency(shooterId);
// Rewind time to when shot was fired on shooter's screen
const historicalTimestamp = Date.now() - shooterLatency;
// Get target's position at that historical time
const targetPosition = this.getHistoricalPosition(
targetId,
historicalTimestamp
);
if (!targetPosition) return false;
// Check if shot hit target at historical position
const distance = Math.hypot(
shotPosition.x - targetPosition.x,
shotPosition.y - targetPosition.y
);
const hitRadius = 20; // Collision radius
return distance <= hitRadius;
}
getPlayerLatency(playerId) {
// Track average latency for each player
const player = this.getPlayer(playerId);
return player ? player.averageLatency : 100;
}
}
// Usage on server
const lagComp = new LagCompensation();
io.on('connection', (socket) => {
socket.on('game:shoot', (data) => {
const hit = lagComp.handleShot(
socket.id,
data.targetId,
data.clientTimestamp,
data.position
);
if (hit) {
io.emit('game:hit', {
shooterId: socket.id,
targetId: data.targetId
});
}
});
});
Lag Compensation Trade-offs: While lag compensation improves fairness for high-latency players, it can cause "shot behind walls" situations. Balance compensation with anti-cheat measures and reasonable latency limits.
Interpolation
Entity interpolation smooths movement between server updates, creating fluid animation despite low update rates.
// Entity interpolation for smooth movement
class InterpolatedEntity {
constructor() {
this.positionBuffer = [];
this.renderDelay = 100; // Delay rendering by 100ms
this.position = { x: 0, y: 0 };
}
addServerUpdate(position, timestamp) {
this.positionBuffer.push({ position, timestamp });
// Keep buffer size reasonable
if (this.positionBuffer.length > 60) {
this.positionBuffer.shift();
}
}
update() {
const now = Date.now();
const renderTime = now - this.renderDelay;
// Find two positions to interpolate between
let previous = null;
let next = null;
for (let i = 0; i < this.positionBuffer.length - 1; i++) {
if (this.positionBuffer[i].timestamp <= renderTime &&
this.positionBuffer[i + 1].timestamp >= renderTime) {
previous = this.positionBuffer[i];
next = this.positionBuffer[i + 1];
break;
}
}
if (!previous || !next) {
// Use latest position if no interpolation possible
if (this.positionBuffer.length > 0) {
const latest = this.positionBuffer[this.positionBuffer.length - 1];
this.position = { ...latest.position };
}
return;
}
// Interpolate between positions
const totalTime = next.timestamp - previous.timestamp;
const elapsedTime = renderTime - previous.timestamp;
const t = totalTime > 0 ? elapsedTime / totalTime : 0;
this.position.x = previous.position.x +
(next.position.x - previous.position.x) * t;
this.position.y = previous.position.y +
(next.position.y - previous.position.y) * t;
// Clean up old positions
this.positionBuffer = this.positionBuffer.filter(
p => p.timestamp > renderTime - 1000
);
}
getRenderPosition() {
return this.position;
}
}
// Usage
const entities = new Map();
socket.on('game:state', (state) => {
state.players.forEach(playerData => {
if (!entities.has(playerData.id)) {
entities.set(playerData.id, new InterpolatedEntity());
}
const entity = entities.get(playerData.id);
entity.addServerUpdate(playerData.position, state.timestamp);
});
});
// In game loop
function render() {
entities.forEach(entity => {
entity.update();
const pos = entity.getRenderPosition();
drawPlayer(pos.x, pos.y);
});
}
State Synchronization
Efficient state synchronization minimizes bandwidth while keeping all clients updated.
// Delta compression for state updates
class StateSync {
constructor() {
this.lastSentState = new Map();
}
createDeltaUpdate(fullState, clientId) {
const lastState = this.lastSentState.get(clientId);
if (!lastState) {
// First update - send full state
this.lastSentState.set(clientId, this.cloneState(fullState));
return { type: 'full', data: fullState };
}
// Calculate delta
const delta = {
type: 'delta',
added: [],
updated: [],
removed: []
};
// Find added and updated entities
fullState.entities.forEach((entity, id) => {
const lastEntity = lastState.entities.get(id);
if (!lastEntity) {
delta.added.push({ id, ...entity });
} else if (this.hasChanged(entity, lastEntity)) {
delta.updated.push({
id,
...this.getDifference(entity, lastEntity)
});
}
});
// Find removed entities
lastState.entities.forEach((entity, id) => {
if (!fullState.entities.has(id)) {
delta.removed.push(id);
}
});
// Update last sent state
this.lastSentState.set(clientId, this.cloneState(fullState));
return delta;
}
hasChanged(entity1, entity2) {
return entity1.position.x !== entity2.position.x ||
entity1.position.y !== entity2.position.y ||
entity1.rotation !== entity2.rotation ||
entity1.health !== entity2.health;
}
getDifference(entity, lastEntity) {
const diff = {};
if (entity.position.x !== lastEntity.position.x ||
entity.position.y !== lastEntity.position.y) {
diff.position = entity.position;
}
if (entity.rotation !== lastEntity.rotation) {
diff.rotation = entity.rotation;
}
if (entity.health !== lastEntity.health) {
diff.health = entity.health;
}
return diff;
}
cloneState(state) {
return {
entities: new Map(state.entities),
timestamp: state.timestamp
};
}
}
// Server usage
const stateSync = new StateSync();
function broadcastState() {
const fullState = getCurrentGameState();
io.sockets.sockets.forEach((socket) => {
const update = stateSync.createDeltaUpdate(fullState, socket.id);
// Delta updates are typically 80-95% smaller than full state
socket.emit('game:state', update);
});
}
Networking Best Practices:
- Update rate: 20-30 Hz for most games, 60+ Hz for competitive FPS
- Use unreliable transport (UDP-like) for frequent position updates
- Use reliable transport for critical events (damage, scoring)
- Implement client-side prediction for local player only
- Interpolate other players 100-200ms in the past
- Use delta compression to reduce bandwidth
Practice Exercise:
- Implement a complete client-side prediction system with server reconciliation for a simple top-down movement game
- Create an interpolation system that smoothly renders other players between server updates
- Build a lag compensation system for a shooting game that rewinds time for hit detection
- Implement delta compression to reduce state update bandwidth by 80%
- Create a latency display that shows ping and packet loss for debugging