Tutorial · 2D Shooter

Build a 2D multiplayer arena shooter

A top-down free-for-all where players move, aim, and shoot each other in real time. You'll wire up matchmaking, sync players with state, fire bullets over the event relay, handle damage and respawns, and show a live scoreboard — the full SpawnWeaver toolkit in one game.

Matchmaking Entity state sync Realtime events Snapshots Respawn & score

How it works

The design keeps networking simple and cheat-resistant for a friendly game:

  • Each player owns one entity (keyed by their player id) holding x, y, rot, hp, kills, name. Moving patches that entity; everyone else receives the change. Late joiners get a full snapshot.
  • Shots are events. Firing sends a shoot event with a position + direction; every other client spawns that bullet locally.
  • You are the authority on hits against you. Each client only checks bullets against its own player, lowers its own hp, and on death sends a frag event so the shooter can count the kill.

1. Create the scene

Add a Node2D scene, attach a new script, and save it (e.g. arena_2d.tscn). The script builds its own UI and arena, so there's nothing else to wire up. Everything below is one script — paste each part in order.

2. Constants & state

extends Node2D
## SpawnWeaver tutorial — 2D top-down arena shooter (free-for-all).

const GAME_MODE := "arena2d_ffa"
const SPEED := 220.0
const BULLET_SPEED := 540.0
const FIRE_COOLDOWN := 0.22
const SEND_INTERVAL := 0.05      # 20 position updates/sec — under the rate limit
const MAX_HP := 100
const HIT_DAMAGE := 25
const PLAYER_RADIUS := 16.0
const BULLET_RADIUS := 5.0
const ARENA := Rect2(40, 96, 880, 460)

var _status: Label
var _find_btn: Button

var _in_match := false
var _respawning := false
var _my_id := ""
var _my_pos := Vector2.ZERO
var _my_rot := 0.0
var _fire_cd := 0.0
var _send_accum := 0.0
var _players: Dictionary = {}    # entity_id -> { pos, rot, hp, kills, name }
var _bullets: Array = []         # [{ owner, pos, vel, ttl }]

3. Connect & subscribe to signals

In _ready() we build a tiny UI and subscribe to everything we need: matchmaking, state sync, and the event relay. Guest sign-in happens automatically on connect.

func _ready() -> void:
    _build_ui()
    MultiplayerService.welcomed.connect(func(_id): _set_status("Connected. Click Find Match."))
    MultiplayerService.connection_error.connect(func(r): _set_status("Connection error: " + r))
    MultiplayerService.matchmaking_queued.connect(func(_m, _r, _s): _set_status("Searching for players…"))
    MultiplayerService.match_found.connect(_on_match_found)
    MultiplayerService.matchmaking_timeout.connect(func(_m, _r): _on_match_over("No match found — try again."))
    MultiplayerService.state_snapshot_received.connect(_on_snapshot)
    MultiplayerService.entity_state_changed.connect(_on_entity_changed)
    MultiplayerService.entity_state_deleted.connect(func(id): _players.erase(id))
    MultiplayerService.player_left.connect(func(_r, pid): _players.erase(pid))
    MultiplayerService.event_received.connect(_on_event)

4. Find a match & spawn your player

Clicking Find Match enters the matchmaking queue. When the server pairs players it fires match_found with a generated room. We then create and own a player entity — this is what other clients see, and what late joiners receive in their snapshot.

func _on_match_found(_room_id: String, room_code: String, _players_list: Array) -> void:
    _in_match = true
    _respawning = false
    _bullets.clear()
    _my_id = MultiplayerService.player_id
    _my_pos = _random_spawn()
    # Create and OWN my player entity. Late joiners get this in their snapshot.
    MultiplayerService.set_entity_state(_my_id, {
        "x": _my_pos.x, "y": _my_pos.y, "rot": 0.0,
        "hp": MAX_HP, "kills": 0, "name": _my_name(),
    })
    _find_btn.disabled = true
    _set_status("Match %s! Arrows move · mouse aims · click/space shoots." % room_code)

5. Move & aim — sync via entity state

Each frame we move with the arrow keys, aim at the mouse, and (a few times a second) patch our entity so everyone else sees us. We throttle to stay under the rate limit.

func _process(delta: float) -> void:
    if not _in_match:
        return
    _fire_cd = maxf(0.0, _fire_cd - delta)
    if not _respawning:
        _handle_input(delta)
    _update_bullets(delta)
    queue_redraw()

