Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
74 changes: 27 additions & 47 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -172,9 +172,11 @@ authoritative state from `OnWorldTick`.

`WorldInputAsync(string inputJson, long? seq = null)` sends a JSON object that
is itself the input map: the field names are your game's, and the server hands
the map to the world script as it stands. One field name is reserved. If the
map has a top-level `data`, the server substitutes it, so an object `data`
becomes the input and a `data` that is anything else leaves the input empty.
the map to the world script as it stands. One field name is reserved: a payload
whose sole key is `data` holding an object is unwrapped to that object.
Deprecated, and removed at the next protocol break, so name your fields
anything else. A `data` alongside other keys, or a `data` holding anything but
an object, is forwarded verbatim.

Stamp each input with `seq`, a monotonic counter your client owns, to opt into
acknowledgement. Buffer the input under that `seq` and apply it locally at once:
Expand Down Expand Up @@ -244,38 +246,19 @@ for you as of `tick`:
{"type":"world.ack","payload":{"tick":42,"seq":412}}
```

That mark is held per zone, not per connection, and every zone you are
subscribed to acks you independently. Inputs only ever reach the zone you are
standing in, so once you have crossed a boundary you get more than one
`world.ack` per broadcast: the zone you left keeps emitting the frozen mark it
recorded before you moved, so `payload.seq` can go backwards between
consecutive acks. Nothing in the frame says which zone sent it. Keep a running
maximum and ignore any ack that does not exceed it. Dropping everything at or
below `ack.seq` is only safe against a mark that never moves backwards; prune
straight from the received `seq` and you re-apply inputs the server has already
consumed. Tracked as
[widgrensit/asobi#477](https://github.com/widgrensit/asobi/issues/477), which
also covers the "per-connection" wording the server source and the protocol
guide still carry.

`OnWorldAck` hands you the raw envelope, so pull out `payload` before
deserializing into `WsWorldAckPayload` (`long tick`, `long seq`); passing the
envelope straight to `JsonUtility` yields zeros, not an error. Advance the
running maximum, drop every buffered input at or below it, rewind to the
accumulated state, replay the rest:
envelope straight to `JsonUtility` yields zeros, not an error. Drop every
buffered input at or below `ack.seq`, rewind to the accumulated state, replay
the rest:

```csharp
long _acked = -1;

