Skip to main content
How the SDK works

Not translated yet — showing English.

Threads, Lifetime and Testing

You own the loop, and the SDK delivers into exactly one place. The SDK starts no thread you have to know about, hands you no lock, and calls your code from a single context you chose at startup. Call us from any thread you like; we call you from one.

One delivery context, and you own the loop

your threads — any numberthe SDKone delivery context — handlers run one at a timegame threadworkerjob task calls, from any threaddeliveries a call from a handler
Calls go in from any thread; deliveries come out on one context, one handler at a time; a call made from a handler answers as a new delivery

An instance declares exactly one delivery context — the single place where all of its handlers run. It is fixed when you initialise and does not change for the life of the instance. An event, a data delta, the outcome of a call: all of them arrive there and nowhere else.

There are two shapes of it, and you pick one at startup:

  • You pump it. The runtime does nothing on its own; you drain pending deliveries from your own loop. This is the shape an engine wants — deliveries land on the game thread, in a frame you chose.
  • We own it. The runtime holds one dedicated thread of execution. This is the shape a console host or a dedicated server wants.

Neither is a fallback for the other, and there is no third option involving a thread pool: the number of threads the runtime holds is never a question your code has to ask.

The context is never an argument. No handler takes a "which thread am I on" parameter, and there is nothing to query. Where your handler runs is a property of the contract, not data of the call.

Starting and stopping are explicit

Initialisation is a call you make, and it answers with an outcome. Nothing initialises lazily on first use. A lazy start would move the one place where a disabled module is visible into whichever call happened to be first, where it would read as that call failing.

A disabled module is named at startup, and the outcome says which of two things happened: the whole dependent chain is off, or you are running with less, plus the list of what is unavailable. There is no silent third case.

Initialise with an explicit outcome, pump from your own loop, shut down when you are done
// the outcome names a disabled module and what it took with it — it is not an exception
options.Delivery = DeliveryContext.Pumped(out IPump pump);   // or DeliveryContext.Owned()
InitializationOutcome outcome = await PlayServRuntime.Initialize(options);
foreach (var gap in outcome.Unavailable) Log(gap);

void OnFrame() => pump.Drain();                          // your loop, your frame

await runtime.DisposeAsync();                            // explicit, idempotent
// the outcome names a disabled module and what it took with it — it is not a thrown error
const outcome = await PlayServ.runtime.initialize({
  delivery: PlayServ.delivery.pumped(),                 // or .owned()
});
outcome.unavailable.forEach(log);

const onFrame = () => outcome.pump.drain();             // your loop, your frame

await runtime.close();                                  // explicit, idempotent
# the outcome names a disabled module and what it took with it — it is not an exception
outcome = await playserv.runtime.initialize(
    delivery=playserv.delivery.pumped(),                # or .owned()
)
for gap in outcome.unavailable:
    log(gap)

def on_frame():
    outcome.pump.drain()                                # your loop, your frame

await runtime.close()                                   # explicit, idempotent
Coming soon — Go

Attribute-style declaration in Go is still open (O-1) — the samples land when the carrier is decided.

// deliveries land on the game thread; a gap is a named outcome, not an exception
FPlayServClient::Connect(Options,
    TPSOnResult<FPlayServClient*>::CreateLambda([](const TPSResult<FPlayServClient*>& Result)
    {
        if (!Result.HasValue()) { return; }
        FPlayServClient* Client = Result.Value();
        for (const FPSGap& Gap : Client->Unavailable())
        {
            UE_LOG(LogPlayServ, Warning, TEXT("%s"), *Gap.Text);
        }
    }));

// no pump call: the plugin drains on the game thread for you

Client->Shutdown();                          // explicit, idempotent — and not a cancel
// co_await support ships later: a built-in SDK coroutine wrapper that respects Unreal's GC and threading.
Unity C# is the same C# API here — runs as-is in Unity; deliveries land on the main thread and the package drains them for you
// the outcome names a disabled module and what it took with it — it is not an exception
options.Delivery = DeliveryContext.Pumped(out IPump pump);   // or DeliveryContext.Owned()
InitializationOutcome outcome = await PlayServRuntime.Initialize(options);
foreach (var gap in outcome.Unavailable) Log(gap);

void OnFrame() => pump.Drain();                          // your loop, your frame

await runtime.DisposeAsync();                            // explicit, idempotent

Shutdown is explicit, complete and idempotent: after it succeeds, no handler of that instance is called again.

Shutting down doesn't cancel work in flight

Shutdown says nothing about operations already started. Each stays discoverable by the means that operation named; if you need to know whether a purchase went through, ask that way, not by shutting down.

Every handle has one declared end

A subscription, a deferred-work descriptor, a session: each is a handle, and each has exactly one end that the contract names. Releasing is idempotent, so releasing twice is not an error.

Three consequences that are easy to get wrong:

  • The end is never a finaliser, a destructor or a scope. A handle you drop on the floor stays open. That is a bug in your code, not something we quietly reclaim — because a lifetime that depends on the language would be a different lifetime in every binding.
  • Using a handle after its end is a declared refusal, with a code. Not an empty result, not undefined behaviour, and not a generic disposed-object error that carries nothing you can act on.
  • A dropped connection is not the end of a handle. A subscription survives a disconnect and keeps receiving after reconnection. Handles end for the reasons the contract names, and losing the network is not one of them.

