All projects

KotchAIna

I built a Basra engine that finds every legal capture, estimates the opponent's hidden hand, solves the final four cards exactly, and shows its reasoning in a React dashboard.

Context
Personal project, Egyptian Basra card game
Role
Design and implementation: engine, AI, server and interfaces
Stack
Python, Pygame, FastAPI, React 19, TypeScript, pytest
Year
2025 to 2026
Outcome
302 passing tests; playable on the desktop and in the browser
Diagram of a 7 of diamonds played on a floor of 9, 3 and 6. The 7 of diamonds acts as a 9, so it captures the 9 and the 3 plus 6 group, clears the floor and scores a Basra.

An engine that plays Basra and explains its moves.

KotchAIna plays Egyptian Basra against a person. It lists every legal capture, scores the options, guesses which cards the opponent holds, and solves the last four cards exactly. A React dashboard shows the AI's reasoning move by move.

Basra is a fishing card game. You capture floor cards that match your card or add up to its value, and clearing the floor scores a bonus. A numeric card can take one match, several cards that sum to it, or both at once, so the rules are harder to code than they look. The hidden hand makes good play harder still.

I wrote the rules engine, the game state, the opponent model, the move scorer, the endgame solver, a Pygame client, a FastAPI server and the React dashboard: about 7,700 lines of Python, including the Pygame client, and 1,500 lines of TypeScript. 302 tests pass in 18 seconds.

The project in 36 seconds, using the engine's own cards and the worked examples on this page.

Five stages, each handing its numbers on.

The rules produce legal moves. The tracker works out which cards could still be hidden, and the opponent model weights them. The scorer ranks each legal move. When the deck runs out in Hard mode, an exact solver takes over from the scorer.

Every stage passes its numbers to the telemetry layer, so the interface can show them. The Pygame client calls the engine directly; the browser client reaches it through a FastAPI server.

KotchAIna: from rules to dashboard Inside the Python engine, the rules produce legal moves, the tracker works out which cards are unseen, the opponent model weights those cards, and the move scorer ranks each move. When the deck is empty in Hard mode, the endgame solver replaces the scorer, using the unseen cards as the opponent's hand. The telemetry stage receives the numbers and a FastAPI server passes them to the React dashboard. The Pygame client calls the engine directly. From rules to dashboard Each stage passes its numbers on, so the interface can show them. Python engine Rules Legal captures, Basra check Tracker Seen cards, unseen pool Opponent model Weights over the unseen cards Move scorer Points, Basra bonus, discard risk Telemetry Rationale, win bar, report Endgame solver Deck empty, Hard mode only. Replaces the move scorer. unseen cards = opponent's hand FastAPI React dashboard Browser, React 19 and TypeScript direct calls Pygame client Desktop window
The engine's stages and the two clients. The endgame solver is the only stage that replaces another one.

Capture rules are a search problem.

I defined the game state as a partition of the 52 cards: each card is in a hand, on the floor, in the deck or in a captured pile. Defined that way, bugs are easy to see, because a card in two places means something is wrong. The architecture document states this as an invariant; I have not added a runtime check for it.

For a numeric card, the capture routine finds exact rank matches and every subset of floor cards whose values add up to the card's value, then combines groups that share no card. Jacks and the 7♦ take the whole floor. Kings and queens match only their own rank. The search is brute force and exponential in the size of the floor, which is fine for the small floors real games produce.

When several captures are legal, the engine takes the one with the most cards, then the most points.

The 7♦ rule was the hardest to get right. The 7♦ clears the floor, and it scores a Basra if the floor adds up to 10 or less with no king or queen. The extra rule is that it can act as any value from 1 to 10. On a floor of 9, 3 and 6, the 7♦ acts as a 9: it takes the 9 and the 3 + 6 pair, so the floor is cleared. That is the case drawn at the top of this page. The code tries each value from 1 to 10 and checks whether one of them can capture every card on the floor. A king or queen on the floor blocks it.

Scoring a move with a weighted sum.

Each legal move gets a score: the point value of what it captures (the 10♦ is 3, the 2♣ is 2, aces and jacks are 1), plus half a point per captured card, plus a bonus for a Basra. Discards are penalised, heavily so for jacks, the 7♦ and scoring cards. Because the score is a plain sum, each part can be reported back to the player.

The Basra bonus depends on the score. It is 15 normally, 50 when the AI is 20 or more points behind, and 5 when it is 30 or more ahead. The AI chases a Basra harder when it trails. It also subtracts an estimate of what the opponent could capture from the floor a move leaves behind.

Three difficulty levels share the scorer. Hard always plays the best-scoring move. Medium plays it four times in five and otherwise picks at random among the top three. Easy plays at random half the time and otherwise picks from the better half. These are settings in the AI's move choice; I have not measured them against human players.

Guessing the opponent's hand with a heuristic.

The AI cannot see the opponent's hand, but it can see every card that has been played. The cards it cannot place, the opponent's hand plus the deck, start with equal weight.

Each time the opponent plays, the AI asks of every unseen card: would playing that card have scored better than what they actually played? If so, the opponent probably does not hold it, and its weight is multiplied by e−0.2 × d, where d is how much better the alternative would have scored. The played and captured cards drop to zero, a weight that falls below 0.01 is set to zero, and the weights are renormalised.

The weights rank plausibility. They are not calibrated probabilities, and I have not tested them against recorded games. The scorer uses them to estimate what the opponent could capture.

To see it work, I ran the model on the opening floor of the screenshots below (5♠, 6♠, 10♣, K♠) with an arbitrary hand for the AI. The opponent plays 6♥ and takes the 6♠. The weights of the 44 unseen cards move like this:

Each of the four jacks
2.3% before, 1.5% after. A jack would have taken the whole floor for more points than the 6 did.
The 7♦
2.3% before, 1.8% after.
An ordinary card, such as the 6♣
2.3% before, 2.4% after, from renormalising.
The 6♥
2.3% before, 0% after, because it was just played.

The last four cards are solved exactly.

When the deck is empty, every card the AI cannot see is in the opponent's hand. The AI works the hand out by elimination, and from then on the game has no hidden information. Each player holds four cards, so there are at most 4! × 4! = 576 orders of play. The solver runs minimax over them, caches repeated positions, and awards the leftover floor to whoever captured last.

It runs in Hard mode only. If its card bookkeeping disagrees with the real hand sizes, it stops and the normal scorer plays instead. Inside the solver, each card uses the engine's best capture, so it searches over which card to play, not over which capture group to take.

What the player sees.

The browser client talks to a FastAPI server and refreshes after each move. A side panel shows the AI's one-line rationale, a win-probability bar, how certain the AI is about the opponent's hand, a deck-potency figure, and which jacks and 7♦ are still unseen. Selecting a card highlights what it would capture.

When a match ends, the server can write an HTML report with score swings, Basra events and the turns where the AI was most surprised by the human's move.

The win probability is a logistic curve of the score gap, sharper late in the deal. It is a display aid, not a trained model.

Limits and what I would change.

  • The opponent model is a heuristic with no calibration test.
  • The difficulty levels are not measured against people. I have no win-rate data and no comparison with other agents.
  • The capture search is brute force. A floor of 20 or more cards would need caching.
  • The scorer's weights are tuned by hand. Learning them from self-play is the next step.
  • A stricter kind of inference, such as "this player cannot hold a jack", is written and documented but not connected to the live model.