Skip to main content

Real-Time Multiplayer Best Practices

This guide covers best practices for building performant, scalable real-time multiplayer games with the Moitribe JavaScript SDK.

Performance Optimization

Message Design

Keep Messages Small

Smaller messages reduce latency and bandwidth usage. Send only what's necessary.

// ✅ Good - Compact position update
const position = new ArrayBuffer(12); // 3 floats (x, y, z)
const view = new DataView(position);
view.setFloat32(0, player.x, true);
view.setFloat32(4, player.y, true);
view.setFloat32(8, player.z, true);

// ❌ Bad - Verbose JSON
const position = {
x: player.x,
y: player.y,
z: player.z,
timestamp: Date.now(),
playerId: player.id,
room: currentRoom.id
};

Rate Limiting

Control message frequency to prevent network congestion:

class RateLimiter {
constructor(maxMessagesPerSecond) {
this.maxMessages = maxMessagesPerSecond;
this.messages = [];
}

canSend() {
const now = Date.now();
this.messages = this.messages.filter(time => now - time < 1000);
return this.messages.length < this.maxMessages;
}

recordSend() {
this.messages.push(Date.now());
}
}

const positionLimiter = new RateLimiter(30); // 30 updates per second

function sendPosition(position) {
if (positionLimiter.canSend()) {
MoitribeSDK(GAME_ID, 'standardmessage', {
messageData: encodePosition(position),
participantIds: [],
isReliable: false
});
positionLimiter.recordSend();
}
}

Message Batching

Combine multiple updates into single messages:

class MessageBatcher {
constructor(maxSize = 1024, flushInterval = 50) {
this.maxSize = maxSize;
this.flushInterval = flushInterval;
this.buffer = new ArrayBuffer(maxSize);
this.offset = 0;
this.timer = null;
}

add(data) {
if (this.offset + data.byteLength > this.maxSize) {
this.flush();
}

new Uint8Array(this.buffer, this.offset).set(new Uint8Array(data));
this.offset += data.byteLength;

if (!this.timer) {
this.timer = setTimeout(() => this.flush(), this.flushInterval);
}
}

flush() {
if (this.offset > 0) {
const message = this.buffer.slice(0, this.offset);
MoitribeSDK(GAME_ID, 'standardmessagetoall', {
messageData: message,
isReliable: false
});
this.offset = 0;
}

if (this.timer) {
clearTimeout(this.timer);
this.timer = null;
}
}
}

Network Optimization

Adaptive Quality

Adjust message reliability based on network conditions:

class AdaptiveQuality {
constructor() {
this.latency = 0;
this.packetLoss = 0;
this.quality = 'high';
}

updateMetrics(latency, packetLoss) {
this.latency = latency;
this.packetLoss = packetLoss;

if (latency > 200 || packetLoss > 0.05) {
this.quality = 'low';
} else if (latency > 100 || packetLoss > 0.02) {
this.quality = 'medium';
} else {
this.quality = 'high';
}
}

shouldUseReliable() {
return this.quality === 'low';
}

getUpdateRate() {
switch (this.quality) {
case 'high': return 60; // 60 FPS
case 'medium': return 30; // 30 FPS
case 'low': return 15; // 15 FPS
}
}
}

Client-Side Prediction

Reduce perceived latency by predicting game state:

class ClientPrediction {
constructor() {
this.pendingInputs = [];
this.serverState = null;
this.clientState = null;
}

addInput(input) {
this.pendingInputs.push({
input: input,
timestamp: Date.now()
});

// Apply prediction immediately
this.applyInput(input);
}

applyServerState(state) {
this.serverState = state;

// Reconcile with pending inputs
this.pendingInputs.forEach(pending => {
this.applyInput(pending.input);
});
}

applyInput(input) {
// Apply input to client state
// This depends on your game logic
}
}

Memory Management

Object Pooling

Reuse objects to reduce garbage collection:

class ObjectPool {
constructor(createFn, resetFn, initialSize = 10) {
this.createFn = createFn;
this.resetFn = resetFn;
this.pool = [];

for (let i = 0; i < initialSize; i++) {
this.pool.push(this.createFn());
}
}

get() {
if (this.pool.length > 0) {
return this.pool.pop();
}
return this.createFn();
}

release(obj) {
this.resetFn(obj);
this.pool.push(obj);
}
}

