Skip to content

M6.4 — Item Pickups on the Ground

What you'll learn

  • How to spawn a dropped item into the world as an Area2D pickup that carries its ItemData.
  • How to show rarity at a glance — a colored label and/or a "beam" — using Rarity.COLORS (M6.2).
  • Two pickup styles — walk-over (auto) versus press-to-pick (the M1.3 interact action) — and when each fits.
  • How a pickup hands its item to the inventory via signal, closing the gap between "it dropped" (M6.3) and "it's in my bag" (M7).

How it applies

  • The drop on the floor is the reward made visible. M6.3 decided an item exists; this is where the player sees it — a glowing thing with a color that tells them whether to run over. The pickup is the moment the loot loop pays out, and its readability (rarity color, label) is what makes a good drop feel good.
  • Pickup style shapes pacing. Auto-pickup on walk-over keeps a fast game flowing (good for common currency); press-to-pick gives the player control and avoids grabbing junk (good for gear). The choice is a design decision with a real feel difference.
  • Floor clutter is a real problem. A dense fight leaves many drops; rarity-colored labels let the player triage at a glance, and a despawn timer on commons keeps the floor from becoming noise. These are quality-of-life decisions the genre lives and dies on.
  • The pickup is the only node in the item story. ItemData is data; the pickup is the brief physical node that holds it in the world until collected, then hands it off and frees itself — a clean lifecycle the save system (M8) only has to account for at the data level.

Concepts

The pickup is a small node holding data

A dropped item needs a physical presence: something the player can see and collect. That is an Area2D (detects the player) with a Sprite2D (the item icon) and a Label (the name, colored by rarity). It holds the ItemData from M6.3. When collected, it announces the item and frees itself. The item data lives on; the pickup node is temporary.

Example

A pickup scene's script. It is handed an ItemData, displays it with the rarity color, and on collection emits the item and removes itself:

# res://scripts/item_pickup.gd
class_name ItemPickup
extends Area2D

var item: ItemData

@onready var sprite: Sprite2D = $Sprite
@onready var label: Label = $Label

func setup(new_item: ItemData) -> void:
    item = new_item

func _ready() -> void:
    sprite.texture = item.icon
    label.text = item.display_name
    label.modulate = Rarity.COLORS[item.rarity]   # item.rarity is a Rarity.Tier value (M6.2)
    body_entered.connect(_on_body_entered)

func _on_body_entered(body: Node2D) -> void:
    if body.is_in_group("player"):
        _collect()

func _collect() -> void:
    SignalBus.item_picked_up.emit(item)   # M7's inventory listens
    queue_free()

The pickup is on the pickup layer (8) with a mask for player (2), so body_entered fires when the player walks over it. It emits item_picked_up (a new SignalBus signal) and frees itself — decoupled from the inventory exactly like the death event is decoupled from loot.

Showing rarity

The single most important readability cue is the rarity color, pulled from the Rarity.COLORS table (M6.2) so the convention is consistent with everything else (the M7 tooltip uses the same table). Two common presentations:

  • Colored name label above the drop — cheap, precise, readable even in a pile.
  • Light beam / glow — a vertical colored gradient or a PointLight2D in the rarity color, visible from across the room. Higher rarities get brighter/taller beams so the player spots a yellow from afar.

Start with the colored label; add a beam for higher tiers when you want drops to call attention from a distance. Both read from the same color table, so a rarity recolor is one edit.

Walk-over vs press-to-pick

Two collection styles, each a design choice:

  • Walk-over (auto). body_entered collects immediately, as above. Fast and frictionless — good for currency and commons in a brisk game. Risk: the player hoovers up junk they didn't want.
  • Press-to-pick. body_entered/body_exited mark the pickup as "in range"; the player presses the interact action (M1.3) to collect the nearest in-range pickup. More control, no accidental grabs — the usual choice for gear in a loot-focused ARPG.

Example

Press-to-pick: track range, collect on the interact action.

var _player_in_range := false

func _on_body_entered(body: Node2D) -> void:
    if body.is_in_group("player"):
        _player_in_range = true

func _on_body_exited(body: Node2D) -> void:
    if body.is_in_group("player"):
        _player_in_range = false

func _unhandled_input(event: InputEvent) -> void:
    if _player_in_range and event.is_action_pressed("interact"):
        _collect()

Using the interact action (M1.3) means the same button picks up loot, opens doors, and talks to NPCs — consistent and rebindable. With several pickups in range, you'd collect the nearest; for one-at-a-time drops the simple version is fine.

Spawning the pickup (closing M6.3)

M6.3's _spawn_pickup(item, at_position) stub becomes real here: instance the pickup scene, setup() it with the rolled ItemData, position it at the drop location (often with a small random scatter so a pack's drops don't stack), and add it under World.

Example

@export var pickup_scene: PackedScene   # item_pickup.tscn

func _spawn_pickup(item: ItemData, at_position: Vector2) -> void:
    var pickup := pickup_scene.instantiate() as ItemPickup
    pickup.setup(item)
    pickup.global_position = at_position + Vector2.RIGHT.rotated(randf() * TAU) * randf() * 16.0
    get_tree().current_scene.get_node("World").add_child(pickup)

