Structured Payloads

Why Structured Payloads

A raw byte payload, such as a struct's bytes, is fast and needs no serialization code, but it is opaque. The receiver has to know the exact layout, and nothing on the wire says what the bytes mean, so the two ends have to agree on the layout out of band. In C++ that means building both ends from the same headers with the same compiler settings.

A structured payload is the self-describing alternative. It is built from exactly the types the Photon wire protocol understands, so it carries its own type information and can be read by clients on other platforms. In C++ it is a RealtimeValue, a variant declared in RealtimeValue.h in the RealtimeCore::Matchmaking namespace. In JavaScript it is a plain JavaScript value. The wire type is inferred from the JavaScript type or set explicitly through helper functions.

Use it for anything that crosses a version or platform, for anything with optional or variable-length parts and for payloads that are easier to describe as a map than as a struct. Keep raw bytes for the high-frequency, fixed-layout traffic where every byte counts.

Room and player custom properties use the same values, so everything on this page about building and reading values applies there too. See Custom Properties.

Value Types

Every structured value has one of the following wire types, listed as the ValueType enumeration names them. In C++, Type() reports which one a RealtimeValue holds, and a default-constructed RealtimeValue is Null. In JavaScript, the wire type follows from the value you send, and a received value comes back as the plain JavaScript value in the table.

ValueType C++ type JavaScript
Null std::monostate null; undefined is sent as null
Bool bool boolean
Byte uint8_t byte(n), reads as a number
Int16 int16_t int16(n), reads as a number
Int32 int32_t an integer number within 32 bits, or int32(n)
Int64 int64_t a bigint, or int64(n), reads as a bigint
Float float float(n), reads as a number
Double double any other number, or double(n)
String StringType string
ByteArray std::vector<uint8_t> Uint8Array
Int16Array std::vector<int16_t> Int16Array
Int32Array std::vector<int32_t> Int32Array
Int64Array std::vector<int64_t> BigInt64Array
FloatArray std::vector<float> Float32Array
DoubleArray std::vector<double> Float64Array
BoolArray std::vector<bool> bools([...]), reads as an array of booleans
StringArray std::vector<StringType> strings([...]), reads as an array of strings
Array RealtimeArray an Array, whatever its elements are
Map RealtimeMap a plain object or a Map, reads as a plain object when every key is a string, otherwise as a Map
Dictionary RealtimeDictionary dictionary(keyType, entries), reads as a Map
Custom RealtimeCustom custom(code, bytes), reads as a CustomValue

Building Values

Most values are written inline, and the wire type follows the value's type. In C++, RealtimeValue converts implicitly from any of its alternatives and picks the alternative by exact type: an integer literal lands in Int32 and a uint8_t lands in Byte, so cast explicitly when the wire size matters. In JavaScript, an integer number lands in Int32, and the helpers byte(), int16(), int32(), int64(), float() and double() request a specific numeric type.

A list of values of different types builds an Array. The following builds values of several wire types:

C++
C
JavaScript

C++

RealtimeValue flag        = true;
RealtimeValue smallNumber = static_cast<uint8_t>(7);
RealtimeValue number      = 42; // Int32
RealtimeValue text        = PHOTON_STR("hello");
RealtimeValue samples     = std::vector<float>{1.0F, 2.0F};
RealtimeValue mixed       = {RealtimeValue(1), RealtimeValue(PHOTON_STR("two")), RealtimeValue(3.0)};

C

/* The rt_* blob helpers, copied from "Blob Helpers" on the C API page. */
#include "photon_blob.h"

/* There is no RealtimeValue over the ABI. The closest equivalent is the map
   blob used for properties, where a one-byte tag records each value's type. */
rt_blob values = {0};

rt_begin(&values);
rt_put_bool(&values, "flag", 1);       /* tag 0  */
rt_put_u8(&values, "smallNumber", 7);  /* tag 1  */
rt_put_i32(&values, "number", 42);     /* tag 3  */
rt_put_str(&values, "text", "hello");  /* tag 7  */
rt_end(&values);

/* A float array is tag 10: [int32 count][float]... */
rt_str(&values, "samples");
rt_u8(&values, 10);
rt_i32(&values, 2);
{
    float samples[] = {1.0F, 2.0F};
    rt_raw(&values, samples, (int32_t)sizeof(samples));
}
++values.count;

/* Structured Payloads Are C++ Only: Nothing on this page has a C
   counterpart for events: realtime_send_event carries raw bytes and there
   is no ABI function that encodes a RealtimeValue into the Photon wire
   form. The map blob shown above is the C API's own layout for room and
   player properties, which is where a C client gets self-describing
   values. The tags cover bool, Byte, Int16, Int32, Int64, Float, Double,
   String, ByteArray, Int32Array, FloatArray and StringArray; the remaining
   ValueType entries have no tag. */

JavaScript

import { byte, double } from '@photonengine/realtime-core';

const flag = true;
const smallNumber = byte(7);
const number = 42; // Int32
const text = 'hello';
const samples = new Float32Array([1.0, 2.0]);
const mixed = [1, 'two', double(3)];

3.0 is the integer 3 in JavaScript and would be sent as an Int32, so double() keeps the last element a Double.

Reading Values

In C++, reading is deliberately explicit: nothing converts silently, so a payload built by another client cannot be misread as the wrong type. In JavaScript, a received value is a plain JavaScript value, checked with typeof, instanceof or Array.isArray() before it is used.

The C++ RealtimeValue has the following accessors.

Member Result
Type() The ValueType currently held.
IsNull() true for a Null value.
Is<T>() true when the value holds exactly T.
TryGet<T>() A const T*, or nullptr when the value does not hold T.
Get<T>() A const T&; asserts when the value does not hold T, and falls back to a default-constructed T when assertions are off.
TryGetNumber<T>() A std::optional<T> holding the number converted to T, empty when the conversion would lose information.
TryGetCustom<T>() A std::optional<T> decoded from a custom value, empty on a code or payload mismatch.
Visit(visitor) Calls the visitor with the alternative currently held.
Raw() The underlying std::variant, for code that wants to work with it directly.

TryGet<T>() checks and reads in one step and never throws.

The following reads a string and ignores a value of any other type:

C++
C
JavaScript

C++

if (const RealtimeCore::Common::StringType* text = value.TryGet<RealtimeCore::Common::StringType>())
{
    // Use *text.
}

C

/* The rt_* blob helpers, copied from "Blob Helpers" on the C API page. */
#include "photon_blob.h"

/* The tag byte is the type check: read the payload only through the branch
   that matches it, which is what TryGet<T> does in C++. */
uint8_t tag = rt_get_u8(&r);

if (tag == 7) /* string */
{
    int32_t     textLen = 0;
    const char* text    = rt_get_str(&r, &textLen);
    printf("%.*s\n", (int)textLen, text);
}
else
{
    rt_skip_value(&r, tag);
}

/* There is no tolerant numeric read: a value the sender wrote as tag 1
   does not come back through the tag-3 branch, so widening a Byte to an
   int32_t is the reader's job. Always call rt_skip_value for a tag you do
   not handle, because skipping is what keeps the reader positioned on the
   next entry. */

JavaScript

const GREETING_CODE = 11;

client.onEvent(GREETING_CODE, ({ data }) => {
    if (typeof data === 'string') {
        console.log(`greeting: ${data}`);
    }
});

Integer width is not preserved on read: a Byte, Int16, Int32, Float or Double reads as a number, and an Int64 reads as a bigint.

In C++, TryGetNumber<T>() exists because a sender may legitimately pick a narrower type than the receiver expects, for example sending Byte where the game logic wants an int32_t. It only converts when the conversion is lossless for every possible value of the stored type, so widening succeeds and narrowing does not. Byte to Int32 works, Int32 to Int16 does not, Int32 to float does not because a 32-bit integer does not fit a float mantissa, and bool is never numeric.

In C++, equality is strict about types: RealtimeValue(42) and RealtimeValue(int64_t{42}) are not equal, because they do not travel over the wire as the same type.

Containers

An Array holds elements that can each have a different type. In C++ it is a RealtimeArray, a plain std::vector<RealtimeValue>, in JavaScript it is an Array.

A Map is a key-value container whose keys are a Byte, Int16, Int32, Int64, Float, Double or String, and keys of different types may be mixed in one map. In C++ it is a RealtimeMap, an ordered container whose keys are a RealtimeKey, a variant over uint8_t, int16_t, int32_t, int64_t, float, double and StringType. Insertion order is preserved and Set() upserts. In JavaScript, a plain object is a map with string keys, and a Map can also have numbers (inferred like values), bigint or helper-tagged keys such as byte(1).

The C++ RealtimeMap has the following members.

Member Description
Set(key, value) Inserts the entry, or replaces the value of an existing key.
TryGet(key) A const RealtimeValue*, or nullptr when the key is absent.
Remove(key) Removes the entry and reports whether it existed.
Contains(key) Whether the key is present.
Size(), IsEmpty() The entry count.
KeyAt(index), ValueAt(index) Positional access for iterating in insertion order.

A Dictionary is the homogeneously keyed variant of the same container, its key type is fixed when it is created. In C++ it is a RealtimeDictionary, whose key type is fixed at construction with a RealtimeDictionary::Key (Byte, Int16, Int32, Int64, Float, Double or String). KeyType() reports it, Set() returns false when a key of another type is offered, and the rest of the interface matches RealtimeMap. In JavaScript, dictionary(keyType, entries) builds one with a key type of 'byte', 'int16', 'int32', 'int64', 'float', 'double' or 'string', and a key of another type throws a TypeError when the value is sent.

All containers can be nested within each other.

The following builds a map and a dictionary and iterates over the map:

C++
C
JavaScript

C++

RealtimeMap scoreboard;
scoreboard.Set(RealtimeCore::Common::StringType(PHOTON_STR("round")), RealtimeValue(3));
scoreboard.Set(RealtimeCore::Common::StringType(PHOTON_STR("scores")), RealtimeValue(std::vector<int32_t>{10, 7}));

RealtimeDictionary byPlayer(RealtimeDictionary::Key::Int32);
if (!byPlayer.Set(1, RealtimeValue(PHOTON_STR("Alice"))))
{
    // Rejected only when the key type does not match Key::Int32.
}

for (size_t i = 0; i < scoreboard.Size(); ++i)
{
    const RealtimeKey&   key   = scoreboard.KeyAt(i);
    const RealtimeValue& value = scoreboard.ValueAt(i);
}

C

/* The rt_* blob helpers, copied from "Blob Helpers" on the C API page. */
#include "photon_blob.h"

/* Not available: the map blob has no tag for a nested container, so
   RealtimeArray, RealtimeMap, RealtimeDictionary and RealtimeCustom values
   cannot be built or read over the ABI. A map is one level deep, its keys are
   always strings and its values are always scalars or the four typed arrays. */
rt_blob scoreboard = {0};

rt_begin(&scoreboard);
rt_put_i32(&scoreboard, "round", 3);
rt_end(&scoreboard);

/* An int32 array is tag 9, the flattest stand-in for a nested list. */
rt_str(&scoreboard, "scores");
rt_u8(&scoreboard, 9);
rt_i32(&scoreboard, 2);
rt_i32(&scoreboard, 10);
rt_i32(&scoreboard, 7);
++scoreboard.count;

/* Numeric keys are also unavailable: keys in the blob are always strings,
   so a RealtimeMap with a numeric key or a RealtimeDictionary cannot be
   represented.

   A C++ sender's non-string keys are dropped from the blob rather than
   converted, which is why an entry count can come back lower than the map
   the sender built. */

JavaScript

import { dictionary } from '@photonengine/realtime-core';

const scoreboard = { round: 3, scores: new Int32Array([10, 7]) };

// A Map's keys do not have to be strings.
const labels = new Map([
    [1, 'one'],
    [2, 'two'],
]);

// Every key of this dictionary is an Int32. A key of another type throws a TypeError when the value is sent.
const byPlayer = dictionary('int32', [[1, 'Alice']]);

for (const [key, value] of Object.entries(scoreboard)) {
    console.log(key, value);
}

Custom Types

A custom type is your own type with a wire code, carried as the code plus its serialized bytes: a RealtimeCustom in C++, a CustomValue in JavaScript. Photon clients on other platforms register the same code, so a custom type is how a game-specific type such as a vector or a quaternion crosses SDK boundaries.

In C++, any type satisfying the CustomValueType concept converts to a RealtimeValue implicitly. The concept asks for three members:

Member Purpose
static constexpr uint8_t TypeCode The wire code identifying the type. Valid codes are 1 through 254.
void SerializeValue(std::vector<uint8_t>& out) const Appends the value's bytes to out.
static std::optional<T> DeserializeValue(std::span<const uint8_t> bytes) Rebuilds the value, or returns std::nullopt on errors

