Skip to content

The loot table that never mentions loot

Your drop-table system is also a spawn director, a bark picker and a weather roller. It has never once looked at what it was awarding.

Recipe

Systems required: Loot. Package: Inventory, Pickups & Crafting, or Complete. Shape: one file holding a component, its authoring struct and its result struct. Two private random sources. Public API only. It assumes: nothing. No LootService, no adapters, no scene wiring — the roller is static. Once you change it, it is your code. Copying and editing is the intended path — so a modified recipe is yours to maintain and debug. Support covers the framework's behaviour, not a copy of this class.

The part that is not obvious

LootRoller.Roll(LootTable, IRandomProvider) is public static, pure, and never resolves the id it awards. It is a weighted string picker that happens to live in a folder called Loot.

Two independent checks, because this is the claim everything else rests on:

The import list. Runtime/Systems/Loot/Core/LootRoller.cs imports Core.Abstractions.Random and Core.Diagnostics. That is all. No Inventory, no item database, nothing that could look an id up even if it wanted to.

The award line. When an entry is selected, this is what happens to it:

items.Add(new LootItemGrant(entry.itemGuid.Trim(), RollQuantity(entry, rng)));

The string is trimmed and carried straight through. Nothing validates it, nothing warns about it, and nothing anywhere in the roller can tell sword.iron from enemy.goblin from weather.storm.

LootEntry says so itself, in its own remarks:

Items are referenced by GUID string rather than by asset, which is what keeps the Loot assembly free of any dependency on Inventory. Resolving a GUID to a real item is the adapter's job.

There is no adapter here. So the id is just a payload, and what you get back is a weighted pick with seeding, group sizes, no-repeat rolls, nested sub-tables, a depth limit and a cycle guard — all of which somebody already built, tested and shipped, for loot.

The inspector will say Item Guid, and that is the price

A LootTable asset authored for encounters is a table whose rows are labelled Item Guid and Currency Id, because that is what the fields are called. A designer opening it will see loot vocabulary describing rooms.

That is a real cost and it is worth paying with your eyes open. This recipe softens it by giving the game its own authoring struct — EncounterOption has an id, not an itemGuid — and translating in one place. The translation is four lines and it is the entire composition.

Why LootRoller.Roll and not LootService.Roll

The obvious first move is the component, because components are what you reach for in a scene. It is the wrong door, and the reason is worth stating precisely because the wrong version of it is easy to repeat.

It is not that the service grants your encounter to an inventory. It does not. Roll rolls; RollAndGrant is the one that hands awards to adapters. Rolling through the service will not put enemy.goblin in anybody's bag.

It is that the service puts the roll into your loot pipeline, which other parts of your game are listening to as loot:

What LootService.Roll does What that means for a non-loot roll
Runs every ILootModifier up the owner chain A pity counter waiting on a rare drop counts your weather roll and pays out early
Raises Rolled A drop feed announces that the player found a storm.heavy
Reuses shared item and currency buffers across rolls An encounter roll fired from inside a loot modifier re-enters a system that had to be taught to survive exactly that

None of that is a fault in the service — it is what a service for loot should do. It is simply the wrong door for a roll that is not loot.

The static roller is the seam because it has no pipeline to pollute. A table, a random source, a result. No owner, no modifiers, no events, no adapters, no component, and nothing in your game that could mistake the outcome for a drop.

Even with no owner, Rolled still fires

Passing owner: null skips the modifiers — ApplyModifiers returns immediately without one — but the event is raised regardless. So "I'll just not pass an owner" closes one of the three rows above and leaves the other two.

The random seam is one method, and it is yours

public interface IRandomProvider
{
    float Value01();
}

That is the whole interface. The framework's seeded implementation is internal, deliberately — a game that wants reproducibility wants it on its own terms, tied to its own run id and its own save file — so supplying one is part of using the roller rather than a way around it.

Two are included below, both private to the recipe. UnitySource wraps Random.value. SeededSource is a 32-bit xorshift, chosen over System.Random because it is reproducible across platforms and .NET versions, which is what a replay or a shared-seed daily run actually needs. It is nowhere near good enough for anything security-shaped, and it does not need to be.

Five things that will bite

A blank id must become Nothing, not an empty item