The small random offset scatters multiple drops so they don't perfectly overlap. setup() before add_child so the item is set when _ready runs and reads it.

Lifecycle and clutter

A pickup that is never collected lingers. For commons, a despawn Timer (e.g., 30–60 s) keeps the floor clean; rares might linger longer or forever. Pickups are also high-churn in a dense fight — a candidate for the object pooling from M4.4 if profiling shows the instantiate/free of many drops causing hitches.

Walkthrough

  1. Build the pickup scene: Area2D root renamed ItemPickup, children CollisionShape2D (a small circle), Sprite2D named Sprite, Label named Label (positioned above). Attach item_pickup.gd. Set the area's layer to pickup (8) and mask to player (2). Save as res://scenes/fx/item_pickup.tscn.
  2. Add the item_picked_up(item: ItemData) signal to SignalBus.
  3. Replace M6.3's _spawn_pickup stub with the real instancing (above); assign pickup_scene = item_pickup.tscn on the loot system in the Inspector.
  4. Choose a style: start with walk-over (_on_body_entered_collect), or wire press-to-pick with the interact action.
  5. (Temporary, until M7) Connect SignalBus.item_picked_up to a printer: func(i): print("picked up ", i.display_name).
  6. Press F5, kill skeletons, and watch drops appear with rarity-colored names. Walk over (or press interact near) one and confirm the printer reports it and the pickup disappears.

Optional sanity check

Force a rare by temporarily setting the rarity table so only rare has weight (M6.3). Confirm the dropped label is the rare color from Rarity.COLORS, not a default — proof the pickup reads the shared rarity table. Then drop several items at once and confirm the random scatter keeps their labels readable rather than stacked exactly. Restore the rarity weights.

Self-check quiz

Q1 — Why is the pickup an Area2D while the ItemData it carries is a Resource?

A. Area2D renders icons; Resources can't. B. The pickup is the temporary physical presence (detects the player, shows the icon, then frees itself); the item is the persistent data it carries and hands to the inventory. Node for presence, Resource for data. C. Resources can't be added to the scene tree, so loot must be an Area2D. D. Area2D is required for queue_free.

Reveal answer

B. The split mirrors the whole item model: data (Resource) persists into the inventory and the save; the world presence (node) is brief — it detects the player, displays the item, emits it, and frees itself. A is false (a Sprite2D child renders the icon). C is true that Resources aren't tree nodes but isn't the reasoning. D is false.

Q2 — Why does the pickup read its color from Rarity.COLORS rather than storing its own color?

A. To save memory. B. So rarity color is defined once and shared by the pickup label, any beam, and the M7 tooltip — a single source of truth, so recoloring a tier is one edit everywhere. C. Because Area2D can't store a Color. D. Colors must be constants.

Reveal answer

B. Centralizing the rarity-to-color mapping (M6.2) keeps every surface that shows rarity consistent and makes a recolor a one-line change. A duplicated color per pickup would drift from the tooltip's color. A, C, D are false.

Q3 — When is press-to-pick preferable to walk-over auto-pickup?

A. Never; auto-pickup is always better. B. For gear in a loot-focused game, where the player wants control and to avoid grabbing junk; walk-over suits currency/commons in a fast game. C. Only on mobile. D. Press-to-pick is required for rares.

Reveal answer

B. Press-to-pick gives deliberate control (don't hoover up junk, choose what to grab), which suits gear; walk-over keeps a brisk game flowing for low-value pickups. A overstates auto-pickup. C and D are fabricated constraints.

Integration question

Q4 — open

Follow one item across the entire loot loop, naming the M4/M6 piece responsible at each step: a skeleton dies, an item ends up in the player's hands. Explain specifically how the pickup decouples 'an item exists in the world' from 'an item is in the inventory,' and why that decoupling (via item_picked_up) is the same architectural move as enemy_died decoupling death from loot.

Reveal expected answer

The chain: the skeleton's Health.died fires and enemy.gd emits SignalBus.enemy_died(self, position) (M4.1); the loot system, subscribed to that signal, rolls the weighted drop and rarity tables (M6.3), and on a hit calls ItemFactory.roll to deep-duplicate a base type (M6.1) and roll affixes for the chosen rarity (M6.2), producing a finished ItemData; the loot system instances the pickup scene, setup()s it with that item, and places it at the death position (M6.4); the pickup displays the icon and the rarity-colored name from Rarity.COLORS, waits for the player (walk-over or interact), then emits SignalBus.item_picked_up(item) and frees itself; M7's inventory, subscribed to that signal, adds the item to the bag. The pickup decouples "an item exists in the world" from "an item is in the inventory" by making collection an announcement: the pickup never references the inventory, it just emits item_picked_up and disappears, and any inventory (or a test double, or a future auto-collect system) subscribes. This is exactly the enemy_died move from M4.1 — the producer of an event names no consumer, so the loot system could be added without touching enemies and the inventory can be added without touching pickups. The same decoupling principle bookends the loop: an enemy announces its death without knowing loot exists, and a pickup announces its collection without knowing the inventory exists, so each system attaches by subscribing rather than by being wired into the others.