Run a world: ticks and deltas
Move your fighter in one client and watch the change arrive on every other client, streamed as per-tick deltas from the server.
In step 11 you joined a world and received the initial snapshot. Now you run the loop. It is the same server-authoritative rule as an arena round: the client sends intent, the server decides, the server broadcasts state. The only difference is the shape of the broadcast. A persistent arena does not resend the whole arena every tick. It sends deltas - just what changed - and it sends them per zone, so a client only hears about the part of the arena it is standing in.
The loop
- Client sends
world.input- the intent (e.g. "move right"). - Server applies it to your entity, in whichever zone owns that entity.
- Server ticks the world (default 20 Hz, one tick every 50 ms).
- Each zone computes what changed and broadcasts a
world.tickdelta to its subscribers. - Clients apply the delta to what they already have on screen.
You never tell the server which zone you are in. world.input carries only your intent; the server routes it to the zone that currently owns your entity, and recomputes your zone membership as you move. See large-worlds for how zones and the 3x3 interest neighbourhood work, and world-server for the server world.lua callbacks that move the fighter.
The delta shape
Every world.tick after the first carries a tick number and an updates list. Each update has an op code:
{
"type": "world.tick",
"payload": {
"tick": 42,
"updates": [
{ "id": "01j8x...07", "op": "a", "x": 120, "y": 80 },
{ "id": "01j8x...02", "op": "u", "x": 121 },
{ "id": "01j8x...05", "op": "r" }
]
}
}op: "a"- added. A full entity state; a fighter just entered your view.op: "u"- updated. Only the changed fields; here the fighter moved one cell right.op: "r"- removed. The id only; a fighter left your view or was deleted.
Apply each op against a local table keyed by entity id: insert on a, patch on u, delete on r. The first world.tick after world.joined is the full snapshot you handled in step 11, so the same handler covers both.
Register the world.tick handler before you join, or you will miss that first snapshot. This holds for every SDK below.
The server side is unchanged
The fighter is moved in world.lua, which you wrote in Part 6. The client sends world.input; the server maps it to the player's entity and updates its position; the delta falls out of the zone diff automatically. You do not write the delta encoding. See world-server for the input-to-entity handling and large-worlds for zone routing. Nothing on the server changes between cloud and self-hosting.
Base server URL - the only thing that differs per deployment
Every SDK call in this step is identical on cloud and self-hosted. The single difference is the base URL your client already connected to in step 2:
- Cloud: the
wss://URL of your deployed environment, shown byasobi deployand in the console.asobi.dev environment view. Database and guest pepper were auto-provisioned; you configure nothing here. - Self-hosted: your own host and port,
ws(s)://<your-host>:8084/ws(default port 8084, single/wsendpoint), backed by your own Postgres.
Because you are already connected, the calls below are written once and are byte-for-byte the same on both.
Send input and render deltas - per SDK
Three moves per tab: register the world.tick handler, join the world, then send world.input when the player acts. connect and join come from steps 3 and 11; they are repeated only for orientation.
Unity: OnWorldTick hands you the raw JSON envelope string; parse it yourself.
Unreal: OnWorldTick is a dynamic multicast delegate; the handler must be a UFUNCTION and receives the tick number plus the updates array as a raw JSON string.
Dart: onWorldTick is a broadcast stream of typed WorldTick values.
JS: Raw transport: subscribe to the wire name, and the input payload IS the intent map (no data wrapper).
LÖVE: Mapped callback name, and the transport needs a manual per-frame pump or no callbacks fire.
local rt = client.realtime
rt:on("world_tick", function(payload)
for _, u in ipairs(payload.updates) do
apply_delta(u.id, u.op, u)
end
end)
rt:connect()
rt:join_world(world_id)
rt:send_world_input({ move_x = 1, move_y = 0 })func _on_world_tick(payload: Dictionary) -> void:
for u in payload["updates"]:
apply_delta(u["id"], u["op"], u)
Asobi.realtime.world_tick.connect(_on_world_tick)
Asobi.realtime.connect_to_server()
Asobi.realtime.world_join(world_id)
Asobi.realtime.world_input({ "move_x": 1, "move_y": 0 })client.Realtime.OnWorldTick += rawJson => {
// parse rawJson, then apply each update in payload.updates
};
await client.Realtime.ConnectAsync();
await client.Realtime.WorldJoinAsync(worldId);
await client.Realtime.WorldInputAsync("{\"move_x\":1,\"move_y\":0}");// UFUNCTION(); signature: (int64 Tick, const FString& UpdatesJson)
WebSocket->OnWorldTick.AddDynamic(this, &UMyClass::HandleWorldTick);
WebSocket->Connect(Url); // then Authenticate(Token) in your OnConnected handler
WebSocket->WorldJoin(WorldId);
WebSocket->WorldInput(TEXT("{\"move_x\":1,\"move_y\":0}"));client.realtime.onWorldTick.stream.listen((WorldTick tick) {
for (final u in tick.updates) {
applyDelta(u);
}
});
await client.realtime.connect(autoReconnect: false);
await client.realtime.joinWorld(worldId);
client.realtime.sendWorldInput({'move_x': 1, 'move_y': 0});const ws = asobi.websocket({ token });
ws.on("world.tick", (payload) => {
for (const u of payload.updates) applyDelta(u.id, u.op, u);
});
await ws.connect();
await ws.send("world.join", { world_id });
ws.sendFire("world.input", { move_x: 1, move_y: 0 });client.realtime:on("world_tick", function(payload)
for _, u in ipairs(payload.updates) do
apply_delta(u.id, u.op, u)
end
end)
client.realtime:connect()
client.realtime:join_world(world_id)
client.realtime:send_world_input({ move_x = 1, move_y = 0 })
function love.update(dt)
client.realtime:update() -- required every frame
endCheckpoint
Run two clients joined to the same world (step 11), each with its world.tick handler registered before joining.
- In client A, send one
world.inputthat moves your fighter. - In client B, watch a
world.tickarrive with anop: "u"update carrying the new position for A's entity id. - Move A across a zone boundary. B, if it is in the neighbouring zone, receives an
op: "a"(A entered its view) orop: "r"(A left it) - proof the server is routing by zone, not broadcasting to everyone.
If the delta lands on the other client, the loop is closed: intent in, authoritative state out, streamed as deltas.
Next: 13. End a world
world.finished, the empty-grace timer, and finishing from post_tick.