Skip to content
Open
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
14 changes: 14 additions & 0 deletions codex-plugin/plugins/spacetimedb/skills/csharp-client/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,18 @@ var conn = DbConnection.Builder()

Compression options are `Compression.Brotli`, `Compression.Gzip`, and `Compression.None`. The SDK uses Brotli when `WithCompression` is omitted.

## Automatic Reconnect and Token Refresh

Automatic reconnect is opt-in: add `.WithAutomaticReconnect()` to the builder. Initial connection failures do not retry. After an established connection is lost, the SDK retries with exponential backoff and jitter, capped at 30 seconds. `Disconnect()` permanently stops recovery.

Keep calling `FrameTick()` while `IsActive` is false. `IsReconnecting` is true while recovering before the next handshake. Use `.OnDisconnect((conn, error, next) => ...)` and `.OnConnectError((error, next) => ...)` to inspect `NextReconnect?`: a non-null value provides `Attempt` and `Delay`; null means no retry is scheduled. Existing callback overloads still work.

`OnConnect` runs on every successful reconnect, so create the subscription in the example above only on the first connection, or move it after `Build()`. Register row callbacks once as well. The same connection, identity, table handles, and subscriptions survive; each attempt gets a fresh `ConnectionId`. Cached rows stay readable but stale during outages. The SDK replays subscriptions in one batch and emits only net row changes. Subscription `OnApplied` runs again after replay; use it to mark data ready, not for repeated one-time setup.

For expiring credentials, combine `.WithToken(initialToken)` with `.WithTokenProvider(() => RefreshTokenAsync())`, where your provider returns `Task<string>` for the same identity. The provider is not called for the initial connection. Before retries, it is called when remaining validity is at most 30 seconds or 5% of the token lifetime, whichever is greater, when expiry cannot be read, or after a reused token is rejected. Provider failures retry; rejection of a freshly provided token is terminal. No periodic refresh runs while connected. Disconnecting ignores a pending provider result but does not cancel the provider's own work.

Calls made while disconnected fail immediately. Pending reducer calls receive `Status.UnknownResult`; pending procedures and one-off queries fail with `UnknownResultException`. These calls may already have executed and are never replayed. Regenerate bindings so unhandled unknown reducer outcomes reach `OnUnhandledReducerError`.

## Event Loop (Critical)

**`FrameTick()` must be called in your main loop.** The SDK queues all network messages and only processes them when you call `FrameTick()`. Without it, no callbacks fire.
Expand Down Expand Up @@ -153,6 +165,8 @@ conn.Reducers.OnSendMessage += (ReducerEventContext ctx, string text) =>
Console.WriteLine($"Message sent: {text}");
else if (ctx.Event.Status is Status.Failed(var reason))
Console.Error.WriteLine($"Send failed: {reason}");
else if (ctx.Event.Status is Status.UnknownResult)
Console.Error.WriteLine("Connection lost before the result arrived; the message may have been sent.");
};
```

Expand Down
17 changes: 16 additions & 1 deletion codex-plugin/plugins/spacetimedb/skills/unity/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -120,6 +120,16 @@ public class SpacetimeManager : MonoBehaviour

**Thread safety**: `FrameTick()` processes messages on the calling thread (the main thread in Unity). Do NOT call it from a background thread. Do NOT access `conn.Db` from background threads.

## Automatic Reconnect

Add `.WithAutomaticReconnect()` to the builder to recover an established connection after an outage. In the singleton above, create subscriptions only on the first `OnConnected`, or move them after `Build()`: `OnConnected` runs again after each successful reconnect. Keep calling `FrameTick()` during outages, or use `SpacetimeDBNetworkManager`, which also ticks reconnecting connections. Do not gate ticking on `IsActive`.

The SDK retains the identity, table handles, callbacks, and subscriptions, and replays subscriptions in one batch. Cached rows stay readable during an outage; row callbacks report net changes after recovery. `OnApplied` runs again, so separate one-time object setup from marking data ready. Each reconnect attempt has a fresh `ConnectionId`.

`IsReconnecting` reports recovery before the next successful handshake. The `OnDisconnect((conn, error, next) => ...)` and `OnConnectError((error, next) => ...)` overloads expose `NextReconnect?`, with the upcoming `Attempt` and `Delay`, or null for a terminal failure. Initial connection failures do not retry. Retries use exponential backoff and jitter capped at 30 seconds; `Disconnect()` stops them.

Calls made while disconnected fail immediately. Pending reducer calls may report `Status.UnknownResult`, meaning the server may have executed them; they are not replayed. Pending procedures and one-off queries fail with `UnknownResultException`. Regenerate bindings when upgrading to include unknown-outcome handling.

---

## Row Callbacks for Game State
Expand All @@ -143,7 +153,7 @@ void RegisterCallbacks()
}
```

