Custom Properties
Property Values and Types
Custom properties are typed key-value pairs attached to a room or a player, stored on the server and synchronized to every client in the room.
A set of properties maps string keys to values: a RealtimeMap of RealtimeValue entries in C++ and a plain object in JavaScript.
A value can be of any type the Photon wire protocol carries, so a property can hold anything an event payload can.
| Wire Type | C++ | JavaScript |
|---|---|---|
| Null | A default-constructed RealtimeValue |
null or undefined |
| Boolean | bool |
boolean |
| Byte, the 8-bit unsigned integer | uint8_t |
byte(n) |
| 16-bit integer | int16_t |
int16(n) |
| 32-bit integer | int32_t |
An integer number within 32 bits, or int32(n) |
| 64-bit integer | int64_t |
bigint, or int64(n) |
| 32-bit float | float |
float(n) |
| 64-bit float | double |
Any other number, or double(n) |
| String (UTF-8) | StringType, see Strings |
string |
| Byte array | std::vector<uint8_t> |
Uint8Array |
| Integer arrays | std::vector<int16_t>, std::vector<int32_t>, std::vector<int64_t> |
Int16Array, Int32Array, BigInt64Array |
| Floating-point arrays | std::vector<float>, std::vector<double> |
Float32Array, Float64Array |
| Boolean array | std::vector<bool> |
bools([...]) |
| String array | std::vector<StringType> |
strings([...]) |
| Heterogeneous list of nested values | RealtimeArray |
Array |
| Nested key-value container | RealtimeMap |
Plain object (string keys) or Map |
| Typed dictionary, every key of one type | RealtimeDictionary |
dictionary(keyType, entries) |
| Serialized application type, see Structured Payloads | RealtimeCustom |
custom(code, bytes) |
The JavaScript helpers (byte(), int16(), int32(), int64(), float(), double(), bools(), strings(), dictionary() and custom()) are exports of the @photonengine/realtime-core package.
In JavaScript, a value without a helper gets its wire type from its JavaScript type, and an Array is always a heterogeneous list, whatever it holds.
For the C++ value and container types, see Data Types; for the JavaScript value rules, see Values.
Integers travel with their exact width, and in C++ and C a value stored as one width does not read back as another.
In C++, write static_cast<int32_t>(value) rather than relying on a plain literal.
In JavaScript, use a helper wherever a peer expects a width other than the inferred one; values read back are plain JavaScript values, every integer width reads as a number (a 64-bit integer as a bigint), so the width is not preserved on read.
A JavaScript read returns a Map for a typed dictionary or a key-value container with non-string keys, and a CustomValue for a serialized application type.
The only 8-bit integer type is the unsigned byte, covering 0 through 255; there is no signed 8-bit type, so a C++ int8_t needs an explicit cast to one of the listed widths.
Room and player properties are string-keyed on the wire.
A C++ RealtimeMap may hold numeric keys, and the C++ property setters reject a map containing any non-string key, returning false without sending anything.
A JavaScript property set is a plain object, so its keys are always strings; a value of an unsupported type, such as a Date or a class instance, throws a TypeError before anything is sent.
Reading Property Values
Reading a property is a key lookup followed by a read of the value as its stored type. A lookup of a key that is not present yields nothing, so check for absence before using a value that may be missing.
| Task | C++ | JavaScript |
|---|---|---|
| Write an entry | RealtimeMap::Set(key, value) |
An object literal, or properties[key] = value |
| Look up an entry | TryGet(key), a null pointer when the key is absent |
properties[key], undefined when the key is absent |
| Test for, remove and count entries | Contains(key), Remove(key), Size(), IsEmpty() |
key in properties, delete properties[key], Object.keys(properties).length |
| Walk the entries | KeyAt(index), ValueAt(index), in insertion order |
Object.entries(properties) |
| Read a value as its type | Get<T>() asserts on a type mismatch, TryGet<T>() returns a null pointer on mismatch |
The value is a plain JavaScript value |
| Test or dispatch on the stored type | Is<T>(), Visit(visitor) |
typeof, instanceof |
| Read a number without loss | TryGetNumber<T>() yields a value only when the stored number converts to T without loss |
Every integer width reads as a number, a 64-bit integer as a bigint |
Writing and reading back an integer and a string:
C++
RealtimeMap properties;
properties.Set(PHOTON_STR("mode"), PHOTON_STR("deathmatch"));
properties.Set(PHOTON_STR("round"), static_cast<int32_t>(3));
RealtimeCore::Common::StringType mode = properties.TryGet(PHOTON_STR("mode"))->Get<RealtimeCore::Common::StringType>();
int32_t round = properties.TryGet(PHOTON_STR("round"))->Get<int32_t>();
C
/* The rt_* blob helpers, copied from "Blob Helpers" on the C API page. */
#include "photon_blob.h"
/* Writing: a map blob is built key by key, with the type in the tag byte. */
rt_blob properties = {0};
rt_begin(&properties);
rt_put_str(&properties, "mode", "deathmatch");
rt_put_i32(&properties, "round", 3);
rt_end(&properties);
/* Reading: walk the entries and pick out the keys of interest. */
rt_reader r = rt_read(properties.data, properties.len);
int32_t count = rt_get_i32(&r);
for (int32_t i = 0; i < count; ++i)
{
int32_t keyLen = 0;
const char* key = rt_get_str(&r, &keyLen);
uint8_t tag = rt_get_u8(&r);
if (tag == 3 && keyLen == 5 && memcmp(key, "round", 5) == 0)
{
int32_t round = rt_get_i32(&r);
printf("round %d\n", round);
}
else
{
rt_skip_value(&r, tag);
}
}
rt_free(&properties);
/* RealtimeMap and RealtimeValue do not cross the ABI as objects: a
property set is a map blob, and the type of each value is a one-byte tag
ahead of its payload. Exact integer widths matter for the same reason
they do in C++, and the tag is what records them: a value written with
rt_put_i32 (tag 3) does not read back through the tag-2 or tag-4 branch.
Nested containers have no tag, so RealtimeArray, RealtimeMap,
RealtimeDictionary and RealtimeCustom values cannot be used as
properties from C, and the int16_t, int64_t, double and bool array types
have no tag either. */
JavaScript
// A property set is a plain object: 'deathmatch' is sent as a string and 3 as an int32.
client.room?.setProperties({ mode: 'deathmatch', round: 3 });
// The room's properties read back as a plain object.
const mode = client.room?.customProperties.mode;
const round = client.room?.customProperties.round;
A player's properties read back the same way, through player.customProperties.
The C++ example reads both values without checking for absence because both keys were just written. For values that may be absent, check first, as the room and player examples below do.
Room Properties
Seed room properties at creation time through the custom properties of the create options. Once in the room, change them through the room: set several keys at once, set a single key or remove keys.
| Operation | C++ | C | JavaScript |
|---|---|---|---|
| Set several properties | Room::SetProperties() |
realtime_room_set_properties() |
room.setProperties() |
| Set a single property | Room::SetProperty() |
A one-entry map | room.setProperties() with one key |
| Remove properties | Room::RemoveProperties() |
realtime_room_remove_properties() |
room.removeProperties() |
| Read the properties | Room::GetCustomProperties() |
realtime_room_custom_properties() |
room.customProperties |
The server synchronizes every change to all clients in the room, so a value written by one client appears in every other client's view once that client's service loop delivers the update.
One client writes, the others read the updated value:
C++
// Client A publishes the selected map.
std::optional<Room> room = clientA.GetCurrentRoom();
if (room)
{
room->SetProperty(PHOTON_STR("map"), RealtimeCore::Common::StringType(PHOTON_STR("harbor")));
}
// Client B, in the same room, reads it once the change has been synchronized.
std::optional<Room> view = clientB.GetCurrentRoom();
if (view)
{
const RealtimeMap& properties = view->GetCustomProperties();
const RealtimeValue* value = properties.TryGet(PHOTON_STR("map"));
if (value != nullptr)
{
RealtimeCore::Common::StringType map = value->Get<RealtimeCore::Common::StringType>();
}
}
C
/* The rt_* blob helpers, copied from "Blob Helpers" on the C API page. */
#include "photon_blob.h"
/* Client A publishes the selected map. */
rt_blob properties = {0};
rt_begin(&properties);
rt_put_str(&properties, "map", "harbor");
rt_end(&properties);
realtime_room_set_properties(clientA, properties.data, properties.len);
rt_free(&properties);
/* Client B, in the same room, reads it once the change has been synchronized. */
int32_t length = 0;
const uint8_t* props = realtime_room_custom_properties(clientB, &length);
rt_reader r = rt_read(props, length);
int32_t count = rt_get_i32(&r);
for (int32_t i = 0; i < count; ++i)
{
int32_t keyLen = 0;
const char* key = rt_get_str(&r, &keyLen);
uint8_t tag = rt_get_u8(&r);
if (tag == 7 && keyLen == 3 && memcmp(key, "map", 3) == 0)
{
int32_t mapLen = 0;
const char* map = rt_get_str(&r, &mapLen);
printf("map is %.*s\n", (int)mapLen, map);
}
else
{
rt_skip_value(&r, tag);
}
}
/* There is no single-key setter over the C API:
realtime_room_set_properties always takes a whole map, so a one-key
change is a one-entry blob. realtime_room_remove_properties deletes keys
and takes a string array blob built with rt_add_str instead of a map.
Remember that the pointer from realtime_room_custom_properties is only
valid until the next call on the handle. */
JavaScript
// Client A publishes the selected map.
clientA.room?.setProperties({ map: 'harbor' });
// Client B, in the same room, reads it once the change has been synchronized.
await clientB.once('roomPropertiesChanged');
const map = clientB.room?.customProperties.map;
Player Properties
Player properties are set through the client rather than through the room, and they change the local player's properties: set several keys at once, set a single key or remove keys.
In C++ and JavaScript, the setters return false when the client is not in a room.
| Operation | C++ | C | JavaScript |
|---|---|---|---|
| Set several properties | SetPlayerProperties() |
realtime_set_player_properties() |
setPlayerProperties() |
| Set a single property | SetPlayerProperty() |
A one-entry map | setPlayerProperties() with one key |
| Remove properties | RemovePlayerProperties() |
realtime_remove_player_properties() |
removePlayerProperties() |
| Read every player's properties | Room::GetPlayers(), then Player::CustomProperties |
realtime_room_players() |
room.players, then player.customProperties |
Other players' properties arrive through their player entries: list the room's players and read each player's custom properties.
Publish a per-player choice and read everyone else's:
C++
// Publish the local player's loadout.
client.SetPlayerProperty(PHOTON_STR("loadout"), static_cast<int32_t>(2));
// Read the loadout of every player in the room.
std::optional<Room> room = client.GetCurrentRoom();
if (room)
{
for (const Player& player : room->GetPlayers())
{
const RealtimeValue* value = player.CustomProperties.TryGet(PHOTON_STR("loadout"));
if (value != nullptr)
{
int32_t loadout = value->Get<int32_t>();
}
}
}
C
/* The rt_* blob helpers, copied from "Blob Helpers" on the C API page. */
#include "photon_blob.h"
/* Publish the local player's loadout. */
rt_blob mine = {0};
rt_begin(&mine);
rt_put_i32(&mine, "loadout", 2);
rt_end(&mine);
realtime_set_player_properties(client, mine.data, mine.len);
rt_free(&mine);
/* Read the loadout of every player in the room. */
int32_t length = 0;
const uint8_t* players = realtime_room_players(client, &length);
rt_reader r = rt_read(players, length);
int32_t playerCount = rt_get_i32(&r);
for (int32_t i = 0; i < playerCount; ++i)
{
int32_t number = rt_get_i32(&r);
int32_t nameLen = 0;
(void)rt_get_str(&r, &nameLen);
int32_t userIdLen = 0;
(void)rt_get_str(&r, &userIdLen);
/* customProperties of this player */
int32_t entries = rt_get_i32(&r);
for (int32_t j = 0; j < entries; ++j)
{
int32_t keyLen = 0;
const char* key = rt_get_str(&r, &keyLen);
uint8_t tag = rt_get_u8(&r);
if (tag == 3 && keyLen == 7 && memcmp(key, "loadout", 7) == 0)
{
printf("#%d uses loadout %d\n", number, rt_get_i32(&r));
}
else
{
rt_skip_value(&r, tag);
}
}
(void)rt_get_u8(&r); /* isInactive */
(void)rt_get_u8(&r); /* isMasterClient */
}
/* realtime_set_player_properties returns void, so it cannot report the
false the C++ setters return when the client is not in a room. Check
realtime_is_in_room first, and watch for PropertiesChangeFailed (114)
for a server-side rejection. Other players' properties are nested inside
the player array blob, so reading one key means walking every player's
map; the layout is on the C API page. */
JavaScript
// Publish the local player's loadout.
client.setPlayerProperties({ loadout: 2 });
// Read the loadout of every player in the room.
for (const player of client.room?.players ?? []) {
const loadout = player.customProperties.loadout;
if (loadout !== undefined) {
console.log(`#${player.number} uses loadout`, loadout);
}
}
Compare-and-Swap Updates
Setting room properties together with a second set of expected values makes the update a compare-and-swap: the server applies the update only if the current server-side values still match the expected ones. This makes concurrent updates race-free and is the right tool for turn counters, item claims and every other "only one client may win" situation.
| C++ | C | JavaScript |
|---|---|---|
Room::SetProperties(properties, expected) |
realtime_room_set_properties_expected() |
room.setProperties(properties, expected) |
An atomic increment reads the current value and makes it the expected value of the update:
C++
std::optional<Room> room = client.GetCurrentRoom();
if (room)
{
const RealtimeMap& properties = room->GetCustomProperties();
int32_t turn = properties.TryGet(PHOTON_STR("turn"))->Get<int32_t>();
RealtimeMap next;
RealtimeMap expected;
next.Set(PHOTON_STR("turn"), static_cast<int32_t>(turn + 1));
expected.Set(PHOTON_STR("turn"), turn);
// Applied only if no other client changed "turn" in the meantime.
room->SetProperties(next, expected);
}
C
/* The rt_* blob helpers, copied from "Blob Helpers" on the C API page. */
#include "photon_blob.h"
/* Read the current value first: it becomes the expected value of the update. */
int32_t length = 0;
const uint8_t* props = realtime_room_custom_properties(client, &length);
rt_reader r = rt_read(props, length);
int32_t count = rt_get_i32(&r);
int32_t turn = 0;
int hasTurn = 0;
for (int32_t i = 0; i < count; ++i)
{
int32_t keyLen = 0;
const char* key = rt_get_str(&r, &keyLen);
uint8_t tag = rt_get_u8(&r);
if (tag == 3 && keyLen == 4 && memcmp(key, "turn", 4) == 0)
{
turn = rt_get_i32(&r);
hasTurn = 1;
}
else
{
rt_skip_value(&r, tag);
}
}
if (hasTurn)
{
rt_blob next = {0};
rt_blob expected = {0};
rt_begin(&next);
rt_put_i32(&next, "turn", turn + 1);
rt_end(&next);
rt_begin(&expected);
rt_put_i32(&expected, "turn", turn);
rt_end(&expected);
/* Applied only if no other client changed "turn" in the meantime. */
realtime_room_set_properties_expected(client,
next.data, next.len,
expected.data, expected.len);
rt_free(&next);
rt_free(&expected);
}
/* realtime_room_set_properties_expected is the compare-and-swap entry
point and takes two separate map blobs. Build both before the call: the
read above must finish first, because the expected map is built from a
pointer into the scratch buffer that the next call on the handle would
invalidate. A rejected update arrives as PropertiesChangeFailed (114). */
JavaScript
const room = client.room;
if (room) {
const turn = room.customProperties.turn;
// Applied only if no other client changed "turn" in the meantime.
room.setProperties({ turn: turn + 1 }, { turn });
}
When the expected values no longer match, the server rejects the whole update and the client that attempted it receives the properties-change-failed notification: OnPropertiesChangeFailed in C++, RealtimeEventType_PropertiesChangeFailed in C and the propertiesChangeFailed event in JavaScript.
Re-read the current values and retry if the update still applies.
Lobby-Visible Properties
By default custom properties are only visible to the clients inside the room. The lobby properties list, set in the create options or later on the room, selects the keys that are also exposed to the lobby, where they appear in room listings and drive matchmaking filters.
| C++ | C | JavaScript | |
|---|---|---|---|
| At creation | CreateRoomOptions.LobbyProperties |
Not available | lobbyProperties create option |
| In the room | Room::SetLobbyProperties() |
realtime_room_set_lobby_properties() |
room.setLobbyProperties() |
Keep the lobby-visible set small, because these values are broadcast to lobby clients with every room-list update. For query-style filtering on lobby-visible values, see SQL Lobby Matchmaking.
Property Change Callbacks
Property changes are announced to every client in the room: one callback delivers the changed room keys and another the changed keys of one player, together with the player's number. Both carry only the keys that changed, not the full property set.
| C++ | C | JavaScript | |
|---|---|---|---|
| Room properties changed | OnRoomPropertiesChanged |
RealtimeEventType_RoomPropertiesChanged |
roomPropertiesChanged event |
| Player properties changed | OnPlayerPropertiesChanged |
RealtimeEventType_PlayerPropertiesChanged |
playerPropertiesChanged event |
Reacting to one specific key:
C++
RealtimeCore::Common::ScopedSubscription mapChanged = client.OnRoomPropertiesChanged.Subscribe(
[](const RealtimeMap& changed) {
if (changed.Contains(PHOTON_STR("map")))
{
// Load the new map.
}
});
C
/* The rt_* blob helpers, copied from "Blob Helpers" on the C API page. */
#include "photon_blob.h"
/* RoomPropertiesChanged (102) carries only the keys that changed, as a map. */
if (e->type == RealtimeEventType_RoomPropertiesChanged && e->blobOffset >= 0)
{
rt_reader r = rt_read(blob + e->blobOffset, e->blobLength);
int32_t count = rt_get_i32(&r);
for (int32_t i = 0; i < count; ++i)
{
int32_t keyLen = 0;
const char* key = rt_get_str(&r, &keyLen);
rt_skip_value(&r, rt_get_u8(&r));
if (keyLen == 3 && memcmp(key, "map", 3) == 0)
{
/* Load the new map. */
}
}
}
/* PlayerPropertiesChanged (107) has the same map payload and puts the
player number in origin. Both deliver only the changed keys, so a client
that needs the full set reads it back with
realtime_room_custom_properties or realtime_room_players after the
batch. */
JavaScript
client.on('roomPropertiesChanged', ({ changed }) => {
if ('map' in changed) {
console.log('load the new map:', changed.map);
}
});
Last updated on
Back to top