Tutorial · 3D Shooter

Build a 3D multiplayer arena shooter

A third-person arena where players drive around and shoot each other with hitscan weapons. Same SpawnWeaver toolkit as the 2D tutorial — matchmaking, entity-state sync, the event relay, snapshots, damage and respawns — applied in 3D, with the whole scene built in code.

Matchmaking Entity state sync Realtime events Snapshots Hitscan · respawn · score

How it works

  • One entity per player holds x, z, yaw, hp, kills, name. Movement patches it; remote players are drawn as capsules updated from that state.
  • Hitscan shots are events. Firing sends a shoot event with a ray (origin + direction). Each client tests the ray against its own player and, if hit, lowers its own hp — the same victim-authoritative model as the 2D version, so there are no bullets to simulate.
  • Tank controls (turn + drive) keep input simple with Godot's default keys — no mouse capture needed.

1. Create the scene

Add a Node3D scene, attach a script, and save it (e.g. arena_3d.tscn). The script builds the camera, light, ground, player capsules and a HUD itself. Everything below is one script, pasted in order.

2. Constants & state

extends Node3D
## SpawnWeaver tutorial — 3D arena shooter (hitscan, free-for-all).

const GAME_MODE := "arena3d_ffa"
const MOVE_SPEED := 7.0
const TURN_SPEED := 2.6
const FIRE_COOLDOWN := 0.30
const SEND_INTERVAL := 0.06
const MAX_HP := 100
const HIT_DAMAGE := 34
const HIT_RADIUS := 1.2
const ARENA_HALF := 28.0

var _status: Label
var _hud: Label
var _find_btn: Button
var _camera: Camera3D

var _in_match := false
var _respawning := false
var _my_id := ""
var _my_pos := Vector3.ZERO
var _my_yaw := 0.0
var _fire_cd := 0.0
var _send_accum := 0.0
var _players: Dictionary = {}    # id -> { pos, yaw, hp, kills, name }
var _meshes: Dictionary = {}     # id -> MeshInstance3D

3. Build the world & subscribe to signals

func _ready() -> void:
    _build_world()
    _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(_remove_player)
    MultiplayerService.player_left.connect(func(_r, pid): _remove_player(pid))
    MultiplayerService.event_received.connect(_on_event)

func _build_world() -> void:
    var light := DirectionalLight3D.new()
    light.rotation_degrees = Vector3(-55, -40, 0)
    add_child(light)

    var world_env := WorldEnvironment.new()
    var env := Environment.new()
    env.background_mode = Environment.BG_COLOR
    env.background_color = Color(0.05, 0.04, 0.03)
    env.ambient_light_color = Color(0.40, 0.36, 0.30)
    env.ambient_light_energy = 0.6
    world_env.environment = env
    add_child(world_env)

    var ground := MeshInstance3D.new()
    var plane := PlaneMesh.new()
    plane.size = Vector2(ARENA_HALF * 2.0, ARENA_HALF * 2.0)
    ground.mesh = plane
    var gmat := StandardMaterial3D.new()
    gmat.albedo_color = Color(0.16, 0.13, 0.10)
    ground.material_override = gmat
    add_child(ground)

    _camera = Camera3D.new()
    _camera.position = Vector3(0, 10, 12)
    _camera.look_at(Vector3.ZERO, Vector3.UP)
    add_child(_camera)

4. Find a match & spawn your player

func _on_match_found(_room_id: String, room_code: String, _list: Array) -> void:
    _in_match = true
    _respawning = false
    _my_id = MultiplayerService.player_id
    _my_pos = _random_spawn()
    _my_yaw = 0.0
    _ensure_mesh(_my_id, Color(0.96, 0.70, 0.36))
    MultiplayerService.set_entity_state(_my_id, {
        "x": _my_pos.x, "z": _my_pos.z, "yaw": 0.0,
        "hp": MAX_HP, "kills": 0, "name": _my_name(),
    })
    _find_btn.disabled = true
    _update_hud()
    _set_status("Match %s! Left/Right turn · Up/Down move · Space shoots." % room_code)

5. Drive, aim & sync

Tank controls: turn with Left/Right, drive with Up/Down. Facing is the yaw angle; we patch our entity a few times a second and keep the camera behind us.

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_camera()

func _handle_input(delta: float) -> void:
    if Input.is_action_pressed("ui_left"):
        _my_yaw += TURN_SPEED * delta
    if Input.is_action_pressed("ui_right"):
        _my_yaw -= TURN_SPEED * delta
    var fwd := Vector3(sin(_my_yaw), 0.0, cos(_my_yaw))
    var drive := 0.0
    if Input.is_action_pressed("ui_up"):
        drive += 1.0
    if Input.is_action_pressed("ui_down"):
        drive -= 1.0
    _my_pos += fwd * drive * MOVE_SPEED * delta
    _my_pos.x = clampf(_my_pos.x, -ARENA_HALF, ARENA_HALF)
    _my_pos.z = clampf(_my_pos.z, -ARENA_HALF, ARENA_HALF)
    _update_my_mesh()
    if Input.is_action_pressed("ui_accept"):
        _try_fire(fwd)
    _send_accum += delta
    if _send_accum >= SEND_INTERVAL:
        _send_accum = 0.0
        MultiplayerService.patch_entity_state(_my_id, {"x": _my_pos.x, "z": _my_pos.z, "yaw": _my_yaw})