func _handle_input(delta: float) -> void:
    var move := Input.get_vector("ui_left", "ui_right", "ui_up", "ui_down")
    if move != Vector2.ZERO:
        _my_pos += move * SPEED * delta
        _my_pos.x = clampf(_my_pos.x, ARENA.position.x, ARENA.end.x)
        _my_pos.y = clampf(_my_pos.y, ARENA.position.y, ARENA.end.y)
    _my_rot = (get_global_mouse_position() - _my_pos).angle()
    if Input.is_action_pressed("ui_accept") or Input.is_mouse_button_pressed(MOUSE_BUTTON_LEFT):
        _try_fire()
    _send_accum += delta
    if _send_accum >= SEND_INTERVAL:
        _send_accum = 0.0
        MultiplayerService.patch_entity_state(_my_id, {"x": _my_pos.x, "y": _my_pos.y, "rot": _my_rot})

6. Shoot — broadcast bullets as events

Firing spawns a bullet locally (the relay never echoes to the sender) and sends a shoot event so everyone else spawns the same bullet. Receiving a shoot creates a bullet owned by the firer.

func _try_fire() -> void:
    if _fire_cd > 0.0:
        return
    _fire_cd = FIRE_COOLDOWN
    var dir := Vector2.RIGHT.rotated(_my_rot)
    var muzzle := _my_pos + dir * (PLAYER_RADIUS + 6.0)
    _bullets.append({"owner": _my_id, "pos": muzzle, "vel": dir * BULLET_SPEED, "ttl": 1.4})
    MultiplayerService.send_event("shoot", {"x": muzzle.x, "y": muzzle.y, "dx": dir.x, "dy": dir.y})

func _on_event(event: String, data: Dictionary, from_id: String) -> void:
    if event == "shoot":
        var dir := Vector2(float(data.get("dx", 0.0)), float(data.get("dy", 0.0)))
        _bullets.append({
            "owner": from_id,
            "pos": Vector2(float(data.get("x", 0.0)), float(data.get("y", 0.0))),
            "vel": dir * BULLET_SPEED,
            "ttl": 1.4,
        })
    elif event == "frag" and str(data.get("killer", "")) == _my_id:
        var me: Dictionary = _players.get(_my_id, {})
        me["kills"] = int(me.get("kills", 0)) + 1
        _players[_my_id] = me
        MultiplayerService.patch_entity_state(_my_id, {"kills": me["kills"]})

7. Damage, death & respawn

Bullets travel locally. Each client only resolves hits on itself from bullets it didn't fire — that keeps health authoritative and avoids double-counting. On death we credit the shooter with a frag event and respawn.

func _update_bullets(delta: float) -> void:
    var still_alive: Array = []
    for b in _bullets:
        b["pos"] += b["vel"] * delta
        b["ttl"] -= delta
        if b["ttl"] <= 0.0 or not ARENA.grow(40.0).has_point(b["pos"]):
            continue
        if not _respawning and b["owner"] != _my_id and b["pos"].distance_to(_my_pos) <= PLAYER_RADIUS + BULLET_RADIUS:
            _take_hit(str(b["owner"]))
            continue                      # consume the bullet
        still_alive.append(b)
    _bullets = still_alive

func _take_hit(shooter_id: String) -> void:
    var me: Dictionary = _players.get(_my_id, {})
    var hp := int(me.get("hp", MAX_HP)) - HIT_DAMAGE
    if hp <= 0:
        MultiplayerService.send_event("frag", {"killer": shooter_id})   # credit the shooter
        _respawn()
    else:
        me["hp"] = hp
        _players[_my_id] = me
        MultiplayerService.patch_entity_state(_my_id, {"hp": hp})

func _respawn() -> void:
    _respawning = true
    _set_status("You were fragged! Respawning…")
    await get_tree().create_timer(1.5).timeout
    _my_pos = _random_spawn()
    var me: Dictionary = _players.get(_my_id, {})
    me["hp"] = MAX_HP
    _players[_my_id] = me
    MultiplayerService.patch_entity_state(_my_id, {"x": _my_pos.x, "y": _my_pos.y, "hp": MAX_HP})
    _respawning = false
    _set_status("Fight! Arrows move · mouse aims · click/space shoots.")

8. Receive other players (snapshot + updates)

On join we get a snapshot of everyone already in the room; afterwards each change arrives via entity_state_changed with the full merged state. We ignore our own entity since we render it from our local position.

