Custom Messages & RPC

Send custom messages and remote procedure calls between clients and server for game events, chat, and actions.

Beyond automatic state synchronization, Web Engine provides flexible messaging systems for game events, player actions, and remote procedure calls (RPCs) with type safety and reliability options.

Message Types#

Custom Messages

Send any serializable data between clients and server with custom message types.

Remote Procedure Calls

Type-safe RPC system for invoking server/client methods with return values.

Reliable Delivery

Choose between unreliable (fast) or reliable (guaranteed) delivery per message.

Binary Packets

Send raw binary data for maximum performance and minimal overhead.

Built-in Packet Types#

Web Engine defines several packet types for common multiplayer operations:

Packet Types
typescript
import { PacketType } from '@web-engine/core/network';
export enum PacketType {
CONNECT = 0, // Initial connection
WELCOME = 1, // Server welcome with client ID
STATE_UPDATE = 2, // Entity state synchronization
INPUT = 3, // Player input packets
SPAWN = 4, // Entity spawning
DESPAWN = 5, // Entity removal
CHAT = 6, // Chat messages
SDP_OFFER = 7, // WebRTC offer (P2P)
SDP_ANSWER = 8, // WebRTC answer (P2P)
ICE_CANDIDATE = 9, // WebRTC ICE candidate (P2P)
SCENE_DATA = 10 // Scene serialization
}

Sending Custom Messages#

Send custom messages using the packet system with type-safe interfaces:

Custom Message Example
typescript
import { NetworkManager, PacketType } from '@web-engine/core/network';
const network = NetworkManager.getInstance();
// Define custom packet interface
interface ChatPacket {
type: PacketType.CHAT;
senderId: number;
timestamp: number;
name: string;
message: string;
}
// Send chat message
function sendChatMessage(message: string) {
const packet: ChatPacket = {
type: PacketType.CHAT,
senderId: network.getClientId(),
timestamp: network.getServerTime(),
name: 'Player1',
message: message
};
network.sendPacket(packet);
}
// Listen for chat messages
network.on('chat', (packet: ChatPacket) => {
console.log(`${packet.name}: ${packet.message}`);
displayChat(packet.name, packet.message);
});

Colyseus Room Messages#

When using Colyseus transport, you can also use the room's message system directly:

Server-Side Room Messages
typescript
// In GameRoom.ts (server)
export class GameRoom extends Room<GameRoomState> {
onCreate(options: any) {
// Register message handler
this.onMessage('player_action', (client, message) => {
console.log(`Player ${client.sessionId} action:`, message);
// Validate action
if (!this.isValidAction(client, message)) {
return;
}
// Process action
this.handlePlayerAction(client.sessionId, message);
// Broadcast to other players
this.broadcast('player_action', {
playerId: client.sessionId,
action: message.action,
timestamp: Date.now()
}, { except: client });
});
}
private isValidAction(client: Client, message: any): boolean {
// Server-side validation
const player = this.state.players.get(client.sessionId);
return player && player.canPerformAction(message.action);
}
}
Client-Side Room Messages
typescript
// Client-side: send message to room
const transport = network.getTransport();
if (transport instanceof ColyseusTransport) {
const room = transport.room;
// Send message to server
room.send('player_action', {
action: 'jump',
timestamp: Date.now()
});
// Listen for messages from server
room.onMessage('player_action', (message) => {
console.log('Player action:', message);
handleRemotePlayerAction(message);
});
}

Remote Procedure Calls (RPC)#

The RPC system provides type-safe remote method invocation with automatic serialization:

RPC Decorator Example
typescript
import { NetworkRPC, getRPCSystem } from '@web-engine/core/network';
// Define networked class with RPC methods
class PlayerController {
@NetworkRPC({ target: 'server', reliable: true })
async requestJump(timestamp: number): Promise<boolean> {
// This method runs on the server
console.log('Jump request at', timestamp);
// Validate jump
const canJump = this.validateJump();
if (canJump) {
this.applyJumpForce();
}
return canJump;
}
@NetworkRPC({ target: 'client', reliable: false })
playEffect(effectId: string, position: [number, number, number]): void {
// This method runs on the client
this.effectSystem.play(effectId, position);
}
}
// Call RPC from client
const rpc = getRPCSystem();
const success = await rpc.call<boolean>(
'PlayerController',
'requestJump',
Date.now()
);
if (success) {
console.log('Jump successful!');
} else {
console.log('Jump denied by server');
}

RPC Benefits

RPCs provide type-safe method calls with automatic serialization, timeout handling, and error propagation. They're ideal for request-response patterns like inventory actions, purchases, or any operation that needs confirmation.

Reliable vs Unreliable Messages#

Choose the appropriate delivery guarantee for your messages:

