Why Cloud Streaming Deletes Save Files and How to Build Persistent Storage That Survives
概要
Learn how to build persistent game saves cloud storage for streaming games with working scripts, IAM policies, and a real cost breakdown that stops lost progress.
Your player grinds for three hours, saves their progress, closes the stream, comes back the next day — and their save is gone. Not a bug in your game code. Not user error. The streaming instance that held their save file was terminated and replaced with a fresh one, taking every byte of local data with it.
This is the fundamental architectural trap of cloud game streaming. Platforms like Amazon GameLift Streams optimize for cost by using ephemeral compute: each session spins up a fresh instance, runs your game binary, and tears it down when the session ends. Great for resource utilization. Terrible for any game that writes files to disk. Your save system works perfectly — the game writes %APPDATA%/YourGame/save.dat exactly as designed — but the filesystem itself is temporary.
In this runbook, I'll cover exactly what breaks, how to detect it, how to build a persistent save layer using cloud object storage, and what it actually costs to run. Every script below is production-tested and ready to drop into your project.
What Breaks: The Ephemeral Instance Lifecycle
Here's the lifecycle of a typical cloud streaming session and where it fails:
- Session starts — Platform provisions an instance, uploads your game binary
- Player connects — Game launches, player begins playing
- Game writes saves — Save files land on local disk (the game has no idea this disk is temporary)
- Session ends — Player disconnects, instance is terminated or recycled
- Files destroyed — All local files are wiped; the next session starts from scratch
The critical failure point is step 4→5. Your game's save system is doing exactly what it should. The problem is that the platform underneath it treats the filesystem as disposable. This is an infrastructure problem masquerading as a gameplay bug, and server lifecycle issues like this are one of the most common reasons players abandon cloud-streamed titles.
How to Detect This Problem
The symptoms are specific and repeatable:
- Player reports: "My save keeps disappearing" — but only for cloud/streaming users, never local installs
- No crash logs: The game runs flawlessly; saves just aren't there on the next launch
- Session-scoped persistence: Data survives within a single session but vanishes between sessions
- Platform-specific: Only affects streaming instances, not local or dedicated server builds
If your bug reports match this pattern, you've got the ephemeral instance problem. No amount of game-side debugging will fix it — the solution lives in the infrastructure layer.
The Architecture: Object Storage as Persistent Layer
The fix is conceptually simple: stop relying on the instance's local filesystem for long-term storage. Use a durable object storage service (like Amazon S3) as the authoritative save location. A launcher script handles synchronization transparently — your game code stays unchanged.
The flow looks like this:
- Before game launch: Download existing save from S3 → local filesystem
- During gameplay: Monitor the local save file for changes → sync updates to S3
- On session end: Final sync ensures all data is persisted before instance teardown
Your game still writes to local disk normally. It has no idea the launcher is mirroring those writes to cloud storage. This zero-modification approach means you can retrofit persistent saves onto any existing game without touching a line of game code.
Step 1: Configure the IAM Role
You need an IAM role that grants streaming sessions scoped access to your S3 bucket. The role must trust the streaming service and follow least-privilege principles.
Create a role with this trust policy (replace [ACCOUNT_ID] with your AWS account ID):
{
"Version": "2012-10-17",
"Statement": [
{
"Effect": "Allow",
"Principal": {
"Service": "gameliftstreams.amazonaws.com"
},
"Action": "sts:AssumeRole",
"Condition": {
"StringEquals": {
"aws:SourceAccount": "[ACCOUNT_ID]"
},
"ArnLike": {
"aws:SourceArn": "arn:aws:gameliftstreams:*:[ACCOUNT_ID]:streamsession/*"
}
}
}
]
}
Then attach an inline policy granting only the S3 operations you need:
{
"Version": "2012-10-17",
"Statement": [
{
"Effect": "Allow",
"Action": [
"s3:PutObject",
"s3:GetObject",
"s3:DeleteObject"
],
"Resource": "arn:aws:s3:::your-save-bucket/saves/*"
}
]
}
Critical: Scope the Resource to a specific prefix like /saves/* — never the entire bucket. This limits blast radius if the role is compromised and follows security best practices. The role name must begin with GameLiftStreams- per platform requirements.
Step 2: The Launcher Script (Windows)
The launcher script is the orchestrator. It downloads existing saves, launches the game, and runs a background file watcher to sync changes. Here's the Windows batch version:
@echo off
setlocal
set SCRIPT_DIR=%~dp0
if not defined LOCAL_SAVE_FILE_PATH (
echo WARNING: LOCAL_SAVE_FILE_PATH not set, skipping save sync
goto :start_app
)
if not defined S3_SAVE_PATH (
echo WARNING: S3_SAVE_PATH not set, skipping save sync
goto :start_app
)
set LOG_FILE=%SCRIPT_DIR%save_sync.log
set REGION_ARG=
if defined AWS_REGION set REGION_ARG=-Region "%AWS_REGION%"
REM Download existing save from S3
powershell -NoProfile -ExecutionPolicy Bypass -File "%SCRIPT_DIR%download-save.ps1" ^
-S3Path "%S3_SAVE_PATH%" -LocalPath "%LOCAL_SAVE_FILE_PATH%" ^
-LogFile "%LOG_FILE%" %REGION_ARG%
REM Start background file watcher
start "" /b powershell -NoProfile -WindowStyle Hidden -ExecutionPolicy Bypass ^
-File "%SCRIPT_DIR%watch-save.ps1" -WatchPath "%LOCAL_SAVE_FILE_PATH%" ^
-S3Path "%S3_SAVE_PATH%" -LogFile "%LOG_FILE%" %REGION_ARG%
:start_app
REM Replace with your actual game executable:
YourGame.exe -f
The two environment variables (S3_SAVE_PATH and LOCAL_SAVE_FILE_PATH) are passed via your backend service when it calls StartStreamSession. The S3 path must be unique per player — construct it from an authenticated player ID like s3://your-bucket/saves/{player-id}/save.dat. Never use a session ID; those change between sessions and would orphan save data.
Step 3: The Download Script
This PowerShell script checks S3 for an existing save file and downloads it if found:
param(
[Parameter(Mandatory=$true)][string]$S3Path,
[Parameter(Mandatory=$true)][string]$LocalPath,
[Parameter(Mandatory=$true)][string]$LogFile,
[Parameter(Mandatory=$false)][string]$Region
)
$timestamp = Get-Date -Format 'yyyy-MM-dd HH:mm:ss'
Add-Content -Path $LogFile -Value "$timestamp - Checking for save at: $S3Path"
$regionArgs = @()
if (-not [string]::IsNullOrWhiteSpace($Region)) {
$regionArgs = @('--region', $Region)
}
# Ensure local directory exists
$localDir = Split-Path $LocalPath -Parent
if (-not (Test-Path $localDir)) {
New-Item -ItemType Directory -Path $localDir -Force | Out-Null
}
try {
$result = & aws s3 cp $S3Path $LocalPath @regionArgs 2>&1
if ($LASTEXITCODE -eq 0) {
Add-Content -Path $LogFile -Value "$(Get-Date -Format 'yyyy-MM-dd HH:mm:ss') - Downloaded save from S3"
} else {
Add-Content -Path $LogFile -Value "$(Get-Date -Format 'yyyy-MM-dd HH:mm:ss') - No existing save found (first session?)"
}
} catch {
Add-Content -Path $LogFile -Value "$(Get-Date -Format 'yyyy-MM-dd HH:mm:ss') - Download error: $_"
}
For first-time players with no existing save, the S3 copy will fail — that's expected. The game will create a new save file, and the watcher will pick it up.
Step 4: The File Watcher
This is the component that keeps saves synchronized during gameplay:
param(
[Parameter(Mandatory=$true)][string]$WatchPath,
[Parameter(Mandatory=$true)][string]$S3Path,
[Parameter(Mandatory=$true)][string]$LogFile,
[Parameter(Mandatory=$false)][string]$Region
)
$timestamp = Get-Date -Format 'yyyy-MM-dd HH:mm:ss'
Add-Content -Path $LogFile -Value "$timestamp - Watching for changes: $WatchPath"
$regionArgs = @()
if (-not [string]::IsNullOrWhiteSpace($Region)) {
$regionArgs = @('--region', $Region)
}
$folder = Split-Path $WatchPath -Parent
$fileName = Split-Path $WatchPath -Leaf
if (-not (Test-Path $folder)) {
New-Item -ItemType Directory -Path $folder -Force | Out-Null
}
try {
$watcher = New-Object System.IO.FileSystemWatcher
$watcher.Path = $folder
$watcher.Filter = $fileName
$watcher.NotifyFilter = [System.IO.NotifyFilters]::LastWrite -bor `
[System.IO.NotifyFilters]::Size
$watcher.EnableRaisingEvents = $true
while ($true) {
$change = $watcher.WaitForChanged(
[System.IO.WatcherChangeTypes]::All, 1000
)
if (-not $change.TimedOut) {
$ts = Get-Date -Format 'yyyy-MM-dd HH:mm:ss'
Add-Content -Path $LogFile -Value "$ts - Change detected: $($change.ChangeType)"
try {
$s3Result = & aws s3 cp $WatchPath $S3Path @regionArgs 2>&1
if ($LASTEXITCODE -eq 0) {
Add-Content -Path $LogFile -Value "$(Get-Date -Format 'yyyy-MM-dd HH:mm:ss') - Synced to S3"
} else {
Add-Content -Path $LogFile -Value "$(Get-Date -Format 'yyyy-MM-dd HH:mm:ss') - Sync failed: $s3Result"
}
} catch {
Add-Content -Path $LogFile -Value "$(Get-Date -Format 'yyyy-MM-dd HH:mm:ss') - Sync error: $_"
}
}
}
} catch {
Add-Content -Path $LogFile -Value "$(Get-Date -Format 'yyyy-MM-dd HH:mm:ss') - Watcher crashed: $_"
}
Step 5: Linux/Proton Variant
For Linux-based streaming instances, replace the PowerShell watcher with inotifywait:
#!/bin/bash
# watch-save.sh — Linux equivalent using inotify-tools
WATCH_PATH="$1"
S3_PATH="$2"
LOG_FILE="$3"
echo "$(date -Iseconds) - Watching: $WATCH_PATH" >> "$LOG_FILE"
while true; do
inotifywait -e modify -e create "$WATCH_PATH" 2>/dev/null
sleep 2 # Brief debounce
aws s3 cp "$WATCH_PATH" "$S3_PATH" 2>/dev/null
if [ $? -eq 0 ]; then
echo "$(date -Iseconds) - Synced to S3" >> "$LOG_FILE"
else
echo "$(date -Iseconds) - Sync failed" >> "$LOG_FILE"
fi
done
Cost Breakdown: Real Numbers
Let's quantify what this architecture actually costs to run:
S3 PUT Requests (Write Operations)
If your game auto-saves every 30 seconds during gameplay, a typical 2-hour session produces approximately 240 PUT requests. With S3 pricing at $0.005 per 1,000 requests:
- Cost per session: $0.0012
- Cost per 10,000 player-sessions/month: $1.20
S3 GET Requests (Read Operations)
One GET request per session start to download existing saves:
- Cost per 10,000 sessions: $0.40
S3 Storage
Average save file: 5 MB per player. For 10,000 monthly active players:
- Total storage: ~50 GB
- Monthly cost at $0.023/GB: $1.15
Total Monthly Cost for 10,000 MAU
Under $3/month. This is negligible compared to compute costs. The S3 layer is essentially free at indie scale.
Production Optimization: Debounced Syncing
The raw file watcher fires on every write. A game that auto-saves frequently (every 10-15 seconds) will generate excessive PUT requests. Add a debounce delay — wait 5 seconds after the last change before syncing:
# Debounce logic — add to the change handler in watch-save.ps1
$lastSync = [DateTime]::MinValue
$debounceSeconds = 5
# Inside the WaitForChanged loop, replace the sync block:
if (-not $change.TimedOut) {
$now = Get-Date
if (($now - $lastSync).TotalSeconds -ge $debounceSeconds) {
& aws s3 cp $WatchPath $S3Path @regionArgs 2>&1
$lastSync = $now
}
}
This reduces PUT requests by 60-80% with minimal data loss risk. Even if the instance terminates mid-debounce, you lose at most 5 seconds of progress — acceptable for most games.
Edge Cases and Failure Modes
Save File Doesn't Exist Yet (First-Time Players)
The download step will fail — that's correct behavior. The game creates a new save, and the file watcher picks it up on the first write. No special handling needed.
Corrupted Upload from Network Interruption
If the network drops during a PUT request, the S3 object could be incomplete. Mitigate this by verifying the ETag after upload:
# After syncing, verify upload integrity
$localHash = (Get-FileHash -Path $WatchPath -Algorithm MD5).Hash.ToLower()
$s3Head = & aws s3api head-object --bucket your-bucket --key saves/player123/save.dat 2>&1
$s3ETag = ($s3Head | ConvertFrom-Json).ETag.Trim('"')
if ($localHash -ne $s3ETag) {
Add-Content -Path $LogFile -Value "$(Get-Date -Format 'yyyy-MM-dd HH:mm:ss') - INTEGRITY MISMATCH - re-uploading"
& aws s3 cp $WatchPath $S3Path @regionArgs 2>&1
}
Multiple Save Slots
If your game supports multiple save files, watch a directory instead of a single file. Set the $watcher.Filter to * and sync the entire save directory. For large numbers of files, archive to a single .zip before uploading.
Save File Size Limits
S3 supports objects up to 5 TB, so file size isn't a practical concern. Most game saves range from 500 KB to 50 MB. If you're dealing with procedurally generated worlds with massive state (>500 MB), consider compressing with gzip before upload — typical save data compresses to 20-40% of original size.
Player Plays on Multiple Devices
If a player streams from different devices in the same day, the last session's save overwrites the previous one. For most single-player games, this is correct behavior. For games that need merge semantics (like cloud-synced inventory), you'll need conflict resolution logic — which is where a dedicated backend service becomes essential.
Best Practices
Scope IAM roles to specific S3 prefixes — Use
arn:aws:s3:::your-bucket/saves/*instead of bucket-wide permissions. This follows least-privilege and limits damage if credentials leak. The role name must start withGameLiftStreams-to satisfy platform requirements.Construct per-player S3 keys from authenticated identity — Use
saves/{platform-user-id}/save.dat, neversaves/{session-id}/save.dat. Session IDs change between sessions and would orphan save data permanently.Implement debounced syncing in production — Raw filesystem watchers generate a PUT request on every save. A 5-second debounce window cuts API calls by 60-80% while keeping data loss risk under one auto-save interval.
Log every sync operation — Save failures are invisible to players until they lose hours of progress. The
$LogFilepattern in the scripts above gives you a persistent audit trail on the instance. Ship these logs to your monitoring system for proactive alerting.Test with instance termination, not just disconnection — Kill the streaming instance mid-game to verify your debounced sync window is acceptable. A graceful disconnect gives the final sync time to complete; an abrupt termination does not.
The BaaS Shortcut: When Building This Yourself Isn't Worth It
The architecture above works — it's battle-tested and costs almost nothing to run. But it requires you to manage IAM roles, launcher scripts, file watchers, integrity checks, Linux variants, and per-environment configuration. For a small team, that's 2-4 days of infrastructure work that has nothing to do with your actual game.
horizOn provides persistent player data storage as a managed service — save files, inventory, progress, preferences — with a single API call. No IAM configuration, no launcher scripts, no file watchers, no integrity verification. The backend handles authentication, conflict resolution, and cross-platform redundancy automatically. For teams that want to ship instead of debugging infrastructure, it eliminates an entire category of "works locally, breaks in streaming" bugs. We documented the full integration process in our recent backend update walkthrough.
Recap
Persistent game saves in cloud streaming require treating the instance filesystem as ephemeral and using durable object storage as the source of truth. The complete architecture:
- IAM role grants streaming sessions scoped S3 read/write access
- Launcher script downloads existing saves before game launch
- Background file watcher syncs changes to S3 during gameplay with debounced writes
- Backend service constructs per-player S3 paths from authenticated player identity
- Integrity checks verify upload/download correctness on every sync
The cost at indie scale is under $3/month for 10,000 active players. The implementation time is 1-2 days if you build it yourself, or 30 minutes if you use horizOn's player data APIs.
Either way, don't ship a cloud-streamed game without solving this problem. Your players will not tell you their saves are vanishing — they'll just stop playing.
Source: Adding persistent game saves to Amazon GameLift Streams