For the complete documentation index, see llms.txt. This page is also available as Markdown.

Custom Types in Properties

Summary

Entity, room and player properties hold a ksMultiType, which covers primitives, vectors, colors, quaternions, strings, and arrays of each. Real games quickly need something structured instead: an inventory, a loadout, a stats block, a build order.

This tutorial shows how to put your own type in a property by implementing ksISerializable, how to read it back, and the one rule about it that catches everyone: a packed property is a snapshot, not a live reference.

Requirements

What ksMultiType Already Covers

Before writing a custom type, check whether you need one. ksMultiType natively stores byte, short, ushort, int, uint, long, ulong, float, double, char, bool, string, ksVector2, ksVector3, ksVector2Int, ksVector3Int, ksColor, ksQuaternion, and an array form of each.

A custom type earns its place when a value only makes sense as a group. Three parallel float arrays that must stay index-aligned are a custom type waiting to happen. A single cooldown timer is not.

Implementing ksISerializable

The interface is two methods, and they must mirror each other exactly: whatever Serialize writes, Deserialize reads back in the same order and the same widths.

Put the type in your common assembly so both sides can use it. See How Server Code Is Compiled for where that is.

Loadout.cs

using KS.Reactor;

public class WeaponSlot : ksISerializable
{
    public string Weapon;
    public ushort Ammo;

    // Required. ReadSerializableArray<T> constrains T to new(), and declaring any other
    // constructor removes the implicit parameterless one.
    public WeaponSlot() { }

    public WeaponSlot(string weapon, ushort ammo)
    {
        Weapon = weapon;
        Ammo = ammo;
    }

    public void Serialize(ksStreamBuffer output)
    {
        output.Write(Weapon);
        output.Write(Ammo);
    }

    public void Deserialize(ksStreamBuffer input)
    {
        Weapon = input.ReadString();
        Ammo = input.ReadUShort();
    }
}

public class Loadout : ksISerializable
{
    public string Name;
    public byte Level;
    public ksVector3 SpawnOffset;
    public WeaponSlot[] Slots;

    public void Serialize(ksStreamBuffer output)
    {
        output.Write(Name);
        output.Write(Level);
        output.Write(SpawnOffset.X);
        output.Write(SpawnOffset.Y);
        output.Write(SpawnOffset.Z);

        // Nested custom types compose. The array form writes its own length.
        output.WriteSerializableArray(Slots);
    }

    public void Deserialize(ksStreamBuffer input)
    {
        Name = input.ReadString();
        Level = input.ReadByte();
        float x = input.ReadFloat();
        float y = input.ReadFloat();
        float z = input.ReadFloat();
        SpawnOffset = new ksVector3(x, y, z);
        Slots = input.ReadSerializableArray<WeaponSlot>();
    }
}

Pick the narrowest type that fits. Ammo is a ushort rather than an int because a magazine count never exceeds 65535, and that choice costs two bytes every time the property changes.

ksStreamBuffer has matched pairs for every primitive, plus WriteEncodedValue and ReadEncodedInt and friends for 7 bit encoded integers, which are smaller for values that are usually small. There is no automatic schema. If Serialize and Deserialize disagree, you get garbage rather than an error, so keep them adjacent and edit them together.

Storing and Reading

Pack with ksMultiType.FromSerializable, unpack with ToSerializable<T>.

Server

Client

As with any property, read the current value in Initialize as well as handling the change event. A client that sees the entity for the first time receives the value as initial state, not as a change, so a handler alone would miss it.

Arrays of a custom type have their own pair, which avoids wrapping the array in a container type:

A Packed Property Is a Snapshot

This is the rule to internalise. FromSerializable runs your Serialize immediately and stores the resulting bytes. The property does not hold a reference to your object. Changing the object afterwards changes nothing that anyone else will ever see.

Confirming this directly, packing a loadout, mutating the source, then unpacking:

The restored copy kept the values as they were at the moment of packing. Unpacking likewise produces a fresh object each call, so ToSerializable<T>() twice gives you two unrelated instances, and writing to either changes nothing.

The practical consequence is that every change means re-serializing and reassigning the whole value. Structure your data so that things which change at different rates live in different properties. A loadout that changes on respawn and an ammo count that changes every shot should not share one property, or you will resend the weapon names on every trigger pull.

What You Give Up

A custom type is opaque to everything above the byte level, which costs you three things.

No field level delta. The property is replaced as a unit. Changing one number resends the entire payload.

No prediction. The predictors interpolate typed values, and their predicted property types are the numeric and vector kinds listed in Motion Prediction. A packed custom type is not among them, so it arrives in discrete steps on sync frames. Anything that needs to move smoothly should be a float or a vector property in its own right, even when it is conceptually part of a larger structure.

No inspector support. A packed property is bytes, so tooling cannot show you the fields.

Versioning

Your serialized format is a wire format. A client built against one version of Deserialize and a server built against another will not error, they will misread. If the type will outlive a single release, spend a byte on a version tag:

Common Problems

Symptom
Cause

Values come back as garbage, or reads run off the end of the buffer

Serialize and Deserialize disagree on order or width. They are not checked against each other.

Changes to the object never reach clients

The property was not reassigned. Mutating the source object after packing does nothing.

ReadSerializableArray<T> will not compile

T needs a public parameterless constructor. Declaring any other constructor removes the implicit one.

The value is empty on a client that joined late

Initialize handles the change event but does not read the current property value.

Cannot convert null to 'ksMultiType' because it is a non-nullable value type

ksMultiType is a struct. There is no null to guard against. Test value.Type == ksMultiType.Types.NULL instead.

The property is not among the predictable types

Correct, custom types cannot be predicted. Split out anything that must interpolate.

Bandwidth is worse than expected

The whole payload resends on every change. Split fields that change at different rates into separate properties.

Where to Go Next

Last updated