func _on_snapshot(snapshot: Dictionary) -> void:
    for e in snapshot.get("entities", []):
        var id := str(e.get("entityId", ""))
        if id != _my_id:
            _apply_entity(id, e.get("state", {}))

func _on_entity_changed(entity_id: String, _patch: Dictionary, full: Dictionary) -> void:
    if entity_id != _my_id:        # I'm authoritative over my own dot
        _apply_entity(entity_id, full)

func _apply_entity(id: String, s: Dictionary) -> void:
    _players[id] = {
        "pos": Vector2(float(s.get("x", 0.0)), float(s.get("y", 0.0))),
        "rot": float(s.get("rot", 0.0)),
        "hp": int(s.get("hp", MAX_HP)),
        "kills": int(s.get("kills", 0)),
        "name": str(s.get("name", "Player")),
    }

func _on_match_over(reason: String) -> void:
    _in_match = false
    _find_btn.disabled = false
    _set_status(reason)

9. Draw the arena, players, bullets & scoreboard

func _draw() -> void:
    draw_rect(ARENA, Color(0.10, 0.09, 0.07), true)
    draw_rect(ARENA, Color(0.42, 0.30, 0.18), false, 3.0)
    if not _in_match:
        return
    for id in _players:
        if id == _my_id:
            continue
        var p: Dictionary = _players[id]
        _draw_player(p["pos"], float(p["rot"]), Color(0.91, 0.44, 0.29), int(p["hp"]), str(p["name"]))
    var me: Dictionary = _players.get(_my_id, {})
    if not _respawning:
        _draw_player(_my_pos, _my_rot, Color(0.96, 0.70, 0.36), int(me.get("hp", MAX_HP)), "You")
    for b in _bullets:
        draw_circle(b["pos"], BULLET_RADIUS, Color(1.0, 0.95, 0.7))
    _draw_scoreboard()

func _draw_player(pos: Vector2, rot: float, color: Color, hp: int, label: String) -> void:
    draw_circle(pos, PLAYER_RADIUS, color)
    draw_line(pos, pos + Vector2.RIGHT.rotated(rot) * (PLAYER_RADIUS + 10.0), color, 4.0)
    var w := 36.0
    var bar := pos + Vector2(-w * 0.5, -PLAYER_RADIUS - 12.0)
    draw_rect(Rect2(bar, Vector2(w, 5.0)), Color(0, 0, 0, 0.5), true)
    draw_rect(Rect2(bar, Vector2(w * clampf(float(hp) / MAX_HP, 0.0, 1.0), 5.0)), Color(0.56, 0.81, 0.43), true)
    draw_string(ThemeDB.fallback_font, pos + Vector2(-w * 0.5, -PLAYER_RADIUS - 16.0), label, HORIZONTAL_ALIGNMENT_LEFT, -1, 13)

func _draw_scoreboard() -> void:
    var rows: Array = _players.values()
    rows.sort_custom(func(a, b): return int(a.get("kills", 0)) > int(b.get("kills", 0)))
    var y := 110.0
    draw_string(ThemeDB.fallback_font, Vector2(ARENA.end.x - 150.0, y), "SCORE", HORIZONTAL_ALIGNMENT_LEFT, -1, 14)
    for r in rows:
        y += 20.0
        draw_string(ThemeDB.fallback_font, Vector2(ARENA.end.x - 150.0, y), "%s  %d" % [str(r.get("name", "?")), int(r.get("kills", 0))], HORIZONTAL_ALIGNMENT_LEFT, -1, 13)

10. UI & helpers

func _random_spawn() -> Vector2:
    return Vector2(randf_range(ARENA.position.x + 40.0, ARENA.end.x - 40.0),
        randf_range(ARENA.position.y + 40.0, ARENA.end.y - 40.0))

func _my_name() -> String:
    return "P%d" % (randi() % 1000)

func _build_ui() -> void:
    var bar := HBoxContainer.new()
    bar.position = Vector2(16, 16)
    bar.add_theme_constant_override("separation", 8)
    add_child(bar)
    var connect_btn := Button.new()
    connect_btn.text = "Connect"
    connect_btn.pressed.connect(_on_connect)
    bar.add_child(connect_btn)
    _find_btn = Button.new()
    _find_btn.text = "Find Match"
    _find_btn.disabled = true
    _find_btn.pressed.connect(_on_find)
    bar.add_child(_find_btn)
    _status = Label.new()
    _status.position = Vector2(16, 56)
    _status.text = "Click Connect (uses the key + URL saved in the SpawnWeaver dock)."
    add_child(_status)

