Every Playloop SDK (Unity, Unreal, Godot, Python, TypeScript) emits the same four canonical events. Devs declare gameplay state through a small accumulator API; the SDK ships the full state on every heartbeat and on session end. The AI digest reads only these four event names, so the contract is the contract.
| Event | When SDK fires it | Carries |
|---|---|---|
session_start | Once at session begin, immediately after client.start(). | Identity + build metadata (sessionId, deviceId, buildVersion, sdkVersion, platform, locale). Lightweight. |
event | Every client.track(name, props) call. | The dev-defined game event. The original name lives in properties.eventName so a single envelope shape carries every custom event. |
session_heartbeat | Every 60s while the session is active (configurable). | Full current snapshot of gameplay state at the top level, plus tickNumber and playTimeSec. Liveness + crash-recovery source. |
session_summary | Once at session close, fired by the SDK auto-flush. | Full final snapshot of gameplay state at the top level. AUTHORITATIVE end-of-session state. |
The digest reads these four canonical names directly. If your snapshot or summary lives on a differently-named event, name it in Settings (below) and Playloop reads that too (it also auto-detects the obvious names). Import adapters synthesize the same canonical names when porting other providers' exports.
Every per-session AI digest begins with a block titled Session summary (end-of-session state). The payload of the session_summary event lands there verbatim. It is the first thing the analyst reads, and it is the only block guaranteed to capture resolution state (final score, did-they-finish, last room reached, completion flags). Use the state API deliberately.
The mental model: track() emits immutable historical events; setState/incrementState updates a mutable state buffer that flushes once as the session_summary snapshot and as a top-level snapshot on every session_heartbeat.
| Call | What it does |
|---|---|
setState(partial) | Merges partial into the SDK’s in-memory state accumulator (last-write-wins per key). Cheap; safe to call from a tight loop. |
incrementState(partial) | Same as setState but numeric values add into existing state instead of overwriting. Useful for counters (jar_clicks, harvests). Non-numeric keys behave like setState. |
clearState() | Drop every accumulated key. For "new game" mid-session. |
snapshot() | Returns a defensive copy of the current accumulator. For diagnostics; production code rarely needs it. |
State is preserved across heartbeats. The SDK emits the FULL current accumulator on every heartbeat and on session_summary, not deltas. Devs who want deltas compute them client-side and pass them as additional setState keys.
Session-end flushes are best-effort. Honest expectations:
| Scenario | Snapshot lands? |
|---|---|
| Clean shutdown (tab close, app quit, endSession() called) | Yes. Auto-flush fires session_summary with the full accumulator. |
| Force-quit before any heartbeat fired | No. No session_summary, no heartbeat snapshot exists yet. |
| Force-quit mid-session (after ≥1 heartbeat) | Partial. The latest heartbeat-carried snapshot becomes the digest's resolution block. Up to ∼60s of state changes since the last heartbeat may be lost. |
| Game crashes during the synchronous flush itself | Maybe. Browsers via sendBeacon are robust; native runtimes via direct HTTP are platform-dependent. |
Auto-flush hooks per runtime:
pagehide and visibilitychange:hidden auto-installed. Opt-out via new Playloop({ installShutdownHooks: false }).client.installShutdownHooks() during your service bootstrap to wire beforeExit+ SIGINT + SIGTERM. Opt-in so we don't conflict with host signal handling.atexit is wired automatically. Pass install_signal_handlers=True to configure() for SIGTERM coverage.Application.quitting fires the flush. Nothing to wire by hand.NOTIFICATION_WM_CLOSE_REQUEST on the autoload. Nothing to wire by hand.UPlayloopGameInstanceSubsystem::Deinitialize calls EndSession. Nothing to wire by hand when bAutoStart is on.Heartbeats default to 60 seconds. Configure via heartbeatSec at construction:
(0, 30) are clamped up to 30 at construction with a console warning. A sub-30s cadence emits a ton of events for marginal crash-recovery benefit.session_summary for the snapshot. No crash recovery.If your game (or an imported stream like PlayFab) already fires its own events for end-of-session state or periodic snapshots, tell Playloop their names from /games/<slug>/settings/events. There are two optional fields.
Summary event names a single end-of-session event carrying the final run-state. Playloop renders it as the Session summary. It is auto-detected when named session_summary.
Heartbeat event names a recurring event that snapshots game state over the run. Playloop reads the trajectory across them (how your key numbers moved), not just the final value. It is auto-detected when the name contains heartbeat.
Both apply to BOTH SDK and import paths, both are optional, and a game with neither just gets normal event analysis. Most SDK games leave these blank and rely on session_summary plus the auto-detected names.
TypeScript
client.setState({ final_souls: 268_000, void_pact: true })
client.incrementState({ jar_clicks: 1 })
// pagehide auto-flushes session_summary in the browser; call
// client.installShutdownHooks() in Node/Bun, or await
// client.endSession() explicitly at a custom game-over moment.Python
client.set_state({"final_souls": player_souls, "void_pact": True})
client.increment_state({"jar_clicks": 1})
# atexit fires the flush automatically.
# Pass install_signal_handlers=True to configure() for SIGTERM.Unity (C#)
client.State.SetState(new Dictionary<string, object> {
{ "final_souls", playerSouls },
{ "void_pact", true },
});
client.State.IncrementState(new Dictionary<string, object> {
{ "jar_clicks", 1 },
});
// Application.quitting fires session_summary automatically.Godot (GDScript)
Playloop.set_state({ "final_souls": player_souls, "void_pact": true })
Playloop.increment_state({ "jar_clicks": 1 })
# Autoload NOTIFICATION_WM_CLOSE_REQUEST fires session_summary automatically.Unreal (C++)
UPlayloopClient* Client = UPlayloopClient::GetCurrent();
if (Client)
{
Client->SetStateNumber(TEXT("final_souls"), PlayerSouls);
Client->SetStateBool(TEXT("void_pact"), true);
Client->IncrementState(TEXT("jar_clicks"), 1.0);
}
// UPlayloopGameInstanceSubsystem::Deinitialize fires
// EndSession automatically when bAutoStart is on.On each session page, the Session summary card headlines a few fields from your snapshot before listing everything else alphabetically. Pick which fields get headlined (and give them display labels) from /games/<slug>/settings/events. The picker offers every field seen in your recent summaries. Until you choose, the card features the SDK-provided basics (session length and exit method).
Configure per-event behavior from the dashboard at /games/<slug>/settings/events (or, on Unity / Godot, from the in-engine inspector). The SDK fetches the config on startup from GET /api/v1/games/<slug>/event-config and applies it to every subsequent track()call. Dashboard surface: the three flags below live in each event’s Details editor on the events table.
| Flag | What it does |
|---|---|
sdkIgnore | Drop the event before it enters the telemetry buffer. It never reaches the network. Use for high-frequency noise or events you decided you do not need. |
linkToSummary | Every fire of this event merges its payload into the SDK’s state accumulator (last-writer-wins per key). The event still ships to the server as a normal event; the state buffer gets a snapshot of its fields on top. |
hideFromAi | Server-side AI digest exclusion. The event stays in the transcript but is omitted from AI findings. The SDK forwards this for completeness; the actual filtering happens server-side. |
To enable the runtime consumer, pass gameSlug (the URL slug, not the database id):
TypeScript
const client = new Playloop({
apiKey: process.env.PLAYLOOP_INGEST_KEY!,
gameSlug: 'necromancers-army',
})
// fire-and-forget startup fetch; events fired before it settles flow
// through unchanged.
// Inspector save-then-pull dev loop:
await client.refreshEventConfig()Without gameSlug, the SDK skips the fetch and applies no filtering. Same shape across all five SDKs. Production picks up changes on the next game session; refreshEventConfig() (or its per-language equivalent) pulls mid-session for dev.
Studios author forms in the dashboard (Feedback tab, free for every studio). At runtime the SDK exposes client.feedback.submit(...) (TypeScript / Python / Unity / Godot / Unreal). Game code calls the helper with the form id + an array of field responses; the SDK POSTs to POST /api/telemetry/feedback with the same Authorization: Bearer <ingest-key> header used by track(). Every call lands as ONE submission (shared submissionId) with N answer rows; the dashboard reassembles them into a single card per submission.
Wire body:
{
"formId": "ff_…", // dashboard-issued form id
"sessionId": "sess_…", // active session id
"responses": [
{ "fieldId": "q1", "value": "5" }, // rating-1-5
{ "fieldId": "q2", "value": "great pacing" }, // short-text
{ "fieldId": "q3", "value": "" } // long-text (optional, skipped)
],
"askedAtSec": 1730000000.4, // optional, when the form was shown
"answeredAtSec": 1730000007.1 // optional, when the player submitted
}Player Feedback is free for every studio. Two server-side caps protect the player: one submission per (session, form) and three submissions max per session across all forms. Hardcoded, no opt-out. See /docs/feedback for the full feature page and /docs/api » list_game_feedback_forms for the read side that the SDK uses to discover available forms.
New code should target feedback.submit(...) on the top-level client. Every SDK (TypeScript, Python, Unity, Godot, Unreal) shares the same wire format above.
Fire it as a normal track() event in addition to writing it to state. Events flow through the outbox + retry loop and are far more reliable than the synchronous shutdown-flush path. Use state for the resolution block you want the AI to read first; use track() for the underlying state changes that must not be lost.
These docs are evolving. Playloop is in active development ahead of launch, so APIs and details may change as we polish.