# SpawnWeaver — complete documentation Everything below is the official SpawnWeaver documentation as one markdown document, intended for AI assistants and offline reading. Canonical per-page URLs: https://spawnweaver.dev/dashboard/docs/{slug}. --- # Quickstart (Getting started) Get from an empty Godot project to two players moving on screen in under five minutes. No backend to write, no servers to configure — one autoload, one key, one node. ## What you need - **Godot 4.3+** - A SpawnWeaver project key (`pk_…`) — free, from the [dashboard](/dashboard) ## 1. Get a project key Sign up at the [dashboard](/dashboard), create a project, and copy its **public key** (`pk_…`). The public key is safe to ship inside your game — it identifies your project, nothing more. (Never put the `sk_…` secret key in a game client.) ## 2. Install the SDK From your Godot project root (the folder with `project.godot`): ```powershell iwr https://spawnweaver.dev/install.ps1 -UseBasicParsing | iex # Windows ``` ```bash curl -fsSL https://spawnweaver.dev/install.sh | bash # macOS / Linux ``` This downloads the addon into `addons/spawnweaver/`. See [Installation](/dashboard/docs/installation) for manual install and upgrade options. ## 3. Enable and configure the plugin 1. In Godot: **Project → Project Settings → Plugins → enable "SpawnWeaver"**. 2. Open the **SpawnWeaver dock** (right panel). 3. Click **Get a free key** — your browser opens, you approve (signing up right there if you're new), and the key appears in the dock by itself: saved and connection-tested, nothing to copy. Already have a key? Paste it into **Project key**, click **Save**, then **Test connection**. Either way the dock writes `res://spawnweaver.cfg` and the autoload reads it automatically — no code needed for configuration. ## 4. Add multiplayer ### Option A — generated starter scene (zero code) In the SpawnWeaver dock, click **Generate starter scene**. It creates `res://spawnweaver/StarterGame.tscn`: a ready-to-run scene with a Create room button, a Join field, and a movable square per player, already synced over the network. ### Option B — drop-in lobby node (zero code) Add a **LobbyBrowser** node to your menu scene (Create Node dialog → LobbyBrowser). It renders a full lobby — name field, Create room, Quick match, join by code, live public-room list — and connects on its own. Hook one signal to enter your game: ```gdscript $LobbyBrowser.entered_room.connect(func(room): get_tree().change_scene_to_file("res://arena.tscn")) ``` ### Option C — ten lines of GDScript ```gdscript extends Node func _ready() -> void: await SpawnWeaver.start() var result := await SpawnWeaver.create_room() if result.ok: print("Room code: ", result.room.code) # share this with player 2 # Player 2 runs this instead of create_room(): # await SpawnWeaver.join_room("4V8772") ``` That is a working multiplayer session. For player movement you usually need **zero further networking code**: drop a `PlayerSpawner` node into your level, assign your player scene in the Inspector, and put a `SpawnSync` node inside that player scene. The local copy sends its transform; remote copies interpolate smoothly. See [State sync](/dashboard/docs/sync). ## 5. Run two players 1. In the SpawnWeaver dock, click **Playtest ×2** — two game windows open, each with its own player identity. (Godot's own **Debug → Run Multiple Instances** works too.) 2. In window one, create a room and note the code (e.g. `4V8772`). 3. In window two, join with that code. Both squares move, each controlled by its own window. You have multiplayer. ## No internet? No account yet? You don't need either to start building. `SpawnWeaver.start_offline()` runs a complete local session in-process — rooms, state sync, storage, the same signals and awaits — with no server and no key. Build your whole multiplayer flow offline, then swap one line (`start_offline()` → `start()`) when you're ready to go online. See [Offline mode](/dashboard/docs/offline-mode). ## Where to go next | Goal | Read | |---|---| | Understand the moving parts | [Core concepts](/dashboard/docs/concepts) | | Rooms, codes, and rosters | [Rooms](/dashboard/docs/rooms) | | Automatic movement sync | [State sync](/dashboard/docs/sync) | | Skill-free matchmaking | [Matchmaking](/dashboard/docs/matchmaking) | | Persistent player saves | [Player storage](/dashboard/docs/storage) | | Full API reference | [SDK reference](/dashboard/docs/reference-sdk) | --- # Installation (Getting started) The SpawnWeaver SDK is a single Godot addon: `addons/spawnweaver/`. Install it with the one-line script or by copying the folder, enable the plugin, and paste your project key. ## Requirements | Requirement | Version | |---|---| | Godot | 4.3 or newer | | Renderer / platform | Any — the SDK is pure GDScript over WebSockets | ## Install methods ### One-line installer (recommended) Run from your Godot project root — the folder that contains `project.godot`: ```powershell iwr https://spawnweaver.dev/install.ps1 -UseBasicParsing | iex # Windows ``` ```bash curl -fsSL https://spawnweaver.dev/install.sh | bash # macOS / Linux ``` The script downloads the packaged addon (`/sdk/spawnweaver.zip`) from the server and extracts it into `addons/spawnweaver/`. ### Manual install Copy the `addons/spawnweaver/` folder from the [SDK repository](https://spawnweaver.dev) into your project so you end up with: ``` your-game/ project.godot addons/ spawnweaver/ spawnweaver.gd # the autoload — the whole client API plugin.gd / plugin.cfg models/ # SWResult, SWError, SWPlayer, SWRoom, SWRoomSummary nodes/ # SpawnSync, PlayerSpawner editor/ # the SpawnWeaver dock templates/ # starter scene generated by the dock ``` ## Enable the plugin **Project → Project Settings → Plugins → enable "SpawnWeaver"**. Enabling the plugin registers three things: | What | Details | |---|---| | Autoload | `SpawnWeaver` at `/root/SpawnWeaver` — the entire client API | | Editor dock | Right panel: key/server config, Test connection, live counts, starter scene generator | | Node types | `SpawnSync` and `PlayerSpawner`, available in the Create Node dialog | ## Configure Open the **SpawnWeaver dock**, paste your project key (`pk_…` from the [dashboard](/dashboard)), and click **Save**, then **Test connection**. The dock has three fields: | Field | Meaning | |---|---| | Project key | Your `pk_…` public key | | Server | WebSocket URL. Leave empty for the hosted service (`wss://spawnweaver.dev/connect`); use `ws://127.0.0.1:5159/connect` for a local server | | Environment | A label for your own bookkeeping: `production`, `staging`, or `development` | ### The config file: `res://spawnweaver.cfg` The dock writes a plain Godot `ConfigFile` at your project root: ```ini [spawnweaver] project_key="pk_your_public_key" server_url="wss://spawnweaver.dev/connect" environment="production" ``` - `project_key` — required. The public key is safe to ship and safe to commit; add the file to `.gitignore` only if you prefer to keep keys out of a public repo. - `server_url` — optional; defaults to the hosted service. - `debug` — optional boolean; `true` turns on SDK console logging (same as `SpawnWeaver.set_debug_enabled(true)`). The file lives at the project root (not inside `addons/`) so addon upgrades never touch it, and the dock never overwrites a custom server URL. ### Configuration resolution order When `SpawnWeaver.start()` runs, values are resolved in this order — first hit wins: 1. `SpawnWeaver.configure("pk_…", "wss://…")` called in code before `start()` 2. `user://spawnweaver_override.cfg` — per-machine override used by local tooling and tests; never ship this file 3. `res://spawnweaver.cfg` — the file the dock writes 4. Built-in default server URL (`wss://spawnweaver.dev/connect`) ```gdscript # Optional: skip the config file entirely and configure in code. SpawnWeaver.configure("pk_your_public_key", "ws://127.0.0.1:5159/connect") await SpawnWeaver.start() ``` ## Upgrading Re-run the one-line installer from your project root. It replaces `addons/spawnweaver/` with the latest version; your `res://spawnweaver.cfg` and any scenes using `SpawnSync`/`PlayerSpawner` are untouched. After upgrading, restart the editor so the plugin reloads cleanly. ## Verify the install 1. The **SpawnWeaver dock** appears in the right panel. 2. **Test connection** in the dock reports success. 3. From any script: `print(SpawnWeaver.SDK_VERSION)` prints the SDK version. If the dock is missing or scripts report `SpawnWeaver` as undeclared, the plugin is not enabled — see [Troubleshooting](/dashboard/docs/troubleshooting). ## Next - [Quickstart](/dashboard/docs/quickstart) — two players moving in under five minutes - [Core concepts](/dashboard/docs/concepts) — the mental model behind the API --- # Core concepts (Getting started) SpawnWeaver has a small number of moving parts. This page gives you the mental model; each concept links to a guide with the details. ## Project and keys A **project** is one game. It has two keys: | Key | Where it lives | What it can do | |---|---|---| | Public key `pk_…` | Inside your game client (`spawnweaver.cfg`) | Open realtime connections; identify the project. Safe to ship. | | Secret key `sk_…` | Your own servers/tools only — **never in the game** | Full HTTP access to every player's storage; shown once at creation. | Game clients only ever need the public key. If a secret key leaks, regenerate it from the dashboard. ## Player identity Players are **anonymous-first**: no accounts, no login screens. The first time a client connects, the server mints a new player id (`player_…`) and hands back a signed **player token**. The SDK persists that token in `user://` and presents it on every later connect — so `SpawnWeaver.player.id` is stable across app restarts. A fresh token is issued on each successful connect (sliding expiration). ```gdscript await SpawnWeaver.start() print(SpawnWeaver.player.id) # same id tomorrow, on this machine SpawnWeaver.set_display_name("Ada") # what other players see ``` Identity is scoped per project (and per server URL), so one machine has an independent identity in each of your games. See [Players & identity](/dashboard/docs/players). ## Connection and session `await SpawnWeaver.start()` opens one WebSocket to the server and resolves when the session is welcomed. From then on the SDK: - keeps `SpawnWeaver.status` up to date (`OFFLINE` / `CONNECTING` / `ONLINE`), - heartbeats and measures `SpawnWeaver.latency_ms`, - **reconnects forever** after drops (exponential backoff, capped at 30s) until you call `SpawnWeaver.stop()`. Every request method is awaitable and returns an `SWResult` — `result.ok`, `result.value` (or a typed accessor like `result.room`), and `result.error`. You never need to connect a signal to learn a request's outcome. ## Rooms A **room** is where players meet: it has a short join **code** (e.g. `4V8772`), a roster, a **host**, and optional attributes (`name`, `max_players`, `metadata`). - **Visibility**: `"private"` rooms are joinable by code only; `"public"` rooms also appear in `list_rooms()` and can be joined by id. A "lobby" is just a public room. - **One room per connection** — you are in at most one room at a time. - **Host**: the creator (or first matched player). Only the host can `update_room()` and write room-level state. If the host disconnects or leaves, the server promotes the earliest-joined connected player — **host migration** is automatic. - **Disconnect grace**: if a player's connection blips, their seat (and their entities) are kept for a grace window (default 60s). They resume exactly where they were. `SpawnWeaver.room` is maintained for you: roster, host, code, metadata, and live state. See [Rooms](/dashboard/docs/rooms). ## Matchmaking Instead of sharing codes, players can queue: `await SpawnWeaver.find_match("duel")` resolves when the server has filled a bucket of players with the same mode, region, and size — the result is a normal room with a host. See [Matchmaking](/dashboard/docs/matchmaking). ## Events vs. state sync Two ways to communicate inside a room — use both, for different jobs: | | Events | State sync | |---|---|---| | Call | `send_event("fired", {...})` | `set_entity()` / `patch_entity()` / `set_room_state()` | | Nature | Transient message, relayed once | Persistent value, kept by the server | | Who receives | Other room members (not you) | Everyone, including you (as the ack) | | Late joiners | Never see past events | Get the full state snapshot on join | | Good for | Shots, chat, emotes, "round started" pings | Positions, health, scores, game phase | **Rule of thumb:** if a player joining late needs to know it, it's state. If only players present right now care, it's an event. State has two levels: **room state** (one shared object, host-writable) and **entities** (per-object state, writable only by the owning player). The `SpawnSync` node automates entity sync for transforms. See [State sync](/dashboard/docs/sync) and [Events](/dashboard/docs/events). ## Player storage Per-player key-value storage that persists across sessions — save games, unlocks, settings — with no server of your own: ```gdscript await SpawnWeaver.storage_set("save", {"level": 3, "gold": 120}) var save := await SpawnWeaver.storage_get("save") ``` The same data is reachable over HTTP (with the secret key, or the player's own token) for tooling and dashboards. See [Player storage](/dashboard/docs/storage). ## The debugger When something goes wrong, the dashboard explains *why*: - **Session inspector** — a per-connection timeline: connect, auth, every action, every rejection, the disconnect reason. - **Error explorer** — protocol errors aggregated by code, each with a suggested fix. - **Debug bundle viewer** — paste a client's `SpawnWeaver.create_debug_report_string()` output to inspect its state offline. See [Debugging](/dashboard/docs/debugging). ## Putting it together A typical casual game uses: `start()` once at boot → a menu that calls `create_room()` / `join_room()` / `find_match()` → a level with a `PlayerSpawner` (avatars + movement sync, zero code) → `send_event()` for one-shot actions → `set_room_state()` from the host for the game phase → `storage_set()` for progression. That's the whole stack. --- # Adding multiplayer to your game (Getting started) Every other page explains one feature. This one is the map: what you actually do, in order, to get a multiplayer game shipped — whether you're starting fresh or adding multiplayer to a game you've already built. ## 1. Connect the editor (about 3 minutes) Identical for every project: 1. **Install the addon** — one line in a terminal at your project root: ```powershell iwr https://spawnweaver.dev/install.ps1 -UseBasicParsing | iex # Windows ``` ```bash curl -fsSL https://spawnweaver.dev/install.sh | bash # macOS / Linux ``` 2. **Enable the plugin** — Project Settings → Plugins → SpawnWeaver. The `SpawnWeaver` autoload registers itself; that's your whole API surface. 3. **Click "Get a free key"** in the SpawnWeaver dock. Your browser opens, you approve (signing up right there if you're new), and the key appears back in the editor — saved and connection-tested. Nothing to copy or paste. No server to deploy, no backend code, no account needed until step 3. If you'd rather not sign up yet, `SpawnWeaver.start_offline()` runs the whole API locally with no account at all — see [Offline mode](/dashboard/docs/offline-mode). ## 2a. Starting a new game Click **Generate starter scene** in the dock, then **Playtest ×2**. Two windows open; one creates a room, the other joins with the code, and two characters move around in sync. You now have a working multiplayer game to modify instead of a blank file to fill. Everything in it uses the same public API you'd write yourself — read it, delete the parts you don't want, keep the calls. ## 2b. Adding multiplayer to an existing game The realistic case, and the one SpawnWeaver is shaped around: **your single-player code keeps working**. You add nodes, not rewrites. **Give players a way in.** Drop a `LobbyBrowser` node into your menu scene. You get quick match, a live list of open games, and join-by-code, styled by your project's theme. Connect one signal: ```gdscript $LobbyBrowser.entered_room.connect(func(room): get_tree().change_scene_to_file("res://level.tscn")) ``` Prefer your own menu? Call the API directly — `find_match()`, `create_room()`, `join_room(code)` — see [Rooms](/dashboard/docs/rooms) and [Matchmaking](/dashboard/docs/matchmaking). **Spawn everyone.** Add a `PlayerSpawner` to your level and point it at your existing player scene. It creates one avatar per player, removes them on leave, and re-syncs correctly after a reconnect. **Replicate movement.** Put a `SpawnSync` node *inside* your player scene. The local copy sends its transform; remote copies interpolate smoothly. Your movement script doesn't change at all — this is the step where the game becomes multiplayer. **Guard your input with window focus.** Godot's `Input.is_physical_key_pressed()` and `get_global_mouse_position()` read *global OS state*, not per-window input — so two test windows will both react to one keypress unless you check: ```gdscript func _physics_process(delta): if not get_window().has_focus(): return # another instance owns the keyboard right now # ... your normal movement code ``` Every multiplayer game needs this, and it surprises almost everyone the first time they run two windows. ## 3. Decide what your game shares This is the part no SDK decides for you, and getting it right early saves rewrites. Three buckets: | Kind of data | Where it goes | Late joiners see it? | |---|---|---| | Transient moments — a shot, an explosion, a chat line | **Events** (`send_event`) | No | | Lasting truth — scores, match phase, who's ready | **Room state** (`set_room_state`) | Yes, automatically | | Per-player facts — position, health, cosmetics | **Entities** (`SpawnSync` / `set_entity`) | Yes, automatically | | Saves that outlive the match — unlocks, settings | **Storage** (`storage_set`) | N/A (per player, permanent) | The classic mistake is putting the scoreboard in events: it works perfectly until someone joins late and sees 0–0 forever. If it must survive a join, it's state. **Who decides what?** SpawnWeaver relays messages; it never runs your game logic. The normal pattern: - **Each player owns their own entity** — their position and health. Nobody else writes it (the server enforces this). - **The host owns shared truth** — the scoreboard, the match timer. Check `SpawnWeaver.is_host`, and let host migration hand it to someone else when the host leaves. The arena tutorials use client-authoritative hits (the shooter decides). That's right for co-op and casual games, and wrong for competitive ones — see [Best practices](/dashboard/docs/best-practices) for the trade-offs. ## 4. Test like it's real - **Playtest ×2/×3/×4** in the dock — each window gets its own player identity automatically. - **Kill a window mid-match and relaunch it.** The SDK reconnects on its own and resumes the same room inside the 60-second grace window. Make sure your UI handles the `reconnecting` and `room_left` signals instead of freezing. - **Watch the [debugger](/dashboard/docs/debugging)** while you play: live rooms, every player's entity state updating in real time, per-session timelines, and errors with suggested fixes. ## 5. Ship 1. **Create a separate production project** so live players never share rooms or storage with your test builds. One project per environment. 2. **Export normally.** Your public key ships inside the build (that's what it's for — it's public by design), and the plugin bundles the config automatically. Desktop and web exports both work; see the [FAQ](/dashboard/docs/faq) for the web caveats. 3. **Re-read [Limits & quotas](/dashboard/docs/limits)** against your real traffic — send rates, entity counts, storage sizes — and fix anything over budget before players find it. 4. **Expecting a launch spike?** Tell us ahead of time and we'll size your project's limits for the day. ## How long this actually takes | Goal | Realistic time | |---|---| | Two players moving in a fresh project | Under 5 minutes | | Synced movement in an existing game with a working player scene | ~30 minutes | | A polished, shipped multiplayer game | Weeks — but spent on *your game*, not on netcode, servers, or sockets | That last row is the honest one. SpawnWeaver removes the infrastructure and the plumbing; it doesn't remove game design. What you save is the month you'd otherwise spend building and operating a backend. ## Where to go next | You want to | Read | |---|---| | Follow a complete game start to finish | [2D arena tutorial](/dashboard/docs/tutorials-2d-arena) · [3D arena](/dashboard/docs/tutorials-3d-arena) | | Understand rooms, hosts, and grace windows | [Rooms](/dashboard/docs/rooms) | | Sync more than transforms | [State sync](/dashboard/docs/sync) | | Send hits, chat, or abilities | [Events](/dashboard/docs/events) | | Save player progress | [Storage](/dashboard/docs/storage) | | Add scoreboards | [Leaderboards](/dashboard/docs/leaderboards) | | Let friends join from Steam | [Steam](/dashboard/docs/steam) | | Work without an account or network | [Offline mode](/dashboard/docs/offline-mode) | --- # Rooms (Guides) Rooms are where players meet. Every room has a short join code, a roster, and a host. This guide covers the full room lifecycle and the events that keep your UI in sync. ## The zero-code option: LobbyBrowser Before writing any lobby code, know that you may not have to: drop a **LobbyBrowser** node (Create Node dialog → LobbyBrowser) into a scene and you get a complete lobby — player name, **Create room**, **Quick match**, join by code, and a live list of public rooms. Connect one signal and it's done: ```gdscript $LobbyBrowser.entered_room.connect(func(room): get_tree().change_scene_to_file("res://arena.tscn")) ``` It's built from standard Controls, so your project's `Theme` restyles it, and the Inspector exports cover the common tweaks (quick-match size, default room size, hide-on-join, which widgets to show). When your game outgrows it, everything it does is public SDK API — the rest of this page. ## Create a room ```gdscript await SpawnWeaver.start() var result := await SpawnWeaver.create_room() if result.ok: show_code(result.room.code) # e.g. "4V8772" — share with friends ``` All options are optional: ```gdscript var result := await SpawnWeaver.create_room({ "name": "Arena", # display name "visibility": "public", # "private" (default) or "public" "max_players": 4, # omitted = unlimited "metadata": {"mode": "ffa"}, # your own data, visible to joiners and in listings }) ``` The creator becomes the **host**. Creating fails with `already-in-room` if you are already in a room — leave it first. ## Join and leave ```gdscript var result := await SpawnWeaver.join_room("4V8772") if not result.ok: match result.error.code: "room-not-found": say("No such room — check the code.") "room-full": say("That room is full.") _: say(result.error.message) await SpawnWeaver.leave_room() ``` `join_room()` accepts a share code (works for private and public rooms) or a public room's `id` from `list_rooms()`. Note: `max_players` counts *briefly disconnected* members too — a seat inside the grace window is still a seat. ## Private vs. public, and listing - **Private** (default): joinable only by someone who has the code. - **Public**: also appears in listings and can be joined by id — this is your lobby browser. ```gdscript var result := await SpawnWeaver.list_rooms(20) if result.ok: for summary in result.rooms: # Array of SWRoomSummary print("%s %d/%s players" % [ summary.name if not summary.name.is_empty() else summary.code, summary.player_count, "∞" if summary.max_players == 0 else str(summary.max_players)]) if summary.has_space(): add_join_button(summary.id) # join_room(summary.id) works too ``` Listings return the project's public rooms, most recently active first. ## The roster: `SpawnWeaver.room` While you are in a room, `SpawnWeaver.room` is an `SWRoom` kept up to date automatically — you never mutate it yourself: ```gdscript var room := SpawnWeaver.room room.code # "4V8772" room.players # Array[SWPlayer] — the full roster, including you room.host() # the host's SWPlayer (or null) room.other_players() # everyone except you room.get_player(id) # lookup by player id room.metadata # your developer metadata SpawnWeaver.is_host # true when you are the host ``` React to roster changes with signals (they never echo yourself): ```gdscript SpawnWeaver.player_joined.connect(func(p): add_row(p)) SpawnWeaver.player_left.connect(func(p, reason): remove_row(p.id)) SpawnWeaver.room_joined.connect(func(room): rebuild_ui(room)) # you entered a room SpawnWeaver.room_left.connect(func(reason): show_menu()) # you left ("left"/"disconnected"/"expired") ``` `room_joined` fires on create, join, matchmaking, **and automatic resume after a reconnect** — treat it as "rebuild everything from `room`", not "the join button was clicked". Rebuild idempotently from `room.players`. ## Host and host migration Every room always has a host — `room.host_id` — including matchmade rooms (the first matched player). Only the host may call `update_room()` and `set_room_state()`. Migration is automatic: when the host leaves *or disconnects*, the earliest-joined connected player is promoted immediately (the game never waits out the grace window to write room state). If the ex-host returns, they come back as a regular member. ```gdscript SpawnWeaver.host_changed.connect(func(new_host): if SpawnWeaver.is_host: take_over_game_logic()) # you were promoted ``` ## Updating a room (host only) ```gdscript var result := await SpawnWeaver.update_room({"max_players": 6, "name": "Arena II"}) ``` Only the fields you pass change — except `metadata`, which **replaces the whole map**. Non-hosts get `not-host`. All members receive `room_updated(room)`. ## Room metadata `metadata` is a free-form dictionary for anything joiners should see before or after joining: game mode, map name, difficulty. It appears in `SWRoomSummary` entries too, so your lobby browser can filter on it. It is not gameplay state — use [room state](/dashboard/docs/sync) for values that change during play. ## The disconnect grace window When a member's connection drops, they are **not** removed immediately: 1. Their roster entry stays, with `connected = false`. You receive `player_disconnected(p)` — grey out their row, pause their turn. 2. If they return within the grace window (default **60s**) — by token reconnect or by rejoining with the code — their seat and their entities are restored. You receive `player_reconnected(p)`. 3. If the window expires, you receive `player_left(p, "disconnected")` and their entities are removed (`entity_removed(id, "owner-left")`). ```gdscript SpawnWeaver.player_disconnected.connect(func(p): mark_row_ghosted(p.id)) SpawnWeaver.player_reconnected.connect(func(p): mark_row_active(p.id)) ``` Your own side of this is symmetric: if *your* connection blips, the SDK reconnects and the server puts you straight back into the room — `room_joined` fires again with the fresh roster and state. Rooms with no connected members expire after a short TTL (default 60s); members still in grace then receive `room_left("expired")`. ## Common mistakes | Mistake | What happens | Fix | |---|---|---| | Calling `create_room()`/`join_room()` before `start()` | `SWResult` fails with `not-connected` | `await SpawnWeaver.start()` first (once, at boot) | | Creating/joining while already in a room | `already-in-room` | One room per connection — `await SpawnWeaver.leave_room()` first | | Treating `room_joined` as "user clicked join" | Duplicate UI/avatars after a reconnect resume | Rebuild idempotently from `SpawnWeaver.room` each time it fires | | Removing players on `player_disconnected` | Players vanish during a 2-second Wi-Fi blip | Grey them out; only remove on `player_left` | | Assuming the host never changes | Host-only calls start failing with `not-host` after migration | Watch `host_changed` and check `SpawnWeaver.is_host` before host-only calls | | Passing partial `metadata` to `update_room()` | The rest of the metadata is erased | `metadata` replaces the whole map — send the complete dictionary | ## Next - [Matchmaking](/dashboard/docs/matchmaking) — fill rooms automatically - [State sync](/dashboard/docs/sync) — room state and entities - [Events](/dashboard/docs/events) — transient messages between members --- # Matchmaking (Guides) Matchmaking pairs players who queue with the same mode, region, and match size, then drops them all into a freshly created room. One awaitable call takes you from "Find match" to "in a room with opponents". ## Find a match ```gdscript var result := await SpawnWeaver.find_match("duel") if result.ok: start_game(result.room) # a normal SWRoom — roster, code, host, state else: match result.error.code: "match-timeout": say("Nobody around right now — try again.") "cancelled": pass # the player backed out _: say(result.error.message) ``` `find_match(mode, options)` queues a ticket and **resolves when the match is found** — which can take seconds, or as long as the server-side timeout. The await handles the whole wait; there is no polling. ## Modes, regions, and sizes Matching is exact: players are bucketed by project + `mode` + `region` + `size`, and a room is created the moment a bucket fills. First-come, first-served. ```gdscript await SpawnWeaver.find_match("duel") # size 2, global await SpawnWeaver.find_match("ffa", {"size": 4}) # 4-player bucket await SpawnWeaver.find_match("ranked", {"region": "eu", "size": 2}) # region-scoped ``` | Option | Default | Notes | |---|---|---| | `mode` | `"default"` | Any string without `\|`. Your own vocabulary: `"duel"`, `"coop"`, … | | `region` | `"global"` | Any string without `\|`. Only players in the same region match. | | `size` | `2` | 2–64 players. The room is created when exactly this many are queued. | Players with *different* modes, regions, or sizes never match each other — a player searching `{"size": 2}` and one searching `{"size": 3}` sit in separate buckets. Keep your option matrix small, or players will wait. ## Cancelling ```gdscript SpawnWeaver.cancel_matchmaking() ``` Cancelling makes any awaited `find_match()` resolve immediately with error code `cancelled`, and removes the server-side ticket. Disconnecting also removes the ticket. Calling `find_match()` again while searching simply **replaces** the previous ticket (the earlier await resolves with the newer outcome pathway — don't run two searches in parallel; there is one ticket per connection). ## Timeouts If no bucket fills within the server's matchmaking timeout (default **30 seconds**, deployment-configurable), the ticket expires and `find_match()` resolves with `match-timeout`. The error is marked `retryable` — offer a "Search again" button, or retry automatically with a message: ```gdscript func search_forever() -> void: while true: var result := await SpawnWeaver.find_match("duel") if result.ok: start_game(result.room) return if result.error.code != "match-timeout": return # cancelled, disconnected, … say("Still searching…") ``` The SDK also applies its own client-side guard (120s) so an await can never hang forever, even if the server reply is lost — that surfaces as a retryable `timeout`. ## A matched room is a normal room The result of `find_match()` is a regular `SWRoom`: - It has a **host** — the first matched player — with all the usual host powers (room state, `update_room()`), and normal [host migration](/dashboard/docs/rooms). - It has a join code, so a disconnected player can rejoin within the grace window. - `room_joined` fires, `PlayerSpawner` spawns avatars, `SpawnSync` starts syncing — everything from the [Rooms](/dashboard/docs/rooms) guide applies unchanged. You cannot search while in a room — `find_match()` fails with `already-in-room`. `leave_room()` first (e.g. for a "requeue" button after a match). ## UI pattern: searching spinner with cancel ```gdscript func _on_find_match_pressed() -> void: _spinner.visible = true _cancel_button.visible = true var result := await SpawnWeaver.find_match("duel") _spinner.visible = false _cancel_button.visible = false if result.ok: get_tree().change_scene_to_file("res://levels/arena.tscn") elif result.error.code == "match-timeout": _status.text = "No opponents found — try again?" elif result.error.code != "cancelled": _status.text = "Matchmaking failed: %s" % result.error.message func _on_cancel_pressed() -> void: SpawnWeaver.cancel_matchmaking() # the await above resolves with "cancelled" ``` Because the outcome comes back through the same `await`, the spinner logic lives in one function — no signal bookkeeping. ## Testing matchmaking locally Run two instances (**Debug → Run Multiple Instances → 2**) and press "Find match" in both. With the default `size` of 2, they match each other within a second. For larger sizes, run that many instances — every instance is its own player identity. ## Common mistakes | Mistake | What happens | Fix | |---|---|---| | Searching while in a room | `already-in-room` | `await SpawnWeaver.leave_room()` before `find_match()` | | Using `\|` in mode or region | `invalid-payload` | The `\|` character is reserved; pick another separator | | `size` of 1 or above 64 | `invalid-payload` | Sizes are 2–64 | | Too many mode/region/size combinations | Players wait forever in separate buckets | Start with one mode and `"global"`; add options when you have the player base | | No timeout handling | Players stare at an endless spinner | Handle `match-timeout` — it is retryable by design | | Forgetting the matched room has a host | Nobody writes room state; game never starts | The first matched player is host — check `SpawnWeaver.is_host` and act | ## Next - [Rooms](/dashboard/docs/rooms) — everything the matched room can do - [State sync](/dashboard/docs/sync) — start the game with host-written room state --- # State sync (Guides) State sync keeps live values — positions, health, game phase — on the server, so every member (including late joiners) always sees the current truth. For player movement, the `SpawnSync` and `PlayerSpawner` nodes do it with zero networking code. ## The model: room state and entities A room's live state has two levels: | | Room state | Entities | |---|---|---| | Shape | One JSON object per room | Many objects, each with an `entity_id` and an owner | | Who may write | The **host** only | The entity's **owner** only (its creator) | | Typical use | Game phase, scores, timer, settings | Player transforms, bombs, pickups, projectiles | | Cleanup | Lives as long as the room | Deleted by the owner, or garbage-collected when the owner leaves | Writes are **shallow patches**: keys you provide overwrite, keys you omit stay, and a `null` value **removes** the key. Changes are broadcast to *all* members — including the writer, whose copy doubles as the acknowledgment. ## Player movement, zero code: `PlayerSpawner` + `SpawnSync` 1. Add a `PlayerSpawner` node to your level and assign **Player Scene** in the Inspector. 2. Inside that player scene, add a `SpawnSync` node as a child of the root. Done. When a room is entered, `PlayerSpawner` instantiates one copy of the scene per player, wires each `SpawnSync` (entity id = player id, `is_local` on your own copy), despawns on leave, and survives reconnects. The local copy sends its transform only when it changes; remote copies interpolate smoothly. ### `PlayerSpawner` exports | Export | Default | Meaning | |---|---|---| | `player_scene` | — | The scene instantiated per player (required) | | `spawn_root` | empty | Where instances are added; empty = the spawner's parent | | `spawn_points` | empty | A node whose children (Marker2D/3D, Node2D/3D) are used as spawn positions, round-robin by roster order; empty = the root's origin | | `spawn_local_player` | `true` | Also spawn an avatar for you; turn off if you place your own player by hand | Signals: `player_spawned(player, node)` and `player_despawned(player_id, node)`. Optional hook on your player scene's root script — called right before `add_child`: ```gdscript func setup(player: SWPlayer, is_local: bool) -> void: name_label.text = player.display_name() if not is_local: $Camera2D.enabled = false # only your own avatar gets the camera ``` `PlayerSpawner` *reconciles* rather than blindly spawns: on every room entry (first join, rejoin, and automatic resume after a reconnect) it despawns avatars whose players are gone and spawns any that are missing — idempotent by design. ### `SpawnSync` exports Add `SpawnSync` as a **child of the node to replicate** (Node2D or Node3D): | Export | Default | Meaning | |---|---|---| | `entity_id` | empty | Stable entity id. Empty on a local node = your own player id (one avatar per player). Other owned objects need a unique id you set. | | `is_local` | `false` | `true` on the single copy this client owns (it sends, never interpolates); `false` on remote copies (they interpolate, never send) | | `sync_position` | `true` | Replicate position (`x`,`y` in 2D; `x`,`y`,`z` in 3D) | | `sync_rotation` | `true` | Replicate rotation (`rot` in 2D; `rx`,`ry`,`rz` in 3D) | | `sync_scale` | `false` | Replicate scale | | `synced_properties` | `[]` | Extra parent property names to replicate (e.g. `"hp"`, `"team"`). **Primitives only** — for a `Color` or `Vector2`, sync the components | | `send_rate` | `8` | Updates per second the local copy may send (1–10). The default stays inside the server's 10/s state budget with headroom | | `interpolate` | `true` | Smooth remote copies toward incoming state instead of snapping | | `interpolation_speed` | `16.0` | Higher = snappier follow, lower = smoother/laggier (1–40, frame-rate independent) | | `free_parent_on_remove` | `true` | `queue_free()` the parent when the entity is removed on the server | Behavior worth knowing: - **Dirty-check**: the local copy compares the state it would send with the last one sent — idle objects cost zero bandwidth. - **Reconnect-safe**: on every `room_joined` (including resumes) it re-registers a local entity, or re-applies the room snapshot on a remote one. - **Cleanup**: a local `SpawnSync` deletes its entity when it leaves the tree, so other players see the object disappear. - `set_local(true)` switches a copy to sending at runtime (used by `PlayerSpawner`). ## Manual entity sync (non-player objects) Use the entity API directly for owned objects that aren't node transforms — or when you want full control: ```gdscript # The player who plants the bomb owns it: await SpawnWeaver.set_entity("bomb_%d" % bomb_index, {"x": 10, "y": 4, "fuse": 3.0}) # Later — patch only what changed; null removes a key: await SpawnWeaver.patch_entity("bomb_1", {"fuse": 1.5}) await SpawnWeaver.patch_entity("bomb_1", {"defused_by": null}) # Gone: await SpawnWeaver.delete_entity("bomb_1") ``` Everyone (including you) receives the change: ```gdscript SpawnWeaver.entity_changed.connect(func(id, state, patch, owner_id): if id.begins_with("bomb_"): update_bomb_visual(id, state)) SpawnWeaver.entity_removed.connect(func(id, reason): # reason: "deleted" (owner deleted it) or "owner-left" (owner's membership ended) if id.begins_with("bomb_"): remove_bomb_visual(id)) ``` Only the creator of an entity may set/patch/delete it — anyone else gets `state-forbidden`. When an owner's membership ends (left, or grace expired), their entities are garbage-collected with reason `owner-left`. ## Room state (host only) One shared object for room-wide facts. Only the host writes it; everyone reads it. ```gdscript # Host: if SpawnWeaver.is_host: await SpawnWeaver.set_room_state({"phase": "combat", "round": 2}) # Everyone (including the host — the broadcast doubles as the ack): SpawnWeaver.room_state_changed.connect(func(state, patch): if patch.has("phase"): switch_phase(state["phase"])) ``` `set_room_state()` is also a shallow patch with the same `null`-removes-key rule. Non-hosts get `not-host`. Remember [host migration](/dashboard/docs/rooms): after `host_changed`, the new host takes over writing. ## Late joiners: the snapshot Late joiners need no special handling — the **full state snapshot arrives inside the room payload** of `room_joined` (and after matchmaking or a reconnect resume): ```gdscript SpawnWeaver.room_joined.connect(func(room): apply_phase(room.state.get("phase", "lobby")) # room-level state for entity_id in room.entities: # entity_id -> {owner_id, state} var entry: Dictionary = room.entities[entity_id] spawn_visual(entity_id, entry["owner_id"], entry["state"])) ``` `SpawnWeaver.room.state` and `SpawnWeaver.room.entities` stay current from then on. ## Limits | Limit | Value | Error when exceeded | |---|---|---| | Entities per room | 50 | `state-limit-exceeded` | | State per entity | 4 KB | `state-too-large` | | Room state size | 16 KB | `state-too-large` | | State updates per client | 10/s sustained, burst 20 | `rate-limited` (retryable) | See [Limits](/dashboard/docs/limits) for the full table. ## Common mistakes | Mistake | What happens | Fix | |---|---|---| | Patching an entity you don't own | `state-forbidden` | Only the creator writes an entity; route the change through its owner or an event | | Writing room state as a non-host | `not-host` | Check `SpawnWeaver.is_host`; hand over on `host_changed` | | Sending state every frame | `rate-limited` | Send at 8–10/s max; `SpawnSync`'s default `send_rate` is safe | | Two clients using the same `entity_id` | The second writer gets `state-forbidden` | Ids must be unique per owner-created object; player ids are already unique | | Storing blobs (inventories, chat logs) in state | `state-too-large` | State is for small live values; use [storage](/dashboard/docs/storage) or [events](/dashboard/docs/events) | | Expecting `set_entity` to merge | Keys you omit are gone | `set_entity` **replaces**; `patch_entity` merges | | Vector2/Color in `synced_properties` | Property doesn't sync correctly | Primitives only — sync `x`/`y`/components as separate properties | ## Next - [Events](/dashboard/docs/events) — transient messages, and when to prefer them - [Best practices](/dashboard/docs/best-practices) — events vs. entities vs. room state vs. storage --- # Events (Guides) Events are transient messages relayed to the other members of your room — shots fired, emotes, chat lines, "round started" pings. One call to send, one signal to receive. ## Send and receive ```gdscript # Sender — fire and forget, no await: SpawnWeaver.send_event("player_fired", {"dir": [0, 1], "weapon": "bow"}) # Every OTHER member receives it: SpawnWeaver.event_received.connect(func(event_name, data, sender): match event_name: "player_fired": spawn_arrow(sender.id, data["dir"], data["weapon"])) ``` - `event_name` is any string you choose; `data` is any JSON-serializable Dictionary. - `sender` is the `SWPlayer` who sent it — use `sender.id` and `sender.display_name()`. - You must be in a room; events go to your current room's members only. ## Sender-excluded semantics The sender **never receives its own event**. Apply the local effect immediately when you send, and handle `event_received` for everyone else: ```gdscript func fire(dir: Vector2) -> void: spawn_arrow(SpawnWeaver.player.id, [dir.x, dir.y], "bow") # local, instant SpawnWeaver.send_event("player_fired", {"dir": [dir.x, dir.y], "weapon": "bow"}) ``` This is the opposite of state changes (`room_state_changed` / `entity_changed`), which *do* echo back to the sender as the acknowledgment. ## Fire-and-forget, with queuing during reconnect `send_event()` returns immediately — there is no success reply to await. Failures (rate limit, oversized payload, not in a room) arrive on the `error` signal: ```gdscript SpawnWeaver.error.connect(func(err): if err.code == "rate-limited": pass # back off — see Limits ) ``` If the connection is mid-reconnect, events are **queued automatically** and flushed the moment the session resumes (up to 128 queued messages; oldest are dropped beyond that). If you are fully offline (`start()` never succeeded, or after `stop()`), the event is dropped with an editor warning instead. ## Example: chat ```gdscript # --- sending --- func _on_chat_submitted(text: String) -> void: if text.strip_edges().is_empty(): return _append_line(SpawnWeaver.player.display_name(), text) # your own line, locally SpawnWeaver.send_event("chat", {"text": text}) _input.clear() # --- receiving --- func _ready() -> void: SpawnWeaver.event_received.connect(_on_event) func _on_event(event_name: String, data: Dictionary, sender: SWPlayer) -> void: if event_name == "chat": _append_line(sender.display_name(), str(data.get("text", ""))) func _append_line(who: String, text: String) -> void: _log.append_text("[b]%s:[/b] %s\n" % [who, text]) ``` Chat is a perfect event: transient (late joiners don't need old lines), sender-known, and low-rate. If you *do* want history for late joiners, keep the last N lines in host-written [room state](/dashboard/docs/sync) instead. ## Events vs. state | Question | Events | State | |---|---|---| | Does a late joiner need it? | No — events are never replayed | Yes — the snapshot arrives on join | | Is it a moment or a value? | A moment ("fired", "emoted") | A value (position, hp, phase) | | Delivery | Relayed once to current members | Kept on the server, changes broadcast | | Sender receives it? | No | Yes (the ack copy) | Rule of thumb: **transient → event, persistent → state**. A door opening is an event if it's cosmetic; it's state (`{"door_3": "open"}`) if players joining later must see the door open. When in doubt, make it state — it survives reconnects for free. ## Rate and size budget Events share the connection's message budget: **20 messages/s sustained (burst 40)** and **16 KB per message**. Exceeding them raises `rate-limited` (retryable — back off briefly) or `payload-too-large` on the `error` signal. Don't stream continuous values through events — that's what [SpawnSync](/dashboard/docs/sync) and its dirty-check are for. ## Common mistakes | Mistake | What happens | Fix | |---|---|---| | Waiting for your own event to render the effect | Nothing happens — sender is excluded | Apply the local effect when sending | | Sending movement every frame via events | `rate-limited`, jittery remotes | Use `SpawnSync` / entities for continuous values | | Sending before joining a room | `error` signal with `not-in-room` | Events need a current room | | Using events for "current game phase" | Late joiners are lost | Put it in host-written room state | | Huge payloads (base64 images, full inventories) | `payload-too-large` | Keep events small; store big data in [storage](/dashboard/docs/storage) | ## Next - [State sync](/dashboard/docs/sync) — persistent live values - [Best practices](/dashboard/docs/best-practices) — choosing the right channel --- # Players & identity (Guides) SpawnWeaver players are anonymous-first: no sign-up, no passwords. The first connect mints a stable player id; the SDK persists it so the same machine is the same player tomorrow. ## How identity works 1. On first connect, the server creates a new player id (`player_…`) and returns a signed **player token** in the welcome. 2. The SDK stores that token on disk (`user://spawnweaver/identity.cfg`) and presents it on every later connect — same token, same `player.id`. 3. Every successful connect issues a **fresh token** (sliding expiration); the SDK always stores the newest one. As long as the player keeps playing occasionally, the identity never expires. ```gdscript await SpawnWeaver.start() print(SpawnWeaver.player.id) # "player_…" — stable across app restarts ``` There is nothing to build: no auth UI, no account database. If you later want real accounts, map your account ids to SpawnWeaver player ids in [player storage](/dashboard/docs/storage). ## Display names Identity and display name are separate. Set the name other players see: ```gdscript SpawnWeaver.set_display_name("Ada") ``` The name is applied on your **next** create/join/match — set it before entering a room (e.g. from a name field in your main menu). It is per-room, not stored server-side; to persist a chosen name across sessions, keep it in storage: ```gdscript # Boot: var saved := await SpawnWeaver.storage_get("display_name") if saved.ok and saved.value != null: SpawnWeaver.set_display_name(str(saved.value)) # When the player edits their name: SpawnWeaver.set_display_name(new_name) await SpawnWeaver.storage_set("display_name", new_name) ``` ## `SWPlayer` fields Roster entries (and your own `SpawnWeaver.player`) are `SWPlayer` objects: | Field / method | Type | Meaning | |---|---|---| | `id` | `String` | Stable player id — survives reconnects and app restarts | | `name` | `String` | Display name given when creating/joining (may be empty) | | `connected` | `bool` | `false` while briefly disconnected (inside the grace window) | | `is_local` | `bool` | `true` when the entry is you | | `display_name()` | `String` | The name to show in UIs: `name`, or a short id fallback | ```gdscript for p in SpawnWeaver.room.players: print("%s%s%s" % [ p.display_name(), " (you)" if p.is_local else "", "" if p.connected else " — reconnecting…"]) ``` ## Persistence across restarts The token lives in Godot's `user://` directory, so identity survives: - app restarts and updates of your game, - SDK upgrades, - reconnects and network changes. It does **not** survive the player clearing the game's user data, or switching machines — that machine is then a brand-new player. There is no cross-device identity in the current release; treat the player id as "this install of the game". ## Multiple projects (and servers) Identity is scoped per **server URL + project key** combination. The identity file holds one token per scope, so: - Each of your games (different `pk_…`) gets an independent player identity, even on the same machine. - The same game pointed at a local dev server and at production has two independent identities. - Rotating your project's **public key** starts a new scope — existing installs get fresh identities. For tools and tests that run several SDK clients in one process, the advanced `identity_scope` property isolates identity persistence per client instance. Leave it empty in games. ## What resets identity | Cause | Effect | |---|---| | Player clears `user://` data (or a fresh install on a new machine) | New anonymous player on next connect | | Token expires after long inactivity | Server rejects it; a new identity is needed | | Project public key rotated | New identity scope per install | | `SpawnWeaver.stop()` / app quit | **No effect** — identity persists | ## Identity and the disconnect grace window Because identity is stable, reconnecting players are recognized: if your connection drops while in a room, the server keeps your seat for the grace window and the SDK's automatic reconnect resumes it — same `player.id`, same entities. Other players see `player_disconnected` then `player_reconnected`, not a leave/join. Details in [Rooms](/dashboard/docs/rooms). ## Common mistakes | Mistake | What happens | Fix | |---|---|---| | Setting the display name after joining | Others see an empty name this session | Call `set_display_name()` before create/join/match | | Using `name` as a unique key | Names collide and can be empty | Key everything by `player.id` | | Expecting identity across devices | Each install is its own player | Layer your own account system on top if you need it, via storage | | Testing "two players" with two rooms in one instance | One connection = one player = one room | Use **Debug → Run Multiple Instances** to run two clients side by side | ## Next - [Player storage](/dashboard/docs/storage) — persist data against the player id - [Rooms](/dashboard/docs/rooms) — rosters and the grace window --- # Player storage (Guides) 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): ```gdscript # 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](/dashboard/docs/sync) or [events](/dashboard/docs/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 ```gdscript 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 | ```bash # 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](/dashboard/docs/reference-http-api). 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](/dashboard/docs/sync) — storage is per-player and not broadcast | | One-shot actions, chat | [Events](/dashboard/docs/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](/dashboard/docs/reference-http-api) — full storage endpoint details - [Limits](/dashboard/docs/limits) — every quota in one table --- # Debugging (Guides) When multiplayer misbehaves, SpawnWeaver can tell you exactly what happened — on the client (SDK logging, debug reports) and on the server (session timelines, aggregated errors). This guide is your toolbox, plus checklists for the three most common failures. ## SDK logging ```gdscript SpawnWeaver.set_debug_enabled(true) ``` With debug enabled, the SDK prints every message it sends and receives, and every error, to the Godot output: ``` [SpawnWeaver] → room.create [SpawnWeaver] ← room.created [SpawnWeaver] ← room.player_joined [SpawnWeaver] error rate-limited: Sending faster than the allowed rate… ``` You can also switch it on without code by adding `debug=true` to the `[spawnweaver]` section of `res://spawnweaver.cfg`. ## Debug reports and the Debug Bundle viewer Every SDK client can produce a copyable diagnostic snapshot — SDK and Godot versions, connection status, server URL, player and room ids, latency, reconnect attempts, and ring buffers of the last 50 messages and last 10 errors: ```gdscript print(SpawnWeaver.create_debug_report_string()) # pretty-printed JSON ``` Wire it to a debug key or a "Copy debug info" button in your bug-report UI: ```gdscript func _input(event: InputEvent) -> void: if event.is_action_pressed("copy_debug_report"): DisplayServer.clipboard_set(SpawnWeaver.create_debug_report_string()) ``` Paste the report into the dashboard's **Debug Bundle viewer** ([/dashboard/debug](/dashboard/debug)) to inspect a player's state offline — perfect for playtester bug reports: "paste what the game copied to your clipboard". ## The dashboard debugger The **Debugger** hub in the [dashboard](/dashboard) explains *why* a session failed: | Tool | Where | What it shows | |---|---|---| | Session inspector | `/dashboard/sessions/{id}` | Per-connection timeline: connected → authenticated → every action → rejections → disconnected; plus IP, SDK + Godot versions, current room, auth status, disconnect reason | | Error explorer | `/dashboard/errors` | Protocol errors aggregated by code, with counts, affected sessions, and a **suggested fix** for each | | Room & matchmaking inspectors | Debugger hub | A room's members, host, and metadata; the matchmaking queue's contents | | Debug bundle viewer | `/dashboard/debug` | Paste a client's `create_debug_report_string()` output | | Live activity & logs | Debugger hub | Live connections/rooms and recent server logs | The SDK reports its `sdkVersion` and engine version on connect, so the session inspector can tell you a tester is on an old build. ## The editor dock The SpawnWeaver dock is the first stop for setup problems: - **Test connection** — performs a real end-to-end connect with your saved key and server URL, from inside the editor. - **Live counts** — shows your project's connected players and open rooms, refreshed every few seconds while the editor runs. If your game connects and the count rises, the pipeline works. ## Simulating bad networks ```gdscript SpawnWeaver.simulate_connection_loss() ``` Drops the socket as if the network blipped: auto-reconnect and room resume kick in exactly as they would in production. Use it to test your `reconnecting`, `player_disconnected`, and `room_joined`-resume handling without pulling cables. ## Checklists ### "Could not connect" `start()` fails, or the dock's Test connection fails. 1. **Key**: is `project_key` a `pk_…` value with no spaces? (Not the `sk_…` secret.) 2. **URL**: hosted service → leave the Server field empty. Local server → `ws://127.0.0.1:5159/connect` (note `ws://`, not `wss://` — local dev has no TLS; hosted/production is `wss://`). 3. **Server up?** Local: is `./quickstart.ps1` (or your `dotnet run`) still running? Check `http://localhost:5159/health`. 4. **Project active?** A deactivated project rejects the handshake with 401. 5. Enable `set_debug_enabled(true)` and read the close reason in the output; check the session (or its absence) in the dashboard's session inspector. ### "Join failed" 1. `room-not-found` — the code is wrong, or the room expired (rooms with no connected members expire after ~60s; a server restart drops all rooms). 2. `room-full` — `max_players` reached; note players inside the disconnect grace window still hold seats. 3. `already-in-room` — you're already in a room (one per connection); `await SpawnWeaver.leave_room()` first. 4. `not-connected` — you called `join_room()` before `start()` succeeded. ### "Movement is choppy" 1. Remote copies should have `interpolate = true` on their `SpawnSync` (default). If you snap positions yourself in `_process`, you're fighting the interpolation. 2. `interpolation_speed`: lower = smoother but laggier; try 10–16 for avatars. 3. Check `SpawnWeaver.latency_ms` — with high ping, favor smoothness. 4. Watch the output for `rate-limited` errors: if other traffic (events, manual state calls) eats the budget, `SpawnSync` updates get dropped. Keep `send_rate` at the default 8 and batch other messages. 5. Only the *local* copy should have `is_local = true` — two senders for one entity produce `state-forbidden` errors and teleporting. More symptom → fix tables in [Troubleshooting](/dashboard/docs/troubleshooting). ## Next - [Troubleshooting](/dashboard/docs/troubleshooting) — symptom → cause → fix, with exact error codes - [Limits](/dashboard/docs/limits) — the limit behind each limit error --- # Leaderboards (Guides) Leaderboards are project-scoped ranked score boards. A board springs into existence the first time a player submits to it, and every player holds at most **one entry per board** — resubmitting updates that entry according to the board's mode. Boards persist across sessions and servers, and you manage them from the dashboard. Board names match `^[a-z0-9_.\-]{1,64}$` (lowercase letters, digits, `_`, `.`, `-`). ## Submitting scores ```gdscript await SpawnWeaver.start() var result := await SpawnWeaver.leaderboard_submit("highscore", 4200) if result.ok: print("Best: ", result.value["best"], " rank #", result.value["rank"]) if not result.value["updated"]: print("Didn't beat your stored score.") ``` `leaderboard_submit(board, score, mode)` resolves with `{best, rank, updated}`: | Field | Meaning | |---|---| | `best` | The score stored for you **after** the submit | | `rank` | Your 1-based rank on the board | | `updated` | `false` when the submit didn't beat your stored score (max/min modes) | The display name set with `set_display_name()` is stored with your entry, so listings can show names instead of player ids. ## Modes The third argument picks how a resubmit interacts with your stored score: | Mode | Behavior | Use for | |---|---|---| | `"max"` (default) | Keep the **higher** score | Highscores, most kills, longest streak | | `"min"` | Keep the **lower** score; ranks read ascending | Lap times, speedruns, fewest moves | | `"latest"` | Always overwrite | Season ratings, ELO-style values you compute | Use the same mode for every submit to a given board — the mode travels with the submit, not the board. ## Reading the board ```gdscript # The top 10, best first (value: Array[SWLeaderboardEntry]): var top := await SpawnWeaver.leaderboard_top("highscore") if top.ok: for entry in top.value: print("#", entry.rank, " ", entry.display_name(), " ", entry.score) # The window around you (great for "you are #17" UIs): var around := await SpawnWeaver.leaderboard_around("highscore", 2) if around.ok: print("You are #", around.value["player_rank"], " of ", around.value["total"]) for entry in around.value["entries"]: print("#", entry.rank, " ", entry.display_name(), " ", entry.score, " ← you" if entry.is_local else "") ``` `leaderboard_top(board, limit)` returns up to `limit` (1–100, default 10) entries. `leaderboard_around(board, entry_range)` returns your entry ± `entry_range` (1–50, default 5) neighbors, plus `player_rank` and the board's `total` entry count. If you have no entry yet, `player_rank` is `-1` and the top of the board is returned instead. Each [`SWLeaderboardEntry`](/dashboard/docs/reference-sdk) carries `rank`, `player_id`, `player_name`, `score`, `updated_at`, and an `is_local` flag that is `true` on your own row — perfect for highlighting it. ## Ranks and ties - Ranks are 1-based. **Equal scores share a rank** — two players at 100 are both `#1`, and the next score is `#3`. - Within a tie, the **oldest entry lists first** (defending a score beats matching it). - On `"min"` boards ranks read ascending: the lowest score is `#1`. ## Managing boards from the dashboard The **Leaderboards** page in the dashboard (Build → Leaderboards) shows every board of a project with its top 100. From there you can: - **Delete a single entry** — e.g. an obvious cheater. The player can submit again immediately (their next submit re-creates the entry). - **Reset a whole board** — removes every entry, e.g. at the start of a new season. The board re-appears on the next submit. ## Limits | Limit | Value | Error | |---|---|---| | Board name | `^[a-z0-9_.\-]{1,64}$` | `leaderboard-invalid-board` | | Score range | ±2^53-1 (JSON-safe integer) | `leaderboard-invalid-score` | | `top` page size | 1–100 entries (default 10) | clamped | | `around` range | ±1–50 entries (default 5) | clamped | | Player name | 64 characters | trimmed to fit | Leaderboard calls share the connection's message budget (20/s) — submit when a run ends, not every time the score ticks up. ## Common mistakes | Mistake | What happens | Fix | |---|---|---| | Calling before `start()` | `not-connected` | Leaderboards ride the realtime connection | | Uppercase or spaced board names (`"High Scores"`) | `leaderboard-invalid-board` | Use `"high_scores"` — lowercase, no spaces | | Mixing modes on one board | Confusing best-score behavior | Pick one mode per board and stick to it | | Submitting lap times with `"max"` | The **worst** time is kept | Use `"min"` for time-based boards | | Storing scores in [player storage](/dashboard/docs/storage) | No ranks, not readable by other players | Storage is private per player; leaderboards are shared | | Submitting every frame | Burns the message budget | Submit on run end / meaningful changes | ## Next - [Protocol reference](/dashboard/docs/reference-protocol) — the wire messages - [SDK reference](/dashboard/docs/reference-sdk) — full method and model details - [Limits](/dashboard/docs/limits) — every quota in one table --- # Steam integration (Guides) # Steam integration Let friends join each other straight from the Steam friends list. SpawnWeaver ships a `SteamSync` node that bridges [GodotSteam](https://godotsteam.com) to your rooms — rich presence, "Join game", overlay invites, and Steam persona names. **Without GodotSteam installed the node is a safe no-op**, so you can leave it in every build. ## Setup 1. Install GodotSteam (the module build or the GDExtension) and initialize it as usual in your game (`Steam.steamInit()` + `run_callbacks` pumping — SpawnWeaver never touches the Steam lifecycle, it only reads the singleton). 2. Drop a **SteamSync** node somewhere persistent (your main scene works). That's the whole integration: - Entering any SpawnWeaver room publishes `+room ` as Steam Rich Presence, with a status line friends can see (`status_format`, default `"In room {code}"`). - A friend picking **Join game** routes into `SpawnWeaver.join_room(code)` automatically — including the launch-from-invite case (`+room CODE` on the command line starts the game, connects, and joins). - `invite_overlay()` opens the Steam invite dialog for the current room. - With `use_steam_persona` on (default), players' SpawnWeaver display names become their Steam persona names. ```gdscript # Optional hooks: $SteamSync.steam_join_requested.connect(func(code): show_joining_screen(code)) $SteamSync.invite_overlay() # e.g. behind an "Invite friends" button print($SteamSync.is_available()) # false when GodotSteam/Steam isn't present ``` ## Notes - Works with GodotSteam 4.x module and GDExtension builds (detected at runtime; no compile-time dependency — projects without GodotSteam still export fine). - Steam auth/ownership verification is not part of this integration — player identity stays SpawnWeaver's anonymous-first tokens ([players](/dashboard/docs/players)). - Test without Steam freely: everything no-ops, and the room-code flow keeps working as the universal fallback. --- # Offline mode (Guides) # Offline mode `SpawnWeaver.start_offline()` runs a complete local session **in-process**: no account, no project key, no network. Use it to prototype your multiplayer architecture before signing up, run CI without a server, or ship a practice/solo mode on the same code path as your online game. ```gdscript await SpawnWeaver.start_offline() # instead of start() — that's the only change var result := await SpawnWeaver.create_room() print(result.room.code) # always "LOCAL" await SpawnWeaver.set_entity(SpawnWeaver.player.id, {"x": 100}) await SpawnWeaver.storage_set("save", {"level": 2}) ``` ## What works offline | Feature | Offline behavior | |---|---| | Identity | A local `player_offline_…` id; `SpawnWeaver.player` works normally | | Rooms | One local room (code `LOCAL`); create/join/leave/list/update; you're always host | | State sync | Full room + entity state with the same signals and null-removes-key patches; entities survive leave/rejoin | | SpawnSync / PlayerSpawner | Work unchanged (your own avatar spawns and syncs locally) | | Storage | Persisted to `user://spawnweaver/offline_storage.cfg` across runs | | Events / RPC | Accepted, delivered to nobody — you're the only player | | Matchmaking, leaderboards | Return the error code `"offline"` immediately (never hang) | `stop()` ends the offline session; a later `start()` goes online normally — game code that awaits results and listens to signals doesn't change between the two. ## Common uses - **Try before signing up**: build your whole room/state/storage flow first; swap `start_offline()` → `start()` when you get a key. - **CI**: the SDK's own `tests/headless_offline.tscn` runs green with no server — yours can too. - **Practice mode**: run the exact multiplayer scene solo, no branching game code. --- # "Tutorial: 2D arena" (Tutorials) # Tutorial: build a 2D multiplayer arena You'll build a complete small game: players matchmake (or share a room code), move around an arena, shoot each other, respawn, and race up a shared scoreboard. The finished project ships with the SDK at `examples/tutorial_2d_arena/` — build along, or open it and read this as the guided tour. **You'll use every core SpawnWeaver feature:** rooms + matchmaking, `PlayerSpawner` + `SpawnSync` replication, events, room state, and player storage. Time: ~45 minutes. ## 0. Setup [Install the SDK](/dashboard/docs/installation), enable the plugin, and put your key in the SpawnWeaver dock. Create a fresh scene with a `Node2D` root named `Arena`. ## 1. The player scene Create `player.tscn`: a `CharacterBody2D` with a `Polygon2D` square, a `CollisionShape2D`, a `Label` for the name, and — the important part — a **SpawnSync** node as a child. In the SpawnSync inspector, turn off *Sync Rotation* and add `hp` to *Synced Properties*. The script reads input only on the local copy: ```gdscript extends CharacterBody2D const SPEED := 260.0 const MAX_HP := 3 var hp: int = MAX_HP # replicated via SpawnSync's synced_properties var is_local := false func setup(player: SWPlayer, local: bool) -> void: is_local = local $NameLabel.text = player.display_name() + (" (you)" if local else "") func _ready() -> void: if not is_local: set_physics_process(false) # remote copies are driven by SpawnSync func _physics_process(_delta: float) -> void: # Input polling reads GLOBAL OS state, so an unfocused window would move this # player too — two test windows would walk in lockstep. Always guard on focus. if not get_window().has_focus(): velocity = Vector2.ZERO move_and_slide() return velocity = Input.get_vector("ui_left", "ui_right", "ui_up", "ui_down") * SPEED move_and_slide() ``` Three things to notice: - **`setup(player, is_local)`** is a convention `PlayerSpawner` calls right before the avatar enters the tree — your one hook to configure local vs remote copies. - Remote copies **disable physics**. SpawnSync interpolates their position; simulating physics on top would fight it. - **The focus guard is not optional.** `Input.is_physical_key_pressed()` and `get_global_mouse_position()` are global to the OS, not per-window — without the check, every instance you have open responds to the same keypress. ## 2. Spawning — zero code Drop a **PlayerSpawner** node into the Arena scene. In the Inspector, set *Player Scene* to `player.tscn`. Optionally add a `SpawnPoints` node with a few `Marker2D` children and point *Spawn Points* at it. That's the entire spawn system. When a room is entered, PlayerSpawner instantiates one avatar per player, wires each SpawnSync (entity id + authority), despawns on leave, and reconciles after reconnects. ## 3. Getting players into a room Drop a **LobbyBrowser** node into the Arena scene. That's the whole pre-game flow: quick match, a live list of open games, and join-by-code for friends — themed by your project, no code to write. Set its Inspector fields: | Field | Value | |---|---| | Title | `2D Arena` | | Quick Match Mode | `arena-2d` | | Quick Match Size | `2` | Then connect the arena to the SDK's signals: ```gdscript extends Node2D func _ready() -> void: SpawnWeaver.room_joined.connect(_on_room_joined) var result := await SpawnWeaver.start() if not result.ok: push_error(result.error.message) func _on_room_joined(room: SWRoom) -> void: print("In room %s with %d players" % [room.code, room.players.size()]) ``` The lobby hides itself on join and reappears when the room is left, so the arena only has to manage its own in-game HUD. Movement now replicates with no further networking code — run two instances and see it. > **Prefer your own menu?** Everything the lobby does is public API — three calls: > ```gdscript > await SpawnWeaver.find_match("arena-2d", {"size": 2}) # quick match > await SpawnWeaver.create_room({"visibility": "public"}) # host a game > await SpawnWeaver.join_room(code.strip_edges().to_upper()) > ``` > `list_rooms()` fills a browser of public games. Normalize typed codes with > `strip_edges().to_upper()` — the node does this for you, and forgetting it is the > most common cause of a "join does nothing" bug. The > [3D arena tutorial](/dashboard/docs/tutorials-3d-arena) uses the same node. ## 4. Shooting — events Shots are **transient**: they matter for a second, then they're gone. That's exactly what [events](/dashboard/docs/events) are for (state sync is for things late joiners need). On click, the local player spawns a projectile locally and tells everyone else: ```gdscript func _unhandled_input(event: InputEvent) -> void: if event is InputEventMouseButton and event.pressed: var direction := (get_global_mouse_position() - me.position).normalized() _fire(me.position, direction) # local, instant SpawnWeaver.send_event("shot", {"x": me.position.x, "y": me.position.y, "dx": direction.x, "dy": direction.y}) # everyone else func _on_event(event_name: String, data: Dictionary, sender: SWPlayer) -> void: if event_name == "shot": _fire(Vector2(data.x, data.y), Vector2(data.dx, data.dy)) ``` > **Clicks not firing?** Any full-screen `Control` — a background `ColorRect`, a > decorative overlay — swallows mouse input before `_unhandled_input` ever runs, > because Controls default to `MOUSE_FILTER_STOP`. Set decorations to > `MOUSE_FILTER_IGNORE`. Everything still *looks* right, which makes this one hard > to spot. ## 5. Hits and health — pick an authority Someone must decide a shot connected. This game uses the simplest scheme that stays consistent: **the shooter detects the hit, the victim applies the damage**. ```gdscript # Shooter's client, when its projectile overlaps a remote player: SpawnWeaver.send_event("hit", {"victim": victim_id}) # Every client, in the event handler: "hit": if data.victim == SpawnWeaver.player.id: # only the victim applies it me.hp -= 1 # hp replicates via SpawnSync if me.hp <= 0: me.respawn() ``` Because only the **owner** ever mutates `hp` (and SpawnSync replicates it out), there are no write conflicts. For the tradeoffs of trusting clients like this, read [best practices](/dashboard/docs/best-practices). ## 6. Scoreboard — room state Scores must be **shared and consistent** — that's [room state](/dashboard/docs/sync), which only the host can write: ```gdscript # On death, the victim announces the kill: SpawnWeaver.send_event("kill", {"killer": killer_id}) # The HOST (only) applies it: "kill": if SpawnWeaver.is_host: var scores: Dictionary = SpawnWeaver.room.state.get("scores", {}).duplicate() scores[data.killer] = int(scores.get(data.killer, 0)) + 1 SpawnWeaver.set_room_state({"scores": scores}) # Everyone re-renders when it changes: SpawnWeaver.room_state_changed.connect(func(state, _patch): _render_scores(state)) ``` Host migration is automatic — if the host quits, the next player takes over and keeps scoring. Late joiners get the current scores inside `room_joined`. ## 7. Remember the player — storage ```gdscript # On boot: var saved := await SpawnWeaver.storage_get("display_name") if saved.ok and saved.value != null: SpawnWeaver.set_display_name(str(saved.value)) # When they set a name: await SpawnWeaver.storage_set("display_name", name) ``` ## 8. Disconnects — already handled Kill one instance's network mid-game (or just close it): the other player sees the avatar pause; within the grace window a reconnect resumes the same room, seat, and entities automatically. Show it in your UI with `player_disconnected` / `player_reconnected` if you want the polish. ## Common mistakes | Mistake | Symptom | Fix | |---|---|---| | Simulating physics on remote copies | Rubber-banding avatars | `set_physics_process(false)` when not local | | Non-owner writing `hp` | `state-forbidden` errors | Only the victim (owner) mutates its own state | | Non-host writing scores | `not-host` errors | Route through a `kill` event; the host writes | | Scores as events instead of state | Late joiners see 0–0 | Shared, lasting data belongs in room state | | Polling input without a focus check | Both test windows move one player | Guard on `get_window().has_focus()` | | Full-screen decoration over the game | Clicks never reach `_unhandled_input` | `MOUSE_FILTER_IGNORE` on backgrounds/overlays | | Passing a typed code straight to `join_room` | Join silently fails | `strip_edges().to_upper()` first | ## Where next - [Tutorial: 3D arena](/dashboard/docs/tutorials-3d-arena) — the same architecture with a character controller, camera, and projectiles. - [Learn paths](/dashboard/docs/learn-beginner) — guided checklists from zero to shipped. --- # "Tutorial: 3D arena" (Tutorials) # Tutorial: build a 3D multiplayer arena Third-person 3D combat: mouse-look character controller, hitscan shooting with tracers, health, respawns, and a shared scoreboard. The finished project ships at `examples/tutorial_3d_arena/`. Do the [2D arena tutorial](/dashboard/docs/tutorials-2d-arena) first — this one reuses its architecture (spawning, events, authority, room state) and focuses on what's different in 3D. ## 1. The level A `Node3D` root with a `StaticBody3D` floor (BoxShape3D + BoxMesh), a `DirectionalLight3D` with shadows, a `WorldEnvironment`, and a `SpawnPoints` node holding four `Marker3D`s around the arena. Drop in a **PlayerSpawner**, point it at the player scene from step 2 and at `SpawnPoints`. ## 2. The character controller `player_3d.tscn`: a `CharacterBody3D` with a capsule collision + mesh, a `Label3D` (billboard) for the name, a `CameraArm` (`Node3D`) holding the chase `Camera3D`, and a **SpawnSync** child with `hp` in *Synced Properties* (rotation sync stays ON — you want to see who's facing where). The controller is standard Godot; the multiplayer-relevant parts: ```gdscript func setup(player: SWPlayer, local: bool) -> void: is_local = local player_name = player.display_name() func _ready() -> void: $CameraArm/Camera3D.current = is_local # only YOUR camera is live if is_local: Input.mouse_mode = Input.MOUSE_MODE_CAPTURED else: set_physics_process(false) # SpawnSync drives remote copies set_process_input(false) ``` Mouse-look rotates the body on Y (`rotation.y`) and pitches only the camera arm. Because SpawnSync syncs rotation, other players see you turn — free aim telegraphing. ## 3. Hitscan shooting 3D shots are instant rays, not travelling projectiles: ```gdscript # Local player fires: raycast from the camera. var from := camera.global_position var direction := -camera.global_transform.basis.z var query := PhysicsRayQueryParameters3D.create(from, from + direction * 100.0) query.exclude = [me.get_rid()] # don't shoot yourself var hit := get_world_3d().direct_space_state.intersect_ray(query) SpawnWeaver.send_event("shot", { ... }) # so everyone draws the tracer if hit.collider is CharacterBody3D: SpawnWeaver.send_event("hit", {"victim": victim_id}) ``` Everyone renders the tracer (an `ImmediateMesh` line, freed after 80 ms) when the `shot` event arrives — instant feedback, no synced projectile entities needed. Damage, kills, scores, and storage work **exactly** as in the 2D tutorial: victim applies its own `hp`, host owns the scoreboard, `find_match("arena-3d")` for matchmaking. ## What's different in 3D — recap | Concern | 2D | 3D | |---|---|---| | Movement sync | position (+rot off) | position + rotation (aim telegraphing) | | Camera | none/shared | per-player chase cam, `current` only on local | | Shooting | travelling Area2D projectile | hitscan raycast + tracer event | | Input | arrows/WASD | + captured mouse, Esc to release | ## Common mistakes | Mistake | Symptom | Fix | |---|---|---| | Every camera `current = true` | View snaps to the last spawned player | Only the local player's camera | | Raycast includes the shooter | You instantly shoot yourself | `query.exclude = [me.get_rid()]` | | Syncing the camera arm pitch | Remote heads twitch | Sync the body; keep the arm local | ## Where next - [Sync guide](/dashboard/docs/sync) — everything SpawnSync and PlayerSpawner can do - [Best practices](/dashboard/docs/best-practices) — authority models beyond shooter-decides - [Learn: advanced path](/dashboard/docs/learn-advanced) — optimization, authority, production deployment --- # "Learn: beginner path" (Learn) # Beginner path: your first multiplayer game A guided checklist from "never done multiplayer" to two players moving in a game you built. Each step links the doc that explains it and ends with a checkpoint — don't move on until the checkpoint is true. Stuck? Every step has a **Copy AI prompt** block: copy it into Claude/ChatGPT along with your error message for tailored help. > **How multiplayer works here, in one paragraph:** your game connects to SpawnWeaver's > servers over the internet. Players meet in a *room*. Whatever a player does gets sent > to the server, which relays it to everyone else in the room. The SDK hides the > plumbing; your job is deciding *what* to share. ## Step 1 — Get connected - [ ] Create an account and a project ([quickstart](/dashboard/docs/quickstart)) - [ ] Install the SDK and enable the plugin ([installation](/dashboard/docs/installation)) - [ ] Paste your key into the SpawnWeaver dock → **Save** → **Test connection** **Checkpoint:** the dock shows *"✓ Connected — your key and server are good to go."* ```text I'm following the SpawnWeaver beginner path, step 1 (connecting a Godot 4 project). SpawnWeaver is a multiplayer service for Godot: an addon at addons/spawnweaver adds a "SpawnWeaver" autoload; config lives in res://spawnweaver.cfg with my project key. The dock's Test connection says: . What should I check? ``` ## Step 2 — See it work - [ ] In the dock, press **Generate starter scene** and open `res://spawnweaver/StarterGame.tscn` - [ ] *Debug → Run Multiple Instances → Run 2 Instances*, then press Play - [ ] Window A: **Create room**. Window B: type the code → **Join**. Move both squares. **Checkpoint:** each window shows the other's square moving. ## Step 3 — Understand what you just ran - [ ] Read [core concepts](/dashboard/docs/concepts) (10 minutes) - [ ] Open `StarterGame.gd` and find: the `await SpawnWeaver.start()` call, the `create_room()`/`join_room()` calls, and where `SpawnSync` is added to each square **Checkpoint:** you can answer — *what is a room code? which copy of the square sends, and which interpolates?* ## Step 4 — Make it yours - [ ] New scene: your own player (any `CharacterBody2D` with your art + movement) - [ ] Add a **SpawnSync** node inside it, and a **PlayerSpawner** in your level pointing at the scene ([sync guide](/dashboard/docs/sync)) - [ ] Add two buttons + a code field wired to `create_room()` / `join_room(code)` **Checkpoint:** two instances of *your* game, two of *your* characters moving. ```text I'm on SpawnWeaver's beginner path step 4. My player scene is a CharacterBody2D with a SpawnSync child; my level has a PlayerSpawner with player_scene assigned. The SDK API: await SpawnWeaver.start(), await SpawnWeaver.create_room() -> result.room.code, await SpawnWeaver.join_room(code). Signals: room_joined(room), player_joined(player). Problem: . ``` ## Step 5 — Talk to each other - [ ] Add a chat box using `send_event("chat", {"text": t})` + the `event_received` signal ([events guide](/dashboard/docs/events)) - [ ] Remember: events go to the *others* — print your own line locally **Checkpoint:** messages typed in one window appear in the other. ## Finish line You've built connect → room → replicated movement → chat: the skeleton of most multiplayer games. Next: the [intermediate path](/dashboard/docs/learn-intermediate) turns this into a complete online game. --- # "Learn: intermediate path" (Learn) # Intermediate path: a complete online game You can connect two players and sync movement ([beginner path](/dashboard/docs/learn-beginner)). Now build something shippable: matchmaking, a real gameplay loop, disconnect resilience, and persistence. The [2D arena tutorial](/dashboard/docs/tutorials-2d-arena) is the worked example for most of these steps. ## Step 1 — A menu that matches players - [ ] **Find match** button → `await SpawnWeaver.find_match("your-mode")` with a *Searching…* state and a **Cancel** button (`cancel_matchmaking()`) ([matchmaking guide](/dashboard/docs/matchmaking)) - [ ] Keep the friend path: create/join by code - [ ] Optional: public lobbies with `create_room({"visibility": "public"})` + `list_rooms()` ([rooms guide](/dashboard/docs/rooms)) **Checkpoint:** two instances both press Find match and land in the same room. ## Step 2 — A gameplay loop - [ ] Pick your loop (shoot/tag/collect/race). Route each piece of shared data through the right channel: **transient → events**, **per-player → entity state**, **shared/lasting → room state** ([concepts](/dashboard/docs/concepts)) - [ ] Implement an authority rule for conflicts (e.g. victim applies its own damage; host owns the scoreboard — see the [2D arena](/dashboard/docs/tutorials-2d-arena) steps 5–6) - [ ] Win condition + rematch (host sets `{"phase": "over", "winner": id}` in room state; everyone reacts to `room_state_changed`) **Checkpoint:** a full round start-to-winner works with two players. ```text I'm designing the data flow for my SpawnWeaver game. Channels available: send_event (transient, relayed to others), set_entity/patch_entity (per-player owned state, replicated + snapshot for late joiners), set_room_state (host-only shared state). My game: . For each mechanic, which channel should carry it, and who should have authority? ``` ## Step 3 — Survive real networks - [ ] Show `player_disconnected` / `player_reconnected` in your UI (grey the avatar out) - [ ] Test it: play a round, then call `SpawnWeaver.simulate_connection_loss()` on one side — verify it resumes into the same room with state intact - [ ] Handle `room_left("expired")` and matchmaking timeouts with friendly messages **Checkpoint:** killing one player's connection mid-round self-heals within seconds. ## Step 4 — Persistence - [ ] Save the player's name and stats with `storage_set` / `storage_get` ([storage guide](/dashboard/docs/storage)) - [ ] Restart the game and verify identity + saves survive **Checkpoint:** wins recorded in one session are visible in the next. ## Step 5 — Ship a playtest - [ ] Watch a session end-to-end in the dashboard's **Live activity** and **Sessions** while you play ([debugging guide](/dashboard/docs/debugging)) - [ ] Fix everything in the **Errors** page (each has a suggested fix) - [ ] Export builds and hand them to two friends with your quickstart notes **Checkpoint:** two people who aren't you played a round on their own machines. Next: the [advanced path](/dashboard/docs/learn-advanced) — smoothness, bandwidth, authority hardening, and production deployment. --- # "Learn: advanced path" (Learn) # Advanced path: production-grade multiplayer For games that already work: make them smooth, efficient, hard to cheat, and deployed. ## Step 1 — Bandwidth & smoothness - [ ] Audit every `SpawnSync`: is `send_rate` as low as the game feels good at? (8/s default; slow-moving objects are fine at 2–4/s — the dirty-check already silences idle objects) - [ ] Trim `synced_properties` to what remote players actually render - [ ] Tune `interpolation_speed` per object (snappier for players, smoother for props); for shots/dashes, prefer an event + local simulation over per-tick sync - [ ] Watch the **rate-limited** count on the Errors page during a stress playtest ([limits](/dashboard/docs/limits)) **Checkpoint:** a 4-player session shows zero `rate-limited` errors. ## Step 2 — Authority hardening - [ ] Write down, per mechanic, *who can lie about it* (the [best-practices authority section](/dashboard/docs/best-practices) has the framework) - [ ] Move abusable decisions to the host: victims applying their own damage is convenient; the host validating kills is sturdier - [ ] Bound-check everything received in `event_received` — never trust positions, damage numbers, or ids from the wire - [ ] Handle `host_changed` mid-round: the new host must be able to take over any host-side logic (keep host state IN room state, not in host-local variables) **Checkpoint:** a modified client that sends `{"damage": 9999}` events can't win. ```text Review my SpawnWeaver authority model. Architecture: clients exchange events (relayed, sender-excluded), per-player entity state (owner-write-only), and host-only room state; the platform has no server-side game logic. My mechanics and current authority: . Which are exploitable by a modified client, and what's the strongest mitigation available WITHOUT server-side code? ``` ## Step 3 — Resilience engineering - [ ] Chaos-test: `simulate_connection_loss()` at every game phase (menu, matchmaking, mid-round, round-end) — each must recover or fail with a clear message - [ ] Handle the *permanent* failure: grace expired, room gone — `room_left("disconnected")` should land the player on a "match lost" screen, not a frozen arena - [ ] Queue-audit your `send_event` usage: everything queued during a blip flushes on resume — make sure replaying stale events (e.g. old ability casts) is harmless **Checkpoint:** no phase of the game can dead-end from a network blip. ## Step 4 — Launch readiness - [ ] Split environments: create a **production project** separate from the one your playtests used, so live players never share rooms or storage with dev builds (set it on the project page; the key in your shipped build is the only switch) - [ ] Sweep your key hygiene: the production public key is in the shipped build (fine — it's public by design), but it should appear nowhere else; rotate it if it ever leaked into a stream or screenshot alongside test data you care about - [ ] Re-read [Limits & quotas](/dashboard/docs/limits) against your real traffic — `SpawnSync` rates, event bursts, storage writes — and fix anything that budgets over the caps *before* players find it - [ ] Expecting a launch spike (festival, streamer, Next Fest)? Contact us ahead of time so your project's connection cap is sized for the day - [ ] Force-kill the game mid-match on a real device and relaunch: the reconnect flow from Step 3 is only done when it works outside the editor **Checkpoint:** the launch build points at a clean production project and a mid-match kill + relaunch gets the player back into their room with identity intact. ## Step 5 — Live operations - [ ] Bookmark **Sessions** and **Errors**; check them after every playtest - [ ] Add `create_debug_report_string()` behind a debug key in your game; ask bug reporters to paste it — inspect via the dashboard's **Debug bundle** viewer - [ ] Track your usage on the **Usage** page as your playtests grow **Checkpoint:** when a player says "it broke", you can see *their* session timeline. You're production-grade. Go ship it — and tell us what you built via the feedback box on the landing page. --- # Limits & quotas (Operations) Every SpawnWeaver limit, its value, the error you get when you hit it, and the fix. Nothing here is a silent cap — every limit answers with a named error code. ## The table | Limit | Value | Error raised | What to do | |---|---|---|---| | Message size (any realtime message) | 16 KB | `payload-too-large` | Shrink the payload; move big data to [storage](/dashboard/docs/storage) | | Message rate per connection | 20/s sustained, burst 40 | `rate-limited` (retryable) | Back off briefly and retry; batch sends | | State updates per client | 10/s sustained, burst 20 | `rate-limited` (retryable) | Send at ≤10/s; `SpawnSync`'s default `send_rate` of 8 is safe | | Entities per room | 50 | `state-limit-exceeded` | Delete finished entities; pool projectiles into events | | State per entity | 4 KB | `state-too-large` | Keep entities small (transform + a few fields) | | Room state size | 16 KB | `state-too-large` | Trim room state; remove keys with `null` patches | | Storage value size | 64 KB per key | `storage-value-too-large` (HTTP 413) | Split or compress the value | | Storage keys per player | 100 | `storage-quota-exceeded` (HTTP 409) | Delete unused keys; consolidate into fewer dictionaries | | Storage key length | 128 chars, non-empty | `storage-invalid-key` (HTTP 400) | Use short, stable key names | | Matchmaking size | 2–64 players | `invalid-payload` | Pick a size in range | | Matchmaking wait | 30 s per ticket | `match-timeout` (retryable) | Offer "search again"; see [Matchmaking](/dashboard/docs/matchmaking) | | Connections per project | generous per-tier cap | Handshake rejected with HTTP 429 | Retry with backoff; contact us if your launch needs more | | Disconnect grace | 60 s | after expiry: `player_left` with reason `disconnected`; entities removed (`owner-left`) | Design reconnect UX around ~1 minute | | Empty-room TTL | 60 s | room expires: joiners get `room-not-found`; in-grace members get `room_left("expired")` | Rejoin/recreate; don't park empty rooms | | Room metadata | no dedicated cap | bounded by the 16 KB message limit | Keep metadata to a handful of small strings | | Auth endpoint rate (sign-in/up) | per-IP throttle | HTTP 429 | Wait a minute; don't loop sign-in attempts | ## How limit errors behave - **Retryable errors** (`rate-limited`, `match-timeout`, plus the SDK-local `timeout` and `disconnected`) carry `retryable: true` on the `SWError` — the same call may succeed after a short backoff. - **Hard errors** (`payload-too-large`, `state-too-large`, `state-limit-exceeded`, storage quota errors) are not retryable: the same call will fail again until you change what you send. - Rate and size rejections still echo your request id, so an awaited call resolves with the error rather than timing out: ```gdscript var result := await SpawnWeaver.set_entity("boss", huge_dictionary) if not result.ok and result.error.code == "state-too-large": push_warning("Entity too big: %s" % result.error.message) ``` - `send_event()` has no await; its limit errors arrive on the `error` signal instead. ## Budgeting within the rate limits The 20 messages/s connection budget covers *everything* you send: events, state updates, storage calls, and the SDK's heartbeat (one ping every 15 s — negligible). A practical split for an action game: | Traffic | Budget | |---|---| | `SpawnSync` transform updates | 8/s (the default `send_rate`) | | Gameplay events | ≤ 5/s sustained | | Manual state patches (host, room state) | ≤ 2/s | | Storage saves | occasional (on change, not on a timer) | Bursts up to 40 messages are absorbed by the token bucket, so a spiky moment (three events in one frame) is fine — sustained overrun is what triggers `rate-limited`. ## Timing windows at a glance | Window | Default | Meaning | |---|---|---| | Disconnect grace | 60 s | A dropped player's seat and entities are kept; reconnect resumes them | | Empty-room TTL | 60 s | A room with no connected members is removed | | Matchmaking timeout | 30 s | A ticket that finds no match resolves with `match-timeout` | | SDK request timeout | 10 s | An awaited call with no reply fails with local `timeout` (retryable) | | SDK matchmaking guard | 120 s | Client-side ceiling on `find_match()` awaits | | Heartbeat | 15 s interval | Connection declared dead after ~45 s of silence; auto-reconnect kicks in | | Reconnect backoff | 0.5 s → 30 s cap | Exponential with jitter, forever until `stop()` | ## Need a higher limit? The values above are tuned so a normal game never meets them; they exist to keep one project's bug from degrading everyone else's service. If your game legitimately needs more — a big playtest, a launch spike, bigger rooms — [contact us](/dashboard/docs/faq) and we'll raise the right knob for your project. --- # Pricing (Help) > **Draft pricing — numbers under review; the alpha is free for everyone today.** Two commitments shape this page. **Every feature is in every tier** — paid plans buy capacity, not features. And we meter **concurrent players (CCU)**, not bandwidth, so your bill is something you can reason about instead of a daily meter that cuts your game off mid-playtest. ## Tiers ### Free — $0 For development, playtests, and small launches. Not a trial. - **Rooms up to 8 players** — a full party, not a 4-player teaser - **100 concurrent players** (roughly 1,000–2,000 monthly players) - **Unlimited projects** - **All features**: rooms & lobbies, matchmaking, state sync, realtime events, player storage, leaderboards, Steam, the multiplayer debugger ### Indie — $15/month The tier most shipped games live on. - **Rooms up to 32 players** - **1,000 concurrent players** (roughly 10,000–20,000 monthly players) - Higher storage and leaderboard quotas - Everything in Free ### Studio — $79/month For a game that's found its audience. - **Rooms up to 128 players** - **10,000 concurrent players** - Priority support — a human answers first - Everything in Indie Bigger than that? [Talk to us](/dashboard/docs/faq) — past 10,000 concurrent players you want a conversation, not a checkout page. ## Honest notes ### What costs money today? Nothing. SpawnWeaver is in **alpha and free for everyone** — the tiers above are a draft of where we intend to land, published so you can plan (and object) before they ship. Alpha users get generous notice before anything changes. ### What is a "concurrent player"? Everyone connected to your game at the same moment. It's the number the [dashboard](/dashboard/realtime) shows you live, and it maps directly to what serving your game actually costs us — which is why we bill on it. Concurrent players are a small slice of total players: a common rule of thumb is that peak CCU lands around 5–10% of monthly players. A game with 10,000 people playing over a month typically peaks somewhere around 500–1,000 at once. ### Why meter players instead of bandwidth? Because you can predict players and you can't predict bytes. Bandwidth meters punish exactly the moment you want to celebrate — a launch spike, a streamer picking your game up — and they make you audit your netcode to understand your invoice. Competing services cap free lobbies at 4 players *and* meter daily traffic; we'd rather charge for the thing you're actually buying. ### Are there hidden caps? No. The technical limits — message rates, state sizes, storage quotas — are listed with their exact values in [Limits & quotas](/dashboard/docs/limits). Nothing is throttled silently. ### What happens when I hit a limit? You get a **clear, named error** (for example `rate-limited` or `storage-quota-exceeded`), documented in [Limits & quotas](/dashboard/docs/limits) together with the fix. Nothing is silently dropped and your room isn't killed. ### What if I go over my CCU on launch day? We contact you — we don't cut your game off mid-session. If a launch, festival or stream is coming, tell us ahead of time and we'll size your project for the day. ### Why is the free room size 8, not 4? Because 4 players isn't a party — it's a teaser. We'd rather you ship your 6-player co-op on the free tier and pay only when your game genuinely outgrows it. ### Can I trust these numbers? Treat them as a **draft**. The structure — full features in every tier, CCU-based metering, a free tier you can actually ship on — is the commitment; exact prices and ceilings may still move before launch. This page is updated first. --- # FAQ (Help) Short, honest answers. If your question isn't here, use the feedback form on the landing page — it goes straight to the developers. ## What does SpawnWeaver cost? Nothing right now. SpawnWeaver is in **alpha** and free while we stabilize it. When paid tiers arrive, there will be a free tier that comfortably covers development and small playtests, and alpha users will get generous notice before anything changes. ## Which engines are supported? **Godot 4.3+ only**, via the GDScript SDK. There is no Unity/Unreal/web SDK. The [wire protocol](/dashboard/docs/reference-protocol) is documented JSON over a WebSocket, so a client for another engine is possible to write — but Godot is the product. ## How many players can it handle? Honest alpha numbers: a single server comfortably handles **hundreds of concurrent connections** and thousands of messages per second. Rooms support up to 64-player matchmaking sizes, but the sweet spot is small-room games (2–16 players). SpawnWeaver runs on one node today — there is no multi-node clustering yet. It is good enough for playtests and small launches, not for a viral hit on day one. ## Does my game need the secret key? **Never.** Game clients only use the public key (`pk_…`), which is safe to ship and commit. The secret key (`sk_…`) is for your own servers and tools (HTTP storage access, admin scripts). If you ever put `sk_…` in a client build, regenerate it from the dashboard. ## Can I run server-side game logic or dedicated servers? Not yet. SpawnWeaver relays events and hosts shared state between clients; it does not run your game code. The usual pattern is **host-authoritative**: the room's host client makes the decisions and writes room state (see [Best practices](/dashboard/docs/best-practices)). Dedicated/authoritative simulation is not part of the current release. ## Which export platforms work? Desktop exports (Windows, macOS, Linux) work out of the box — the SDK is pure GDScript over WebSockets. Mobile exports should work like desktop but see less testing today. **Web (HTML5) exports work** — verified end to end (a browser build connects, joins rooms, and syncs state like any desktop build). The SDK authenticates via the connect URL (never custom headers, which browsers can't set on WebSockets), uses `WebSocketPeer` (browser-WebSocket-backed on web), needs no threads, and stores identity in `user://` (IndexedDB-backed on web). The plugin also ships your `spawnweaver.cfg` inside every export automatically — no export-filter fiddling. Two caveats: a page served over `https://` must connect via `wss://` (the default — mixed content is blocked otherwise), and two tabs of your game in the same browser share one player identity. ## Where is my data stored? Player storage lives in the service's PostgreSQL database on infrastructure we operate; realtime room data exists only in memory and is never persisted. Storage entries are yours — delete them any time via the SDK or the dashboard, and deleting a project removes its data. ## What happens when the server restarts? Active **rooms and sessions are dropped** — realtime state is in-memory. Clients reconnect automatically and player identities survive, but everyone is back in the menu and needs to create/join rooms again. Player **storage is unaffected** — it's in the database. Restarts are rare and scheduled where possible. ## Can my AI assistant help me build with SpawnWeaver? Yes — point it at [`/llms.txt`](https://spawnweaver.dev/llms.txt) (a compact index) or [`/llms-full.txt`](https://spawnweaver.dev/llms-full.txt) (the entire documentation as one markdown file). Paste the full file into Claude, ChatGPT, or Copilot Chat and it can answer questions and write SpawnWeaver code against the real, current API. The [AI tutorial prompts](/dashboard/docs/learn-beginner) in the Learn section pair well with this. ## Is SpawnWeaver open source? The **SDK is source-visible by nature** — it's plain GDScript that lives in your project under `addons/spawnweaver/`, so you can read, step through, and patch every line your game runs. The **backend is a hosted service**, not open source — you never deploy or operate anything. License terms for the SDK are still being finalized during the alpha. ## How does player identity work without accounts? The first connect creates an anonymous player id and a signed token; the SDK stores the token on the player's machine and presents it on every reconnect. Same machine, same player — no sign-up, no passwords. Details in [Players & identity](/dashboard/docs/players). ## How is this different from Photon? Photon is a mature, engine-agnostic commercial platform with big-scale infrastructure. SpawnWeaver is **Godot-native and radically simpler**: one autoload, awaitable calls, two drop-in nodes, and a debugger that speaks Godot. If you want a 4-player co-op or a small arena game running this afternoon without learning a networking framework, that's the trade we optimize for. Migrating? See [Migration](/dashboard/docs/migration). ## How is this different from Nakama? Nakama is an open-source game server you deploy and extend with server-side Lua/Go/TS modules — powerful, and correspondingly more to operate and learn. SpawnWeaver is a hosted service with no server code to write: rooms, matchmaking, state sync, and storage over one WebSocket. If you need custom server-side logic today, Nakama is the better fit; if you need multiplayer without backend work, SpawnWeaver is. ## Why is my room gone after everyone left? Rooms with no connected members expire after about a minute (the empty-room TTL). Rooms are meeting places, not persistent worlds — persist anything long-lived in [player storage](/dashboard/docs/storage) and recreate rooms on demand. ## Can two players on the same machine test my game? Yes — click **Playtest ×2** in the SpawnWeaver dock (or use Godot's **Debug → Run Multiple Instances → 2** and press Play); each window is a separate client. This is the standard local test loop (see the [Quickstart](/dashboard/docs/quickstart)). --- # Troubleshooting (Help) Find your symptom, get the fix. Errors come with machine-readable codes — locally raised codes come from the SDK, kebab-case codes like `room-not-found` come from the server. Turn on `SpawnWeaver.set_debug_enabled(true)` to see them all in the output. ## Install & plugin issues | Symptom | Cause | Fix | |---|---|---| | `SpawnWeaver` is "not declared in the current scope" | Plugin not enabled | Project → Project Settings → Plugins → enable "SpawnWeaver", restart the editor | | "PlayerSpawner: the SpawnWeaver plugin is not enabled" / same for SpawnSync | Nodes used without the autoload registered | Enable the plugin; the autoload registers at `/root/SpawnWeaver` | | No SpawnWeaver dock in the editor | Plugin disabled, or addon extracted to the wrong path | The addon must live at `addons/spawnweaver/`; re-run the installer from the folder containing `project.godot` | | Installer script fails to download | No network, or a proxy blocking the download | Check `https://spawnweaver.dev/health` opens in a browser; use the manual zip install as fallback | | Errors after upgrading the addon | Editor caching old scripts | Close Godot, delete `.godot/` cache folder if needed, reopen | ## Connect failures `start()` fails, or the dock's **Test connection** reports an error. | Symptom / code | Cause | Fix | |---|---|---| | `not-configured` — "No project key" | No key in the dock, `spawnweaver.cfg`, or `configure()` | Paste your `pk_…` key in the dock and Save | | `connect-failed` — "is the server reachable and the project key valid?" | Wrong URL, server down, or handshake rejected | Check the Server URL; for local dev confirm `http://localhost:5159/health`; verify the key | | Handshake rejected with **401** | Missing/unknown/inactive project key, or an invalid/expired player token | Copy the key again from the dashboard; a deactivated project or rotated public key also 401s | | Handshake rejected with **403** | Web build served from an origin the service rejects | Native builds are unaffected; for web builds, contact us to allow your game's origin | | Handshake rejected with **429** | The project's connection cap is reached | Retry with backoff; contact us before a launch spike to raise the cap | | Works locally, fails in a release build | Using `ws://` against a TLS server (or vice versa) | Hosted/production is `wss://…/connect`; plain `ws://` only for local unproxied dev | | Connects, then drops after ~45s repeatedly | A proxy on the player's network kills idle WebSocket traffic | Common on corporate/school networks — the SDK's auto-reconnect recovers, but sustained play needs a network that allows `wss://` | ## In-game errors These come back on the awaited `SWResult` (or the `error` signal for `send_event`). | Code | When | Fix | |---|---|---| | `not-connected` | Any request before `start()` succeeded (SDK-local) | `await SpawnWeaver.start()` once at boot; check `SpawnWeaver.is_online()` | | `not-in-room` | Room-scoped call (events, state, leave) without a room | Create/join a room first | | `already-in-room` | `create_room`/`join_room`/`find_match` while in a room | `await SpawnWeaver.leave_room()` first — one room per connection | | `room-not-found` | Bad code, or the room expired (empty-room TTL, server restart) | Re-check the code; recreate the room | | `room-full` | `max_players` reached — in-grace members count | Wait, or raise `max_players` via the host's `update_room()` | | `not-host` | `update_room()` or `set_room_state()` from a non-host | Check `SpawnWeaver.is_host`; react to `host_changed` | | `state-forbidden` | Writing an entity another player owns | Only the creator writes an entity; route via its owner or an event | | `state-limit-exceeded` / `state-too-large` | Too many entities / entity or room state too big | See [Limits](/dashboard/docs/limits); delete finished entities, slim the state | | `rate-limited` | Message or state budget exceeded (retryable) | Back off and retry; cap send rates ([Limits](/dashboard/docs/limits)) | | `payload-too-large` | A message over 16 KB | Shrink the payload; big data belongs in [storage](/dashboard/docs/storage) | | `entity-not-found` | Patch/delete of an entity that doesn't exist | Create with `set_entity()` first; it may have been GC'd when its owner left | | `storage-invalid-key` / `storage-value-too-large` / `storage-quota-exceeded` | Storage rules violated | Key ≤128 chars, value ≤64 KB, ≤100 keys — delete old keys | | `invalid-payload` | A malformed field (e.g. matchmaking `size` outside 2–64, `\|` in mode/region) | Fix the offending field; the message says which | | `timeout` (SDK-local, retryable) | No reply within 10s (120s for `find_match`) | Usually a dying connection — the SDK reconnects; retry the call | | `disconnected` (SDK-local, retryable) | Connection dropped mid-request | Retry after `connected` fires; queued events flush automatically | | `cancelled` (SDK-local) | You called `cancel_matchmaking()` | Expected — not an error to surface to players | | `match-timeout` (retryable) | No match within the server window | Offer "search again" ([Matchmaking](/dashboard/docs/matchmaking)) | | `malformed-message` / `unknown-message-type` | Hand-rolled protocol messages, or SDK/server version mismatch | Upgrade the SDK and server to matching versions | ## Editor dock issues | Symptom | Cause | Fix | |---|---|---| | Test connection fails but the key is right | Server field points somewhere stale (e.g. an old local port) | Clear the Server field for hosted, or set `ws://127.0.0.1:5159/connect` for local | | "Live: —" never updates | Stats need a saved, valid key; the poll hits `/connect/stats?projectKey=…` | Save the key first; verify the server is reachable from the editor | | Dock shows old values after `quickstart.ps1` | Config was written while the editor had the file cached | The script writes `res://spawnweaver.cfg`; reopen the project or reload the dock | | Starter scene button does nothing visible | Scene generated to `res://spawnweaver/` | Look for `res://spawnweaver/StarterGame.tscn` in the FileSystem panel | ## Reconnect loops The `reconnecting(attempt, delay)` signal fires repeatedly and the game never settles. | Cause | How to tell | Fix | |---|---|---| | Service unreachable (outage or local network) | Attempts climb forever; `connect-failed` details in debug output | The SDK backs off to 30s between attempts and recovers alone once the connection is back | | Project key deactivated or rotated mid-session | Reconnects are rejected (401) over and over | Ship the new key; call `stop()` and surface a "update required" message | | A proxy/VPN on the player's network kills WebSocket traffic | Connects fail or drop only on that network | Players on corporate/school networks may need to allow `wss://` to `spawnweaver.dev` | While reconnecting, awaited calls fail fast with `disconnected` and `send_event` calls queue automatically — design the UI to show a "reconnecting…" state rather than erroring. When the session resumes, `connected` fires and, if your room seat survived the grace window, `room_joined` fires again with the fresh state. ## Still stuck? 1. `SpawnWeaver.set_debug_enabled(true)` and read the message log. 2. Copy `SpawnWeaver.create_debug_report_string()` and paste it into the dashboard's [Debug Bundle viewer](/dashboard/debug). 3. Find the session in the [session inspector](/dashboard) — the timeline shows every rejection with its code. 4. See [Debugging](/dashboard/docs/debugging) for the full toolbox. --- # Best practices (Help) Patterns that keep SpawnWeaver games simple, cheap, and robust. Most of this boils down to two habits: put each piece of data in the right channel, and let the host decide. ## Choose the right channel Four channels, four jobs: | Channel | Persistence | Visibility | Writer | Use for | |---|---|---|---|---| | **Events** (`send_event`) | none — relayed once | other room members | anyone | Moments: shots, emotes, chat, "ready" clicks | | **Entities** (`set_entity`/`SpawnSync`) | life of the owner's membership | whole room + late joiners | the owner | Per-object live state: transforms, hp, a bomb's fuse | | **Room state** (`set_room_state`) | life of the room | whole room + late joiners | the host | Room-wide facts: phase, scores, round timer, map | | **Storage** (`storage_set`) | forever | that player only | that player | Progression: saves, unlocks, settings | Decision shortcuts: - *Would a late joiner need it?* → not an event. - *Does exactly one player own it?* → entity. *Is it about the whole room?* → room state. - *Should it outlive the room?* → storage. - When torn between event and state, pick **state** — it survives reconnects for free. ## Host-authoritative patterns Without dedicated servers, the room **host** is your referee. Keep game-deciding logic on the host and let everyone else render: ```gdscript # Everyone reports their moment: SpawnWeaver.send_event("flag_touched", {}) # Only the host adjudicates and writes the result: func _on_event(name: String, data: Dictionary, sender: SWPlayer) -> void: if name == "flag_touched" and SpawnWeaver.is_host: if _flag_untouched: _flag_untouched = false SpawnWeaver.set_room_state({"winner": sender.id, "phase": "ended"}) # Everyone (host included) reacts to the verdict: SpawnWeaver.room_state_changed.connect(func(state, patch): if patch.has("phase") and state["phase"] == "ended": show_victory(state["winner"])) ``` Rules of the pattern: - **Inputs up as events, verdicts down as room state.** Ties are resolved by the host processing events in arrival order. - **Always handle `host_changed`.** The new host must be able to pick up refereeing from the current room state alone — keep everything the referee needs *in* room state, not in host-local variables. - Idempotent verdicts: a re-sent event or a resumed session must not double-award. ## Trusting clients (and when not to) SpawnWeaver clients are authoritative over their own entities — a modified client can teleport its own avatar or lie in events. For casual and co-op games between friends, accept this; the simplicity is worth more than cheat-proofing. Where it matters: - **Let the host validate.** Sanity-check event data (`speed`, positions, cooldowns) before writing verdicts. - **Never trust clients with persistent rewards.** If gold/unlocks matter, have the host write results, and treat storage as the player's own save (they can only hurt themselves). - **Competitive/ranked play needs server authority**, which SpawnWeaver does not provide today — design around it or wait for it. ## Bandwidth budgeting You have 20 messages/s per connection (state changes: 10/s). Spend them deliberately: - Leave `SpawnSync.send_rate` at 8 — it looks smooth with interpolation and leaves headroom. - The dirty-check means idle objects are free: **stop moving = stop sending**. Design levels where things rest. - Patch, don't set: `patch_entity(id, {"hp": 90})` beats re-sending the whole state. - Coalesce events: one `"round_results"` event, not ten `"player_scored"` events in a burst. - Don't mirror `SpawnSync` data in events or room state — one channel per datum. ## Testing with multiple instances - **Debug → Run Multiple Instances → 2** (or more) is the core loop; every feature should be exercised with two windows before you trust it. - Test the ugly paths deliberately: call `SpawnWeaver.simulate_connection_loss()` mid-game and watch your reconnect UX; kill a window to watch the grace window and host migration. - Keep a debug key bound to `create_debug_report_string()` → clipboard from day one. - Run against a local server (`./quickstart.ps1`) for fast iteration; hit the hosted service before releases to catch TLS/latency differences. ## Handling `player_disconnected` in UI Disconnections are usually 5-second blips, not departures. The good pattern: ```gdscript SpawnWeaver.player_disconnected.connect(func(p): grey_out(p.id) # ghost the avatar, pause their turn show_toast("%s is reconnecting…" % p.display_name())) SpawnWeaver.player_reconnected.connect(func(p): restore(p.id)) SpawnWeaver.player_left.connect(func(p, reason): remove(p.id) # only NOW is it a real departure if reason == "disconnected": show_toast("%s lost connection." % p.display_name())) ``` Never remove players (or their entities) on `player_disconnected` — the server keeps their seat and their entities for the grace window, and `SpawnSync` will resume seamlessly when they return. ## Graceful degradation Multiplayer fails sometimes; the game should bend, not break: - **Boot without blocking.** If `start()` fails, open the main menu anyway with multiplayer buttons disabled and a retry — don't gate the whole game on connectivity. - **Show reconnect state.** On `reconnecting(attempt, delay)`, freeze gameplay input and show a banner; on `connected` + `room_joined`, resume. Awaited calls made during the gap fail fast with `disconnected` — treat that as "try again shortly", not fatal. - **Expect `room_left`.** A room can end under you (`"expired"`, `"disconnected"` after a long outage) — always have a path back to the menu with a human message. - **Cache storage locally.** Keep the last-known save in `user://` and reconcile with `storage_get` on boot, so an offline launch still plays. - **Single-player fallback.** If your design allows, let a solo player play against the environment while waiting for `find_match` — the await pattern makes it easy to swap in the real room when it resolves. ## Small habits that pay off - One `await SpawnWeaver.start()` at boot, in an autoload of your own — everything else assumes online-or-reconnecting. - Key all game logic by `player.id`, never by display name or roster index. - Namespace event names (`"chat"`, `"combat/fired"`) and entity ids (`"bomb_3"`, `"pickup_hp_1"`) from the start. - Check `result.ok` on every await in shipped code — the compiler won't make you. - Read [Limits](/dashboard/docs/limits) once; design inside them rather than discovering them in production. --- # Migrating to SpawnWeaver (Help) Mapping tables from the three systems people most often arrive from. The short version: rooms, spawning, and transform sync map cleanly; RPCs become events; server authority does not map — SpawnWeaver has no server-side game code. ## From Godot's built-in High-Level Multiplayer API Godot's HLMP (ENet peers, `MultiplayerSpawner`, `MultiplayerSynchronizer`, RPCs) is peer-hosted: one player *is* the server, everyone needs connectivity to them (port forwarding/NAT), and the session dies with the host. SpawnWeaver replaces the transport and hosting with a service — no ports, no host machine, codes instead of IPs. | Godot HLMP | SpawnWeaver equivalent | |---|---| | `ENetMultiplayerPeer.create_server(port)` | `await SpawnWeaver.create_room()` — no ports, share `room.code` | | `ENetMultiplayerPeer.create_client(ip, port)` | `await SpawnWeaver.join_room(code)` | | `multiplayer.peer_connected` / `peer_disconnected` | `player_joined(p)` / `player_left(p, reason)` signals | | `multiplayer.get_unique_id()` (peer int) | `SpawnWeaver.player.id` (stable string, survives restarts) | | `multiplayer.is_server()` | `SpawnWeaver.is_host` (host can change — watch `host_changed`) | | `MultiplayerSpawner` + spawn function | `PlayerSpawner` node (player avatars, automatic) | | `MultiplayerSynchronizer` + replication config | `SpawnSync` node (transform + `synced_properties`) | | `@rpc` methods / `rpc()` calls | `send_event(name, data)` + `event_received` — data, not remote calls | | `@rpc("authority")` server-side decisions | Host-authoritative pattern via room state ([Best practices](/dashboard/docs/best-practices)) | | Scene tree replication of arbitrary nodes | Entities (`set_entity`/`patch_entity`) — state dictionaries, not node trees | | Host quits → session dies | Host migration — the room continues with a new host | What doesn't map: - **RPCs.** There is no "call a function on another peer". Send an event carrying data; the receiver decides what to do. This is a design improvement in disguise — events are inspectable, queueable, and can't call arbitrary code. - **Tick-level physics sync / rollback.** SpawnSync interpolates at up to 10 updates/s. Fighting-game-grade netcode is out of scope. - **`multiplayer.multiplayer_peer` ecosystem** (e.g. WebRTC mesh) — SpawnWeaver is its own client, not a `MultiplayerPeer` implementation. ## From Photon (PUN / Realtime / Fusion) Photon's room model translates almost one-to-one; the main mental shift is that SpawnWeaver has no Unity-style magic components — sync is explicit nodes and calls. | Photon | SpawnWeaver equivalent | |---|---| | `PhotonNetwork.ConnectUsingSettings()` | `await SpawnWeaver.start()` | | App id | Project public key (`pk_…`) | | `CreateRoom` / `JoinRoom(name)` | `create_room()` / `join_room(code)` | | `JoinRandomRoom` / lobbies | `find_match(mode)` — or `list_rooms()` for a browser UI | | Lobby / room listing | Public rooms + `list_rooms()` (a lobby is just a public room) | | Room custom properties | Room `metadata` (static facts) + room state (live values) | | Player custom properties | Your player's entity state, or per-player keys in room state | | Master client | Host (`SpawnWeaver.is_host`) with automatic migration | | `RaiseEvent` / `OnEvent` | `send_event()` / `event_received` (sender-excluded, like default Photon) | | `PhotonView` + `PhotonTransformView` | `SpawnSync` node | | Instantiate over network | `PlayerSpawner` for avatars; `set_entity()` + a spawn-on-`entity_changed` listener for other objects | | RPCs (`photonView.RPC`) | Events — data messages, not method calls | What doesn't map: Photon's regions/cloud tiers, interest groups, and Fusion's server-authoritative simulation. SpawnWeaver rooms are small and fully-broadcast — no interest management yet. ## From Nakama Nakama is a self-hosted server you extend with Lua/Go/TS server modules; SpawnWeaver deliberately has no server-side scripting. The client-facing features map like this: | Nakama | SpawnWeaver equivalent | |---|---| | Device / anonymous authentication | Automatic — first connect mints an identity, token persisted by the SDK | | Sessions & refresh tokens | Handled inside the SDK (fresh token per connect, sliding expiration) | | Match (relayed multiplayer) | Room | | Match join code / match listing | Room code / `list_rooms()` | | Matchmaker (`AddMatchmakerAsync`) | `find_match(mode, {region, size})` — exact-match buckets, no skill query language | | Match state messages (op codes) | Events (`send_event` with a name instead of an op code) | | Match presence events | `player_joined` / `player_left` / `player_disconnected` signals | | Storage engine (collections/keys) | Player storage (`storage_get/set/delete/list`) — per-player only, no shared collections | | Server runtime modules (authoritative matches, hooks) | **No equivalent** — use the host-authoritative pattern | | Leaderboards, groups, chat channels, notifications | **No built-ins** — chat is a trivial [event](/dashboard/docs/events); leaderboards need your own service over the [HTTP storage API](/dashboard/docs/reference-http-api) | ## Honest summary Choose SpawnWeaver when you want rooms, matchmaking, transform sync, and saves with near-zero integration cost in Godot. Stay with (or choose) the alternatives when you need server-authoritative simulation, competitive anti-cheat, interest management for big worlds, or built-in social systems — SpawnWeaver doesn't pretend to have them today. Migration order that works well: 1. Replace connection/room plumbing (`start`, `create_room`/`join_room`) — one afternoon. 2. Swap synchronizers for `SpawnSync`/`PlayerSpawner` and delete your interpolation code. 3. Re-express RPCs as events; move authoritative decisions into host-written room state. 4. Port saves to player storage. Then delete your port-forwarding docs, relay servers, and connection-failure FAQ — that's the payoff. --- # SDK reference (Reference) Complete reference for the SpawnWeaver Godot SDK (v1.0.0). The entire client API lives on the `SpawnWeaver` autoload; every request method is awaitable and returns an [`SWResult`](#swresult). Guides: [Rooms](/dashboard/docs/rooms), [State sync](/dashboard/docs/sync), [Events](/dashboard/docs/events), [Storage](/dashboard/docs/storage). ## The `SpawnWeaver` autoload ### Properties | Property | Type | Meaning | |---|---|---| | `status` | `Status` enum | `OFFLINE`, `CONNECTING`, or `ONLINE` | | `player` | `SWPlayer` | Your identity; `null` until `start()` succeeds | | `room` | `SWRoom` | Your current room, maintained automatically; `null` when not in a room | | `is_host` | `bool` (read-only) | `true` when you host your current room | | `latency_ms` | `float` | Heartbeat round-trip in milliseconds | | `identity_scope` | `String` | Advanced: isolates identity persistence per client instance (tools/tests). Leave empty in games | ### Constants | Constant | Value | Meaning | |---|---|---| | `SDK_VERSION` | `"1.0.0"` | Reported to the server and in debug reports | | `CONFIG_PATH` | `res://spawnweaver.cfg` | Project config written by the editor dock | | `OVERRIDE_CONFIG_PATH` | `user://spawnweaver_override.cfg` | Per-machine override (local tooling; never ship) | | `IDENTITY_PATH` | `user://spawnweaver/identity.cfg` | Where the player token persists between runs | | `DEFAULT_SERVER_URL` | `wss://spawnweaver.dev/connect` | Fallback server URL | --- ## Lifecycle ### `configure(project_key: String, server_url: String = "") -> void` Sets credentials in code instead of using `res://spawnweaver.cfg`. Call before `start()`. `server_url` is optional (defaults to production). ```gdscript SpawnWeaver.configure("pk_your_key", "ws://127.0.0.1:5159/connect") ``` ### `start() -> SWResult` Connects using the configured project key. Resolves once the session is welcomed (**value: your `SWPlayer`**) or the first connection attempt fails. After a successful start, the SDK auto-reconnects forever until `stop()`. Calling `start()` while already online resolves immediately; while connecting, it awaits the in-flight attempt. ```gdscript var result := await SpawnWeaver.start() if not result.ok: show_error(result.error.message) # e.g. code "not-configured", "connect-failed" ``` ### `stop() -> void` Disconnects and stops auto-reconnect. In-flight requests fail with code `stopped`; `room_left("left")` fires if you were in a room. Safe to call at any time. ### `is_online() -> bool` `true` when `status == Status.ONLINE`. ### `set_display_name(display_name: String) -> void` Sets the name other players see. Applied on your **next** create/join/match. ### `set_debug_enabled(enabled: bool) -> void` Toggles SDK console logging of every message and error. --- ## Rooms ### `create_room(options: Dictionary = {}) -> SWResult` Creates a room and puts you in it as host. **Value: the `SWRoom`** (`result.room`). | Option | Type | Default | Notes | |---|---|---|---| | `name` | `String` | none | Display name | | `visibility` | `String` | `"private"` | `"private"` (code-only) or `"public"` (listable) | | `max_players` | `int` | unlimited | In-grace members count toward the cap | | `metadata` | `Dictionary` | `{}` | Your data, visible to joiners and listings | ```gdscript var result := await SpawnWeaver.create_room({"visibility": "public", "max_players": 4}) if result.ok: print(result.room.code) ``` Errors: `already-in-room`, `not-connected`. ### `join_room(code: String) -> SWResult` Joins by share code, or by a public room's `id` from `list_rooms()` (strings starting with `room_`/`match_` are treated as ids). **Value: the `SWRoom`.** ```gdscript var result := await SpawnWeaver.join_room("4V8772") ``` Errors: `room-not-found`, `room-full`, `already-in-room`, `not-connected`. ### `leave_room() -> SWResult` Leaves your current room. **Value: null.** Fires `room_left("left")` on success. Errors: `not-in-room`, `not-connected`. ### `list_rooms(limit: int = 20) -> SWResult` Lists joinable public rooms of your project, most recently active first. **Value: `Array` of [`SWRoomSummary`](#swroomsummary)** (`result.rooms`). ```gdscript var result := await SpawnWeaver.list_rooms() for summary in result.rooms: print(summary.code, " ", summary.player_count) ``` ### `update_room(changes: Dictionary) -> SWResult` Host only. Updates room attributes; only provided fields change, but `metadata` **replaces the whole map**. Accepts the same keys as `create_room` options. **Value: the updated `SWRoom`.** All members receive `room_updated`. Errors: `not-host`, `not-in-room`. --- ## Matchmaking ### `find_match(mode: String = "default", options: Dictionary = {}) -> SWResult` Queues for a match and resolves when one is found (**value: the `SWRoom`**), the server times the ticket out, or you cancel. Options: `region` (`String`, default `"global"`), `size` (`int`, default 2, range 2–64). A second call replaces the first ticket. ```gdscript var result := await SpawnWeaver.find_match("duel", {"region": "eu"}) if result.ok: start_game(result.room) elif result.error.code == "match-timeout": offer_retry() ``` Errors: `match-timeout` (retryable), `cancelled`, `already-in-room`, `invalid-payload` (bad size, or `|` in mode/region), `not-connected`. Client-side await ceiling: 120 s. ### `cancel_matchmaking() -> SWResult` Cancels matchmaking: any awaited `find_match()` resolves with code `cancelled`, and the server-side ticket is removed. **Value: `{"hadTicket": bool}`.** --- ## Events ### `send_event(event_name: String, data: Dictionary = {}) -> void` Fire-and-forget relay to the **other** members of your room (you never receive your own event). Not awaitable; failures arrive on the `error` signal. Queued automatically while reconnecting (up to 128 messages) and flushed on resume; dropped with a warning when fully offline. ```gdscript SpawnWeaver.send_event("fired", {"dir": [0, 1]}) ``` --- ## State sync All four calls resolve with **value: the confirmed change payload** (a raw `Dictionary` — e.g. `{roomId, entityId, ownerId, patch, state}`); the matching signal (`room_state_changed` / `entity_changed` / `entity_removed`) fires for everyone, including you. Patches are shallow merges; a `null` value **removes** the key. ### `set_room_state(patch: Dictionary) -> SWResult` Host only: patches the room-level shared state. ```gdscript await SpawnWeaver.set_room_state({"phase": "combat", "old_key": null}) ``` Errors: `not-host`, `not-in-room`, `state-too-large`, `rate-limited`. ### `set_entity(entity_id: String, data: Dictionary) -> SWResult` Creates **or replaces** an entity you own. Errors: `not-in-room`, `state-forbidden` (someone else owns it), `state-limit-exceeded`, `state-too-large`, `rate-limited`. ### `patch_entity(entity_id: String, patch: Dictionary) -> SWResult` Patches an entity you own. Errors: as `set_entity`, plus `entity-not-found`. ### `delete_entity(entity_id: String) -> SWResult` Deletes an entity you own. Everyone receives `entity_removed(id, "deleted")`. Errors: `entity-not-found`, `state-forbidden`, `not-in-room`. --- ## Player storage ### `storage_get(key: String) -> SWResult` **Value: the stored Variant, or `null` when the key was never set.** ### `storage_set(key: String, value: Variant) -> SWResult` Persists any JSON-serializable Variant. Survives restarts. **Value: null.** Errors: `storage-invalid-key`, `storage-value-too-large`, `storage-quota-exceeded`. ### `storage_delete(key: String) -> SWResult` **Value: `bool` — `true` when the key existed.** ### `storage_list(prefix: String = "") -> SWResult` **Value: `Array[String]` of this player's keys**, optionally filtered by prefix. ```gdscript await SpawnWeaver.storage_set("save", {"level": 3}) var save := await SpawnWeaver.storage_get("save") # save.value == {"level": 3} var keys := await SpawnWeaver.storage_list() # keys.value == ["save"] var gone := await SpawnWeaver.storage_delete("save") # gone.value == true ``` --- ## Leaderboards Project-scoped ranked score boards ([guide](/dashboard/docs/leaderboards)). A board springs into existence on first submit; a player holds at most one entry per board. Board names match `^[a-z0-9_.\-]{1,64}$`. ### `leaderboard_submit(board: String, score: int, mode: String = "max") -> SWResult` Submits a score. `mode`: `"max"` (default — keep the higher score), `"min"` (keep the lower; ranks read ascending), `"latest"` (always overwrite). **Value: `{best, rank, updated}`** — `updated` is `false` when the submit didn't beat your stored score. ```gdscript var result := await SpawnWeaver.leaderboard_submit("highscore", 4200) if result.ok: print("rank #", result.value["rank"]) ``` Errors: `leaderboard-invalid-board`, `leaderboard-invalid-score`, `not-connected`. ### `leaderboard_top(board: String, limit: int = 10) -> SWResult` The board's best entries, best first. `limit` 1–100. **Value: `Array[SWLeaderboardEntry]`.** Errors: `leaderboard-invalid-board`, `not-connected`. ### `leaderboard_around(board: String, entry_range: int = 5) -> SWResult` Your entry ± `entry_range` neighbors (1–50). **Value: `{entries: Array[SWLeaderboardEntry], player_rank: int, total: int}`** — `player_rank` is `-1` when you have no entry (the top of the board is returned instead). Errors: `leaderboard-invalid-board`, `not-connected`. --- ## Diagnostics ### `get_ping_ms() -> float` The heartbeat round-trip latency (same as `latency_ms`). ### `create_debug_report() -> Dictionary` A diagnostic snapshot: SDK/engine versions, status, server URL, player/room ids, latency, reconnect attempts, and ring buffers of the last 50 messages and 10 errors. ### `create_debug_report_string() -> String` The report as pretty-printed JSON — paste into the dashboard's [Debug Bundle viewer](/dashboard/debug). ### `simulate_connection_loss() -> void` Testing helper: drops the socket so auto-reconnect and room resume run exactly as after a real network blip. --- ## Signals Broadcasts only — request outcomes come back via `await`, never signals. | Signal | Arguments | When | |---|---|---| | `connected` | — | Session welcomed; requests will flow (also after each reconnect) | | `disconnected` | `err: SWError` | The connection dropped; auto-reconnect follows unless `stop()` was called | | `reconnecting` | `attempt: int, delay: float` | A reconnect attempt will run after `delay` seconds | | `status_changed` | `new_status: Status` | `status` changed | | `room_joined` | `room: SWRoom` | You entered a room — create, join, matchmaking, **or automatic resume** | | `room_left` | `reason: String` | You left: `"left"`, `"disconnected"`, or `"expired"` | | `player_joined` | `player: SWPlayer` | Someone joined your room (never echoes you) | | `player_left` | `player: SWPlayer, reason: String` | Someone left: `"left"` or `"disconnected"` (grace expired) | | `player_disconnected` | `player: SWPlayer` | A member's connection blipped; they may return within the grace window | | `player_reconnected` | `player: SWPlayer` | That member returned | | `host_changed` | `player: SWPlayer` | Host migration promoted a new host | | `room_updated` | `room: SWRoom` | Room attributes changed (name/visibility/max_players/metadata) | | `event_received` | `event_name: String, data: Dictionary, sender: SWPlayer` | Another player sent an event | | `room_state_changed` | `state: Dictionary, patch: Dictionary` | Room state changed (includes your own confirmed changes) | | `entity_changed` | `entity_id: String, state: Dictionary, patch: Dictionary, owner_id: String` | An entity changed (includes your own confirmed changes) | | `entity_removed` | `entity_id: String, reason: String` | An entity was removed: `"deleted"` or `"owner-left"` | | `error` | `err: SWError` | An unsolicited server error (awaited calls carry their errors in the `SWResult`) | --- ## Models ### SWResult The outcome of every awaited call. | Member | Type | Meaning | |---|---|---| | `ok` | `bool` | `true` on success | | `value` | `Variant` | The success value (call-specific; see each method above) | | `error` | `SWError` | The failure, or `null` on success | | `room` | `SWRoom` (accessor) | `value` when it is a room (create/join/find_match/update), else `null` | | `rooms` | `Array` (accessor) | `value` when it is a list (list_rooms), else `[]` | | `player` | `SWPlayer` (accessor) | `value` when it is a player (start), else `null` | ### SWError | Member | Type | Meaning | |---|---|---| | `code` | `String` | Machine-readable kebab-case code (see [Error codes](#error-codes)) | | `message` | `String` | Human-readable explanation, safe to log | | `retryable` | `bool` | `true` when the same call may succeed after a short backoff | | `details` | `Dictionary` | Optional extra context (e.g. `{"entityId": "p1"}`) | ### SWPlayer | Member | Type | Meaning | |---|---|---| | `id` | `String` | Stable player id (survives reconnects and restarts) | | `name` | `String` | Display name given at create/join (may be empty) | | `connected` | `bool` | `false` while inside the disconnect grace window | | `is_local` | `bool` | `true` when the entry is you | | `display_name()` | `String` | UI-ready name: `name`, or a short id fallback | ### SWRoom | Member | Type | Meaning | |---|---|---| | `id` | `String` | Server id (`room_…` / `match_…`) | | `code` | `String` | Short join code (e.g. `"4V8772"`) | | `visibility` | `String` | `"public"` or `"private"` | | `name` | `String` | Optional display name | | `max_players` | `int` | Player cap; `0` = unlimited | | `metadata` | `Dictionary` | Developer metadata | | `host_id` | `String` | Current host's player id (kept valid by host migration) | | `players` | `Array[SWPlayer]` | The roster, maintained automatically | | `state` | `Dictionary` | Room-level shared state (host-writable) | | `entities` | `Dictionary` | `entity_id -> {"owner_id": String, "state": Dictionary}` | | `get_player(player_id)` | `SWPlayer` | Roster lookup, or `null` | | `host()` | `SWPlayer` | The host's roster entry, or `null` | | `other_players()` | `Array[SWPlayer]` | Everyone except you | ### SWLeaderboardEntry One row from `leaderboard_top()` / `leaderboard_around()`. | Member | Type | Meaning | |---|---|---| | `rank` | `int` | 1-based rank; ties share a rank | | `player_id` | `String` | The entry owner's stable player id | | `player_name` | `String` | Display name sent with the submit (may be empty) | | `score` | `int` | The stored score | | `updated_at` | `String` | ISO-8601 UTC timestamp of the last accepted submit | | `is_local` | `bool` | `true` when the entry is you | | `display_name()` | `String` | UI-ready name: `player_name`, or a short id fallback | ### SWRoomSummary One entry from `list_rooms()`. | Member | Type | Meaning | |---|---|---| | `id` | `String` | Room id (can be passed to `join_room()`) | | `code` | `String` | Join code | | `name` | `String` | Optional display name | | `player_count` | `int` | Current members | | `max_players` | `int` | Cap; `0` = unlimited | | `metadata` | `Dictionary` | Developer metadata | | `has_space()` | `bool` | `true` when the room can still be joined | --- ## Nodes ### LobbyBrowser A complete lobby UI with zero code (extends `Control`): player name field, Create room, Quick match, join by code, and a live public-room list. Built from standard Controls, so your project `Theme` restyles it. Connect `entered_room` and switch to your game scene — that's the whole integration. | Export | Type | Default | Meaning | |---|---|---|---| | `auto_start` | `bool` | `true` | Call `SpawnWeaver.start()` when entering the tree | | `show_name_field` | `bool` | `true` | Show the player-name field (persisted per device) | | `show_quick_match` | `bool` | `true` | Show the Quick match button | | `quick_match_size` | `int` | `2` | Players per quick match (2–64) | | `default_max_players` | `int` | `8` | Cap for rooms created here (0 = unlimited) | | `hide_on_join` | `bool` | `true` | Hide on room entry; reappear when the room is left | | `refresh_seconds` | `float` | `3.0` | Public-room list refresh interval | | `title` | `String` | `"Multiplayer"` | Heading text (empty hides it) | | Signal | Arguments | When | |---|---|---| | `entered_room` | `room: SWRoom` | A room was entered (create, join, code, or match) | | `errored` | `err: SWError` | An action failed (also shown in the status line) | ### SpawnSync Drop-in network sync for a Node2D or Node3D — add as a **child** of the node to replicate. Local copies send (dirty-checked, at `send_rate`); remote copies interpolate. Re-registers after reconnects/room changes; deletes its entity on exit. | Export | Type | Default | Meaning | |---|---|---|---| | `entity_id` | `String` | `""` | Stable id; empty on a local node = your player id | | `is_local` | `bool` | `false` | `true` on the single copy this client owns | | `sync_position` | `bool` | `true` | Replicate position | | `sync_rotation` | `bool` | `true` | Replicate rotation | | `sync_scale` | `bool` | `false` | Replicate scale | | `synced_properties` | `PackedStringArray` | `[]` | Extra parent properties (primitives only) | | `send_rate` | `float` | `8.0` | Local updates per second (1–10) | | `interpolate` | `bool` | `true` | Smooth remote copies toward incoming state | | `interpolation_speed` | `float` | `16.0` | Higher = snappier (1–40, frame-rate independent) | | `free_parent_on_remove` | `bool` | `true` | Free the parent when the entity is removed server-side | Method: `set_local(local: bool)` — switches the controlling client and (re)registers (called by `PlayerSpawner` when spawning). State keys sent: 2D — `x`, `y`, `rot`, `sx`, `sy`; 3D — `x`, `y`, `z`, `rx`, `ry`, `rz`, `sx`, `sy`, `sz`; plus each `synced_properties` name verbatim. ### PlayerSpawner Spawns one instance of your player scene per player in the room; despawns on leave; survives reconnects (idempotent reconcile); auto-wires any `SpawnSync` inside the scene (entity id + local authority) before it enters the tree. | Export | Type | Default | Meaning | |---|---|---|---| | `player_scene` | `PackedScene` | — | The scene instantiated per player (required) | | `spawn_root` | `NodePath` | empty | Where instances are added; empty = the spawner's parent | | `spawn_points` | `NodePath` | empty | Node whose children are used as spawn positions, round-robin by roster order | | `spawn_local_player` | `bool` | `true` | Also spawn an avatar for you | | Signal | Arguments | When | |---|---|---| | `player_spawned` | `player: SWPlayer, node: Node` | After a player's avatar entered the tree | | `player_despawned` | `player_id: String, node: Node` | After a player's avatar was removed | Optional hook on the player scene's root script, called right before `add_child`: ```gdscript func setup(player: SWPlayer, is_local: bool) -> void: pass ``` --- ## Error codes ### SDK-local codes (raised without a server round-trip) | Code | Retryable | When | |---|---|---| | `not-configured` | no | `start()` with no project key configured | | `connect-failed` | no | The first connection attempt failed (`start()` resolves with this; no auto-retry) | | `connection-lost` | yes | A mid-session drop (carried by the `disconnected` signal) | | `stopped` | no | `stop()` was called; in-flight requests fail with this | | `not-connected` | no | A request made while offline — call `start()` first | | `timeout` | yes | No reply within 10 s (120 s for `find_match`) | | `disconnected` | yes | The connection dropped mid-request | | `cancelled` | no | `cancel_matchmaking()` resolved a pending `find_match()` | | `match-timeout` | yes | The server found no match within its window | ### Server codes | Code | Retryable | Meaning | |---|---|---| | `malformed-message` | no | Frame wasn't valid JSON or had no type | | `unknown-message-type` | no | No handler for the type (SDK/server version mismatch) | | `invalid-payload` | no | A required field is missing or invalid | | `room-not-found` | no | No such room code, or the room expired | | `room-full` | no | The room is at `max_players` | | `already-in-room` | no | Already in a room — leave it first | | `not-in-room` | no | The operation needs a current room | | `not-host` | no | Host-only operation (`update_room`, room state) | | `payload-too-large` | no | Message over the 16 KB limit | | `rate-limited` | yes | Sending too fast — back off and retry | | `entity-not-found` | no | No such entity in this room | | `state-forbidden` | no | Only the entity's owner may change it | | `state-limit-exceeded` | no | Entity count cap (50) reached | | `state-too-large` | no | Entity (4 KB) or room state (16 KB) cap exceeded | | `leaderboard-invalid-board` | no | Board name doesn't match `^[a-z0-9_.\-]{1,64}$` | | `leaderboard-invalid-score` | no | Score missing or outside ±2^53-1 | | `storage-invalid-key` | no | Storage key empty or over 128 chars | | `storage-value-too-large` | no | Stored value over 64 KB | | `storage-quota-exceeded` | no | At the 100-key quota | Limits and remedies: [Limits](/dashboard/docs/limits). Wire-level details: [Protocol reference](/dashboard/docs/reference-protocol). --- # Protocol reference (Reference) The realtime gateway speaks a small JSON protocol over one WebSocket connection. This page is the wire-level specification (protocol v2) — read it to build a client for another engine, or to understand exactly what the Godot SDK sends. Transport: WebSocket **text** frames, UTF-8 JSON; binary frames are ignored. ## Design rules 1. **One room per connection.** A connection is in at most one room. Requests never carry a `roomId` — every room-scoped operation targets the caller's current room. Server broadcasts include `roomId` for context. 2. **Request → reply → broadcast.** A request `x` gets exactly one reply (the `x.…ed` form) sent to the caller with the request's `requestId` echoed. Notifications to other members are separate broadcast types and never carry a `requestId`. 3. **One error shape.** Every failure is a single `error` message, echoing the `requestId` whenever the failure is attributable to a request — including rate and size rejections. 4. **Lobby = public room.** There is no separate lobby family; a `public` room is listable via `room.list`. 5. **kebab-case on the wire.** Error codes, reasons, and enum-like values are lowercase kebab-case strings. ## Envelope Every message, in both directions, is a single JSON object: ```json { "type": "message.type", "requestId": "optional-correlation-id", "payload": { } } ``` | Field | Type | Required | Notes | |---|---|---|---| | `type` | string | yes | Identifies the message; unknown types are rejected | | `requestId` | string \| null | no | Echoed on the reply (and on attributable errors) | | `payload` | object \| null | no | Type-specific body | `null` fields are omitted from server-sent messages. ## Authentication & identity Connect with the project's **public** key: ```text wss://host/connect?projectKey=pk_…[&playerToken=…][&sdkVersion=…][&engine=…] ``` - Without a `playerToken`, a new anonymous player identity is created. - With a valid `playerToken`, the same `playerId` is restored. A fresh token is issued on every successful connect (sliding expiration) — always store the most recent one. - An invalid/expired/foreign-project token rejects the handshake with HTTP 401. - `sdkVersion` and `engine` are optional diagnostics shown in the dashboard. Handshake rejections: `400` not a WebSocket upgrade · `401` missing/unknown/inactive project key or bad player token · `403` Origin not allowed (when `Security:AllowedOrigins` is configured) · `429` per-project connection limit reached. ## Shared objects ### Player (roster entry) ```json { "id": "player_…", "name": "Alice", "connected": true } ``` `name` is the display name given when the player created/joined (may be null). `connected` is `false` while the player is inside the disconnect grace window. ### Room ```json { "id": "room_…", "code": "4V8772", "visibility": "private", "name": "Arena", "maxPlayers": 4, "metadata": { "mode": "ffa" }, "hostId": "player_…", "players": [ { "id": "player_…", "name": "Alice", "connected": true } ], "state": { "room": { "phase": "combat" }, "entities": [ { "id": "p1", "ownerId": "player_…", "state": { "x": 1 } } ] } } ``` - `visibility`: `"private"` (join by code only) or `"public"` (also listable/joinable via `room.list`). - `name`, `maxPlayers`, `metadata` are optional developer-facing attributes. - `state` is the full live-state snapshot. It is included in `room.created`, `room.joined`, `match.found`, and the welcome's `resumedRoom`; it is **omitted** from `room.updated` broadcasts (state has its own change messages). ### Room summary (`room.listed` entries) ```json { "id": "room_…", "code": "AB12CD", "name": "Arena", "visibility": "public", "playerCount": 2, "maxPlayers": 4, "metadata": { "mode": "ffa" } } ``` ## Session ### `session.welcome` (server → client) Sent once after a successful connect: ```json { "type": "session.welcome", "payload": { "connectionId": "conn_…", "playerId": "player_…", "playerToken": "player_….proj_….1750000000.", "tokenExpiresAtUtc": "2026-06-10T09:45:31Z", "serverTimeUtc": "2026-06-03T09:45:31.89Z", "resumedRoom": { "…": "Room, only when resuming" } } } ``` `resumedRoom` is present only when the player reconnected (same `playerToken`) while still inside a room's grace window: the server re-attached the membership and the client should treat itself as in that room. Other members received `room.player_reconnected`. ### `ping` → `pong` Application-level liveness; the reply echoes `requestId`: ```json { "type": "ping", "requestId": "req_42" } { "type": "pong", "requestId": "req_42" } ``` ## Errors ### `error` (server → client) ```json { "type": "error", "requestId": "req_7", "payload": { "code": "room-not-found", "message": "No room with code 'ZZZZ99'.", "retryable": false, "details": { } } } ``` `requestId` is echoed whenever the offending request could be identified — including `payload-too-large` and `rate-limited` rejections (the limiter parses just far enough to recover the id). `retryable: true` means the same request may succeed after a short backoff. ### Error codes | Code | Retryable | Meaning | |---|---|---| | `malformed-message` | no | Frame was not valid JSON or had no `type` | | `unknown-message-type` | no | The `type` has no handler (check versions) | | `invalid-payload` | no | A required field is missing or invalid | | `room-not-found` | no | No such room code, or the room expired | | `room-full` | no | The room is at `maxPlayers` | | `already-in-room` | no | The connection is already in a room — leave first | | `not-in-room` | no | The operation needs a current room | | `not-host` | no | Only the host may do this (`room.update`, room state) | | `payload-too-large` | no | Message exceeded `Realtime:MaxMessageBytes` (default 16 KB) | | `rate-limited` | yes | Sending faster than allowed — back off and retry | | `entity-not-found` | no | No such entity in this room's state | | `state-forbidden` | no | Only the entity's owner may change it | | `state-limit-exceeded` | no | Entity count cap reached (`State:MaxEntitiesPerRoom`) | | `state-too-large` | no | Resulting state exceeds the per-entity/per-room size cap | | `leaderboard-invalid-board` | no | Board name doesn't match `^[a-z0-9_.\-]{1,64}$` | | `leaderboard-invalid-score` | no | Score missing or outside the supported range (±2^53-1) | | `storage-invalid-key` | no | Storage key is empty or too long | | `storage-value-too-large` | no | Stored value exceeds the per-key size limit | | `storage-quota-exceeded` | no | The player is at their stored-key quota | ## Rooms Rooms are in-memory, project-scoped, identified by a short join code. Empty rooms (no connected members) expire after `Realtime:EmptyRoomTtl`. ### `room.create` → `room.created` ```json { "type": "room.create", "requestId": "c1", "payload": { "playerName": "Alice", "name": "Arena", "visibility": "public", "maxPlayers": 4, "metadata": { "mode": "ffa" } } } { "type": "room.created", "requestId": "c1", "payload": { "room": { "…": "Room" } } } ``` All payload fields optional: `visibility` defaults `"private"`, `maxPlayers` omitted = unlimited, `metadata` defaults `{}`. The creator becomes the **host**. Fails with `already-in-room` if the connection is in a room. ### `room.join` → `room.joined` + `room.player_joined` ```json { "type": "room.join", "requestId": "j1", "payload": { "code": "4V8772", "playerName": "Bob" } } { "type": "room.joined", "requestId": "j1", "payload": { "room": { "…": "Room" } } } { "type": "room.player_joined", "payload": { "roomId": "room_…", "player": { "id": "player_…", "name": "Bob", "connected": true } } } ``` Join by `code` works for public and private rooms; public rooms may also be joined by `id` from `room.list` (`payload: { "id": "room_…" }`). Rules: - `room-full` when `maxPlayers` is reached (connected + in-grace members both count). - `already-in-room` when the connection is already in a room. - If the **same player** (by `playerId`) is in the room but disconnected (grace), the join **resumes** that membership: the joiner gets `room.joined`, others get `room.player_reconnected` (not `room.player_joined`). ### `room.leave` → `room.left` + `room.player_left` ```json { "type": "room.leave", "requestId": "l1" } { "type": "room.left", "requestId": "l1", "payload": { "roomId": "room_…" } } { "type": "room.player_left", "payload": { "roomId": "room_…", "playerId": "player_…", "reason": "left" } } ``` `reason` is `"left"` (explicit) or `"disconnected"` (grace expired). Fails with `not-in-room` without a room. ### `room.list` → `room.listed` Public rooms of the caller's project, most recently active first: ```json { "type": "room.list", "requestId": "r1", "payload": { "limit": 20 } } { "type": "room.listed", "requestId": "r1", "payload": { "rooms": [ { "…": "summary" } ] } } ``` ### `room.update` → `room.updated` (host only) Only provided fields change; `metadata` replaces the whole map: ```json { "type": "room.update", "requestId": "u1", "payload": { "name": "Arena II", "maxPlayers": 6 } } { "type": "room.updated", "payload": { "room": { "…": "Room without state" } } } ``` Sent to **all** members; the caller's copy echoes the `requestId`. Fails with `not-host` / `not-in-room`. ### `room.host_changed` (server → members) ```json { "type": "room.host_changed", "payload": { "roomId": "room_…", "hostId": "player_…" } } ``` ### `room.closed` (server → members) Broadcast if a room is removed while it still has members in grace; rooms with zero members are removed silently: ```json { "type": "room.closed", "payload": { "roomId": "room_…", "reason": "expired" } } ``` ## Disconnects (grace & resume) When a member's socket drops, the membership is **not** removed immediately: 1. The member is marked `connected: false`; others receive `room.player_disconnected { roomId, playerId }`. 2. If the same player reconnects (token) or rejoins by code within `Realtime:DisconnectGrace` (default 60 s), the membership — including owned entities — is reclaimed. Others receive `room.player_reconnected { roomId, player }`. On token reconnect the welcome carries `resumedRoom`. 3. If the grace window expires, the membership is removed: others receive `room.player_left { …, "reason": "disconnected" }` and the player's entities are garbage-collected (`state.entity.removed { …, "reason": "owner-left" }` each). A room whose members are all disconnected expires after `Realtime:EmptyRoomTtl`. ## Host migration Every room has a host (`hostId`) — including matchmade rooms, where the first matched player is host. The host is the only member who may `room.update` and patch **room** state. Rules: - Host **leaves** or their **grace expires** → the earliest-joined connected member becomes host; `room.host_changed` is broadcast. - Host **disconnects** (enters grace) → migration happens immediately (the game must not wait a minute to write room state); if the ex-host returns they are a regular member. - If no member is connected, `hostId` keeps its last value; when the next player connects or joins, migration re-runs if the current host is not connected. ## Matchmaking Players queue; when enough players wait with the same project + `mode` + `region` + `size`, the server creates a room and places them in it. ### `match.find` → `match.searching`, then `match.found` / `match.timeout` ```json { "type": "match.find", "requestId": "m1", "payload": { "mode": "duel", "region": "global", "size": 2, "playerName": "Ada" } } { "type": "match.searching", "requestId": "m1", "payload": { "mode": "duel", "region": "global", "size": 2 } } { "type": "match.found", "requestId": "m1", "payload": { "room": { "…": "Room" } } } { "type": "match.timeout", "requestId": "m1", "payload": { "mode": "duel", "region": "global" } } ``` All fields optional: `mode` defaults `"default"`, `region` `"global"`, `size` `2` (range 2–64). `mode` and `region` must not contain `|`. A second `match.find` replaces the first ticket. Fails with `already-in-room` when in a room. `match.found` and `match.timeout` echo the `requestId` of the `match.find` that created the ticket, so a client can await the outcome of its own request. ### `match.cancel` → `match.cancelled` ```json { "type": "match.cancel", "requestId": "m2" } { "type": "match.cancelled", "requestId": "m2", "payload": { "hadTicket": true } } ``` Disconnecting also removes the ticket. ## Events ### `event.send` → `event.received` Fire-and-forget relay to the **other** members of the caller's room (sender excluded). `data` is opaque application JSON. There is no success reply; failures come back as `error` (echoing `requestId` when one was provided): ```json { "type": "event.send", "payload": { "name": "player_fired", "data": { "dir": [0, 1] } } } { "type": "event.received", "payload": { "roomId": "room_…", "name": "player_fired", "data": { "dir": [0, 1] }, "senderId": "player_…" } } ``` ## State sync Room-scoped live state, always targeting the caller's current room: - **Room state** — one JSON object per room; only the **host** may patch it. - **Entity state** — per-entity JSON objects; an entity is owned by its creator and only the **owner** may set/patch/delete it. Patches are shallow merges: provided keys overwrite, a `null` value **removes** the key. Limits (config section `State`): `MaxEntitiesPerRoom` 50 · `MaxEntityStateBytes` 4 KB · `MaxRoomStateBytes` 16 KB · `MaxStateUpdatesPerSecondPerClient` 10 (burst 20). ### Change messages double as acks `state.room.changed` / `state.entity.changed` / `state.entity.removed` are broadcast to **all** members including the sender. The **sender's copy echoes the request's `requestId`** — it is both the ack and the authoritative result; there is no separate success reply. ```json { "type": "state.room.patch", "requestId": "s1", "payload": { "patch": { "phase": "combat" } } } { "type": "state.entity.set", "requestId": "s2", "payload": { "entityId": "p1", "state": { "x": 1, "y": 2 } } } { "type": "state.entity.patch", "requestId": "s3", "payload": { "entityId": "p1", "patch": { "x": 5 } } } { "type": "state.entity.delete", "requestId": "s4", "payload": { "entityId": "p1" } } { "type": "state.room.changed", "payload": { "roomId": "room_…", "patch": { }, "state": { } } } { "type": "state.entity.changed", "payload": { "roomId": "room_…", "entityId": "p1", "ownerId": "player_…", "patch": { }, "state": { } } } { "type": "state.entity.removed", "payload": { "roomId": "room_…", "entityId": "p1", "reason": "deleted" } } ``` `state.entity.removed` reasons: `"deleted"` (owner deleted it) · `"owner-left"` (owner's membership ended; the entity was garbage-collected). Late joiners need no snapshot message — the full state arrives inside `room.joined` (`room.state`), `match.found`, and `resumedRoom`. ## Leaderboards Project-scoped ranked score boards. A board springs into existence on first submit; a player holds at most one entry per board. Board names match `^[a-z0-9_.\-]{1,64}$`. ```json { "type": "leaderboard.submit", "requestId": "b1", "payload": { "board": "highscore", "score": 4200, "mode": "max", "playerName": "Ada" } } { "type": "leaderboard.submitted", "requestId": "b1", "payload": { "board": "highscore", "score": 4200, "best": 4200, "rank": 17, "updated": true } } { "type": "leaderboard.top", "requestId": "b2", "payload": { "board": "highscore", "limit": 10, "offset": 0, "order": "desc" } } { "type": "leaderboard.entries", "requestId": "b2", "payload": { "board": "highscore", "total": 812, "entries": [ { "rank": 1, "playerId": "player_…", "playerName": "Ada", "score": 99120, "updatedAtUtc": "…" } ] } } { "type": "leaderboard.around", "requestId": "b3", "payload": { "board": "highscore", "range": 5 } } { "type": "leaderboard.entries", "requestId": "b3", "payload": { "board": "highscore", "total": 812, "playerRank": 17, "entries": [ "…" ] } } ``` - `mode`: `"max"` (default — keep the higher score), `"min"` (keep the lower; ranks read ascending), `"latest"` (always overwrite). `updated` is false when the submit didn't beat the stored score. - `top`: `limit` 1–100 (default 10), `offset` ≥ 0, `order` `"desc"` (default) / `"asc"`. - `around`: the caller's entry ± `range` (1–50, default 5); `playerRank` is omitted when the caller has no entry (then the top of the board is returned). - Ties share a rank; ordering within a tie is oldest-entry-first. - Errors: `leaderboard-invalid-board`, `leaderboard-invalid-score`, plus the usual `invalid-payload` / `rate-limited`. ## Player storage Persistent per-player key-value storage, scoped to the connection's project and player identity. Values are arbitrary JSON, persisted across sessions (the same data as the [HTTP storage API](/dashboard/docs/reference-http-api), which also accepts the `playerToken` as a bearer token). ```json { "type": "storage.set", "requestId": "k1", "payload": { "key": "save", "value": { "level": 3 } } } { "type": "storage.saved", "requestId": "k1", "payload": { "key": "save", "updatedAtUtc": "…" } } { "type": "storage.get", "requestId": "k2", "payload": { "key": "save" } } { "type": "storage.value", "requestId": "k2", "payload": { "key": "save", "value": { "level": 3 }, "updatedAtUtc": "…" } } { "type": "storage.delete", "requestId": "k3", "payload": { "key": "save" } } { "type": "storage.deleted", "requestId": "k3", "payload": { "key": "save", "existed": true } } { "type": "storage.list", "requestId": "k4", "payload": { "prefix": "sav" } } { "type": "storage.listed", "requestId": "k4", "payload": { "keys": ["save"] } } ``` Missing keys read back as `{ "key": "save", "value": null }`. Failures: `storage-invalid-key`, `storage-value-too-large`, `storage-quota-exceeded`. ## Connection limits Per-connection inbound limits (config section `Realtime`): - **Size** — messages over `MaxMessageBytes` (default 16 KB) → `payload-too-large`. - **Rate** — token bucket (`MaxMessagesPerSecond` 20 sustained, `MessageBurst` 40) → `rate-limited`. Both rejections echo the request's `requestId` when it can be recovered. ## Message type catalog | Client → server | Reply (requestId echoed) | Broadcast to other members | |---|---|---| | `ping` | `pong` | — | | `room.create` | `room.created` | — | | `room.join` | `room.joined` | `room.player_joined` / `room.player_reconnected` | | `room.leave` | `room.left` | `room.player_left` | | `room.list` | `room.listed` | — | | `room.update` | `room.updated` (to all; caller's copy echoes id) | `room.updated` | | `match.find` | `match.searching`, then `match.found` \| `match.timeout` | — | | `match.cancel` | `match.cancelled` | — | | `event.send` | — (errors only) | `event.received` | | `state.room.patch` | `state.room.changed` (to all; sender's copy echoes id) | `state.room.changed` | | `state.entity.set` / `state.entity.patch` | `state.entity.changed` (same rule) | `state.entity.changed` | | `state.entity.delete` | `state.entity.removed` (same rule) | `state.entity.removed` | | `storage.get` / `storage.set` / `storage.delete` / `storage.list` | `storage.value` / `storage.saved` / `storage.deleted` / `storage.listed` | — | | `leaderboard.submit` | `leaderboard.submitted` | — | | `leaderboard.top` / `leaderboard.around` | `leaderboard.entries` | — | | — | `session.welcome` (on connect) | `room.player_disconnected`, `room.host_changed`, `room.closed` | ## Writing a custom client: checklist 1. Connect to `/connect?projectKey=pk_…`, store the welcome's `playerToken`, and re-present it (plus store each newer one) on every reconnect. 2. Correlate replies by `requestId`; treat an `error` with your `requestId` as that request's failure. 3. Apply broadcasts (`room.player_*`, `state.*`, `event.received`, `room.updated`, `room.host_changed`, `room.closed`) to your local room model — including the requestId-echoing ack copies of state changes. 4. Handle `resumedRoom` in the welcome as "you are in this room again". 5. Send `ping` periodically (the Godot SDK uses 15 s) and treat prolonged silence as a dead connection. 6. Respect the rate budget (20 msg/s, burst 40) and back off on `rate-limited`. ## Versioning & stability A breaking change to the wire shape updates the backend, the SDK, the tests, and the protocol spec together. Unknown message types are rejected with `unknown-message-type` — clients should log and ignore reply types they don't recognize to stay forward-compatible with additive changes. --- # HTTP API reference (Reference) The HTTP control plane: developer accounts, projects, player storage, health, and diagnostics. Realtime gameplay happens over the [WebSocket protocol](/dashboard/docs/reference-protocol); everything else is here. Base URL: `https://spawnweaver.dev`. ## Authentication methods | Method | Used by | How | |---|---|---| | Session cookie | Dashboard/account/project endpoints | Set by `POST /api/auth/signup` or `/signin` (HttpOnly cookie); send it with subsequent requests (`curl -b jar.txt`) | | Project secret key | Storage (full access) | `Authorization: Bearer sk_…` — server-side callers only | | Player token | Storage (own data only) | `Authorization: Bearer ` | | Admin API key | `/api/admin/*` | `Authorization: Bearer ` — **required**; without a configured key the admin API is disabled (except local Development) | | Public project key | `/connect`, `/connect/stats` | `?projectKey=pk_…` query parameter | ## Error shape (RFC 7807) Failures return `application/problem+json`: ```json { "title": "Unauthorized", "status": 401, "detail": "Sign in to create a project." } ``` Validation failures add an `errors` map of field → messages: ```json { "title": "One or more validation errors occurred.", "status": 400, "errors": { "name": ["Name is required."] } } ``` Every response carries an `X-Correlation-Id` header — include it in bug reports. --- ## Health ### `GET /health` No auth. Liveness/readiness probe for monitors, Docker, and tunnels. ```bash curl https://spawnweaver.dev/health # 200 -> { "status": "ok", "service": "Platform.Api", "version": "1.0.0.0" } ``` --- ## Auth & accounts Developer accounts back the dashboard. Sign-up/sign-in are rate-limited per IP (HTTP `429` when exceeded). With a real email provider configured, sign-up requires email verification before sign-in; local/dev instances auto-verify. ### `POST /api/auth/signup` ```bash curl -c jar.txt -X POST https://spawnweaver.dev/api/auth/signup \ -H "Content-Type: application/json" \ -d '{"email":"you@studio.com","displayName":"You","password":"supersecret123"}' # 200 -> { "userId": "user_…", "email": "you@studio.com", "displayName": "You", # "redirect": "/dashboard/onboarding" } (cookie set; dev instances) # 200 with "redirect": "/dashboard/verify-pending" (hosted: check your inbox first) ``` Errors: `409` email already in use · `400` weak password (minimum length in the message) or invalid email · `429` rate-limited. ### `POST /api/auth/signin` ```bash curl -c jar.txt -X POST https://spawnweaver.dev/api/auth/signin \ -H "Content-Type: application/json" \ -d '{"email":"you@studio.com","password":"supersecret123"}' # 200 -> { "userId": "…", "email": "…", "displayName": "…", "redirect": "/dashboard" } ``` Errors: `401` incorrect email or password · `403` with `code: "email_not_verified"` when verification is pending · `429` rate-limited. ### `GET /api/auth/verify?token=…` Confirms an emailed verification link. Redirects to `/dashboard` on success (signed in), or to `/dashboard/verify-pending?expired=1` for a stale token. ### `POST /api/auth/verify/resend` Body `{"email": "…"}`. Always returns `200` (no account enumeration); a new link is sent only when the account exists and still needs verification. ### `POST /api/auth/signout` Ends the session. `200 -> { "redirect": "/dashboard/signin" }`. ### `POST /api/auth/magic/request` Passwordless sign-in link by email. Body `{"email": "…"}`. `200 -> { "sent": true, "message": "Check your email…", "devLink": null }` — `devLink` carries the clickable link on non-production instances without a real email provider. `429` when requesting links too quickly. ### `GET /api/auth/magic?token=…` Consumes a magic link; redirects to `/dashboard` (signed in) or `/dashboard/signin?error=link`. ### Account endpoints (session cookie required; `401` otherwise) | Method & path | Body | Response | |---|---|---| | `GET /api/account` | — | `200` profile: `id`, `email`, `displayName`, `createdAtUtc`, `lastLoginAtUtc`, `organizationId`, `organizationName` | | `PUT /api/account` | `{"displayName": "…"}` | `200`, or `400` when empty | | `POST /api/account/password` | `{"currentPassword": "…", "newPassword": "…"}` | `200`, or `400` on wrong password / weak new one | | `GET /api/account/sessions` | — | `200` `{ "sessions": [ { "id", "createdAtUtc", "lastSeenAtUtc", "expiresAtUtc", "current" } ] }` | | `POST /api/account/sessions/revoke-all` | — | `200` — revokes every session, then re-issues one for this browser | --- ## Projects Project management requires a signed-in developer (session cookie); projects belong to your workspace. ### `POST /api/projects` Creates a project and returns both keys. **The secret key is returned exactly once** — it is stored only as a hash and can never be retrieved again. ```bash curl -b jar.txt -X POST https://spawnweaver.dev/api/projects \ -H "Content-Type: application/json" \ -d '{"name":"Duel Arena","gameType":"Arena1v1","multiplayerMode":"MatchmakingAndRooms","persistenceFeatures":["PlayerProfile"]}' ``` ```json { "id": "proj_…", "name": "Duel Arena", "publicKey": "pk_…", "secretKey": "sk_…", "createdAtUtc": "2026-07-29T10:00:00Z", "slug": "duel-arena", "organizationId": "org_…", "gameType": "Arena1v1", "multiplayerMode": "MatchmakingAndRooms", "persistenceFeatures": ["PlayerProfile"], "environment": "Development", "recommendedSetup": { "exampleProject": "1v1 Matchmaking Arena", "steps": ["…"] } } ``` `201 Created`. Only `name` is required; the optional onboarding fields (`gameType`, `multiplayerMode`, `persistenceFeatures`, `targetPlatform`, `environment`) tailor the `recommendedSetup` plan. Valid choices: `GET /api/onboarding/options` (no auth). Errors: `401` not signed in · `400` missing/too-long name. ### `GET /api/projects/{projectId}` Project details for the owning workspace — never includes the secret key. ```bash curl -b jar.txt https://spawnweaver.dev/api/projects/proj_xxx # 200 -> { "id", "name", "publicKey", "isActive", "createdAtUtc", "slug", # "organizationId", "gameType", "multiplayerMode", "persistenceFeatures", "environment" } ``` Errors: `401` not signed in · `404` unknown **or not yours** (existence is not probeable). ### `POST /api/projects/{projectId}/keys/secret` Regenerates the secret key; returns the new plaintext **once**: `200 -> { "secretKey": "sk_…" }`. The old key stops working immediately. Errors: `401` / `404`. ### `POST /api/projects/{projectId}/keys/public` Rotates the public key: `200 -> { "publicKey": "pk_…" }`. **Disruptive** — shipped clients using the old key stop connecting. Errors: `401` / `404`. --- ## Player storage `/api/storage/{projectId}/players/{playerId}/keys[…]` — persistent per-player key-value data, the same data the SDK reads via `storage_get()`. Two accepted bearer credentials: - **Secret key** `sk_…` — any player in the project (server-side tooling). - **Player token** (from the realtime welcome) — only when `{playerId}` matches the token's own player; anything else is `401`. ### `PUT /api/storage/{projectId}/players/{playerId}/keys/{key}` The request body is the raw JSON value to store. ```bash curl -X PUT https://spawnweaver.dev/api/storage/proj_xxx/players/player_1/keys/score \ -H "Authorization: Bearer sk_xxx" -H "Content-Type: application/json" -d '42' # 200 -> { "key": "score", "updatedAtUtc": "2026-07-29T10:00:00Z" } ``` Errors: `400` invalid/too-long key · `413` value over the size limit · `409` at the stored-key quota · `401` bad credential. ### `GET /api/storage/{projectId}/players/{playerId}/keys/{key}` ```bash curl https://spawnweaver.dev/api/storage/proj_xxx/players/player_1/keys/score \ -H "Authorization: Bearer sk_xxx" # 200 -> { "key": "score", "value": "42", "updatedAtUtc": "…" } # 404 when the key does not exist ``` `value` is the stored raw JSON as a string — parse it client-side. ### `DELETE /api/storage/{projectId}/players/{playerId}/keys/{key}` `204 No Content` on success, `404` when the key didn't exist. ### `GET /api/storage/{projectId}/players/{playerId}/keys` `200 -> { "keys": ["score", "save"] }` — the player's stored key names. Quotas (defaults): 64 KB per value, 100 keys per player, 128-char keys. See [Limits](/dashboard/docs/limits). --- ## Realtime handshake & stats ### `GET /connect` (WebSocket upgrade) The realtime gateway. Query parameters: `projectKey` (required, `pk_…`), `playerToken`, `sdkVersion`, `engine` (optional). On success the server completes the WebSocket upgrade and sends `session.welcome` — full details in the [protocol reference](/dashboard/docs/reference-protocol). Rejections: `400` not a WebSocket upgrade request · `401` missing/unknown/inactive project key, or invalid/expired player token · `403` Origin not in the configured allowlist · `429` per-project connection limit reached. ### `GET /connect/stats?projectKey=pk_…` Live counts for **your** project (key-scoped, never node-wide) — this powers the editor dock's live status: ```bash curl "https://spawnweaver.dev/connect/stats?projectKey=pk_xxx" # 200 -> { "activeConnections": 3, "activeRooms": 1 } ``` Errors: `401` missing/unknown/inactive key. --- ## Admin API Read-only diagnostics under `/api/admin/*`. **Requires** `Authorization: Bearer ` matching the deployment's `Admin__ApiKey`. Fail-closed: when no key is configured the admin API is disabled entirely (Development environments excepted, for local convenience). All endpoints return `401` without a valid key. ```bash curl https://your-domain/api/admin/metrics \ -H "Authorization: Bearer $ADMIN_API_KEY" ``` | Method & path | Returns | |---|---| | `GET /api/admin/projects` | Up to 100 project summaries: `{ "projects": [ { "id", "name", "isActive", "createdAtUtc" } ] }` | | `GET /api/admin/projects/{id}` | One project summary, or `404` | | `GET /api/admin/realtime` | Live snapshot: `activeConnections`, `activeRooms`, per-connection and per-room details | | `GET /api/admin/sessions` | Recent connection sessions (the session inspector's list) | | `GET /api/admin/sessions/{connectionId}` | One session's full timeline — connect, auth, actions, rejections, disconnect reason; `404` unknown | | `GET /api/admin/errors` | Protocol errors aggregated by code, with counts and affected sessions | | `GET /api/admin/matchmaking` | Current matchmaking queue contents | | `GET /api/admin/rooms/{roomId}` | One room's members, host, and metadata; `404` unknown | | `GET /api/admin/logs?level=warning` | Recent server log entries (up to 200), optionally filtered by level | | `GET /api/admin/metrics` | Metrics snapshot: connections, rooms, messages, errors | | `GET /api/admin/feedback` | Feedback submitted via the landing page form (up to 200) | These are the endpoints behind the dashboard's Debugger hub — see [Debugging](/dashboard/docs/debugging). --- ## SDK installers ### `GET /install.ps1` · `GET /install.sh` No auth. One-line installer scripts served with the deployment's own base URL baked in; they download `/sdk/spawnweaver.zip` and extract it into the current Godot project's `addons/` folder. ```powershell iwr https://spawnweaver.dev/install.ps1 -UseBasicParsing | iex ``` ```bash curl -fsSL https://spawnweaver.dev/install.sh | bash ``` --- ## Conventions - All timestamps are UTC ISO-8601 (`…AtUtc` fields). - JSON field names are camelCase. - Ids are prefixed strings: `proj_…`, `player_…`, `room_…`, `conn_…`, `org_…`, `user_…`. - Keys: `pk_…` is safe to ship in clients; `sk_…` must never leave your infrastructure — regenerate it immediately if it does.