Unreal Engine 5 C++ plugin SDK for the Asobi game backend. Requires UE 5.4+ (tested on 5.4–5.7).
Clone into your project's Plugins/ directory:
cd YourProject/Plugins
git clone https://github.com/widgrensit/asobi-unreal.git AsobiSDKRegenerate project files, then enable Asobi SDK in Edit → Plugins → Networking.
The SDK talks to an Asobi server. The fastest way to get one is:
git clone https://github.com/widgrensit/sdk_demo_backend
cd sdk_demo_backend && docker compose up -dThat serves at http://localhost:8084 (HTTP + WebSocket on /ws) with a 2-player demo mode. For the full reference game (arena shooter) see asobi_arena_lua.
#include "AsobiClient.h"
#include "AsobiAuth.h"
#include "AsobiMatchmaker.h"
#include "AsobiWebSocket.h"
UAsobiClient* Client = NewObject<UAsobiClient>();
Client->SetBaseUrl(TEXT("http://localhost:8084"));
UAsobiAuth* Auth = NewObject<UAsobiAuth>();
Auth->Init(Client);
FOnAsobiAuthResponse OnLogin;
OnLogin.BindDynamic(this, &AMyPawn::HandleLogin);
Auth->Login(TEXT("player1"), TEXT("secret"), OnLogin);The matchmaker pushes match.matched after pairing players; a client-initiated match.join produces match.joined. Subscribe to both to cover matchmade and direct-join paths:
WebSocket->OnMatchMatched.AddDynamic(this, &UMyClass::OnReady);
WebSocket->OnMatchJoined.AddDynamic(this, &UMyClass::OnReady);See the WebSocket protocol guide for the full event surface.
MatchFindOrCreate puts the player into a live match of a mode, spawning one if there is none. Prefer it over match.list then match.join: browse-then-join races, so two clients reading the same empty listing each create a match. This resolves server-side and is serialised, so simultaneous callers converge on one match.
WebSocket->MatchFindOrCreate(TEXT("arena"));The reply is match.joined, the same frame JoinMatch answers with, so the OnMatchJoined subscription above already covers it. The call takes the mode and nothing else; every other match parameter comes from the mode's server-side config.
The mode must set quick_play = true, which defaults to false for match modes; a mode that has not opted in is refused with quick_play_disabled. This is not the same axis as listed, which is browser visibility. Refusals also include not_found (the mode name is unknown or not configured, most often a typo), match_capacity_reached (node-wide cap), wrong_mode_type (a world mode) and join_rate_limited (the same bucket as match.join and world.join).
Requires asobi server v0.86.0 or later.
The world twin is WorldFindOrCreate.
Sign in without a username by pairing a stable device id with a device secret. The secret must be the base64 of at least 32 CSPRNG bytes, generated and stored by your game (the SDK passes it through, it does not generate or persist it for you). The same (DeviceId, DeviceSecret) pair resumes the same guest player on a later launch; store both securely on device.
Auth->Guest(DeviceId, DeviceSecret,
FOnAsobiAuthResponse::CreateLambda([](bool bOk, const FAsobiAuthTokens& Tokens, const FAsobiError& Error)
{
// Tokens are stored on the client automatically, same as Login/OAuth.
// Tokens.bCreated distinguishes a new guest (true) from a resumed one (false);
// Tokens.bGuest is true and Tokens.Username carries the assigned guest name.
if (!bOk)
{
// Error.StatusCode / Error.Reason, e.g. 400 weak_device_secret,
// 409 device_already_registered, 503 guest_capacity_reached.
}
}));Later, convert the guest into a permanent account. The call is authenticated with the guest's current access token and replaces the stored token pair with the claimed account's:
Auth->UpgradeGuest(TEXT("player1"), TEXT("secret"), OnUpgrade);On success the returned FAsobiAuthTokens has bUpgraded == true.
Every auth entrypoint (Register, Login, Refresh, OAuthAuthenticate, Guest, GuestDevice, UpgradeGuest) reports failures through the third callback parameter:
FOnAsobiAuthResponse::CreateLambda([](bool bOk, const FAsobiAuthTokens& Tokens, const FAsobiError& Error)
{
if (!bOk)
{
UE_LOG(LogTemp, Warning, TEXT("auth failed: %d %s"), Error.StatusCode, *Error.Reason);
}
});Error.Reason is the backend's stable snake_case code -- weak_device_secret, invalid_device_secret, guest_revoked, guest_capacity_reached, device_already_registered, not_an_unclaimed_guest, username_taken, invalid_credentials, validation_failed, ... -- so you can branch on it rather than showing one generic message. Three reasons are produced by the SDK itself: network_error (no response, StatusCode == 0), malformed_response (2xx with no usable access token) and unknown_error (a non-2xx body with no error field).
GuestDevice is the one-call version: it generates the (DeviceId, DeviceSecret) pair on first run, persists it to a USaveGame slot, reuses it on every later launch, and signs in — so you never hand-roll base64, storage, or the >=32-byte rule.
Auth->GuestDevice(
FOnAsobiAuthResponse::CreateLambda([](bool bOk, const FAsobiAuthTokens& Tokens, const FAsobiError& Error)
{
if (!bOk) return;
// Tokens.bCreated == true on the very first sign-in (brand-new guest),
// false when the persisted pair resumed an existing player.
}));To forget the guest ("switch account" / "play as someone else"), erase the stored pair — the next GuestDevice mints a brand-new guest. This is local-only; pair it with Logout to end the current session, or call UpgradeGuest first to keep the player:
#include "AsobiDevice.h"
AsobiDevice::Clear();AsobiDevice::Clear deletes nothing on the server — the account and its data stay, merely unreachable from that install. For an actual "delete my data" request, and for the in-app account deletion the app stores require, erase it:
// Guest or provider-only account: no password to confirm with.
Client->Auth->EraseAccount(TEXT(""), FOnAsobiResponse::CreateLambda(
[](bool bSuccess, const FString& Response) { /* ... */ }));
// Account with a password: it must be echoed.
Client->Auth->EraseAccount(TEXT("secret123"), Callback);Irreversible. A wrong password comes back 403 with code player.confirmation_failed and changes nothing. On success the local session is cleared, because the server deleted the token pair in the same transaction; anything afterwards on that session is a 401, which for a retried erase means it already worked.
Needs a server carrying POST /api/v1/players/me/erase; older ones answer 404.
The default byte source is best-effort, not a guaranteed CSPRNG on every platform (UE ships no portable one). For higher assurance, or to choose your own storage slot, drive AsobiDevice::LoadOrCreate with options and pass the pair to Guest():
FAsobiDeviceOptions Options;
Options.SlotName = TEXT("MyGameGuest");
Options.RandomBytes = [](int32 NumBytes) { return MyPlatformCsprng(NumBytes); };
const FAsobiDeviceCredentials Creds = AsobiDevice::LoadOrCreate(Options);
Auth->Guest(Creds.DeviceId, Creds.DeviceSecret, OnGuest);WorldInput sends no sequence number, and the server acks only players who
stamped one. To reconcile a prediction, stamp every input with your own counter
via WorldInputWithSeq and bind OnWorldAck.
AddDynamic binds by name, not by address: the method pointer it takes is only a
type check, and the delegate stores the handler's FName and looks it up in the
class's UFUNCTION table. Declare the handler UFUNCTION(), or the code
compiles and the bind fails at runtime:
// MyClass.h
#include "AsobiWebSocket.h"
#include "MyClass.generated.h"
UCLASS()
class UMyClass : public UObject
{
GENERATED_BODY()
public:
void Play(UAsobiWebSocket* WebSocket);
UFUNCTION()
void HandleWorldAck(const FAsobiWorldAck& Ack);
private:
int64 NextSeq = 0;
};// MyClass.cpp
void UMyClass::Play(UAsobiWebSocket* WebSocket)
{
WebSocket->OnWorldAck.AddDynamic(this, &UMyClass::HandleWorldAck);
// Seq is yours to generate and increment; the SDK does neither.
WebSocket->WorldInputWithSeq(TEXT("{\"move_x\":1}"), ++NextSeq);
}
void UMyClass::HandleWorldAck(const FAsobiWorldAck& Ack)
{
// Ack.Tick and Ack.Seq are both int64. Drop every pending input whose Seq
// is at or below Ack.Seq, then replay the rest.
}WorldInputWithSeq(const FString& DataJson, int64 Seq) is a second Blueprint
node rather than an extra pin on WorldInput(const FString& DataJson), because
UFUNCTIONs cannot overload by name. Both send DataJson as the payload itself,
because that is the map the server forwards to your handle_input/3; only
WorldInputWithSeq stamps seq, and it rides as a top-level sibling of
payload, never nested:
{"type":"world.input","cid":"c-7","seq":412,"payload":{"move_x":1}}
{"type":"world.ack","payload":{"tick":42,"seq":412}}DataJson must be a JSON object. An empty string sends an empty map; an array,
a bare value or text that is not JSON is dropped with a [asobi] log line
rather than going out as the empty map it would otherwise become, which reads
from the game as input that does nothing. Logged once per socket, since a send
loop repeats the same mistake every frame.
data is reserved at the top level of that map, but only in one shape: a
payload whose sole key is data mapped to an object is unwrapped. That shape is
deprecated and goes at the next protocol break. A data beside other keys, or a
data holding anything but an object, is forwarded verbatim, siblings and all.
SendMatchInput keeps its own data handling: the server's match.input
clause takes data as an object or as a JSON string it decodes, so the SDK
still wraps the payload under data there on purpose.
Ack.Seq is a high-water mark: the highest input the server had consumed for
you as of Ack.Tick, not a receipt for one input. A rejected input still
advances it, so a dropped input never strands the client. Ack.Tick is the
broadcast tick number, the same counter OnWorldTick reports on a delta
frame. The snapshot a new zone subscription opens with is not a delta and goes
out as tick 0, so do not read every OnWorldTick tick as a step forward in
time.
Keep Seq within 0 .. 9007199254740991 (2^53-1). A value outside that range is
ignored, but the input is not: it is still queued and applied to the world as
normal, and only the acknowledgement for it is skipped. So do not seed the
counter from a nanosecond timestamp even though int64 holds one; the SDK also
stamps seq through a double, which loses precision above 2^53 before the frame
leaves.
The SDK keeps no counter of its own, so keep Seq monotonic for the life of the
connection: a Seq at or below one already recorded is never acked again.
The mark is per connection and dies with the socket, so a reconnect leaves nothing holding one and a counter restarting at 0 acks normally.
OnWorldTick(int64 Tick, const FString& UpdatesJson) delivers a delta, not a
snapshot. UpdatesJson is the updates array; each entry carries an op:
op |
Meaning | Fields |
|---|---|---|
"a" |
Added, full state | id plus every field on the entity |
"u" |
Updated, diff | id plus only the changed fields |
"r" |
Removed | id only |
A new zone subscription opens with a full "a" snapshot of that zone's entities,
tagged tick 0, and the frames after it are deltas. Joining subscribes you to your
whole interest ring, so a join delivers one snapshot per loaded, non-empty zone in
it, several frames rather than one. A zone holding no entities skips the entity
snapshot, but the terrain push after it is unconditional, so a world with a terrain
provider still delivers that zone's chunk on OnWorldTerrain.
A crossing delivers fresh snapshots too. It recomputes the ring, and every zone
that just entered it is a new subscription replaying a full snapshot. Only the
destination zone is a no-op: at view_radius 1 it was already in the old ring, so
resubscribing to it changes nothing. Do not read that one no-op as the whole
crossing being quiet.
Nor is a snapshot a once-per-zone event. Leaving the ring unsubscribes you, and
that zone sends an "r" for each of its entities on the way out. Walk back in and
you resubscribe and get another full snapshot, so a player oscillating across a
boundary re-snapshots every time.
Accumulate the deltas into your own state map: assigning UpdatesJson wholesale to
an "authoritative state" variable is wrong, and a map seeded only from "u" entries
stays empty, because your own player first arrives as an "a".
When a zone's broadcast tick produced changes it sends world.tick first and
world.ack second. When nothing changed that zone sends no world.tick at all and
the ack arrives on its own. So prune and replay in the OnWorldAck handler, never
inside OnWorldTick: an ack that arrives with no tick beside it still has to drain
the pending buffer. The loop:
- Increment
NextSeq, apply the input locally, and keep it in aTMap<int64, FMyInput>of pending inputs (FMyInputis your own struct). - Send it with
WorldInputWithSeq. - Fold every
OnWorldTickdelta into your authoritative state. - On
OnWorldAck, drop every pending input withSeq <= Ack.Seq, and re-apply the remainder inSeqorder on top of that state.
The ack only rides broadcast ticks. broadcast_interval defaults to 3 simulation
ticks; set it to 1 for an ack every tick, which is what prediction wants
(world server config).
Binding OnWorldAck is not enough on its own: keep calling plain WorldInput
and nothing ever fires, with no error. Input sent while you are not in a zone is
dropped with no reply at all, which looks the same from the client.
Needs plugin release v1.4.0 or newer, and asobi v0.84.1 or later on the server.
AsobiSDK.uplugin does not track the release version, its VersionName reads
1.0.0 in every release, so check the source instead: OnWorldAck and
WorldInputWithSeq are in Source/AsobiSDK/Public/AsobiWebSocket.h from v1.4.0
onwards and absent before it.
Full frame reference: client-side prediction.
Server extensions expose methods over the same socket.
Blueprint — drag off the Asobi RPC node:
Asobi RPC (Realtime, "quests.claim", "{\"quest_key\":\"daily\"}")
-> Success ResultJson
-> Failure Error.Code / Error.Message / Error.DetailsJson
C++:
TSharedPtr<FJsonObject> Params = MakeShareable(new FJsonObject);
Params->SetStringField(TEXT("quest_key"), TEXT("daily"));
Realtime->Rpc(TEXT("quests.claim"), Params,
[](const FString& ResultJson, const FAsobiRpcError* Error)
{
if (Error)
{
if (Error->Code == TEXT("quests.already_claimed")) { /* ... */ }
return;
}
// ResultJson is the result object - parse it as the extension documents
});ResultJson is raw JSON because the extension defines its shape. Calls are
correlated by cid, so several can be in flight at once and may answer out of
order.
Exactly one outcome fires, always: a call made while disconnected fails with
code not_connected rather than leaving a latent node waiting forever. Branch
on Code; Message is for humans and may be reworded at any time.
Ask for the binary encoding and world.tick arrives as a WebSocket binary frame
in about a quarter of the bytes, and already decoded - which is the real saving
here, since OnWorldTickPayload hands you the payload unparsed and
OnWorldTick's fixed signature cannot carry the frame's own fields at all.
Client->WebSocket->bRequestBinaryWire = true;
Client->WebSocket->OnWorldTickBinary.AddLambda(
[](const asobi::core::WireFrame& Frame)
{
// Frame.ZoneX / ZoneY, Frame.Kind, Frame.FrameSeq, Frame.Kf, Frame.Tick
for (const asobi::core::WireRecord& R : Frame.Records)
{
// R.Op is Add / Update / Remove, R.Id is the entity id,
// R.Fields holds float / int32 / bool / string / null values
}
});
Client->WebSocket->Connect(Url);C++ only, deliberately. The decoded frame holds a per-record map of variant
values, which has no faithful Blueprint representation; flattening it into parallel
typed maps to satisfy the reflection system would hand Blueprint users a worse
shape than the JSON they already have, for a saving only C++ can spend. Blueprint
stays on OnWorldTickPayload, and nothing there changes.
Binary frames fire OnWorldTickBinary and never OnWorldTick /
OnWorldTickPayload: re-serialising a decoded frame to fire them would hand back
exactly the text-parsing cost the binary wire exists to remove. Only world.tick
is affected; everything else stays JSON text on both wires.
Entity ids are 2-byte slots on the wire and the SDK resolves them, so R.Id is the
same id the JSON wire gives. It is empty only when the add that would have
established the binding was lost, which is a FrameSeq gap - call WorldResync
and the keyframe rebuilds every binding.
Requires the server to have binary_wire switched on. If it does not, you silently
stay on text - GrantedWire reads "json" or "binary" once OnConnected has
fired, so read it rather than assume. The same fallback happens per frame for
anything the server cannot encode as binary, such as an entity field holding an
array.
| Subsystem | REST | WebSocket |
|---|---|---|
| Auth (register, login, guest, refresh, OAuth, IAP) | ✓ | — |
| Players & stats | ✓ | — |
| Matches & matchmaker | ✓ | ✓ |
| Worlds (MMO-scale, terrain streaming) | ✓ | ✓ |
| Chat & direct messages | ✓ | ✓ |
| Social (friends, groups) | ✓ | — |
| Economy (wallets, store, inventory) | ✓ | — |
| Leaderboards & tournaments | ✓ | — |
| Cloud saves & storage | ✓ | — |
| Presence & notifications | ✓ | ✓ |
| Voting (cast, veto) | ✓ | ✓ |
| Extensions (RPC) | — | ✓ |
Blueprint-callable on every subsystem. Typed USTRUCT responses for player, world, match, DM, leaderboard, etc.
UAsobiClient— HTTP + JSON helpers, auth token storeUAsobiAuth,UAsobiMatch,UAsobiMatchmaker,UAsobiWorld,UAsobiEconomy,UAsobiLeaderboard,UAsobiSocial,UAsobiStorage,UAsobiTournament,UAsobiDirectMessageUAsobiWebSocket— real-time client with typed multicast delegates (OnMatchState,OnWorldTick,OnWorldAck,OnWorldTerrain,OnDmMessage, …)
See asobi-unreal-demo for a working UE5 sample project.
Every release of this plugin runs the canonical SDK smoke flow against widgrensit/sdk_demo_backend. See Source/AsobiSDK/Tests/SmokeTest.md for the manual run command and CI notes, and docs/RELEASE_CHECKLIST.md for what CI does and does not cover per release.
Apache 2.0