Chunked replay upload
The active online run path uses the existing combat replay model and rule-based
validator, but transports replay data as independently compressed chunks. Inline
replays in rpc_submit_run are rejected.
Format
- Protocol/schema:
replay-v2-chunked - Payload schema after reconstruction: existing
ReplayPayloadschema1 - Serialization: compact camel-case JSON, UTF-8
- Compression: one independent Gzip member per chunk
- Chunk indices: contiguous and zero-based
- Target compressed chunk size: 384 KiB
- Maximum compressed chunk size: 512 KiB
- Maximum chunks: 128
- Maximum uncompressed replay size: 64 MiB
- Maximum compressed replay size: 32 MiB
- Existing semantic ceilings remain 65,536 events and 65,536 checkpoints
- Client upload parallelism: 3
- Per-chunk timeout: 30 seconds
- Retry limit: 5 with exponential backoff starting at 250 ms
Each decompressed chunk is:
{
"schemaVersion": "replay-v2-chunked",
"chunkIndex": 0,
"startedAtTick": 100,
"completedAtTick": 200,
"events": [],
"checkpoints": []
}
The compressed hash is SHA-256 over the UTF-8 bytes of the canonical base64
representation. This representation is used because Nakama's sandboxed
JavaScript API hashes strings, not byte arrays. The uncompressed hash is
SHA-256 over the exact UTF-8 segment JSON. The full hash is SHA-256 over the
concatenated uncompressed segment JSON bytes in ascending chunk order. Gzip
decompression uses .NET's BCL on the client and bundled pako on Nakama 3.22,
whose JavaScript runtime has no native Gzip API.
Manifest
Server collection replay_manifests, key <replayId>, owner <playerId>,
owner-readable and server-write-only:
{
"protocol": "replay-v2-chunked",
"replayId": "<sha256>",
"runId": "...",
"submissionId": "<sha256>",
"playerId": "...",
"uploadSessionId": "<server uuid>",
"seedId": "...",
"scoreVersion": "performance-score-v2",
"replayFormatVersion": "replay-v2-chunked",
"gameVersion": "0.1.0",
"buildVersion": "development",
"balanceVersion": "...",
"runDefinitionHash": "...",
"loadoutHash": "...",
"startedAtTick": 100,
"completedAtTick": 200,
"durationMs": 100,
"chunkCount": 3,
"totalEventCount": 900,
"uncompressedSizeBytes": 800000,
"compressedSizeBytes": 300000,
"fullHash": "<sha256>",
"status": "Uploading",
"createdAt": "...",
"updatedAt": "...",
"expiresAt": "...",
"completedAt": "...",
"lastError": "..."
}
Statuses are Created, Uploading, Finalizing, Complete, Failed,
Expired, and Rejected. Terminal replays cannot be modified.
Chunks use collection replay_chunks, key <replayId>:<zero-padded-index>.
They contain replay/run/owner identity, index, base64 Gzip data, compressed and
uncompressed hashes and sizes, and upload time. Both collections have
permissionWrite=0; other players cannot overwrite them.
RPC flow
replay_upload_beginbinds an immutable manifest to the authenticated run, submission, seed, versions, ticket, and owner. Repeating the same request returns the existing server-generateduploadSessionId, present indices, 512 KiB server limit, and expiry.replay_upload_statusreturns uploaded/missing indices, stored bytes, status, expiry, and last error.replay_chunk_uploadaccepts chunks in any order. An identical duplicate is successful; the same index with different content is rejected. The server validates token, index, size, base64 hash, Gzip, uncompressed hash, segment schema, segment index, timeline, and record limits before storage.replay_upload_finalizeverifies every stored chunk again, aggregate sizes, order, full hash, schema, and record count, then marks the manifestComplete. Missing chunks leave it resumable. Repeated finalization is safe.rpc_submit_runaccepts onlyreplayIdandreplayHash, reconstructs the completed replay through the shared server reader, executes the existing authoritative rule validation and score calculation, and only then writes history and a leaderboard projection.
The client queries status before upload and schedules only missing indices. There is never more than one task for an index. Cancellation propagates through parallel upload, delay, and timeout waits.
Local files and resume
Production uses user://replays/<replayId>/:
upload-state.json stores submission and upload-session IDs, confirmed
zero-based indices, status, retry count, last error, creation time, and whether
the server confirmation was persisted. The package reader verifies disk data
before use. A retry (including a package loaded after restart) begins
idempotently, obtains status, and skips confirmed server chunks.
Local cleanup retains at most 8 replay directories, 128 MiB total, and 14 days. The active replay is protected. A replay is removed only after server finalization and accepted canonical result confirmation have been written locally.
Server cleanup and retention
cleanupReplayUploadsForOwner idempotently removes expired incomplete,
failed/rejected, and orphaned records for an owner, but never Complete
manifests. The authenticated maintenance RPC is owner-scoped; production-wide
cleanup requires trusted orchestration over owners. Completed replay artifacts
used by run history retain the existing 180-day audit marker and must not be
deleted while referenced by leaderboard, dispute, or audit policy.
Logs contain correlation fields (runId, submissionId, replayId,
playerId, and chunk index) for begin, duplicate/store/reject, resume,
finalization, validation, and cleanup. Replay contents and upload tokens are
never logged.
Testing interruption and resume
- Start Nakama and complete a run.
- Disconnect the client after one or more
ReplayChunkStoredlog entries. - Reconnect and trigger pending-result retry.
- Confirm
replay_upload_statusreports uploaded and missing indices. - Confirm only missing indices produce new
ReplayChunkStoredentries. - Disconnect after all chunks but before finalization; retry and verify idempotent finalization.
- Verify no leaderboard row exists before
ReplayUploadFinalizedandRunValidationAccepted.
Automated coverage is in ChunkedReplayTests.cs and
modules/tests/chunkedReplay.test.js.
Schema migration
Version changes require a new protocol identifier, reader branch, client/server
deployment, and compatibility fixtures. Do not reinterpret stored bytes under
an existing schema. Existing replay-json-v1 inline artifacts remain readable
through their legacy artifact path but cannot be submitted through the new
online flow. In-progress v1 client submissions must be completed before rollout
or discarded and replayed; there is no lossless migration from a missing local
v1 payload.
Current boundary
Recording uses the existing event/checkpoint model. Mid-run, the recorder seals
immutable event prefixes through ChunkedReplayChunkBuilder once they reach
the target compressed size (checkpoints and power-ups stay local until finalize so
packing order matches ChunkedReplayCodec.Encode).
Sealed chunks are enqueued on ReplayUploadQueue and uploaded in the background via
replay_stream_begin / replay_stream_chunk under a provisional streaming replay id
(SHA256("stream:" + runId + ":" + playerId)). The recorder never waits on network I/O.
At successful run end the client drains the queue, finalizes the remaining package, then
uses the existing replay_upload_begin → missing replay_chunk_upload →
replay_upload_finalize → rpc_submit_run path. Begin adopts already-streamed chunks
with matching hashes so only the tail (if any) is uploaded after the run.
Aborted runs cancel the streaming queue, call replay_stream_abort (also invoked from
rpc_abort_run), and never upload/validate or project a leaderboard row. Expired
Streaming manifests are removed by replay_upload_cleanup_owned.
Chunk segments use chunk-local tick ranges (within the manifest span). Power-up selections ride in a dedicated trailing chunk so mid-run sealed prefixes stay byte-stable.