Building Flutter Apps (5/5) — Hexagonal Minesweeper: Cube Coordinates and BLoC
Hexagonal grid coordinate design, event-driven state management, and custom hit-testing — what changes compared to a square grid.
What This Article Covers
- Why to use Cube Coordinates instead of offset coordinates in hexagonal grids, and the conversion rules
- Why BLoC fits better than Riverpod when a single input triggers cascading state transitions
- Flood Fill, touch hit-testing (Cube Round), and test boundaries — the implementation points that change in a hexagonal grid
- BLoC separation criteria to prevent state explosion
1. Coordinate System — Offset vs Cube
A square grid is handled sufficiently with a (row, col) 2D array. A hexagonal grid is not. Starting with offset coordinates means neighbor indices differ for odd and even rows, and direction calculations scatter into conditional branches.
Cube coordinates represent each cell with three axes (q, r, s) subject to the constraint q + r + s = 0. The core benefits of this representation are:
- Uniform neighbor calculation: a single array of 6 direction offsets yields all neighbors. Row parity branching disappears.
- Simplified distance calculation: distance between two cells is
(|dq| + |dr| + |ds|) / 2. - Mathematical representation of rotation and reflection: achievable through axis permutation alone.
Offset coordinates are convenient at render time because they are close to screen coordinates, but keeping logic in cube and display in offset produces cleaner separation. Maintaining only two conversion functions (offsetToCube / cubeToOffset) keeps all remaining logic consistent.
Application Principle
When working with a hexagonal grid, start with cube coordinates from the beginning. Offset-coordinate neighbor logic accumulates bugs repeatedly, and the cost of a full rewrite late in development is high.
2. State Management — When BLoC Is the Right Choice
Choosing a Flutter state management solution depends on project characteristics. Even within the same app ecosystem, the appropriate tool differs.
| Project Type | Characteristics | Suitable Pattern |
|---|---|---|
| Viewer / Reader | Simple input, shallow state graph | Riverpod (reactive) |
| Game / Workflow | Single input triggers cascading transitions | BLoC (event-driven) |
Characteristics of Game State
A single "cell tap" in minesweeper triggers the following flow:
Input: CellTapped(q, r, s)
→ Mine check
→ Flood Fill if empty cell
→ Victory condition check
→ UI state update
This flow must be treated as a single transaction. BLoC's Event → State mapping corresponds directly to this structure. The log shows exactly which Event caused which State transition, making regression tracing and reproduction straightforward.
Where Riverpod Falls Short
Riverpod composes state via an inter-Provider dependency graph. In "one input → multiple cascading state changes" scenarios, the Provider update order tends to become implicit, and tracing the originating event during debugging is difficult.
Decision Rule
- Input → state change is 1-to-many and ordering matters → BLoC
- Input → state change is mostly 1-to-1 and declarative composition is central → Riverpod
3. Flood Fill — BFS-Based Implementation
Tapping an empty cell with no neighboring mines causes adjacent zero-mine cells to open in cascade. The principle is the same as a square grid, but the neighbor set is computed using cube coordinates.
directions = [
(+1, -1, 0), (+1, 0, -1), ( 0, +1, -1),
(-1, +1, 0), (-1, 0, +1), ( 0, -1, +1),
]
BFS Instead of Recursion
On large boards, a single Flood Fill can open hundreds of cells. Recursive DFS risks stack overflow. Implementing with queue-based BFS is safe, and a visited Set prevents duplicate expansion.
Performance Observation Points
- Cost of a single Flood Fill pass: O(number of cells opened)
- UI updates are batched and emitted after Flood Fill completes. Calling state emit per cell causes a rebuild explosion.
4. Custom Rendering and Touch Hit-Testing
Regular Hexagons with CustomPainter
- x-offset differs for even and odd rows
- y-spacing between rows is 3/4 of tile height (
height * 0.75) - Tiles are unified as either pointy-top (30° rotated) or flat-top hexagons
Touch Hit-Testing — Cube Round
Hexagons have angled edges, making bounding box comparison error-prone. At tile boundaries that cross into adjacent cells, the wrong cell gets selected.
The solution is to descend through: screen coordinates → fractional cube coordinates → integer cube coordinate rounding (Cube Round).
1. Convert touch coordinates (px, py) to fractional cube coordinates (qf, rf, sf)
2. Round each axis → (qi, ri, si)
3. If the q + r + s = 0 constraint is violated, recompute the axis with the largest rounding error from the remaining two axes
This approach mathematically guarantees the nearest tile center. It is cheaper and more accurate than running a point-in-polygon test per tile.
5. Test Boundaries — The Separation Line BLoC Provides
A key advantage of BLoC architecture is UI-independent testing. Because game logic and rendering are separated, the following can be verified with pure Dart tests:
| Test Target | Input | Expected Output |
|---|---|---|
| Game over | CellTapped on mine cell |
GameState.over |
| Victory | All non-mine cells opened | GameState.victory |
| Flood Fill | CellTapped on zero-mine cell |
Set of cascade-opened cells |
| Coordinate conversion | offset → cube → offset | Original coordinates match (round-trip) |
If the coordinate conversion round-trip breaks, all features fail in cascade. It is the highest-priority test target to lock in first.
6. BLoC Separation Criteria — Preventing State Explosion
Consolidating all game state into a single BLoC causes event types and branching to grow rapidly. Separate by responsibility.
| BLoC | Responsibility |
|---|---|
GameBloc |
Board state, cell open/flag, win-loss determination |
TimerBloc |
Elapsed time, pause |
LeaderboardBloc |
Record save/retrieve (Hive integration) |
The separation criteria are state lifetime and update frequency. Timer emits per second; Game emits per input event. Combining them causes Timer's high-frequency updates to trigger Game listeners unnecessarily, producing unwanted rebuilds.
Open Questions and Limitations
- Cube coordinate adoption cost: team members unfamiliar with the system face a learning curve. Documentation is mandatory.
- Accessibility of hexagonal tile UI: tile identification in screen reader and high-contrast modes requires additional design work.
- Difficulty generation algorithm: uniform mine distribution does not guarantee a "good game." First-click safety, guaranteed logical solution paths — these are separate topics.
- Platform-specific gestures: long-press (flag) on mobile and right-click on desktop can both be absorbed as BLoC events, but UX guidelines should be separated per platform.
Applicability
- The same coordinate system applies to hexagonal board games in general (turn-based strategy, puzzles)
- Workflow-style apps with a "single input → cascading state transitions" structure (checkout flows, multi-step forms, turn-based UI) also benefit from BLoC
- Cube Round generalizes beyond hexagons to coordinate correction for non-rectangular tile layouts
댓글
댓글 쓰기