This is the trap worth the class. An Item entry whose itemGuid is blank still consumes its pick and awards nothing — which, from outside, is indistinguishable from bad luck. The roller warns about it for exactly that reason:

[Loot] '<table>' has an Item entry with no item GUID. It consumed a pick and awarded nothing. Set the GUID, or use a Nothing entry if an empty outcome is what you meant.

LootEntryKind.Nothing is the supported way to say "sometimes nothing happens" — a quiet room, an empty corridor. A blank id is translated into one here, so authoring a deliberately empty option is one field rather than a mistake that looks like variance.

A runtime table is an object with a lifetime

LootTable.Create returns a ScriptableObject marked HideFlags.DontSave. Nothing destroys it for you, and it is not collected while anything references it.

One table per encounter is one leaked ScriptableObject per encounter

Build a table inside the method that rolls it and you have written a leak that nothing reports: no error, no warning, no visible symptom until a long session's memory profile is inspected by someone who already suspects it.

This class builds at most one, rebuilds only when asked, and destroys the previous one first. Rebuild() is public precisely so changing the options at runtime has a correct route.

Zero total weight produces nothing, quietly

An empty table, or one where every option is weighted zero, has nothing eligible; the roller stops and returns an empty result. That is identical in appearance to a table that rolled Nothing.

LootTable.TotalWeight exists because this is a common authoring mistake rather than an exotic one — its own remarks say so. CanPick here is that property, surfaced where a caller will actually think to look.

Nesting is bounded, and the bound is silent

A Table option delegates to another table, which is how a region table reaches a "forest ambush" table. Two guards apply, and both stop at a warning rather than an exception:

  • MaxNestingDepth is 8. Deeper entries award nothing.
  • A table that reaches itself is refused. The check is path-based, so the same table appearing in two different branches is fine — only a genuine cycle is stopped.

Both produce a DevDiagnostics.Warn and an empty result for that branch, which is the right shape for a runtime guard and the wrong shape for something you want to find before shipping. Catching a loot tree that quietly awards nothing is the editor-time answer, and it works on these tables exactly as it does on drop tables.

A destroyed nested table changes what the option means

The nested field's tooltip says the id and counts are ignored when a table is assigned. Destroy that table and, without a guard, they stop being ignored.

Unity's fake-null is why. A destroyed Object reference is not null to the CLR but is falsy to Unity's ==, so if (option.nested) goes false and an unguarded translation drops straight through to the id branch — quietly turning a delegating option into one that awards whatever placeholder string was sitting in a field the author had been told did not matter.

ToEntry separates the two cases with ReferenceEquals. A never-filled field is the ordinary case and says nothing; a filled field whose asset is gone degrades to Nothing and logs a warning naming the id it declined to award. Picking nothing is the smaller wrong answer, and either way you get told — which is the same bargain the roller makes for its own bad references, and the reason this recipe should not quietly make a different one.

What you get for free that you would otherwise write

Worth listing, because the argument for this recipe is not cleverness — it is that all of this already exists and is already tested:

Weighted selection Relative weights, zero-weight rows skipped, boundary-exact picking
Group sizes countMin/countMax per option, inclusive, never below one
Several picks per roll picksMin/picksMax, inclusive
Draw without replacement allowRepeats: false, exhaustion handled
Sub-tables With a depth limit and a cycle guard
"Sometimes nothing" A first-class entry kind
Seeded reproducibility Through a one-method interface you control
Testable without a scene Because the roller is static and pure

Drop it in

EncounterTable.cs
using System;
using System.Collections.Generic;

using RevGaming.RevFramework.Core.Abstractions.Random;
using RevGaming.RevFramework.Loot.Core;

using UnityEngine;

namespace RevGaming.RevFramework.Cookbook.TableOfAnything
{
    /// <summary>
    /// One thing the table can pick, in your game's vocabulary rather than the inspector's.
    /// </summary>
    /// <remarks>
    /// This exists because the field a <see cref="LootEntry"/> carries the payload in is called
    /// <c>itemGuid</c>, and a designer authoring encounters should not have to know that. Everything
    /// here is the game's own authoring surface; it is translated into loot entries in one place, and
    /// that translation is the whole of the composition.
    /// </remarks>
    [Serializable]
    public struct EncounterOption
    {
        [Tooltip("What this option means to your game. Any string at all — an encounter name, a " +
                 "prefab key, an addressable address, a bark id. Leave it blank to mean 'nothing " +
                 "happens', which is a supported outcome rather than a mistake.")]
        public string id;

