Documentation

s&box SDK reference

Everything you need to add InsightKit to an s&box gamemode — your own, or one you're reading for the first time — and to understand exactly what it sends and when. New here? Sign in and create a project to mint a write key.

C#
using Sandbox;

// 1 — once, when your server starts:
InsightKit.Init( "pk_live_3f9a…", "https://api.insightkit.dev/v1/events" );

// 2 — anywhere on the server, for any moment you care about:
InsightKit.Track( "round_started", new { map = "dm_arena" } );   // server event
InsightKit.Track( "round_won", channel, new { score = 4200 } );  // player event → retention

Overview

InsightKit is two calls. InsightKit.Init(…) once when your server starts, then InsightKit.Track(…) for any moment worth measuring. It runs in your game's server/host process, batches events in memory, and POSTs them over HTTPS to the InsightKit API.

It uses only s&box's built-in Sandbox.Http, has zero third-party dependencies, and is built so it never blocks or crashes your game — all I/O is async and every failure is swallowed.

Install

There's no NuGet package or package manager. Download the SDK and copy the InsightKit/ folder into your gamemode's Code/ directory — s&box's Roslyn compiler picks the source files up automatically alongside your own code.

Code/
Code/
  InsightKit/            ← copy this folder into your gamemode
    InsightKit.cs        ← public API: Init / Track / FlushAsync
    AnalyticsConfig.cs   ← optional tuning knobs
    AnalyticsEvent.cs    ← internal wire model
    EventQueue.cs        ← internal bounded queue
    HttpSender.cs        ← internal batched HTTP + retry

Only InsightKit.cs is API you call; the other four files are internal plumbing (the wire model, the queue, the HTTP sender, and the config record).

Initialize

Call Init once, at server startup. The natural home is ISceneStartup.OnHostInitialize() on a persistent component (your GameManager) — it runs on the host exactly once when the scene starts. Calling Init again is a no-op; re-init isn't supported.

C#
using Sandbox;

public sealed class MyGame : Component, ISceneStartup
{
    // Runs once on the host when the scene starts — the right place to Init.
    void ISceneStartup.OnHostInitialize()
    {
        InsightKit.Init(
            "pk_live_3f9a…",                          // write key — Settings → Keys
            "https://api.insightkit.dev/v1/events"    // your events endpoint
        );
    }
}

Both arguments come from your dashboard. The write key lives under Settings → Keys (a pk_live_… token); the endpoint is your project's events URL. The write key is public and write-only — see Managing write keys for why it's safe to ship in your source.

Track events

Track has two overloads. The difference is one argument — whether you pass a player's Connection — and it's the whole mental model of the SDK.

Server event — no player. Use it for things that happen to the match or the world, not one person. Stored under player_id "server".

C#
// Server event — no player. Stored under player_id "server".
InsightKit.Track( "round_started", new { map = "dm_arena" } );
InsightKit.Track( "round_ended", new { winner = "red", duration_secs = 183 } );

Player event — pass the player's channel. The SDK auto-captures their SteamID64 and manages a per-player session for you.

C#
// Player event — pass the Connection. This is what powers retention.
InsightKit.Track( "player_died", channel, new
{
    weapon   = "shotgun",
    map      = "dm_arena",
} );

Properties are just an anonymous object — any JSON-serializable shape, no schema to declare up front. Prefer snake_case keys (they match how the data is stored and shown in the dashboard) and keep key names stable over time so a property stays comparable across releases.

Where does channel come from? It's an s&box Connection, which you get from server-side lifecycle hooks — most commonly Component.INetworkListener.OnActive(Connection channel) and OnDisconnected(Connection channel). Because these fire on the host, listen from a single persistent component (not the player prefab) so each join fires once.

C#
using Sandbox;

// A Connection comes from server-side lifecycle hooks. INetworkListener fires
// on the host, so a single persistent component is the place to listen.
public sealed class MyGame : Component, Component.INetworkListener
{
    void Component.INetworkListener.OnActive( Connection channel )
        => InsightKit.Track( "player_joined", channel );
}

Server vs player events

Rule of thumb: if it happened to a specific player, pass the channel. A round starting or a map loading is a server event; a player joining, dying, winning, or buying something is a player event.

The distinction matters beyond tidiness: only player events feed retention. Server events (player_id "server") are deliberately excluded from cohorts — there's no person to bring back. If you want retention numbers, make sure every session emits at least one player event (see What retention measures).

Auto-captured fields

You never pass these — the SDK attaches them to every event so the backend has a complete record.

FieldWhat it is
event_idA UUID minted per event. The backend deduplicates retried batches on it, so a flaky network never double-counts.
player_idSteamID64 string for player events, or the literal "server" for server events.
session_idA per-player UUID, created on that player's first event and reused after. null for server events.
identity_providerWhich identity namespace player_id lives in — "steam" on s&box. The schema is provider-agnostic for future engines.
event_tsUTC timestamp (ISO-8601), captured at the moment you call Track — not when it's flushed.
sdk_versionThe SDK version (e.g. "1.0.0"), so the backend always knows which wire format an event came from.
game_versionReserved for your gamemode build/version — currently unset, pending the right s&box runtime API.

