Meta

Analytics

Genre-agnostic gameplay-event pipeline: server-authoritative Track + a rate-capped client wire, batched off-thread to the game's IAnalyticsSink SPI (Segment/Amplitude/BigQuery/…). Ships inert (null sink counts + drops).

Server
services.AddCrossplayAnalytics();
Unity package
com.crossplay.analytics
Depends on
core

Seams you implement 1

Crossplay calls these; your game supplies them. Each ships an inert or permissive default, so register yours before services.AddCrossplayAnalytics(); and it wins.

IAnalyticsSink game SPI

THE routing SPI (server-side): the game implements this to ship batched events to ITS warehouse — Segment, Amplitude, BigQuery, Kafka, a JSONL file, anything. The framework buffers events and calls Flush with a batch on the analytics tick, OFF the poll thread — so an implementation may block on network I/O without ever stalling the game loop. Implementations must be thread-safe with respect to their own resources; the piece guarantees Flush is never called concurrently with itself (single-flight).

public sealed class JsonlSink : IAnalyticsSink
{
    public void Flush(IReadOnlyList<AnalyticsEvent> batch)
    {
        foreach (var e in batch)
            _writer.WriteLine($"{e.TimestampUnixMs},{e.AccountId},{e.Name}");
        _writer.Flush(); // may block — we are off the poll thread
    }
}
  • void Flush(IReadOnlyList<AnalyticsEvent> batch)

    Ships one batch of events. The batch list belongs to the caller and is only valid for the duration of the call — copy anything you retain. May block (it runs off the poll thread).

Services you call 1

Crossplay implements these. Resolve them from DI and call them from your own systems.

IAnalyticsService

The gameplay-event pipeline. Track is the PRIMARY, server-authoritative path: the game's server logic records product events ("match_completed", "item_purchased", "level_up") with its own opaque properties schema; because it is called from trusted server code, the events are unforgeable. Events are buffered and flushed in batches to the game's IAnalyticsSink off the poll thread.

  • void Track(long? accountId, string eventName, byte[] properties = null)

    Records one server-authoritative event. Allocation-free on the hot path — the game may call this a lot. properties is opaque and is buffered by reference; the caller must not mutate it after the call (hand ownership to analytics). A null or empty eventName is dropped.

  • void TrackFromClient(ISession session, AnalyticsEventRequest request)

    Records one CLIENT-reported event from an inbound AnalyticsEventRequest. Validates the name length and properties size against AnalyticsOptions and applies the per-session rate cap; anything over a limit is dropped silently (fire-and-forget). The subject account is taken from the session (unforgeable) — the client never supplies an account id.

Configuration 1

Every tunable lives in an options object — there are no magic numbers to hunt for.

AnalyticsOptions

Analytics tuning (every default lives here — never inlined in logic, per the no-magic-numbers rule). The piece ships INERT regardless of these: with the default NullAnalyticsSink events are merely counted and dropped until the game registers a real IAnalyticsSink.

  • bool AllowClientEvents { get; set; }

    Master switch for the client wire. When false, AnalyticsEventRequest frames are dropped unconditionally — a server-only analytics deployment (games that emit purely from server logic).

  • int ClientEventBurst { get; set; }

    Per-session burst allowance for client events (token-bucket capacity).

  • double ClientEventsPerSecond { get; set; }

    Per-session sustained rate cap for client events (token refill/second).

  • int FlushBatchSize { get; set; }

    Buffered-event count that forces an early flush before the interval elapses — keeps latency bounded under bursty load without waiting the full interval.

  • float FlushIntervalSeconds { get; set; }

    Seconds between buffer flushes to the sink (the periodic drain).

  • int MaxBufferedEvents { get; set; }

    Hard cap on buffered (not-yet-flushed) events — the ring-buffer capacity. When full, the OLDEST event is overwritten and Dropped is incremented: telemetry can never grow unbounded or block the game if a sink stalls.

  • int MaxEventNameLength { get; set; }

    Maximum length of a CLIENT-reported event name; longer names are rejected (dropped).

  • int MaxPropertiesBytes { get; set; }

    Maximum size of a CLIENT-reported properties blob, in bytes; larger blobs are rejected.

Wire messages 1

The protocol this piece speaks. Ids are allocated per piece so they can never collide.

4301 AnalyticsEventRequest

Client → server: report one client-originated analytics event (a UI funnel step, a screen view, a tutorial checkpoint). Name is opaque to the framework — the game defines its own event vocabulary ("tutorial_step_3", "store_opened"); the piece never interprets it. Properties is an optional opaque blob whose schema is entirely the game's (JSON, MemoryPack, protobuf — the wire does not care). Both are validated server-side against AnalyticsOptions (MaxEventNameLength, MaxPropertiesBytes) and rate-capped per session before buffering. This message is FIRE-AND-FORGET: there is deliberately no reply. Analytics is best-effort, high-volume telemetry; a per-event ack would double packet count for zero product value, and any event that fails validation or the rate cap is dropped silently and harmlessly (exactly like an over-limit Emote). Server-authoritative events (the primary, unforgeable path) never use this message at all — the game's server code calls IAnalyticsService.Track directly.

Unity services you inject 1

Crossplay binds these in the client context. Inject and call them from your own MonoBehaviours and presenters.

ICrossplayAnalyticsClient

The Analytics puzzle piece, client side: a pure emit-only API. Call Track with a game-defined event name and an optional opaque properties blob; the client frames one AnalyticsEventRequest and fires it FIRE-AND-FORGET to the server. There is deliberately no inbound wire (the server never replies) and no presentation — the framework never interprets the name or the bytes. Remove this package and analytics calls simply have nowhere to go; nothing else breaks.

  • void Track(string name, byte[] properties = null)

    Reports one client-originated analytics event. name is a game-defined event identifier (opaque to the framework — "tutorial_step_3", "store_opened"); properties is an optional opaque blob whose schema is entirely the game's. Best-effort and fire-and-forget: an empty name, a disconnected link, the capability gate, or the client throttle all drop the event silently and harmlessly (exactly like an over-limit event server-side) — never an exception.

  • long Sent

    Count of events actually put on the wire (diagnostics/telemetry-of-telemetry).

  • long Suppressed

    Count of events dropped client-side before the wire (empty name, disconnected, gated, throttled).