Asynchronous Operations

Operations

Every asynchronous client operation, such as connecting, joining a room or fetching a room list, hands back its outcome later instead of blocking. In C++ the operation returns a Task<Result<T, E>>, where E is that operation's own error enum (see Results). In JavaScript it returns a Promise that resolves with the value or rejects with a RealtimeError. The C API returns nothing: the outcome arrives as an event on the queue, and the C API page lists which event each operation raises.

In C++, 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.

Operations 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 it only makes progress while the client is serviced (see Client and Service Loop). In C++ and C, your game loop calls the service function, Service() or realtime_service(). In C++, it resumes the suspended operation. In JavaScript, the automatic pump services the client on a timer, unless the client was created with autoPump: false.

Awaiting an Operation

Awaiting an operation suspends your own asynchronous flow until the operation completes and yields its outcome. In C++, co_await task does this inside one of your own coroutines; in JavaScript, await does it inside an async function. 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 (in JavaScript, the event loop) while the server round-trip is in flight.

The following connects and then creates a room, one step after the other:

C++
C
JavaScript

C++

Task<Result<Room>> ConnectAndCreate(RealtimeClient& client)
{
    Result<void, ConnectError> connected = co_await client.Connect();
    if (connected.IsErr())
    {
        co_return Result<Room>::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 RealtimeEventType_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 RealtimeEventType_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. */

JavaScript

async function connectAndCreate() {
    await client.connect(); // a rejection here ends the function and rejects its promise
    return client.createRoom('battle-01');
}

const room = await connectAndCreate();

A failed step needs no forwarding: the rejected await rejects connectAndCreate() with the same RealtimeError.

Polling an Operation

From a plain game loop that does not await, poll the operation instead: check each frame whether it has completed, then fetch the outcome once. In C++, 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 of the following loop stands in for one frame of a game loop:

C++
C
JavaScript

C++

Task<Result<void, ConnectError>> connectTask = client.Connect();

while (!connectTask.IsReady())
{
    client.Service();
    std::this_thread::sleep_for(std::chrono::milliseconds(16));
}

Result<void, ConnectError> connected = connectTask.Get();

C

int32_t connectError = -1; /* -1 = still pending, 0 = ok, otherwise a RealtimeConnectError */

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 == RealtimeEventType_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. */

JavaScript

let outcome = 'pending'; // then 'ok', or the RealtimeError

client.connect().then(
    () => (outcome = 'ok'),
    (error) => (outcome = error),
);

function frame() {
    client.service(); // with autoPump: false, the game loop drives the client
    if (outcome === 'pending') {
        requestAnimationFrame(frame); // still pending: check again next frame
    }
}
requestAnimationFrame(frame);

A promise cannot be inspected synchronously, so a variable set when it settles takes the place of IsReady(). With the automatic pump the frame callback needs no service() call: the timer settles the promise on its own.

Operation Lifetime and Discarding

Dropping an operation that has not completed is safe: the operation keeps running to completion, driven by the service loop 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.

In C++, destroying a Task that has not completed detaches the handle from the underlying operation. 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.

In JavaScript, a discarded promise that rejects is reported as an unhandled rejection. Attach a catch() handler to an operation you start without awaiting it.

Results

An operation completes either with a value or with an error, which pairs a machine-readable code with a human-readable message. Operations such as connecting and leaving a room succeed without a value.

Each operation has its own set of error codes, which lists exactly the codes that operation can report. Connecting, for example, reports connection and authentication codes, while joining a room reports room and matchmaking codes. Every set is a subset of the full ErrorCode list, and a code keeps its name and value in each; the enums reference lists the codes of every operation.

Outcome C++ C JavaScript
Success Result<T, E> holding the value; Result<void, E> for operations without one. The operation's result event with mode 0; any value is in the event blob. The promise resolves with the value, or with undefined for operations without one.
Failure Result<T, E> holding an Error<E> with Code, of the operation's error enum E, and Message. The result event with the code of the operation's error enum, such as RealtimeConnectError, in mode and the message in the blob. The promise rejects with a RealtimeError with code, one of the operation's codes, codeValue and message.

In C++, E is the operation's own error enum: Connect() completes with a Result<void, ConnectError>, JoinRoom() with a Result<Room, JoinRoomError>. Every one of these enums widens implicitly to ErrorCode, the union of all codes, and Result<T> on its own means Result<T, ErrorCode>. In JavaScript, the operation's codes form a string union type, such as ConnectErrorCode for connect() and JoinRoomErrorCode for joinRoom().

When writing your own coroutines in C++, construct results with the static factories: Result<T>::Ok(value) or Result<void>::Ok() and Result<T>::Err(code, message).

Checking for Success

Check the outcome once and read the error only on the failure branch. In C++, IsOk() and IsErr() query the state, and operator bool mirrors IsOk(), so a result can be tested directly in an if. In JavaScript, a failure is a rejection, so the awaited call goes in a try block and the error is handled in catch.

The following connects and reports a failure with its code and message:

C++
C
JavaScript

C++

Result<void, ConnectError> connected = co_await client.Connect();

if (connected.IsOk())
{
    // connected — matchmaking calls are valid from here on
}
else
{
    const auto& error = connected.GetError();
    std::printf("connect failed (%d): %s\n",
                static_cast<int>(error.Code),
                reinterpret_cast<const char*>(error.Message.c_str()));
}

C

/* In the polled batch, with `blob` from realtime_event_queue_blob: */
if (e->type == RealtimeEventType_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. */

JavaScript

import { isRealtimeError } from '@photonengine/realtime-core';

try {
    await client.connect();
    // connected: matchmaking calls are valid from here on
} catch (error) {
    if (!isRealtimeError(error)) {
        throw error;
    }
    console.log(`connect failed (${error.codeValue}): ${error.message}`);
}

isRealtimeError() separates a failed operation from a programming error, which the catch block rethrows.

Accessing Values and Errors

In C++, 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 the enum's Ok when the result is a success, which makes it convenient for switch-based handling.

In JavaScript, the value is what the awaited promise resolves with. The RealtimeError a failed operation rejects with carries code, the error code as a string such as 'roomNotFound', codeValue, the numeric code, and message. The Error Codes table lists both spellings.

Chaining Operations

Multi-step flows compose without nested checks: map the success value, chain a dependent step, recover from an error or rewrite the error. An error anywhere in the chain skips the remaining success steps and travels to the end of the chain unchanged. In C++, both Result and Task<Result<T>> offer four monadic combinators for this. When the steps report different error enums, AndThen and OrElse widen the result to the enum both share, usually ErrorCode: Connect() followed by CreateRoom() yields a Task<Result<Room, ErrorCode>>. In JavaScript, the promise methods then() and catch() cover the same four roles. The C API has no combinators: each link of the chain is a case in the event handler.

C++ JavaScript Callback Receives On Success On Error
Transform then(), returning a value the value Runs the callback; its return value becomes the new value. Skipped; the error passes through.
AndThen then(), returning a promise or throwing the value Runs the callback and continues with its result. Skipped; the error passes through.
OrElse catch(), returning a value or rethrowing the error Skipped; the value passes through. Runs the callback to recover or replace the error.
TransformError catch(), throwing a new error the error Skipped; the value passes through. Runs the callback and continues with the rewritten error.

In C++, Transform wraps the callback's return value in Ok, AndThen callbacks return Result<U> or Task<Result<U>>, OrElse callbacks return Result<T> or Task<Result<T>> with the same T, and TransformError callbacks return a new Error, of any code type. On a Task, the AndThen and OrElse callbacks may return either a plain Result or another Task<Result>; in JavaScript, a then() or catch() callback may likewise return a plain value or another promise. An entire asynchronous flow, connect, then join, then configure, therefore composes into one awaitable chain that ends in a single final outcome.

The following chain connects, creates a room and resolves to the room's name:

C++
C
JavaScript

C++

Task<Result<RealtimeCore::Common::StringType>> chain =
    client.Connect()
        .AndThen([&client] { return client.CreateRoom(PHOTON_STR("battle-01")); })
        .Transform([](const Room& 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 RealtimeEventType_ConnectResult:
        realtime_create_room(client, "battle-01", 0, 1, 1, 0, 0, NULL, NULL, 0);
        break;

    case RealtimeEventType_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. */

JavaScript

const name = await client
    .connect()
    .then(() => client.createRoom('battle-01'))
    .then((room) => room.name);

Recovering turns a specific expected error back into a success and lets every other error pass through:

C++
C
JavaScript

C++

Task<Result<void, ConnectError>> ensureConnected =
    client.Connect()
        .OrElse([](const auto& error) {
            if (error.Code == ConnectError::AlreadyConnected)
            {
                return Result<void, ConnectError>::Ok(); // already connected — treat as success
            }
            return Result<void, ConnectError>::Err(error);
        });

C

/* In the polled batch: */
if (e->type == RealtimeEventType_ConnectResult)
{
    if (e->mode == 0 || e->mode == RealtimeConnectError_AlreadyConnected)
    {
        /* 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. */

JavaScript

import { isRealtimeError } from '@photonengine/realtime-core';

await client.connect().catch((error) => {
    if (isRealtimeError(error) && error.code === 'alreadyConnected') {
        return; // already connected: treat as success
    }
    throw error;
});

A chained step can also be a synchronous follow-up that may fail:

C++
C
JavaScript

C++

Task<Result<void>> joinAndReady =
    client.JoinRoom(PHOTON_STR("battle-01"))
        .AndThen([&client](const Room&) {
            const bool accepted = client.SetPlayerProperty(PHOTON_STR("ready"), true);
            return accepted ? Result<void>::Ok()
                            : Result<void>::Err(ErrorCode::NotInRoom);
        });

C

/* The rt_* blob helpers, copied from "Blob Helpers" on the C API page. */
#include "photon_blob.h"

/* In the polled batch: */
if (e->type == RealtimeEventType_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. */

JavaScript

import { RealtimeError } from '@photonengine/realtime-core';

await client.joinRoom('battle-01').then(() => {
    if (!client.setPlayerProperties({ ready: true })) {
        throw new RealtimeError('notInRoom', 'the ready flag could not be sent');
    }
});

Throwing inside then() rejects the chain, the way a returned Err ends the C++ chain.

Rewriting the error adds context or maps low-level codes before the error reaches the caller:

C++
C
JavaScript

C++

Task<Result<void, ConnectError>> connectForLogin =
    client.Connect()
        .TransformError([](auto error) {
            error.Message = PHOTON_STR("login: ") + error.Message;
            return error;
        });

C

/* In the polled batch, with `blob` from realtime_event_queue_blob: */
if (e->type == RealtimeEventType_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. */

JavaScript

import { RealtimeError, isRealtimeError } from '@photonengine/realtime-core';

const connectForLogin = client.connect().catch((error) => {
    if (isRealtimeError(error)) {
        throw new RealtimeError(error.codeValue, `login: ${error.message}`, { cause: error });
    }
    throw error;
});

Passing codeValue keeps the numeric code even when code is 'unknown', and cause keeps the original error.

Exceptions

This section applies to C++. In JavaScript, every failure travels as a rejection: an exception thrown inside an async function or a then() callback rejects its promise the same way a failed operation does. Only failed operations reject with a RealtimeError, so isRealtimeError() tells them apart from programming errors. Misusing the API, such as calling a disposed client, throws a plain Error, TypeError or RangeError synchronously. The C API has no exceptions, a failure arrives as the error code in the result event's mode.

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 lost connection, 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 Async Operations

Your own asynchronous functions compose client operations into larger ones: they await client operations, or other functions of your own, and return the final value. In C++, 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. In JavaScript, an async function behaves the same way: it runs up to the first await of a pending promise and returns a promise of its result.

A typical composition wraps connect, room entry and initial state into one reusable operation:

C++
C
JavaScript

C++

Task<Result<void>> EnterMatch(RealtimeClient& client)
{
    Result<void, ConnectError> connected = co_await client.Connect();
    if (connected.IsErr())
    {
        co_return connected;
    }

    Result<Room, JoinRoomError> 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 rt_* blob helpers, copied from "Blob Helpers" on the C API page. */
#include "photon_blob.h"

/* 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 == RealtimeEventType_ConnectResult && flow->state == Enter_Connecting) ||
        (e->type == RealtimeEventType_JoinOrCreateRoomResult && flow->state == Enter_Joining))
    {
        if (e->mode != 0)
        {
            flow->state = Enter_Failed;
            flow->error = e->mode;
            return;
        }
    }

    if (e->type == RealtimeEventType_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 == RealtimeEventType_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. */

JavaScript

async function enterMatch() {
    await client.connect();
    await client.joinOrCreateRoom('battle-01');
    client.setPlayerProperties({ ready: false });
}

await enterMatch();

Your own operations are driven exactly like the built-in ones: keep the returned task or promise to await or poll it, or discard it deliberately for fire-and-forget. The service loop resumes them along with everything else; nothing needs to be registered.

Last updated on

Back to top