Dammee: Play Damii (Ghanaian Draughts) Online
Dammee is a web platform for playing Damii (Ghanaian Draughts) live online. It enforces the Ghanaian rules on the server and adds ratings, bots, puzzles and clubs for players in Ghana and abroad.
What it does
Dammee (dammee.com) is a website for playing Damii, the Ghanaian version of draughts, against other people in real time. Damii isn't international draughts with a new skin. It uses a mirrored 10×10 board, 20 pieces per side, compulsory capture, backward capture by men, flying kings, and the freedom to pick any valid capture sequence instead of being forced into the longest one. The server checks every move against these rules, so the rules are the main thing that sets it apart.
It's built mainly for Ghanaian Damii players on phones, and also for the diaspora, students learning the game, and people who organise clubs and tournaments. Players can:
- Find a match in rated or casual queues, with bullet, blitz, rapid and classical time controls.
- Play three bot levels (Kooko, Yuyu and Yenu), as a guest or signed in. The tiers form a ladder, and Yenu is the strongest.
- Watch live games and replay finished ones.
- Learn from lessons, a puzzle trainer and rules and strategy guides.
- Climb leaderboards and league cycles.
- Join clubs, challenge friends by link, email or WhatsApp, and enter tournaments.
I haven't had the rated ruleset checked by Ghanaian referees or the federation yet, so I don't describe it as officially endorsed.
How I built it
Frontend: Angular 21 with Tailwind CSS 4, using standalone, lazy-loaded routes. It runs with Angular SSR on an Express server. Public pages (guides, lessons, clubs, tournaments) are prerendered or server-rendered so search engines get real HTML. Live-game and account pages are client-only and marked noindex. It's an installable PWA with a service worker and web manifest, and it uses self-hosted fonts. The SignalR client lives in a small framework-free TypeScript package (@damii/core), shared with a separate native client project in the same repo.
Backend: ASP.NET Core on .NET 10, with REST endpoints and SignalR for live play. I used the ABP framework for identity, permissions, settings, audit logging and the admin screens, and wrote the Damii-specific code on top of it. Game state runs in Akka.NET actors (game session, clock, matchmaking, presence, bots, tournaments, persistence), and the server alone decides moves, clocks, results and rating changes. Ratings use Glicko-2.
The rules engine and the bots: I wrote the Damii rules engine and move generator myself, including flying-king and multi-step capture generation, with a square map that matches the client's numbering. The bots search the game tree with minimax in negamax form, pruned with alpha-beta. There are two separate search engines, so that changing one bot's strength can't change another's.
- Classic search (Kooko and Yuyu). Alpha-beta with iterative deepening, a transposition table keyed by Zobrist hashes, killer moves, and putting the best move from the previous pass first. A quiescence search keeps playing out capture sequences at the leaves, so the bot doesn't misjudge a position in the middle of a trade (the horizon effect). Their difficulty comes from tuned depth and time limits, plus deliberate imperfection: evaluation noise, a chance of mistakes, a chance of missing captures, and a "play to lose" mode for brand-new players.
- Elite search (Yenu). A separate engine, switched on by a flag in the bot profile and used only by the top tier. It works on a compact array-based board with make/unmake moves, which turned out to be the biggest speed gain, because the classic path allocates a fresh immutable board at every node. On top of that it uses:
- principal variation search (null-window scouting);
- late move reductions, with a full re-search if a reduced move turns out to be good;
- a transposition table of entries that hold no object references;
- killer moves and a history table for ordering quiet moves;
- aspiration windows between deepening passes;
- a quiescence search that is never allowed to "stand pat" while a capture is available, because captures are compulsory in Damii;
- extensions for forced single replies;
- repetition detection inside the search;
- soft time management that only starts a new depth if it can plausibly finish.
I deliberately left out null-move pruning. Passing isn't legal in Damii and zugzwang-like endgames are normal, so it would prune winning lines.
- Keeping the two engines honest. Tests check that the fast evaluator gives exactly the same scores as the readable reference evaluator, that the classic search is unchanged by the speed-up work, and that Elite beats the old hard search over a series of games (it must take at least 60% of the points and win more games than it loses).
Data and infrastructure:
- PostgreSQL 17 with EF Core migrations.
- Redis for caching and the SignalR backplane.
- Hangfire for background jobs such as streak reminders, weekly summaries and league cycles.
- Serilog for logging.
- Rate limiting on login.
Hosting and CI: everything runs in Docker Compose on a single Ubuntu server behind Nginx. A GitHub Actions workflow on a self-hosted runner runs on every push to main. It runs the tests, builds the images, runs the database migrations, restarts the containers and waits for health checks.
Testing:
- About 300 backend xUnit tests, mostly on the engine, bots, move handling, ratings and seeded puzzles and lessons.
- 22 frontend unit spec files and 5 Playwright end-to-end spec files.
- 6 test files in the shared core package.
CI only gates deploys on the engine and invariant tests plus the Angular production build. The database-backed integration tests and the browser tests don't run there yet.
SEO and analytics: canonical tags, JSON-LD, a sitemap, robots.txt, llms.txt, and GA4 and Tag Manager loaded when the browser is idle.
The app is English only. There's no i18n and no error tracking yet.
What I learned
- A timeout is not a rejection. When the server took too long to answer a move, I sent the client "move rejected", and the client rolled back moves the server had actually accepted. I gave moves a client-generated ID so repeats get the original result, added a separate timeout result, and made the client check the server's game state instead of guessing.
- Order of checks matters for retries. The duplicate-move check has to run before the "is it your turn?" check, because a retry always arrives after the game has moved on. I also had to stop the replayed result from being broadcast to the opponent a second time, or the move gets applied twice.
- Equal-looking values can be unequal.
GameMoveis a C# record, but its immutable arrays compare by reference. The engine's move ordering silently did nothing for weeks. There was no error and no wrong answer, just no speed-up. A test that fails while printing identical expected and actual values is the sign of this. - Search speed is mostly data structures. Adding pruning tricks mattered less than moving the elite search off an immutable, dictionary-backed board onto arrays with make/unmake. Even for the existing search, the cheapest win was generating the legal-move list once per node instead of two or three times, without changing which move it picks.
- Chess engine tricks don't all carry over. Damii's compulsory captures and illegal passing meant the usual quiescence "stand pat" shortcut and null-move pruning would give wrong answers. I rewrote the first and left the second out.
- Bots need tests too. The "hard" bot played like the beginner bot because the onboarding rule and the difficulty tier were decided in two different places. I fixed it by resolving both together in one function, and added a test that guards the invariant.
- A CI allow-list can be better than a broad filter. Running every Damii test in a bare container failed on tests that need a seeded database, which blocked every deploy. So CI now runs a named list of fast, deterministic suites and leaves the database-backed ones out until they can run against a real database.
- Accessibility gaps stay hidden until you audit. An audit found no
prefers-reduced-motionhandling anywhere and a one-tap, irreversible Resign button. I added app-wide reduced-motion, transparency and contrast support, and a confirmation step on resign.