Skip to content
Back to skills

Summer World Saves

ASecurity

Keep a persistent Summer World between restarts: checkpoint saves, restore before join, the final save.

  • 77 stars
  • 0 votes
  • 0 copies
  • 0 views
  • Added October 16, 2026
ai-agentsgoshellnodetesting

Works with

  • claude code
  • cursor
  • cli
  • mcp

Security analysis

A100/100

Pro scans all 2 files and shows the line behind each finding

Scanned October 16, 2026

npx -y skills add SummerEngine/summer --skill summer-world-saves --agent claude-code

Installs into .claude/skills of the current project.

Are you the author of Summer World Saves?

Add the live security badge to your README. It updates with every re-scan.

Security grade badge for Summer World Saves
[![Security: A — Skills Directory](https://www.skillsdirectory.com/api/skills/summerengine-summer-world-saves-summer/badge)](https://www.skillsdirectory.com/skills/summerengine-summer-world-saves-summer)

More formats (shields.io, HTML) on the badges page. Keep it an A: scan every change in CI with Pro.

Download with Pro
SKILL.md
---
name: summer-world-saves
description: "Keep a persistent Summer World between restarts: checkpoint saves, restore before join, the final save."
license: MIT
compatibility: [Cursor, Claude Code, Windsurf, Codex]
category: multiplayer-and-networking
user-invocable: true
allowed-tools: Read Grep Edit Write summer_get_script_errors summer_project_setting summer_play summer_stop summer_get_diagnostics
paths: ["**/*.gd", "world.json", "summer.build.json"]
---

# /summer-world-saves — a World that is still there tomorrow

> **Preview.**
> - Verified in Local Play: restore of a new World, commit and receipt.
> - **Not** verified: restoring after a real restart. Local Play keeps saves in memory only, so only hosted play proves it.
> - Persistent Worlds need hosting support for your game. Check that it is available before you promise it to players.

## Overview

Most Summer Worlds are **match-scoped**: the match ends and its state is gone.
A **persistent** World is a place players come back to, such as a shared
base, a farm or a realm. Its authority can stop for maintenance, a crash or
a move. A new authority then restores the World from its last save.

| Keep | Use |
|---|---|
| What belongs to the place: built walls, chests, the coins left on the ground | **World save** (this skill) |
| What belongs to a player: their coins, unlocks, level | **Player data** (`summer-player-data`) |

The engine never serializes nodes for you. The game decides what one save
holds. It must be **one consistent cut**: if an item moves from a chest into
an inventory, both sides go in the same save.

This skill extends the Courtyard game from `multiplayer-state`: its
`match_doc` (the coins still on the ground, and the scoreboard) becomes the
World's saved state.

## Steps

### 1. Declare a persistent World

In `world.json`, change these fields and keep everything else:

```json
{
  "lifetime": "persistent",
  "settlement": { "mode": "none" },
  "persistence": {
    "mode": "checkpoint",
    "recovery_point_seconds": 60,
    "snapshot_schemas": ["courtyard.v1"],
    "migration_ids": []
  },
  "lifecycle": {
    "reconnect_seconds": 30,
    "drain_seconds": 120,
    "transfer": "disabled",
    "completion_owner": "world"
  }
}
```

- `recovery_point_seconds` (1 to 3600) is the most play you accept losing in a crash. Commit at least that often.
- `snapshot_schemas`: exactly one name for your save format in this release. There are no migrations.
- `drain_seconds`: 60 to 300.
- A save holds at most **16 MiB**.

### 2. Restore before anyone joins

Restore runs right after `Summer.initialize`, **before** waiting for
`binding_ready`. Players are admitted only after `finish_restore` succeeds,
so restore is part of becoming ready. Add to `authority/main.gd`:

```gdscript
const SAVE_SCHEMA := "courtyard.v1"      # the WorldDefinition's snapshot_schemas entry
const CHECKPOINT_SECONDS := 30.0         # at most the WorldDefinition's recovery_point_seconds
var saves: SummerAuthorityWorld          # Summer.authority.world: this World's save slot
var save_revision := "0"
var save_sequence := 0
var save_op: SummerWorldSaveOperation    # the commit in flight, if any
var restored := false
var world_dirty := false
var acknowledged: Array[String] = []
```

In `_ready()`, between `Summer.initialize` and the `binding_ready` wait:

```gdscript
	# Restore before waiting for binding_ready: players are admitted only
	# after the World confirms its restore.
	if not await _restore_world():
		push_error("could not restore this World; not starting")
		return
	var checkpoints := Timer.new()
	checkpoints.wait_time = CHECKPOINT_SECONDS
	checkpoints.timeout.connect(_on_checkpoint_timer)
	add_child(checkpoints)
	checkpoints.start()
	if not world.is_ready():
		await world.binding_ready
```

Create the shared `match` State group **after** the restore, so it starts
from the restored document.

```gdscript
## Loads the World's last checkpoint, or starts fresh when it has none.
func _restore_world() -> bool:
	saves = Summer.authority.world
	saves.save_requested.connect(_on_save_requested)
	# Local Play binds the World on the frame after initialize; let that
	# frame pass before loading.
	await get_tree().process_frame
	var loading := saves.load_save()
	var result: SummerResult = await loading.get_result_or_completed_signal()
	if not result.ok:
		return false   # never treat a failed load as an empty World
	var saved := loading.get_save()
	if saved.has_save():
		var doc: Variant = JSON.parse_string(saved.get_data().get_string_from_utf8())
		if not doc is Dictionary or not doc.has_all(["coins", "scores"]):
			return false
		match_doc = doc
	save_revision = saved.get_revision()
	save_sequence = int(saved.get_state_sequence())
	result = await saves.finish_restore(saved).get_result_or_completed_signal()
	if not result.ok:
		return false
	restored = true
	# A save request may have arrived before this point.
	var pending := saves.get_snapshot_request_id()
	if not pending.is_empty():
		_on_save_requested(pending, saves.get_snapshot_deadline_unix_msec())
	return true
```

**A failed or corrupt load must stop the World.** Starting fresh instead
would overwrite the players' place with an empty one on the next save.

### 3. Checkpoint while playing

Mark the World dirty wherever saved state changes. Here that is after
`match_group.reset(...)` in `_collect`:

```gdscript
	world_dirty = true
```

```gdscript
func _on_checkpoint_timer() -> void:
	if world_dirty:
		_checkpoint()


## Commits one consistent copy of the World. One save at a time; a retry
## resends exactly the same five arguments.
func _checkpoint() -> SummerWorldSaveReceipt:
	while save_op != null:
		await save_op.get_result_or_completed_signal()
	world_dirty = false
	save_sequence += 1
	var sequence := str(save_sequence)
	var save_id := "cut-" + sequence
	var bytes := JSON.stringify(match_doc).to_utf8_buffer()
	var expected := save_revision
	for attempt in 3:
		save_op = saves.commit_save(expected, save_id, SAVE_SCHEMA, bytes, sequence)
		var result: SummerResult = await save_op.get_result_or_completed_signal()
		var receipt := save_op.get_receipt()
		save_op = null
		if result.ok:
			save_revision = receipt.get_revision()
			return receipt
		if not result.retryable:
			# Conflict, corrupt, incompatible or fenced: stop, never overwrite.
			push_error("world save failed: %s" % result.code)
			break
	world_dirty = true
	return null
```

The rules behind it:

- **One save at a time.** `commit_save` takes the revision you expect to replace, so two overlapping saves would conflict.
- **Capture once, retry exactly.**
  - On retryable `world_save_unavailable`, resend the same expected revision, `save_id`, schema, bytes and sequence.
  - A lost reply may hide a commit that already succeeded.
- **The sequence only grows.** It continues from the restored save's `state_sequence`, so `save_id`s never repeat across restarts.
- **Stop on non-retryable codes.** `world_save_conflict`, `world_save_corrupt`, `world_save_incompatible`, `world_restore_required` and `runtime_fenced` mean this authority must not keep writing.

### 4. Answer the final save request

Before Summer stops, restarts or moves the World, it emits `save_requested`
with a deadline. Commit a fresh cut, then acknowledge it with that receipt:

```gdscript
## The platform asks for a final save before it stops or moves this World.
## Commit a fresh cut, then acknowledge it. Never wait for timers or frames
## here: gameplay is frozen while the final save runs.
func _on_save_requested(request_id: String, _deadline_unix_msec: int) -> void:
	if not restored or request_id in acknowledged:
		return
	acknowledged.append(request_id)
	var receipt := await _checkpoint()
	if receipt == null:
		return   # never acknowledge a save that did not commit
	await saves.finish_snapshot(request_id, receipt).get_result_or_completed_signal()
	# This runtime may stop right here. Put nothing important after it.
```

- During the final save, scene processing and timers are frozen; only save operations complete. Never `await` a timer or a frame in this path.
- A crash delivers no request, so periodic checkpoints are still required.
- For a planned shutdown, `saves.shutdown_notice_changed` gives advance notice; a `null` notice means it was cancelled. Use it to warn players ("Server restarts in 5 minutes") and to finish events that are in progress.

### 5. Bring players back to the same World

A player returns to a persistent World by its id:

```gdscript
var join := Summer.client.join(SummerJoinTarget.world(world_id))
var joined: SummerResult = await join.get_result_or_completed_signal()
```

Read the id with `Summer.get_world().world_id` after the first join, and keep
it, for example in the player's public player data. A sleeping World wakes
and restores before the player is admitted.

Never call `saves.complete()` to "save and stop". It ends the World
permanently.

### 6. Test in Local Play

Local Play runs a persistent World with an **in-memory** save store:

- Every run starts with no save, so `has_save()` is false.
- Commits succeed and return receipts.
- Nothing survives the run.

```
summer_project_setting key="summer/local_play/players" value=2
summer_play
summer_get_diagnostics
summer_stop
```

Add a marker after restore, such as `print("WORLD_RESTORED has_save=", saved.has_save())`,
and one after each successful commit. Then check:

- the restore marker appears before any player joins;
- a checkpoint commits after state changes;
- there are no `world_save_*` errors.

Restoring after a real restart can only be proven hosted.

## Checklist

- [ ] `world.json` declares `lifetime: persistent` and a `checkpoint` persistence block with one schema.
- [ ] The authority restores before awaiting `binding_ready`, and a failed load stops it.
- [ ] Checkpoints run at least every `recovery_point_seconds` while the World changes.
- [ ] Only one `commit_save` is in flight; retries reuse all five arguments.
- [ ] `save_requested` commits a fresh cut, then calls `finish_snapshot` with that receipt, without awaiting timers.
- [ ] Player progress lives in player data, not in the World save.

## Common mistakes

| Don't | Do | Why |
|---|---|---|
| Await `binding_ready` before restoring | Restore first, then await `binding_ready` | Readiness waits for the restore, so the World never becomes ready |
| Call `load_save()` in the same frame as `initialize` | Await one `process_frame` first | Local Play binds the World a frame later; the load fails with `world_save_unavailable` ("no active SummerNetworkWorld binding") |
| Start an empty World when the load fails | Stop and report | The next checkpoint would overwrite the real World |
| Save only on shutdown | Checkpoint every `recovery_point_seconds` | Crashes deliver no save request |
| Start a second save while one is pending | Serialize saves | Both expect the same revision; one conflicts |
| Recapture or mint a new `save_id` after a lost reply | Retry the same five arguments | The first commit may have succeeded |
| Await a timer inside the final save | Await only save operations | Timers are frozen during final saving |
| Put player progress in the World save | Player data | It must follow the player to other Worlds |
| Call `complete()` to sleep the World | Let the platform stop it after the final save | `complete()` ends the World forever |

## See also

- `summer-player-data` — progress that follows a player
- `multiplayer-state` — the shared document this skill saves
- `multiplayer-project` — `world.json` and the authority scene
- `multiplayer-testing` — Local Play runs and markers

<!-- shell-commands: generated from the operation catalog by npm run generate:registry; do not edit by hand -->
## From a shell

No Summer MCP tools in this session? Run each one as a command; when `summer --version` is missing or below 4.0, use `npx -y summer-engine@4.3.0` in place of `summer`. Fields are flags in kebab-case (`--scene-path res://main.tscn`) or one `--input '<json>'`, and `summer ops describe <op>` prints the exact input. The `summer-cli` skill has the rest.

| In this skill | Command |
|---|---|
| `summer_get_diagnostics` | `summer engine get-diagnostics` |
| `summer_get_script_errors` | `summer engine get-script-errors` |
| `summer_play` | `summer engine play` |
| `summer_project_setting` | `summer engine project-setting` |
| `summer_stop` | `summer engine stop` |
<!-- /shell-commands -->

Files in this skill

  • SKILL.md12.2 KB
  • resource.yaml859 B

Attribution

Is this your skill, or is something wrong with this listing? Request removal or report an issue. Author removals are honored within 72 hours.

Comments

Loading comments…

Newsletter

Get the weekly list of safe new skills, and the ones we pulled.