Worked example — instrument an existing gamemode

You don't need to have written the gamemode. To add analytics to an existing one — say Facepunch's open-source sandbox — find the persistent component that already runs the game (the one implementing ISceneStartup / INetworkListener) and add a few lines. This is exactly the integration we test the SDK against.

C#
using Sandbox;

// Adding analytics to an existing gamemode (e.g. Facepunch's open-source
// "sandbox") is a few lines on the component that already runs the game.
public sealed class GameManager : Component, ISceneStartup, Component.INetworkListener
{
    void ISceneStartup.OnHostInitialize()
    {
        InsightKit.Init( "pk_live_3f9a…", "https://api.insightkit.dev/v1/events" );
    }

    // One player-linked event per session is all retention needs.
    void Component.INetworkListener.OnActive( Connection channel )
        => InsightKit.Track( "player_joined", channel );

    void Component.INetworkListener.OnDisconnected( Connection channel )
        => InsightKit.Track( "player_left", channel );
}

// …then, wherever your gameplay code already has a Connection, add the
// moments you care about:
//   InsightKit.Track( "round_won", channel, new { score } );

That alone gets you a live event feed and working retention. From there, sprinkle Track calls wherever your gameplay logic already has a Connection (deaths, purchases, level-ups) or a server-level moment (round start/end, map load).

Batching & delivery

You don't manage any of this — it's automatic — but here's what happens to an event after Track returns, and the knobs you can turn at Init.

  • Track() returns instantly. Your event goes onto an in-memory queue; all network I/O runs on a background loop, so the game thread is never blocked.
  • Batched flush. The queue flushes when it reaches MaxBatchSize (default 50) or every FlushIntervalSecs (default 10) — whichever comes first. A busy server batches efficiently; a quiet one still reports promptly.
  • Bounded memory. The queue is capped at MaxQueueSize (default 500). If the backend is unreachable long enough to fill it, the oldest events are dropped to make room — the game never stalls and memory stays flat. (At ~1 event/sec that's roughly 8 minutes of outage before any loss.)
  • Retries, deduped. A failed batch is retried up to 3 times with exponential backoff + jitter (≈1s / 2s / 4s). Together with the per-event event_id, delivery is at-least-once and the backend drops duplicates — a flaky network won't inflate your numbers.
  • Fails silent. A downed backend, a bad key, or a missing Init degrade to dropped events — never an exception thrown into your game.
C#
InsightKit.Init( "pk_live_…", "https://api.insightkit.dev/v1/events", new AnalyticsConfig
{
    MaxBatchSize      = 50,   // flush as soon as this many events are queued
    FlushIntervalSecs = 10,   // …or at least this often on a quiet server
    MaxQueueSize      = 500,  // hard cap; oldest dropped if the backend is unreachable
} );

Clean shutdown. Anything still queued when the process exits is lost. If you can hook a clean server stop, call await InsightKit.FlushAsync() to drain the queue first.

C#
// Optional: drain the queue on a clean shutdown so the last events aren't lost.
await InsightKit.FlushAsync();

What retention measures

Retention answers “of the players who first showed up on a given day, how many came back?” — reported as D1 / D7 / D30 cohorts. A player counts as active on a day if at least one of their events landed that day; a nightly rollup turns that into first-seen dates and daily-active sets.

Two consequences for how you instrument:

  • Retention is computed only from player-linked events — the ones where you passed a channel. Server events are excluded by design.
  • So every session needs at least one player event. The simplest reliable pattern is the OnActive hook from the example above: one player_joined per session and your cohorts fill in. Any player event works — a round_won, a kill — it doesn't have to be a dedicated session event.

A no-code path — a drag-and-drop “Track Event” node for s&box's visual ActionGraph — is on the roadmap. For now, retention is wired with the one-line Track(name, channel) call above.

Managing write keys

Your write key is a pk_live_… token. Its scope is fixed to write — it can create events but can never read your data — which is exactly why it's safe to ship embedded in your gamemode source (and s&box distributes gamemodes as plain source).

Manage keys in the dashboard under Settings → Keys:

ActionWhat it does
MintCreate a new key. The full pk_live_… is shown once — copy it then. Only a prefix and a hash are stored, so it can't be revealed again.
RotateRevoke the old key and mint a replacement in one step. The old key stops working immediately, so update your gamemode with the new key and redeploy your server.
RevokeKill a key now. Any game still using it stops sending events until you ship a new one.

Gotchas

  • It's server-authoritative. Init and the send loop live in the server/host process, so call Track from server-side gameplay code. A Track call that runs only on a client (e.g. a pure client-side UI button) is a harmless no-op — the SDK fails silent when it wasn't initialized in that process. For solo / host-hosted games, host == server, so this just works.
  • Silence is by design. Because the SDK never throws, you confirm it's working by watching the dashboard, not by catching exceptions. If events aren't showing up, check the key, the endpoint, and that Init ran on the host.
  • When data appears. Events reach the Events explorer within seconds (at most your FlushIntervalSecs). Retention is computed by a nightly rollup, so D1/D7/D30 numbers appear the day after the activity.

Open the dashboard →