Save games in named slots, with recovery from a damaged save and upgrades of old save formats.
Import blitz.saves As Saves gives your program these, with Saves. in front. Each name leads to its part of the reference.
LastError:Str()OpenSaves:SaveStore(directory:Str, gameId:Str, schemaVersion:Int)AddMigration:Int(store:SaveStore, fromVersion:Int, migration:Migration)SaveSlot:Int(store:SaveStore, slot:Str, payload:Int, summary:SaveSummary)DeleteSlot:Int(store:SaveStore, slot:Str)LoadSlot:LoadedSave(store:SaveStore, slot:Str)ListSlots:SlotList(store:SaveStore)ExportCloudRecord:Str(store:SaveStore, slot:Str)ReadCloudRecord:CloudSave(store:SaveStore, slot:Str, path:Str)ReleaseCloudRecord:Int(candidate:CloudSave)ImportCloudRecord:Int(store:SaveStore, slot:Str, candidate:CloudSave)SaveSummarytitle:Str, playSeconds:Double, description:StrSaveStoreLoadedSavePayload:Int(), SchemaVersion:Int(), Generation:Long(), Recovered:Int(), Title:Str(), PlaySeconds:Double(), Description:Str()SlotInfoName:Str(), Status:Int(), Diagnostic:Str(), SchemaVersion:Int(), Generation:Long(), Title:Str(), PlaySeconds:Double(), Description:Str()SlotListCount:Int(), Slot:SlotInfo(index:Int)CloudSaveDeleted:Int(), Payload:Int(), AccountID:Str(), Revision:Str(), Title:Str(), PlaySeconds:Double(), Description:Str()MigrationUpgrade:Int(payload:Int)SlotStatusReady, Recovered, Damaged, Incompatible, DeletedSaves keeps your players' progress safe. You put your game's state in a JSON
document and save it in a slot, such as slot1, with a title for the load
menu. Each slot keeps the save before the latest one too, so if the newest
save is ever damaged, loading falls back to the one before it instead of
losing everything. And when a new version of your game stores things
differently, old saves can be upgraded as they load.