        [Tooltip("Relative likelihood. Values are relative to the other options, so 1/1/2 behaves " +
                 "exactly like 50/50/100. Zero is never picked.")]
        [Min(0f)] public float weight;

        [Tooltip("Smallest group size when this option is picked. Zero and one both mean one.")]
        [Min(0)] public int countMin;

        [Tooltip("Largest group size. Below the minimum is treated as equal to it.")]
        [Min(0)] public int countMax;

        [Tooltip("Optional: delegate to an authored table instead of awarding this id. The id and " +
                 "counts are ignored when this is set.")]
        public LootTable nested;
    }

    /// <summary>
    /// One thing that was picked: your id, and how many of it.
    /// </summary>
    public readonly struct EncounterPick
    {
        /// <summary>The id you authored, back again.</summary>
        public readonly string Id;

        /// <summary>How many. Always at least one.</summary>
        public readonly int Count;

        /// <summary>Creates a pick.</summary>
        public EncounterPick(string id, int count)
        {
            Id = id;
            Count = count;
        }

        /// <inheritdoc />
        public override string ToString() => $"{Id} x{Count}";
    }

    /// <summary>
    /// A weighted picker for anything you can name with a string, built on the loot roller — which
    /// turns out to be a general weighted selector that has never once looked at what it is awarding.
    /// </summary>
    /// <remarks>
    /// <para><b>Recipe.</b> One class, dropped into a project that already exists. Systems required:
    /// <b>Loot</b> only. Public API only.</para>
    ///
    /// <para><b>The finding.</b> <c>LootRoller.Roll(LootTable, IRandomProvider)</c> is
    /// <c>public static</c>, pure, and <b>never resolves the id it awards</b>. Two independent checks
    /// say so: the file imports only <c>Core.Abstractions.Random</c> and <c>Core.Diagnostics</c> — no
    /// Inventory, no item database, nothing that could look an id up — and the award line reads
    /// <c>items.Add(new LootItemGrant(entry.itemGuid.Trim(), …))</c>, carrying the string straight
    /// through. The id is a payload the roller transports and hands back. Whether it names an item is
    /// entirely your adapter's business, and there is no adapter here.</para>
    ///
    /// <para>So a system built for drop tables is also a spawn director, a bark picker, a room
    /// chooser, a weather selector and a random-event roller — seeded, nesting-guarded,
    /// cycle-guarded, and testable without a scene, because all of that was already built for
    /// loot.</para>
    ///
    /// <para><b>Why <c>LootRoller.Roll</c> and not <c>LootService.Roll</c>.</b> The service does not
    /// grant anything either — <c>RollAndGrant</c> is the one that hands awards to adapters. What the
    /// service does do is put the roll <i>into your loot pipeline</i>: it runs every
    /// <c>ILootModifier</c> in the owner chain, and it raises <c>Rolled</c>. Those modifiers are your
    /// game's code, written about loot: a pity counter that has been waiting for a rare drop will
    /// happily count your weather roll, and a drop feed subscribed to <c>Rolled</c> will announce that
    /// the player found a <c>storm.heavy</c>. The service also reuses shared buffers across rolls, so
    /// an encounter roll fired from inside a loot modifier is re-entering a system that had to be
    /// taught to survive exactly that.</para>
    ///
    /// <para>None of which is a fault in the service — it is what a service for loot should do. It is
    /// simply the wrong door for a roll that is not loot. <b>The static roller is the seam because it
    /// has no pipeline to pollute:</b> a table, a random source, a result, and no observers.</para>
    ///
    /// <para><b>The random seam is one method, and it is yours.</b> <c>IRandomProvider</c> declares
    /// <c>float Value01()</c> and nothing else. Determinism here spans a <i>run</i>: reseed with the
    /// same value and the same sequence follows, but the position within that sequence is not saved,
    /// so a save-and-resume continues from wherever the new session's source starts rather than from
    /// where the old one stopped. Replaying a layout means reseeding and re-rolling from the top.
    /// The framework's seeded implementation is
    /// <c>internal</c> — deliberately, since a game that wants reproducibility wants it on its own
    /// terms — so supplying one is part of using the roller rather than a workaround. Two are provided
    /// below, both private: Unity's generator, and a small deterministic one for replays and tests.</para>
    ///
    /// <para><b>A blank id must become a <c>Nothing</c> entry, not an empty item.</b> This is the trap
    /// worth the class. An <c>Item</c> entry whose <c>itemGuid</c> is blank still <i>consumes its
    /// pick</i> and awards nothing, which is indistinguishable from bad luck by watching the output —
    /// the roller warns about it for exactly that reason. <c>LootEntryKind.Nothing</c> is the
    /// supported way to say "sometimes nothing happens", and it is what a blank id is translated into
    /// here.</para>
    ///
    /// <para><b>The table is an object with a lifetime.</b> <see cref="LootTable.Create"/> returns a
    /// <c>ScriptableObject</c> marked <c>HideFlags.DontSave</c>. It is not garbage collected while
    /// anything references it and nothing destroys it for you, so a component that builds one per
    /// encounter and walks away leaks one <c>ScriptableObject</c> per encounter, silently, forever.
    /// This class builds at most one, rebuilds only when asked, and destroys the old one first.</para>
    ///
    /// <para><b>Zero total weight produces nothing, quietly.</b> An empty table, or one where every
    /// option is weighted zero, has nothing eligible and the roller stops. That is a common authoring
    /// mistake rather than an exotic one, which is why <c>LootTable.TotalWeight</c> exists — and why
    /// <see cref="CanPick"/> is offered here rather than leaving a caller to diagnose it from an empty
    /// list.</para>
    ///
    /// <para><b>What this is not.</b> It is not a spawner. It picks a string and hands it back;
    /// turning <c>enemy.goblin</c> into a GameObject is your game's job, and it is the one place the
    /// framework genuinely has nothing to offer, because the id means whatever you decided it
    /// means.</para>
    /// </remarks>
    [DisallowMultipleComponent]
    public sealed class EncounterTable : MonoBehaviour
    {
        [Tooltip("The options this table picks between. Weights are relative.")]
        [SerializeField] private EncounterOption[] options = Array.Empty<EncounterOption>();

