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
38 changes: 38 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -323,6 +323,44 @@ Frame reference: [client-side prediction](https://asobi.dev/docs/protocols/webso
- **Realtime** — WebSocket with events for matches, chat, presence, matchmaking
- **Extensions** — Call server extension methods over RPC

## Binary `world.tick`

Ask for the binary encoding and `world.tick` arrives as a WebSocket binary frame
in roughly a fifth of the bytes - and **already decoded**, which is the real
saving on this SDK, since `OnWorldTick` hands you raw JSON text and parses none of
it.

```csharp
client.Realtime.RequestBinaryWire = true;
client.Realtime.OnWorldTickFrame += frame =>
{
// frame.ZoneX / ZoneY, frame.FrameSeq, frame.Kf, frame.Tick
foreach (var r in frame.Records)
{
// r.Op is "a" / "u" / "r", r.Id is the entity id,
// r.Fields holds float / int / bool / string / null values
}
};
await client.Realtime.ConnectAsync();
```

Binary frames reach `OnWorldTickFrame`, not `OnWorldTick`, and never both. The two
are separate events on purpose: re-serialising a decoded frame to fire
`OnWorldTick` would hand back exactly the text-parsing cost the binary wire exists
to remove. Only `world.tick` is affected; everything else stays JSON text on both
wires.

Entity ids are 2-byte slots on the wire and the SDK resolves them for you, so
`r.Id` is the same id the JSON wire gives. `r.Id` is null only when the `add` that
would have established the binding was lost, which is a `FrameSeq` gap - call
`WorldResyncAsync` and the keyframe rebuilds every binding.

Requires the server to have `binary_wire` switched on. If it does not, you
silently stay on text - `client.Realtime.Wire` reads `"json"` or `"binary"` once
`OnConnected` 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.

## Build targets

| Target | Status |
Expand Down
10 changes: 10 additions & 0 deletions Runtime/Models/RealtimeModels.cs
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,16 @@ internal class WsConnectPayload
public string token;
}

// A separate class rather than a nullable field on the one above, because
// JsonUtility writes EVERY field: an optional `wire` would put `"wire": ""` on
// every connect frame a caller never asked to change.
[Serializable]
internal class WsConnectWirePayload
{
public string token;
public string wire;
}

