t1k:unity:dots-inventory:grid
| Field | Value |
|---|---|
| Module | dots-inventory |
| Version | 2.1.7 |
| Effort | medium |
| Tools | — |
Keywords: adjacency, AdjacencyMathUtility, auto-battler board, crafting, diminishing returns, gacha, grid, GridAdjacencyEvaluator, inventory, loot, match-3 combo, passive, polyomino, set bonus, synergy, tower defense adjacency
How to invoke
Section titled “How to invoke”/t1k:unity:dots-inventory:gridDOTS Inventory Grid — Grid, Crafting, Passives, Sets, Loot
Section titled “DOTS Inventory Grid — Grid, Crafting, Passives, Sets, Loot”Package:
com.the1studio.dots-inventory— namespaceDOTSInventory.*Related skills:dots-ecs-core(API) ·dots-combat-rpg(stats pipeline) ·dots-core-ecs-core(ECB)
When This Skill Triggers
Section titled “When This Skill Triggers”- Implementing polyomino / 2D inventory grid (slot cells, occupancy, placement)
- Crafting recipes with ingredient matching and craft progress tracking
- Passive item effects that aggregate into derived stat buffers
- Set bonuses triggered when N matching items are equipped
- Loot table drops and gacha pulls with rarity tiers
- Computing synergy / adjacency bonuses with diminishing returns (inventory, board, tower, puzzle)
- Auto-battler unit placement bonuses based on neighboring allies
- Tower defense adjacency fire-rate boosts between adjacent towers of the same type
Quick Reference
Section titled “Quick Reference”| Topic | Reference File |
|---|---|
| GridAdjacencyEvaluator — diminishing-returns synergy formula (generic, Burst-safe) | references/grid-adjacency-evaluator-guide.md |
Public Components
Section titled “Public Components”| Component | Type | Purpose |
|---|---|---|
InventoryGridCell | IBufferElementData (cap 0) | Entity ItemEntity, bool IsActive; 1D buffer represents 2D grid |
InventoryGridConfig | IComponentData (singleton) | int Width, int Height — defines grid dimensions |
CraftingRecipe | IBufferElementData | int RecipeId, int OutputItem, int RequiredCount |
CraftingIngredient | IBufferElementData | int ItemTypeId, int Quantity |
CraftingState | IComponentData | int ActiveRecipeId, float Progress |
ItemPassiveEffect | IBufferElementData | int StatId, float Value, PassiveOp Op |
ItemSetBonus | IBufferElementData | int SetId, int RequiredCount, int StatId, float Bonus |
EquippedItem | IBufferElementData | Entity ItemEntity, int SlotIndex |
LootTable | IBufferElementData | int ItemTypeId, float Weight, int MinCount, int MaxCount |
GachaEntry | IBufferElementData | int ItemTypeId, float Probability, RarityTier Rarity |
LootPityTracker | IBufferElementData (cap 4) | int ItemId, int ConsecutiveDrops; per-player anti-streak state |
Public Systems
Section titled “Public Systems”| System | Group | Role |
|---|---|---|
InventoryGridPlacementSystem | InventorySystemGroup | Validates grid bounds and marks cells occupied |
CraftingSystem | InventorySystemGroup | Matches ingredients against recipes, advances CraftingState.Progress |
PassiveEffectRollupSystem | InventorySystemGroup | Aggregates ItemPassiveEffect entries → writes derived stat component |
SetBonusEvaluationSystem | InventorySystemGroup | Counts EquippedItem per SetId; applies bonus when threshold met |
LootDropSystem | InventorySystemGroup | Weighted-random drop from LootTable buffer on trigger event |
GachaPullSystem | InventorySystemGroup | Probabilistic pull with pity counter; spawns item entity via ECB |
Common Patterns
Section titled “Common Patterns”Grid index — 1D buffer as 2D
Section titled “Grid index — 1D buffer as 2D”// Cell address: row-major orderint cellIndex = gridY * config.Width + gridX;var cells = SystemAPI.GetBuffer<InventoryGridCell>(gridEntity);ref var cell = ref cells.ElementAt(cellIndex);bool occupied = cell.ItemEntity != Entity.Null;Passive effect aggregation
Section titled “Passive effect aggregation”// PassiveEffectRollupSystem — accumulate all equipped items' effects:float totalAttack = 0f;foreach (var item in equippedItems){ var passives = SystemAPI.GetBuffer<ItemPassiveEffect>(item.ItemEntity); foreach (var p in passives) if (p.StatId == STAT_ATTACK) totalAttack += p.Value;}// Write to DerivedCombatStats via ComponentLookup RWSet bonus — all-or-nothing
Section titled “Set bonus — all-or-nothing”// SetBonusEvaluationSystem — count per SetId using NativeHashMap:var setCounts = new NativeHashMap<int, int>(8, Allocator.Temp);foreach (var item in equippedItems) setCounts.TryGetValue(item.SetId, out int c); setCounts[item.SetId] = c + 1;// Apply bonus only when count >= required thresholdWeighted loot drop
Section titled “Weighted loot drop”// LootDropSystem — weighted reservoir:float totalWeight = 0f;foreach (var entry in lootTable) totalWeight += entry.Weight;float roll = rng.NextFloat(0f, totalWeight);foreach (var entry in lootTable){ roll -= entry.Weight; if (roll <= 0f) { /* spawn entry.ItemTypeId */ break; }}Per-item anti-streak pity — DOTSInventory.Loot.ItemPityRoller
Section titled “Per-item anti-streak pity — DOTSInventory.Loot.ItemPityRoller”Stateless Burst utility paired with the per-player LootPityTracker buffer.
Reroll up to maxRerolls times if the just-rolled item id has streaked to
consecutiveThreshold+ consecutive drops. State persists across frames in
the buffer; the utility updates streak counters in place.
// In OnCreate: cache the lookupprivate BufferLookup<LootPityTracker> m_PityLookup;this.m_PityLookup = state.GetBufferLookup<LootPityTracker>(false);
// In OnUpdate after rolling an item id:if (this.m_PityLookup.HasBuffer(playerEntity)){ var pityBuf = this.m_PityLookup[playerEntity]; if (ItemPityRoller.ShouldReroll(in pityBuf, rolledItemId, consecutiveThreshold: 2)) { for (int attempt = 0; attempt < 3; attempt++) { int rerolled = RollItem(in lootBuffer, ref rng); if (rerolled != rolledItemId) { rolledItemId = rerolled; break; } } } ItemPityRoller.UpdatePityTracker(ref pityBuf, rolledItemId);}Bake the buffer onto the looting entity (player, party, dungeon run) in its
authoring baker: this.AddBuffer<LootPityTracker>(entity); — an empty buffer
means “no streaks yet”. See BackpackCrawlerPlayerAuthoring.Bake and
ChaosForgeDemo.Authoring.PlayerAuthoring.ForgeBaker for reference.
Companion — rarity-pity: when pity should force a min rarity tier after N sub-tier draws (not per-item streaks), use
DOTSInventory.Loot.RarityRollerwith aBlobAssetReference<RarityWeightsBlob>instead. The two patterns are independent and can be layered.
Weighted-sampling SSOT — the 3 loot rollers delegate to DOTSCore.WeightedSampler
Section titled “Weighted-sampling SSOT — the 3 loot rollers delegate to DOTSCore.WeightedSampler”All three DOTSInventory.Loot rollers now share one weighted-sampling primitive
(DOTSCore.WeightedSampler.Sample(in NativeArray<float> weights, ref Random rng))
instead of each carrying its own cumulative-sum loop. This is the r1-D F2 dedup
consolidation — there is now a single source of truth for “pick an index from a
weight vector”.
| Roller | Storage shape | Pity / FTUE | What it returns |
|---|---|---|---|
RarityRoller | blob (BlobAssetReference<RarityWeightsBlob>, fixed config) | min-tier hard pity (drawsSinceMinTier / pityThreshold / minTierIndex) | byte tier index (InvalidTier=255 on bad input) |
WeightedRarityRoll | buffer (DynamicBuffer<LootRarityWeights>, per-stage mutable) | none (add at caller) | ItemRarity (DefaultRarity=Common on empty/zero) |
GachaWeightedRoll | GachaConfig singleton (consumer-supplied weights + pity threshold) | FTUE-first-pull guarantee + config-driven consecutive-common pity | GachaRarity |
Each roller builds a temp NativeArray<float> of its weights, calls
WeightedSampler.Sample, disposes the temp, then maps the returned index back to
its own return type. Pity/FTUE logic stays in the roller as a thin adapter layer
around the shared sampler. When adding a fourth roller, follow the same pattern —
do NOT re-implement cumulative-sum sampling.
Gacha config + item resolution moved out of the library (Wave-4).
GachaWeightedRoll.Roll(ref rng, isFtueFirstPull, commonConsecutive, in GachaConfig config)reads weights/pity from a consumer-suppliedGachaConfig(back-compat overload uses library defaults viaGachaConstants). The oldResolveItemId(...)is[Obsolete]— the library does NOT know item catalogs. Register anIGachaItemResolverviaGachaItemResolverBridge.Register(...)and callGachaItemResolverBridge.Instance.ResolveItemId(...)instead.
Cross-Package Interactions
Section titled “Cross-Package Interactions”| Direction | Package | Detail |
|---|---|---|
| Depends on | DOTSCore base | LocalTransform, ECB, SystemAPI |
| Depends on | DOTSCore.Stats | DerivedCombatStats that passive rollup writes into |
| Used by | DOTSCombat | Combat reads inventory passives for derived stats |
| Future split | dots-economy | CurrencyWallet moving to dots-economy in Wave 2 |
Demo Precedents
Section titled “Demo Precedents”- BackpackCrawler — polyomino grid, drag-drop, set bonuses, loot drops per room,
ItemPityRollerreference impl (CrawlerLootTable.GetRandomLootWithPity) - InventoryDemo — grid showcase with passive rollup and crafting UI
- AutoBattlerDemo — draft shop uses
GachaEntrytiers for unit reveal - ChaosForgeDemo — forge-roll loop (
ForgeRollSystem) consumesItemPityRollerfor anti-streak protection (W2.6)
Gotchas
Section titled “Gotchas”- Grid cells are a 1D buffer representing 2D layout — never use a 2D NativeArray per entity; the flat buffer with row-major indexing (
Y * Width + X) is chunk-friendly and Burst-safe. Accessing out-of-bounds silently returns the wrong cell — always bounds-check before ElementAt. - Multi-cell items occupy consecutive cells in row-major order — placement must reserve a contiguous run of cells for wide items; overlap detection is the consumer’s responsibility.
- Set bonuses are all-or-nothing — partial set membership never grants partial bonus.
SetBonusEvaluationSystemclears and re-evaluates every frame; do NOT cache the bonus outside the system (drift risk). - CurrencyWallet moves to dots-economy post-Wave 2 — do NOT add new references to
CurrencyWalletinsideDOTSInventory.*code; route through the incomingDOTSEconomy.Walletcomponent instead. Existing references are flagged[Obsolete]pending migration. - PassiveEffectRollupSystem runs every frame — if the equipped item set is stable, guard with a dirty-flag component (
InventoryDirtyTagIEnableableComponent) to skip rollup when nothing changed. - GachaPullSystem pity counter — the pity counter persists between pulls as a component field. If the entity is destroyed and recreated (e.g., respawn), the pity resets to 0. Persist pity in save data via
DOTSProgression.Persistenceif continuity is required. ItemPityRoller≠RarityRoller—ItemPityRollerrerolls the same item id after consecutive draws (anti-streak).RarityRollerforces a min rarity tier after N sub-tier draws (gacha-style hard pity). Both live inDOTSInventory.Loot. Don’t conflate them — they answer different design problems and require different state shapes (LootPityTrackerbuffer vs. a singleint drawsSinceMinTiercounter).GachaWeightedRollneedsusing Random = Unity.Mathematics.Random;— the file imports bothUnity.MathematicsandSystem(for[Obsolete]), so the bareRandomtoken is ambiguous (CS0104:Unity.Mathematics.RandomvsSystem.Random). The alias resolves it to the Burst-safe deterministic RNG. Any newDOTSInventory.Lootfile that uses both namespaces must add the same alias.- Loot rollers delegate to
DOTSCore.WeightedSampler— don’t re-implement cumulative-sum sampling.RarityRoller,WeightedRarityRoll, andGachaWeightedRollall build a tempNativeArray<float>and callWeightedSampler.Sample(in weights, ref rng). See § “Weighted-sampling SSOT” above. Adding a parallel hand-rolled sampler is an SSOT violation. UpdatePityTrackermutates the buffer in place — theref DynamicBuffer<LootPityTracker>argument captures the buffer reference. Do NOT call it inside a job that has structural changes scheduled between OnUpdate ticks; structural changes invalidate buffer references. Always run it on the main thread inside the sameISystem.OnUpdateslice as the roll.- Adjacency helpers live in
DOTSInventory.Utilities— needs an explicitusing. TheAdjacencyEffectenum,AdjacencyMathUtility, andSynergyClassifierwere extracted from demo code into the library under theDOTSInventory.Utilitiesnamespace (Phase 6 de-dup, 2026-05-31). This is a SEPARATE namespace fromDOTSInventoryandDOTSInventory.Grid— referencingAdjacencyEffect(or anyUtilitiestype) from a caller that only hasusing DOTSInventory;producesCS0246/CS0103. Fix: addusing DOTSInventory.Utilities;to every caller — runtime evaluators (AdjacencyRuleEvaluator,AdjacencySynergySystem, deploy bridges) AND test files (AdjacencyResolveSystemTests). Demo commitsb839d446+d89edb1cpatched the BackpackBattlefield callers; the sameusingis required in any new code that touches adjacency-effect classification. (Note:AdjacencyMathUtilityitself is[Obsolete]— new synergy math should callGridAdjacencyEvaluator, see the reference guide; but theAdjacencyEffectenum andSynergyClassifierremain canonical inDOTSInventory.Utilities.)