Saves

Save games in named slots, with recovery from a damaged save and upgrades of old save formats.

Commands

Import blitz.saves As Saves gives your program these, with Saves. in front. Each name leads to its part of the reference.

Functions

Types

Interfaces

Enums

Guide

Saves 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.

Three save slots: slot 1 holds a score of 5, slot 2 a score of 12, and slot 3 is empty

Try it

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.

How saving keeps things safe

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.

How do I...

Save more than one value?

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

Show more in the load menu?

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.

List the saves?

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.

Load safely?

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

Delete a save?

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()

Upgrade old saves?

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

SaveSlot only borrows your JSON, so free it yourself afterwards. But the JSON in a loaded save belongs to the save: never FreeJson loaded.Payload(). Deleting the loaded save frees it.

Good to know

More examples

Three more complete programs come with the module:

The reference below has every function and rule.

Reference

Needs
No other modules
Examples
migrate_recover.bb, quick_start.bb, three_slots.bb

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.

API, failures and ownership

All functions below are qualified by the imported Saves alias. Calls are synchronous and main-thread only. No matching method aliases are published.

FunctionContract and example
OpenSaves(directory:Str, gameId:Str, schemaVersion:Int):SaveStoreValidates/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):IntBorrows 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):LoadedSaveCaller 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):SlotListCaller 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):IntExplicit, 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):IntRegisters 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():StrMain-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:

ResultRead methods
LoadedSavePayload():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.
SlotListCount():Int; Slot(index:Int):SlotInfo for 0..Count-1. Store the result in a local before calling its methods: method-result chaining is unsupported.
SlotInfoName():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/referenceOwner and cleanup
SaveStoreCaller deletes it. Owns registration records/collection and private file adapter; owns no persistent open file or pending transaction.
Migration implementationsCaller owns them; keep alive through store use, delete after the store. The store never deletes them.
SaveSlot payload and summaryCaller retains ownership. May mutate/free/delete immediately after the synchronous call.
LoadedSaveCaller 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 SlotInfoCaller 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.

Identifiers, limits and filesystem contract

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.

Version 1 envelope and recovery rules

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:

MemberJSON type / meaning
game, slotStrings matching the opened store/requested identifier
schemaInteger schema version
generationPositive signed 64-bit integer
deletedBoolean tombstone flag
title, descriptionBounded strings
playSecondsFinite nonnegative number; integer JSON also accepted
payloadExact serialized game JSON stored as a string
payloadSha256SHA-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.

Candidate migrations and callback phase

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.

Examples

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.

Browser storage and deferrals

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.

Optional cloud adapter

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.