Encoding and Decoding Game Data
Efficient data encoding and decoding is crucial for real-time multiplayer performance. This guide covers various techniques to serialize game data for network transmission while maintaining speed and compatibility.
Data Serialization Options
1. JSON Serialization
Simple and human-readable, but with higher overhead.
JavaScript Example
// Encode game state to JSON
function encodeGameState(gameState) {
return JSON.stringify({
type: 'gameState',
timestamp: Date.now(),
players: gameState.players,
score: gameState.score,
level: gameState.level
});
}
// Decode JSON to game state
function decodeGameState(jsonString) {
return JSON.parse(jsonString);
}
// Send JSON message
const gameState = {
players: [
{ id: 'p1', x: 100, y: 50, health: 100 },
{ id: 'p2', x: 200, y: 75, health: 85 }
],
score: 1500,
level: 3
};
const jsonMessage = encodeGameState(gameState);
const buffer = new TextEncoder().encode(jsonMessage).buffer;
MoitribeSDK('my-game', 'sendmsg', {
message: buffer,
isReliable: true
}, (result) => {
console.log('JSON message sent:', result.success);
});
TypeScript Example
import MoitribeSDK from '@veniso/moitribe-js';
interface Player {
id: string;
x: number;
y: number;
health: number;
}
interface GameState {
type: 'gameState';
timestamp: number;
players: Player[];
score: number;
level: number;
}
class JSONSerializer {
static encode(data: GameState): ArrayBuffer {
const jsonString = JSON.stringify(data);
return new TextEncoder().encode(jsonString).buffer;
}
static decode(buffer: ArrayBuffer): GameState {
const decoder = new TextDecoder();
const jsonString = decoder.decode(buffer);
return JSON.parse(jsonString) as GameState;
}
}
// Usage
const gameState: GameState = {
type: 'gameState',
timestamp: Date.now(),
players: [
{ id: 'p1', x: 100, y: 50, health: 100 },
{ id: 'p2', x: 200, y: 75, health: 85 }
],
score: 1500,
level: 3
};
const messageBuffer = JSONSerializer.encode(gameState);
MoitribeSDK('my-game', 'sendmsg', {
message: messageBuffer,
isReliable: true
}, (result: { success: boolean }) => {
console.log('Typed JSON message sent:', result.success);
});
2. Binary Serialization
More efficient for numeric data and frequent updates.
Position Data Encoding
class PositionEncoder {
static encode3D(x, y, z) {
const buffer = new ArrayBuffer(12); // 3 floats * 4 bytes
const view = new DataView(buffer);
view.setFloat32(0, x, true); // Little-endian
view.setFloat32(4, y, true);
view.setFloat32(8, z, true);
return buffer;
}
static encode2D(x, y) {
const buffer = new ArrayBuffer(8); // 2 floats * 4 bytes
const view = new DataView(buffer);
view.setFloat32(0, x, true);
view.setFloat32(4, y, true);
return buffer;
}
static decode3D(buffer) {
const view = new DataView(buffer);
return {
x: view.getFloat32(0, true),
y: view.getFloat32(4, true),
z: view.getFloat32(8, true)
};
}
static decode2D(buffer) {
const view = new DataView(buffer);
return {
x: view.getFloat32(0, true),
y: view.getFloat32(4, true)
};
}
}
// Usage
const positionBuffer = PositionEncoder.encode3D(100.5, 50.2, 0);
const position = PositionEncoder.decode3D(positionBuffer);
Compact Integer Encoding
class CompactEncoder {
// Encode coordinates as 16-bit integers (saves 50% space)
static encodePosition(x, y, scale = 100) {
const buffer = new ArrayBuffer(4); // 2 uint16 * 2 bytes
const view = new DataView(buffer);
view.setUint16(0, Math.round(x * scale), true);
view.setUint16(2, Math.round(y * scale), true);
return buffer;
}
static decodePosition(buffer, scale = 100) {
const view = new DataView(buffer);
return {
x: view.getUint16(0, true) / scale,
y: view.getUint16(2, true) / scale
};
}
// Encode player state efficiently
static encodePlayerState(player) {
const buffer = new ArrayBuffer(7); // Optimized layout
const view = new DataView(buffer);
view.setUint16(0, Math.round(player.x * 100), true); // Position X (2 bytes)
view.setUint16(2, Math.round(player.y * 100), true); // Position Y (2 bytes)
view.setUint8(4, Math.min(255, player.health)); // Health (1 byte)
view.setUint8(5, player.facing); // Direction (1 byte)
view.setUint8(6, player.flags); // State flags (1 byte)
return buffer;
}
static decodePlayerState(buffer) {
const view = new DataView(buffer);
return {
x: view.getUint16(0, true) / 100,
y: view.getUint16(2, true) / 100,
health: view.getUint8(4),
facing: view.getUint8(5),
flags: view.getUint8(6)
};
}
}
3. Custom Protocol Design
Message Header System
class GameProtocol {
// Message types
static POSITION_UPDATE = 0x01;
static ACTION_EVENT = 0x02;
static GAME_STATE = 0x03;
static PLAYER_JOIN = 0x04;
static PLAYER_LEAVE = 0x05;
static encodeMessage(type, data) {
const dataBuffer = this.encodeData(type, data);
const buffer = new ArrayBuffer(3 + dataBuffer.byteLength); // 2 bytes length + 1 byte type + data
const view = new DataView(buffer);
view.setUint16(0, dataBuffer.byteLength, true);
view.setUint8(2, type);
const dataView = new Uint8Array(buffer, 3);
dataView.set(new Uint8Array(dataBuffer));
return buffer;
}
static decodeMessage(buffer) {
const view = new DataView(buffer);
const dataLength = view.getUint16(0, true);
const messageType = view.getUint8(2);
const dataBuffer = buffer.slice(3, 3 + dataLength);
const data = this.decodeData(messageType, dataBuffer);
return { type: messageType, data };
}
static encodeData(type, data) {
switch (type) {
case this.POSITION_UPDATE:
return CompactEncoder.encodePlayerState(data);
case this.ACTION_EVENT:
return this.encodeAction(data);
case this.GAME_STATE:
return this.encodeGameState(data);
default:
throw new Error(`Unknown message type: ${type}`);
}
}
static decodeData(type, buffer) {
switch (type) {
case this.POSITION_UPDATE:
return CompactEncoder.decodePlayerState(buffer);
case this.ACTION_EVENT:
return this.decodeAction(buffer);
case this.GAME_STATE:
return this.decodeGameState(buffer);
default:
throw new Error(`Unknown message type: ${type}`);
}
}
static encodeAction(action) {
const buffer = new ArrayBuffer(6); // 1 byte type + 1 byte target + 4 bytes timestamp
const view = new DataView(buffer);
view.setUint8(0, this.getActionCode(action.type));
view.setUint8(1, action.targetId || 0);
view.setUint32(2, action.timestamp || Date.now(), true);
return buffer;
}
static decodeAction(buffer) {
const view = new DataView(buffer);
return {
type: this.getActionName(view.getUint8(0)),
targetId: view.getUint8(1),
timestamp: view.getUint32(2, true)
};
}
static getActionCode(actionType) {
const actions = {
'move': 1, 'attack': 2, 'defend': 3, 'jump': 4, 'shoot': 5
};
return actions[actionType] || 0;
}
static getActionName(actionCode) {
const actions = {
1: 'move', 2: 'attack', 3: 'defend', 4: 'jump', 5: 'shoot'
};
return actions[actionCode] || 'unknown';
}
}
Advanced Encoding Techniques
Delta Compression
class DeltaCompressor {
constructor() {
this.lastState = null;
}
// Compress current state against last state
compress(currentState) {
if (!this.lastState) {
this.lastState = JSON.parse(JSON.stringify(currentState));
return this.encodeFullState(currentState);
}
const delta = this.calculateDelta(this.lastState, currentState);
this.lastState = JSON.parse(JSON.stringify(currentState));
return this.encodeDelta(delta);
}
calculateDelta(last, current) {
const delta = { type: 'delta', changes: [] };
// Compare player positions
for (const playerId in current.players) {
const lastPlayer = last.players[playerId];
const currentPlayer = current.players[playerId];
if (!lastPlayer ||
Math.abs(lastPlayer.x - currentPlayer.x) > 0.1 ||
Math.abs(lastPlayer.y - currentPlayer.y) > 0.1) {
delta.changes.push({
type: 'position',
playerId,
x: currentPlayer.x,
y: currentPlayer.y
});
}
}
return delta;
}
encodeFullState(state) {
const jsonString = JSON.stringify({ type: 'full', ...state });
return new TextEncoder().encode(jsonString).buffer;
}
encodeDelta(delta) {
const jsonString = JSON.stringify(delta);
return new TextEncoder().encode(jsonString).buffer;
}
decompress(buffer) {
const decoder = new TextDecoder();
const jsonString = decoder.decode(buffer);
const data = JSON.parse(jsonString);
if (data.type === 'full') {
return data;
} else if (data.type === 'delta') {
return this.applyDelta(this.lastState, data);
}
return null;
}
applyDelta(baseState, delta) {
const newState = JSON.parse(JSON.stringify(baseState));
delta.changes.forEach(change => {
if (change.type === 'position') {
if (!newState.players[change.playerId]) {
newState.players[change.playerId] = {};
}
newState.players[change.playerId].x = change.x;
newState.players[change.playerId].y = change.y;
}
});
return newState;
}
}
Bit Packing
class BitPacker {
// Pack multiple boolean flags into a single byte
static packFlags(flags) {
let packed = 0;
packed |= flags.isMoving ? 1 : 0;
packed |= (flags.isJumping ? 1 : 0) << 1;
packed |= (flags.isAttacking ? 1 : 0) << 2;
packed |= (flags.isDefending ? 1 : 0) << 3;
packed |= (flags.isStunned ? 1 : 0) << 4;
packed |= (flags.isInvisible ? 1 : 0) << 5;
packed |= (flags.isPoweredUp ? 1 : 0) << 6;
packed |= (flags.isDead ? 1 : 0) << 7;
return packed;
}
static unpackFlags(packed) {
return {
isMoving: !!(packed & 1),
isJumping: !!(packed & 2),
isAttacking: !!(packed & 4),
isDefending: !!(packed & 8),
isStunned: !!(packed & 16),
isInvisible: !!(packed & 32),
isPoweredUp: !!(packed & 64),
isDead: !!(packed & 128)
};
}
// Compact player state with bit packing
static encodeCompactPlayer(player) {
const buffer = new ArrayBuffer(6); // Super compact
const view = new DataView(buffer);
view.setUint16(0, Math.round(player.x * 50), true); // Position X (2 bytes)
view.setUint16(2, Math.round(player.y * 50), true); // Position Y (2 bytes)
view.setUint8(4, player.health); // Health (1 byte)
view.setUint8(5, this.packFlags(player.flags)); // All flags (1 byte)
return buffer;
}
static decodeCompactPlayer(buffer) {
const view = new DataView(buffer);
return {
x: view.getUint16(0, true) / 50,
y: view.getUint16(2, true) / 50,
health: view.getUint8(4),
flags: this.unpackFlags(view.getUint8(5))
};
}
}
Performance Comparison
Message Size Analysis
| Data Type | JSON Size | Binary Size | Savings |
|---|---|---|---|
| Position (x,y) | ~20 bytes | 8 bytes | 60% |
| Player State | ~80 bytes | 7 bytes | 91% |
| Game State | ~500 bytes | ~200 bytes | 60% |
Encoding Speed Test
function benchmarkEncoding() {
const testData = {
players: Array.from({ length: 10 }, (_, i) => ({
id: `player${i}`,
x: Math.random() * 1000,
y: Math.random() * 1000,
health: Math.floor(Math.random() * 100),
flags: {
isMoving: Math.random() > 0.5,
isJumping: Math.random() > 0.8,
isAttacking: Math.random() > 0.7
}
})),
score: Math.floor(Math.random() * 10000),
level: Math.floor(Math.random() * 10) + 1
};
// JSON encoding
const jsonStart = performance.now();
for (let i = 0; i < 1000; i++) {
const json = JSON.stringify(testData);
const buffer = new TextEncoder().encode(json).buffer;
}
const jsonTime = performance.now() - jsonStart;
// Binary encoding
const binaryStart = performance.now();
for (let i = 0; i < 1000; i++) {
const buffer = GameProtocol.encodeMessage(
GameProtocol.GAME_STATE,
testData
);
}
const binaryTime = performance.now() - binaryStart;
console.log(`JSON encoding: ${jsonTime.toFixed(2)}ms`);
console.log(`Binary encoding: ${binaryTime.toFixed(2)}ms`);
console.log(`Speed improvement: ${(jsonTime / binaryTime).toFixed(2)}x`);
}
Error Handling and Validation
class SafeEncoder {
static validateAndEncode(data, schema) {
try {
this.validate(data, schema);
return this.encode(data);
} catch (error) {
console.error('Encoding validation failed:', error);
return null;
}
}
static validate(data, schema) {
for (const key in schema) {
if (schema[key].required && !(key in data)) {
throw new Error(`Required field missing: ${key}`);
}
if (key in data) {
const expectedType = schema[key].type;
const actualType = typeof data[key];
if (expectedType !== actualType) {
throw new Error(`Type mismatch for ${key}: expected ${expectedType}, got ${actualType}`);
}
if (schema[key].min !== undefined && data[key] < schema[key].min) {
throw new Error(`Value too small for ${key}: ${data[key]} < ${schema[key].min}`);
}
if (schema[key].max !== undefined && data[key] > schema[key].max) {
throw new Error(`Value too large for ${key}: ${data[key]} > ${schema[key].max}`);
}
}
}
}
static safeDecode(buffer, expectedType) {
try {
if (!buffer || buffer.byteLength === 0) {
throw new Error('Empty buffer');
}
const message = GameProtocol.decodeMessage(buffer);
if (message.type !== expectedType) {
throw new Error(`Unexpected message type: ${message.type}, expected ${expectedType}`);
}
return message.data;
} catch (error) {
console.error('Decoding failed:', error);
return null;
}
}
}
// Usage with schema validation
const playerSchema = {
x: { type: 'number', required: true },
y: { type: 'number', required: true },
health: { type: 'number', required: true, min: 0, max: 100 },
flags: { type: 'object', required: false }
};
const playerData = { x: 100, y: 50, health: 85 };
const buffer = SafeEncoder.validateAndEncode(playerData, playerSchema);
Next Steps
- Message Best Practices - Optimization strategies and patterns
- Message Format - Understanding ArrayBuffer structure
- Reliable vs Unreliable - Choosing delivery types