[Serializable]
internal class WsMatchInputPayload
{
Expand Down
33 changes: 33 additions & 0 deletions Runtime/WebSocket/AsobiDispatcher.cs
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,28 @@ public class AsobiDispatcher
public event Action<string> OnVoteResult;
public event Action<string> OnVoteVetoed;
public event Action<string> OnWorldTick;

/// <summary>
/// A <c>world.tick</c> that arrived on the binary wire, already decoded.
/// </summary>
/// <remarks>
/// Fires instead of <see cref="OnWorldTick"/> for connections that
/// negotiated <c>RequestBinaryWire</c>, and never alongside it. The two are
/// separate events on purpose: <see cref="OnWorldTick"/> hands you raw JSON
/// text and parses none of it, so re-serialising a decoded frame to fire it
/// would hand back exactly the text-parsing cost the binary wire exists to
/// remove.
/// </remarks>
public event Action<AsobiWireFrame> OnWorldTickFrame;

/// <summary>The wire the server granted at <c>session.connected</c>, read
/// off the frame itself. <see cref="AsobiRealtime.Wire"/> is the public
/// spelling.</summary>
protected string GrantedWire { get; private set; } = "json";

/// <summary>Raises <see cref="OnWorldTickFrame"/>. Internal - the receive
/// loop calls this after decoding a binary frame.</summary>
protected void RaiseWorldTickFrame(AsobiWireFrame frame) => OnWorldTickFrame?.Invoke(frame);
public event Action<string> OnWorldAck;
public event Action<string> OnWorldTerrain;
public event Action<string> OnWorldJoined;
Expand Down Expand Up @@ -48,6 +70,10 @@ public class AsobiDispatcher

protected void RaiseDisconnected(string reason) => OnDisconnected?.Invoke(reason);

/// <summary>Raises <see cref="OnError"/> for a fault the SDK detected
/// itself, rather than a server <c>error</c> frame.</summary>
protected void RaiseError(string message) => OnError?.Invoke(message);

// Fired before each retry, so games can show "Reconnecting (attempt/max)...".
protected void RaiseReconnecting(int attempt, int maxAttempts) => OnReconnecting?.Invoke(attempt, maxAttempts);

Expand All @@ -67,6 +93,13 @@ internal void HandleMessage(string raw)
switch (env.Type)
{
case "session.connected":
// The wire the server GRANTED, which is not always the one
// requested: a server with the binary wire off answers "json".
// Read rather than infer it from the first frame's opcode.
// ExtractField takes the first match in the whole document,
// which is safe here and only here: session.connected carries
// player_id and wire, and no nested object that could shadow it.
GrantedWire = JsonScan.Unquote(JsonScan.ExtractField(raw, "wire")) ?? "json";
OnConnected?.Invoke();
break;
case "match.state":
Expand Down
73 changes: 70 additions & 3 deletions Runtime/WebSocket/AsobiRealtime.cs
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
using System;
using System.Collections.Concurrent;
using System.IO;
using System.Net.WebSockets;
using System.Text;
using System.Threading;
Expand All @@ -10,6 +11,27 @@ namespace Asobi
{
public class AsobiRealtime : AsobiDispatcher, IDisposable
{
/// <summary>
/// Ask the server for the binary <c>world.tick</c> encoding: roughly a fifth
/// of the bytes, and it arrives already decoded rather than as text you still
/// have to parse.
/// </summary>
/// <remarks>
/// Set it before <see cref="ConnectAsync"/>. Frames then reach
/// <see cref="AsobiDispatcher.OnWorldTickFrame"/> instead of
/// <see cref="AsobiDispatcher.OnWorldTick"/>; nothing else changes, and only
/// <c>world.tick</c> is affected. A server with the binary wire switched off
/// answers <c>json</c> and you silently stay on text, so read
/// <see cref="Wire"/> after <c>OnConnected</c> rather than assuming the
/// request was honoured.
/// </remarks>
public bool RequestBinaryWire;

/// <summary>The wire the server actually granted: <c>"json"</c> or
/// <c>"binary"</c>. Valid once <c>OnConnected</c> has fired.</summary>
public string Wire => GrantedWire;

readonly AsobiWire _wire = new AsobiWire();
readonly AsobiClient _client;
ClientWebSocket _ws;
CancellationTokenSource _cts;
Expand Down Expand Up @@ -43,7 +65,13 @@ public async Task ConnectAsync()
await _ws.ConnectAsync(new Uri(_client.Config.WsUrl), _cts.Token);
_ = ReceiveLoop();

var payload = JsonUtility.ToJson(new WsConnectPayload { token = _client.AccessToken });
var payload = RequestBinaryWire
? JsonUtility.ToJson(new WsConnectWirePayload
{
token = _client.AccessToken,
wire = "binary",
})
: JsonUtility.ToJson(new WsConnectPayload { token = _client.AccessToken });
await SendAsync("session.connect", payload);

_reconnectAttempts = 0;
Expand Down Expand Up @@ -475,6 +503,23 @@ async Task<string> SendAsync(string type, string payloadJson)
return await tcs.Task;
}

// A binary frame is a world.tick and nothing else. Decoded here and raised
// as OnWorldTickFrame; a malformed one is dropped with OnError rather than
// throwing, since a throw on the receive loop tears down the socket.
void HandleBinaryTick(byte[] bytes, int length)
{
var frame = _wire.Decode(bytes, length);
if (frame == null)
{
// Losing one frame costs a gap that frame_seq detects and a resync
// repairs; guessing at it would corrupt the caller's entity table
// with no way to notice.
RaiseError("malformed binary world.tick frame");
return;
}
RaiseWorldTickFrame(frame);
}

async Task SendFireAndForget(string type, string payloadJson, long? seq = null)
{
var msg = WsFrame.FireAndForget(type, payloadJson, seq);
Expand All @@ -486,13 +531,20 @@ async Task ReceiveLoop()
{
var buffer = new byte[8192];
var sb = new StringBuilder();
// Only world.tick ever arrives as binary; everything else is JSON text
// on both wires. Accumulated separately from `sb` because UTF8-decoding
// binary bytes into a string, which is what this loop used to do to
// every frame regardless, produces mojibake rather than an error.
var binary = new MemoryStream();

try
{
while (_ws.State == WebSocketState.Open && !_cts.IsCancellationRequested)
{
sb.Clear();
binary.SetLength(0);
WebSocketReceiveResult result;
var isBinary = false;

do
{
Expand All @@ -503,10 +555,25 @@ async Task ReceiveLoop()
ScheduleReconnect();
return;
}
sb.Append(Encoding.UTF8.GetString(buffer, 0, result.Count));
if (result.MessageType == WebSocketMessageType.Binary)
{
isBinary = true;
binary.Write(buffer, 0, result.Count);
}
else
{
sb.Append(Encoding.UTF8.GetString(buffer, 0, result.Count));
}
} while (!result.EndOfMessage);

HandleMessage(sb.ToString());
if (isBinary)
{
HandleBinaryTick(binary.GetBuffer(), (int)binary.Length);
}
else
{
HandleMessage(sb.ToString());
}
}
}
catch (OperationCanceledException) { }
Expand Down
Loading
Loading