Save Games
How to persist and restore game state across launches. Board gives every game a slice of OS-managed storage where it can create, list, load, update, and remove saves: small binary payloads with metadata. The storage model and player-association rules are identical across the three SDKs; only the call shape (async/await vs request-id signals) and a few field names differ. This guide covers both, side by side.
New to Board’s player model? Read Player Management for how the session roster and active profile work, since saves are scoped to players.
The save model
A save is a binary payload plus metadata, owned by the OS and scoped to players, not to the game alone. Three rules hold across every SDK:
- Saves auto-associate with the session’s players at creation. You do not pass a player list. Whoever is in the session when you create the save is who the save belongs to.
- Loading a save is not read-only. Board activates the players associated with the save, replacing the session roster to match. If a profile no longer exists, it is replaced with a Guest. Watch your roster after a load.
- There is no direct delete. A game removes its own players from a save; the OS deletes the save once no players remain. This prevents one game from erasing another player’s shared progress.
Metadata fields
Every SDK returns the same logical metadata for each save. The exact field names and a couple of units differ per engine:
| Field | Meaning | Unity | Godot | Web |
|---|---|---|---|---|
| id | Stable unique id, used for every later operation | id |
id |
id |
| description | User-visible label | description |
description |
description |
| game version | Version that wrote the save | gameVersion |
game_version |
gameVersion |
| created at | Creation time (epoch ms) | createdAt |
created_at |
createdAt |
| updated at | Last-update time (epoch ms) | updatedAt |
updated_at |
updatedAt |
| played time | Game-supplied play time | playedTime (seconds) |
played_time (ms) |
playedTime (ms) |
| payload size | Payload size in bytes | (use storage info) | file_size |
fileSize |
| player count | Players associated with the save | playerIds.Length |
player_count |
playerCount |
| players | Resolved player objects | players |
(not on metadata) | players |
| has cover image | Whether a cover exists | hasCoverImage |
(not on metadata) | hasCoverImage |
Mind the played-time unit. Unity’s
playedTimeis in seconds; Godot and Web express played time in milliseconds (both the create/update argument and the metadata field). Convert at the boundary so a save written by one SDK reads correctly in another.
Per-SDK call conventions
The operations are the same; the way you call them and receive results is native to each engine:
| Convention | Unity | Godot | Web |
|---|---|---|---|
| Async style | Task<T> + await |
request-id int + result signal, or await_* helper |
Promise<T> + await |
| Payload type | byte[] |
PackedByteArray |
Uint8Array |
| Create returns | BoardSaveGameMetadata |
BoardSaveMetadata |
BoardSaveGameMetadata |
| Load method name | LoadSaveGame |
load_data (not load) |
load |
| Field naming | PascalCase | snake_case | camelCase |
Godot names the load method load_data because Board.save.load(...) would shadow GDScript’s load built-in and fail to parse. On every SDK, only one save operation should be in flight at a time: await the current one before starting the next.
Creating a save
Serialize your game state to bytes, then create the save with a description, the payload, the played time, and your game version. The save auto-associates with the players currently in the session.
using Board.Save;
public async void SaveGame()
{
// Serialize your game state to bytes.
byte[] payload = SerializeGameState();
var metadataChange = new BoardSaveGameMetadataChange
{
description = "Chapter 3 - The Forest",
gameVersion = Application.version,
playedTime = (ulong)totalPlayTimeSeconds, // seconds
coverImage = CaptureScreenshot() // optional Texture2D
};
try
{
BoardSaveGameMetadata saved =
await BoardSaveGameManager.CreateSaveGame(payload, metadataChange);
Debug.Log($"Save created: {saved.id}");
}
catch (System.Exception e)
{
Debug.LogError($"Save failed: {e.Message}");
}
}
import { Board } from "@board.fun/web-sdk";
async function saveGame() {
// Serialize your game state to a Uint8Array.
const payload: Uint8Array = serializeGameState();
const playedTimeMs = totalPlayTimeMs; // milliseconds
try {
const saved = await Board.save.create(
"Chapter 3 - The Forest",
payload,
playedTimeMs,
GAME_VERSION
);
console.log(`Save created: ${saved.id}`);
} catch (e) {
console.error("Save failed", e);
}
}
func _on_save_pressed() -> void:
if not Board.is_on_device:
return
var data: PackedByteArray = _serialize_game_state()
var played_ms: int = _total_played_ms() # milliseconds
# await_create returns the metadata, or null on failure.
var meta: BoardSaveMetadata = await Board.save.await_create(
"Chapter 3 - The Forest", data, played_ms, GAME_VERSION)
if meta == null:
push_warning("[save] create failed")
return
_current_save_id = meta.id
print("[save] created %s (%d bytes)" % [meta.id, meta.file_size])
Cover images on create. Only Unity accepts a cover image at create/update time, via
BoardSaveGameMetadataChange.coverImage(aTexture2D, standardized to 432×243 PNG). Godot and Web can read a save’s cover back (see Cover images) but do not currently expose a write path through the save API.
Serializing game state
The payload is just bytes; how you produce them is up to you. Keep a schema version inside the payload so future builds can migrate old saves.
// Example: JSON via Unity's JsonUtility, then UTF-8 bytes.
byte[] SerializeGameState()
{
string json = JsonUtility.ToJson(currentState);
return System.Text.Encoding.UTF8.GetBytes(json);
}
GameState DeserializeGameState(byte[] payload)
{
string json = System.Text.Encoding.UTF8.GetString(payload);
return JsonUtility.FromJson<GameState>(json);
}
function serializeGameState(): Uint8Array {
const state = { level, score, version: 1 /* schema version */ };
return new TextEncoder().encode(JSON.stringify(state));
}
function deserializeGameState(payload: Uint8Array): GameState {
return JSON.parse(new TextDecoder().decode(payload));
}
func _serialize_game_state() -> PackedByteArray:
var state := {
"level": _level,
"score": _score,
"version": 1, # schema version
}
return var_to_bytes(state)
func _deserialize_game_state(bytes: PackedByteArray) -> Dictionary:
if bytes.is_empty():
return {}
return bytes_to_var(bytes)
Godot note:
var_to_bytes()is engine-version-bound. A save written under Godot 4.6 must be read by a 4.x runtime. For long-term durability, hand-roll a versioned binary format instead.
Loading a save
Load by id to get the payload back, then deserialize and restore. Remember that loading also activates the save’s players: your session roster changes to match the save.
public async void LoadGame(string saveId)
{
try
{
byte[] payload = await BoardSaveGameManager.LoadSaveGame(saveId);
DeserializeGameState(payload);
Debug.Log("Game loaded");
}
catch (System.Exception e)
{
Debug.LogError($"Load failed: {e.Message}");
}
}
async function loadGame(saveId: string) {
try {
const payload: Uint8Array = await Board.save.load(saveId);
deserializeGameState(payload);
console.log("Game loaded");
} catch (e) {
console.error("Load failed", e);
}
}
func _on_load_pressed(save_id: String) -> void:
if not Board.is_on_device:
return
# Note: load_data, NOT load (load is a GDScript built-in).
var bytes: PackedByteArray = await Board.save.await_load(save_id)
if bytes.is_empty():
push_warning("[save] load failed or empty")
return
var state := bytes_to_var(bytes) as Dictionary
_apply_game_state(state)
_current_save_id = save_id
Loading changes the roster. After a load, the session players reflect the save’s associated players. A missing profile is replaced with a Guest that keeps the original session slot but gets a fresh player id. In Unity, subscribe to
BoardSession.playersChanged; in Godot, listen forplayers_changed. The Web SDK has no players-changed event, so re-readBoard.session.getPlayers()after the load resolves.
Listing saves
Get all saves for the current app. Each SDK returns metadata only (cheap); the payload is fetched separately with a load. Saves come back sorted by last-updated time, most recent first.
public async void DisplaySaveSlots()
{
BoardSaveGameMetadata[] saves =
await BoardSaveGameManager.GetSaveGamesMetadata();
foreach (var save in saves)
{
Debug.Log($"{save.description} " +
$"updated={save.updatedAt} " +
$"played={save.playedTime}s " +
$"players={save.playerIds.Length}");
}
}
async function displaySaveSlots() {
const saves = await Board.save.list();
for (const save of saves) {
console.log(
`${save.description} updated=${save.updatedAt} ` +
`played=${save.playedTime}ms players=${save.playerCount}`
);
}
}
func _refresh_saves_ui() -> void:
if not Board.is_on_device:
return
var saves: Array = await Board.save.await_list()
_clear_saves_ui()
for s in saves:
print("%s updated=%d played=%dms players=%d" % [
s.description, s.updated_at, s.played_time, s.player_count])
_add_save_row(s)
The Godot request-id pattern
Unity and Web are pure async/await. Godot’s underlying model returns a request-id int immediately and delivers the result on a typed signal carrying that id. The await_* helpers above wrap this for you, but you can wire the signal directly when you want to drive several requests at once or surface failures globally.
func _ready() -> void:
if not Board.is_on_device:
return
Board.save.save_listed.connect(_on_listed)
Board.save.save_failed.connect(_on_failed)
var rid := Board.save.list()
func _on_listed(rid: int, saves: Array) -> void:
print("[save] listed %d saves" % saves.size())
func _on_failed(rid: int, error: String) -> void:
push_error("[save] request %d failed: %s" % [rid, error])
Every Godot save operation emits save_failed(rid, error) on failure, regardless of which method started the request, so a single global handler catches them all.
Updating a save
Overwrite an existing save by id with a new payload and metadata. Track the current save id and update it rather than creating duplicates.
public async void UpdateSave(string saveId)
{
byte[] payload = SerializeGameState();
var metadataChange = new BoardSaveGameMetadataChange
{
description = currentLevelName,
gameVersion = Application.version,
playedTime = (ulong)totalPlayTimeSeconds,
coverImage = CaptureScreenshot()
};
BoardSaveGameMetadata updated =
await BoardSaveGameManager.UpdateSaveGame(saveId, payload, metadataChange);
Debug.Log($"Save updated: {updated.updatedAt}");
}
async function updateSave(saveId: string) {
const payload = serializeGameState();
await Board.save.update(
saveId,
currentLevelName,
payload,
totalPlayTimeMs,
GAME_VERSION
);
console.log("Save updated");
}
func _on_overwrite_save(save_id: String) -> void:
if not Board.is_on_device:
return
var data := _serialize_game_state()
var played_ms := _total_played_ms()
var ok: bool = await Board.save.await_update(
save_id, "Updated %s" % Time.get_time_string_from_system(),
data, played_ms, GAME_VERSION)
if not ok:
push_warning("[save] update failed")
Unity’s
UpdateSaveGameresolves the updated metadata; Web’supdateresolvesvoid; Godot’sawait_updateresolves abool(true on success).
Removing players and deleting saves
There is no direct delete on any SDK. To get rid of a save, remove its player associations; the OS deletes the save once none remain. Two operations let you detach players: remove all of this game’s session players, or remove just the active profile (leaving other players’ progress on the save intact).
// Remove all of this session's players from the save.
// If none remain, the save is deleted automatically.
bool removed = await BoardSaveGameManager.RemovePlayersFromSaveGame(saveId);
// Or remove only the active profile, preserving other players.
await BoardSaveGameManager.RemoveActiveProfileFromSaveGame(saveId);
// Remove all of this game's players from the save.
// If none remain, the OS deletes the save.
await Board.save.removePlayersFromSave(saveId);
// Or remove only the active profile, preserving other players.
await Board.save.removeActiveProfileFromSave(saveId);
# Remove all of this session's players from the save.
# If none remain, the OS deletes the save.
var removed: bool = await Board.save.await_remove_players(save_id)
# Or remove only the active profile, preserving other players.
await Board.save.await_remove_active_profile(save_id)
Deletion is silent and final. When the last player is removed, the save is deleted with no confirmation and no recovery. Warn the user first: check the player count (Unity
playerIds.Length, Godotplayer_count, WebplayerCount) before removing, so they know they are about to erase the last copy.
Cover images
A save can carry a cover image (432×243, 16:9). All three SDKs can read a save’s cover back. Only Unity can write one, via the cover on its metadata-change object at create/update time.
Reading a cover
public async void LoadCoverImage(BoardSaveGameMetadata save)
{
if (!save.hasCoverImage)
{
coverImage.texture = defaultCover;
return;
}
// Returns a Texture2D, or null if the save has no cover.
Texture2D texture =
await BoardSaveGameManager.LoadSaveGameCoverImage(save.id);
coverImage.texture = texture ?? defaultCover;
}
async function loadCover(save: BoardSaveGameMetadata): Promise<string | null> {
if (!save.hasCoverImage) return null;
// Resolves a "data:image/png;base64,..." data URI.
const dataUri = await Board.save.loadCoverImage(save.id);
imgElement.src = dataUri;
return dataUri;
}
func _load_cover_for(save_id: String) -> Texture2D:
# Returns the PNG bytes for the cover, or an empty array if none.
var png_bytes: PackedByteArray =
await Board.save.await_load_cover_image(save_id)
if png_bytes.is_empty():
return null
var img := Image.new()
if img.load_png_from_buffer(png_bytes) != OK:
return null
return ImageTexture.create_from_image(img)
Writing a cover (Unity only)
Capture a clean game frame before any pause overlay or dimming renders, then hand the texture to the metadata-change object on create or update. The SDK scales and converts it to a 432×243 PNG.
Texture2D CaptureScreenshot()
{
var rt = new RenderTexture(Screen.width, Screen.height, 24);
Camera.main.targetTexture = rt;
Camera.main.Render();
RenderTexture.active = rt;
var screenshot = new Texture2D(rt.width, rt.height, TextureFormat.RGB24, false);
screenshot.ReadPixels(new Rect(0, 0, rt.width, rt.height), 0, 0);
screenshot.Apply();
Camera.main.targetTexture = null;
RenderTexture.active = null;
Destroy(rt);
return screenshot;
}
Godot and Web do not currently expose a cover write path through the save API; they can only read covers that already exist. Track each SDK’s changelog for when the write path lands.
Storage limits
Board enforces a per-app storage budget and per-save limits. The authoritative values come from the OS at runtime through sync getters: do not hardcode them. The defaults today are 16 MB per save, 64 MB total per app, and a 100-character description.
public async void CheckStorage()
{
BoardAppStorageInfo info =
await BoardSaveGameManager.GetAppStorageInfo();
Debug.Log($"Used {info.usedStorage} / {info.totalStorage} bytes " +
$"({info.usagePercentage:P0})"); // usagePercentage is 0.0..1.0
long maxPayload = BoardSaveGameManager.GetMaxPayloadSize();
long maxStorage = BoardSaveGameManager.GetMaxAppStorage();
int maxDescLen = BoardSaveGameManager.GetMaxSaveDescriptionLength();
}
function checkStorage() {
const info = Board.save.getAppStorageInfo();
console.log(
`Used ${info.usedStorage} / ${info.totalStorage} bytes ` +
`(${Math.round(info.usagePercentage * 100)}%)` // 0.0..1.0
);
const maxData = Board.save.getMaxDataSize();
const maxApp = Board.save.getMaxAppStorageSize();
const maxDesc = Board.save.getMaxDescriptionLength();
}
func _check_storage() -> void:
var info: Dictionary = Board.save.get_app_storage_info()
print("Used %d / %d bytes (%.0f%%)" % [
info.used_storage, info.total_storage,
info.usage_percentage * 100.0]) # usage_percentage is 0.0..1.0
var max_data := Board.save.get_max_data_size()
var max_app := Board.save.get_max_app_storage_size()
var max_desc := Board.save.get_max_description_length()
Check your payload against the limit before saving so you can show a friendly message instead of failing the request.
public async void OnSavePressed()
{
byte[] payload = SerializeGameState();
if (payload.Length > BoardSaveGameManager.GetMaxPayloadSize())
{
ShowStorageFullDialog();
return;
}
await BoardSaveGameManager.CreateSaveGame(payload, BuildMetadataChange());
}
async function onSavePressed() {
const payload = serializeGameState();
if (payload.length > Board.save.getMaxDataSize()) {
showStorageFullDialog();
return;
}
await Board.save.create("Save", payload, totalPlayTimeMs, GAME_VERSION);
}
func _on_save_pressed() -> void:
if not Board.is_on_device:
return
var data := _serialize_game_state()
if data.size() > Board.save.get_max_data_size():
push_warning("[save] payload exceeds max size")
return
await Board.save.await_create("Save", data, _total_played_ms(), GAME_VERSION)
The storage-info
usagePercentage(Web/Unity) andusage_percentage(Godot) is a fraction from 0.0 to 1.0, not a 0–100 percentage. Multiply by 100 before showing a percent.
Player associations
Saves are linked to the players that were in the session when they were created. This lets you filter saves by the active profile and build “who has played” views. The mechanics differ a little per SDK because Godot’s metadata exposes only a count, while Unity and Web carry resolved player objects.
Finding saves for the active profile
using System.Linq;
public async Task<BoardSaveGameMetadata[]> GetSavesForActiveProfile()
{
var allSaves = await BoardSaveGameManager.GetSaveGamesMetadata();
var activeId = BoardSession.activeProfile?.playerId;
if (string.IsNullOrEmpty(activeId))
return allSaves;
return allSaves
.Where(s => s.playerIds.Contains(activeId))
.ToArray();
}
async function getSavesForActiveProfile() {
const allSaves = await Board.save.list();
const active = Board.session.getActiveProfile();
if (!active) return allSaves;
return allSaves.filter(s =>
s.players.some(p => p.playerId === active.playerId)
);
}
# Godot metadata exposes player_count, not individual ids, so the practical
# filter is "saves with at least one associated player."
func _saves_with_players() -> Array:
var profile: BoardPlayer = Board.session.get_active_profile()
if profile == null:
return []
var all: Array = await Board.save.await_list()
return all.filter(func(s): return s.player_count > 0)
Getting unique players across saves
Unity and Web ship a helper that dedupes profile players across a set of saves (guests and AI excluded). Godot’s metadata does not carry per-save player ids, so there is no equivalent helper there: to enumerate players you would load each save and read the activated roster, which is an admin-only operation, not something to run per frame.
var saves = await BoardSaveGameManager.GetSaveGamesMetadata();
var uniquePlayers = saves.GetUniquePlayers(); // extension, profiles only
foreach (var player in uniquePlayers)
Debug.Log($"Player in saves: {player.name}");
const saves = await Board.save.list();
const uniquePlayers = Board.save.getUniquePlayers(saves); // profiles only
for (const player of uniquePlayers) {
console.log(`Player in saves: ${player.name}`);
}
Save and Quit integration
When the player picks Save and Quit from the pause screen, you get one window to persist before the app terminates. Save the current state, then quit. Create on the first save, update afterward.
void OnEnable()
{
BoardApplication.pauseScreenActionReceived += OnPauseAction;
}
async void OnPauseAction(BoardPauseAction action, BoardPauseAudioTrack[] tracks)
{
if (action == BoardPauseAction.ExitGameSaved)
{
await SaveCurrentGame();
BoardApplication.Exit();
}
}
async Task SaveCurrentGame()
{
byte[] payload = SerializeGameState();
var change = BuildMetadataChange();
if (string.IsNullOrEmpty(_currentSaveId))
{
var meta = await BoardSaveGameManager.CreateSaveGame(payload, change);
_currentSaveId = meta.id;
}
else
{
await BoardSaveGameManager.UpdateSaveGame(_currentSaveId, payload, change);
}
}
Board.pause.onResult(async (result) => {
if (result.action === "save_and_quit") {
await saveCurrentGame();
Board.application.quit();
}
});
async function saveCurrentGame() {
const payload = serializeGameState();
if (!currentSaveId) {
const meta = await Board.save.create(
saveDescription(), payload, totalPlayTimeMs, GAME_VERSION);
currentSaveId = meta.id;
} else {
await Board.save.update(
currentSaveId, saveDescription(), payload, totalPlayTimeMs, GAME_VERSION);
}
}
func _ready() -> void:
Board.pause.pause_result_received.connect(_on_pause_result)
func _on_pause_result(result: BoardPauseResult) -> void:
if result.action == Board.pause.ACTION_SAVE_AND_QUIT:
await _save_current_game()
Board.application.quit()
func _save_current_game() -> void:
var data := _serialize_game_state()
var played_ms := _total_played_ms()
if _current_save_id == "":
var meta: BoardSaveMetadata = await Board.save.await_create(
_save_description(), data, played_ms, GAME_VERSION)
if meta != null:
_current_save_id = meta.id
else:
await Board.save.await_update(
_current_save_id, _save_description(), data, played_ms, GAME_VERSION)
See Pause Menu for the full pause-flow context and the action constants.
Best practices
- Await each operation before starting the next. Only one save operation should be in flight at a time. Serialize them so failure handling stays deterministic.
- Check storage before saving. Compare your payload against the max-payload getter and surface a friendly message instead of a failed request.
- Handle the roster change after loading. A load rewrites the session players. In Unity subscribe to
playersChanged, in Godot listen forplayers_changed, in Web re-readgetPlayers(). - Warn before removing the last player. No-player saves are deleted silently with no recovery.
- Update, do not duplicate. Track the current save id and update it rather than creating a new save each time.
- Stamp the game version on every write. A build parsing a months-old save needs to know which schema it used.
See Also
- Player Management — the session roster and active profile that saves are scoped to
- Pause Menu — the Save and Quit flow
- Profile Switcher — reloading saves when the active profile changes
- Per-SDK API references: Unity, Godot, Web
</content> </invoke>