Every project I've touched has had some version of this: an if/else chain buried in the enemy's die() function, hardcoded item IDs, drop rates as magic numbers scattered across scripts. Finally sat down and wrote a proper system.
The design is two custom Resources. LootEntry holds an item ID, weight, quantity range, and optional tag arrays (required and forbidden). LootTable holds the entries plus roll config: guaranteed rolls, and an optional bonus roll gated by probability.
# LootEntry.gd
class_name LootEntry
extends Resource
@export var item_id: String = ""
@export var weight: float = 1.0
@export var quantity_min: int = 1
@export var quantity_max: int = 1
@export var required_tags: Array[String] = []
@export var forbidden_tags: Array[String] = []
@export var nested_table: LootTable = null
func matches_context(tags: Array[String]) -> bool:
for tag in required_tags:
if tag not in tags: return false
for tag in forbidden_tags:
if tag in tags: return false
return true
# LootTable.gd
class_name LootTable
extends Resource
@export var entries: Array[LootEntry] = []
@export var guaranteed_rolls: int = 1
@export var bonus_roll_chance: float = 0.0
@export var bonus_rolls: int = 1
func roll(context_tags: Array[String] = []) -> Array[Dictionary]:
var eligible := entries.filter(func(e): return e.matches_context(context_tags))
if eligible.is_empty():
return []
var total_weight := 0.0
for e in eligible:
total_weight += e.weight
var results: Array[Dictionary] = []
for _i in guaranteed_rolls:
var pick := _weighted_pick(eligible, total_weight)
if pick:
results.append(_resolve(pick, context_tags))
for _i in bonus_rolls:
if randf() < bonus_roll_chance:
var pick := _weighted_pick(eligible, total_weight)
if pick:
results.append(_resolve(pick, context_tags))
return results
func _weighted_pick(pool: Array, total: float) -> LootEntry:
var r := randf() * total
for entry in pool:
r -= entry.weight
if r <= 0.0:
return entry
return pool.back()
func _resolve(entry: LootEntry, tags: Array[String]) -> Dictionary:
if entry.nested_table:
return {"nested": entry.nested_table.roll(tags)}
return {
"item_id": entry.item_id,
"quantity": randi_range(entry.quantity_min, entry.quantity_max)
}
Context tags are a plain string array you pass at roll time: loot_table.roll(["dungeon", "elite"]). Entries get filtered before the weighted pick, so anything that doesn't match the current context isn't in the pool. One .tres file per enemy type handles every case without branching in code.
The nested table support is the part I'm happiest with. A LootEntry can point to another LootTable instead of a direct item, so a shared "currency" or "rare consumables" pool can be referenced by multiple enemy tables without duplicating entries. Works recursively, though I haven't stress-tested deep nesting.
The one thing I haven't cracked: guaranteed unique drops per roll session. With multiple guaranteed rolls and a high-weight entry, the same item can appear twice in one drop. Could pull picked entries from the pool before each subsequent roll, but that changes the probability distribution. Not sure it actually matters in practice or if I'm overthinking it.
Anyone built something similar? Curious how you handle the uniqueness problem, and whether tag-based context filtering is worth the setup overhead on smaller projects or if it's overengineering.