func _update_camera() -> void:
    if not _meshes.has(_my_id):
        return
    var fwd := Vector3(sin(_my_yaw), 0.0, cos(_my_yaw))
    _camera.position = _my_pos + Vector3(0, 6.0, 0) - fwd * 9.0
    _camera.look_at(_my_pos + Vector3(0, 1.0, 0), Vector3.UP)

6. Shoot (hitscan) over the event relay

Firing sends a ray. Everyone draws a tracer; each client checks whether the ray passes close to its own player and, if so, takes damage.

func _try_fire(fwd: Vector3) -> void:
    if _fire_cd > 0.0:
        return
    _fire_cd = FIRE_COOLDOWN
    var origin := _my_pos + Vector3(0, 1.0, 0)
    MultiplayerService.send_event("shoot", {
        "ox": origin.x, "oy": origin.y, "oz": origin.z,
        "dx": fwd.x, "dy": fwd.y, "dz": fwd.z,
    })
    _spawn_tracer(origin, origin + fwd * 60.0)

func _on_event(event: String, data: Dictionary, from_id: String) -> void:
    if event == "shoot":
        var origin := Vector3(float(data.get("ox", 0)), float(data.get("oy", 0)), float(data.get("oz", 0)))
        var dir := Vector3(float(data.get("dx", 0)), float(data.get("dy", 0)), float(data.get("dz", 0)))
        _spawn_tracer(origin, origin + dir * 60.0)
        if _respawning:
            return
        var me := _my_pos + Vector3(0, 1.0, 0)
        var t := (me - origin).dot(dir)             # distance along the ray to my chest
        if t < 0.0:
            return                                   # I'm behind the shooter
        if (origin + dir * t).distance_to(me) <= HIT_RADIUS:
            _take_hit(from_id)
    elif event == "frag" and str(data.get("killer", "")) == _my_id:
        var rec: Dictionary = _players.get(_my_id, {})
        rec["kills"] = int(rec.get("kills", 0)) + 1
        _players[_my_id] = rec
        MultiplayerService.patch_entity_state(_my_id, {"kills": rec["kills"]})
        _update_hud()

7. Damage, death & respawn

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

func _respawn() -> void:
    _respawning = true
    _set_status("Fragged! Respawning…")
    await get_tree().create_timer(1.5).timeout
    _my_pos = _random_spawn()
    var rec: Dictionary = _players.get(_my_id, {})
    rec["hp"] = MAX_HP
    _players[_my_id] = rec
    MultiplayerService.patch_entity_state(_my_id, {"x": _my_pos.x, "z": _my_pos.z, "hp": MAX_HP})
    _respawning = false
    _update_hud()
    _set_status("Fight! Left/Right turn · Up/Down move · Space shoots.")

8. Receive other players (snapshot + updates)

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:
        _apply_entity(entity_id, full)

func _apply_entity(id: String, s: Dictionary) -> void:
    _players[id] = {
        "pos": Vector3(float(s.get("x", 0)), 0.0, float(s.get("z", 0))),
        "yaw": float(s.get("yaw", 0.0)),
        "hp": int(s.get("hp", MAX_HP)),
        "kills": int(s.get("kills", 0)),
        "name": str(s.get("name", "Player")),
    }
    _ensure_mesh(id, Color(0.91, 0.44, 0.29))
    var mi: MeshInstance3D = _meshes[id]
    mi.position = _players[id]["pos"] + Vector3(0, 1.0, 0)
    mi.rotation = Vector3(0, _players[id]["yaw"], 0)
    _update_hud()

func _remove_player(id: String) -> void:
    if _meshes.has(id):
        _meshes[id].queue_free()
        _meshes.erase(id)
    _players.erase(id)
    _update_hud()

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

9. Meshes, tracer & helpers

func _ensure_mesh(id: String, color: Color) -> void:
    if _meshes.has(id):
        return
    var body := MeshInstance3D.new()
    var capsule := CapsuleMesh.new()
    capsule.radius = 0.5
    capsule.height = 2.0
    body.mesh = capsule
    var mat := StandardMaterial3D.new()
    mat.albedo_color = color
    body.material_override = mat
    var barrel := MeshInstance3D.new()      # shows facing
    var box := BoxMesh.new()
    box.size = Vector3(0.2, 0.2, 1.2)
    barrel.mesh = box
    barrel.position = Vector3(0, 0, 0.8)
    barrel.material_override = mat
    body.add_child(barrel)
    add_child(body)
    _meshes[id] = body

