ZOA Loot System
Author loot tables and choose the appropriate rolling model.
Loot turns authored tables into item results for rewards, containers, and drops. The package includes distinct rolling paths: weighted selection from a pool and independent chance checks on entries.
Choose the model that matches the intended reward. A weight changes an entry’s share of a selection pool; an independent drop chance lets multiple entries succeed in the same roll.
Tooling
Editor tools
Loot Workbench module
Tools > ZOA > Workbench > Open Workbench, then Loot
Three capabilities under the Items and Economy workflow: a Loot Tables browser and editor, a Containers browser and editor, and an Overview describing the drop pipeline. Both browsers create, validate, duplicate, and delete definition assets in place.
Loot Table Wizard
Tools > ZOA > Advanced > Define > Items > Loot > Loot Table Wizard
A five-step guided authoring flow: identity, weighted entries, guaranteed drops, roll count, and review. The menu entry routes into the Workbench's loot module rather than opening a floating window. Assets land under Assets/ZOA/Generated/LootTables.
Bundled loot tables
Tools > ZOA > Advanced > Generate > Loot > Bundled Tables > Ensure Assets
Emits three baseline tables, loot.common_cache, loot.uncommon_cache, and loot.boss_drop, under Assets/ZOA/Generated/Loot/Tables. Idempotent: re-running detects existing assets and skips them.
Loot scene installer utility
ZOALootSceneInstallerUtility.InstallOrUpdateInActiveScene creates or finds the ZOA Loot root and its ZOALootSceneInstaller, with options for the root name, parent, Dont Destroy On Load, and selecting the object afterwards. Scene scaffolding calls it directly; there is no menu item.
Declared ZOA dependencies (6)
Referenced by ZOA manifests (4)
How it works
Concepts
A table is a weighted pool plus a guaranteed floor
ILootTableDefinition carries four lists. Entries is the weighted pool: each LootEntry names an item definition id, a Weight, a quantity range, a LootRarity, and an optional condition list. GuaranteedDrops is a parallel list applied unconditionally. AffixPool is a list of AffixId values the roller may stamp onto drops. Tags classify the table for filtering.
LootRoller.Roll(tableId, rollCount, luckModifier) resolves the table from ILootTableRegistry, emits every guaranteed drop first, then performs rollCount independent weighted picks over Entries. Each pick is a single entry, so a table with three rolls and one guaranteed drop yields four items. Quantity is drawn per drop from the entry's min and max, inclusive.
The luck modifier is narrow. It multiplies the weight of entries whose rarity is Rare or above, and leaves Common and Uncommon weights alone. That means luck shifts the shape of the pool toward the good end rather than inflating everything uniformly, and a luck of 1.0 reproduces the authored distribution exactly.
Determinism is a constructor argument, not a global. new LootRoller(registry, seed) gives a repeatable sequence for tests and replays; the parameterless seed leaves the underlying System.Random unseeded.
Affixes are ids on the drop, not stats applied by the roller
When a table has a non-empty AffixPool, the roller attaches between zero and three affix ids to every drop it produces, sampling the pool with replacement. Those ids land on LootDropItem.AppliedAffixes and travel with the drop.
The roller stops there. AffixDefinition describes what an affix means, with an AffixType of Prefix, Suffix, or Implicit, a list of AffixStatMod records carrying an additive and a multiplicative term per stat id, a rarity, tags, and an IsStackable flag. Nothing in this package resolves an AffixId back to an AffixDefinition or applies those stat mods to an item, because the item and its stat model belong to inventory and armory, not to loot.
Treat the affix pool as a tagging mechanism that the roller populates and your item-instantiation code consumes. If you need stackability enforced or a weighted affix selection, that logic lives on your side of the AffixId boundary.
Containers own lifecycle; the roller owns randomness
LootContainerService is constructed with an ILootRoller and holds two dictionaries: registered LootContainerDefinition records and their live ContainerState. Opening a container is a single call, OpenContainer(containerId, entityId), which gates on availability, rolls the container's table with MaxItems as the roll count, stamps the state, and raises ContainerOpened.
Availability has three inputs. An exhausted container is never available. A container with RespawnTimeSeconds above zero becomes available again once that many seconds of wall-clock time have elapsed since the last open, evaluated lazily inside IsContainerAvailable rather than on a timer. A container that is IsOneTimeOnly, or whose respawn time is zero, is marked exhausted immediately after a successful open and raises ContainerExhausted.
That last rule is the most common authoring surprise: on LootContainerDefinitionAsset, a respawn time of zero does not mean instant respawn, it means never respawns. The service treats it as one-shot so a save and reload cannot roll the same cache twice.
State is snapshot-friendly. GetAllContainerStates returns clones rather than live references, and RestoreContainerState applies a captured state back onto a registered container id, refusing states whose container was never registered.
One interactable, two idioms, pluggable content
LootInteractable is the single world-placed loot surface, and it covers both AAA delivery shapes through a serialized LootInteractionMode. DropStyle grants everything into the collector's bag the instant the object is opened, with no panel: the looter-shooter idiom. BrowseStyle opens an inventory panel the player drags items out of, granting only what is taken: the RPG and survival idiom.
What is inside is a separate axis, authored polymorphically through a [SerializeReference] ILootContentSource. ContainerLootContentSource rolls a LootContainerDefinitionAsset through the registered ILootContainerService. InventoryLootContentSource binds a live InventoryComponent, which is the corpse-loot case: the bag is the truth, items leave it as the player takes them, and CanOpen goes false once it is empty so a drained body stops prompting.
The two axes combine cleanly. A container source in BrowseStyle has no live bag to bind, so it returns false from TryProvideBrowseInventory and the interactable materialises a transient inventory from the roll instead; what the player leaves behind is discarded on close. An inventory source in DropStyle snapshots the bag into a LootDropResult and empties it into the collector.
Bus discipline is uniform across every combination. Interact completion raises once per open. Item-collected and the LootDelivery.Granted fan-out fire per item actually delivered: all at once at open time for DropStyle, per drag-out for BrowseStyle. So the objective and analytics view of "loot reached a player" is identical whichever idiom a given crate uses.
Every grant funnels through one static hub
LootDelivery is a static event carrying a LootDelivery.Grant struct: item definition id, quantity, collector GameObject, collector entity id, and a diagnostic source label. Every granter in the product raises through it, and LootDelivery.Raise wraps each subscriber individually in a try/catch so one throwing listener cannot strand the others mid-grant.
LootGrantInventoryAdapter is the one canonical subscriber that mutates a bag. It resolves the player inventory through IInventoryService first and falls back to walking up from the collector for an InventoryComponent, then calls TryAddQuantity. It also subscribes to ItemPickup.Granted from the inventory package and forwards those through the same path, so ground pickups and crate loot take the same code route.
The hub is a static event rather than a service. Grants happen on paths that already exist (trigger enter, interact, corpse spawn) and the listener set is tiny, so the registry resolve would buy nothing. The adapter is ensured into every loot-bearing scene idempotently by ZOALootSceneInstaller, and it owns its subscription through OnEnable and OnDisable so re-entering play mode cannot double-subscribe.
One filter is worth knowing about. Grants whose source label begins with loot-browse-observed: are skipped by the adapter, because a browse-panel drag already moved the item directly; those events exist only so objectives and analytics still see the delivery.
Chance-model drops for enemies and waves
LootDropper is a MonoBehaviour that resolves LootDropTableDefinition assets, where every LootDropEntry carries its own DropChance in the range zero to one. Resolution walks the entries, multiplies each chance by the component's DropChanceMultiplier, clamps to one, and rolls independently, so a resolution can produce several entries or none at all.
It exposes three entry points. DropFromTable resolves an explicit table. DropWaveLoot treats the wave number as a one-indexed lookup into an ordered wave-table list, clamping to the last table past the end. DropEnemyLoot looks up a table by the killed agent's type id and returns empty when no table is mapped, which is the intended behaviour for enemy types that should not drop.
The dropper never instantiates anything. It publishes: a C# OnLootResolved event always, and a LootDroppedEvent on the messaging bus when one has been installed through SetEventBus. A spawner or demo bridge subscribes and materialises the physical container. Randomness is injectable through SetRandom, with UnityEngineLootRandom as the production default and SeededLootRandom for deterministic tests.
Setup
Workflow
- 01
Author a table
Create a LootTableDefinitionAsset from Assets > Create > Tools > ZOA > Loot > Loot Table, or step through the Loot Table Wizard. Give it a stable slug, add weighted entries keyed by item definition id, set the roll count, and add any guaranteed drops. Weights are relative to their siblings, so there is nothing to normalise.
- 02
Wrap the table in a container
Create a LootContainerDefinitionAsset, point it at the table, and set the lifecycle. MaxItems is passed straight through as the roll count. Respawn seconds above zero makes the container re-openable after that delay; zero, or ticking One Time Only, makes it a one-shot that exhausts on first open.
- 03
Install the services in the scene
Add a ZOALootSceneInstaller to a scene root, then drop your tables into Tables To Register and your containers into Containers To Register. On Awake it registers ILootTableRegistry, ILootRoller, and ILootContainerService against FoundryServiceRegistry and ensures the grant adapter. Tick Dont Destroy On Load when the loot services should survive scene changes.
- 04
Place the interactable
Add a LootInteractable to the world object, pick DropStyle or BrowseStyle, and assign a content source. For a crate that is a ContainerLootContentSource pointing at the container asset. For BrowseStyle with a container source, also assign the Browse Inventory field: the rolled items are pushed into that inventory on open and cleared on close.
- 05
Consume grants
The canonical inventory routing already happens through LootGrantInventoryAdapter. Subscribe to LootDelivery.Granted yourself only for the extras: toast UI, audio cues, analytics. Never write to the bag from a second subscriber, or a browse-panel take will be counted twice.
- 06
Add enemy and wave drops if you need them
For kill and wave rewards, author LootDropTableDefinition assets, put a LootDropper in the scene, map agent type ids to tables, and call DropEnemyLoot or DropWaveLoot from your death or wave-clear handler. Subscribe to OnLootResolved to spawn whatever physical pickup or crate the drop should become.
Reference
Key types
ILootTableDefinition
interface
The read side of an authored table: weighted entries, guaranteed drops, an affix pool, and classification tags.
- LootTableId Id { get; }
- string DisplayName { get; }
- IReadOnlyList<LootEntry> Entries { get; }
- IReadOnlyList<LootEntry> GuaranteedDrops { get; }
- IReadOnlyList<AffixId> AffixPool { get; }
- IReadOnlyList<string> Tags { get; }
ILootTableRegistry
interface
Id-keyed store of table definitions. The roller resolves through it, so a table can be authored, generated, or built in code without the roller caring which.
- void Register(ILootTableDefinition table)
- void Unregister(LootTableId tableId)
- bool TryGet(LootTableId tableId, out ILootTableDefinition table)
- IReadOnlyCollection<ILootTableDefinition> GetAll()
- event Action<ILootTableDefinition> TableRegistered
- event Action<LootTableId> TableUnregistered
ILootRoller
interface
The single rolling entry point for the pool model. One call takes a table id, a roll count, and a luck modifier and returns everything that dropped.
- LootDropResult Roll(LootTableId tableId, int rollCount = 1, float luckModifier = 1.0f)
LootRoller
class
Default roller. Emits guaranteed drops first, then performs rollCount weighted picks, scaling weights by the luck modifier only for entries at Rare or above. Takes an optional seed for determinism.
- LootRoller(ILootTableRegistry tableRegistry, int? seed = null)
ILootContainerService
interface
Owns container registration, availability, opening, and exhaustion. Also the save seam: states can be enumerated and restored wholesale.
- void RegisterContainer(LootContainerDefinition containerDef)
- LootDropResult OpenContainer(LootContainerId containerId, string entityId)
- bool IsContainerAvailable(LootContainerId containerId)
- ContainerState GetContainerState(LootContainerId containerId)
- IReadOnlyCollection<ContainerState> GetAllContainerStates()
- bool RestoreContainerState(ContainerState state)
- event Action<LootContainerId, string> ContainerOpened
- event Action<LootContainerId> ContainerExhausted
ContainerState
class
Per-container runtime state: availability, who opened it last, when, and whether it is exhausted. Handed out as clones so a caller cannot mutate service state by accident.
- LootContainerId ContainerId { get; set; }
- bool IsAvailable { get; set; }
- string LastOpenedBy { get; set; }
- double LastOpenedTime { get; set; }
- bool IsExhausted { get; set; }
LootEntry
class
One row of a table. Item id, selection weight, quantity range, rarity tier, and an optional condition id list.
- string ItemDefinitionId { get; set; }
- float Weight { get; set; }
- int MinQuantity { get; set; }
- int MaxQuantity { get; set; }
- LootRarity Rarity { get; set; }
- List<string> Conditions { get; set; }
LootDropResult
class
What a roll produced: a list of LootDropItem records, each with an item id, a quantity, a rarity, and the affix ids the roller stamped on it.
- List<LootDropItem> Items { get; set; }
LootTableBuilder
class
Fluent construction of a LootTableDefinition in code. The path bootstraps and tests use when there is no asset to author against.
- LootTableBuilder AddEntry(string itemId, float weight, int minQuantity, int maxQuantity, LootRarity rarity = LootRarity.Common)
- LootTableBuilder AddGuaranteedDrop(string itemId, int quantity, LootRarity rarity = LootRarity.Common)
- LootTableBuilder WithAffixPool(List<AffixId> affixIds)
- LootTableBuilder AddTag(string tag)
- LootTableDefinition Build()
AffixDefinition
class
Describes an affix: prefix, suffix, or implicit, with a list of AffixStatMod records, a rarity, tags, and a stackability flag. Data only; this package never applies it.
- AffixId Id { get; set; }
- AffixType AffixType { get; set; }
- List<AffixStatMod> StatModifications { get; set; }
- LootRarity Rarity { get; set; }
- bool IsStackable { get; set; }
LootRarity
enum
Common, Uncommon, Rare, Epic, Legendary, Mythic. The roller only branches on it for the luck modifier, which applies from Rare upward.
LootInteractionMode
enum
DropStyle grants everything silently on open with no panel. BrowseStyle opens an inventory panel and grants only what the player takes.
ILootContentSource
interface
The strategy that decides what a LootInteractable yields. Implementations are pure data adapters: they resolve contents and never publish to the objective or grant buses themselves.
- string InteractableId { get; }
- string DisplayName { get; }
- bool CanOpen { get; }
- LootDropResult Resolve(string collectorEntityId)
- bool TryProvideBrowseInventory(out InventoryComponent inventory)
- void OnBrowseClosed()
LootInteractable
component
The world-placed loot surface. Implements IZOACloseable so the interact key toggles a browse panel open and closed, and exposes replay entry points the network shims drive.
- LootInteractionMode Mode { get; set; }
- ILootContentSource Source { get; }
- bool IsOpen { get; }
- bool TryOpen(GameObject collector)
- bool TryClose()
- void SetContentSource(ILootContentSource source)
- static event Action<LootInteractable, GameObject, LootContainerId, LootDropResult> Granted
LootDelivery
class
The static fan-out every granter raises through. One event, one canonical inventory subscriber, and per-subscriber exception isolation on publish.
- static event Action<Grant> Granted
- static void Raise(in Grant grant)
- readonly struct Grant(string itemDefinitionId, int quantity, GameObject collector, string collectorEntityId, string sourceLabel)
LootGrantInventoryAdapter
component
The single grant-to-inventory router. Subscribes to LootDelivery.Granted and ItemPickup.Granted, resolves the player inventory, and calls TryAddQuantity. One per scene, ensured idempotently.
- static LootGrantInventoryAdapter EnsureInScene(Transform parent = null)
ZOALootSceneInstaller
component
Scene-root bootstrap at execution order -9300. Registers the registry, roller, and container service, registers the authored tables and containers listed on it, and ensures the grant adapter. Unregisters its own instances on destroy.
- void EnsureRegistered()
- ILootTableRegistry TableRegistry { get; }
- ILootRoller Roller { get; }
- ILootContainerService ContainerService { get; }
LootDropper
component
The chance-model counterpart to LootRoller. Resolves independent per-entry drop chances and publishes the result; it never spawns the physical container itself.
- IReadOnlyList<ResolvedDrop> DropFromTable(LootDropTableDefinition table, Vector3 position, string sourceIdOverride = null)
- IReadOnlyList<ResolvedDrop> DropWaveLoot(Vector3 position, int waveNumber)
- IReadOnlyList<ResolvedDrop> DropEnemyLoot(Vector3 position, string agentTypeId)
- void SetRandom(ILootRandom random)
- void SetEventBus(IFoundryEventBus bus)
- event Action<Vector3, string, IReadOnlyList<string>, IReadOnlyList<int>> OnLootResolved
ILootRandom
interface
Random source the dropper consumes. UnityEngineLootRandom is the production default; SeededLootRandom wraps System.Random for deterministic tests.
- float NextValue()
- int NextInt(int minInclusive, int maxInclusive)
LootDroppedEvent
struct
Readonly struct published on the messaging bus when a dropper resolves a non-empty set. Carries the world position, a source id, and parallel arrays of item ids and quantities.
- Vector3 Position
- string SourceId
- string[] ItemDefinitionIds
- int[] Quantities
ILootBrowseUiSurface
interface
The seam between loot and whatever hosts the browse panel. Loot declares the contract and resolves it from the registry; the equipment package's inventory screen registers itself as the implementation, so the dependency never runs the other way.
- void OpenBrowse(InventoryComponent inventory, string title)
- void CloseBrowse(InventoryComponent inventory)
- event Action<InventoryComponent> BrowseClosed
- bool IsBrowseOpen { get; }
BrowsableLootBuilder
class
Turns a concrete item set into a browsable loot pile: ensure a configured inventory on a host, fill it with real GameDatabase items, and wrap it in a BrowseStyle LootInteractable. The path corpse loot and demo piles share.
- static InventoryComponent EnsureLootInventory(GameObject host, InventoryDefinition definitionOverride = null)
- static int PopulateFromDatabase(InventoryComponent inventory, int rolls, int maxStackCap = 3)
- static LootInteractable AttachBrowsableInteractable(...)
- static LootInteractable BuildDatabaseLootPile(...)
RewardContainerProp
component
A lootable container registered as a world prop. Adds Locked gating, id-addressed actuation, optional self-fill on first open, and Depleted bookkeeping on top of a sibling LootInteractable.
- LootInteractable Loot { get; }
- void SetLocked(bool locked)
- override string Kind => "reward_container"
Reference
Authoring assets
LootTableDefinitionAsset
asset
The pool-model authoring asset, created from Assets > Create > Tools > ZOA > Loot > Loot Table. Holds a stable table id, a roll count, the weighted entry list, the guaranteed list, and an affix id pool. Tags come from DefinitionBase rather than a local field.
- string TableId
- int Rolls
- IReadOnlyList<SerializedLootEntry> Entries
- IReadOnlyList<SerializedLootEntry> GuaranteedDrops
- IReadOnlyList<string> AffixPool
- LootTableDefinition ToRuntimeDefinition()
LootContainerDefinitionAsset
asset
Pairs a container id with a table reference and a lifecycle policy: max items per open, respawn seconds, one-time-only, and an optional key id. Created from Assets > Create > Tools > ZOA > Loot > Loot Container.
- string ContainerId
- LootTableDefinitionAsset Table
- int MaxItems
- float RespawnTimeSeconds
- bool OneTimeOnly
- string RequiresKey
- LootContainerDefinition ToRuntimeDefinition()
LootDropTableDefinition
asset
The chance-model table, created from Assets > Create > ZOA > Gameplay > Loot Drop Table. Each entry carries its own DropChance in zero to one, so several entries can resolve in a single drop. Consumed by LootDropper, never by LootRoller.
- string TableId { get; set; }
- IReadOnlyList<LootDropEntry> Entries
- int EntryCount
- void AddEntry(LootDropEntry entry)
- void ClearEntries()
Usage
Examples
using ZOA.Loot.Core;
var registry = new LootTableRegistry();
var table = new LootTableBuilder(new LootTableId("boss_drop"), "Boss Drop")
.AddGuaranteedDrop("med.bandage", quantity: 2)
.AddEntry("ammo.medium", weight: 5f, minQuantity: 6, maxQuantity: 12)
.AddEntry("weapon.rifle.marksman", weight: 1f, minQuantity: 1, maxQuantity: 1, LootRarity.Epic)
.WithAffixPool(new List<AffixId> { new AffixId("scorched"), new AffixId("reinforced") })
.AddTag("boss")
.Build();
registry.Register(table);
// Seeded so the same sequence comes back every run.
var roller = new LootRoller(registry, seed: 1337);
// Three weighted picks plus the guaranteed bandages, with luck
// biasing only the Rare-and-above entries.
LootDropResult result = roller.Roll(new LootTableId("boss_drop"), rollCount: 3, luckModifier: 1.5f);
foreach (var item in result.Items)
Debug.Log(item.ItemDefinitionId + " x" + item.Quantity + " (" + item.Rarity + ")");using ZOA.Loot.Core;
using ZOA.Messaging;
var containers = FoundryServiceRegistry.Get<ILootContainerService>();
containers.ContainerExhausted += id =>
Debug.Log("Cache " + id.Value + " is spent; hide the prompt.");
var cacheId = new LootContainerId("starter_cache.alpha");
if (containers.IsContainerAvailable(cacheId))
{
// Null means the open was refused between the probe and the call.
LootDropResult drop = containers.OpenContainer(cacheId, entityId: "player.local");
if (drop != null)
{
foreach (var item in drop.Items)
Debug.Log("Looted " + item.ItemDefinitionId + " x" + item.Quantity);
}
}using System.Collections.Generic;
using ZOA.Loot.Core;
// Capture: states come back as clones, safe to hold and serialise.
IReadOnlyCollection<ContainerState> snapshot = containers.GetAllContainerStates();
// Restore, after the same containers have been registered again.
foreach (var state in snapshot)
{
// Returns false when the id is not registered, which is the
// signal that the save is ahead of the scene's authored content.
if (!containers.RestoreContainerState(state))
Debug.LogWarning("No registered container for " + state.ContainerId.Value);
}using UnityEngine;
using ZOA.Inventory.Unity.Components;
using ZOA.Loot.Unity.Interactables;
using ZOA.Loot.Unity.Interactables.LootContentSources;
// The AI package does this when an agent dies: the bag stays the
// source of truth, so what the player leaves behind is still there
// on the second interact.
InventoryComponent bag = BrowsableLootBuilder.EnsureLootInventory(corpse);
BrowsableLootBuilder.PopulateFromDatabase(bag, rolls: 4);
var interactable = corpse.AddComponent<LootInteractable>();
interactable.Mode = LootInteractionMode.BrowseStyle;
interactable.SetContentSource(new InventoryLootContentSource(
bag,
displayName: "Marine",
interactableId: "corpse:grunt_011"));using UnityEngine;
using ZOA.Loot.Unity.Drop;
using ZOA.Messaging;
public sealed class EnemyDeathLootBridge : MonoBehaviour
{
[SerializeField] private LootDropper dropper;
private void Awake()
{
dropper.SetEventBus(FoundryServiceRegistry.Get<IFoundryEventBus>());
dropper.OnLootResolved += SpawnPile;
}
public void OnAgentKilled(string agentTypeId, Vector3 position)
{
// Empty result when the agent type has no mapped table.
dropper.DropEnemyLoot(position, agentTypeId);
}
private void SpawnPile(
Vector3 position,
string sourceId,
IReadOnlyList<string> itemIds,
IReadOnlyList<int> quantities)
{
// Materialise the physical container here; the dropper
// deliberately does not instantiate anything itself.
}
}Integration
Behavior to account for
See also