Metaplay logo
How We Replaced Scattered Callbacks with a Single Async Loop in Metaplay Release 36

How We Replaced Scattered Callbacks with a Single Async Loop in Metaplay Release 36

Chris Wilson with Jarkko Pöyry
February 17, 2026tech-talks

Every game on Metaplay had its own handmade state machine for managing connections. In R36, we replaced all of that with a single async method that cut about a third of the lifecycle code from our sample projects.

"When porting our sample projects, I was surprised how much code I could remove and how simple the logic became. I was of course expecting them to become better, that's why we did this in the first place. But even then, still... the impact surprised me."

That's Jarkko, who built the new async session API in Release 36 (R36). When he migrated our own sample projects, roughly a third of the lifecycle code disappeared — and a bug in error state transitions got fixed along the way.

Every Game Had Its Own State Machine (And Its Own Bugs)

Before R36, Metaplay exposed connection lifecycle through IMetaplayLifecycleDelegate callbacks — OnSessionLost, OnFailedToStartSession, and a handful of others. The callbacks worked, but they pushed a burden onto every integration.

"You often had to manage your own state machine from these lifecycle events," Jarkko explains. "This meant you had one extra layer converting the events into the 'suitable' lifecycle state that could be used by the application."

Every application has a lifecycle state machine, explicit or implicit. The SDK had one. Your game had another one on top. If your game used a DI container with async service initialization — increasingly common in .NET — you were converting event-driven signals into async flows, and each conversion point was a spot for edge case bugs.

Two failure modes that kept coming back

Two things in particular kept tripping people up. First, error handling in the callback model can't be async. A connection error fires and you want to load back to an error scene — SceneManager.LoadSceneAsync("ErrorScene") is an async call. With callbacks, you could start that transition but then had to manually track whether one was already in progress and block anything else from acting.

Second, control flow leaked into the UI. Take the "client update required" dialog — in the callback model, the dialog itself had to kick the next state transition after the player dismissed it, and you had to check for ongoing transitions before doing so. Those checks ended up scattered across UI code and game logic. "In the async model, ShowUpdateNeeded only reports when it's done," Jarkko says. "It cannot fail to check for transitions, or miss checks."

What this actually cost in production

The catastrophic version of these bugs — game fails to start — was rare and caught in testing. The subtle version was worse. Say your analytics system depends on session start to include a PlayerId in all events. If game logic doesn't properly wait for analytics initialization, you send events before it's ready. This sneaks past dev builds where analytics are usually disabled, though release tests would catch it.

But the real operational pain came from error handling in exotic failure cases. When a rare error happened and the state change wasn't handled correctly, cascading failures flooded the logs. "The incident reports will contain a large amount of error spam and red herrings from systems that were victims of the broken state change," Jarkko says. "They don't really affect production since by definition we have already run into an error. But they do make triage, debugging and response slower."

A Single Async Loop That Replaces All of It

R36 introduces MetaplayClient.ConnectAsync() and a MetaplaySession object. At its simplest, the entire lifecycle is this:

while (true)
{
    MetaplaySession session = await MetaplayClient.ConnectAsync();
    session.SessionStartComplete();
    await session.WaitForSessionEndAsync();
}

Connect, signal ready, wait for session end, loop. The MetaplaySession gives you PlayerContext, GameConfig, and all the session state your game logic needs.

Real games need to handle connection failures, version mismatches, and mid-session disconnects. With the async API, all of that stays in one method:

