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 conventionPlayerSpawnercalls 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()andget_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 withstrip_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 backgroundColorRect, a decorative overlay — swallows mouse input before_unhandled_inputever runs, because Controls default toMOUSE_FILTER_STOP. Set decorations toMOUSE_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.