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