Skip to main content

Data Collection Reference

This page documents every category of user, device, and hardware data that the Playroll desktop client collects, stores, or transmits. It is the source of truth for privacy disclosures and tester FAQs.

The desktop client ships as two cooperating processes:

  • Playroll UI (playroll.exe) — Electron + React frontend that handles sign-in, library scanning, account, and update checks.
  • Playroll Core (playroll-core.exe) — C++ backend that performs capture, encoding, and uploads.

Where data is collected by only one of the two, the table column Component marks ui or core. Where both touch it, the entry says both.

The Destination column lists where the data travels beyond local memory:

  • local — written to disk on the user's machine only.
  • core ↔ ui — exchanged between the two local processes over 127.0.0.1:8080. Never leaves the machine on its own.
  • otel — sent to https://otel.playroll.gg/v1/logs (SigNoz backend).
  • supabase — sent to Supabase (Postgres + Edge Functions).
  • s3 — uploaded to the S3 recordings bucket via presigned URL.
  • steam / xbox — Steam OpenID / Xbox Live OAuth endpoints during sign-in.

Account and identity

ItemComponentDestinationNotes
Supabase user UUIDbothlocal, supabase, otel, s3Used as RLS principal and as the top-level S3 key prefix (<user_id>/<game_slug>/<date>/<session_id>/). Logged on the core via hashForLog by default; sent plaintext in OTel enduser.id.
Supabase JWT (access + refresh)bothlocal (encrypted), supabase, core ↔ uiStored encrypted in %LOCALAPPDATA%\Playroll\auth.json via Electron safeStorage (DPAPI on Windows). UI forwards the access token to the core; the core does not persist it.
Steam ID (64-bit)uilocal (encrypted), supabaseCaptured from the Steam OpenID callback; embedded in the synthetic email steam_<steamId>@playroll.gg used as the Supabase account email.
Steam usernameuilocal (encrypted), supabase, otelSurfaced in account UI; also reachable from the JWT user_metadata claim that the core reads.
Steam avatar URLuilocal (unencrypted), supabaseUsed by the launcher avatar. URL points to Steam CDN.
Xbox Live tokens (access, refresh, XSTS, XUID)uilocal (encrypted)Encrypted via safeStorage in %LOCALAPPDATA%\Playroll\xbox-auth.json. Used only to enumerate the Xbox Game Pass library.
Install IDuilocal (registry), supabaseRandom UUID generated once per install, stored at HKCU\Software\Playroll\InstallId. Sent to the resolve-update Edge Function for rollout bucketing.
Core session secretbothlocal, core ↔ ui64-char hex string generated once and persisted at %LOCALAPPDATA%\Playroll\core-session-secret.txt. Used as a shared secret on the local HTTP API. Never leaves the machine.
Onboarding profile (age confirmed, country, language, consent timestamps)uilocal, supabasePersisted in the local gate-store and replicated to Supabase as part of the onboarding flow.
Contributor link requestuisupabaseThe contributor link intent (request_id, app_user_id) is created via the create-contributor-link-intent Edge Function and resolved later via the contributor_status_projection table.

Hardware and display configuration

The core enumerates the user's hardware at startup and logs each item to OTel. Strings are hashed by default (hashForLog) but opt-out is one env var awayPLAYROLL_STARTUP_CONTEXT_LOG_MODE=plaintext disables hashing for non-sensitive fields.

ItemComponentDestinationNotes
CPU architecture, logical core count, page sizecoreotelPlaintext.
Total / available physical memory (bytes)coreotelPlaintext.
System uptimecoreotelPlaintext.
GPU vendor (NVIDIA / AMD / Intel / other)coreotelPlaintext.
GPU model, driver version, vendor ID, device IDcoreotelPer-adapter, plaintext.
GPU dedicated and shared memorycoreotelPlaintext.
Encoder capability (NVENC presence)coreotelPlaintext. Reported by the encoder selection probe. Pure-v2 is NVENC-only; AMD/software paths were removed in EC-173.
Monitor countcoreotelPlaintext.
Per-monitor resolution, refresh rate, color depth, orientationcoreotelPlaintext.
Monitor device namecoreotelHashed by default.
Audio endpoint counts (render / capture)coreotelPlaintext.
Audio endpoint ID and friendly namecoreotelHashed by default — friendly names can identify external devices ("Marco's AirPods").
All displays (Electron view): bounds, scaleFactor, colorDepth, colorSpace, rotation, touchSupportuiotelPlaintext. Emitted as display.inventory, display.inventory_changed, display.window_mapping.
Color profile setting (sRGB / Adobe RGB / Display P3)uiotelPlaintext.
Configured GPU acceleration flagsuiotelPlaintext.

