Asynchronous Operations
Task
Every asynchronous client operation, connecting, joining a room, fetching a room list, returns a Task<Result<T>>.
A Task is a lightweight, move-only handle to a coroutine; it is not a thread, and no work happens on it in the background.
Tasks start eagerly.
The operation begins the moment you call the method and runs until its first internal suspension, usually the point where it starts waiting for a server response.
From then on the suspended coroutine is resumed by Service(), so a Task only makes progress while your game loop keeps pumping the client (see Client and Service Loop).
Awaiting a Task
Inside one of your own coroutines, co_await task suspends until the operation completes and yields its Result.
This is the most direct way to express sequential network flows: each step reads like a plain function call, and control returns to the game loop while the server round-trip is in flight.
The coroutine below connects and then creates a room, one step after the other.
C++
Task<Result<MutableRoomView>> ConnectAndCreate(RealtimeClient& client)
{
Result<void> connected = co_await client.Connect();
if (connected.IsErr())
{
co_return Result<MutableRoomView>::Err(connected.GetError());
}
co_return co_await client.CreateRoom(PHOTON_STR("battle-01"));
}
C
/* There is no coroutine to suspend, so the same flow becomes two handlers:
each step is started from the result event of the step before it. */
static void OnEvent(RealtimeHandle client, const RealtimeEvent* e, const uint8_t* blob)
{
if (e->mode != 0)
{
/* Any failure ends the flow; the blob holds the message. */
return;
}
switch (e->type)
{
case RT_ConnectResult:
realtime_create_room(client, "battle-01",
0, /* maxPlayers: unlimited */
1, 1, /* isVisible, isOpen */
0, 0, /* playerTtl, emptyRoomTtl */
NULL, /* no plugins */
NULL, 0);/* no initial properties */
break;
case RT_CreateRoomResult:
/* In the room. Read it with the realtime_room_* queries. */
break;
default:
break;
}
}
/* The C API has no Task: every asynchronous call returns void immediately
and its outcome arrives as an event on the queue. The C API page lists
which event each operation raises. */
Polling a Task
From a plain, non-coroutine game loop, poll the task instead: IsReady() reports completion and Get() fetches the outcome.
Get() moves the result out of the task, so call it once, after IsReady() returns true.
Each iteration stands in for one frame of a game loop.
C++
Task<Result<void>> connectTask = client.Connect();
while (!connectTask.IsReady())
{
client.Service();
std::this_thread::sleep_for(std::chrono::milliseconds(16));
}
Result<void> connected = connectTask.Get();
C
int32_t connectError = -1; /* -1 = still pending, 0 = ok, > 0 = ErrorCode */
realtime_connect(client, NULL, NULL, 255, NULL, NULL);
while (connectError < 0)
{
RealtimeEvent events[64];
int32_t count;
realtime_service(client);
while ((count = realtime_event_queue_poll(queue, events, 64)) > 0)
{
for (int32_t i = 0; i < count; ++i)
{
if (events[i].type == RT_ConnectResult)
{
connectError = events[i].mode;
}
}
}
realtime_event_queue_flush(queue);
SleepMs(16);
}
/* Polling the queue is the only way to consume a result over the C API, so
this shape is the C equivalent of both awaiting and polling a Task. Note
that the queue is shared: a single poll can return events unrelated to
the operation being waited on, so match on type rather than assuming the
batch holds one event. */
Task Lifetime and Discarding
Destroying a Task that has not completed is safe: the handle detaches and the underlying operation keeps running to completion, driven by Service() as usual.
Discarding the return value of an operation is therefore a deliberate fire-and-forget, not a bug, useful when you do not need the outcome.
The one rule: a Task that another live coroutine is currently co_awaiting must outlive that awaiter.
Keep the awaited Task alive, typically by owning it in the awaiting coroutine's frame, which the co_await task expression does naturally, until the await resumes.
Result
Result<T> holds either a value of type T or an Error, which pairs a machine-readable Code with a human-readable Message.
Result<void> covers operations that succeed without a payload, such as Connect() and LeaveRoom().
When writing your own coroutines, construct results with the static factories: Result<T>::Ok(value) or Result<void>::Ok() and Result<T>::Err(code, message).
Checking for Success
IsOk() and IsErr() query the state, and operator bool mirrors IsOk(), so a result can be tested directly in an if.
The usual shape checks once and reads the error only on the failure branch.
C++
Result<void> connected = co_await client.Connect();
if (connected.IsOk())
{
// connected — matchmaking calls are valid from here on
}
else
{
const Error& error = connected.GetError();
std::printf("connect failed (%d): %s\n",
static_cast<int>(error.Code),
reinterpret_cast<const char*>(error.Message.c_str()));
}
C
if (e->type == RT_ConnectResult)
{
if (e->mode == 0)
{
/* connected - matchmaking calls are valid from here on */
}
else
{
/* `mode` is the ErrorCode, the blob is the message: unterminated UTF-8. */
printf("connect failed (%d): %.*s\n",
e->mode, (int)e->blobLength,
(e->blobOffset >= 0) ? (const char*)(blob + e->blobOffset) : "");
}
}
/* Result collapses into two fields of the event: mode is the code and the
blob is the message. A successful *Result carries no message, so the
blob is empty unless the operation returns data of its own. */
Accessing Values and Errors
On success, GetValue() returns the value (it is ref-qualified, so it moves out of an rvalue result), operator-> gives direct member access and ValueOr(default) substitutes a fallback value on error.
On failure, GetError() returns the full Error.
GetErrorCode() is safe to call unconditionally: it returns the error's code, or ErrorCode::Ok when the result is a success, which makes it convenient for switch-based handling.
Chaining Operations
Both Result and Task<Result<T>> offer the same four monadic combinators, so multi-step flows compose without nested if blocks: Transform maps the success value, AndThen chains a dependent step, OrElse recovers from an error and TransformError rewrites the error.
An error anywhere in the chain short-circuits the remaining Transform and AndThen steps and travels to the end of the chain unchanged.
| Combinator | Callback Receives | Callback Returns | On Ok | On Err |
|---|---|---|---|---|
Transform |
the value | a new value | Runs the callback and wraps its return value in Ok. |
Skipped; the error passes through. |
AndThen |
the value | Result<U> or Task<Result<U>> |
Runs the callback and continues with its result. | Skipped; the error passes through. |
OrElse |
the Error |
Result<T> or Task<Result<T>> (same T) |
Skipped; the value passes through. | Runs the callback to recover or replace the error. |
TransformError |
the Error |
a new Error |
Skipped; the value passes through. | Runs the callback and continues with the rewritten error. |
On a Task, the AndThen and OrElse callbacks may return either a plain Result or another Task<Result>.
An entire asynchronous flow, connect, then join, then configure, therefore composes into one awaitable chain that resolves to a single final Result.
The chain below connects, creates a room and resolves to the room's name, drive it like any other task.
C++
Task<Result<RealtimeCore::Common::StringType>> chain =
client.Connect()
.AndThen([&client] { return client.CreateRoom(PHOTON_STR("battle-01")); })
.Transform([](const MutableRoomView& room) { return room.GetName(); });
C
/* No combinators: each link of the chain is a case in the event handler, and
the "transform" is just what the last handler does with the result. */
switch (e->type)
{
case RT_ConnectResult:
realtime_create_room(client, "battle-01", 0, 1, 1, 0, 0, NULL, NULL, 0);
break;
case RT_CreateRoomResult:
{
char name[64] = {0};
realtime_room_name(client, name, (int32_t)sizeof(name));
printf("room name: %s\n", name);
break;
}
default:
break;
}
/* Short-circuiting has to be written out: check mode before dispatching on
type, as the earlier samples do, so a failed step does not start the
next one. */
OrElse turns a specific expected error back into a success and lets every other error pass through.
C++
Task<Result<void>> ensureConnected =
client.Connect()
.OrElse([](const Error& error) {
if (error.Code == ErrorCode::InvalidState)
{
return Result<void>::Ok(); // already connected — treat as success
}
return Result<void>::Err(error);
});
C
#define RT_ERROR_INVALID_STATE 52
if (e->type == RT_ConnectResult)
{
if (e->mode == 0 || e->mode == RT_ERROR_INVALID_STATE)
{
/* Already connected counts as connected. */
OnConnected(client);
}
else
{
OnConnectFailed(e->mode);
}
}
/* Recovering from a specific error is a comparison against the numeric
ErrorCode in mode. */
AndThen callbacks can also return a plain Result for synchronous follow-up steps that may fail.
C++
Task<Result<void>> joinAndReady =
client.JoinRoom(PHOTON_STR("battle-01"))
.AndThen([&client](const MutableRoomView&) {
const bool accepted = client.SetPlayerProperty(PHOTON_STR("ready"), true);
return accepted ? Result<void>::Ok()
: Result<void>::Err(ErrorCode::NotInRoom);
});
C
/* rt_* helpers come from the C API page. */
if (e->type == RT_JoinRoomResult && e->mode == 0)
{
rt_blob props = {0};
rt_begin(&props);
rt_put_bool(&props, "ready", 1);
rt_end(&props);
realtime_set_player_properties(client, props.data, props.len);
rt_free(&props);
}
/* A synchronous follow-up step is a plain call in the handler.
realtime_set_player_properties returns void, so it reports nothing: a
rejected change surfaces as a PropertiesChangeFailed (114) event
instead. */
TransformError adds context or maps low-level codes before the error reaches the caller.
C++
Task<Result<void>> connectForLogin =
client.Connect()
.TransformError([](const Error& error) {
return Error{error.Code, PHOTON_STR("login: ") + error.Message};
});
C
if (e->type == RT_ConnectResult && e->mode != 0)
{
/* The message is a view into the queue blob, so add context where it is
consumed rather than trying to rewrite it in place. */
printf("login: connect failed (%d): %.*s\n",
e->mode, (int)e->blobLength,
(e->blobOffset >= 0) ? (const char*)(blob + e->blobOffset) : "");
}
/* There is nothing between the operation and the handler to rewrite an
error in, and the message bytes live in the queue blob only until the
next flush. Copy them out if the error has to travel further than the
current frame. */
Exceptions
Exceptions are orthogonal to Result::Err.
An exception thrown inside a coroutine body or a combinator callback is captured by the Task and re-thrown where the outcome is consumed, from Get() or at the co_await, and it is never converted into an Err.
A caller that only checks IsErr() will not observe it.
Keep the two channels separate by intent.
Model expected, recoverable failures, a full room, a timeout, no match found, as Result errors, and reserve exceptions for programming errors and truly exceptional conditions.
Realtime Core itself does not make use of exceptions and only works with Error values it returns.
Writing Your Own Coroutines
Any function returning Task<T> is a coroutine: co_await client operations (or your own tasks) inside it and co_return the final value.
It starts eagerly like every Task and suspends the first time it awaits something that is not yet complete.
A typical composition wraps connect, room entry and initial state into one reusable operation.
C++
Task<Result<void>> EnterMatch(RealtimeClient& client)
{
Result<void> connected = co_await client.Connect();
if (connected.IsErr())
{
co_return connected;
}
Result<MutableRoomView> joined = co_await client.JoinOrCreateRoom(PHOTON_STR("battle-01"));
if (joined.IsErr())
{
co_return Result<void>::Err(joined.GetError());
}
client.SetPlayerProperty(PHOTON_STR("ready"), false);
co_return Result<void>::Ok();
}
C
/* The C equivalent of a composed coroutine is an explicit state machine. */
typedef enum { Enter_Idle, Enter_Connecting, Enter_Joining, Enter_Done, Enter_Failed } EnterState;
typedef struct
{
EnterState state;
int32_t error;
} EnterMatch;
static void EnterMatchStart(EnterMatch* flow, RealtimeHandle client)
{
flow->state = Enter_Connecting;
flow->error = 0;
realtime_connect(client, NULL, NULL, 255, NULL, NULL);
}
static void EnterMatchOnEvent(EnterMatch* flow, RealtimeHandle client, const RealtimeEvent* e)
{
if ((e->type == RT_ConnectResult && flow->state == Enter_Connecting) ||
(e->type == RT_JoinOrCreateRoomResult && flow->state == Enter_Joining))
{
if (e->mode != 0)
{
flow->state = Enter_Failed;
flow->error = e->mode;
return;
}
}
if (e->type == RT_ConnectResult && flow->state == Enter_Connecting)
{
flow->state = Enter_Joining;
realtime_join_or_create_room(client, "battle-01", 0, 1, 1, 0, 0, NULL, NULL, 0);
}
else if (e->type == RT_JoinOrCreateRoomResult && flow->state == Enter_Joining)
{
rt_blob props = {0};
rt_begin(&props);
rt_put_bool(&props, "ready", 0);
rt_end(&props);
realtime_set_player_properties(client, props.data, props.len);
rt_free(&props);
flow->state = Enter_Done;
}
}
/* Tracking the step explicitly matters once more than one flow is in
flight, because the queue is shared: two operations of the same kind
produce two indistinguishable *Result events, so the state, not the
event, decides which step just finished. */
Your coroutines are driven exactly like the built-in ones: keep the returned Task to co_await or poll it, or discard it deliberately for fire-and-forget.
Service() resumes them along with everything else; nothing needs to be registered.