func _update_my_mesh() -> void:
    var mi: MeshInstance3D = _meshes.get(_my_id)
    if mi != null:
        mi.position = _my_pos + Vector3(0, 1.0, 0)
        mi.rotation = Vector3(0, _my_yaw, 0)

func _spawn_tracer(a: Vector3, b: Vector3) -> void:
    var im := ImmediateMesh.new()
    im.surface_begin(Mesh.PRIMITIVE_LINES)
    im.surface_add_vertex(a)
    im.surface_add_vertex(b)
    im.surface_end()
    var mi := MeshInstance3D.new()
    mi.mesh = im
    var mat := StandardMaterial3D.new()
    mat.shading_mode = BaseMaterial3D.SHADING_MODE_UNSHADED
    mat.albedo_color = Color(1.0, 0.9, 0.6)
    mi.material_override = mat
    add_child(mi)
    get_tree().create_timer(0.05).timeout.connect(mi.queue_free)

func _random_spawn() -> Vector3:
    return Vector3(randf_range(-ARENA_HALF + 3.0, ARENA_HALF - 3.0), 0.0,
        randf_range(-ARENA_HALF + 3.0, ARENA_HALF - 3.0))

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

10. HUD & connect controls

func _build_ui() -> void:
    var layer := CanvasLayer.new()
    add_child(layer)
    var bar := HBoxContainer.new()
    bar.position = Vector2(16, 16)
    bar.add_theme_constant_override("separation", 8)
    layer.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)."
    layer.add_child(_status)
    _hud = Label.new()
    _hud.position = Vector2(16, 84)
    layer.add_child(_hud)

func _update_hud() -> void:
    var me: Dictionary = _players.get(_my_id, {})
    var text := "HP %d   Kills %d\n— scores —\n" % [int(me.get("hp", MAX_HP)), int(me.get("kills", 0))]
    var rows: Array = _players.values()
    rows.sort_custom(func(a, b): return int(a.get("kills", 0)) > int(b.get("kills", 0)))
    for r in rows:
        text += "%s: %d\n" % [str(r.get("name", "?")), int(r.get("kills", 0))]
    _hud.text = text

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 manages meshes and sends transforms by hand. Here's the same game with movement handled by the SpawnSync drop-in node — it streams and interpolates each player's capsule for you. The hitscan shooting, damage, and respawn are unchanged.

A player scene

Make a player.tscn: a Node3D with a MeshInstance3D (a CapsuleMesh) child for the body, a tiny script (var hp := 100), and a SpawnSync child with Sync position + Sync rotation on and Synced properties = hp.

Spawn local + remote players

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

func _ready() -> void:
    _build_world()                                  # camera, light, ground (from above)
    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.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.get_node("SpawnSync").entity_id = id          # interpolates this capsule; frees on leave
    add_child(p)
    _remotes[id] = p

Drive only yourself

func _process(delta: float) -> void:
    if _me == null:
        return
    if Input.is_action_pressed("ui_left"):
        _me.rotation.y += TURN_SPEED * delta
    if Input.is_action_pressed("ui_right"):
        _me.rotation.y -= TURN_SPEED * delta
    var fwd := Vector3(sin(_me.rotation.y), 0.0, cos(_me.rotation.y))
    if Input.is_action_pressed("ui_up"):
        _me.position += fwd * MOVE_SPEED * delta
    if Input.is_action_pressed("ui_down"):
        _me.position -= fwd * MOVE_SPEED * delta
    if Input.is_action_pressed("ui_accept"):
        _try_fire(fwd)                              # same hitscan shoot event as before
    _update_camera()                               # follow _me
    # No patch_entity_state here — SpawnSync streams _me for you.

What this replaces: the manual set_entity_state/patch_entity_state, _apply_entity, _ensure_mesh/_update_my_mesh, and the transform parts of the snapshot handler — SpawnSync streams and interpolates the capsule. Keep the hitscan _try_fire/_on_event and hit/respawn; hp rides along via Synced properties.

11. Play it with two windows

  1. Debug → Run Multiple Instances, set 2, and run the scene.
  2. In both windows: ConnectFind Match to be paired into the same arena.
  3. Left/Right turn, Up/Down drive, Space shoots. Line up your barrel and fire; the HUD shows HP and the kill scoreboard. Watch sessions on the Debugger.

More players? Raise the match size in join_matchmaking(GAME_MODE, "", 2, …). Want mouse-look or real bullet drop? Swap the hitscan ray for a projectile like the 2D shooter uses.

Extend it

  • Physics & cover — give players a CharacterBody3D and add obstacle meshes; raycast against the physics world for line-of-sight.
  • Persist stats — store wins/kills with player storage (see Save a player profile).
  • Rounds — let the host run a timer/score target with patch_room_state() (see Simple state sync).