Skip to main content

Protocol Networking

Voxelize uses Protocol Buffers for efficient binary serialization of network messages. Understanding the protocol helps when building custom features.

Message Structure​

All messages follow the MessageProtocol structure:

Message Protocol
interface MessageProtocol {
type: MessageType;
json?: string;
text?: string;
chat?: ChatMessage;
peers?: Peer[];
entities?: Entity[];
chunks?: Chunk[];
updates?: Update[];
events?: Event[];
method?: Method;
}

Message Types​

TypeDirectionPurpose
INITServer → ClientInitial world state
JOINServer → ClientPlayer joined notification
LEAVEServer → ClientPlayer left notification
PEERBidirectionalPlayer position/metadata updates
ENTITYServer → ClientEntity create/update/delete
LOADBidirectionalChunk requests and data
UNLOADClient → ServerChunk unload notification
UPDATEServer → ClientWorld stats (time, etc.)
CHATBidirectionalChat messages
EVENTBidirectionalCustom events
METHODBidirectionalRPC calls
TRANSPORTBidirectionalCustom binary data

Entity Protocol​

Entity messages contain:

Entity Protocol
interface EntityProtocol {
id: string;
type: string;
operation: "CREATE" | "UPDATE" | "DELETE";
metadata?: string; // JSON string
}

Operations:

  • CREATE - New entity spawned
  • UPDATE - Entity state changed
  • DELETE - Entity removed

Peer Protocol​

Peer messages contain:

Peer Protocol
interface PeerProtocol {
id: string;
username: string;
metadata: string; // JSON with position, direction, etc.
}

Chunk Protocol​

Chunk data is compressed:

Chunk Protocol
interface ChunkProtocol {
x: number;
z: number;
id: string;
meshes: MeshProtocol[];
voxels: Uint8Array; // Compressed voxel data
lights: Uint8Array; // Compressed light data
}

Custom Transport​

For custom binary data, use the transport system:

Client Side​

Client Transport
network.send({
type: "TRANSPORT",
json: JSON.stringify({
action: "custom-action",
data: { x: 10, y: 20 },
}),
});

Server Side​

Server Transport Handler
world.set_transport_handle(|world, value| {
let action = value.get("action").and_then(|v| v.as_str());

match action {
Some("custom-action") => {
let data = value.get("data").unwrap();
// Process custom data
}
_ => {}
}
});

Network Flow​

Connection Sequence​

  1. Client connects via WebSocket
  2. Client sends JOIN with world name
  3. Server sends INIT with world config, blocks, existing peers/entities
  4. Client processes INIT and starts requesting chunks

Frame Loop​

Each frame:

  1. Client receives: Server messages queued
  2. network.sync(): Messages dispatched to interceptors
  3. Game logic: Updates based on received data
  4. Interceptors queue packets: Add to packets arrays
  5. network.flush(): All queued packets sent to server

Server Tick​

Each server tick:

  1. Process incoming messages
  2. Run ECS systems
  3. Collect changed entities/peers
  4. Send updates to interested clients

Debugging Network​

Client Logging​

Network Debugging
const debugInterceptor = {
onMessage(message: MessageProtocol) {
console.log("Received:", message.type, message);
},
};

network.register(debugInterceptor);

Message Inspection​

Message Inspection
network.on("message", (raw: ArrayBuffer) => {
console.log("Raw message size:", raw.byteLength);
});

Performance Considerations​

Chunk Loading​

Chunks are loaded based on:

  • Player position and direction (frustum culling)
  • Render radius configuration
  • Server max_chunks_per_tick setting

Entity Updates​

Entities only send updates when metadata changes:

Efficient Updates
// Only changed fields are sent
metadata.set("health", &new_health); // Sends if health changed

Peer Updates​

Peer positions are sent every frame, but interpolated on the client:

Peer Interpolation
onPeerUpdate(peer, metadata) {
// Don't set position directly, interpolate
peer.targetPosition.set(...metadata.position);
}

update() {
peer.position.lerp(peer.targetPosition, 0.1);
}

Read on to learn about custom blocks.