        [Tooltip("Fewest picks one roll makes.")]
        [SerializeField, Min(0)] private int picksMin = 1;

        [Tooltip("Most picks one roll makes. Below the minimum is treated as equal to it.")]
        [SerializeField, Min(0)] private int picksMax = 1;

        [Tooltip("When off, one roll will not pick the same option twice — useful for 'three " +
                 "different rooms' and wrong for 'three enemies'.")]
        [SerializeField] private bool allowRepeats = true;

        [Tooltip("Use a fixed seed so the same table produces the same encounters every run. Off " +
                 "uses Unity's generator.")]
        [SerializeField] private bool deterministic;

        [Tooltip("Starting seed for the deterministic source. Editing this field at runtime does " +
                 "nothing on its own, and Reseed() takes the new seed as its argument rather than " +
                 "reading this -- call Reseed(value) to change the sequence mid-run.")]
        [SerializeField] private int seed = 1;

        private LootTable _table;
        private IRandomProvider _rng;

        private readonly List<LootItemGrant> _items = new();
        private readonly List<LootCurrencyGrant> _currency = new();
        private bool _warnedCurrencyDropped;

        /// <summary>
        /// Whether a roll can produce anything at all: false when every option is weighted zero, or
        /// there are no options.
        /// </summary>
        /// <remarks>
        /// Worth asking before blaming the dice. A zero-weight table is silent — it produces an empty
        /// result identical to one where every roll happened to land on a blank option.
        /// </remarks>
        public bool CanPick => Table.TotalWeight > 0f;

        /// <summary>The table this picker is driving. Built on first use from <see cref="options"/>.</summary>
        private LootTable Table
        {
            get
            {
                if (!_table)
                    Rebuild();

                return _table;
            }
        }

