Skip to content

Component Serialization

Component values are not saved in the main buffer by default. The raw values get pushed into the variants array and only referenced by their position in the array in the buffer.

You can provide a serialize/deserialize table to a component. This will allow you to compress the component value into a buffer that will be appended into the main buffer, allowing you to reduce bandwidth usage.

This will also avoid unreliable packets getting over the 1KB limit, which is necessary if you want to use actual unreliable remotes.

You can add a serializer to a component by adding replecs.serdes. It’s important that this function is set in both the client and server.

This value should be a table with this structure:

type SerdesTable = {
serialize: (value: any) -> buffer,
deserialize: (buf: buffer) -> any,
bytespan: number?,
includes_variants: boolean?,
}

Let’s see an example of how we can add a serializer to a position component.

local replecs = require("@pkg/replecs")
local world = require("world")
local position = world:component() :: Entity<Vector3>
world:set(position, replecs.serdes, {
serialize = function(value: Vector3)
local buf = buffer.create(12)
buffer.writef32(buf, 0, transform.X)
buffer.writef32(buf, 4, transform.Y)
buffer.writef32(buf, 8, transform.Z)
return buf
end,
deserialize = function(buf: buffer)
local x = buffer.readf32(buf, 0)
local y = buffer.readf32(buf, 4)
local z = buffer.readf32(buf, 8)
return Vector3.new(x, y, z)
end,
}

If you want to optimize the bandwidth usage a little more, you can hardcode the size of the serialize buffer. This will skip saving the size of the buffer in the packet which is usually 1 Byte.

The bytespan should be taken as a micro-optimization, if the size of the buffer is dynamic, don’t use this.

local position = world:component() :: Entity<Vector3>
world:set(position, replecs.serdes, {
bytespan = 12,
serialize = function(value: Vector3)
local buf = buffer.create(12)
...
end,
deserialize = function(value: Vector3)
...
end,
})

If the value of the component can’t be fully serialized into a buffer, you can return the unknown values in an array after the buffer. These values will be passed to the deserializer as the second argument.

For replecs to be able to handle this, you need to set includes_variants to true.

local replecs = require("@pkg/replecs")
local world = require("world")
type Data = {
can_serialize: number,
cannot_serialize: Instance,
}
local data = world:component() :: Entity<Data>
world:set(data, replecs.serdes, {
includes_variants = true,
serialize = function(value: Data)
local buf = buffer.create(4)
buffer.writef32(buf, 0, value.can_serialize)
return buf, { value.cannot_serialize }
end,
deserialize = function(buf: buffer, variants: { Instance }): Data
local can_serialize = buffer.readf32(buf, 0)
local cannot_serialize = variants[1]
return {
can_serialize = can_serialize,
cannot_serialize = cannot_serialize,
}
end,
})

A preprocessor lets you transform a component value before it is replicated, and transform it back on the client. Unlike a serdes, it does not deal with buffers, it works with plain values and can be used with or without a serdes.

This is useful when the value stored in the world is not what you want to send over the network, for example an object with a metatable, an Enum item, or a table with fields the client doesn’t need.

The preprocessor is set with replicator:set_preprocessor(component, preprocessor) and removed with replicator:remove_preprocessor(component). It’s important that the preprocessor is set in both the client and server, this can be done in the holder returned by replecs.create, which forwards it to both.

The preprocessor should be a table with this structure:

type Preprocessor<Before, After> = {
serialize: (value: Before) -> After,
deserialize: (value: After) -> Before,
}

Let’s see an example where we replicate a class object. Metatables don’t survive replication, so we send the plain data and rebuild the object in the client.

local replecs = require("@pkg/replecs")
local replicator = require("@shared/replicator")
local Inventory = require("inventory")
local inventory = world:component() :: Entity<Inventory.Inventory>
replicator:set_preprocessor(inventory, {
serialize = function(value: Inventory.Inventory)
return value.items
end,
deserialize = function(items: { string })
return Inventory.new(items)
end,
})

If a component has both a preprocessor and a serdes, the preprocessor runs first in the server and last in the client:

  • Server: preprocessor.serialize → serdes.serialize
  • Client: serdes.deserialize → preprocessor.deserialize

This means the serdes only ever sees the preprocessed value. You can use this to keep serializers simple, by turning a complex value into something easy to write into a buffer.

local Material = { "Plastic", "Wood", "Metal" }
local material_index = {}
for index, name in Material do
material_index[name] = index
end
local material = world:component() :: Entity<string>
replicator:set_preprocessor(material, {
serialize = function(name: string): number
return material_index[name]
end,
deserialize = function(index: number): string
return Material[index]
end,
})
world:set(material, replecs.serdes, {
bytespan = 1,
serialize = function(index: number)
local buf = buffer.create(1)
buffer.writeu8(buf, 0, index)
return buf
end,
deserialize = function(buf: buffer)
return buffer.readu8(buf, 0)
end,
})

You can also set a preprocessor on a pair. When replicating a pair, replecs looks for a preprocessor in the exact pair first, and then falls back to the wildcard pair of the relation.

-- applies to every `owns` pair, regardless of the target
replicator:set_preprocessor(jecs.pair(components.owns, jecs.Wildcard), {
serialize = function(value) ... end,
deserialize = function(value) ... end,
})