client.Realtime.OnWorldAck += raw =>
{
var ack = JsonUtility.FromJson<WsWorldAckPayload>(
JsonHelper.ExtractField(raw, "payload"));

if (ack.seq <= _acked) return;
_acked = ack.seq;

_pending.RemoveAll(p => p.seq <= _acked);
_pending.RemoveAll(p => p.seq <= ack.seq);
ResetTo(_state);
foreach (var p in _pending)
Predict(p.input);
Expand All @@ -287,10 +270,10 @@ are your game's.

- The ack is a high-water mark, not a receipt per input. A rejected input still
advances it, so an input the world script declines never strands the client.
- Prune and replay in the ack handler, never in the tick handler. A zone with
deltas to report sends its `world.tick` first and its `world.ack` second, but
a broadcast with nothing to report skips the tick entirely, so an ack can
arrive with no `world.tick` in front of it.
- Prune and replay in the ack handler, never in the tick handler. A broadcast
with deltas to report sends `world.tick` first and `world.ack` second, but one
with nothing to report skips the tick entirely, so an ack can arrive with no
`world.tick` in front of it.
- Acknowledgement is opt-in. The server records a `seq` only for players who
stamp one, so a client that never stamps one gets no `world.ack` at all, and
no error either.
Expand All @@ -299,28 +282,25 @@ are your game's.
- `seq` must be a non-negative integer below 2^53, narrower than C#'s `long`:
count up from zero, never seed from a nanosecond timestamp. An out-of-range
`seq` is ignored, but the input is not. It is queued and applied to the world
as normal, and only the acknowledgement is skipped; if you already have a
valid `seq` on record the acks keep arriving every broadcast tick carrying
that older high-water mark rather than falling silent.
as normal, and only the acknowledgement is skipped.
- `broadcast_interval` is a world-level value copied into each zone's config,
and one ticker per world fans a single shared tick number out to every zone,
so zones are not on independent schedules. Every `broadcast_interval`
simulation ticks, `3` by default, each subscribed zone emits its own pair, so
a full 3x3 ring is up to nine `world.tick` frames and nine `world.ack` frames
rather than one of each, and they land together on the same broadcast tick
rather than interleaved across cadences. Subscription snapshots are sent
immediately and ignore the interval. Set it to `1` in the world mode config
for an ack every tick. See the
[world server guide](https://asobi.dev/docs/world-server).
- Needs a server carrying `world.ack`, which is asobi core v0.84.0 or newer. An
older one sends nothing, and the silence is the only symptom.
simulation ticks, `3` by default, each subscribed zone emits its own
`world.tick`, so a full 3x3 ring is up to nine tick frames rather than one,
and they land together on the same broadcast tick rather than interleaved
across cadences. Subscription snapshots are sent immediately and ignore the
interval. Set it to `1` in the world mode config for an ack every tick. See
the [world server guide](https://asobi.dev/docs/world-server).
- Requires asobi core v0.84.1 or later.
- `OnWorldAck` and the `seq` parameter arrived in asobi-unity v0.18.0, but on
that release the loop above is dead. `WorldInputAsync` still wrapped the
payload as `{"data":"..."}`, which the zone reads as an empty input map, so
acks arrive and `seq` advances while nothing you send moves anything. The loop
needs a release in which `WorldInputAsync` sends the payload verbatim, and
v0.18.0 is not one. Confirm it on the wire: the input frame's `payload` should
be your input map, not an object holding a single `data` string.
payload as `{"data":"..."}`, which the zone receives still wrapped rather than
as your input map, so acks arrive and `seq` advances while nothing you send
moves anything. The loop needs a release in which `WorldInputAsync` sends the
payload verbatim, and v0.18.0 is not one. Confirm it on the wire: the input
frame's `payload` should be your input map, not an object holding a single
`data` string.
- `OnWorldAck` fires on a background thread like every other realtime event, so
reconciliation that touches `UnityEngine.Object` must marshal first.

Expand Down
20 changes: 11 additions & 9 deletions Runtime/WebSocket/AsobiRealtime.cs
Original file line number Diff line number Diff line change
Expand Up @@ -260,13 +260,13 @@ public Task<string> WorldLeaveAsync()

/// <summary>
/// Send one input to the world you are in. Fire-and-forget: the frame
/// carries no <c>cid</c>, so the server has no way to report a bad
/// input back to you.
/// carries no <c>cid</c>, so a bad input draws an <c>error</c> frame
/// with reason <c>invalid_payload</c> that nothing ties back to it.
/// </summary>
/// <param name="inputJson">
/// The input map itself, as a JSON object. <c>world.input</c> takes the
/// payload verbatim and, unlike <c>match.input</c>, does not JSON-decode
/// an inner string, so a wrapped input reaches the zone as an empty map.
/// an inner string, so a wrapped input reaches the zone still wrapped.
/// Null or blank sends an empty map.
/// </param>
/// <param name="seq">
Expand All @@ -277,14 +277,16 @@ public Task<string> WorldLeaveAsync()
/// </param>
/// <exception cref="ArgumentException">
/// <paramref name="inputJson"/> is not a JSON object. Thrown on the
/// first offending frame rather than sent, because nothing downstream
/// could tell you about it.
/// first offending frame rather than sent, because the server answers
/// one with an uncorrelated <c>error</c> frame and nothing else.
/// </exception>
/// <remarks>
/// <c>data</c> is reserved at the top level of the input map: the
/// server unwraps it when it is a map, dropping every sibling key, and
/// discards the whole input when it is not. Name your fields anything
/// else (widgrensit/asobi#478).
/// <c>data</c> is reserved at the top level of the input map: a payload
/// whose sole key is <c>data</c> holding an object is unwrapped to that
/// object. Deprecated, and removed at the next protocol break, so name
/// your fields anything else (widgrensit/asobi#478). A <c>data</c>
/// alongside other keys, or one holding anything but an object, is
/// forwarded verbatim.
/// </remarks>
public Task WorldInputAsync(string inputJson, long? seq = null)
{
Expand Down
8 changes: 4 additions & 4 deletions Runtime/WebSocket/WsFrame.cs
Original file line number Diff line number Diff line change
Expand Up @@ -37,10 +37,10 @@ internal static string Request(string type, string payloadJson, string cid)
/// </summary>
/// <exception cref="ArgumentException">
/// The input is not a JSON object. Non-JSON text splices onto the wire
/// as a malformed frame, and an array or a bare value reaches the
/// server as a badmap; either way <c>world.input</c> carries no
/// <c>cid</c>, so there is nothing to correlate a rejection to and the
/// failure would be silent. Checked by first character rather than
/// as a malformed frame, and an array or a bare value draws an
/// <c>error</c> frame with reason <c>invalid_payload</c>; either way
/// <c>world.input</c> carries no <c>cid</c>, so there is nothing to
/// correlate the rejection to. Checked by first character rather than
/// parsed: this runs once per input frame.
/// </exception>
internal static string WorldInputPayload(string inputJson)
Expand Down
10 changes: 5 additions & 5 deletions Tests/AsobiCore.NET/WsFrameTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -77,9 +77,9 @@ public void AnObjectInputIsPassedThroughVerbatim()
}

// `data` is reserved at the top level (widgrensit/asobi#478): the
// server unwraps it when it is a map and drops the siblings. The SDK
// still sends it verbatim - quietly renaming or re-nesting it here
// would hide the server's rule instead of letting the game hit it.
// server unwraps a payload whose sole key is `data` holding an object.
// The SDK sends it verbatim either way - quietly renaming or re-nesting
// it here would hide the server's rule instead of letting the game hit it.
[Test]
public void ATopLevelDataKeyIsStillSentVerbatim()
{
Expand All @@ -96,8 +96,8 @@ public void LeadingWhitespaceBeforeAnObjectIsAccepted()
}

// Anything that is not an object either splices onto the wire as a
// malformed frame or reaches the server as a badmap. world.input has
// no cid, so neither can be reported back: throw on the developer's
// malformed frame or draws an invalid_payload error frame. world.input
// has no cid, so neither can be correlated back: throw on the developer's
// first frame instead of failing silently for the life of the game.
[TestCase("not json")]
[TestCase("[1,2,3]")]
Expand Down
Loading