Skip to content

feat: surface world.tick's zone, frame_seq and kf, and add a resync request - #46

Merged
Taure merged 1 commit into
mainfrom
feat/world-tick-zone-fields
Aug 17, 2026
Merged

Taure merged 1 commit into
mainfrom
feat/world-tick-zone-fields

Conversation

@Taure

@Taure Taure commented Aug 17, 2026

Copy link
Copy Markdown
Contributor

Dart's half of asobi#498 (v0.89.0), which stamps zone, frame_seq and kf on world.tick.

Why a typed SDK needs a change at all

WorldTick.fromJson read only tick and updates, so all three fields were being silently dropped before any game could see them. A typed model is a filter: an additive server change reaches userland only if the model is widened to let it through. That is the difference between this SDK and asobi-godot, which forwards the raw payload and got the fields for free.

The field that matters

zone. A player is subscribed to an interest ring of several zones at once, each an independent server process, and frames from two of them have no order relative to each other. A crossing emits op: "r" from the zone being left and op: "a" from the zone being entered, so a game merging every zone into one entity map is last-writer-wins - and when the remove lands last the entity is gone for good, because the server will not re-add something already in its own baseline.

Without zone exposed, a Dart game could not have kept per-zone state even if it knew to. The doc comment on the field says this outright, because it is the part a game gets wrong.

frameSeq makes loss detectable, and the doc explains why tick cannot serve: tick skips on the server's broadcast_interval and is suppressed on a tick that changed nothing, so a gap in it is ambiguous. kf marks a complete baseline to replace rather than merge, documented as safe to adopt even when frameSeq moves backwards, since a zone restart resets the sequence while zone identity is unchanged and rejecting a lower value rejects the repair.

resyncWorldZone(zone)

Asks for a fresh baseline for one zone. One zone per call rather than the ring, since the ring is nine zones by default and the caller already knows which one gapped.

This SDK deliberately does not detect the gap and resync for you, unlike asobi-godot which does. The asymmetry is intentional and worth naming so it is a decision rather than an oversight: Godot forwards a raw payload and holds no state, so it can auto-ask harmlessly. Here the whole shape is typed models handed to a game that owns its own state, and only that model knows whether a gap cost it anything. If we want parity later, the hook is one call in the world.tick case.

Defensive decoding, where it has to be

A malformed zone reads as absent rather than throwing, because this runs inside a stream handler - a raise there takes the subscription down and the game stops receiving ticks entirely. kf is true only for a real JSON true, not any truthy value. Coordinates coerce to int, since they index a zone grid and a JSON number may arrive as double.

Tests

7 new cases: all three fields present; all absent reading as null rather than zero (zero is a real frame_seq and a real coordinate); zone: [0, 0] as a real zone; six malformed zone shapes; kf's strictness; float coordinate coercion; and updates still decoding alongside.

They deliberately do not read the fixture corpus - the vendored copy still predates the server change and its sync PR (#45) is separate. Once that lands, dispatch_test exercises the same fields end to end.

Checks

dart analyze: 7 infos, identical count on origin/main, none in this code. dart test: 162/162.

Not reformatted. CI gates analyze and test but not format, and dart format on this Dart version (3.12.2) wants to touch 32 of 40 files, which would bury the change in unrelated churn. Worth deciding separately whether to do a one-off format sweep and add the gate.

…equest

asobi v0.89.0 stamps three fields on world.tick. This SDK narrows the frame into
a typed WorldTick, and that constructor read only `tick` and `updates` - so all
three were being silently dropped before any game could see them. A typed model
is a filter, and an additive server change reaches userland only if the model is
widened to let it through.

The important one is `zone`. A player is subscribed to an interest ring of
several zones at once, each an independent server process, and frames from two of
them have no order relative to each other. A crossing emits op:"r" from the zone
being left and op:"a" from the zone being entered, so a game merging every zone
into one entity map is last-writer-wins - and when the remove lands last the
entity is gone for good, because the server will not re-add something already in
its own baseline. Without `zone` exposed, a Dart game could not have kept per-zone
state even if it knew to. The field's doc comment says so directly, because that
is the part a game gets wrong.

`frameSeq` makes loss detectable: unlike `tick` it never skips, whereas `tick`
skips on the server's broadcast_interval and is suppressed on a tick that changed
nothing, so a gap in it is ambiguous. `kf` marks a complete baseline for a zone,
to replace rather than merge, and is documented as safe to adopt even when
frameSeq moves backwards - a zone restart resets the sequence while the zone's
identity is unchanged, so rejecting a lower value rejects the repair.

Adds `resyncWorldZone(zone)`, which asks for a fresh baseline for one zone. One
zone per call rather than the ring, since the ring is nine zones by default and
the caller already knows which one gapped.

The SDK deliberately does NOT detect the gap and resync on the game's behalf,
unlike asobi-godot which does. The asymmetry is intentional but worth naming: the
Godot SDK forwards the raw payload and holds no state either, so it can auto-ask
harmlessly. Here the decision is left to the caller because this SDK's whole
shape is typed models handed to a game that owns its own state, and only that
model knows whether a gap cost it anything. If we later want parity, the hook is
one call in the world.tick case.

Decoding is defensive where it has to be. A malformed `zone` reads as absent
rather than throwing, because this runs inside a stream handler where a raise
takes the subscription down and the game stops receiving ticks entirely. `kf` is
true only for a real JSON true, not any truthy value. Coordinates coerce to int,
since they index a zone grid and a JSON number may arrive as double.

Tests: 7 cases covering all three fields present, all absent reading as null
rather than zero, zone [0,0] as a real zone, six malformed zone shapes, kf's
strictness, float coordinate coercion, and updates still decoding alongside. They
do not read the fixture corpus: the vendored copy still predates the server
change and its sync PR is separate, so once that lands dispatch_test covers the
same fields end to end.

Checks: dart analyze (7 pre-existing infos, identical count on origin/main, none
in this code) and dart test 162/162. Not reformatted: CI gates analyze and test
but not format, and `dart format` wants to touch 32 of 40 files on this Dart
version, which would bury the change.
@Taure
Taure merged commit 48af2bc into main Aug 17, 2026
2 of 3 checks passed
@Taure
Taure deleted the feat/world-tick-zone-fields branch August 17, 2026 19:36
@Taure
Taure restored the feat/world-tick-zone-fields branch August 17, 2026 23:17
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant