Connect to a world
Join the world you created in the previous step and receive its initial snapshot.
In step 10 you created a world and confirmed it exists. A world is a persistent arena: an always-on, zoned shared space; a match is a bounded, ephemeral round. Now a client enters it.
The server stays authoritative. The client sends world.join as intent; the server decides placement, subscribes you to the 3x3 zone neighbourhood around your spawn, and pushes state. You never move your own fighter; you ask, and the server broadcasts.
The one thing that trips people up
The first world.tick you receive after world.joined is the initial full snapshot: every entity in your zone neighbourhood, each sent with op "a" (added, full state). Every later tick carries only deltas.
So register the world.tick handler before you call join. If you join first and wire the handler afterwards, the snapshot has already gone and you are left reconstructing the world from deltas.
Order, in every SDK:
- Register the
world.tickhandler (this catches the snapshot). - Register the
world.joinedhandler (world metadata: id, mode, tick rate). - Call join.
world.joined is the acknowledgement; the snapshot arrives as the first world.tick. Two separate events, both needed.
Cloud vs self-hosted
Identical on both. The only difference is the base server URL you configured your client with back in step 2:
- Cloud: the environment URL from console.asobi.dev, connecting to
/ws. - Self-hosted: your own host on port 8084, e.g.
ws://your-host:8084/ws.
Every call below is byte-for-byte the same on both paths.
Per-SDK
You are already connected (session.connected, step 3) and you have a world_id from the world you created. Wire the two handlers, then join. Register handlers on the same realtime object you connected with.
-- rt = client.realtime. Callbacks via rt:on(mapped_name, fn); the wire world.tick maps to "world_tick".
rt:on("world_tick", function(frame)
print("snapshot/delta, tick=" .. tostring(frame.tick))
end)
rt:on("world_joined", function(info)
print("joined world " .. tostring(info.world_id))
end)
rt:join_world(world_id, function(ok, err)
if not ok then print("join failed: " .. tostring(err)) end
end)# Asobi.realtime autoload. Signals use underscores; connect them before world_join.
Asobi.realtime.world_tick.connect(_on_world_tick)
Asobi.realtime.world_joined.connect(_on_world_joined)
Asobi.realtime.world_join(world_id)
func _on_world_tick(payload: Dictionary) -> void:
print("tick ", payload.get("tick"))
func _on_world_joined(payload: Dictionary) -> void:
print("joined ", payload.get("world_id"))// Events on client.Realtime; handlers get the raw JSON envelope string, which you parse. WorldJoinAsync returns a Task<string> ack.
client.Realtime.OnWorldTick += rawJson => Debug.Log($"tick: {rawJson}");
client.Realtime.OnWorldJoined += rawJson => Debug.Log($"joined: {rawJson}");
string ack = await client.Realtime.WorldJoinAsync(worldId);// Dynamic multicast delegates on UAsobiWebSocket. Handlers must be UFUNCTIONs. OnWorldTick delivers the tick number and the updates as a JSON string; OnWorldJoined delivers a typed FAsobiWorldInfo.
WebSocket->OnWorldTick.AddDynamic(this, &UMyClass::HandleWorldTick);
WebSocket->OnWorldJoined.AddDynamic(this, &UMyClass::HandleWorldJoined);
WebSocket->WorldJoin(WorldId);
// UFUNCTION() void HandleWorldTick(int64 Tick, const FString& UpdatesJson);
// UFUNCTION() void HandleWorldJoined(const FAsobiWorldInfo& Info);// Broadcast streams on client.realtime. onWorldTick yields a typed WorldTick; onWorldJoined yields a Map. joinWorld returns a Future<Map>.
client.realtime.onWorldTick.stream.listen((WorldTick tick) {
print('tick ${tick.tick}');
});
client.realtime.onWorldJoined.stream.listen((Map<String, dynamic> info) {
print('joined ${info['world_id']}');
});
await client.realtime.joinWorld(worldId);// asobi.websocket(...) uses raw wire event names (dots). Join is an awaited RPC via send.
ws.on("world.tick", (payload) => console.log("tick", payload.tick));
ws.on("world.joined", (payload) => console.log("joined", payload.world_id));
const reply = await ws.send("world.join", { world_id });-- client.realtime with mapped callback names, one callback per event. LOVE needs a manual pump: call client.realtime:update() every frame from love.update, or no callbacks fire.
client.realtime:on("world_tick", function(frame)
print("tick " .. tostring(frame.tick))
end)
client.realtime:on("world_joined", function(info)
print("joined " .. tostring(info.world_id))
end)
client.realtime:join_world(world_id)
-- in love.update(dt):
-- client.realtime:update()No id yet?
If you have not created a world explicitly, swap join for find_or_create (find_or_create_world / world_find_or_create / WorldFindOrCreateAsync / WorldFindOrCreate / findOrCreateWorld / ws.send("world.find_or_create", {mode})). It drops you into the first world with capacity, or makes one, and auto-joins. world.joined and the snapshot arrive exactly the same way.
See the world-server guide for zones, interest management, and terrain; the websocket protocol reference for the full world.* envelope shapes.
Checkpoint
Run your client. In the log you should see, in order:
world.joinedfiring with yourworld_id.- A first
world.tickwhose updates list every entity in your zone, each with op"a".
That first tick is your initial snapshot. If you see world.joined but never a tick, check that the world.tick handler was registered before you called join (and, on LOVE, that :update() is being pumped).
Next: Step 12: Run a world
Send world.input, and watch world.tick deltas (op a/u/r) move your fighter for everyone in the zone.