        /// <summary>
        /// Rolls once and returns what came up, in the order it was rolled.
        /// </summary>
        /// <remarks>
        /// <para>Never null. Empty means the table produced nothing, which is a legitimate outcome —
        /// a <c>Nothing</c> option was picked, or nothing was eligible. <see cref="CanPick"/>
        /// separates the two.</para>
        ///
        /// <para>Repeats appear as separate picks rather than being merged, matching what the roller
        /// does: "two goblins arrived" and "a pair of goblins arrived" are different sentences, and
        /// merging here would throw the distinction away before your game could use it.</para>
        /// </remarks>
        public IReadOnlyList<EncounterPick> Pick()
        {
            _items.Clear();
            _currency.Clear();

            LootRoller.Roll(Table, Rng, _items, _currency);

            // Nothing this class authors can produce a currency grant -- ToEntry only ever writes
            // Item, Table or Nothing. One can still arrive, from a Currency entry inside a nested
            // AUTHORED table, and there is nowhere for it to go: EncounterPick carries an id and a
            // count, and inventing a currency channel here would be the recipe growing a second
            // meaning. So it is dropped, which is the same silent-award-of-nothing this page warns
            // about for blank ids -- and therefore said out loud rather than swallowed.
            if (_currency.Count > 0 && !_warnedCurrencyDropped)
            {
                _warnedCurrencyDropped = true;
                Debug.LogWarning(
                    $"[{nameof(EncounterTable)}] '{name}' rolled {_currency.Count} currency grant(s) from a " +
                    "nested authored table and dropped them. An encounter pick is an id and a count; " +
                    "there is no currency channel here. Award currency from your own code using the " +
                    "picked id, or keep currency out of tables this component rolls.", this);
            }

            if (_items.Count == 0)
                return Array.Empty<EncounterPick>();

            var picks = new EncounterPick[_items.Count];
            for (int i = 0; i < _items.Count; i++)
                picks[i] = new EncounterPick(_items[i].itemGuid, _items[i].quantity);

            return picks;
        }

        /// <summary>
        /// Rebuilds the table from the current options, destroying the previous one.
        /// </summary>
        /// <remarks>
        /// Call this after changing <see cref="options"/> at runtime. The destroy is not optional
        /// tidiness: <see cref="LootTable.Create"/> returns a <c>HideFlags.DontSave</c>
        /// <c>ScriptableObject</c>, and one built per rebuild and abandoned stays alive for the rest
        /// of the session.
        /// </remarks>
        public void Rebuild()
        {
            DestroyTable();

            var entries = new LootEntry[options.Length];
            for (int i = 0; i < options.Length; i++)
                entries[i] = ToEntry(options[i]);

            _table = LootTable.Create(
                LootTableMode.Weighted,
                picksMin,
                picksMax,
                allowRepeats,
                entries);

            _table.name = $"{name} encounters";
        }

        /// <summary>Restarts the deterministic sequence from <paramref name="newSeed"/>.</summary>
        /// <remarks>
        /// Only meaningful while <c>deterministic</c> is on. Two pickers reseeded to the same value
        /// produce identical sequences, which is what makes an encounter layout reproducible from a
        /// run id.
        /// </remarks>
        public void Reseed(int newSeed)
        {
            seed = newSeed;
            _rng = null;
        }

        private IRandomProvider Rng =>
            _rng ??= deterministic ? new SeededSource(seed) : (IRandomProvider)new UnitySource();

        /// <summary>
        /// The whole composition: your option becomes a loot entry, and the id rides in the field the
        /// inspector calls "Item Guid".
        /// </summary>
        /// <remarks>
        /// <para>The order of these three cases is the interesting part. A nested table wins, because
        /// delegating is unambiguous. A blank id becomes <c>Nothing</c> — never an <c>Item</c> with an
        /// empty guid, which would consume the pick, award nothing and warn. Everything else is an
        /// item entry carrying a string the roller will never try to understand.</para>
        ///
        /// <para><b>The destroyed-table case is separated out rather than left to fall through.</b> A
        /// <c>LootTable</c> reference that was assigned and has since been destroyed is Unity's
        /// fake-null: <c>if (option.nested)</c> is false, so an unguarded version drops to the id
        /// branch and starts awarding the id the field's own tooltip says is ignored. The option was
        /// authored as a delegation, so it degrades to <c>Nothing</c> — and says so, because the
        /// roller this recipe wraps warns loudly about its own bad references and mirroring that is
        /// the point of the recipe.</para>
        /// </remarks>
        private static LootEntry ToEntry(in EncounterOption option)
        {
            if (option.nested)
            {
                return new LootEntry
                {
                    kind = LootEntryKind.Table,
                    weight = option.weight,
                    nested = option.nested,
                };
            }

            // Reference-null means the field was never filled in, which is the ordinary case and says
            // nothing. Non-null-but-falsy means it was filled in and the asset is gone.
            if (!ReferenceEquals(option.nested, null))
            {
                Debug.LogWarning(
                    $"[{nameof(EncounterTable)}] an option delegating to a nested table has lost it — the " +
                    "table was assigned and has since been destroyed. This option now picks nothing. It is " +
                    $"NOT falling back to its id ('{option.id}'), because the id is ignored on a delegating " +
                    "option and awarding it here would be a silent change of meaning.");

                return new LootEntry
                {
                    kind = LootEntryKind.Nothing,
                    weight = option.weight,
                };
            }

            if (string.IsNullOrWhiteSpace(option.id))
            {
                return new LootEntry
                {
                    kind = LootEntryKind.Nothing,
                    weight = option.weight,
                };
            }

            return new LootEntry
            {
                kind = LootEntryKind.Item,
                weight = option.weight,
                itemGuid = option.id,
                quantityMin = option.countMin,
                quantityMax = option.countMax,
            };
        }

