Skip to content

Grid Placement v6.3.0

Multiplayer and Split-Screen

Set up multiple controllers with independent placement sessions for split-screen or multiplayer.

Status
Current
Version
v6.3.0
Source updated
2026-10-29
Generated on
2026-10-29

6.3. These pages document the 6.3 release, where placeable is now entry (what changed) and PlacementOwner is deprecated (migration). Still on 6.2? Your version's guides ship inside the plugin itself, under addons/grid_placement/guides/; the 6.2 URLs on this site now redirect to this series.

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) and conflict_root_path (its node path).
  • Distinct cells: every commit succeeds, each record is published under the acting session's session_id and 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_conflict with the winner named; only a 3D GRID attempt blocked by a SMOOTH holder reports cross_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 its next_step points 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 240

Split-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 id

A 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.

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.