Unreliable Messages (Default)#

Fast but not guaranteed to arrive or arrive in order. Use for:

  • State updates (position, rotation, velocity)
  • Visual effects that can be missed
  • Non-critical gameplay events
  • High-frequency updates where latest is most important

Reliable Messages#

Guaranteed delivery with automatic retransmission. Use for:

  • Entity spawn/despawn events
  • Player actions (attack, use item, etc.)
  • Chat messages
  • Game state transitions (round start/end)
  • Critical gameplay events
Reliable Delivery Example
typescript
import {
ReliableUDP,
PacketChannel,
NetworkManager
} from '@web-engine/core/network';
const network = NetworkManager.getInstance();
// Send with reliable ordered delivery
network.sendWithChannel(packet, PacketChannel.RELIABLE_ORDERED);
// Send with unreliable sequenced delivery (drop old)
network.sendWithChannel(stateUpdate, PacketChannel.UNRELIABLE_SEQUENCED);
// Channel options:
// - RELIABLE_ORDERED: Guaranteed delivery, in order
// - RELIABLE_UNORDERED: Guaranteed delivery, any order
// - UNRELIABLE_SEQUENCED: Drop old packets, keep newest
// - UNRELIABLE: Fast, no guarantees

Message Serialization#

Messages are automatically serialized to JSON or binary formats. For maximum performance, use binary serialization:

Binary Message Serialization
typescript
import { PacketSerializer } from '@web-engine/core/network';
const serializer = new PacketSerializer();
// Serialize packet to binary
const buffer = serializer.serialize(packet);
// Deserialize binary to packet
const packet = serializer.deserialize(buffer);
// Send binary data directly
const binaryData = new ArrayBuffer(64);
const view = new DataView(binaryData);
// Write header
view.setUint8(0, PacketType.SPAWN);
view.setUint32(1, senderId, true);
view.setFloat64(5, timestamp, true);
// Write entity data
view.setFloat32(13, position.x, true);
view.setFloat32(17, position.y, true);
view.setFloat32(21, position.z, true);
// Send binary
network.sendBinary(binaryData);

When to Use Binary

Use binary serialization for high-frequency updates (state sync, input) or when sending large amounts of data (scene serialization). For infrequent events (chat, actions), JSON serialization is more convenient and readable.

Packet Validation#

Always validate incoming packets to prevent malicious or malformed data:

Server-Side Validation
typescript
import {
validateIncomingPacket,
isInputPacket,
InputPacket
} from '@web-engine/core/network';
// Type guards for packet validation
network.on('packet', (packet) => {
// Validate packet structure
const validated = validateIncomingPacket(packet);
if (!validated) {
console.warn('Invalid packet received:', packet);
return;
}
// Type-specific validation
if (isInputPacket(validated)) {
handleInput(validated);
}
});
function handleInput(packet: InputPacket) {
// Validate input ranges
const move = packet.inputs.move;
if (Math.abs(move[0]) > 1 || Math.abs(move[1]) > 1) {
console.warn('Invalid move vector:', move);
return;
}
// Validate sequence number (prevent replay attacks)
const lastSeq = getLastSequence(packet.senderId);
if (packet.sequence <= lastSeq) {
console.warn('Out-of-order or duplicate input:', packet.sequence);
return;
}
// Process valid input
processPlayerInput(packet.senderId, packet.inputs);
setLastSequence(packet.senderId, packet.sequence);
}

Message Priorities#

Use priority queues to ensure important messages are sent first during bandwidth constraints:

Priority Queue Example
typescript
import { PacketPriorityQueue } from '@web-engine/core/network';
const queue = new PacketPriorityQueue(1000);
// Enqueue with priority (higher = more important)
queue.enqueue(chatPacket, 1); // Low priority
queue.enqueue(stateUpdate, 5); // Normal priority
queue.enqueue(spawnPacket, 10); // High priority
queue.enqueue(criticalEvent, 100); // Critical priority
// Process queue (sends highest priority first)
while (queue.size() > 0 && bandwidthAvailable()) {
const item = queue.dequeue();
if (item) {
network.sendPacket(item.packet);
}
}

Messaging Best Practices#

  • Choose reliability wisely — Reliable messages have overhead. Use unreliable for high-frequency updates.
  • Validate everything — Never trust client data. Validate all messages on the server.
  • Use priorities — Prioritize critical messages (spawn, actions) over cosmetic ones (chat, emotes).
  • Batch when possible — Combine multiple small messages into one packet to reduce overhead.
  • Rate limit — Prevent spam by rate-limiting message handlers (especially chat and actions).
  • Type safety — Use TypeScript interfaces and type guards to catch errors at compile time.
Multiplayer | Web Engine Docs | Web Engine Docs