Godot 4.x client SDK for the Asobi game backend. Tested on Godot 4.4 LTS and Godot 4.5.
Download asobi-godot-<version>.zip from the latest release and extract it into your project root, so the addon lands in addons/asobi/.
Copy addons/asobi/ from this repo into your project's addons/ folder, or add it as a git submodule pinned to a tag:
git submodule add -b <tag> https://github.com/widgrensit/asobi-godot.git vendor/asobi-godot
ln -s ../vendor/asobi-godot/addons/asobi addons/asobiA source checkout reports its version as 0.0.0-dev in the plugin list. Release zips carry the real version.
Project → Project Settings → Plugins and tick Asobi. Reload the project so Godot picks up the autoload.
The plugin auto-registers an Asobi autoload singleton — you do not need to add an AsobiClient node to your scene.
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.
Use the Asobi autoload directly from any script:
func _ready() -> void:
Asobi.host = "localhost"
Asobi.port = 8084
var resp := await Asobi.auth.login("player1", "secret123")
if resp.has("error"):
push_error("Login failed: %s" % resp.error)
return
# A formed match arrives as match_matched; match.joined is the reply to
# a client-initiated match.join. Both mean "in a match -- match.state
# will follow." Listen for match_matched and join.
Asobi.realtime.match_matched.connect(_on_matched)
Asobi.realtime.match_state.connect(_on_state)
Asobi.realtime.connect_to_server()
Asobi.realtime.add_to_matchmaker("demo")
func _on_matched(payload: Dictionary) -> void:
Asobi.realtime.join_match(payload["match_id"])
func _on_state(payload: Dictionary) -> void:
var players: Dictionary = payload.get("players", {})
print("Tick %s, %d players" % [payload.get("tick", 0), players.size()])A complete worked example lives at example/example_usage.gd. A runnable demo (player vs. player + bots) is at asobi-godot-demo.
Sign a player in with no username or password. A guest is a real player (with a persistent player_id); the device holds a {device_id, device_secret} keypair and the same pair resumes the same player on every launch.
guest_device manages the keypair for you - it generates a standard-base64 secret from a CSPRNG on first run, persists it to user://, reuses it after, and signs in:
var resp := await Asobi.auth.guest_device()
if resp.has("error"):
push_error("Guest sign-in failed: %s" % resp.error)
return
if resp.get("created", false):
print("brand-new guest: %s" % resp.player_id) # run first-time onboarding
else:
print("welcome back: %s" % resp.player_id)Options (all optional) let you choose where the pair is stored or plug in your own byte source:
await Asobi.auth.guest_device({
"path": "user://mygame_device.json", # default user://asobi_device.json
"random_bytes": func(n): return my_source(n), # Callable(int) -> PackedByteArray
})Erase the stored pair to switch account. The next guest_device mints a brand-new guest (created = true). This is local-only - pair it with logout to end the current session, or upgrade_guest first if the player wants to keep the guest:
await Asobi.auth.logout()
AsobiDevice.clear() # pass the same path/store opts you signed in withClearing the pair does not delete anything on the server - the account and its data are still there, just unreachable from this device. For an actual "delete my data" request, erase the account:
await Asobi.players.erase_self() # guest or provider-only account
await Asobi.players.erase_self("secret123") # account with a passwordIrreversible: the player and everything the server holds for it go. Pass the password only for an account that has one - a guest has no credential to re-present, so its session is the confirmation. A wrong password comes back as {"code": "player.confirmation_failed", "status_code": 403} 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; for a retried erase, read that as "it already worked".
Needs a server carrying POST /api/v1/players/me/erase; older ones answer 404.
If you'd rather manage the keypair yourself (e.g. an OS keychain), call guest directly. device_secret must be standard base64 (RFC 4648, +/ with = padding) of >= 32 CSPRNG bytes, or the server rejects it as weak_device_secret; device_id is any stable per-install id:
# One time: create and store a device secret, e.g.
# var bytes := Crypto.new().generate_random_bytes(32)
# var device_secret := Marshalls.raw_to_base64(bytes)
var resp := await Asobi.auth.guest(device_id, device_secret)
if resp.has("error"):
push_error("Guest sign-in failed: %s" % resp.error)Convert a guest into a real account (keeps the same player_id, so no progress is lost):
var upgraded := await Asobi.auth.upgrade_guest("player1", "secret123")
if upgraded.has("error"):
push_error("Upgrade failed: %s" % upgraded.error)All of these store the returned tokens exactly like login, so the player stays signed in.
The saved pair identifies the machine, so two instances of the same project sign in as the same player: matchmaking will not pair them and their views drift. In a dev build, skip persistence and mint a throwaway guest per launch instead:
var creds := AsobiDevice.generate()
var resp := await Asobi.auth.guest(creds["device_id"], creds["device_secret"])For stable test players, open Debug > Customize Run Instances..., give each instance a -- player=N launch argument, and pass {"path": "user://asobi_device_%s.json" % slot} to guest_device. Full recipe, plus why two players can still land in separate matches: Testing with multiple players.
match_find_or_create gets you into a live match of a mode, spawning one if there is none:
Asobi.realtime.match_joined.connect(_on_joined)
Asobi.realtime.match_find_or_create("arena")mode is the only match parameter you supply; every other one comes from the server-side mode config. An optional second argument takes the same join context join_match accepts, and the server hands it to the game's join callback exactly as it does for match.join. The reply is match.joined, so it arrives on match_joined exactly as join_match's reply does - there is no separate "created" signal to connect.
Prefer it over match_list then join_match. The matchmaker groups co-queued tickets and spawns; it never joins a player into a running match, so browse-then-join was the only route into a live one, and it races: two clients reading the same empty listing each create a match. match.find_or_create resolves server-side and is serialized, so simultaneous callers converge on one match.
A mode opts in through its quick_play flag, which defaults to false for match modes; a mode that has not opted in is refused with quick_play_disabled. (listed is browser visibility, a separate axis.) Refusals include not_found, which an unknown or unconfigured mode name gets and is the one a typo hits first, match_capacity_reached (the node-wide cap), wrong_mode_type (a world mode) and join_rate_limited, which shares its bucket with match.join and world.join. All of them arrive on error_received.
- Needs an asobi server >= v0.86.0.
- Needs this addon >= v0.19.0; earlier versions have no
match_find_or_createto call.
The world twin is world_find_or_create(mode).
Worlds are server-ticked sessions you join over the socket:
Asobi.realtime.world_tick.connect(_on_tick)
Asobi.realtime.world_join(world_id)
Asobi.realtime.world_input({"move_x": 1})World signals: world_joined, world_left, world_tick, world_terrain(coords, data), world_list_received, world_phase_changed, world_finished, world_ack, and world_event(event_name, payload) for anything else the game module pushes.
world_tick carries deltas, not a snapshot:
{"type": "world.tick", "payload": {"zone": [3, 5], "frame_seq": 118, "kf": false, "tick": 42, "updates": [{"op": "u", "id": "e1", "x": 3}]}}Keep one entity table per zone, never one flat table. You are subscribed to
several zones at once, each an independent server process, and messages are
ordered per sender only - so a crossing's op: "r" from the zone you left and
op: "a" from the zone you entered can arrive in either order, and applied flat
the removal can land last and delete the entity for good.
frame_seq is contiguous per zone and advances only on a frame actually sent, so
a gap in it means a frame went missing. The SDK detects that for you: it emits
world_gap_detected(zone, expected, received) and asks the server for a fresh
baseline, which arrives as a frame with kf: true.
op is a (added, with the entity's full state), u (updated, only the fields that changed) or r (removed). A full op: "a" snapshot of a zone's entities arrives on every fresh subscription to that zone, not once per zone per session. Joining subscribes your whole interest ring, so it delivers one snapshot frame per loaded, non-empty zone in the ring, not one frame overall. A zone holding no entities sends no entity snapshot, but it still pushes its terrain chunk if the world has a terrain provider, so you get world_terrain from it and no world_tick.
Crossing a boundary delivers fresh snapshots too. The crossing recomputes the ring and subscribes the band of zones that just entered it, and each of those replays a full snapshot: at the default view_radius of 1 your ring is the 3x3 block around you, and an orthogonal step keeps 6 of the 9 zones and brings in 3 new ones. Only the destination zone is a no-op, because at radius 1 it was already in the old ring. Do not generalise that no-op to the crossing as a whole. Zones that dropped out of the ring are unsubscribed and send op: "r" for each of their entities, and walking back re-subscribes them for another full snapshot - a player oscillating across a boundary re-snapshots every time.
Between snapshots a frame mentions an entity solely when something about it changed. Zones are not on independent schedules: one ticker per world fans a single tick number out to every zone, and broadcast_interval (default 3) is one world-level value, so every subscribed zone broadcasts on the same tick and the several frames land together. A zone where nothing changed sends no frame at all. Fold each frame into your own entity map - do not treat one frame as the world. op and id are wire metadata, so keep them out of the state you accumulate.
world_input(data: Dictionary, seq: int = -1) takes a sequence number as its optional second argument. Stamp each input with your own increasing seq and the server answers with world.ack, carrying a seq it has consumed as of tick:
{"type": "world.input", "seq": 412, "payload": {"move_x": 1}}
{"type": "world.ack", "payload": {"tick": 42, "seq": 412}}seq rides as a top-level sibling of payload, never nested inside it. The ack arrives as world_ack(payload). JSON.parse_string decodes every JSON number as a float, so payload.tick and payload.seq reach the handler as 42.0 and 412.0 (TYPE_FLOAT), not ints - cast with int() before comparing them against anything you hold.
Reconciliation is yours to write: on each ack, drop the buffered inputs up to and including its seq and replay the rest on top of the state you have accumulated from world_tick. _apply_input and _reset_to_server below are stubs for you to fill in - they are the game-specific half, and everything else runs as pasted.
var _seq := 0
var _predicted: Array[Dictionary] = []
var _entities: Dictionary = {}
func _ready() -> void:
Asobi.realtime.world_ack.connect(_on_ack)
Asobi.realtime.world_tick.connect(_on_tick)
func send_input(data: Dictionary) -> void:
_seq += 1
_predicted.append({"seq": _seq, "data": data})
Asobi.realtime.world_input(data, _seq)
_apply_input(data)
func _on_tick(payload: Dictionary) -> void:
for update in payload.get("updates", []):
var id: String = str(update.get("id", ""))
match update.get("op", ""):
"a":
_entities[id] = _fields(update)
"u":
if _entities.has(id):
_entities[id].merge(_fields(update), true)
"r":
_entities.erase(id)
func _on_ack(payload: Dictionary) -> void:
var acked := int(payload["seq"])
while not _predicted.is_empty() and _predicted[0]["seq"] <= acked:
_predicted.pop_front()
_reset_to_server(_entities)
for input in _predicted:
_apply_input(input["data"])
# Drop the wire metadata: merging `op` into accumulated state would rewrite it
# on every delta, and `id` is already the key.
func _fields(update: Dictionary) -> Dictionary:
var out := update.duplicate()
out.erase("op")
out.erase("id")
return out
# Yours: apply one input to your local player.
func _apply_input(data: Dictionary) -> void:
pass
# Yours: snap your local player back onto the authoritative state, so the
# replay above starts from what the server confirmed.
func _reset_to_server(entities: Dictionary) -> void:
pass- Opt-in: the server acks only players that stamped a
seq. Connect toworld_ackbut never pass one and nothing ever arrives, with no error. - Each ack is a high-water mark, not a receipt per input - one
seq, the highest the server has consumed as oftick. Acks are addressed to you alone and never ride on the sharedworld.tickbroadcast. - A rejected input still advances the ack, so a dropped input cannot strand the client.
- Prune and replay in the ack handler, not the tick handler. When a broadcast produced deltas the
world.tickarrives first and theworld.acksecond, so a replay driven off the tick has not seen the new ack yet and re-applies inputs the server has already consumed. When nothing changed there is noworld.tickat all and the ack arrives alone, which a tick-driven replay misses entirely. - The
-1default means unsequenced. Onlyseq >= 0is stamped, andseq0 is a real value, so a counter starting at 0 is fine. - The server accepts
0 <= seq <= 2^53 - 1. Outside that range theseqis ignored, not the input: the input is still queued and applied to the world exactly as normal, and only the acknowledgement skips it. GDScript ints are 64-bit and reach far higher, so a counter seeded from a nanosecond timestamp never advances the ack; count up from 0. broadcast_intervalis one world-level value. Set it to 1 in the world mode config for an ack on every simulation tick; the default is 3. See world server.- Needs an asobi server >= v0.84.1; older ones never send
world.ack, and that silence is not an error. - Needs this addon >= v0.18.0; earlier versions have no
world_acksignal to connect to and noseqargument onworld_input.
Frame reference: client-side prediction.
- Auth - Register, login, guest / anonymous, token refresh
- Players - Profiles, updates
- Matchmaker - Queue, status, cancel
- Matches - List, details
- Leaderboards - Top scores, around player, submit
- Economy - Wallets, store, purchases
- Inventory - Items, consume
- Social - Friends, groups, chat history
- Tournaments - List, join
- Notifications - List, read, delete
- Storage - Cloud saves, generic key-value
- Realtime - WebSocket with signals for matches, worlds, chat, presence, matchmaking
See the WebSocket protocol guide for the full event surface.
Apache-2.0
Ask for the binary encoding and world.tick arrives as a WebSocket binary frame
in about a quarter of the bytes, decoded here about 2.4x faster than Godot's
native JSON parser (measured, 40 records, same output structure).
Asobi.realtime.request_binary_wire = true
Asobi.realtime.connect_to_server()Nothing else changes. The decoder maps the wire's compact 2-byte entity slots
back to entity ids before it hands anything on, so world_tick carries the same
dictionary either way and every handler you have already written keeps working.
Only world.tick is affected; everything else stays JSON text on both wires.
Requires the server to have binary_wire switched on. If it does not, you
silently stay on text - Asobi.realtime.wire reads "json" or "binary" once
connected 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.
Entity positions can travel over UDP instead of the WebSocket, so one lost packet costs one frame of staleness rather than stalling everything behind a TCP retransmit.
Asobi.realtime.entities = AsobiEntities.new()
Asobi.realtime.request_datagram = true
Asobi.realtime.entities.entity_updated.connect(func(id, state, changed):
if "x" in changed:
move_sprite(id, state.x, state.y))
Asobi.realtime.connect_to_server()It needs AsobiEntities, and that is not incidental. Two carriers describe
the same entity and they can disagree, so somebody has to hold the merge rule -
which world.tick field loses to a fresher position, which wins regardless, and
which frame may never create an entity at all. Putting that in a registry the SDK
owns is the difference between an API and a subtle correctness problem handed to
every game.
The registry is useful on its own: bind it without request_datagram and it
folds world.tick into a per-zone entity view with the crossing and keyframe
rules already handled.
The WebSocket carries everything in every state. If the server has no
gateway, if a firewall drops UDP, or if the path goes quiet for two seconds, the
SDK falls back to taking positions from world.tick and keeps trying in the
background. There is no state in which your game stops working - which is why
this is safe to switch on, and why a web export simply never opens it, since
browsers have no raw UDP.
What it needs from the server: binary_wire on, a dgram_pose manifest, and a
gateway reachable at the endpoint the mint hands back. See
self-hosting.
The whole story - what it carries, why losing packets is fine, the server
compose file and what happens when it does not work - is in
the datagram plane guide.