// Usage for message objects
const messagePool = new ObjectPool(
() => ({ data: null, timestamp: 0 }),
(obj) => { obj.data = null; obj.timestamp = 0; }
);

function createMessage(data) {
const message = messagePool.get();
message.data = data;
message.timestamp = Date.now();
return message;
}

function releaseMessage(message) {
messagePool.release(message);
}

ArrayBuffer Management

Efficiently handle binary data:

class BufferManager {
constructor(initialSize = 1024) {
this.buffer = new ArrayBuffer(initialSize);
this.view = new DataView(this.buffer);
this.offset = 0;
}

reset() {
this.offset = 0;
}

ensureCapacity(size) {
if (this.offset + size > this.buffer.byteLength) {
const newSize = Math.max(this.buffer.byteLength * 2, this.offset + size);
const newBuffer = new ArrayBuffer(newSize);
new Uint8Array(newBuffer).set(new Uint8Array(this.buffer));
this.buffer = newBuffer;
this.view = new DataView(this.buffer);
}
}

writeFloat32(value) {
this.ensureCapacity(4);
this.view.setFloat32(this.offset, value, true);
this.offset += 4;
}

writeUint8(value) {
this.ensureCapacity(1);
this.view.setUint8(this.offset, value);
this.offset += 1;
}

getData() {
return this.buffer.slice(0, this.offset);
}
}

Room Management

Connection Handling

Robust connection management:

class RoomManager {
constructor() {
this.currentRoom = null;
this.reconnectAttempts = 0;
this.maxReconnectAttempts = 5;
this.reconnectDelay = 1000;
}

joinRoom(roomId, callbacks) {
this.callbacks = callbacks;
this.currentRoom = roomId;
this.reconnectAttempts = 0;

this.attemptJoin();
}

attemptJoin() {
MoitribeSDK(GAME_ID, 'joinstandardroominvcode', {
invitationID: this.currentRoom,
onJoinedRoom: (success, room) => {
if (success) {
this.reconnectAttempts = 0;
this.callbacks.onJoinedRoom(room);
} else {
this.handleJoinFailure();
}
},
onRoomDisconnected: () => {
this.handleDisconnection();
}
});
}

handleJoinFailure() {
if (this.reconnectAttempts < this.maxReconnectAttempts) {
this.reconnectAttempts++;
const delay = this.reconnectDelay * Math.pow(2, this.reconnectAttempts - 1);

setTimeout(() => {
console.log(`Reconnecting attempt ${this.reconnectAttempts}...`);
this.attemptJoin();
}, delay);
} else {
this.callbacks.onJoinFailed();
}
}

handleDisconnection() {
console.log('Room disconnected, attempting to reconnect...');
this.handleJoinFailure();
}
}

Player State Synchronization

Efficient state synchronization:

class StateSynchronizer {
constructor() {
this.lastState = {};
this.stateBuffer = [];
this.bufferSize = 10;
}

updateState(playerId, state) {
const timestamp = Date.now();
const update = {
playerId,
state,
timestamp,
delta: this.calculateDelta(this.lastState[playerId], state)
};

this.stateBuffer.push(update);
if (this.stateBuffer.length > this.bufferSize) {
this.stateBuffer.shift();
}

this.lastState[playerId] = state;
}

calculateDelta(lastState, currentState) {
if (!lastState) return currentState;

const delta = {};
for (const key in currentState) {
if (lastState[key] !== currentState[key]) {
delta[key] = currentState[key];
}
}
return delta;
}

getInterpolatedState(playerId, targetTime) {
const updates = this.stateBuffer.filter(u => u.playerId === playerId);
if (updates.length < 2) return updates[updates.length - 1]?.state;

// Find surrounding updates
let before = updates[0];
let after = updates[updates.length - 1];

for (let i = 0; i < updates.length - 1; i++) {
if (updates[i].timestamp <= targetTime && updates[i + 1].timestamp > targetTime) {
before = updates[i];
after = updates[i + 1];
break;
}
}

// Linear interpolation
const factor = (targetTime - before.timestamp) / (after.timestamp - before.timestamp);
return this.interpolateStates(before.state, after.state, factor);
}

interpolateStates(state1, state2, factor) {
const result = {};
for (const key in state1) {
if (typeof state1[key] === 'number' && typeof state2[key] === 'number') {
result[key] = state1[key] + (state2[key] - state1[key]) * factor;
} else {
result[key] = factor < 0.5 ? state1[key] : state2[key];
}
}
return result;
}
}