        private void OnDestroy()
        {
            DestroyTable();
        }

        private void DestroyTable()
        {
            if (!_table)
                return;

            // Application.isPlaying is the discriminator Unity requires: Destroy is deferred and
            // throws in edit mode, DestroyImmediate is forbidden during play from most callbacks.
            if (Application.isPlaying)
                Destroy(_table);
            else
                DestroyImmediate(_table);

            _table = null;
        }

        /// <summary>Unity's generator, wrapped to the one method the roller asks for.</summary>
        private sealed class UnitySource : IRandomProvider
        {
            public float Value01() => UnityEngine.Random.value;
        }

        /// <summary>
        /// A small deterministic source, so the same seed lays out the same encounters.
        /// </summary>
        /// <remarks>
        /// Deliberately not <c>System.Random</c>: this is reproducible across platforms and .NET
        /// versions, which is what a replay or a shared-seed daily run needs. It is a 32-bit xorshift,
        /// which is ample for picking rooms and nowhere near good enough for anything security-shaped.
        /// </remarks>
        private sealed class SeededSource : IRandomProvider
        {
            private uint _state;

            public SeededSource(int seed) => _state = seed == 0 ? 0x9E3779B9u : unchecked((uint)seed);

            public float Value01()
            {
                _state ^= _state << 13;
                _state ^= _state >> 17;
                _state ^= _state << 5;

                // 24 bits into a float keeps every value exactly representable, so the sequence is
                // identical everywhere rather than merely nearly so.
                return (_state >> 8) / (float)(1 << 24);
            }
        }
    }
}

Wiring it up

  1. Put EncounterTable on whatever decides encounters — a spawn point, a room, a director object.
  2. Fill in the options. id is yours: a prefab key, an addressable address, an enum name, anything you can look up later.
  3. Ask it when you need one:
if (!table.CanPick)
{
    Debug.LogWarning("Encounter table has no eligible options — every weight is zero.");
    return;
}

foreach (EncounterPick pick in table.Pick())
    SpawnMyOwnThing(pick.Id, pick.Count);   // this half is entirely yours
  1. For a reproducible layout, turn on deterministic and call Reseed(runId) when a run begins.

What it deliberately does not do

It does not spawn anything. It picks a string and hands it back. Turning enemy.goblin into a GameObject is the one place the framework genuinely has nothing to offer, because the id means whatever you decided it means — a prefab, an addressable, a key into your own dictionary.

It does not use the currency channel. LootEntryKind.Currency is a second string-plus-number channel sitting right there, and a game that wants two kinds of outcome from one table can use it. This recipe reads only the item channel because one channel is enough to make the point, and a result type with two lists in it would be teaching plumbing.

It does not validate your ids. Nothing checks that enemy.goblin resolves to anything, because nothing here knows what it should resolve to. If a typo should be caught before shipping, that is an editor-time check over your own id list, and it is yours to write.

It does not persist. The picker holds no state between rolls except the seeded sequence. An encounter that must stay decided — this room already rolled its ambush — is a fact about the world, and one store, many facts is where a fact like that goes.

It ships no ids of its own. No encounter. prefix, no naming scheme, no schema. A table that came with an opinion about what an encounter is would be a table with an opinion about your game.