Skip to content
Work

Dominican dominoes · iPhone, web, iMessage

App Store v1.1.0 Live playcapi.com

Capi

Play 1v1 or 2v2 with friends, live on iPhone and the web or turn by turn in iMessage. No account needed.

A 1v1 Capi game on iPhone during a round, with both scores at the top.
iPhone app
Role
Solo: design, code, release
Timeline
Feb 2026 to now
Stack
TypeScript, Next.js, React Native, Expo, Swift, Supabase, Postgres, Vitest

On the App Store, version 1.1.0. Live at playcapi.com. Public code on GitHub. The iMessage extension comes with the iPhone app. Status checked Oct 22, 2026.

By the numbers

~1,240

lines in the engine, with 0 runtime dependencies

960

seeded endgames checked for legal moves and completion

8

screen widths, from 320 to 1280 px, with no tile overlap after any move

Fig. 1 The path of one move
  • Client web, iPhone or iMessage
  • API Next.js route
  • Postgres Supabase
  • Realtime Supabase
  1. Client to API 01 POST /api/games/[id]/move The move and the state_version last read by the client.
  2. API to Postgres 02 Reads the table
  3. API to Client 03 409 if state_version changed The client fetches the latest state.
  4. API 04 applyMove Uses the same rules engine as the web and iPhone apps: about 1,240 lines and 0 runtime dependencies.
  5. API to Postgres 05 Writes if state_version is unchanged Only the API can write: no anonymous insert or update policies.
  6. Postgres to Realtime 06 postgres_changes The updated row.
  7. Realtime to Client 07 Sends the new state to every player
  8. Client 08 Resync If no updates arrive, the client fetches the table state after 10 seconds, then every 10 seconds for up to 30 minutes.

The idea

Capi brings Dominican dominoes online. Pick a name, share a code or link, and play 1v1 or 2v2 with your people. No account needed.

Version 1.1 added iMessage games, with invitations and turns sent as chat bubbles.

What I built

I wrote the rules in one TypeScript package: about 1,240 lines with no runtime dependencies. The web app, server API and iPhone app all import it, keeping capicúa, tranque, scoring and board layout consistent. Production deals use crypto.getRandomValues; tests can replay any game from a seed.

The web app uses Next.js and Supabase. Postgres stores game state, and only the API can write to it after a migration removed all anonymous insert and update policies. Supabase Realtime delivers moves; inactive tables also refresh every 10 seconds. The iPhone app uses Expo and React Native with the same API. A Swift iMessage extension creates chat bubbles and displays the web game inside Messages.

The hard parts

  1. 01 Validating every move

    The API validates each move through the engine and saves it only if state_version still matches what the client read. A move based on an older state gets HTTP 409, preventing simultaneous moves from corrupting the table.

    Where in the code: apps/web/src/app/api/games/[id]/move/route.ts (opens in a new tab)

  2. 02 Recovering a stalled table

    In September, a player reported a stalled game caused by a missed realtime update. I added a state refresh after 10 seconds of inactivity, repeated every 10 seconds for up to 30 minutes. The client shares the fetched state through the table’s realtime chat channel, so players still on version 1.0 catch up too. A new test suite plays 960 seeded games and checks that every table has a legal action and finishes.

    Where in the code: apps/web/src/hooks/useRealtimeGame.ts (opens in a new tab)

  3. 03 One board on every screen

    The domino chain turns at the table edges and has to fit screens from 320 to 1280 px. A property test plays 200 games through the real reducer. After every move, it checks for overlapping tiles at 8 widths and 2 layout tiers.

    Where in the code: packages/engine/__tests__/boardLayout.overlap.test.ts (opens in a new tab)

  4. 04 The same table inside Messages

    The native Swift extension handles chat bubbles, player identity and seating. It displays playcapi.com in a web view and uses the same engine and API as the app and web, without duplicating the rules. A script message handler reports moves, round endings and game endings to the extension, which turns them into bubbles. Each game uses one Messages session, so new bubbles replace the previous one.

    Where in the code: apps/mobile/targets/messages/MessagesViewController.swift (opens in a new tab)

Before and after

BeforeAfterSource
Recovery from a missed realtime updateA missed update could leave a table where nobody could move.A 10-second resync, a rebroadcast through the table chat and 960 seeded endgames checked in tests.Commit f5cadf0 and the stall-fix commits of September 30, 2026

What’s next

Next release: show your tile designs to other players and preview designs in full before buying. In iMessage, choose a table and a 100- or 200-point game, with two fixes for joining games.

A 1v1 Capi game in Messages on iPhone, with New game and Open in Capi buttons above the table.
iMessage
A 2v2 Capi game on the Colmado table.
iPhone app
Capi new-game form on iPhone, with options for name, color, table, mode and winning score.
iPhone app

Try it

Shared approaches