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).
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.
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 SentCount of events actually put on the wire (diagnostics/telemetry-of-telemetry).
-
long SuppressedCount of events dropped client-side before the wire (empty name, disconnected, gated, throttled).