Grid Placement supports multiple independent placement interactions through one PlacementSession per player/controller.
The important split is:
Player A session ─┐
├─ GridPlacementHost → shared committed placement world
Player B session ─┘Selections/previews are independent. Committed occupancy/mounts are shared when both players are building in the same world.
Keep auto_configure off for split-screen
GridPlacementHost.auto_configure registers one authored session at _ready. A split-screen scene does the opposite: each player gets a controller that owns and registers its own session, so leave auto_configure disabled here. Enabling it would also register the single authored session. The authored shortcut (host session plus auto_configure) is for the single-session case.
Controller setup runs in _enter_tree, before the host's _ready, so a controller that configures the host always wins and services are never composed twice.
Register one session per controller
# One more player from an already configured session: the fork isolates that
# player's runtime state, and registering gives it the level and an indicator
# manager of its own. No scene wiring beyond this call.
var session:= source_session.fork_for_player(player_actions)
host.register_session(self, session, player_device)fork_for_player exists because Resource.duplicate() copies Resources but
shares RefCounted objects: a plain duplicate would still point at the source
session's live states and contexts, so one player's preview, placer or
indicator binding would overwrite the other's. Build a session from scratch
with PlacementSession.new() when the players do not share a starting point.
Unregister it when the controller leaves:
host.unregister_session(self)Use a device id when your local input setup needs device-based routing. For keyboard/mouse or custom input routing, wire the project input layer intentionally rather than assuming the plugin can infer player ownership.
The host wires each registered session
register_session applies the level's surface and objects parent to the session
and creates an indicator manager for it, because a session registered after
configure() would otherwise commit into missing_dependency kind=placed_parent
and then Placement isn't ready yet.. Bind scene consumers (HUD, positioners)
per player as usual; the level and the indicator node are not your job.
Settings vs bundle: precedence
Game-wide configuration lives in GridPlacementBundle (settings, actions, templates). A PlacementSession may override per-player via its Override fields (settings, actions); when set, they win over the bundle's. Leave them null to inherit. For split-screen, prefer a per-player bundle when several fields differ, and use the Override fields only for small tweaks.
What stays independent
Each session can have its own:
- selected entry/terrain;
- GRID vs SMOOTH mode;
- current preview/target;
- pending manipulation/brush action;
- unlock/catalog availability;
- controller-specific UI/input state.
What is shared
Sessions in the same placement world must agree on committed facts:
- placed object identity;
- GRID/SMOOTH occupancy;
- CELL/EDGE/FACE/CORNER/TOP/socket occupancy;
- save/restore world records.
If Player A occupies an EDGE mount, Player B should see that same edge as occupied even though their previews/selections are independent.
Two, three, four players at once
Every registered session can commit in the same frame. Dispatch runs on the main thread, one attempt at a time, so a frame with four presses is four sequential commits, not four interleaved writers: no commit sees a half-written world, and the result is the same as if they had pressed one after another in that order.
Contention is a refusal, never a silent overwrite:
- Same cell, same frame: exactly one commit succeeds. The others refuse
with
occupancy_conflict, and the refusal names the winner:conflict_instance_id(its stable placement instance) andconflict_root_path(its node path). - Distinct cells: every commit succeeds, each record is published under the
acting session's
session_idand placer, and the shared world grows by one record per player. - Different coordinate modes: SMOOTH and GRID commits share one occupancy
world, so a smooth footprint blocks a grid cell over it. Same-mode
contention (GRID vs GRID, including 3D GRID races) reports
occupancy_conflictwith the winner named; only a 3D GRID attempt blocked by a SMOOTH holder reportscross_mode_conflict, with the same winner fields.
There is no reservation window. A player's preview does not hold a cell; only a committed record does. Every session keeps its own ghost, so four players can hold four pre-commits at the same time, two ghosts can sit on the same cell, and the second commit is the one that learns it lost.
Reads for a contention bug in the failure block, lifecycle, or a listener on
GridPlacementHost.get_lifecycle_bus():
| field | means |
|---|---|
session_id |
the player whose attempt this is |
placer_state / placer_path |
which actor requested it |
reason = occupancy_conflict |
the space was taken |
conflict_instance_id |
the committed piece that holds it |
conflict_root_path |
where that piece lives in the scene |
Those last two reach every surface that answers "why did this refuse", so a contention bug needs no debugger:
- the structured console line — one per attempt, WARNING for a refusal:
GP|WARNING|placement_rejected|...|reason=occupancy_conflict|conflict_instance_id=p7_3|conflict_root_path=/root/World/Player2/Piece - the lifecycle result you already hold:
lifecycle.diagnostics.get(&"conflict_instance_id") PlacementExplain, the one function that renders an outcome:PlacementExplain.explain(host.get_last_dispatch_result())["conflict"]is{instance_id, root_path}for a contention and{}otherwise, and itsnext_steppoints at them.
Unregistering one player mid-session (host.unregister_session(controller))
retires only that session: its preview and pending actions are cancelled and the
others keep committing. Register players in _enter_tree and unregister in
_exit_tree so a dropped controller never leaves a stale session behind.
Verified at two, three and four players
res://test/grid_placement/e2e/multi_session_contention_test.gd asserts the
claims on this page at each size instead of only at two: distinct-cell commits
at 2, 3 and 4; a one-cell race at 2, 3 and 4 where exactly one player wins and
every loser's refusal names that winner on the lifecycle result and in
PlacementExplain; four players holding live input at once with each keeping
its own selection, mode, target and ghost; sixteen commits over four rounds
under sustained concurrency; and one player unregistering mid-stream while the
rest keep committing. It also stages one GRID and one SMOOTH session together,
then checks distinct targets and overlapping targets in both commit orders.
Mixed-mode checks include synthetic confirm actions routed by registered device
IDs through the host's input and physics queue. Selection and targets are staged
directly; physical controllers and pointer movement are not exercised.
python3 scripts/testing/run_test_tree.py \
--target res://test/grid_placement/e2e/multi_session_contention_test.gd \
--timeout 240Split-screen presentation
State isolation does not automatically build a split-screen UI for you. Your game owns:
- cameras/viewports;
- player-specific cursor/target adapters;
- per-player HUD/catalog presentation;
- device/action mapping.
Make each HUD read its player's PlacementSession. Avoid global mutable selection state.
Network multiplayer boundary
Grid Placement provides placement mechanics/state, not a complete network-authority model.
Your game still decides:
- client prediction vs server authority;
- RPC/replication format;
- permission/ownership checks;
- rollback/reconciliation;
- persistence ownership.
A networked game should have its authoritative game layer validate/accept placement requests through the supported placement APIs rather than trusting a client-side preview.
2D and 3D
The same session/world split applies to:
- 2D objects and terrain interaction state;
- 3D GRID/SMOOTH object placement;
- CELL/EDGE/FACE/CORNER/TOP/socket structures;
- shared occupancy and restore.
Do not create separate competing placement worlds just because players use different coordinate modes.
3D GRID objects: one service, one slot per session
A 3D scene binds a single ObjectPlacementService3D with
GridPlacementHost.set_object_placement_service_3d(). That service holds the
committed occupancy for everyone, plus one selection/preview slot per session:
the selected entry, the ghost, its yaw, and its mount. When a session gets
input (its controller's events, select_entry_for_session, or a
PlacementRuntime commit), the host binds that session's slot. The other
players' ghosts stay where they left them.
Drive a specific player's preview through the host, not a cached service reference:
var service:= host.get_object_placement_service_3d_for_session(player_two_session)
service.update_preview_cell(cell)
service.rotate_preview(1)
service.try_place_report(cell)# recorded under player two's session idA parked ghost is not revalidated while its player is idle. If another player commits on that spot, the parked ghost keeps its last tint until its owner's next preview update, and the commit is still refused.
Common mistakes
| Symptom | Cause to check |
|---|---|
| Player A changes Player B's selection | Sessions are shared or UI points at the wrong session. |
Second player: missing_dependency placed_parent |
That session never bound the level (host.bind_scene_consumers(level_context, session)). |
| Two players place into the same occupied location | They are not sharing the same authoritative placement world/registry. |
| Wrong player reacts to controller input | Device/action routing or controller→session registration. |
| HUD shows another player's state | UI reads a global/default session instead of its owner session. |
| Network client can bypass rules | Game/server authority is trusting client state instead of validating placement. |
Related guides
Using this in a game?
Grid Placement v6.3.0 — full source, playable demos, commercial license
The guide above is the same setup the shipped demos use. Try the build in your browser before you buy it.