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"));
}

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();

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()));
}

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(); });

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);
        });

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);
        });

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};
        });

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();
}

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.

Back to top