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:
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:
import { NetworkManager, PacketType } from '@web-engine/core/network'; const network = NetworkManager.getInstance(); // Define custom packet interfaceinterface ChatPacket { type: PacketType.CHAT; senderId: number; timestamp: number; name: string; message: string;} // Send chat messagefunction 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 messagesnetwork.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:
// 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: send message to roomconst 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:
import { NetworkRPC, getRPCSystem } from '@web-engine/core/network'; // Define networked class with RPC methodsclass 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 clientconst 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
import { ReliableUDP, PacketChannel, NetworkManager} from '@web-engine/core/network'; const network = NetworkManager.getInstance(); // Send with reliable ordered deliverynetwork.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 guaranteesMessage Serialization#
Messages are automatically serialized to JSON or binary formats. For maximum performance, use binary serialization:
import { PacketSerializer } from '@web-engine/core/network'; const serializer = new PacketSerializer(); // Serialize packet to binaryconst buffer = serializer.serialize(packet); // Deserialize binary to packetconst packet = serializer.deserialize(buffer); // Send binary data directlyconst binaryData = new ArrayBuffer(64);const view = new DataView(binaryData); // Write headerview.setUint8(0, PacketType.SPAWN);view.setUint32(1, senderId, true);view.setFloat64(5, timestamp, true); // Write entity dataview.setFloat32(13, position.x, true);view.setFloat32(17, position.y, true);view.setFloat32(21, position.z, true); // Send binarynetwork.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:
import { validateIncomingPacket, isInputPacket, InputPacket} from '@web-engine/core/network'; // Type guards for packet validationnetwork.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:
import { PacketPriorityQueue } from '@web-engine/core/network'; const queue = new PacketPriorityQueue(1000); // Enqueue with priority (higher = more important)queue.enqueue(chatPacket, 1); // Low priorityqueue.enqueue(stateUpdate, 5); // Normal priorityqueue.enqueue(spawnPacket, 10); // High priorityqueue.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.