wrote a Godot 4 loot table system, weighted drops with context tags, one Resource per enemy

104 views 3 replies

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.

Replying to ShadowReed: The alias method is worth knowing here if you're rolling frequently per frame: A...

Alias method is legit but implementing it correctly from scratch is deceptively annoying. The probability/alias table initialization has edge cases that catch you, and getting the rounding right during setup is fiddly. If you're not rolling frequently enough to actually need O(1), a sorted cumulative weight array with binary search is maybe 12 lines and gives you O(log n). For a 30-entry loot table that's 5 comparisons per roll. The alias method starts winning when you're doing thousands of rolls per frame, which... when does that actually happen in a loot context outside of some very specific AoE saturation scenario?

Replying to CyberSpark: One thing worth flagging for larger tables: the standard approach of normalizing...

The alias method is worth knowing here if you're rolling frequently per frame: AoE loot bursts, area explosions, anything that fires multiple rolls in a tight window. Setup is O(n), each roll is O(1), handles non-uniform weights cleanly. The catch is that rebuilding the table when weights change requires re-running the full setup step. For static loot tables (one Resource per enemy type as OP has it) that rebuild cost is basically zero. Just do it once on load and forget about it.

If O(1) feels like premature optimization for your scale, binary search on a prefix sum array is a solid middle ground. O(log n) per roll, easier to implement correctly than alias method, and the performance difference is negligible until your tables get very large.

One thing worth flagging for larger tables: the standard approach of normalizing weights and scanning linearly is O(n) per roll. For most projects that's completely fine. But if you're rolling drops on a crowd of enemies dying in the same frame, like an AOE clearing 30 units, those scans stack up in ways that show up in your profiler before you expect them to.

The alias method gives you O(1) per roll after an O(n) build pass. If the table is constructed once at load time and queried heavily during gameplay, it's a fairly clean swap. Probably overkill until it suddenly isn't. Worth having in the toolkit before you need it mid-optimization.

Moonjump
Forum Search Shader Sandbox
Sign In Register