When your game uses both 2D and 3D scenes (or plans to), it's tempting to
write code like if event.dimension == &"2d": ... else: ... on every
SpawnEvent and PickupEvent handler. Don't. The plugin intentionally
exposes a dimension field for diagnostics but documents it as
non-branching. This guide explains why and what to do instead.
The shape of the contract
SpawnEvent (and PickupEvent) look like this:
class_name SpawnEvent
extends Object
var spawner: Node # the SceneSpawner that fired
var spawned: Node # the instantiated scene
var stack: ItemStack # resolved item stack (nullable for generic spawns)
var dimension: StringName # &"2d", &"3d", or &""
The doc-comment on the field reads:
Diagnostics only: "2d" or "3d". Behavior must not branch on this.
dimension is populated by the spawner when it builds the event
(SpawnEvent.new(self, instance, _resolve_stack(instance), &"2d")),
and is read by no consumer in the plugin today. It's a debugging
hook, not a routing key.
Why a typed event with a banned field
Three reasons:
Type-level polymorphism already covers it. A
SceneSpawner2DisNode2D; aSceneSpawner3DisNode3D. The dimension is encoded in the static type ofevent.spawner. If your game code needsVector3math, writevar p: Vector3 = event.spawner.global_positioninside a code path that only the 3D variant reaches — Godot's type checker will follow theisnarrowing for you.Branching on
dimensiondefeats the boundary. The whole point of carryingItemStackonSpawnEvent(instead of two parallelSpawnEvent2D/SpawnEvent3Dclasses) is so the bus, the bridge, and any consumer can subscribe to one signal regardless of whether the spawn is happening in a 2D scene or a 3D scene. The moment you branch onevent.dimension, you've re-invented two parallel types with extra steps.The dimension can be wrong. A
SceneSpawner3Dsubclass that overridesspawn()and accidentally spawns aNode2Dis a bug;dimension: &"3d"doesn't catch it. Branching on it would silently work in tests and break in production when an item happens to have both 2D and 3D pickup scenes.
What to do instead
Use type narrowing
func _on_item_dropped(event: SpawnEvent) -> void:
# stack resolution is dimension-neutral
if event.stack == null:
return
var stack: ItemStack = event.stack
# If you need spawner global_position, narrow on the type.
if event.spawner is Node3D:
var p: Vector3 = event.spawner.global_position
# ...do 3D-only work...
elif event.spawner is Node2D:
var p: Vector2 = event.spawner.global_position
# ...do 2D-only work...
# else: generic, dimension-agnostic work.
If your game is 2D-only or 3D-only, write the type that matches and the narrowing never fires the wrong branch.
Use dispatch tables, not if ladders
If you find yourself writing if 2d: ... elif 3d: ... repeatedly,
build a small dispatch table keyed on the spawner class:
# game/systems/drop_dispatcher.gd
class_name DropDispatcher
extends RefCounted
var _handlers_2d: Dictionary = {} # Node2D subclass -> Callable
var _handlers_3d: Dictionary = {} # Node3D subclass -> Callable
func register_2d(spawner_class: Script, handler: Callable) -> void:
_handlers_2d[spawner_class] = handler
func register_3d(spawner_class: Script, handler: Callable) -> void:
_handlers_3d[spawner_class] = handler
func dispatch(event: SpawnEvent) -> void:
var table: Dictionary = _handlers_2d if event.spawner is Node2D else _handlers_3d
var handler = table.get(event.spawner.get_script())
if handler != null:
handler.call(event)
The dispatch table is dimension-typed (you register 2D handlers in the
2D table, 3D in the 3D table) but the dispatch itself never reads
event.dimension.
Use the dimension field only for logging
func _on_item_dropped(event: SpawnEvent) -> void:
Log.debug("spawned %s (dim=%s, stack=%s)" % [
event.spawned.name,
event.dimension,
event.stack.item.id if event.stack != null else "<none>",
])
That's the only sanctioned use. If you find yourself adding else
branches against event.dimension, refactor to type narrowing or a
dispatch table before merging.
When you actually need a 3D-only or 2D-only path
That's fine — but make the boundary explicit at the type level, not the value level. Two patterns:
Pattern A: split consumers at the connection site
# In a 2D-only scene
inventory_drops.item_dropped.connect(_on_item_dropped_2d)
func _on_item_dropped_2d(event: SpawnEvent) -> void:
# The compiler/inspector guarantees event.spawner is Node2D.
var p: Vector2 = event.spawner.global_position
spawn_2d_pickup_fx(p, event.stack)
The 3D scene connects to a different handler. SpawnEvent's payload
is identical; the handling is dimension-specific.
Pattern B: small per-dimension adapter that fans into a shared service
# game/systems/harvest_drop_router.gd (2D version)
class_name HarvestDropRouter2D
extends Node
@export var spawner: SceneSpawner2D
@export var pickup_service: PickupService # dimension-neutral
func _ready() -> void:
spawner.spawned.connect(_on_spawned)
func _on_spawned(event: SpawnEvent) -> void:
pickup_service.register_drop_at(event.spawner.global_position, event.stack)
PickupService is dimension-neutral because Vector2/Vector3 work
is done by the calling adapter, not by the service.
Edge cases and gotchas
The dimension field can be empty
When a SpawnEvent is constructed manually outside a spawner (a test,
an editor tool, a custom drop emitter), dimension may be &"". Treat
empty the same as "unknown" — branch on event.spawner is Node2D /
is Node3D, never on event.dimension.is_empty().
Subclasses that spawn into the other dimension
ShapeEdgeSceneSpawner3D must spawn Node3D instances; the base
spawn() enforces this with a runtime check. If you write a custom
spawner that intentionally spawns into a different dimension (e.g.
a 3D enemy that drops 2D pickup scenes for a UI mini-game), the
runtime check will reject it. That rejection is correct — split
your spawner into two classes instead.
Editor tools and gizmos
Editor gizmos read the spawner's type to choose between 2D and
3D drawing primitives. The gizmo for LaunchForceSceneSpawner2D
calls Node2D._draw(); for LaunchForceSceneSpawner3D it uses
ImmediateMesh on Node3D._draw(). The selection is type-driven,
not value-driven, for the same reason as above.
Verification
- No
if event.dimension == &"2d"/elif event.dimension == &"3d"anywhere in your game code. - Every
event.spawnercast toVector2orVector3happens inside a branch that already narrowed viais Node2D/is Node3D. - Dispatch tables (if used) are keyed on spawner class, not on
dimensionvalue. -
event.dimensiononly appears in logging code, never in branching code.
What's Next
- Read
item-data-and-stack-metadata.mdfor howSpawnEvent.stackis resolved. - Read
runtime-and-pickup-bridge.mdfor routing pickup events into an inventory target.
Source
addons/item_vault/guides/dimension-neutral-handling.md
Plugin docs root:gdscript/plugins/item_vault_dev/addons/item_vault/guides