Pho Logic Board Engine Guide¶
Scope
This guide describes the implemented board engine in Pho Logic version
1.0.0+6. Pho Logic is a student portfolio project. For system-wide
boundaries, see the architecture reference; for
player-facing rules, see the GDD.
Purpose¶
The board engine is split so that game truth, orchestration, presentation, and screen state do not share ownership:
| Part | Responsibility |
|---|---|
GridModel |
Authoritative cells, tile IDs, instance IDs, beds, and blockers |
BoardController |
Swaps, matches, special rules, clearing, gravity, refill, and shuffle |
BoardGame |
Flame components, tap handling, model synchronization, particles, and VFX |
BoardViewport |
Flutter/Flame embedding and booster drop conversion |
GameStateModel |
Moves, objectives, blockers, win, and loss |
flowchart LR
Input[Tap or booster drop] --> Viewport[BoardViewport / BoardGame]
Viewport --> Controller[BoardController]
Controller --> Grid[GridModel]
Grid --> Sync[BoardGame.syncFromModel]
Sync --> Components[Flame components]
Controller --> ClearEvent[Regular victim event]
ClearEvent --> State[GameStateModel]
Core Data¶
Coord¶
Coord(row, col) is the logical position type. It is immutable and supports equality, hashing, and orthogonal-adjacency checks. It is used in maps and sets throughout rules and rendering.
Cell¶
A cell can contain:
tileTypeId: regular1..6, special101..105, ornull.tileInstanceId: stable identity while a tile moves.bedId:-1for void or a playable bed ID.- blocker state, currently the scooter blocker.
Invariants¶
GridModelis the source of truth.- Void cells never swap, match, refill, or receive effects.
- Blockers reject swaps and divide gravity into segments.
- Refill creates regular IDs only.
- Moving tiles preserve their instance IDs.
- Newly spawned tiles receive new instance IDs.
Session Initialization¶
StageLoaderparses a stage asset and resolves weighted0entries.StageBuilderconverts the matrices toBoardState.GridModel.fromBoardState()creates the cell matrix.BoardGamebuilds regular and special tile-definition lookup maps.BoardController.initializeInstanceIds()fills missing tile identities.BoardController.stabilizeInitialBoard()removes accidental starting matches.BoardGame.onLoad()creates beds, blockers, and tile components.
The stage's grid.shape.mask is not consumed by current board construction; bedMap determines playable cells.
Turn Flow¶
flowchart TD
Pair[Adjacent selected tiles] --> Swap[Logical swap]
Swap --> Sync[Animate positions]
Sync --> Valid{Line, 2x2, or special?}
Valid -- no --> Undo[Swap back]
Valid -- yes --> Resolve[Resolve clear and specials]
Resolve --> Gravity[Apply gravity]
Gravity --> Refill[Refill regular tiles]
Refill --> Cascade{New pattern?}
Cascade -- yes --> Resolve
Cascade -- no --> Check[Check legal move]
Check --> Move[Consume one move]
An invalid swap returns without spending a move. BoardGame holds a busy flag for the complete asynchronous transaction, preventing a second input from interleaving with model mutation or VFX.
Match and Special Rules¶
Line detection accepts horizontal and vertical runs of at least three equal regular IDs. The engine also treats a same-type regular 2x2 as a valid pattern.
| Pattern | Result |
|---|---|
| Three or more in a line | Clear regular victims |
| Exactly four horizontal | Spawn ID 101, horizontal Party Popper |
| Exactly four vertical | Spawn ID 102, vertical Party Popper |
| Exactly five straight | Spawn ID 103, Sticky Rice Bomb |
| Intersecting runs with union 5-6 | Spawn ID 104, Firecracker |
| Same-type 2x2 | Spawn ID 105, Dragon Fly |
When creation candidates overlap, the spawner processes Sticky Rice, Firecracker, Dragon Fly, then Party Popper, accepting only non-overlapping patterns.
Normal activation and special-to-special combination are separate policies. A special encountered within another special's clear is removed without recursively activating another VFX.
Blockers, Gravity, and Refill¶
A scooter blocker occupies a cell without a tile. It:
- Cannot be swapped.
- Stops a falling tile from crossing its row.
- Breaks when an adjacent regular match clears.
- Does not currently break from an adjacent special clear.
- Counts toward the stage win condition.
Gravity compacts each column inside playable, blocker-separated segments. Refill then creates weighted regular tiles in empty playable cells. Specials never appear from normal refill.
Rendering Synchronization¶
Coordinates identify places, not moving objects. The renderer therefore tracks three tile registries:
| Map | Question answered |
|---|---|
tilesByInstanceId |
Which component renders this tile? |
instanceAtCoord |
Which tile is now at this cell? |
coordByInstanceId |
Where is this tile now? |
syncFromModel() creates replacement coordinate maps, animates existing components to model positions, changes sprites when a type changes, adds missing components, removes orphans, and swaps the maps atomically. validateBoardSync() performs a defensive repair pass.
For detailed rendering behavior, see the BoardGame rendering reference.
State Events¶
The controller captures regular tile types before clearing. BoardGame receives that map and:
- Spawns object-specific particles unless a VFX owns the impact.
- Forwards regular victims to
GameStateModel.
GameStateModel clamps objective progress to target counts. It also tracks initial and remaining blocker counts. A valid swap consumes one move only after the complete resolution transaction succeeds.
Booster Placement¶
BoardViewport converts the drop position from screen pixels to a logical coordinate. A target must be playable and contain a regular tile. placeSpecialAt() changes the tile type while preserving identity, then inventory spending is attempted.
Non-atomic transaction
Placement and spending are not atomic. A failed spend logs an error but does not restore the original regular tile.
Solvability¶
The possible-move check uses the same acceptance criteria as a player action: line, 2x2, or special involvement. A stable board shuffles only when:
- No special exists.
- No possible move exists.
The shuffle seeks a board with no unintended starting match when required and at least one legal move.
Debugging Checklist¶
When a board/render discrepancy appears:
- Inspect the
GridModelcell values first. - Confirm every non-null tile has a unique
tileInstanceId. - Compare
instanceAtCoordwith model instance IDs. - Confirm
coordByInstanceIdis the inverse mapping. - Run or inspect
validateBoardSync()diagnostics. - Check whether VFX cleared cells directly before the standard controller phase.
- Verify that the busy flag was held across the full transaction.
When an objective discrepancy appears:
- Confirm victim IDs were captured before clearing.
- Confirm specials were filtered and regular IDs retained.
- Confirm VFX-controlled clears call the shared clear path.
- Check that scooter changes use the blocker callback, not tile objectives.
Extension Rules¶
Any new board feature should preserve:
- Model-first mutation.
- Stable identity for moving tiles.
- Explicit VFX/logical timing.
- One owner for objective events.
- Blocker and void filtering.
- Seedable randomness for tests.
- A stable, solvable post-turn board.
The next documents provide focused implementation references: