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++
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++
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++
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++
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.
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