Open slots.bb and run it. Press Space to score some points, pick a slot with 1, 2 or 3, and press S to save. Score some more, then press L to load your save back. Run it again, and your saves are still there.
; Save your score in one of three slots, and load it back. Space scores a
; point, 1, 2 and 3 pick a slot, S saves, L loads and Escape quits.
Dialect "modern"
Import blitz.saves As Saves
; The saves go in a folder of their own, beside the program.
Local store:Saves.SaveStore = Saves.OpenSaves("score-saves", "guide.score", 1)
If store = Null Then RuntimeError Saves.LastError()
Local slots:Saves.SlotList = Saves.ListSlots(store) ; What's saved, for the menu.
Local summary:Saves.SaveSummary = New Saves.SaveSummary
Local score:Int
Local chosen:Int = 1
Local message:Str = "Space scores a point. 1, 2 and 3 pick a slot."
Graphics 640, 300, 0, 2
SetBuffer BackBuffer()
ClsColor 18, 52, 79
While Not KeyDown(1)
If KeyHit(57) Then score = score + 1 ; Space
Local key:Int
For key = 2 To 4 ; The keys 1, 2 and 3.
If KeyHit(key) Then chosen = key - 1
Next
Local slot:Str = "slot" + chosen
If KeyHit(31) ; S saves the game, as JSON, with a title for the menu.
Local game:Int = CreateJsonObject()
Local value:Int = CreateJsonInteger(score)
JsonObjectSet game, "score", value
FreeJson value
summary.title = "Score " + score
If Saves.SaveSlot(store, slot, game, summary)
message = "Saved in slot " + chosen
Else
message = Saves.LastError()
EndIf
FreeJson game ; Saving only borrowed it.
Delete slots
slots = Saves.ListSlots(store)
EndIf
If KeyHit(38) ; L loads it back, if there's anything there.
Local loaded:Saves.LoadedSave = Saves.LoadSlot(store, slot)
If loaded = Null
message = Saves.LastError()
Else
Local saved:Int = JsonObjectGet(loaded.Payload(), "score")
score = JsonInteger(saved)
FreeJson saved
message = "Loaded slot " + chosen
Delete loaded ; This frees its JSON too.
EndIf
EndIf
Cls
Color 255, 255, 255
Text 20, 20, "Score: " + score
Text 20, 50, message
; Draw the three slots, with the title of what's saved in each.
Local index:Int
For index = 1 To 3
Local title:Str = "Empty"
Local entry:Int
For entry = 0 To slots.Count() - 1
Local info:Saves.SlotInfo = slots.Slot(entry)
If info.Name() = "slot" + index Then title = info.Title()
Next
If index = chosen Then Color 60, 150, 210 Else Color 40, 90, 140
Rect index * 200 - 180, 100, 180, 100, True
Color 255, 255, 255
Text index * 200 - 90, 125, "Slot " + index, True
Text index * 200 - 90, 160, title, True
Next
Text 20, 260, "Space: score 1 2 3: slot S: save L: load"
Flip
Wend
Delete slots
Delete summary
Delete store ; This only frees memory. The saves stay on disk.
End
SaveSlot saves a JSON document of your own, with a summary for menus, which
here is just a title. ListSlots tells the menu what's saved, and LoadSlot
gives the save back, with the JSON you saved as its Payload. Load an empty
slot, and LoadSlot gives you Null, and LastError says why.
Each slot is a pair of files in the save folder, such as slot-slot1.0.json
and slot-slot1.1.json. One holds the latest save and the other the save
before it. A new save replaces the older of the two, and only when it has been
written completely, so the latest save is safe while the new one is written.
Each file carries a checksum, a fingerprint of its contents, so a damaged file
is noticed and never loaded. If one of the pair is damaged, LoadSlot loads
the other, and its Recovered() says that one was damaged. It can't tell
whether that was the newer save. If both are damaged, loading fails.
When you change what your game saves, give the save data a new version number. Loading an older save then runs your upgrade on a copy, step by step, from one version to the next. The file itself stays as it was until you save again.
Make a JSON object, and set each value in it. JsonObjectSet keeps a copy, so
free your value straight away.
Local game:Int = CreateJsonObject() Local part:Int = CreateJsonInteger(level) JsonObjectSet game, "level", part FreeJson part part = CreateJsonString(hero) JsonObjectSet game, "hero", part FreeJson part
Fill in a SaveSummary, and save it with the game. SaveSlot keeps a copy,
and ListSlots and LoadSlot give it back. The game counts its own playing
time, in seconds.
Local summary:Saves.SaveSummary = New Saves.SaveSummary summary.title = "At the gate" summary.playSeconds = played summary.description = "Two keys found" If Not Saves.SaveSlot(store, "slot2", game, summary) Then Print Saves.LastError() FreeJson game ; Saving only borrowed it. Delete summary
Null in place of a summary saves one with no title.
ListSlots gives you every slot in the folder, in order of name. The slots in
the list are lent to you, so delete the list, never the slots.
Local slots:Saves.SlotList = Saves.ListSlots(store)
If slots <> Null
Local index:Int
For index = 0 To slots.Count() - 1
Local info:Saves.SlotInfo = slots.Slot(index)
If info.Status() = Saves.SlotStatus.Ready Or info.Status() = Saves.SlotStatus.Recovered
Print info.Name() + ": " + info.Title() + ", " + Int(info.PlaySeconds()) + " seconds"
EndIf
Next
Delete slots
EndIf
Status says whether a slot is Ready to load, Recovered (it can be loaded,
but one of its files is damaged), Damaged, Incompatible (from a newer version
of the game, say) or Deleted.
Check what you load before you use it, so that an old or odd save can't break the game in progress. If anything is wrong, carry on as you were.
Local loaded:Saves.LoadedSave = Saves.LoadSlot(store, "slot2")
If loaded = Null
Print "Couldn't load: " + Saves.LastError()
Else
If loaded.Recovered() Then Print "One of the slot's files was damaged."
Local payload:Int = loaded.Payload()
If JsonKind(payload) = JSON_OBJECT
Local saved:Int = JsonObjectGet(payload, "level") ; 0 if it isn't there.
If saved <> 0
If JsonKind(saved) = JSON_INTEGER Then level = JsonInteger(saved)
FreeJson saved
EndIf
EndIf
Delete loaded
EndIf
DeleteSlot marks the slot as deleted. It then shows as Deleted in the
list, and won't load, until you save in it again.
If Not Saves.DeleteSlot(store, "slot3") Then Print Saves.LastError()
Open the saves with your save data's new version number. For each step, from
one version to the next, write a Migration whose Upgrade makes an
upgraded copy of the JSON it's given, and add it with AddMigration.
Type AddCoins Implements Saves.Migration
Method Upgrade:Int(payload:Int)
Local upgraded:Int = CopyJson(payload) ; Never change or free payload.
Local coins:Int = CreateJsonInteger(0)
JsonObjectSet upgraded, "coins", coins
FreeJson coins
Return upgraded ; The module looks after it now.
End Method
End Type
Local newStore:Saves.SaveStore = Saves.OpenSaves("my-saves", "my.game", 2)
Local addCoins:AddCoins = New AddCoins
If Not Saves.AddMigration(newStore, 1, addCoins) Then Print Saves.LastError()
Loading a version 1 save now gives you a version 2 one, with coins. Return 0
from Upgrade if the save can't be upgraded, and LoadSlot gives Null.
Delete the store before the migration.
Blip's tip
SaveSlotonly borrows your JSON, so free it yourself afterwards. But the JSON in a loaded save belongs to the save: neverFreeJson loaded.Payload(). Deleting the loaded save frees it.
- and _, up to 64 of them,
starting with a letter or a digit. A name that breaks the rules stops the
program.OpenSaves makes the folder if it isn't
there, and a relative path starts from the current folder. The game ID you
give it is saved in every file, and saves from a different game ID are never
loaded. Only one program should use a folder at a time.SaveSlot returns,
and nothing tells your game when, so don't promise the player that their
progress is safe the moment it returns.Three more complete programs come with the module:
The reference below has every function and rule.
0.2.0 source preview. Save caller-owned JSON in named slots,
recover a previous complete generation, and migrate old schemas on a candidate.
No module dependencies. Requires Extended object-lifecycle-v1 (runtime 1114 /
module ABI 5 or later). No window, file, clock or random state is touched by Import.
Blitz3D+ comes with this module, so any Extended program can Import blitz.saves.
Open an example and run it.
Dialect "modern"
Import blitz.saves
Local store:Saves.SaveStore = Saves.OpenSaves("my-game-saves", "my.game", 1)
If store = Null Then RuntimeError Saves.LastError()
Local game:Int = CreateJsonObject()
If Not Saves.SaveSlot(store, "slot1", game, Null) Then RuntimeError Saves.LastError()
FreeJson game ; Saving borrowed it; the game still owns this handle.
Local loaded:Saves.LoadedSave = Saves.LoadSlot(store, "slot1")
If loaded = Null Then RuntimeError Saves.LastError()
Print JsonStringify(loaded.Payload(), 0)
Delete loaded ; Frees its owned JSON. Do not FreeJson loaded.Payload().
Delete store ; Only memory cleanup. The disk save remains.
End
Pass explicit Null for no summary. The compiler does not support a Null
default on an object parameter, so SaveSlot has four required arguments.
All functions below are qualified by the imported Saves alias. Calls are
synchronous and main-thread only. No matching method aliases are published.
| Function | Contract and example |
|---|---|
OpenSaves(directory:Str, gameId:Str, schemaVersion:Int):SaveStore | Validates/anchors the root to the current directory, creates missing directories, returns a caller-owned store or Null and LastError. Never calls ChangeDir. Saves.OpenSaves("saves", "studio.game", 2). The application chooses a writable sandbox/data path. |
SaveSlot(store, slot:Str, payload:Int, summary:SaveSummary):Int | Borrows a live JSON handle and optional summary for this call. Serializes before writing, returns True on atomic-file completion, False and LastError on expected failure. Never consumes or edits the caller's JSON. Saves.SaveSlot(store, "slot1", game, Null). |
LoadSlot(store, slot:Str):LoadedSave | Caller owns the result; Null and LastError for missing/deleted/damaged/incompatible slots or migration failure. Validates both files and migrates an isolated candidate. Never writes a save or swaps live game state. |
ListSlots(store):SlotList | Caller owns a sorted snapshot. Null on directory/enumeration failure. Includes damaged, incompatible and deleted slots. Each record carries its own diagnostic; per-slot damage is not a whole-list LastError. No migration callback runs during enumeration. |
DeleteSlot(store, slot:Str):Int | Explicit, fallible write of a higher-generation tombstone. Missing slots also get a tombstone. Retains the previous file. Returns True/False as SaveSlot does. |
AddMigration(store, fromVersion:Int, migration:Migration):Int | Registers one borrowed N-to-N+1 callback. Returns False and LastError for Null/deleted callback, duplicate N or N outside 1..target-1. Does not replace an existing registration. |
LastError():Str | Main-thread, module-local diagnostic. Cleared at each fallible API entry and populated on failure. Copy immediately before another operation. Result read methods and LastError itself do not clear it. |
SaveSummary is a caller-owned input record with public fields title:Str,
playSeconds:Double and description:Str, initially empty/zero. SaveSlot copies
their values. Titles allow 128 UTF-8 bytes and descriptions 1024; playSeconds is
finite, nonnegative seconds. The game supplies elapsed time; the module never
samples a clock, pauses a game, sleeps or updates a world.
SaveStore, LoadedSave, SlotList and SlotInfo have private constructors and
representation. Use factories; New Saves.SaveStore(...) is rejected. Their read
methods are the only public accessors:
| Result | Read methods |
|---|---|
LoadedSave | Payload():Int borrows its owned JSON until deletion; SchemaVersion():Int is the migrated in-memory version; Generation():Long is the original physical generation; Recovered():Int reports a damaged companion. Title():Str, PlaySeconds():Double, Description():Str copy summary values. |
SlotList | Count():Int; Slot(index:Int):SlotInfo for 0..Count-1. Store the result in a local before calling its methods: method-result chaining is unsupported. |
SlotInfo | Name():Str, Status():Int, Diagnostic():Str, SchemaVersion():Int, Generation():Long, Title():Str, PlaySeconds():Double, Description():Str. Metadata describes the physical generation, not a migrated result. No valid selected record means zero/empty metadata. |
SlotStatus is an Int enum: Ready=0, Recovered=1, Damaged=2,
Incompatible=3, Deleted=4. Ready/Recovered means a migration chain is
registered, not that a callback or the game's domain rules will accept the data.
A deleted borrowed migration becomes a missing chain. Incompatible includes a
future schema/format, foreign identity or missing migration chain. Damaged includes
conflicting equal generations and absence of any valid generation.
Local summary:Saves.SaveSummary = New Saves.SaveSummary
summary.title = "At the gate"
summary.playSeconds = 125.5D
summary.description = "Two keys"
If Not Saves.SaveSlot(store, "slot2", game, summary) Then Print Saves.LastError()
Delete summary
Local slots:Saves.SlotList = Saves.ListSlots(store)
If slots <> Null
Local index:Int
For index = 0 To slots.Count() - 1
Local info:Saves.SlotInfo = slots.Slot(index)
Print info.Name() + ": " + info.Title() + " " + info.Diagnostic()
Next
Delete slots
EndIf
| Resource/reference | Owner and cleanup |
|---|---|
| SaveStore | Caller deletes it. Owns registration records/collection and private file adapter; owns no persistent open file or pending transaction. |
| Migration implementations | Caller owns them; keep alive through store use, delete after the store. The store never deletes them. |
| SaveSlot payload and summary | Caller retains ownership. May mutate/free/delete immediately after the synchronous call. |
| LoadedSave | Caller deletes it; its destructor frees its JSON exactly once. Independent of store lifetime. Payload may be edited as a candidate, copied with CopyJson, or borrowed by SaveSlot. Never FreeJson the borrowed handle. |
| SlotList and SlotInfo | Caller deletes the list, which deletes its owned records and collection. SlotInfo is borrowed until then; do not Delete it. Snapshots remain unchanged by later saves and survive store deletion. |
Scope exit and assigning Null do not run user destructors. Delete explicitly. Deleting typed Null/already-deleted aliases is harmless. Null/deleted stores, invalid slot/game identifiers, nonpositive schema, zero/stale JSON handles and out-of-range Slot indexes are programming errors. Expected filesystem/corruption, size/summary and migration failures use the return value plus diagnostics.
[a-z0-9], rest [a-z0-9_-].
Game ID: 1..128 of the same characters, with . additionally allowed after
the first. Neither becomes an arbitrary path. Filenames are prefixed slot-,
so even names such as con do not become Windows device filenames./; relative paths are anchored at OpenSaves. UTF-8 text and live
native handles must satisfy the runtime's normal input contract. The directory
belongs to this game; do not share it with a different game ID.Assumes one writer per directory. Sequential store use is supported, but cross-process locking, concurrent writes and symlink containment are outside v1. The application supplies a trusted root. An I/O failure can happen after atomic replacement; False means completion may be uncertain. A retry rereads disk and increments the highest valid generation it finds.
Each slot has only slot-<id>.0.json and slot-<id>.1.json. No index file is needed.
The outer object has exactly format:"blitz.saves", version:1, record:Str and
sha256:Str. SHA-256 covers the exact UTF-8 bytes of the record string, without
reserializing it. The parsed record has exactly these members:
| Member | JSON type / meaning |
|---|---|
game, slot | Strings matching the opened store/requested identifier |
schema | Integer schema version |
generation | Positive signed 64-bit integer |
deleted | Boolean tombstone flag |
title, description | Bounded strings |
playSeconds | Finite nonnegative number; integer JSON also accepted |
payload | Exact serialized game JSON stored as a string |
payloadSha256 | SHA-256 of the exact UTF-8 payload text |
A tombstone has an empty payload/title/description, zero playSeconds and the hash of the empty payload. Thus integrity covers deletion and generation metadata as well as the payload. The parser rejects duplicate object keys and invalid UTF-8. There is no unspecified JSON canonicalization rule or authentication claim.
Readers bound file size, validate outer types/checksum, parse and validate the record/identity/types/checksum, then parse the payload. Both files are checked. The highest valid generation wins. Identical equal-generation copies are harmless; different records with an equal generation fail closed. An unknown envelope version or checksum-valid foreign identity blocks rollback/writes. The highest valid future-schema record blocks load/save/delete even if an older compatible backup exists. No migration is attempted backward.
A damaged companion plus a valid record yields Recovered. The flag deliberately reports degraded redundancy: damaged bytes cannot reliably prove whether the companion was newer or older. A healthy tombstone wins over an older payload even if the payload remains after interrupted cleanup. Recreating a slot requires an explicit SaveSlot at a higher generation. No automatic cleanup removes tombstones. Arbitrary later corruption of the only tombstone is not protected from rollback; keep backups if deletion must survive unrelated subsequent disk corruption.
With no valid generation, load fails and SaveSlot/DeleteSlot refuse to silently replace data; back up/repair/remove those files explicitly outside this API. A new write serializes and validates first, then atomically replaces only the older/invalid generation file. The previous highest valid generation stays intact. All operations use memory proportional to bounded input; listing validates each slot in turn and sorts O(n²), n<=4096. Migration lookup is linear in the small registration list. A missing large schema gap is rejected without looping billions of versions. There is no background thread or incremental save job.
Implement Migration.Upgrade(payload:Int):Int. It borrows the old JSON for this
call only and returns a new owned JSON handle for exactly the next schema,
or zero on failure. Do not free/retain the input or return its handle. Return
CopyJson(payload) if the step makes no data changes. The module validates/serializes
each returned document before replacing its previous candidate, and frees all
candidates on expected failure. Ownership of a successful returned handle transfers
to the module; the callback must not later free it.
Type UpgradeOne Implements Saves.Migration
Method Upgrade:Int(payload:Int)
Local candidate:Int = CopyJson(payload)
Local items:Int = CreateJsonArray()
JsonObjectSet candidate, "inventory", items
FreeJson items
Return candidate
End Method
End Type
Local upgrade:UpgradeOne = New UpgradeOne
If Not Saves.AddMigration(store, 1, upgrade) Then Print Saves.LastError()
; LoadSlot now applies schema 1 -> 2 if store's target schema is 2.
; Delete store before Delete upgrade.
The complete chain is checked before callbacks. During Upgrade, every operation on that same store, including Delete store and AddMigration, is a programming error. Use another independent store if needed; LastError may be read. Do not delete the active callback or objects it borrows. RuntimeError/End inside a callback terminates the program; no exception recovery or deferred-delete facility is promised. Mutation of the borrowed input cannot reach disk/live game state, but callbacks should treat it as read-only. Saving is never a destructor side effect.
After LoadSlot, the game must validate domain rules and build a candidate state before replacing live state. A failed load leaves it alone. SchemaVersion reports the migrated target while Generation reports the original file generation. Persist the candidate only with explicit SaveSlot. There is no pending commit, Stop/Cancel operation or durable-browser Commit acknowledgement to disguise as cleanup; Delete releases memory only.
All three examples own a fresh scratch directory and remove their files at exit.
Change their root to your application's persistent directory when integrating.
They generate drawings and JSON in source, and accept finite --verify runs.
SaveSlot reports atomic virtual-file completion, not durable IndexedDB acknowledgement. The runtime queues browser persistence asynchronously and exposes no Blitz completion signal. A browser UI must not promise durable storage solely because SaveSlot returned True.
First release explicitly defers encryption/authentication, reflection/live-handle serialization, cross-process locks and cloud conflicts. Licence/provenance are in license.txt and provenance.md.
ExportCloudRecord, ReadCloudRecord, ImportCloudRecord and
ReleaseCloudRecord validate and explicitly transfer isolated candidates without
changing the local recovery protocol. Native requests are in blitz.savecloud.
See the complete cloud workflow and API.