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 actually 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, ad-free, and it never sends anything but a bug report, and only if you tap the button or hit a crash.
02A card game with nobody else in the room
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 actually 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]. The pipeline owns the whole
/// turn — the policy only supplies the per-stage scoring for each branch below.
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 verbatim.
final fifty = firstActionOfKind(actions, ClassicHareegActionKind.claimFifty);
if (fifty != null && policy.shouldClaimFifty(observation)) {
return ClassicHareegCpuMovePlan(
scenario: ClassicHareegCpuMoveScenario.fiftyClaim,
actionId: fifty.actionId,
);
}
// ...draw phase: take the top discard or draw stock (pending-discard handled above).
// 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 —
// 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.
}
// ...cover walk, then the threat-ranked safe discard.
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 actually 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;
}
// Worth the gamble only when the -3 buys me breathing room...
if (observation.ownScore >= _highRiskScoreFloor) {
return true;
}
// ...or the +3 can actually land on a high-scoring, punishable seat.
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 really helped was me. It gave me a window into what the bots were thinking, and that is how I actually 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 is the exact reasoning the hardest opponent is acting 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].
///
/// A pure function of the passed-in state: no mutation, no I/O, no time. It
/// reuses the same analysis brain the Expert CPU uses ([CpuObservation] /
/// [MeldPartitionEnumerator] / cover rules) to classify the human's situation.
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);
// ...take-and-finish, opening, play-meld, pickup builders, in priority order.
// Cover advice runs before _addDiscardSuggestion, which suppresses its floor
// when the Expert 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);
}04A rulebook nothing can cheat
All of this leans on one decision I made early and never regretted: the rules live in pure Dart, in one place, and nothing reaches around them. The UI and the bots cannot invent a move. They can only ask the controller what is legal right now and then commit one of those options back through it. The CPU even gets a deliberately smaller menu than the UI, because handing a bot every legal table play would make it re-run the heavy meld search on every turn and stall the screen.
/// A bounded legal surface for CPU turns. [legalActionIdsFor] exposes every
/// legal table play for the UI; CPU turns only need one candidate per
/// high-value category plus discard fallbacks, so the heavy meld/opening
/// enumeration never blocks the UI isolate.
List<String> cpuActionIdsFor(PlayerSeat seat) {
// ...an active Fifty claim replays the engine's validated finish plan step
// by step, so a CPU claim plays its proof out visibly at normal pacing.
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 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's mistake-ness depends on the hand,
// so the static filter above can't catch it — resolve the actual claim and
// strip it unless it backs a real finishing partition.
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';
}
// finish() logs the timed search, then returns List.unmodifiable(ids).
return finish(reason, ids);
}05The genuinely hard algorithm
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.
// Public entry: a lazy generator that yields legal partitions one at a time.
// static Iterable<MeldPartition> partitionsOf(List<HareegCard> hand, {...})
// The recursive core. Every recursion node bumps [_traversed]; the moment it
// reaches [safetyCap] the whole search unwinds, so a pathological hand stops
// the walk instead of exploding (callers take the top slice they can afford).
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;
}
// A node with enough melds is itself a legal partition — yield it (deduped).
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 (available.length < 3 || melds.length >= maxMelds) {
return;
}
// Anchor-based branching: either build a meld CONTAINING the first card...
final anchor = available.first;
for (final group in _candidateGroupsContainingAnchor(available, anchor)) {
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)) {
yield* _search(
available: remaining,
melds: [...melds, resolved.meld],
cardsUsed: [...cardsUsed, ...resolved.meld.cards],
// ...carry leftovers + joker assignments forward.
);
}
}
// ...or skip the anchor into leftovers (unless it is the mustUse card).
if (mustUseCardId == anchor.id) {
return;
}
yield* _search(
available: List.unmodifiable(available.skip(1)),
leftovers: [...leftovers, anchor],
melds: melds,
cardsUsed: cardsUsed,
jokerAssignments: jokerAssignments,
);
}06I built it mostly with AI, and the guardrails were the job
I want to be honest about how this got made, because it is the most interesting thing about it. I leaned on AI agents far harder here than on anything else I have built. I even generated the two main card themes with a model. Being a developer has quietly gone pay-to-win. You can still ship without AI, it will just cost you so much more time and effort that the smart move is to use it, and use it well. Mostly AI-generated does not mean I shipped code I do not understand, though. I read it, work out why it holds together, and fold whatever is new to me into what I reach for on the next project.
The first stretch taught me the real lesson the hard way. I had no safety net, so the loop went like this: let an agent build a feature, ship a build, install it on my phone, start a game, and only then watch a bug surface mid-play. It was slow and demoralizing. So I stopped and rebuilt my whole setup around guardrails. Unit tests for the rules, integration tests for whole turns, regression tests that pin every bug I have already killed, and automated AI review on top to catch what the agent missed while it was writing the feature. When a model can hand you a feature in minutes, the craft is no longer in typing the code. It is in the nets you build so the speed does not quietly ship you bugs.
The nets are most of the work, so here is what they actually are. The heaviest one is an invariant sweep. It drives full matches with the real CPU strategy across a matrix of seeds and configs, and after every action and every round it asserts the things that must always hold: cards never get created or destroyed, scores only move at round boundaries, and only by the exact amounts the rules allow. The whole seed matrix is slow, so a pull request runs a reduced set for fast feedback, while every push to main plus a nightly run sets an environment flag and grinds the full matrix. On top of that sit golden replays, a handful of curated games recorded action by action. If a change quietly alters how a match plays out, the transcript diffs and I have to look at it on purpose. There are around 133 test files now, run on every push through CI.
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 actually make. The fix was small. The test that makes sure it can never come back, across every CPU planner, is the part I would show you.
/// 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 with no provable finish, because humans
/// still need the affordance to attempt a wrong claim. For a CPU the upside is
/// zero, so each planner must filter it back out without a finishing partition.
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).
});
}07Where it stands
The one thing it does send is the bug report, and I built that so I would never have to feel weird about it. A report is versioned JSON: a capped buffer of roughly the last 200 diagnostic events plus a replayable transcript of the match's actions, enough that I can re-run your game on my machine and watch the same bug happen. It deliberately leaves out your preferences, your language, and the player names. So "it never sends anything but a bug report" is not a promise I am asking you to take on faith. The report format simply cannot carry anything else.
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.