Operating system and environment

ItemComponentDestinationNotes
Windows version (major.minor.build)coreotelPlaintext.
Windows product type (workstation / server)coreotelPlaintext.
Process architecture, build type (debug / release)coreotelPlaintext.
HostnameuiotelPlaintext. Sent on every OTel batch as host.name.
os.type() and os.release()uiotelPlaintext.
Windows usernamecoreotelHashed by default; logged via GetUserNameW.
Computer namecoreotelHashed by default.
Executable path and current working directorycoreotelHashed by default — file paths leak the Windows username.
Process ID, parent process IDcoreotelPlaintext.
Integrity level, elevation type, elevated flagcoreotelPlaintext.
Windows session ID, console session ID, remote-session flag, SESSIONNAMEcoreotelPlaintext.
Environment variables (all of them, at startup)coreotelIterated and logged. Keys matching API_KEY, PASSWORD, TOKEN, SECRET, AUTH, CREDENTIAL, CERT, KEY, PRIVATE, BEARER, ACCESS_TOKEN are always hashed regardless of mode. All other values are hashed by default.
Command-line argumentscoreotelSame redaction policy as env vars.
PLAYROLL_RELEASE_TRACK, PLAYROLL_INSTALLER_VERSIONcoreotelHashed by default.

Game library and process monitoring

ItemComponentDestinationNotes
List of monitored game executablescorelocalLoaded from res/games.json. Defines which processes the core watches.
Detected installed games (Steam, Epic, GOG, EA, Ubisoft, Battle.net, Amazon, Xbox, Riot, itch.io)uilocal, supabaseLibrary detectors scan store directories and registries. The resulting eligible-game list is synced to the Supabase eligible_games_per_user table for catalog filtering.
Steam manifest data (app IDs, install folder paths, install state)uilocalParsed from local Steam VDF files. Not transmitted.
Steam registry keys (HKCU\Software\Valve\Steam — SteamPath, ModInstallPath)uilocalRead to locate the Steam library. Not transmitted.
Xbox Game Pass cataloguilocalFetched from Xbox Live using the stored Xbox token, cached locally.
Detected game name and slugcorelocal, supabase, s3, otelE.g. "Elden Ring" / "elden-ring". Embedded in S3 key paths and in upload-notification payloads.
IGDB game ID, Supabase game-store IDcorelocal, supabaseStored on the session row to deduplicate against the canonical catalog.
Foreground window handle (HWND) and titlecoreotel (diagnostics only)Used by the window watchdog. Window titles are logged in window diagnostics events.

Recording artifacts (the files we capture)

These are the actual game-recording outputs. They contain the most sensitive content the client ever produces. They live on disk by default in %USERPROFILE%\Videos\PlayrollCaptures and are uploaded to S3 when a session is eligible.

