Chris' Tutorials
Docs/Item Vault

Item Vault v1.0

Dimension-Neutral Handling

Why SpawnEvent/PickupEvent carry a dimension field but forbid branching on it.

StatusDraft
Versionv1.0
UpdatedDevelopment docs generated from GDScript source

This is unreleased documentation in active development. APIs, class names, and behavior may change before the final release.

Draft — Unreleased:This page is in active development. APIs, class names, and behavior may change before the release is finalized. Use it as a preview of what's coming — not as a stable integration target.

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:

  1. Type-level polymorphism already covers it. A SceneSpawner2D is Node2D; a SceneSpawner3D is Node3D. The dimension is encoded in the static type of event.spawner. If your game code needs Vector3 math, write var p: Vector3 = event.spawner.global_position inside a code path that only the 3D variant reaches — Godot's type checker will follow the is narrowing for you.

  2. Branching on dimension defeats the boundary. The whole point of carrying ItemStack on SpawnEvent (instead of two parallel SpawnEvent2D/SpawnEvent3D classes) 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 on event.dimension, you've re-invented two parallel types with extra steps.

  3. The dimension can be wrong. A SceneSpawner3D subclass that overrides spawn() and accidentally spawns a Node2D is 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.spawner cast to Vector2 or Vector3 happens inside a branch that already narrowed via is Node2D / is Node3D.
  • Dispatch tables (if used) are keyed on spawner class, not on dimension value.
  • event.dimension only appears in logging code, never in branching code.

What's Next

Source

addons/item_vault/guides/dimension-neutral-handling.md

Plugin docs root:gdscript/plugins/item_vault_dev/addons/item_vault/guides