Skip to content

t1k:unity:dots-inventory:grid

FieldValue
Moduledots-inventory
Version2.1.7
Effortmedium
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

/t1k:unity:dots-inventory:grid

DOTS Inventory Grid — Grid, Crafting, Passives, Sets, Loot

Section titled “DOTS Inventory Grid — Grid, Crafting, Passives, Sets, Loot”

Package: com.the1studio.dots-inventory — namespace DOTSInventory.* Related skills: dots-ecs-core (API) · dots-combat-rpg (stats pipeline) · dots-core-ecs-core (ECB)

  • 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
TopicReference File
GridAdjacencyEvaluator — diminishing-returns synergy formula (generic, Burst-safe)references/grid-adjacency-evaluator-guide.md
ComponentTypePurpose
InventoryGridCellIBufferElementData (cap 0)Entity ItemEntity, bool IsActive; 1D buffer represents 2D grid
InventoryGridConfigIComponentData (singleton)int Width, int Height — defines grid dimensions
CraftingRecipeIBufferElementDataint RecipeId, int OutputItem, int RequiredCount
CraftingIngredientIBufferElementDataint ItemTypeId, int Quantity
CraftingStateIComponentDataint ActiveRecipeId, float Progress
ItemPassiveEffectIBufferElementDataint StatId, float Value, PassiveOp Op
ItemSetBonusIBufferElementDataint SetId, int RequiredCount, int StatId, float Bonus
EquippedItemIBufferElementDataEntity ItemEntity, int SlotIndex
LootTableIBufferElementDataint ItemTypeId, float Weight, int MinCount, int MaxCount
GachaEntryIBufferElementDataint ItemTypeId, float Probability, RarityTier Rarity
LootPityTrackerIBufferElementData (cap 4)int ItemId, int ConsecutiveDrops; per-player anti-streak state
SystemGroupRole
InventoryGridPlacementSystemInventorySystemGroupValidates grid bounds and marks cells occupied
CraftingSystemInventorySystemGroupMatches ingredients against recipes, advances CraftingState.Progress
PassiveEffectRollupSystemInventorySystemGroupAggregates ItemPassiveEffect entries → writes derived stat component
SetBonusEvaluationSystemInventorySystemGroupCounts EquippedItem per SetId; applies bonus when threshold met
LootDropSystemInventorySystemGroupWeighted-random drop from LootTable buffer on trigger event
GachaPullSystemInventorySystemGroupProbabilistic pull with pity counter; spawns item entity via ECB
// Cell address: row-major order
int cellIndex = gridY * config.Width + gridX;
var cells = SystemAPI.GetBuffer<InventoryGridCell>(gridEntity);
ref var cell = ref cells.ElementAt(cellIndex);
bool occupied = cell.ItemEntity != Entity.Null;
// 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 RW
// 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 threshold
// 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 lookup
private 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.RarityRoller with a BlobAssetReference<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”.

RollerStorage shapePity / FTUEWhat it returns
RarityRollerblob (BlobAssetReference<RarityWeightsBlob>, fixed config)min-tier hard pity (drawsSinceMinTier / pityThreshold / minTierIndex)byte tier index (InvalidTier=255 on bad input)
WeightedRarityRollbuffer (DynamicBuffer<LootRarityWeights>, per-stage mutable)none (add at caller)ItemRarity (DefaultRarity=Common on empty/zero)
GachaWeightedRollGachaConfig singleton (consumer-supplied weights + pity threshold)FTUE-first-pull guarantee + config-driven consecutive-common pityGachaRarity

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-supplied GachaConfig (back-compat overload uses library defaults via GachaConstants). The old ResolveItemId(...) is [Obsolete] — the library does NOT know item catalogs. Register an IGachaItemResolver via GachaItemResolverBridge.Register(...) and call GachaItemResolverBridge.Instance.ResolveItemId(...) instead.

DirectionPackageDetail
Depends onDOTSCore baseLocalTransform, ECB, SystemAPI
Depends onDOTSCore.StatsDerivedCombatStats that passive rollup writes into
Used byDOTSCombatCombat reads inventory passives for derived stats
Future splitdots-economyCurrencyWallet moving to dots-economy in Wave 2
  • BackpackCrawler — polyomino grid, drag-drop, set bonuses, loot drops per room, ItemPityRoller reference impl (CrawlerLootTable.GetRandomLootWithPity)
  • InventoryDemo — grid showcase with passive rollup and crafting UI
  • AutoBattlerDemo — draft shop uses GachaEntry tiers for unit reveal
  • ChaosForgeDemo — forge-roll loop (ForgeRollSystem) consumes ItemPityRoller for anti-streak protection (W2.6)
  • 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. SetBonusEvaluationSystem clears 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 CurrencyWallet inside DOTSInventory.* code; route through the incoming DOTSEconomy.Wallet component 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 (InventoryDirtyTag IEnableableComponent) 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.Persistence if continuity is required.
  • ItemPityRollerRarityRollerItemPityRoller rerolls the same item id after consecutive draws (anti-streak). RarityRoller forces a min rarity tier after N sub-tier draws (gacha-style hard pity). Both live in DOTSInventory.Loot. Don’t conflate them — they answer different design problems and require different state shapes (LootPityTracker buffer vs. a single int drawsSinceMinTier counter).
  • GachaWeightedRoll needs using Random = Unity.Mathematics.Random; — the file imports both Unity.Mathematics and System (for [Obsolete]), so the bare Random token is ambiguous (CS0104: Unity.Mathematics.Random vs System.Random). The alias resolves it to the Burst-safe deterministic RNG. Any new DOTSInventory.Loot file that uses both namespaces must add the same alias.
  • Loot rollers delegate to DOTSCore.WeightedSampler — don’t re-implement cumulative-sum sampling. RarityRoller, WeightedRarityRoll, and GachaWeightedRoll all build a temp NativeArray<float> and call WeightedSampler.Sample(in weights, ref rng). See § “Weighted-sampling SSOT” above. Adding a parallel hand-rolled sampler is an SSOT violation.
  • UpdatePityTracker mutates the buffer in place — the ref 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 same ISystem.OnUpdate slice as the roll.
  • Adjacency helpers live in DOTSInventory.Utilities — needs an explicit using. The AdjacencyEffect enum, AdjacencyMathUtility, and SynergyClassifier were extracted from demo code into the library under the DOTSInventory.Utilities namespace (Phase 6 de-dup, 2026-05-31). This is a SEPARATE namespace from DOTSInventory and DOTSInventory.Grid — referencing AdjacencyEffect (or any Utilities type) from a caller that only has using DOTSInventory; produces CS0246/CS0103. Fix: add using DOTSInventory.Utilities; to every caller — runtime evaluators (AdjacencyRuleEvaluator, AdjacencySynergySystem, deploy bridges) AND test files (AdjacencyResolveSystemTests). Demo commits b839d446 + d89edb1c patched the BackpackBattlefield callers; the same using is required in any new code that touches adjacency-effect classification. (Note: AdjacencyMathUtility itself is [Obsolete] — new synergy math should call GridAdjacencyEvaluator, see the reference guide; but the AdjacencyEffect enum and SynergyClassifier remain canonical in DOTSInventory.Utilities.)