t1k:unity:dots-core:puzzle
| Field | Value |
|---|---|
| Module | dots-core |
| Version | 2.3.2 |
| Effort | medium |
| Tools | — |
How to invoke
Section titled “How to invoke”/t1k:unity:dots-core:puzzleDOTS Puzzle Core — Board, Match, Cascade, Scoring
Section titled “DOTS Puzzle Core — Board, Match, Cascade, Scoring”Package:
com.the1studio.dots-puzzle— namespaceDOTSPuzzle.*Related skills:dots-core-ecs-core(API) ·dots-combat-puzzle(puzzle→combat bridge)
When This Skill Triggers
Section titled “When This Skill Triggers”- Implementing grid-based puzzle boards (match-3, polyomino, block-blast, slide, merge)
- Writing match detection — 3-in-a-row, L/T shapes, combo chains
- Building cascade / gravity systems after cell removal
- Implementing swap, rotation, fill with seeded random
- Connecting puzzle scoring to combat (see
dots-combat-puzzle)
Public Components
Section titled “Public Components”| Component | Type | Purpose |
|---|---|---|
BoardConfig | IComponentData (singleton) | int Width, int Height, int GemTypeCount, uint Seed |
BoardCell | IBufferElementData (cap 0) | int GemType, bool IsMatched, bool IsEmpty, Entity CellEntity |
MatchGroup | IBufferElementData | int GroupId, int CellIndex, int Size |
CascadeStep | IComponentData | int Step, bool IsComplete |
SwapRequest | IComponentData | int FromIndex, int ToIndex, bool IsValid |
PuzzleScore | IComponentData (singleton) | int TotalScore, int ChainMultiplier, int MoveCount |
SpecialGem | IComponentData | SpecialGemType Type, int PowerValue; added to cell entities with special behavior |
UndoState | IBufferElementData | Snapshot of BoardCell buffer for undo support |
public enum SpecialGemType { Bomb, LineBlast, ColorBurst, Multiplier }Public Systems
Section titled “Public Systems”| System | Group | Role |
|---|---|---|
BoardInitSystem | PuzzleSystemGroup (OrderFirst) | Creates BoardCell buffer sized Width * Height; seeds gems via Random.CreateFromIndex(Seed) |
SwapSystem | PuzzleSystemGroup | Validates SwapRequest; swaps two cells; marks for match scan |
MatchDetectionSystem | PuzzleSystemGroup (after Swap) | Scans rows + columns for 3+ contiguous same-type; marks IsMatched; populates MatchGroup |
SpecialGemActivationSystem | PuzzleSystemGroup (after Match) | Resolves special gem effects (bombs, line blasts) before cascade |
CascadeSystem | PuzzleSystemGroup (after Match) | Shifts non-empty cells downward per column; fills vacancies with new random gems |
ScoringSystem | PuzzleSystemGroup (after Cascade) | Awards points per MatchGroup.Size; applies chain multiplier; resets after no-match cascade |
UndoSystem | PuzzleSystemGroup | Restores BoardCell buffer from UndoState snapshot on UndoRequestTag |
Common Patterns
Section titled “Common Patterns”Board layout — 1D buffer as 2D
Section titled “Board layout — 1D buffer as 2D”// Row-major index: (row, col) → col * Height + row OR row * Width + col// DOTS-puzzle uses column-major for gravity (columns are contiguous):int cellIndex = column * config.Height + row;var cells = SystemAPI.GetBuffer<BoardCell>(boardEntity);ref var cell = ref cells.ElementAt(cellIndex);Match detection (horizontal)
Section titled “Match detection (horizontal)”// MatchDetectionSystem — scan each row:for (int row = 0; row < height; row++){ int runStart = 0, runLen = 1; for (int col = 1; col < width; col++) { int prev = (col - 1) * height + row; int curr = col * height + row; if (cells[curr].GemType == cells[prev].GemType && cells[curr].GemType >= 0) runLen++; else { if (runLen >= 3) MarkMatched(cells, runStart, col - 1, row, height); runStart = col; runLen = 1; } } if (runLen >= 3) MarkMatched(cells, runStart, width - 1, row, height);}Cascade (gravity)
Section titled “Cascade (gravity)”// CascadeSystem — compact non-empty cells downward per column:for (int col = 0; col < width; col++){ int writeRow = 0; for (int row = 0; row < height; row++) { int idx = col * height + row; if (!cells[idx].IsEmpty) { cells.ElementAt(col * height + writeRow) = cells[idx]; if (writeRow != row) cells.ElementAt(idx) = default; // empty slot writeRow++; } } // Fill remaining rows with new random gems}Seeded fill after cascade
Section titled “Seeded fill after cascade”// Deterministic fill — ensure same seed + move count → same board state:var rng = Random.CreateFromIndex(config.Seed + (uint)score.MoveCount * 100u + (uint)column);cells.ElementAt(idx) = new BoardCell { GemType = rng.NextInt(0, config.GemTypeCount) };Cross-Package Interactions
Section titled “Cross-Package Interactions”| Direction | Package | Detail |
|---|---|---|
| Depends on | DOTSCore base | ECB, SystemAPI, Random |
| Used by | DOTSPuzzleCombat | MatchGroup.Size → elemental damage pipeline |
| Used by | Match3RPGDemo | Board drives combat; each gem color maps to element |
| Used by | ColorFitDemo | Queue-to-grid dispatch uses BoardCell + SwapSystem |
Supported Puzzle Types (7)
Section titled “Supported Puzzle Types (7)”| Type | Key variation |
|---|---|
| Match-3 | MatchDetectionSystem with shape variants (L/T via MatchGroup.Size >= 4) |
| Polyomino | SwapRequest replaced by placement system; BoardCell.CellEntity tracks piece origin |
| Block-blast | Row/column clear on match; SpecialGemType.LineBlast encodes this |
| Slide-merge | Directional CascadeSystem variant (4 directions); GemType doubles on collision |
| Merge | CascadeSystem runs after drop, not gravity; merge on adjacency |
| Tube sort | BoardConfig encodes tube columns; match = full monochrome tube |
| Queue dispatch | Input queue IBufferElementData; dispatch via SwapSystem to target cells |
Gotchas
Section titled “Gotchas”- Column-major vs row-major —
DOTSPuzzleuses column-major indexing (col * Height + row) so gravity sweeps are cache-coherent (all cells of one column are contiguous). Using row-major in new code creates silent correctness bugs (wrong neighbor checks). SpecialGemActivationSystemmust run BEFORECascadeSystem— bombs and line blasts mark additional cellsIsMatched; cascade must process all marked cells in one pass. Reversing order causes orphaned matched cells.- Act-before-set: phase-gated cascade systems MUST be explicitly ordered, or single-Update phase tests silently break — The puzzle cascade pipeline is a phase state machine (
PhaseMatchCheck → PhaseDestroy → PhaseGravity → …). Each system is gated to one phase and the EditMode tests run a SINGLEWorld.Update()then assert the intermediate phase. The invariant: the system that ACTS on a phase must run BEFORE the system that SETS (transitions into) that phase, so a freshly-set phase is NOT consumed in the same frame (which would skip ahead one or more phases and fail the assertion). Concretely:SpecialPieceCreationSystemis[UpdateBefore(typeof(MatchDestroySystem))]so special pieces (blast radius, line width) are configured beforeMatchDestroySystemadvancesPhaseDestroy → PhaseGravity.MatchDetectionSystemis[UpdateAfter(typeof(MatchDestroySystem))]+[UpdateBefore(typeof(BoardShuffleSystem))]: AFTER destroy so aMatchCheck-frame detection that setsPhaseDestroyis not consumed same-frame (would clearMatchGroupand advance toGravitybefore observers read it); BEFORE shuffle because shuffle SETSPhaseMatchCheckand detection ACTS on it.- Why it’s a trap: an UNCONSTRAINED match/cascade system (no
[UpdateBefore]/[UpdateAfter]) compiles, passes today, then DRIFTS in thePuzzleSystemGrouptopological sort the instant another ordering edge is added elsewhere — silently re-ordering a same-frame cascade and breaking the intermediate-phase test with no code change to the affected system. Always pin cascade/match/special systems with explicit ordering attributes; never rely on the implicit sort. Document the act-before-set reasoning inline (see the comment blocks onMatchDetectionSystemandSpecialPieceCreationSystemincom.the1studio.dots-puzzle).
BoardInitSystemre-runs ifBoardConfigis destroyed and re-added — the system has noOnCreateguard. Guard withstate.RequireForUpdate<BoardConfig>()AND checkcells.IsCreated && cells.Length > 0before re-init.- Seeded fill must include move count in the seed — using
config.Seedalone produces the same fill after every cascade; includingMoveCountensures variety across the game. - Chain multiplier resets on no-match cascade —
ScoringSystemresetsChainMultiplier = 1whenMatchGroupbuffer is empty after a cascade step. Consumer must read the multiplier BEFORE cascade resolves to display the correct chain count in UI. - Undo only stores 1 snapshot —
UndoStateis a single buffer overwritten each move. Multi-level undo requires a stack pattern (multipleUndoStatebuffers or a circular buffer entity).