async Task SessionLoop()
{
    while (true)
    {
        MetaplaySession session;
        try
        {
            session = await MetaplayClient.ConnectAsync();
        }
        catch (FailedToStartSessionException ex)
        {
            ConnectionLostEvent lostEvent = ex.Failure;
            if (lostEvent.Reason == ConnectionLostReason.ClientVersionTooOld)
                await ShowUpdateNeeded();
            else
                await ShowConnectionErrorPopup(lostEvent);
            continue;
        }

        try
        {
            session.SessionStartComplete();
        }
        catch (Exception ex)
        {
            ConnectionLostEvent sessionStartFailed =
                session.SessionStartFailed(ex);
            await ShowConnectionErrorPopup(sessionStartFailed);
            continue;
        }

        ConnectionLostEvent connectionLost =
            await session.WaitForSessionEndAsync();
        if (connectionLost.AutoReconnectRecommended)
            continue;
        else
            await ShowConnectionErrorPopup(connectionLost);
    }
}

Error handling is natively async — await ShowUpdateNeeded() just works. Nothing outside this loop makes lifecycle decisions.

For comparison, the same error handling with IMetaplayLifecycleDelegate:

void IMetaplayLifecycleDelegate.OnSessionLost(
    ConnectionLostEvent connectionLost)
{
    if (connectionLost.AutoReconnectRecommended)
        SwitchToState(ApplicationState.Initializing);
    else
        ShowConnectionErrorPopup(connectionLost);
}

void IMetaplayLifecycleDelegate.OnFailedToStartSession(
    ConnectionLostEvent connectionLost)
{
    if (connectionLost.Reason == ConnectionLostReason.ClientVersionTooOld)
        ShowUpdateNeeded();
    else
        ShowConnectionErrorPopup(connectionLost);
}

Looks clean in isolation. But ShowConnectionErrorPopup and ShowUpdateNeeded can't be async here, and every callback needs to know what state the rest of the system is in before it acts.

Why We Chose Async/Await Over a State Machine API

We considered a more traditional explicit state machine API. Readability won.

"Async integrates beautifully in the .NET ecosystem, that's the main reason," Jarkko says. "Awaiting methods allows splitting the logic to high-level plan and low-level details, allowing even a complex lifecycle to be represented in readable manner... unlike nested state machines."

With async/await, the connect-play-disconnect flow reads linearly. The code structure is the state machine — you don't need to hold a mental model of valid transitions.

The callback approach still works. Both APIs run simultaneously, and we're committed to backwards compatibility. If your state machine does what you need, no pressure to change.

To migrate: move the logic from your IMetaplayLifecycleDelegate callbacks into the async loop. Since both APIs coexist, you can port incrementally. "While porting, just don't be surprised if you end up spending most of the time cleaning the extra layers and checks that were needed to make the callback API fit in into your game," Jarkko adds.

In our sample projects, the migration removed about a third of the lifecycle code and fixed an error state transition bug that had been there all along. It also made the logic local — you can read a single method and get the full picture. R36 also removes the need to call MetaplayClient.Update() in your Unity update loop; the SDK handles that automatically now.

For migration details, check the R36 release notes, Connection Management guide, and the step-by-step Implementing Your Own Game Server Connection walkthrough.

What We Don't Know Yet

The API is new and we don't have external feedback yet. One thing we're watching for is whether people will initialize their async services inside the session loop (before calling session.SessionStartComplete()) or keep using the dedicated OnSessionStartedAsync() callback. Both are supported, and there aren't strong technical arguments either way.

We'd like to hear how it goes if you try it. Discord is the best place to come find us — drop by and let us know.

FAQ

Do I have to migrate?

No. IMetaplayLifecycleDelegate is fully supported with no deprecation planned.

How long does migration take?

The core change is mechanical: move callback logic into the async loop. Most of the time goes into removing state-tracking code that's no longer needed. Our sample projects ended up about a third smaller.

Did any namespaces change?

Some session-related types moved from Metaplay.Unity to Metaplay.Core.Session. Check the R36 release notes for the full list.

Any gotchas?

Standard Unity async rules: start on the Unity thread, don't use .ConfigureAwait(false). Both require deliberate effort to break, so you're unlikely to hit them accidentally.

Can I use both APIs during migration?

Yes. Callback and async APIs work simultaneously, so you can port incrementally.