- Abstract
- Core Philosophy
- Architecture
- Key Engineering Solutions
- Game Mechanics
- Business Logic
- Tech Stack
- Installation
- Database
- Environment Variables
Mahjong Flow is a 3D Mahjong Solitaire implementation built as a cognitive focus tool. The board engine operates on a coordinate-based tile graph; freedom is evaluated geometrically per tile in O(n) time. A FastAPI microservice runs a heuristic dead-end probability model on every board state and returns ranked advice. Supabase provides persistent auth, a global leaderboard, and a quest-progress store with atomic increment via PL/pgSQL.
A 144-tile Turtle layout is a bounded, deterministic problem space. Every move has a traceable consequence; there is no randomness after the deal. The brain operating on a problem with this property enters narrow-focus — the same cognitive state as deep work.
The product is positioned as a structured alternative to passive consumption, not as entertainment. The design principle that follows: a session should end with the user feeling clearheaded, not depleted. There are no push notifications, no infinite scroll, no variable reward loops. The timer exists to measure the user, not pressure them.
Browser (Next.js 16 App Router)
│
├── Zustand store tile graph, game mode, sprint, skin, history
├── React components board, modals, sidebar panels, FABs
├── @supabase/ssr client auth session, leaderboard upsert, quest RPC
│
├── /auth/callback route handler: exchanges OAuth code for session cookie
└── middleware.ts refreshes Supabase session cookie on every request
Supabase (PostgreSQL + PostgREST + Auth)
│
├── auth.users managed by Supabase Auth
├── public.users synced via handle_new_user() trigger on INSERT
├── public.leaderboard personal-best upsert (user_id, date, type)
└── public.quests_progress atomic increment via increment_quest_progress() RPC
FastAPI (Python 3.11+)
│
├── BoardAnalyzer mirrors isTileFree() logic from the TypeScript store
├── HeuristicPredictor weighted sigmoid over 8 board features
└── /api/analyze-board stateless: client sends full tile array on every call
Auth flow:
signInWithOAuth / signInWithPassword
→ Supabase redirects to /auth/callback
→ route.ts: exchangeCodeForSession()
→ session cookie set
→ middleware.ts refreshes cookie on every subsequent request
→ useAuthUser(): onAuthStateChange mirrors live session state
Tile freedom is computed geometrically without a graph traversal. A tile at (x, y, z) is free when:
- No tile occupies
z+1within±1unit on both x and y axes (nothing above). - At least one of
(x-2, y, z)or(x+2, y, z)is unoccupied (one lateral side open).
This check runs in O(n) per tile against the active tile list. The Python microservice mirrors the identical predicate so board analysis and browser state are always in agreement.
HeuristicPredictor computes a weighted linear combination of eight features extracted from the board state, then passes the result through a sigmoid:
| Feature | Weight | Rationale |
|---|---|---|
1 - mobility |
2.5 | Low move variety is the strongest leading indicator of a dead end |
isolation_ratio |
2.0 | Types with one free tile and a buried partner approach irreversibility |
1 - free_ratio |
1.5 | Dense coverage reduces future options nonlinearly |
mean_burial_depth |
0.8 | Lagging indicator of overall board openness |
| Zero pairs (hard penalty) | 3.0 | Board is already stuck |
The architecture uses a Predictor ABC. Swapping in a trained CatBoost model requires implementing one method (predict(features) -> float) and no changes to the endpoint.
The client previously read current_value, incremented it, then wrote back — a classic TOCTOU race when two game events fire within the same request cycle. The replacement is a server-side function:
create or replace function public.increment_quest_progress(
u_id uuid, q_id text, inc_val int
)
returns table (current_value int, is_completed boolean)
language plpgsql security definer as $$
declare
_row public.quests_progress%rowtype;
_new int;
begin
select * into _row from public.quests_progress
where user_id = u_id and quest_id = q_id
for update; -- row-level lock for duration of txn
if not found or _row.is_completed then return; end if;
_new := least(_row.current_value + inc_val, _row.target_value);
update public.quests_progress
set current_value = _new, is_completed = (_new >= _row.target_value)
where user_id = u_id and quest_id = q_id;
return query select _new, (_new >= _row.target_value);
end; $$;The browser calls supabase.rpc('increment_quest_progress', { ... }) — one network round-trip, no race condition possible.
A 180-second countdown overlaid on a standard Practice board. Score formula: pairs_cleared × 100 + remaining_seconds × 10. The timer indicator turns red below 30 seconds. When the clock reaches zero the board is locked and a ResultModal is shown with status: 'fail', rendering a "Time's up." headline rather than the win screen. Sprint mode is Pro-only.
Every successful pair match appends a pre-match snapshot of the full tile array to a history: Tile[][] ring buffer capped at 20 entries (Zustand store). undoMove() pops the last snapshot and replaces the current tile state, restoring both tiles to 'idle'. No network call required. Undo is Pro-only and clears the hint state to prevent stale highlights.
When no free pairs remain (detected by hasAvailableMoves()), the board enters a deadlock state and the shuffle button pulses. reshuffleRemaining() runs the same two-phase simulation used by buildSolvableLayout — but scoped only to the remaining idle tiles and using a fresh entropy source (Date.now() ^ Math.random()). If the simulation stalls before covering all remaining tiles, it falls back to a pure type-shuffle so the player always gets a playable board.
MahjongBoard uses a ResizeObserver attached to its flex container to compute the pixel dimensions of the full 3D canvas (canvasW × canvasH, derived from the maximum x, y, z coordinates across all tile positions). A CSS scale() transform is applied to an inner wrapper using transformOrigin: 'top left', with the outer container sized to canvasW × scale so the flex centering tracks correctly. Scale is clamped to [0.4, 1.0] — never larger than native size, never small enough to become unplayable.
The freemium model is UI-driven: the upgrade sells a measurably better cognitive experience rather than locked features.
| Tier | Skin | Symbol system | Cognitive load |
|---|---|---|---|
| Free | Classic | Unicode Mahjong glyphs (🀙 🀐 🀇) | Higher — character recognition required |
| Pro | Elemental | Lucide icon system (Earth / Water / Fire / Air) | Lower — abstract shapes reduce visual parsing time |
The free tier is fully playable. Pro additionally unlocks Undo, Sprint mode, and the AI Coach panel. The AI Coach is gated at the UI level: the sidebar panel renders only when isPro === true; the FAB offers an upgrade prompt otherwise.
Quest progress and leaderboard participation require a free account (email/password or Google OAuth). Anonymous play stores state in localStorage only.
| Layer | Technology |
|---|---|
| Framework | Next.js 16.2 + React 19 (App Router) |
| Styling | Tailwind CSS v4, glassmorphism surfaces |
| Animation | Framer Motion 12 |
| State | Zustand 5 |
| Icons | lucide-react |
| Auth | Supabase Auth (email/password + Google OAuth) |
| Database | Supabase (PostgreSQL via PostgREST + RLS) |
| AI Backend | FastAPI + Python 3.11, uvicorn |
| Fonts | Geist (variable, next/font) |
| Deployment | Vercel (frontend), Render / Railway (AI backend) |
git clone https://github.com/imastikbaev/mahjong-flow
cd mahjong-flow
npm install
cp .env.local.example .env.local # fill in Supabase + AI URL
npm run devAI backend:
cd ai-coach
pip install -r requirements.txt
uvicorn main:app --reload --port 8000Apply the single unified schema via Supabase Dashboard → SQL Editor:
supabase/schema.sql -- all tables, triggers, RLS policies, indexes, atomic RPC
The file is fully idempotent (create or replace, create table if not exists, drop trigger if exists, drop policy if exists) — safe to re-run after schema changes.
Tables created:
| Table | Purpose |
|---|---|
public.users |
Player profiles, references auth.users(id) |
public.leaderboard |
Personal-best scores, unique on (user_id, date, type) |
public.quests_progress |
Quest tracking, unique on (user_id, quest_id) |
RLS summary:
| Table | Read | Write |
|---|---|---|
users |
Public | Own row (auth.uid() = id) |
leaderboard |
Public | Own row (auth.uid() = user_id) |
quests_progress |
Own row | Own row |
| Variable | Description |
|---|---|
NEXT_PUBLIC_SUPABASE_URL |
Supabase project URL (Settings → API) |
NEXT_PUBLIC_SUPABASE_ANON_KEY |
Supabase anon key — public-safe |
NEXT_PUBLIC_AI_URL |
AI Coach base URL. Use http://127.0.0.1:8000 locally |
| Step | Location |
|---|---|
| Run both SQL migrations | SQL Editor |
| Enable Email provider | Authentication → Providers → Email |
| Enable Google OAuth | Authentication → Providers → Google |
| Add redirect URL | Authentication → URL Configuration → https://<domain>/auth/callback |
MIT