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:

# 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:

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 once; design inside them rather than discovering them in production.