Custom Events
Sending Events
Custom events are the low-latency messaging channel of Realtime Core.
An event is an application-defined event code, a number from 0 to 255 you choose, plus a payload, fanned out by the server to the other players in the room.
The payload is either raw bytes or a self-describing value, see Structured Payloads.
Raw bytes are the cheapest form.
In C++, sending accepts the bytes as a span or a vector, or any trivially copyable, non-pointer type whose bytes it sends directly, which covers plain structs without any serialization code.
In JavaScript, a Uint8Array payload is sent as raw bytes.
The C API sends raw bytes only.
Sending returns whether the event could be queued for sending to the server.
The following sends a position update of three floats as raw bytes:
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 */
/* 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 and
encrypt. 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. */
JavaScript
const POSITION_EVENT_CODE = 1;
// Three 32-bit floats
const update = new Uint8Array(new Float32Array([12.5, 0.0, -3.2]).buffer);
// Unreliable: the next update supersedes a lost position anyway.
client.sendEvent(POSITION_EVENT_CODE, update, { reliable: false });
Any value other than a Uint8Array is sent as a structured payload.
Event Options
Every send takes the options below.
In C++ they are the fields of EventOptions, in JavaScript the properties of the optional options object, and in C the positional parameters of realtime_send_event().
| C++ | C | JavaScript | Default | Description |
|---|---|---|---|---|
Reliable |
reliable |
reliable |
true |
Resends the event until acknowledged. Unreliable events may be lost but cost less. |
Channel |
channel |
channel |
0 |
Sequencing channel from 0 to 255. Ordering is guaranteed only within a channel. |
TargetGroup |
receiverGroup |
receivers |
Others, 'others' |
Which players receive the event: the others, all players or the master client. Only used when no target players and no interest group are set. |
TargetPlayers |
targetPlayers, targetCount |
targetPlayers |
none | Explicit recipient player numbers. Overrides the target group and the interest group when non-empty. |
InterestGroup |
interestGroup |
interestGroup |
0 |
Interest group the event is published to. 0 reaches everyone. Overrides the target group when non-zero, only used when no target players are set. |
Caching |
caching |
caching |
DoNotCache, 'doNotCache' |
Whether and how the event is cached for late joiners, see Event Caching. |
Encrypt |
encrypt |
encrypt |
false |
Encrypts the event payload. |
The defaults apply to C++ and JavaScript.
C has none, because realtime_send_event() takes every option as a parameter, with 1 and 0 for true and false.
The target groups are ReceiverGroup::Others, All and MasterClient in C++, 0, 1 and 2 in C, and 'others', 'all' and 'masterClient' in JavaScript.
Unreliable delivery 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
By default an event reaches the others, every player in the room except the sender. The all-players group includes the sender, which is useful when local and remote handling should share one code path, and the master-client group targets only the master client.
When target players are set, they override 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);
/* 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);
/* 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. */
JavaScript
const START_REQUEST_CODE = 2;
const WHISPER_CODE = 3;
// Ask the master client to start the match.
client.sendEvent(START_REQUEST_CODE, new Uint8Array([1]), { receivers: 'masterClient' });
// Reaches only players 2 and 5.
client.sendEvent(WHISPER_CODE, new Uint8Array([0]), { targetPlayers: [2, 5] });
The payloads in this example are single bytes.
In C++ the cast makes sure of that: an integer literal without it is deduced as int and sent as 4 bytes, and a typed variable avoids this as well.
In JavaScript, a Uint8Array holds exactly the bytes it is given.
Receiving Events
Incoming events are delivered to event handlers: SubscribeEvent() in C++, and onEvent(), onEventRange() or onAnyEvent() in JavaScript.
In C++ the call returns a Subscription exactly like the Broadcaster callbacks do; in JavaScript it returns a function that unsubscribes.
See Callbacks and Subscriptions for the lifetime rules.
The C API delivers events on its event queue instead, as the C tab shows.
The handler always receives the event code and the sender's player number, plus the payload in one of two forms.
| Payload | C++ callback | JavaScript |
|---|---|---|
| The raw bytes of a byte-array payload. | (uint8_t eventCode, int senderId, std::span<const uint8_t> data) |
{ code, senderNumber, data } with the asBytes: true option, data is a Uint8Array |
| The decoded payload, whatever type it carries. | (uint8_t eventCode, int senderId, const RealtimeValue& value) |
{ code, senderNumber, data } without asBytes |
In C++, SubscribeEvent() deduces the form from the callback you hand it, so the payload type is never spelled out explicitly unless the callback is generic.
In JavaScript, the asBytes option selects it.
In C++, 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 decodes the position update:
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 == RealtimeEventType_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. */
JavaScript
const POSITION_EVENT_CODE = 1;
client.onAnyEvent(
({ code, senderNumber, data }) => {
switch (code) {
case POSITION_EVENT_CODE:
if (data.byteLength === 12) {
const [x, y, z] = new Float32Array(data.slice().buffer);
// Move the avatar of player senderNumber to the received position.
console.log(`player #${senderNumber} moved to`, x, y, z);
}
break;
default:
break;
}
},
{ asBytes: true },
);
With asBytes: true every payload arrives as a Uint8Array.
It is a copy, so it stays valid after the handler returns.
A raw-bytes handler only accepts byte-array payloads.
When an event carries a structured payload instead, the handler is skipped and the client reports an EventPayloadTypeMismatch error: ClientError::EventPayloadTypeMismatch on OnError in C++, a RealtimeError with code 'eventPayloadTypeMismatch' on the error event in JavaScript.
Use a decoded-value handler to receive those: subscribe with const RealtimeValue& in C++, or leave out asBytes in JavaScript.
A payload that cannot be decoded at all, a multi-dimensional array for example, raises an EventDecodeFailed error (ClientError::EventDecodeFailed in C++, 'eventDecodeFailed' in JavaScript).
Disambiguating Generic Callbacks
This section applies to C++ only.
In JavaScript the asBytes option selects the payload form, and the C API delivers raw bytes only.
In C++, 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);
Filtering by Event Code
A subscription can filter by event code.
| Codes delivered | C++ | JavaScript |
|---|---|---|
Every event code, 0 through 255. |
SubscribeEvent(callback) |
onAnyEvent(handler) |
| Exactly one code. | SubscribeEvent(eventCode, callback) |
onEvent(code, handler) |
| An inclusive range of codes. | SubscribeEvent(firstEventCode, lastEventCode, callback) |
onEventRange(firstCode, lastCode, handler) |
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 == RealtimeEventType_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. */
JavaScript
const FIRST_INVENTORY_CODE = 20;
const LAST_INVENTORY_CODE = 29;
// Returns the function that unsubscribes.
const stopInventory = client.onEventRange(FIRST_INVENTORY_CODE, LAST_INVENTORY_CODE, ({ code, senderNumber }) => {
// Only inventory codes arrive here.
console.log(`inventory event ${code} from player #${senderNumber}`);
});
Typed Subscriptions
Typed subscriptions are a C++ mechanism. In JavaScript and C, receive the raw bytes and check the payload size before decoding it, as the tabs show.
In C++, 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).
The following receives the position update only when its size matches:
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 == RealtimeEventType_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. */
JavaScript
const POSITION_EVENT_CODE = 1;
client.onEvent(
POSITION_EVENT_CODE,
({ senderNumber, data }) => {
if (data.byteLength !== 12) {
return; // The sender's layout differs.
}
const [x, y, z] = new Float32Array(data.slice().buffer);
console.log(`player #${senderNumber} moved to`, x, y, z);
},
{ asBytes: true },
);
JavaScript has no typed subscriptions, so the handler checks the size itself.
EventSizeMismatch is never reported in JavaScript.
A payload whose size differs from sizeof(T) never reaches the callback.
The client logs the mismatch and broadcasts ClientError::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.
In C++ and JavaScript, several handlers can subscribe to the same code, and each of them sees every event.
A C client receives each event once from its event queue and dispatches it to its own handlers.
Sending a struct as raw bytes copies its object representation 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.
A structured payload is the self-describing alternative: a value built from anything the Photon wire protocol can carry, from scalars and typed arrays to nested arrays, maps, dictionaries and your own custom types.
In C++ it is a RealtimeValue, in JavaScript it is a plain JavaScript value.
The following sends a chat message as a map and reads it on the receiving side:
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. */
JavaScript
const CHAT_EVENT_CODE = 10;
client.sendEvent(CHAT_EVENT_CODE, { text: 'hello', channel: 3 });
client.onEvent(CHAT_EVENT_CODE, ({ senderNumber, data }) => {
console.log(`player #${senderNumber} says ${data.text}`);
});
The Structured Payloads page covers the value types, the containers, reading values and custom types.
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. The caching option selects the cache operation, from adding a single event to the room cache to removing cached events again. The Event Caching page covers the cache structure, removal filters 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.
Changing groups manages the local player's subscriptions, taking the group numbers to leave and to join: ChangeGroups() in C++, changeGroups() in JavaScript and realtime_change_groups() in C.
On the sending side, the interest group option 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);
/* 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. */
JavaScript
const FOREST_ZONE = 7;
const FOOTSTEPS_CODE = 5;
// Start receiving events published to the forest zone.
client.changeGroups([], [FOREST_ZONE]);
// Only players subscribed to the zone receive this.
client.sendEvent(FOOTSTEPS_CODE, new Float32Array([4.0, 9.5]), { interestGroup: FOREST_ZONE, reliable: false });
Last updated on
Back to top