Player storage is per-player, per-project key-value storage that persists across sessions. Save games, unlocks, and settings live on the server — reachable from GDScript and from a simple HTTP API for your own tools.
From GDScript
Values are any JSON-serializable Variant (Dictionary, Array, String, numbers, bools):
# Save
await SpawnWeaver.storage_set("save", {"level": 3, "gold": 120, "inventory": ["sword"]})
# Load — value is the stored data, or null when the key was never set
var result := await SpawnWeaver.storage_get("save")
if result.ok and result.value != null:
var save: Dictionary = result.value
load_level(save["level"])
# Delete — value is true when the key existed
var deleted := await SpawnWeaver.storage_delete("old_save")
# List this player's keys, optionally by prefix
var keys := await SpawnWeaver.storage_list("slot_") # value: Array[String]
Storage is scoped to the connected player's identity — every player reads and writes only their own data. There is no client-side way to read another player's storage (do cross-player things with state or events, or server-side over HTTP).
You must be connected (await SpawnWeaver.start()) — storage calls ride the realtime
connection.
Typical pattern: load-on-boot, save-on-change
var profile := {"name": "", "best_score": 0}
func _ready() -> void:
await SpawnWeaver.start()
var result := await SpawnWeaver.storage_get("profile")
if result.ok and result.value != null:
profile = result.value
func record_score(score: int) -> void:
if score > int(profile.get("best_score", 0)):
profile["best_score"] = score
SpawnWeaver.storage_set("profile", profile) # fine to not await for background saves
Over HTTP
The same data is exposed at
/api/storage/{projectId}/players/{playerId}/keys/{key} with two accepted bearer
credentials:
| Credential | Access |
|---|---|
Project secret key (sk_…) |
Any player's data in the project — for your servers, tools, and dashboards only. Never in a game client. |
| Player token (from the realtime welcome) | That player's own data only — safe for a game client's HTTP calls |
# Write a value (the request body is the raw JSON value):
curl -X PUT https://spawnweaver.dev/api/storage/proj_xxx/players/player_abc/keys/save \
-H "Authorization: Bearer sk_your_secret_key" \
-H "Content-Type: application/json" \
-d '{"level": 3, "gold": 120}'
# 200 -> { "key": "save", "updatedAtUtc": "2026-07-29T10:00:00Z" }
# Read it back:
curl https://spawnweaver.dev/api/storage/proj_xxx/players/player_abc/keys/save \
-H "Authorization: Bearer sk_your_secret_key"
# 200 -> { "key": "save", "value": "{\"level\": 3, \"gold\": 120}", "updatedAtUtc": "…" }
# List keys / delete a key:
curl https://spawnweaver.dev/api/storage/proj_xxx/players/player_abc/keys \
-H "Authorization: Bearer sk_your_secret_key"
# 200 -> { "keys": ["save"] }
curl -X DELETE https://spawnweaver.dev/api/storage/proj_xxx/players/player_abc/keys/save \
-H "Authorization: Bearer sk_your_secret_key"
# 204 (or 404 if the key didn't exist)
With a player token the same calls work, but only when {playerId} in the URL matches
the token's own player — anything else is 401. Full method/response details in the
HTTP API reference.
Use the HTTP API for: support tooling ("inspect this player's save"), migration scripts, granting items from a companion service, or web dashboards. Everything in-game should go through the GDScript API.
Quotas
| Quota | Default | Error |
|---|---|---|
| Value size | 64 KB per key | storage-value-too-large (HTTP: 413) |
| Keys per player | 100 | storage-quota-exceeded (HTTP: 409) |
| Key length | 128 characters, non-empty | storage-invalid-key (HTTP: 400) |
When you hit the key quota, delete an old key first — quota errors are not retryable.
What belongs in storage (and what doesn't)
| Data | Put it in |
|---|---|
| Save games, progression, unlocks | Storage |
| Player settings you want to roam with the identity | Storage |
| A chosen display name | Storage (and set_display_name() each boot) |
| Live positions, health, game phase | State sync — storage is per-player and not broadcast |
| One-shot actions, chat | Events |
| Data other players must read in-game | Room state / entities — storage is private to each player |
| Local-only caches, screenshots, large binaries | Godot user:// files — storage values cap at 64 KB |
Storage writes are not rate-limited separately, but they share the connection's
message budget (20/s) — batch your saves (one profile dictionary, not thirty tiny
keys written per second).
Common mistakes
| Mistake | What happens | Fix |
|---|---|---|
Calling storage before start() |
not-connected |
Storage rides the realtime connection |
| Treating a missing key as an error | storage_get succeeds with value == null |
Check result.value != null, not just result.ok |
| Using storage as a shared leaderboard | Players can't read each other's keys | Aggregate server-side over HTTP with the secret key |
Shipping sk_… in the game to call the HTTP API |
Anyone can read/write every player's data | Clients use the GDScript API or their own player token |
| Saving every frame | Burns the message budget | Save on meaningful changes, or on a timer |
Next
- HTTP API reference — full storage endpoint details
- Limits — every quota in one table