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

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:

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:

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 uses the same node.

4. Shooting — events

Shots are transient: they matter for a second, then they're gone. That's exactly what events are for (state sync is for things late joiners need).

On click, the local player spawns a projectile locally and tells everyone else:

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.

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

6. Scoreboard — room state

Scores must be shared and consistent — that's room state, which only the host can write:

# 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

# 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 — the same architecture with a character controller, camera, and projectiles.
  • Learn paths — guided checklists from zero to shipped.