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
shootevent with a ray (origin + direction). Each client tests the ray against its own player and, if hit, lowers its ownhp— 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
- Debug → Run Multiple Instances, set 2, and run the scene.
- In both windows: Connect → Find Match to be paired into the same arena.
- 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
CharacterBody3Dand 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).