Game client startup
What the library sends at startup: revision init, heartbeats and compatibility headers.
This page documents what the unmodified Network Storage client library says to your server on top of the base URL. For pointing the game and editor at your server, see client-setup.md. For which URL types s&box clients can reach, see the reachability table there (all rows currently untested); this page does not repeat it.
Client library source: github.com/sbox-cool/sbox-network-storage
(Code/Core/NetworkStorageRevisionInit.cs for the handshake below).
Library page: sbox.game/sboxcool/network-storage. API and library docs:
sbox.cool/wiki/network-storage-v3.
Base URL rule
The client appends the API version itself (/v3/...), so the base URL is
only scheme, host and port with no path and no trailing slash, for example
http://your-server:8080. Verify the server answers from the machine
running s&box:
curl -i http://your-server:8080/health
curl http://your-server:8080/v3/server-info
Server info handshake
GET /v3/server-info is public and returns server identity, answered
natively without a database write:
{
"kind": "self-hosted",
"product": "sbox-network-storage-server",
"version": "0.0.0-dev",
"apiVersions": ["v3"],
"database": "sqlite",
"capabilities": ["records", "global-records", "endpoints", "workflows", "queries", "game-values", "rate-limits", "auth-sessions", "analytics", "package-sync", "https-tunnel"],
"tunnel": { "enabled": false, "name": "", "hostname": "", "registry": "", "connectorState": "disabled", "connectorVersion": "", "processId": null, "updatedAt": "2026-10-10T00:00:00+00:00" },
"compat": { "library": { "minimum": "1.1.0", "recommended": "1.1.0", "latest": "1.1.0", "legacy": "supported", "deprecated": [], "blocked": [], "knownBroken": [{ "version": "1.0.165702", "revision": 165702, "engine": ">=26.10", "reason": "Stale library breaks editor startup on s&box engine 26.10 or later.", "issue": "sbox-cool/sbox-network-storage#4" }], "tested": [] }, "matrixUrl": "https://github.com/sbox-cool/sbox-network-storage-server/blob/main/compat/library-matrix.json" },
"update": null
}
database is the configured provider name. tunnel describes the
sbox-ns tunnel connector. update carries the latest release notice when
an update check has run, else null. compat is described under
Library compatibility. sbox-ns doctor also polls
this route to check the listener.
The additive compat advertisement and optional x-ns-library-* identity/status
headers are the whole version contract. Unidentified older libraries remain
accepted by default; recommendations and deprecations alone never reject a
working game. An explicit owner minimum and advisory-linked blocks return
HTTP 426 with { "ok": false, "error": { "code": "LIBRARY_UNSUPPORTED", "message": "..." } } (or LIBRARY_BLOCKED); the existing library understands
this structured envelope, but the engine may hide 4xx bodies. See
compatibility.md for the operator policy.
Library compatibility
The server runs many library versions at once, and most are older than the server. Nothing here is required: a library that sends none of the request headers below keeps working exactly as before. The rules are in compat/library-matrix.json, which is also embedded in the binary, so everything on this page works offline.
Request headers (library to server)
The library sends these on runtime and Sync Tool requests. All are optional.
| Header | Value | Meaning |
|---|---|---|
x-ns-library-version |
SemVer, e.g. 1.1.0 |
Version of the Network Storage library. Missing or invalid (not MAJOR.MINOR.PATCH[-pre][+build], longer than 64 characters) is the legacy bucket, never an error. The old constant 1.0 counts as legacy. |
x-ns-library-revision |
integer, e.g. 309133 |
s&box package revision of the library, best effort. Ignored when not a non-negative integer. |
x-ns-client-type |
game, editor or dedicated |
Which kind of client is calling. Any other value is ignored. |
x-ns-revision-id |
integer | Game revision the client was built from (published bundles only). |
x-ns-engine-version |
engine build, e.g. 26.10.02 |
The s&box engine build the client runs on. At most 32 characters of [0-9A-Za-z._+-]; any other value is ignored. |
Libraries that do not send the header can still report their version in a request body, and the server uses it when the headers carry no valid version (a valid header always wins):
networkStorageVersionin therevision-initbody,libraryVersion(orlibrary_version) in heartbeat and analytics event bodies.
An old library's constant "1.0" there is legacy as well.
The resolved identity (library version or legacy, client type, engine build)
is written with each runtime request to storage_request_log and shown as the
Library, Client and Engine columns on the project's Logs tab. Rows
written before the columns existed show them empty.
Response headers (server to library)
Every response carries these, including errors and rejected requests. They are headers only: no response body changes, and older libraries ignore them.
| Header | When | Value |
|---|---|---|
x-ns-server-version |
always | The server version (BuildInfo.Version), e.g. 0.5.0. |
x-ns-library-status |
always | supported, outdated, deprecated, unsupported, blocked or unknown. |
x-ns-library-latest |
status outdated or worse |
Newest library version the server knows. |
x-ns-library-sunset |
status deprecated |
Date (yyyy-MM-dd) support for that version ends. |
| Status | Meaning |
|---|---|
supported |
At or above the recommended version. |
outdated |
Supported, but a newer version is recommended. |
deprecated |
Support ends on the sunset date. |
unsupported |
Below the matrix minimum, or a build known to be broken (for example on the engine builds named in the matrix). |
blocked |
A security or data-loss problem, listed in the matrix blocked ranges. |
unknown |
No valid library version was sent: the legacy bucket. The server serves it as before; the status is unknown because it cannot tell which build this is. |
When several rules match, the strictest status wins. The server only reports status. Runtime requests are served whatever the status; the editor and the dashboard are where developers are pushed to update.
compat block of /v3/server-info
"compat": {
"library": {
"minimum": "1.1.0",
"recommended": "1.1.0",
"latest": "1.1.0",
"legacy": "supported",
"deprecated": [{ "range": "<1.2.0", "sunset": "2027-03-01" }],
"blocked": [{ "range": "1.2.1", "reason": "...", "advisory": "GHSA-..." }],
"knownBroken": [{ "version": "1.0.165702", "revision": 165702, "engine": ">=26.10", "reason": "...", "issue": "sbox-cool/sbox-network-storage#4" }],
"tested": [{ "server": "0.5.0", "library": "1.1.0", "revision": 310500, "engine": "26.10.02", "os": "Windows 11", "database": ["sqlite"], "result": "pass", "evidence": ["..."] }]
},
"matrixUrl": "https://github.com/sbox-cool/sbox-network-storage-server/blob/main/compat/library-matrix.json"
}
minimum and recommended are library versions; latest is the newest the
matrix knows. legacy is the status the matrix gives the no-version bucket.
deprecated and blocked are version ranges (1.2.1 exact, <1.2.0,
>=1.1.0 <1.3.0; all comparators must hold). knownBroken lists builds known to break on
particular engine builds. tested lists the editor and CI runs recorded for
releases (release evidence). deprecated, blocked, knownBroken and
tested are empty arrays when there is nothing to report.
A library should decide features from capabilities, not from version
numbers. Where the server must act on a library version anyway, it does so in
one place (LibraryCompatShims), and each entry there names its reason, its
issue and the server version at which it must be removed.
Release-feed overlay
When update checks are on (updates.check), the server also reads an optional
libraryCompat object from the signed release feed, so a blocked range can reach
servers that have not been updated. The overlay applies only when the release info
came from the signed feed: the unsigned GitHub release.json fallback never carries
one, even if it contains a libraryCompat object. An overlay can only make a status
stricter: it can raise minimum and recommended, add blocked and
deprecated ranges, and deprecate or un-support the legacy bucket. It cannot
change an announced sunset: a feed deprecated range only dates versions the
embedded matrix does not already deprecate. It cannot lower minimum, remove a
block, set legacy back to supported or block the whole legacy bucket; those parts
are ignored, as is an overlay whose optional schemaVersion is newer than the matrix
schema this server reads (1). A feed response without libraryCompat keeps the current
overlay, but only for a while: after 3 consecutive checks with no overlay (or failed
checks) the overlay expires and the embedded matrix applies again. An empty {} removes
it immediately. /v3/server-info reports whether one is in effect in the compat block
("overlay": {"applied": true}). Nothing needs network access:
without the feed, the embedded matrix applies.
Revision init handshake
At game startup the client sends a one-time handshake with its running
revision: POST /v3/manage/{projectId}/revision-init. The server compares
it against the synced game package and reports whether the client is
outdated. The route is read-only and never mutates the store.
Authentication uses the public game key (no management scope needed), sent
as the x-api-key header, the ?apiKey= query parameter, or the
x-public-key header. The client revision travels in the JSON body as
revisionId, or in the x-ns-revision-id header (body wins when both are
present). The x-ns-client-type header marks the caller as the game.
Success body (HTTP 200):
{
"ok": true,
"playerRevision": 42,
"currentRevisionId": 42,
"latestRevisionId": 42,
"revisionOutdated": false,
"message": "Revision is current."
}
revisionOutdated is true when the client revision trails the synced
package. A missing client revision is not an error; the server acknowledges
with its latest revision and a message saying so. A project with no synced
game package answers that there is nothing to compare against. Revisions the
server does not know (playerRevision, currentRevisionId,
latestRevisionId) are left out of the body rather than sent as null.
Failures use the status code plus a short body: 401 UNAUTHORIZED for a
missing, invalid, or disabled key or an unknown project; 403
PROJECT_DISABLED for a disabled project; 400 INVALID_JSON for a
non-JSON body. The client tolerates handshake failure, so a failed
revision-init never blocks gameplay; it only skips the outdated notice.
When the owner saved an explicit version policy (see
version-policy.md), the handshake additively returns
action (none, notify, rejoin, block_writes or block_all), the
owner's notice as message for outdated clients, graceEndsAt (unix
seconds, when a grace window applies) and minimumRevisionId (when set).
Without a policy these fields are omitted and the body above is unchanged.
The handshake stays read-only and never fails because of policy.
Pinned execution and _revisionStatus
Published game bundles send x-ns-revision-id on every runtime call. When
it names a retained revision inside its support window, endpoints,
workflows, queries, game values and collection definitions run from that
revision's snapshot; anything else (no header, unknown or expired
revision) runs against live. Player records are always live.
When a version policy is configured, every endpoint response — success and
error bodies alike — carries a top-level _revisionStatus object with the
library's exact keys (isOutdatedRevision, graceRemainingMinutes,
graceExpired, action, message, playerRevision, currentRevision,
enforcementMode, policy{...}); queries and values responses carry it
too. Without a policy it is absent. With server-side enforcement switched
on, expired known revisions get HTTP 403 REVISION_BLOCKED (secret keys
exempt). Details: version-policy.md.
The game package the server compares against is synced from the editor Sync
Tool (POST /v3/manage/{projectId}/package-sync), exactly as with the
managed service. Sync your Editor/Network Storage/ sources after pointing
the editor at your server (see client-setup.md), then
publish a new build so players run against the synced revision.
Query results
GET /v3/queries/{projectId}/{queryId} (also /v1/queries/... and
/api/storage/{projectId}/queries/...) runs a query over whole collections,
so the server keeps each query's latest result and shares it between
callers:
- A result is reused for the query's
cache.ttlSeconds(300 when not set), and always for at least 2 seconds, even with a TTL of 0. - A record or player-profile write in the project ends that reuse: the next call runs the query again once the result is at least 2 seconds old. A player who saves a score sees it in the leaderboard within about 2 seconds.
- Changing a query, collection or game value drops the project's results at once.
- When many calls miss at the same time, the query runs once and they all get that result.
Calls with the public key always get the shared result, because every
player has that key. ?live=1, ?fresh=1, ?noCache=1 and ?cache=0
force a new run only together with a secret key (as x-api-key or
x-secret-key). Without ?cache=1 the response looks like a live result
(Cache-Control: no-store, no cache fields); lastran and
performance.at say when the query last ran. With ?cache=1 a reused
result also carries fromCache, cachedAt and expiresAt.