Tutorial: build a 3D multiplayer arena
Third-person 3D combat: mouse-look character controller, hitscan shooting with
tracers, health, respawns, and a shared scoreboard. The finished project ships at
examples/tutorial_3d_arena/. Do the 2D arena tutorial
first — this one reuses its architecture (spawning, events, authority, room state) and
focuses on what's different in 3D.
1. The level
A Node3D root with a StaticBody3D floor (BoxShape3D + BoxMesh), a
DirectionalLight3D with shadows, a WorldEnvironment, and a SpawnPoints node
holding four Marker3Ds around the arena. Drop in a PlayerSpawner, point it at the
player scene from step 2 and at SpawnPoints.
2. The character controller
player_3d.tscn: a CharacterBody3D with a capsule collision + mesh, a Label3D
(billboard) for the name, a CameraArm (Node3D) holding the chase Camera3D, and a
SpawnSync child with hp in Synced Properties (rotation sync stays ON — you want
to see who's facing where).
The controller is standard Godot; the multiplayer-relevant parts:
func setup(player: SWPlayer, local: bool) -> void:
is_local = local
player_name = player.display_name()
func _ready() -> void:
$CameraArm/Camera3D.current = is_local # only YOUR camera is live
if is_local:
Input.mouse_mode = Input.MOUSE_MODE_CAPTURED
else:
set_physics_process(false) # SpawnSync drives remote copies
set_process_input(false)
Mouse-look rotates the body on Y (rotation.y) and pitches only the camera arm.
Because SpawnSync syncs rotation, other players see you turn — free aim telegraphing.
3. Hitscan shooting
3D shots are instant rays, not travelling projectiles:
# Local player fires: raycast from the camera.
var from := camera.global_position
var direction := -camera.global_transform.basis.z
var query := PhysicsRayQueryParameters3D.create(from, from + direction * 100.0)
query.exclude = [me.get_rid()] # don't shoot yourself
var hit := get_world_3d().direct_space_state.intersect_ray(query)
SpawnWeaver.send_event("shot", { ... }) # so everyone draws the tracer
if hit.collider is CharacterBody3D:
SpawnWeaver.send_event("hit", {"victim": victim_id})
Everyone renders the tracer (an ImmediateMesh line, freed after 80 ms) when the
shot event arrives — instant feedback, no synced projectile entities needed.
Damage, kills, scores, and storage work exactly as in the 2D tutorial: victim
applies its own hp, host owns the scoreboard, find_match("arena-3d") for
matchmaking.
What's different in 3D — recap
| Concern | 2D | 3D |
|---|---|---|
| Movement sync | position (+rot off) | position + rotation (aim telegraphing) |
| Camera | none/shared | per-player chase cam, current only on local |
| Shooting | travelling Area2D projectile | hitscan raycast + tracer event |
| Input | arrows/WASD | + captured mouse, Esc to release |
Common mistakes
| Mistake | Symptom | Fix |
|---|---|---|
Every camera current = true |
View snaps to the last spawned player | Only the local player's camera |
| Raycast includes the shooter | You instantly shoot yourself | query.exclude = [me.get_rid()] |
| Syncing the camera arm pitch | Remote heads twitch | Sync the body; keep the arm local |
Where next
- Sync guide — everything SpawnSync and PlayerSpawner can do
- Best practices — authority models beyond shooter-decides
- Learn: advanced path — optimization, authority, production deployment