No handle outlives the instance that issued it: once you shut down, every handle it gave you is at its end.

Calling from a handler is fine; waiting inside one is not

Call the surface from any of your threads. Every handle is free-threaded, and that is a promise, not a property of today's build. No synchronisation primitive is part of the surface.

Handlers of one instance are serialised: two never run at the same time, and order within one stream is preserved. So a handler needs no locking of its own.

Serialised does not mean deduplicated. Order is one promise; how many times a message is delivered is a different one, declared on the message type. Under at-least-once you will see the same message twice, and the deduplication key that always travels with it is how you tell.

Starting an operation from inside a handler is legal and cannot deadlock. Its outcome, though, never arrives inside that same handler — it comes back as a separate delivery on the same context.

Never block the delivery context

Inside a handler, don't wait on the network, on someone else's lock, or synchronously on your own call.

The prohibition has a symptom — a handler that holds the context beyond its declared budget produces either a declared degradation of delivery or a declared refusal. What it never produces is a silent slowdown you get to discover in a player's session.

Start work from a handler and return; the outcome arrives as its own delivery
// legal: start and return. The outcome is a later delivery, not a value here.
sub = await room.Events.Subscribe<CrateOpened>(async e => {
    await player.Inventory.Grant(e.Loot);   // started, not awaited-to-completion inside the context
});                                          // ...the grant's outcome arrives on its own

await sub.DisposeAsync();                    // stop receiving — local, works with the network down
// legal: start and return. The outcome is a later delivery, not a value here.
const sub = await room.events.subscribe(CrateOpened, async (e) => {
  await player.inventory.grant(e.loot);
});

await sub.close();                          // stop receiving — local, works with the network down
# legal: start and return. The outcome is a later delivery, not a value here.
sub = await room.events.subscribe(CrateOpened, lambda e: player.inventory.grant(e.loot))

await sub.close()                           # stop receiving — local, works with the network down
Coming soon — Go

Attribute-style declaration in Go is still open (O-1) — the samples land when the carrier is decided.

// subscribing is local and immediate; the outcome is a later delivery, on the game thread
TPSSubscription LootWatch = Room->Subscribe->CrateOpened(
    [this](const FCrateOpened& Opened) { GrantLoot(Opened.Loot); });

LootWatch.Unsubscribe();                    // stop receiving — local, works with the network down
// co_await support ships later: a built-in SDK coroutine wrapper that respects Unreal's GC and threading.
Unity C# is the same C# API here — runs as-is in Unity against the generated types
// legal: start and return. The outcome is a later delivery, not a value here.
sub = await room.Events.Subscribe<CrateOpened>(async e => {
    await player.Inventory.Grant(e.Loot);   // started, not awaited-to-completion inside the context
});                                          // ...the grant's outcome arrives on its own

await sub.DisposeAsync();                    // stop receiving — local, works with the network down

"Cancel" is two different things

One word in most languages, two operations here, and the difference is observable:

You wantWhat it is
stop delivering to melocal. Always succeeds, including with the connection down. Releasing a subscription is this.
stop the worka request to the platform. Idempotent, and it promises nothing about whether the work happened.

The second is the one people get wrong. Cancelling accepted work is a request that may not arrive in time — exactly like a timeout, which also does not mean "it did not apply". After either kind of cancellation, the outcome of a started non-idempotent operation remains discoverable by the means that operation named.

And the two failures are distinguishable: cancelling a call that never reached us is a local failure; cancelling work we had accepted gives you a terminal state from a declared set.

What you receive is a copy of the past

A value delivered to your handler does not change afterwards. We never hand out a live reference into our own state, so nothing you are holding mutates between two lines of your code.

Keeping a delivered value beyond the handler is therefore safe — but what you kept is an observation of a moment, not a window onto the present. Deltas may be merged on the way to you, so a list of values you saved is not a history of what happened.

You also never own our buffers. There is no rent-and-return, no assemble-then-send: changing declared state is the network operation.

Testing: an in-memory implementation, not a mock

There is a full in-memory implementation — same surface, same set of declared outcomes, no network. It is a separate thing you depend on, not a flag on the production runtime.

  • It is not partial. An operation it does not support is refused with a declared code, never answered with an invented success.
  • Time is yours. Declared deadlines — a work descriptor's lifetime, a reservation, a retention window — are reached by advancing a step, not by sleeping.
  • Determinism is declared and bounded: order within a stream, the declared delivery mode, controllable time. Floating-point determinism is not promised, so a full simulation does not replay here either.

What you install, and the version floor

The core is one unit; optional modules are separate ones, each with a declared composition and a declared list of mandatory dependencies. Adding a unit never changes another's surface — a module mounts where its declaration says, so nothing appears or disappears elsewhere because of what you installed beside it.

If an optional unit is referenced but cannot load, that is a declared outcome of initialisation — the same place a disabled module is reported. Never a stub that silently does nothing.

Every binding declares the minimum runtime version it is built against. Below it, you get a refusal at initialisation, not partial operation: a runtime that is too old otherwise breaks at the first capability it lacks, which is somewhere arbitrary in your code and usually on a player's machine rather than yours. Raising that minimum is a breaking change and goes through the same process as any other.

Where to go next