A mahjong game where you play one hand and it stops is a demo. Nobody sits down at a table for one hand. The whole game only starts to mean something once there is a round, a dealer who has to give up the seat eventually, and something real on the table when a hand ends.
What It Is
Hong Kong (Cantonese) mahjong, single-player against three AI. Four sets and a pair to go out, and the three-faan minimum is not a house nicety, the engine refuses to let you declare a hand under three faan. That single rule is what makes it mahjong instead of tile bingo: both you and the AI have to plan toward a hand that is actually worth something, not race to the first complete one.
No flowers, no seasons, no charleston, no jokers. That is a real simplification and I made it on purpose. Flowers add a whole scoring and replacement subsystem for very little of what makes the game interesting.
Coach Mode, Discard Advice & River Reading
To help players learn Hong Kong mahjong strategy in real time:
- Recommended Discards (
💡 Rec): The coach tags your optimal discard directly on the tile face, explaining whether it opens Tenpai, trims an off-suit for a flush, or safely folds away from a hot hand. - End-Game Suit Saturation: When multiple opponents start dumping the same off-suit tiles late in the round, the engine warns you to hold that suit rather than throw into a multi-seat trap.
- Interactive River Reading: Tapping an opponent’s discard river opens an educational breakdown of their discard rhythm.
🔄 New GameRestart: A one-tap button in the top header lets you immediately re-roll seating and winds whenever you want a quick standalone game.
A Round, Not A Hand
The deal makes one full trip around all four seats, so everyone deals at least once. A dealer who wins or the wall runs dry gets to keep the seat for exactly one repeat, then passes regardless of what happens next. A round is 4 to 8 hands, which means you can know the ceiling before you sit down.
I did not build unlimited repeats, on purpose. A dealer on a hot streak with no cap has no upper bound on how long a sitting takes, and this is supposed to be a daily, not an afternoon.
Coins You Can Actually Lose
Every other game on the hub pays coins when you win and nothing when you do not. Mahjong is the first one where you can genuinely lose coins to an opponent, hand by hand.
The payout table is the standard Hong Kong schedule divided by eight, landing on whole numbers, so it is the same table at a different unit rather than one I tuned by feel. A discard win costs the discarder the whole hand. A self-draw costs all three of you, so the winner takes three times the value. The first round each day is free. Every round after that costs a flat fee, and I wrote down in the project’s own docs that the price is a guess I expect to change once real people have played it, not a number I am attached to.
The mechanically annoying part: every other game earns coins through one shared function that every game in the hub calls. Mahjong needed to opt out of that function entirely, since it settles its own coins at the table, and a game earning through both paths would get paid twice. That function is read by every single game on the hub, which made a two-line change the highest-risk edit in the whole build. I moved it slowly and made sure nothing else changed behavior.
A Feature I Built And Then Forgot To Turn On
Self-declared kongs, the play where you can add a fourth tile to a set already on the table and reveal a hidden set of four, along with the window where an opponent can steal that exposed kong out from under you: I built the whole rules layer for this early, tested it thoroughly, and then did not call it from anywhere for an entire session. It sat there, correct and completely inert, until the round-and-coins work finally wired it in.
A promoted kong opens the steal window. A concealed one does not, because those tiles were never claimed from anyone in the first place, there is nothing to take. Neither a steal nor a kong replacement adds any value to a hand: a stolen win still has to clear three faan on its own or it gets refused.
The Review Caught What I Could Not See From Inside Any One Task
I built this in small pieces, and reviewed every piece as I finished it. That caught most things. It did not catch everything.
One bug only showed up when I stepped back and read the whole branch at once, after every individual piece had already passed its own review. The seed for each hand’s tile wall lived in a counter that reset every time the page reloaded. The round it was supposed to track lived somewhere that survives a reload: a saved game and a daily record. Resume a round after the first hand and it would redeal a wall you had already played. Pay for a second round after reloading and it would replay the first round’s free hands, tile for tile, and charge you for the privilege.
Neither half of that was visible from inside the task that built it. One task built the counter. A different task, days later, built the thing that outlived a reload. Only reading both at once, after both were finished, showed the contradiction.
I also caught a soft-lock before it ever reached a real player: closing the tab while a finished hand’s result was still on screen saved a version of the game with no way to move forward, because the save had no record of what had just happened. The fix was to simply never save that one moment, so reopening the game always lands you back on the last hand that was actually still in play.
The smaller catches were about tests that looked like they were checking something and were not. One assertion about which pile a replacement tile came from could pass no matter which pile it actually came from, because both piles happened to end up the same length either way. Another, meant to prove the dealer seat actually rotates, would have passed just as happily against code where it never moved at all, because the specific hand I tested it against never gave it a reason to.
What I Learned
A test suite tells you the pieces work. It does not tell you the whole thing works, because the whole thing is not a piece, it is the relationship between pieces, and no single piece’s test was ever going to see that relationship. The bug that mattered most here was not caught by writing more tests. It was caught by reading the finished branch as one document instead of as thirteen separate ones.
Not Included
No four-wind game. A daily round stays on one prevailing wind the whole way through, so that never rotates. No decision yet on whether an all-same-suit hand with sets already showing should score more than its flush value alone, I left that one open rather than guessing. And the one payment safeguard that clamps a loss at zero coins does not currently say so on screen, so a wiped-out loss and a merely large one look identical to the player right now.