In JavaScript, there is no type registration: custom(code, bytes) builds a custom value from bytes you serialized, and a received one arrives as a CustomValue whose code and data you decode yourself.

The following serializes a two-dimensional vector as a custom type and reads it back:

C++
C
JavaScript

C++

struct Vec2
{
    static constexpr uint8_t TypeCode = 7;

    float X = 0.0F;
    float Y = 0.0F;

    void SerializeValue(std::vector<uint8_t>& out) const
    {
        const auto* xBytes = reinterpret_cast<const uint8_t*>(&X);
        const auto* yBytes = reinterpret_cast<const uint8_t*>(&Y);
        out.insert(out.end(), xBytes, xBytes + sizeof(float));
        out.insert(out.end(), yBytes, yBytes + sizeof(float));
    }

    static std::optional<Vec2> DeserializeValue(std::span<const uint8_t> bytes)
    {
        if (bytes.size() != (2 * sizeof(float)))
        {
            return std::nullopt;
        }
        Vec2 result;
        std::memcpy(&result.X, bytes.data(), sizeof(float));
        std::memcpy(&result.Y, bytes.data() + sizeof(float), sizeof(float));
        return result;
    }
};

RealtimeValue value = Vec2{1.5F, -2.0F};

if (std::optional<Vec2> decoded = value.TryGetCustom<Vec2>())
{
    // decoded->X and decoded->Y are back.
}

C

/* Not available: RealtimeCustom has no tag in the map blob and there is no ABI
   hook for registering a type code, so custom types cannot cross the C API.

   The equivalent from C is a fixed-layout byte payload on a dedicated event
   code, with both ends agreeing on the layout out of band. */
typedef struct
{
    float x;
    float y;
} Vec2;

#define VEC2_EVENT_CODE 7

Vec2 value = {1.5F, -2.0F};

realtime_send_event(client, VEC2_EVENT_CODE,
                    (const uint8_t*)&value, (int32_t)sizeof(value),
                    1, 0, 0, NULL, 0, 0, 0, 0);

/* A byte payload buys none of what a custom type provides: no wire code,
   no cross-platform readability and no encoding check, so the layout
   warning on the Custom Events page applies in full. EventEncodeFailed
   cannot occur over the ABI either, because nothing is encoded. */

JavaScript

import { custom, CustomValue } from '@photonengine/realtime-core';

const VEC2_TYPE_CODE = 7;
const AIM_EVENT_CODE = 12;

// Serialize: two 32-bit floats
const bytes = new Uint8Array(new Float32Array([1.5, -2.0]).buffer);
client.sendEvent(AIM_EVENT_CODE, custom(VEC2_TYPE_CODE, bytes));

client.onEvent(AIM_EVENT_CODE, ({ data }) => {
    // Deserialize: check the type code and the size before reading the bytes.
    if (data instanceof CustomValue && data.code === VEC2_TYPE_CODE && data.data.byteLength === 8) {
        const [x, y] = new Float32Array(data.data.slice().buffer);
        console.log('aim at', x, y);
    }
});

In C++, TryGetCustom<T>() checks the stored code against T::TypeCode before calling DeserializeValue(), so a value carrying a different custom type yields std::nullopt instead of garbage. Custom values nest like any other type, so they can sit inside arrays, maps and dictionaries.

Custom Type Codes

Codes 0 and 255 are reserved and a serialized custom payload is limited to 32767 bytes. A value that violates either rule cannot be encoded, and sending it fails with an EventEncodeFailed error (ClientError::EventEncodeFailed in C++). In JavaScript, custom() throws a RangeError for a reserved code, and an oversized payload makes the send return false and report 'eventEncodeFailed' on the error event.

Sending and Receiving

Sending a structured payload and receiving one with a decoded-value handler are covered on the Custom Events page.

Three client error codes report payload problems. They arrive on OnError in C++, where they are ClientError values, and as a RealtimeError on the error event in JavaScript:

C++ JavaScript Meaning
EventEncodeFailed 'eventEncodeFailed' An outgoing value could not be encoded, for example a custom code outside 1 to 254 or an oversized custom payload. Sending also returns false.
EventDecodeFailed 'eventDecodeFailed' An incoming payload could not be decoded, for example a multi-dimensional array.
EventPayloadTypeMismatch 'eventPayloadTypeMismatch' A raw-bytes handler received a structured payload. Use a decoded-value handler to receive it.

Last updated on

Back to top