Security Considerations

Input Validation

Always validate incoming messages:

class MessageValidator {
constructor() {
this.maxMessageSize = 1024;
this.allowedMessageTypes = ['position', 'action', 'chat'];
}

validate(message) {
// Check message size
if (message.byteLength > this.maxMessageSize) {
console.warn('Message too large:', message.byteLength);
return false;
}

// Parse and validate structure
try {
const parsed = this.parseMessage(message);

if (!this.allowedMessageTypes.includes(parsed.type)) {
console.warn('Invalid message type:', parsed.type);
return false;
}

// Validate specific fields
return this.validateMessageContent(parsed);
} catch (error) {
console.error('Message validation failed:', error);
return false;
}
}

validateMessageContent(message) {
switch (message.type) {
case 'position':
return this.validatePosition(message.data);
case 'action':
return this.validateAction(message.data);
case 'chat':
return this.validateChat(message.data);
default:
return false;
}
}

validatePosition(data) {
return typeof data.x === 'number' &&
typeof data.y === 'number' &&
typeof data.z === 'number' &&
!isNaN(data.x) && !isNaN(data.y) && !isNaN(data.z);
}

validateAction(data) {
return typeof data.actionId === 'number' &&
data.actionId >= 0 &&
data.actionId < 100;
}

validateChat(data) {
return typeof data.message === 'string' &&
data.message.length <= 200;
}
}

Debugging and Monitoring

Performance Metrics

Track key performance indicators:

class PerformanceMonitor {
constructor() {
this.metrics = {
messagesSent: 0,
messagesReceived: 0,
bytesTransferred: 0,
latency: [],
fps: [],
memoryUsage: []
};
this.startTime = Date.now();
}

recordMessageSent(size) {
this.metrics.messagesSent++;
this.metrics.bytesTransferred += size;
}

recordMessageReceived(size, latency) {
this.metrics.messagesReceived++;
this.metrics.bytesTransferred += size;
this.metrics.latency.push(latency);

// Keep only last 100 latency measurements
if (this.metrics.latency.length > 100) {
this.metrics.latency.shift();
}
}

recordFPS(fps) {
this.metrics.fps.push(fps);
if (this.metrics.fps.length > 60) {
this.metrics.fps.shift();
}
}

getStats() {
const runtime = (Date.now() - this.startTime) / 1000;
const avgLatency = this.metrics.latency.reduce((a, b) => a + b, 0) / this.metrics.latency.length;
const avgFPS = this.metrics.fps.reduce((a, b) => a + b, 0) / this.metrics.fps.length;

return {
runtime,
messagesPerSecond: this.metrics.messagesSent / runtime,
bytesPerSecond: this.metrics.bytesTransferred / runtime,
averageLatency: avgLatency,
averageFPS: avgFPS,
totalMessages: this.metrics.messagesSent + this.metrics.messagesReceived
};
}

logStats() {
const stats = this.getStats();
console.log('Performance Stats:', {
'Runtime': `${stats.runtime.toFixed(1)}s`,
'Msg/s': stats.messagesPerSecond.toFixed(1),
'KB/s': (stats.bytesPerSecond / 1024).toFixed(1),
'Latency': `${stats.averageLatency.toFixed(1)}ms`,
'FPS': stats.averageFPS.toFixed(1)
});
}
}

Common Pitfalls to Avoid

1. Sending Too Much Data

  • Problem: Sending entire game state every frame
  • Solution: Send only changes, use delta compression

2. Ignoring Network Conditions

  • Problem: Fixed update rates regardless of connection quality
  • Solution: Implement adaptive quality and rate limiting

3. No Client-Side Prediction

  • Problem: Game feels laggy and unresponsive
  • Solution: Predict movement and reconcile with server state

4. Poor Error Handling

  • Problem: Game crashes on network issues
  • Solution: Robust error handling and reconnection logic

5. Memory Leaks

  • Problem: Performance degrades over time
  • Solution: Object pooling and proper cleanup

Next Steps

Now that you understand best practices, explore:

tip

Start with simple implementations and gradually add optimizations based on performance testing and user feedback.