Register these in `OnSubscriptionApplied` (after initial data is loaded) or in `Start()` before connecting.
Register these once after building the connection, before the first `FrameTick()`. Do not register them repeatedly in `OnConnected` or `OnSubscriptionApplied` when automatic reconnect is enabled.

---

Expand Down Expand Up @@ -172,6 +182,8 @@ SpacetimeManager.Instance.Connection.Reducers.OnSendMessage += (ReducerEventCont
Debug.Log($"Message sent: {text}");
else if (ctx.Event.Status is Status.Failed(var reason))
Debug.LogError($"Send failed: {reason}");
else if (ctx.Event.Status is Status.UnknownResult)
Debug.LogWarning("Connection lost before the result arrived; the message may have been sent.");
};
```

Expand Down Expand Up @@ -220,3 +232,6 @@ The SpacetimeDB SDK uses code generation. If you encounter issues with IL2CPP bu
### Token Persistence
Token save/load via `PlayerPrefs` is demonstrated in the SpacetimeManager singleton above. Persisting the server-issued token and passing it back on reconnect keeps the same identity; without a saved token the server issues a new identity in the `OnConnect` callback. This token does not expire and a lost one can't be recovered, so self-issued identities are for development. For production, authenticate with an OIDC provider such as SpacetimeAuth, which handles token lifecycle.

With automatic reconnect enabled, the SDK retains the authentication token and passes it to `OnConnect`, including on WebGL. Without automatic reconnect, a WebGL connection using a saved token may return a short-lived WebSocket token instead; in that case, keep the original saved token rather than overwriting it in the callback above.

For expiring credentials, configure `.WithToken(initialToken)`, `.WithAutomaticReconnect()`, and `.WithTokenProvider(() => RefreshTokenAsync())`. The provider returns `Task<string>` for the same identity. It is used before reconnect attempts when expiry is unreadable, remaining validity is at most 30 seconds or 5% of the original lifetime, or a reused token is rejected. It is not called for the initial connection or periodically while connected. Provider failures retry; rejection of a freshly provided token stops recovery. `Disconnect()` ignores a pending provider result without canceling the provider's own asynchronous work.
1 change: 1 addition & 0 deletions crates/codegen/src/csharp.rs
Original file line number Diff line number Diff line change
Expand Up @@ -873,6 +873,7 @@ impl Lang for Csharp<'_> {
indented_block(output, |output| {
writeln!(output, "case Status.Failed(var reason): InternalOnUnhandledReducerError(ctx, new Exception(reason)); break;");
writeln!(output, "case Status.OutOfEnergy(var _): InternalOnUnhandledReducerError(ctx, new Exception(\"out of energy\")); break;");
writeln!(output, "case Status.UnknownResult(var _): InternalOnUnhandledReducerError(ctx, new UnknownResultException()); break;");
});
});
writeln!(output, "return false;");
Expand Down
13 changes: 13 additions & 0 deletions crates/codegen/tests/snapshots/codegen__codegen_csharp.snap
Original file line number Diff line number Diff line change
Expand Up @@ -388,6 +388,7 @@ namespace SpacetimeDB
{
case Status.Failed(var reason): InternalOnUnhandledReducerError(ctx, new Exception(reason)); break;
case Status.OutOfEnergy(var _): InternalOnUnhandledReducerError(ctx, new Exception("out of energy")); break;
case Status.UnknownResult(var _): InternalOnUnhandledReducerError(ctx, new UnknownResultException()); break;
}
}
return false;
Expand Down Expand Up @@ -464,6 +465,7 @@ namespace SpacetimeDB
{
case Status.Failed(var reason): InternalOnUnhandledReducerError(ctx, new Exception(reason)); break;
case Status.OutOfEnergy(var _): InternalOnUnhandledReducerError(ctx, new Exception("out of energy")); break;
case Status.UnknownResult(var _): InternalOnUnhandledReducerError(ctx, new UnknownResultException()); break;
}
}
return false;
Expand Down Expand Up @@ -533,6 +535,7 @@ namespace SpacetimeDB
{
case Status.Failed(var reason): InternalOnUnhandledReducerError(ctx, new Exception(reason)); break;
case Status.OutOfEnergy(var _): InternalOnUnhandledReducerError(ctx, new Exception("out of energy")); break;
case Status.UnknownResult(var _): InternalOnUnhandledReducerError(ctx, new UnknownResultException()); break;
}
}
return false;
Expand Down Expand Up @@ -602,6 +605,7 @@ namespace SpacetimeDB
{
case Status.Failed(var reason): InternalOnUnhandledReducerError(ctx, new Exception(reason)); break;
case Status.OutOfEnergy(var _): InternalOnUnhandledReducerError(ctx, new Exception("out of energy")); break;
case Status.UnknownResult(var _): InternalOnUnhandledReducerError(ctx, new UnknownResultException()); break;
}
}
return false;
Expand Down Expand Up @@ -657,6 +661,7 @@ namespace SpacetimeDB
{
case Status.Failed(var reason): InternalOnUnhandledReducerError(ctx, new Exception(reason)); break;
case Status.OutOfEnergy(var _): InternalOnUnhandledReducerError(ctx, new Exception("out of energy")); break;
case Status.UnknownResult(var _): InternalOnUnhandledReducerError(ctx, new UnknownResultException()); break;
}
}
return false;
Expand Down Expand Up @@ -725,6 +730,7 @@ namespace SpacetimeDB
{
case Status.Failed(var reason): InternalOnUnhandledReducerError(ctx, new Exception(reason)); break;
case Status.OutOfEnergy(var _): InternalOnUnhandledReducerError(ctx, new Exception("out of energy")); break;
case Status.UnknownResult(var _): InternalOnUnhandledReducerError(ctx, new UnknownResultException()); break;
}
}
return false;
Expand Down Expand Up @@ -794,6 +800,7 @@ namespace SpacetimeDB
{
case Status.Failed(var reason): InternalOnUnhandledReducerError(ctx, new Exception(reason)); break;
case Status.OutOfEnergy(var _): InternalOnUnhandledReducerError(ctx, new Exception("out of energy")); break;
case Status.UnknownResult(var _): InternalOnUnhandledReducerError(ctx, new UnknownResultException()); break;
}
}
return false;
Expand Down Expand Up @@ -870,6 +877,7 @@ namespace SpacetimeDB
{
case Status.Failed(var reason): InternalOnUnhandledReducerError(ctx, new Exception(reason)); break;
case Status.OutOfEnergy(var _): InternalOnUnhandledReducerError(ctx, new Exception("out of energy")); break;
case Status.UnknownResult(var _): InternalOnUnhandledReducerError(ctx, new UnknownResultException()); break;
}
}
return false;
Expand Down Expand Up @@ -938,6 +946,7 @@ namespace SpacetimeDB
{
case Status.Failed(var reason): InternalOnUnhandledReducerError(ctx, new Exception(reason)); break;
case Status.OutOfEnergy(var _): InternalOnUnhandledReducerError(ctx, new Exception("out of energy")); break;
case Status.UnknownResult(var _): InternalOnUnhandledReducerError(ctx, new UnknownResultException()); break;
}
}
return false;
Expand Down Expand Up @@ -993,6 +1002,7 @@ namespace SpacetimeDB
{
case Status.Failed(var reason): InternalOnUnhandledReducerError(ctx, new Exception(reason)); break;
case Status.OutOfEnergy(var _): InternalOnUnhandledReducerError(ctx, new Exception("out of energy")); break;
case Status.UnknownResult(var _): InternalOnUnhandledReducerError(ctx, new UnknownResultException()); break;
}
}
return false;
Expand Down Expand Up @@ -1048,6 +1058,7 @@ namespace SpacetimeDB
{
case Status.Failed(var reason): InternalOnUnhandledReducerError(ctx, new Exception(reason)); break;
case Status.OutOfEnergy(var _): InternalOnUnhandledReducerError(ctx, new Exception("out of energy")); break;
case Status.UnknownResult(var _): InternalOnUnhandledReducerError(ctx, new UnknownResultException()); break;
}
}
return false;
Expand Down Expand Up @@ -1103,6 +1114,7 @@ namespace SpacetimeDB
{
case Status.Failed(var reason): InternalOnUnhandledReducerError(ctx, new Exception(reason)); break;
case Status.OutOfEnergy(var _): InternalOnUnhandledReducerError(ctx, new Exception("out of energy")); break;
case Status.UnknownResult(var _): InternalOnUnhandledReducerError(ctx, new UnknownResultException()); break;
}
}
return false;
Expand Down Expand Up @@ -1191,6 +1203,7 @@ namespace SpacetimeDB
{
case Status.Failed(var reason): InternalOnUnhandledReducerError(ctx, new Exception(reason)); break;
case Status.OutOfEnergy(var _): InternalOnUnhandledReducerError(ctx, new Exception("out of energy")); break;
case Status.UnknownResult(var _): InternalOnUnhandledReducerError(ctx, new UnknownResultException()); break;
}
}
return false;
Expand Down

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Loading
Loading