BoardController Rule Engine Reference¶
Scope
Source: lib/src/game/board/board_controller.dart. This document
describes the checked-in rule engine for the Pho Logic student portfolio
project.
Role¶
BoardController orchestrates one authoritative GridModel. It contains no Flutter widgets and no Flame components. Visual timing enters through awaited callbacks.
Direct Collaborators¶
| Collaborator | Role |
|---|---|
GridModel |
Mutable board state |
StageData |
Tile definitions, weights, and stage rules |
SfxManager |
Rule-triggered sound effects |
SpecialTileSpawner |
Pattern-to-special creation |
SpecialActivationResolver |
Normal special activation |
SpecialComboResolver |
Ordered special-pair steps |
BoardSolvability helpers |
Legal-move checks and shuffle support |
WeightedPicker |
Regular refill selection |
Public Behavior Surface¶
The important operations are:
- Initialize missing tile instance IDs.
- Stabilize an initial board.
- Check whether a swap is structurally allowed.
- Detect line matches.
- Attempt and resolve a player swap.
- Clear matched/special cells.
- Apply gravity and refill.
- Run cascades.
- Detect possible moves and shuffle.
- Place an inventory special at a coordinate.
- Register clear and blocker callbacks.
Rule Invariants¶
- A playable cell has
bedId != -1. - A swappable cell is playable, has a tile, and has no blocker.
- Only orthogonally adjacent cells can swap.
- Specials are IDs
101..105; normal line matching ignores them. - Refill selects only regular stage tiles.
- A moving tile preserves its instance ID.
- Every clear event captures regular victim types before cells are emptied.
- Special-created spawn coordinates survive their creation clear.
- A stable post-turn board should contain a legal move or a special.
Initialization¶
Instance IDs¶
initializeInstanceIds() assigns unique IDs to existing tiles that lack
them. These IDs are consumed by BoardGame and VFX, so later mutations must
move them with the tile value.
Initial Stabilization¶
stabilizeInitialBoard() repeatedly detects initial line matches and rerolls
matched regular cells. It is a runtime defense when
allowInitialMatches == false.
Stage construction also has initial-match avoidance for unresolved weighted
cells. Because StageLoader normally resolves 0 entries first, initial
randomization currently has overlapping ownership.
Swap Eligibility¶
canSwap(a, b) checks:
- Both coordinates are in bounds.
- Both cells are playable.
- Both contain tiles.
- Neither contains a blocker.
- The coordinates are orthogonally adjacent.
It does not by itself prove that the swap creates an accepted result.
_willSwapBeValid() simulates the exchanged tile types and accepts:
- A horizontal/vertical line created at either swapped coordinate.
- A same-type regular 2x2 around the swap.
- Any swap that includes a special tile.
Swap Transaction¶
sequenceDiagram
participant Game as BoardGame
participant Controller as BoardController
participant Grid as GridModel
Game->>Controller: attemptSwap(a, b, callbacks)
Controller->>Grid: swap cells
Controller->>Game: await onSync()
Controller->>Controller: detect line / 2x2 / special
alt invalid
Controller->>Grid: swap back
Controller->>Game: await onSync()
Controller-->>Game: false
else valid
Controller->>Controller: resolve clear and cascade
Controller-->>Game: true
end
BoardGame, not the controller, decrements the move after a true result.
This keeps session-state ownership outside the board rule engine.
Match Detection¶
detectMatches() scans each playable row and column for contiguous equal
regular tile IDs. It produces Match records and a union of all matched
coordinates.
The scan excludes:
- Void beds.
- Blocker cells.
- Empty cells.
- Special IDs
101..105.
Horizontal and vertical runs can intersect. That overlap is retained so the special spawner can recognize T/L unions.
Special Creation¶
SpecialTileSpawner.processMatches() collects candidates, sorts by priority,
and greedily accepts non-overlapping cells:
| Priority | Pattern | Special |
|---|---|---|
| 5 | Exactly five straight | Sticky Rice 103 |
| 4 | Horizontal/vertical union of 5-6 | Firecracker 104 |
| 3 | Same-type 2x2 around the swap | Dragon Fly 105 |
| 2 | Exactly four horizontal | Party Popper 101 |
| 2 | Exactly four vertical | Party Popper 102 |
Spawn placement prefers swapB, then swapA, then a deterministic
pattern fallback. The accepted spawn cell is removed from cellsToClear
before mutation.
Clear Pipeline¶
clearMatchedCells() coordinates:
- Pattern processing and protected spawn coordinates.
- Special-pair detection.
- Normal special activation or combo-step construction.
- Creation of special tile IDs at protected coordinates.
- VFX-controlled and controller-controlled clear partitioning.
- Victim capture and callbacks.
- Blocker-break animation and final blocker removal.
The method captures tile types before setting tileTypeId and
tileInstanceId to null. Only regular victim IDs reach objective accounting.
Blocker Damage¶
Regular clear calls use isRegularClear: true. Each such coordinate can mark
an adjacent scooter for a pending break. Special clear calls use
isRegularClear: false and do not damage adjacent scooters.
Pending breaks are deduplicated, animated through onBlockerBreak, removed
from the model, and reported through onBlockerCleared.
Normal Special Activation¶
SpecialActivationResolver accepts swap coordinates and the first-selected
chosenCoord. It activates at most one source in the normal path:
- If one swapped tile is special, activate it.
- If both are special but combo routing is not used, activate the chosen coordinate.
- Specials inside the affected set clear without recursively activating.
| ID | Affected cells |
|---|---|
101 |
Playable row |
102 |
Playable column |
103 |
Regular tiles matching the regular swap partner, plus source |
104 |
Playable centered 3x3 |
105 |
Swap partner, source, and one deterministic additional target |
Metadata carries Sticky Rice activation cells and Dragon Fly target coordinates to the VFX layer.
Special Combinations¶
SpecialComboResolver.resolveCombo() returns:
- Whether both swap tiles form a combo.
- Activated and other source/type metadata.
- Ordered
ComboSteprecords. - A union of affected cells.
The controller executes steps sequentially in _runComboSteps(). Each step
can dispatch an independent VFX callback before logical cleanup.
Important non-obvious policies:
104 + 104uses one 3x3 at the activated source.104 + 101/102uses the Firecracker 3x3 only.103 + 103clears all regular tiles.105 + 105selects up to two pre-hit targets.- Sticky Rice mixed combos clear a selected regular type before the other line/3x3 step.
- Dragon Fly mixed combos pre-hit before the other effect.
See the GDD for the complete pair matrix.
Gravity¶
applyGravity() processes columns while treating voids and blockers as hard
boundaries. Within each playable segment, non-null tiles compact toward the
bottom. A tile's type and instance ID move together.
Example:
The blocker prevents tile B from entering the lower segment.
Refill¶
refill() visits empty playable, non-blocker cells and chooses from regular
stage tile definitions using the configured weights. It assigns a fresh
instance ID for every spawned tile.
Special IDs are excluded from refill. Stage 21 can pre-place specials as fixed
IDs, but their zero weights must not enter WeightedPicker.
Cascade Loop¶
runCascade() has a default maxCascades = 10.
For each pass:
- Detect line matches.
- If no line exists, scan all same-type regular 2x2 blocks.
- Clear and create specials.
- Await render/VFX synchronization.
- Apply gravity and synchronize.
- Refill and synchronize.
- Check solvability.
- Repeat while a pattern remains.
Swap coordinates and chosenCoord are used only on the first pass. Automatic
cascades use deterministic fallback spawn positions.
The 10-pass cap protects against an accidental endless resolution loop. Hitting it is logged and should be treated as a test failure in deterministic board tests.
Solvability and Shuffle¶
hasPossibleMove() uses the same validity categories as a real attempt:
line, 2x2, or special involvement.
ensureSolvableOrShuffle() returns immediately when:
- Any special exists, because swapping a special is accepted.
- At least one normal possible move exists.
Otherwise it shuffles regular tile assignments and searches for a board that:
- Meets the initial-match policy.
- Has a possible move.
The method invokes the no-moves callback so Flutter can show the temporary shuffle modal.
Inventory Placement¶
placeSpecialAt(coord, powerTypeId) validates:
- Coordinate bounds.
- Playable bed.
- No blocker.
- Existing regular tile.
- Power ID in
101..105.
It changes the cell's type while retaining or assigning a tile instance ID.
Inventory availability is checked and spent outside this controller in
BoardViewport.
Callback Contract¶
| Callback | Timing | Purpose |
|---|---|---|
onSync |
After mutation boundaries | Let the renderer converge and animate |
onSpecialActivated |
Per normal activation or combo step | Play source-specific VFX with metadata |
onNoMovesDetected |
Before/around shuffle | Display temporary player feedback |
onCellsClearedWithTypes |
After victim capture | Particles and objectives |
onBlockerBreak |
Before blocker removal completes | Await scooter exit VFX |
onBlockerCleared |
After logical blocker clear | Update blocker win state |
Callbacks that control sequencing return Future<void> and are awaited.
Replacing them with fire-and-forget calls can make rendering and model state
diverge.
Mutation Safety¶
When adding a rule:
- Mutate the model, not Flame components.
- Preserve identity when moving an existing tile.
- Protect special spawn coordinates from clears.
- Capture victim IDs before clearing.
- Keep regular and special blocker-damage policy explicit.
- Await VFX whose impact timing controls a clear.
- Synchronize after each observable board phase.
- End with a stable and solvable board.
Complexity Notes¶
For an 8x7 board, straightforward scans are appropriate:
- Line detection is
O(rows * columns). - 2x2 scanning is
O(rows * columns). - Gravity and refill are
O(rows * columns). - Possible-move search examines adjacent pairs and local simulated patterns.
The board is small, so clarity and correctness dominate micro-optimization. The risk is asynchronous orchestration and state ownership, not raw scan cost.
No object-pooling claim should be made for controller data structures; normal Dart collections and records are used.
High-value Unit Tests¶
Match Detection¶
- Runs of 3, 4, and 5 on both axes.
- Runs broken by void, blocker, empty cell, or special.
- Intersecting T/L matches.
- Overlapping matches without duplicate victim accounting.
Special Creation¶
- Every pattern maps to the correct ID.
swapB,swapA, and fallback spawn location.- Priority and overlap rejection.
- Spawn cell excluded from victim set.
- Automatic cascade 2x2 detection.
Activation and Combos¶
- Each normal affected-cell set at edges, voids, and blockers.
- Sticky Rice with valid and invalid swap partner.
- Dragon Fly deterministic target and fallback.
- All special-pair combinations and step order.
- Specials in another effect clear without recursive VFX.
Board Physics¶
- Gravity through irregular void patterns.
- Blocker-separated segments.
- Instance IDs move with tiles.
- Refill never creates specials.
- Stable board has no accidental matches when required.
- Dead board shuffles to a legal board.
Events¶
- One clear callback per logical victim batch.
- Special IDs excluded from objectives.
- Adjacent regular clear breaks each scooter once.
- Special clear does not break scooters under current policy.
- Blocker VFX callback precedes blocker-cleared notification.
Use seeded Random values for all random-dependent tests. Some controller
randomness is currently created internally; injecting it through construction
would improve reproducibility.
Known Technical Debt¶
- StageLoader and StageBuilder duplicate weighted initialization.
- StageValidator is not invoked by the runtime load path.
- Zero-weight fixed specials in stage 21 conflict with the validator's blanket positive-weight rule.
- BoardController is large and owns several phases; extracting transaction phase objects should be considered only alongside tests.
- Special and regular clear paths intentionally differ for blockers and require an explicit design decision.
Related references: