01Built out of spite
I did not grow up playing Hareeg, but I played a lot of it in my late teens and I still do. So when I went looking for it on my phone, I had standards. The app I found had ads, and I put up with them. Then one match, offline, it threw a blank white box on the screen and made me sit there waiting to watch an ad that was never going to load. No internet, no ad, nothing there. It paused a game I was enjoying to show me a void, and it did not even get paid for the interruption.
That was the moment. If an app will break a game you are enjoying to show a loading screen for nothing, the bar is on the floor. So I built my own: offline-first and ad-free. The only diagnostic export is a match report that the player chooses to share or copy.
I also leaned on AI agents more heavily here than on any project before it. Once the first bugs reached my phone instead of a test, verification became part of the build. Generated code did not count as finished until the rules and regression tests could keep it honest.
02A card game with nobody else in the room
If you have never played, Classic Hareeg runs anti-clockwise around four seats. On a normal turn you draw from the stock or take the previous discard, play melds, then discard. A meld is either a set of the same rank in different suits or a same-suit sequence, with at least three cards. A normal finish plays the hand down and leaves one final discard. Everyone still holding cards scores what they are stuck with, so points are the thing you are trying not to collect, and at 31 you are out of the match. A Fifty is a timed claim by the next player after a discard. If that card completes a legal finish, the scoring punishes the player who discarded it.
Offline has a consequence people underrate. There is no human across the table, so the three computer players have to carry the whole experience. They need a real difficulty ladder, four tiers running from a beginner you can beat up to an expert that punishes mistakes, and at every rung they have to know the nuances of Hareeg instead of just shoving legal cards around. Most of this project's git history is exactly that: getting the opponents to play correctly.
Underneath, the top three tiers are not three separate bots. Casual, Skilled, and Expert share one decision pipeline that owns the whole turn, and each plugs in its own scoring and posture through a single policy interface. Same spine, different judgment. Beginner is the exception, on purpose. It runs a plain priority list that only ever looks at the legal moves in front of it, because the bottom rung should feel like someone who just learned the rules, not a genius on a leash. Expert, at the other end, reads the whole table.
/// Runs the full plan pipeline for [observation].
ClassicHareegCpuMovePlan plan(CpuObservation observation) {
final actions = decodeLegalActions(observation);
if (actions.isEmpty) {
return const ClassicHareegCpuMovePlan(
scenario: ClassicHareegCpuMoveScenario.noLegalActions,
actionId: null,
);
}
// ... mid-proof on a claimed Fifty: replay the engine's validated step.
final fifty = firstActionOfKind(
actions,
ClassicHareegActionKind.claimFifty,
);
if (fifty != null && policy.shouldClaimFifty(observation)) {
return ClassicHareegCpuMovePlan(
scenario: ClassicHareegCpuMoveScenario.fiftyClaim,
actionId: fifty.actionId,
);
}
// ... draw phase: pending-discard handling, take-discard, or draw-stock.
// Joker replacement is checked BEFORE meld plays: the swap needs the
// natural card still in hand, and the best meld may consume exactly that
// card (e.g. three natural 8s whose 8♣ could first reclaim a table joker
// representing it) — melding first destroys the swap forever, while
// swapping first keeps the same meld playable with the freed joker.
final replacement = firstActionOfKind(
actions,
ClassicHareegActionKind.replaceJoker,
);
if (replacement != null && policy.gateJokerReplacement(observation)) {
return ClassicHareegCpuMovePlan(
scenario: ClassicHareegCpuMoveScenario.jokerReplacement,
actionId: replacement.actionId,
);
}
final holdForFifty = policy.shouldHoldNormalFinishForFifty(observation);
if (!holdForFifty) {
final meldAction = bestMeldAction(
observation,
partitionLimit: defaultPartitionLimit,
);
if (meldAction != null) {
return ClassicHareegCpuMovePlan(
scenario: ClassicHareegCpuMoveScenario.meldPlay,
actionId: meldAction,
);
}
// ... allowAnyLegalMeldFallback for tiers without a partition ranker.
}
// ... the cover walk.
final discard = bestDiscardAction(observation, actions);
if (discard != null) {
return ClassicHareegCpuMovePlan(
scenario: ClassicHareegCpuMoveScenario.safeDiscard,
actionId: discard.actionId,
);
}
return policy.fallback(observation);
}The nuance that sold me on the whole approach is the Fifty (Khamsin) posture. A Fifty is worth a big swing, but it can only punish one specific seat: the one whose discard the claim would take. The Expert tier will hold back a perfectly good winning move to set up a Fifty, but only when it pays off, which is when the Expert is itself at high risk or that one punishable seat is. A high score anywhere else at the table is out of reach, so it is not worth the gamble. That is the kind of reasoning a person brings to the game, and I got there with heuristics, not a search tree.
static bool holdsNormalFinishForFifty(CpuObservation observation) {
if (observation.finishingPartition() == null ||
observation.stockCount < _fiftyHoldStockFloor ||
handPipValue(observation.ownHand) <= _fiftyHoldHandValueFloor) {
return false;
}
if (observation.ownScore >= _highRiskScoreFloor) {
return true;
}
final target = fiftyPunishTarget(observation);
return target != null &&
observation.scoreFor(target) >= _highRiskScoreFloor;
}
/// The only seat a Fifty claimed by [observation]'s seat can punish: the
/// active seat immediately before it in turn order, whose discard the claim
/// would take. Null in the degenerate no-opponents state.
static PlayerSeat? fiftyPunishTarget(CpuObservation observation) {
final opponents = observation.opponents;
return opponents.isEmpty ? null : opponents.last;
}03A coach that was really a debugger
Tuning an opponent you cannot see into is miserable. You change a weight, play ten hands, and squint at whether it feels any smarter. So I built a coach. It takes the Expert tier's reasoning and puts it on screen as advice. I meant it for first-time players who do not know Hareeg yet, and it does that job, but the first person it helped was me. It gave me a window into what the bots were thinking, and that is how I tuned them.
The good part is that the coach is not a second, parallel implementation that could drift away from the real bots. It calls the same analysis brain the Expert player uses, so the advice a beginner sees comes from the reasoning the hardest opponent acts on.
The guided lessons pull the same trick. There are 24 of them across five packs, and they are all built and playable: fundamentals, core turn, table mechanics, finish and fifty, and table strictness. Instead of a separate tutorial screen, they run on the real table in a practice mode. Deterministic hands get dealt to teach one thing at a time, the actual rules engine decides what is legal, and take-back corrections kick in when you mis-stage a play. Each lesson hand is even audited in CI with the real meld enumerator, so the filler cards cannot accidentally form a meld the lesson did not intend. There is no tutorial sandbox to keep in sync with the game, because there is no sandbox. It is the game.
/// Returns priority-sorted coaching insights for [seat] given [controller].
///
/// Highest-priority insight is first; callers may show only `result.first`
/// (the live table routes the list through [CoachInsightFlow] so stage
/// banners dedupe per round).
static List<CoachingInsight> adviseFor(
ClassicHareegGameController controller,
PlayerSeat seat,
) {
final observation = _observationFor(controller, seat);
// One shared read model for the whole call: the best meld partition, the
// keep-scores, and the Expert plan are each derived at most once (lazily)
// and reused by every builder, instead of each builder re-enumerating the
// same partition lattice.
final analysis = _CoachingAnalysis(
controller: controller,
seat: seat,
observation: observation,
);
final insights = <CoachingInsight>[];
_addFinish(controller, seat, observation, analysis, insights);
_addFifty(controller, seat, observation, insights);
_addTakeAndFinish(controller, seat, observation, insights);
_addOpening(controller, seat, observation, analysis, insights);
_addPlayMeld(controller, seat, analysis, insights);
_addPickup(controller, seat, observation, insights);
// Cover advice is driven by the Expert plan (see _addCover). Order matters:
// it runs before _addDiscardSuggestion, which suppresses its floor when the
// plan surfaced a cover instead.
_addCover(controller, seat, analysis, insights);
_addJokerAdvice(controller, seat, insights);
_addStageBanners(controller, seat, observation, insights);
_addDiscardSuggestion(controller, seat, observation, analysis, insights);
_addDrawStock(seat, observation, insights);
insights.sort((left, right) => right.priority.compareTo(left.priority));
return List.unmodifiable(insights);
}04One rulebook for the table and the bots
This is one decision I made early and never regretted. The rules live in pure Dart, in one place. Neither the UI nor the bots can invent a move. They ask the controller what is legal right now, then commit one of those options back through it. The CPU gets a smaller list than the UI because it needs one candidate per useful category, not every legal table arrangement. Keeping that list bounded avoids rerunning the expensive meld search across every possible play on each CPU turn.
/// Returns a bounded legal action surface for CPU turns.
///
/// [legalActionIdsFor] intentionally exposes every legal table play for
/// rules tests and rich UI affordances. CPU turns only need one candidate
/// per high-value category, plus discard fallbacks. Keeping this list small
/// avoids combinatorial meld/opening enumeration blocking the UI isolate.
List<String> cpuActionIdsFor(PlayerSeat seat) {
// ... timing/debug-log setup, a local finish() helper, and a mid-proof
// Fifty short-circuit are elided here.
final plan = _actionSurfacePlanFor(
seat,
ClassicHareegActionSurfacePurpose.cpu,
logSearches: true,
);
var ids = plan.actionIds;
var reason = plan.reason;
// ... strip static mistake-class ids when the strictness rules out CPU
// mistakes.
// A CPU must never be offered a Fifty claim it cannot validly finish. On
// mistake-allowing tiers (Strict/Table) the claim is advertised so a human
// can opt into a paid wrong-claim, but for the CPU it is a guaranteed
// self-penalty — and on Table tier a self-removal. `claim-fifty` is not a
// static mistake-class id (its mistake-ness depends on the hand), so the
// filter above cannot catch it; resolve the actual claim and strip it
// unless it backs a real finish.
if (ids.contains(ClassicHareegActionIds.claimFifty) &&
_fiftyClaimPlanFor(
seat,
purpose: ClassicHareegFiftyClaimPurpose.apply,
).finishPlan ==
null) {
ids = [
for (final id in ids)
if (id != ClassicHareegActionIds.claimFifty) id,
];
reason = '$reason+nofiftymistake';
}
return finish(reason, ids);
}05Dozens of ways to split one hand
The part of Hareeg that looks simple and is not is laying down melds. A single hand can often be split into legal groups dozens of different ways, and both the bots and the coach need the good splits without the search blowing up on a nasty hand. So the partition finder is a lazy generator with a hard safety cap. It yields legal partitions one at a time, stops the moment it runs past its budget, and lets callers take the top slice they can afford instead of asking for all of them.
// ... the public entry partitionsOf(...) is a lazy generator that runs this
// recursive search without materializing all partitions.
Iterable<MeldPartition> _search({
required List<HareegCard> available,
required List<HareegCard> leftovers,
required List<PlacedMeld> melds,
required List<HareegCard> cardsUsed,
required List<JokerMeldAssignment> jokerAssignments,
}) sync* {
_traversed += 1;
if (_traversed >= safetyCap) {
return;
}
if (melds.length >= minMelds) {
final partition = MeldPartition(
melds: melds,
cardsUsed: cardsUsed,
cardsRemaining: [...leftovers, ...available],
jokerAssignments: jokerAssignments,
);
if (_passesFilters(partition) && _seen.add(_partitionKey(partition))) {
yield partition;
if (_traversed >= safetyCap) {
return;
}
}
}
if (available.length < 3 || melds.length >= maxMelds) {
return;
}
final anchor = available.first;
for (final group in _candidateGroupsContainingAnchor(available, anchor)) {
if (_traversed >= safetyCap) {
return;
}
final groupIds = group.map((card) => card.id).toSet();
final remaining = [
for (final card in available)
if (!groupIds.contains(card.id)) card,
];
for (final resolved in _resolvedMelds(group)) {
if (_traversed >= safetyCap) {
return;
}
yield* _search(
available: remaining,
leftovers: leftovers,
melds: [...melds, resolved.meld],
cardsUsed: [...cardsUsed, ...resolved.meld.cards],
jokerAssignments: [...jokerAssignments, ...resolved.jokerAssignments],
);
}
}
if (mustUseCardId == anchor.id) {
return;
}
yield* _search(
available: List.unmodifiable(available.skip(1)),
leftovers: [...leftovers, anchor],
melds: melds,
cardsUsed: cardsUsed,
jokerAssignments: jokerAssignments,
);
}06The part the agents could not do
I leaned on the agents for most of it, down to generating the two main card themes with a model. On this project, development felt a little pay-to-win. Paying for better access bought a lot of speed. The trade was that I had to read what came back, understand why it held together, and build enough checks to catch where it did not.
At first I had no safety net. The loop was slow and demoralizing: let an agent build a feature, install the build on my phone, start a game, and discover the bug halfway through. I stopped shipping that way and rebuilt the workflow around checks. Unit tests cover the rules, integration tests drive whole turns, regression tests pin bugs I have already fixed, and automated review gets another pass at the code before it lands. When a model can produce a feature in minutes, the job moves toward proving that feature under the cases the model did not think about.
The heaviest check is an invariant sweep. It drives full matches with the real CPU strategy across a matrix of seeds and configurations. After every action and round, it checks that cards are neither created nor lost and that scores change only at round boundaries by allowed amounts. Pull requests run a reduced seed set for faster feedback. Pushes to main and the nightly job run the full matrix. Golden replays add a smaller set of curated games recorded action by action, so a behavior change produces a transcript diff I have to inspect. The repository currently has 133 Dart test files, and CI runs flutter test on pushes, pull requests, and the nightly schedule.
My favorite example is a regression test, not a feature. The rules engine offers a "claim Fifty" action whenever the window is open, because a human is allowed to try a wrong claim and eat the penalty. A bot trying the same thing gains nothing and just loops, re-claiming forever. It first surfaced as one CPU racking up 240 points in a single round off a Fifty claim it could never make. The fix was small. The regression file pins the behavior in the Skilled, Expert, and Priority planners.
/// Regression for the "CPU spam-claims Fifty in a loop" bug.
///
/// The rules engine advertises `claim-fifty` whenever the Fifty window is
/// open and points at the seat — even when the seat has no provable finish,
/// because human players still need the affordance to attempt a wrong claim
/// (penalty + lose-turn under Strict / Table). For a CPU the upside of
/// attempting a wrong claim is zero (it eats a penalty and the next loop tick
/// re-advertises the same claim-fifty), so each planner must filter it back
/// out when the seat lacks a finishing partition.
///
/// The bug surfaced as CPU East racking up huge scores (240 in Round 1) from
/// looping a wrong Fifty claim. This file pins the filter into Skilled,
/// Expert, and Priority planners.
void main() {
group('CPU Fifty filter — claim-fifty without finishing partition', () {
test(
'SkilledCpuMovePlanner.plan does not return claim-fifty without proof',
() {
const planner = SkilledCpuMovePlanner();
final plan = planner.plan(
_FakeCpuObservation(
legalActionIds: const [
ClassicHareegActionIds.claimFifty,
ClassicHareegActionIds.drawStock,
],
ownIsFiftyClaimant: true,
topDiscard: _card(CardRank.nine, CardSuit.clubs),
turnPhase: TurnPhase.draw,
),
);
expect(
plan.actionId,
isNot(ClassicHareegActionIds.claimFifty),
reason:
'BUG: Skilled must drop claim-fifty when no finishing '
'partition exists. Without this filter the CPU loops a wrong '
'claim and inflates the score.',
);
},
);
// ...the identical assertion is pinned for Expert and Priority planners,
// plus a positive case proving all three KEEP claim-fifty when the finish
// is provable (and the same filter holds at the chooseMove boundary).
});
}07The one thing it exports
A player can export an in-progress report from the pause menu or a completed report from the match-over screen. The app builds versioned JSON with a diagnostic buffer capped at 200 events and a replayable action transcript, enough that I can rerun the match on my machine. The current report model leaves out preferences, locale, player names, and other user-entered data. Nothing leaves the app until the player chooses the share sheet, a file download on the web, or copy.
Hareeg Table is offline, ad-free, and private, built in Flutter with the game logic in pure Dart, and the one codebase ships to Android, the web, and desktop. You can play it in the browser right now. It is around 87,000 lines across the app and its tests, and it is the version of that annoying app I wish someone had built for me. It also left me with the clearest lesson of the AI era so far. The speed is real, but keeping it honest costs you just as much discipline as the speed saves you.