ArtifactContainsDestination
*.mp4Raw video pixels of the captured game window plus AAC-encoded per-process loopback game audio, multiplexed in a single MP4 file. The video track captures anything visible on the captured surface — chat overlays, voice-chat UI, leaked HUDs, browser tabs if reflected by the game. The audio track is restricted to the detected game process and its children (see recording-session-lifecycle), so unrelated audio (Discord, Spotify, browsers, system notifications) is not included. Voice chat that the game itself decodes and mixes into its own audio output (Valorant / CS2 teammates) is still in the track — see EC-113.local, s3
*_input.csvTimestamped input events: keyboard scancodes, mouse deltas and clicks, controller buttons / sticks / triggers, scroll, cursor position deltas. Synced to video frame numbers.local, s3
Start/end JSON snapshotsFull input state vector at session boundaries.local, s3
*_mic.aac (EC-259, optional)Microphone audio recorded in parallel with the .mp4, written as a sidecar AAC file (ADTS framing). Only created when the user explicitly opts in from the Settings → Microphone card; absent otherwise. When muted via the HUD button or the F7 hotkey, the recorder writes silent PCM rather than stopping, so the sidecar duration matches the .mp4 to within one frame. As of EC-263 (PR3) the raw audio never leaves the user's machine — it is consumed locally by the transcription pipeline and the transcript JSON is uploaded in its place. The raw audio remains on local disk for the user to keep, edit, or delete.local (uploaded only as _transcript.json after EC-264)
*_transcript.json (EC-260, optional)Plain-text transcript of _mic.aac produced locally by whisper.cpp on a CUDA-capable NVIDIA GPU. Contains: model id, language, full text, per-segment {start_ms, end_ms, text} array, and metadata. Replaces the raw mic audio as the only voice-derived artifact that is uploaded. Created only when the user has the mic toggle on and the privacy gate (see below) allows it. As of EC-286 the user can review/edit the transcript before it is uploaded: with auto-upload off, the recording is held on device and transcription is on-demand, so the user can open _transcript.json and remove any words/sentences they don't want to upload before releasing it.local, s3 (when present)

Recording files are kept on disk after upload by default. Uninstall preserves them unless the user un-ticks "Keep recordings" in the uninstaller.

Upload and session metadata

The core makes two kinds of network calls on the user's behalf during a recording: presigned-URL requests for S3 uploads, and session-boundary notifications.

ItemDestinationNotes
session_id (UUID), game_name, game_slug, lobby_id, lobby_code, recording_started_atsupabase (presigned API)Sent on session start and session end.
S3 key prefix <user_id>/<game_slug>/<YYYY>/<MM>/<DD>/<session_id>/s3Object keys are user- and game-correlatable by construction.
Per-file metadata: file_type (mp4/csv/json), filename, size_bytes, s3_key, retry countlocal (SQLite), s3Persisted in upload_state.db.

Local SQLite state (upload_state.db)

The core keeps its session and upload bookkeeping in %LOCALAPPDATA%\Playroll\upload_state.db. The database is plain SQLite, unencrypted.

TableNotable columns
sessionssession_id, owner_id (= Supabase user UUID), game_name, game_slug, game_store_id, igdb_id, lobby_id, lobby_code, status, created_at, started_at, ended_at, peer_interacted, error_message, detected_resolution (JSON blob with width/height/fullscreen mode).
filessession_id, file_type, file_path (absolute path on local disk — leaks the Windows username), filename, size_bytes, status, retry_count, uploaded_at, s3_key, error_message.
app_settingsFree-form key/value rows for core configuration.

Local HTTP API (http://127.0.0.1:8080)

The core exposes a control plane on localhost for the UI. The API is bound to the loopback interface only and gated by the X-Session-Secret header, but any process running as the same user can reach it.

EndpointDiscloses
GET /api/status, GET /api/recorder/statusCurrent recording state, active game, session details.
GET /api/uploader/statusCurrent upload, queued files, progress.
GET /api/uploader/historyRecent upload history with game names, timestamps, paths.
GET /api/gamesMonitored game list and detection state.
POST /api/authReceives the Supabase JWT from the UI.
POST /api/lobby/setLobby ID, lobby code, game slug for the active multiplayer context.

Telemetry and logs

All structured events from both processes are shipped to the SigNoz collector at https://otel.playroll.gg/v1/logs. Batch size is 50 events / 10 s.

StreamComponentPlaintext fields always present
Core LOG_EVENT(...)coreservice.name=playroll, version, release track, installer version, OS/hardware summary at boot, per-event payload (see Telemetry Event Catalog).
UI OTel loggeruihost.name (plaintext hostname), os.type, os.release, process.pid, service.version, app.channel, enduser.id (= Supabase user UUID, plaintext), session.id (per-process UUID), optional deployment.environment.
UI console bridgeuiCaptures console.log/info/warn/error/debug from main and renderer and forwards them as OTel logs with log.source=console.*.
Local log filesbothplayroll_core.log, playroll_node.log and an optional UI per-process file log. Rotated periodically. Path layout includes the Windows username.

enduser.id and host.username in OTel records are sent plaintext by the UI — there is no hashing on the UI side. The core's startup-context redaction policy only applies inside the core process.

Update and version checks

ItemDestinationNotes
Install IDsupabaseSent on every update check to the resolve-update Edge Function.
Installed InstallerVersion, CoreVersion, UIVersionsupabaseRead from HKCU\Software\Playroll.
Release track (stable / dev / test)supabaseUsed to select the right rollout.
Returned rollout bucket (A/B) and experiment keylocalStored to keep the same bucket across checks.

The UI registers the playroll:// protocol handler. Auth callbacks arriving on this protocol carry the Supabase access_token, refresh_token, and a JSON user object (id, steamId, steamUsername, steamAvatarUrl). These tokens are immediately stored encrypted and never logged.

What we do not collect

The following are explicitly not captured by the client today. This list is meant as a non-exhaustive negative scope:

  • Microphone audio is never uploaded. When the user opts in via Settings → Microphone (EC-259), a _mic.aac sidecar is written to local disk but is not transmitted to S3. The on-device transcription pipeline (EC-260) consumes it locally and uploads only the _transcript.json. The mic toggle is gated by the hardware-based privacy gate (EC-263) — see "On-device transcription and the mic privacy gate" below.
  • Webcam video.
  • Keystroke content as plaintext characters — the input recorder writes scancodes / virtual-key codes synced to frame numbers, not character streams. Anything typed into the captured game window is still visible on video.
  • Browser history, files outside %USERPROFILE%\Videos\PlayrollCaptures, or any content not produced by the captured game.
  • Payment information. Payout flows are not implemented in the client and no banking / KYC fields are collected by the desktop app.
  • MAC addresses, disk serial numbers, or any other dedicated hardware fingerprint beyond the GPU/CPU/audio/display fields listed above.

Microphone capture (EC-259)

The microphone path is opt-in only. The default MicConfig.enabled is false; in that state the core never opens the mic device and no _mic.aac file is created. The user must:

  1. Open Settings → Recording → Microphone.
  2. Toggle "Microphone capture" on.
  3. Optionally pick an input device (default: Windows default communications endpoint, the same device Windows uses for voice chat).

When enabled, every subsequent recording session writes a _mic.aac sidecar alongside the MP4 — same naming pattern as _input.csv / _meta.json ({base}_mic.aac). The user can:

  • Mute mid-session via the HUD button or the global F7 hotkey. Mute does not stop the recorder: it substitutes silent PCM so the sidecar duration always matches the MP4. The user remains in control of the content while the file invariant holds.
  • Test the mic before recording via Settings → Microphone → "Record test" (5-second clip with inline playback, written to a temp .aac that is overwritten on each test and lives in %TEMP%\playroll_mic_test.aac).
  • Disable at any time: toggling the setting off stops the next-session capture immediately and leaves prior _mic.aac files on disk for the user to handle.

The mic stream is not muxed into the MP4; it ships as a separate file so post-production can edit, delete, or transcribe it without rewriting the video container.

In-game voice chat (Valorant / CS2 teammate voices rendered inside the game process) is not in this sidecar — it travels through the per-process game-audio loopback because the game mixes it into its own render path. The privacy investigation for those voices is tracked under EC-113.

On-device transcription and the mic privacy gate (EC-260 / EC-263 / EC-265)

The microphone path is paired with on-device transcription: whisper.cpp running on the user's local NVIDIA GPU produces a _transcript.json sidecar. The raw _mic.aac is consumed by that pipeline and stays on the user's disk — only the transcript JSON is uploaded.

This pairing is enforced by the mic privacy gate in the core (MicPrivacyGate, EC-263). The gate evaluates the user's hardware at core startup and decides whether the transcription pipeline is viable:

  • NVIDIA GPU + enough VRAM/RAM for at least one bundled Whisper model → gate allows. The mic toggle is available in Settings → Microphone; flipping it on writes a _mic.aac per session and queues a transcription job (EC-264) whose _transcript.json is what the uploader ships to S3.
  • No NVIDIA GPU, or insufficient VRAM/RAM → gate denies. The mic toggle is hard-disabled in the UI with an inline reason chip (NO_NVIDIA_GPU, INSUFFICIENT_VRAM, INSUFFICIENT_RAM, NO_MODEL_FOR_TIER). The core rejects any POST /api/audio/mic-config that tries to flip enabled on with HTTP 403 + reason: "MIC_PRIVACY_GATE_BLOCKED".

The rationale is privacy-first: voice audio is the most identifying artifact the client could collect. By coupling the mic toggle to a local transcription pipeline that runs entirely on the user's machine, we guarantee that the only voice-derived artifact ever uploaded is the transcript JSON — never the audio.

The gate also handles retroactive disable: if a user had the mic enabled in a previous run (driver / GPU was OK at the time) but the gate now says no (driver downgrade, GPU swap, etc.), the UI detects the verdict mismatch and pushes a forced enabled=false on next boot. The corresponding telemetry event is mic.privacy_gate.boot (supported / reason / GPU vendor / VRAM / RAM) and, when the gate blocks a write, mic.privacy_gate.block.

The privacy-gate verdict is observable from the UI via the read-only endpoint:

GET /api/transcribe/privacy-gate

which returns the cached { supported, reason, hw: { gpuVendor, gpuVramMb, totalRamMb } } payload.

EC-265 promotes the NVIDIA + CUDA requirement from "feature gate" to onboarding prerequisite: the System Check step (step 7) hard-blocks non-NVIDIA hardware regardless of any other encoder (AMF, QuickSync). On a qualifying system the EDC step (step 4) shows a second toggle — "Enable microphone + on-device transcription" — that polls the same /api/transcribe/privacy-gate endpoint and, when accepted:

  • dispatches an edcVoice consent (recorded as edc_voice in playroll_user_consents via the consents-accept edge function);
  • mirrors the choice to gateState.edcVoiceEnabled so the launcher side reads a single source of truth.

The body copy at that toggle is the user-facing statement of the privacy contract: "Your microphone audio is recorded locally and transcribed on your own NVIDIA GPU. The voice file never leaves your machine — only the JSON transcript is uploaded with the recording."

edc_voice is an optional, opt-in consent — unlike the essential types (age / ToS / privacy / EULA) it never blocks the launcher:

  • The consents-status edge function always reports requires_action: false for edc_voice, whether the user never opted in, withdrew, or an older version string is on record. This is deliberate: launcher builds treat requires_action as "show the blocking accept-or-quit ConsentReacceptanceOverlay", and builds predating the EC-298 two-tier gating don't filter by consent type. Clients that need voice state read accepted_version / withdrawn_at from the same entry instead (the recording voice-gate keys off withdrawn_at).
  • Users who finished onboarding before EC-265 are simply not opted in; they are not re-prompted. Voice capture stays off until they enable it themselves.
  • Backend support (the playroll_user_consents CHECK constraint and the type lists in consents-accept / consents-status) was back-filled by migration 20260703170000_edc_voice_consent_type.sql in playroll-cpp. Before that migration, a voice opt-in during onboarding caused the whole consent batch to be rejected (consents-accept validates the batch atomically), surfacing as "Could not save consents".

A passive transcription status badge in the launcher footer (right side, next to the level target) reads the EC-264 /api/transcribe/jobs endpoint. It surfaces three states:

  • Transcribing N% (lime, polling 500 ms) while a job is in_progress;
  • Transcript ready — fades after 5 s once a job finishes;
  • Transcript failed — sticky until dismissed.

Users on non-NVIDIA hardware never see this badge because the cpp worker never spawns the job in the first place.

Data retention and deletion

  • Local recordings: kept under %USERPROFILE%\Videos\PlayrollCaptures until the user deletes them. Uninstall keeps them by default (opt-out checkbox in the uninstaller). The same applies to the optional _mic.aac sidecar (EC-259) when present.
  • upload_state.db: removed on uninstall.
  • Encrypted credential files (auth.json, xbox-auth.json, core-session-secret.txt): under %LOCALAPPDATA%\Playroll; removed on uninstall.
  • Registry entries (HKCU\Software\Playroll): removed on uninstall.
  • S3 recordings: retained server-side per the recording-bucket lifecycle policy.
  • OTel logs: retained by SigNoz per the configured backend retention.