func _on_connect() -> void:
    if MultiplayerService.connect_using_config():
        _set_status("Connecting…")
        _find_btn.disabled = false
    else:
        _set_status("Save your public key + server URL in the SpawnWeaver dock first.")

func _on_find() -> void:
    if not MultiplayerService.is_connected_to_server():
        _set_status("Connect first.")
        return
    MultiplayerService.join_matchmaking(GAME_MODE, "", 2, _my_name())
    _find_btn.disabled = true
    _set_status("Searching for players…")

func _set_status(text: String) -> void:
    _status.text = text

Simpler: the SpawnSync version

The walkthrough above syncs movement by hand so you can see the protocol. Here's the same game with movement handled by the SpawnSync drop-in node — it streams each player's transform and interpolates remote players for you. Shooting, damage, and respawn are unchanged (they're events, not transforms).

A player scene

Make a player.tscn: a Node2D with the script below (a tiny bit of state + a visual), plus a SpawnSync child. On the SpawnSync node turn on Sync position and Sync rotation, and set Synced properties to hp.

extends Node2D
var hp := 100
var color := Color.WHITE

func _draw() -> void:
    draw_circle(Vector2.ZERO, 16.0, color)                 # body
    draw_line(Vector2.ZERO, Vector2.RIGHT * 26.0, color, 4.0)   # barrel (node rotation aims it)

Spawn local + remote players

SpawnSync handles sending and interpolation; you just decide when to create the scenes:

const PlayerScene := preload("res://player.tscn")
var _me: Node2D = null
var _remotes := {}

func _ready() -> void:
    MultiplayerService.match_found.connect(func(_r, _c, _p): _spawn_local())
    MultiplayerService.state_snapshot_received.connect(func(s):
        for e in s.get("entities", []): _ensure_remote(str(e.get("entityId", ""))))
    MultiplayerService.entity_state_changed.connect(func(id, _p, _f): _ensure_remote(id))
    MultiplayerService.entity_state_deleted.connect(func(id): _remotes.erase(id))
    MultiplayerService.event_received.connect(_on_event)
    MultiplayerService.connect_using_config()

func _spawn_local() -> void:
    _me = PlayerScene.instantiate()
    _me.color = Color(0.96, 0.70, 0.36)
    _me.position = _random_spawn()
    _me.get_node("SpawnSync").local = true        # streams my transform automatically
    add_child(_me)

func _ensure_remote(id: String) -> void:
    if id == MultiplayerService.player_id or _remotes.has(id):
        return
    var p := PlayerScene.instantiate()
    p.color = Color(0.91, 0.44, 0.29)
    p.get_node("SpawnSync").entity_id = id         # interpolates this player; frees itself on leave
    add_child(p)
    _remotes[id] = p

Move only yourself

func _process(delta: float) -> void:
    if _me == null:
        return
    var move := Input.get_vector("ui_left", "ui_right", "ui_up", "ui_down")
    _me.position += move * SPEED * delta
    _me.rotation = (_me.get_global_mouse_position() - _me.position).angle()
    _me.queue_redraw()
    if Input.is_action_pressed("ui_accept") or Input.is_mouse_button_pressed(MOUSE_BUTTON_LEFT):
        _try_fire(_me.position, _me.rotation)      # same shoot event as before
    # No patch_entity_state here — SpawnSync streams _me for you.

What this replaces from the manual version: the set_entity_state/patch_entity_state movement calls, _apply_entity, _on_entity_changed, and the transform parts of _on_snapshot — SpawnSync owns all of it. Keep _try_fire + _on_event (shots) and your hit/respawn logic; because hp is a Synced property, set _me.hp on a hit and it replicates to everyone.

11. Play it with two windows

  1. In Godot: Debug → Run Multiple Instances, set 2.
  2. Run the scene. In both windows click Connect, then Find Match — the server pairs you into the same arena.
  3. Arrow keys move, the mouse aims, click or space shoots. Frag your opponent and watch the scoreboard tick up; check live connections on the Debugger.

Want more than two players in one arena? Raise the match size in join_matchmaking(GAME_MODE, "", 2, …) (e.g. 4) — everyone in the room is rendered automatically.

Extend it

  • Lobbies instead of matchmaking — let friends play by code with create_lobby() / join_lobby() (see Lobby + ready check).
  • Persist stats — save total kills to player storage so they carry across sessions (see Save a player profile).
  • Match state — have the host track a round timer or target score with patch_room_state() (see Simple state sync).