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++
C
JavaScript

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++
C
JavaScript

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++
C
JavaScript

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++
C
JavaScript

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++
C
JavaScript

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