Custom Events
Sending Events
Custom events are the low-latency messaging channel of Realtime Core.
An event is an application-defined event code, a uint8_t you choose, plus a payload, fanned out by the server to the other players in the room.
SendEvent(code, data, options) sends raw bytes, either as a std::span<const uint8_t> or as a std::vector<uint8_t>.
The template overload SendEvent<T>(code, value, options) accepts any trivially-copyable, non-pointer type and sends its bytes directly, which covers plain structs without any serialization code.
A further overload takes a RealtimeValue and sends a self-describing payload, see Structured Payloads.
All of them return bool, indicating whether the request could successfully be queued for sending to the server, false otherwise.
Define a plain struct and send it as-is:
C++
struct PositionUpdate
{
float X;
float Y;
float Z;
};
constexpr uint8_t PositionEventCode = 1;
PositionUpdate update{12.5F, 0.0F, -3.2F};
EventOptions options;
options.Reliable = false; // The next update supersedes a lost one anyway.
client.SendEvent(PositionEventCode, update, options);
C
typedef struct
{
float x;
float y;
float z;
} PositionUpdate;
#define POSITION_EVENT_CODE 1
PositionUpdate update = {12.5F, 0.0F, -3.2F};
int32_t sent = realtime_send_event(client, POSITION_EVENT_CODE,
(const uint8_t*)&update, (int32_t)sizeof(update),
0, /* reliable: the next update supersedes a lost one */
0, /* channel */
0, /* receiverGroup: Others */
NULL, 0, /* no explicit targets */
0, /* interestGroup */
0, /* caching: DoNotCache */
0, /* encrypt */
0); /* cacheSliceIndex */
/* realtime_send_event has no options struct: every field of EventOptions
is a positional parameter, in the order reliable, channel,
receiverGroup, targetPlayers, targetCount, interestGroup, caching,
encrypt and cacheSliceIndex. It returns non-zero when the event was
queued, matching the bool of the C++ call, and it only takes raw bytes,
so the SendEvent<T> and RealtimeValue overloads have no direct
counterpart. */
Event Options
| Field | Type | Default | Description |
|---|---|---|---|
Reliable |
bool |
true |
Resends the event until acknowledged. Unreliable events may be lost but cost less. |
Channel |
uint8_t |
0 |
Sequencing channel. Ordering is guaranteed only within a channel. |
TargetGroup |
ReceiverGroup |
Others |
Which players receive the event: Others, All or MasterClient. Only used when TargetPlayers and InterestGroup are not set. |
TargetPlayers |
std::vector<int> |
empty | Explicit recipient player numbers. Overrides TargetGroup and InterestGroup when non-empty. |
InterestGroup |
uint8_t |
0 |
Interest group the event is published to. 0 reaches everyone. Overrides TargetGroup when non-zero, only used when TargetPlayers is not set. |
Caching |
EventCache |
DoNotCache |
Whether and how the event is cached for late joiners. |
Encrypt |
bool |
false |
Encrypts the event payload. |
CacheSliceIndex |
int |
0 |
Cache slice the event addresses, for the slice-based caching operations. |
Reliable = false suits high-frequency data where the next sample supersedes the last, such as positions where losing one packet is cheaper than waiting for its resend.
Ordering is guaranteed per channel, so put independent streams on separate channels to keep a large reliable transfer from delaying time-critical events.
Targeting Recipients
The default fan-out is ReceiverGroup::Others, every player in the room except yourself.
All includes yourself, which is useful when local and remote handling should share one code path, and MasterClient targets only the master client.
When TargetPlayers is non-empty it overrides the group entirely, and the event goes only to the listed player numbers.
Both targeting styles in use:
C++
constexpr uint8_t StartRequestCode = 2;
constexpr uint8_t WhisperCode = 3;
// Ask the master client to start the match.
EventOptions toMaster;
toMaster.TargetGroup = ReceiverGroup::MasterClient;
client.SendEvent(StartRequestCode, static_cast<uint8_t>(1), toMaster);
// Reaches only players 2 and 5.
EventOptions toSome;
toSome.TargetPlayers = {2, 5};
client.SendEvent(WhisperCode, static_cast<uint8_t>(0), toSome);
C
#define START_REQUEST_CODE 2
#define WHISPER_CODE 3
/* Ask the master client to start the match. */
uint8_t payload = 1;
realtime_send_event(client, START_REQUEST_CODE, &payload, 1,
1, 0,
2, /* receiverGroup: MasterClient */
NULL, 0,
0, 0, 0, 0);
/* Reaches only players 2 and 5. */
const int32_t targets[] = {2, 5};
uint8_t flag = 0;
realtime_send_event(client, WHISPER_CODE, &flag, 1,
1, 0,
0, /* receiverGroup, ignored while targets are set */
targets, 2, /* explicit recipients */
0, 0, 0, 0);
/* receiverGroup takes the numeric ReceiverGroup, and a value outside 0 to
2 falls back to Others. targetPlayers is an int32_t array with an
explicit count, capped at 65536 entries, and takes precedence over both
the group and the interest group when it is not NULL. The single-byte
payloads above show the same point the C++ sample makes: the ABI sends
whatever bytes it is handed, so the width is decided by the type of the
variable, not by a literal. */
In this example the data is casted to a narrower integer type to make sure it is sent as a single byte. If an integer literal is used without a type cast its type is deduced and could be sent as a 4 byte integer. To avoid this a typed variable can be used for the data.
Receiving Events
Incoming events are delivered to handlers registered with RealtimeClient::SubscribeEvent().
It returns a Subscription exactly like the Broadcaster callbacks do, so see Callbacks and Subscriptions for the lifetime rules and the RAII helpers.
The handler always receives the event code and the sender's player number, plus the payload in one of two forms.
| Callback signature | Payload |
|---|---|
(uint8_t eventCode, int senderId, std::span<const uint8_t> data) |
The raw bytes of a byte-array payload. |
(uint8_t eventCode, int senderId, const RealtimeValue& value) |
The decoded payload, whatever type it carries. |
SubscribeEvent deduces the form from the callback you hand it, so the payload type is never spelled out explicitly unless the callback is generic.
The data span points into a decode buffer owned by the subscription and is only valid during the callback.
Copy the payload out, for example with std::memcpy into your struct, before the handler returns if you keep it.
The receiving side mirrors the sending struct:
C++
RealtimeCore::Common::ScopedSubscription events = client.SubscribeEvent(
[](uint8_t eventCode, int senderId, std::span<const uint8_t> data) {
switch (eventCode)
{
case PositionEventCode:
if (data.size() == sizeof(PositionUpdate))
{
PositionUpdate update;
std::memcpy(&update, data.data(), sizeof(update));
// Move senderId's avatar to the received position.
}
break;
default:
break;
}
});
C
/* Event (108): `origin` is the sender, `counter` is the event code, and the
blob holds the raw payload for the lifetime of the batch. */
if (e->type == RT_Event)
{
switch (e->counter)
{
case POSITION_EVENT_CODE:
if (e->blobOffset >= 0 && e->blobLength == (int32_t)sizeof(PositionUpdate))
{
PositionUpdate update;
memcpy(&update, blob + e->blobOffset, sizeof(update));
/* Move e->origin's avatar to the received position. */
}
break;
default:
break;
}
}
/* The payload lives in the queue blob instead of in a per-subscription
decode buffer, so it stays readable until the batch is flushed rather
than only for the duration of a callback. Only the byte form exists over
the ABI, so the payload-type mismatch and decode-failure errors of the
structured form do not apply: an event that a C++ client sent as a
RealtimeValue arrives here as its encoded wire bytes. */
A byte-span subscription only accepts byte-array payloads.
When an event carries a structured payload instead, the handler is skipped and the client broadcasts ErrorCode::EventPayloadTypeMismatch on OnError. Subscribe with const RealtimeValue& to receive those.
A payload that cannot be decoded at all, a multi-dimensional array for example, raises ErrorCode::EventDecodeFailed.
Disambiguating Generic Callbacks
A generic lambda, or any other callable that accepts both payload forms, is ambiguous and fails to compile with a message that says so. Name the payload form explicitly in that case:
C++
auto bytes = client.SubscribeEvent<std::span<const uint8_t>>(handler);
auto values = client.SubscribeEvent<const RealtimeValue&>(handler);
C
/* Not applicable: there are no subscriptions and no templates to disambiguate.
Every event code arrives as the same Event (108) with raw bytes in the blob. */
/* The two payload forms are a C++ overload-resolution concern and have no
C equivalent. */
Filtering by Event Code
SubscribeEvent has three raw overloads that filter the subscription by event code.
| Overload | Codes delivered |
|---|---|
SubscribeEvent(callback) |
Every event code, 0 through 255. |
SubscribeEvent(eventCode, callback) |
Exactly eventCode. |
SubscribeEvent(firstEventCode, lastEventCode, callback) |
The inclusive range from firstEventCode to lastEventCode. |
Ranges are convenient when one subsystem owns a contiguous block of codes:
C++
constexpr uint8_t FirstInventoryCode = 20;
constexpr uint8_t LastInventoryCode = 29;
RealtimeCore::Common::ScopedSubscription inventory = client.SubscribeEvent(
FirstInventoryCode, LastInventoryCode,
[](uint8_t eventCode, int senderId, std::span<const uint8_t> data) {
// Only inventory codes arrive here.
});
C
#define FIRST_INVENTORY_CODE 20
#define LAST_INVENTORY_CODE 29
/* The queue delivers every code, so the filtering happens in the dispatcher. */
if (e->type == RT_Event &&
e->counter >= FIRST_INVENTORY_CODE &&
e->counter <= LAST_INVENTORY_CODE)
{
/* Only inventory codes reach here. */
}
/* The three filtering overloads have no ABI counterpart: Event (108)
always covers the whole 0 to 255 range, so a subsystem that owns a block
of codes tests counter itself. */
Typed Subscriptions
Naming your payload struct as the explicit template argument, SubscribeEvent<T>(eventCode, callback), subscribes by payload type instead of receiving the raw wire form.
T has to be trivially copyable and default constructible, which covers the plain structs SendEvent<T>() sends, and the callback takes (int senderId, const T& value).
C++
RealtimeCore::Common::ScopedSubscription positions = client.SubscribeEvent<PositionUpdate>(
PositionEventCode,
[](int senderId, const PositionUpdate& update) {
// Move senderId's avatar to the received position.
});
C
/* There are no typed subscriptions, so the size check that guards them is
written out at the point the payload is copied. */
if (e->type == RT_Event && e->counter == POSITION_EVENT_CODE)
{
if (e->blobOffset < 0 || e->blobLength != (int32_t)sizeof(PositionUpdate))
{
/* The equivalent of EventSizeMismatch: the sender's layout differs. */
return;
}
PositionUpdate update;
memcpy(&update, blob + e->blobOffset, sizeof(update));
/* Move e->origin's avatar to the received position. */
}
/* The layout compatibility warning on this page applies with full force to
the C API, because the ABI has no typed path at all: both ends copy the
object representation and nothing checks it for them. Since the client
cannot reject a mismatched payload for you, EventSizeMismatch never
appears on the queue and the length test above is the only guard. */
A payload whose size differs from sizeof(T) never reaches the callback.
The client logs the mismatch and broadcasts ErrorCode::EventSizeMismatch on OnError, once for every typed subscription that rejected the payload.
Typed subscriptions are additive: a raw SubscribeEvent handler on the same code still sees the same event, which is what a logging or fallback path needs.
SendEvent<T>() and SubscribeEvent<T>() copy the object representation of T over the wire, with no padding, alignment or endianness normalization.
Both ends have to agree on that layout, so prefer a structured payload for events exchanged between different compilers, architectures or SDKs.
Structured Payloads
Byte payloads are the cheapest option, but they carry no type information, so both ends must agree on a layout out of band.
RealtimeValue is the self-describing alternative: a variant over everything the Photon wire protocol can carry, from scalars and typed arrays to nested arrays, maps, dictionaries and your own custom types.
C++
constexpr uint8_t ChatEventCode = 10;
RealtimeMap payload;
payload.Set(RealtimeCore::Common::StringType(PHOTON_STR("text")), RealtimeValue(PHOTON_STR("hello")));
payload.Set(RealtimeCore::Common::StringType(PHOTON_STR("channel")), RealtimeValue(3));
client.SendEvent(ChatEventCode, payload);
RealtimeCore::Common::ScopedSubscription chat = client.SubscribeEvent(
ChatEventCode,
[](uint8_t eventCode, int senderId, const RealtimeValue& value) {
const RealtimeMap* message = value.TryGet<RealtimeMap>();
if (message == nullptr)
{
return;
}
// Read the entries with message->TryGet(key).
});
C
/* Not available: realtime_send_event carries raw bytes, and there is no ABI
function that encodes a RealtimeValue into the Photon wire form. A map blob
is the C API's own layout for properties, not a structured event payload, so
sending one as an event would arrive as opaque bytes on the other side.
Structured event payloads therefore need C++ on at least the sending end. */
/* Structured Event Payloads Need C++: The map blob the C API uses for
properties is not the wire encoding of a RealtimeValue, so it cannot
stand in for a structured event payload. A C client receives a
structured event as its encoded wire bytes on Event (108) and cannot
decode them, and it cannot produce that encoding either. Use C++ for
events that need a self-describing payload, and plain byte structs from
C. */
The Structured Payloads page covers the value types, the containers, the accessors and how to plug your own types in.
Event Caching
Cached events are stored by the server and replayed, in their original order, to every player who joins later.
This is the built-in mechanism for late-joiner state: a new player receives the cached events as if it had been present.
EventOptions.Caching selects one of the EventCache operations, from adding a single event to the room cache up to slice-based cache management, the dedicated Event Caching page covers the cache structure, removal filters, cache slices and sizing guidance.
Interest Groups
Interest groups partition the event traffic inside a room: every event is published to exactly one group, and players receive only the groups they subscribed to.
Group 0 is the always-on broadcast group that every player receives.
ChangeGroups(remove, add) manages the local player's subscriptions, taking the group numbers to leave and to join.
On the sending side, EventOptions.InterestGroup selects the group the event is published to.
A zone-based interest scheme keeps events local to a map area:
C++
constexpr uint8_t ForestZone = 7;
constexpr uint8_t FootstepsCode = 5;
struct Footsteps
{
float X;
float Y;
};
// Start receiving events published to the forest zone.
client.ChangeGroups({}, {ForestZone});
// Only players subscribed to the zone receive this.
Footsteps steps{4.0F, 9.5F};
EventOptions options;
options.InterestGroup = ForestZone;
options.Reliable = false;
client.SendEvent(FootstepsCode, steps, options);
C
#define FOREST_ZONE 7
#define FOOTSTEPS_CODE 5
typedef struct
{
float x;
float y;
} Footsteps;
/* Start receiving events published to the forest zone. */
const uint8_t add[] = {FOREST_ZONE};
realtime_change_groups(client, NULL, 0, add, 1);
/* Only players subscribed to the zone receive this. */
Footsteps steps = {4.0F, 9.5F};
realtime_send_event(client, FOOTSTEPS_CODE,
(const uint8_t*)&steps, (int32_t)sizeof(steps),
0, /* reliable */
0, /* channel */
0, /* receiverGroup */
NULL, 0, /* no explicit targets */
FOREST_ZONE, /* interestGroup */
0, 0, 0);
/* realtime_change_groups takes the groups to leave and the groups to join
as two uint8_t arrays with their counts, so a call that only joins
passes NULL and 0 for the removals. It returns non-zero when the change
was queued. */