# Phase 5B โ€” Mini-Games (Tic-Tac-Toe + Vier-Gewinnt) > **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. **Goal:** Two server-authoritative turn-based games inside a conversation. Composer โ†’ "๐ŸŽฎ Spielen" โ†’ pick game type โ†’ inline bubble โ†’ click "Spielen" โ†’ fullscreen board; moves are validated and applied by a single PL/pgSQL RPC; winner detection is server-side; realtime postgres_changes UPDATE on the game row keeps both clients in sync. **Architecture:** - One server table `public.conversation_games (id, conversation_id, game_type text, state jsonb, players jsonb, current_turn_user_id, winner_user_id, created_at, finished_at)`. - One server RPC `game_make_move(p_game_id uuid, p_move jsonb) returns jsonb` โ€” owns the entire state machine. - Bubble = `messages` row with payload `{v:1, type:'game', game_id, game_type}`. `MessageBubble` dispatches on `parsed.kind === 'game'`. - Client logic in `packages/shared/src/chat/games.ts` is pure: wrappers around the RPC + tiny render-only helpers (winning-line detection for UI highlighting) โ€” mirrors the server's PL/pgSQL win-check so the UI can highlight without re-querying. - `useGame(gameId)` hook subscribes to UPDATE on the row + exposes `makeMove(move)`. - Two board components + a modal that picks the right component from `game_type`. **Tech Stack:** PostgreSQL + PL/pgSQL (state-machine RPC) + RLS + Supabase Realtime; React 18; no new build-time deps. **Non-goals:** - No chess / Pong / other games (post-MVP per spec). - No spectator UI โ€” exactly two players; bubble's second seat fills on opening. - No move animations beyond CSS transitions. - No 24h auto-draw cron (post-MVP). - No rematch button. --- ## Pre-flight - [ ] **Verify clean working tree on `main`** Run: `cd "D:\Programmieren\ChatApp-Electron\chat-app" && git status` Expected: clean. - [ ] **Confirm tooling is green** Run: `pnpm --filter @chat-app/shared typecheck && pnpm --filter @chat-app/desktop typecheck && pnpm --filter @chat-app/shared test -- --run` Expected: all green; 60 shared tests pass (post-P5A baseline). --- ## Task 1: SQL migration โ€” table + RPC + RLS + realtime **Files:** - Create: `supabase/migrations/20260516000009_mini_games.sql` Idempotent. - [ ] **Step 1: Write the SQL migration** Create `supabase/migrations/20260516000009_mini_games.sql`: ```sql -- Phase 5B: per-conversation mini-games (Tic-Tac-Toe + Vier-Gewinnt / -- Connect Four). -- -- conversation_games: one row per game. The bubble in the chat is a -- regular `messages` row whose plaintext payload is -- `{v:1, type:'game', game_id:, game_type:<'ttt'|'c4'>}`. -- -- state JSON shape: -- ttt: { kind: 'ttt', board: [null|0|1 ร— 9] } (row-major 3ร—3) -- c4: { kind: 'c4', board: [null|0|1 ร— 42] } (row-major 6 rows ร— 7 cols) -- -- players JSON: [user_id_a, user_id_b] โ€” indices 0 and 1 map to board cells. -- current_turn_user_id alternates; null once finished. -- winner_user_id null on draw or unfinished. create table if not exists public.conversation_games ( id uuid primary key default gen_random_uuid(), conversation_id uuid not null references public.conversations(id) on delete cascade, game_type text not null check (game_type in ('ttt', 'c4')), state jsonb not null, players jsonb not null, current_turn_user_id uuid null references auth.users(id) on delete set null, winner_user_id uuid null references auth.users(id) on delete set null, created_at timestamptz not null default now(), finished_at timestamptz null ); create index if not exists conversation_games_conv_idx on public.conversation_games(conversation_id, created_at desc); alter table public.conversation_games enable row level security; drop policy if exists conversation_games_select on public.conversation_games; drop policy if exists conversation_games_insert on public.conversation_games; create policy conversation_games_select on public.conversation_games for select using (public.is_conversation_member(conversation_id)); create policy conversation_games_insert on public.conversation_games for insert with check ( public.is_conversation_member(conversation_id) and current_turn_user_id = auth.uid() and (players->>0)::uuid = auth.uid() ); alter table public.conversation_games replica identity full; do $$ begin if not exists ( select 1 from pg_publication_tables where pubname = 'supabase_realtime' and schemaname = 'public' and tablename = 'conversation_games' ) then execute 'alter publication supabase_realtime add table public.conversation_games'; end if; end $$; -- ---------------------------------------------------------------------- -- Pure winner-check + board-full helpers. -- ---------------------------------------------------------------------- create or replace function public.ttt_check_winner(board jsonb) returns int language plpgsql immutable as $$ declare lines int[][] := array[ array[0,1,2], array[3,4,5], array[6,7,8], array[0,3,6], array[1,4,7], array[2,5,8], array[0,4,8], array[2,4,6] ]; ln int[]; a jsonb; b jsonb; c jsonb; begin foreach ln slice 1 in array lines loop a := board->ln[1]; b := board->ln[2]; c := board->ln[3]; if jsonb_typeof(a) = 'number' and a = b and b = c then return (a)::int; end if; end loop; return null; end; $$; create or replace function public.c4_check_winner(board jsonb) returns int language plpgsql immutable as $$ declare rows constant int := 6; cols constant int := 7; r int; c int; i int; d_r int; d_c int; directions int[][] := array[ array[0, 1], array[1, 0], array[1, 1], array[1, -1] ]; dir int[]; base jsonb; cell jsonb; ok boolean; begin for r in 0..rows-1 loop for c in 0..cols-1 loop base := board->(r * cols + c); if jsonb_typeof(base) <> 'number' then continue; end if; foreach dir slice 1 in array directions loop d_r := dir[1]; d_c := dir[2]; if r + d_r * 3 < 0 or r + d_r * 3 >= rows then continue; end if; if c + d_c * 3 < 0 or c + d_c * 3 >= cols then continue; end if; ok := true; for i in 1..3 loop cell := board->((r + d_r * i) * cols + (c + d_c * i)); if cell is null or cell <> base then ok := false; exit; end if; end loop; if ok then return (base)::int; end if; end loop; end loop; end loop; return null; end; $$; create or replace function public.board_is_full(board jsonb) returns boolean language sql immutable as $$ select not exists ( select 1 from jsonb_array_elements(board) elt where jsonb_typeof(elt) is distinct from 'number' ); $$; -- ---------------------------------------------------------------------- -- The state-machine RPC. -- ---------------------------------------------------------------------- create or replace function public.game_make_move(p_game_id uuid, p_move jsonb) returns jsonb language plpgsql security definer set search_path = public as $$ declare v_game record; v_state jsonb; v_board jsonb; v_kind text; v_player_idx int; v_other_user uuid; v_winner_idx int; v_finished boolean; v_cell int; v_col int; v_row int; v_cols constant int := 7; v_rows constant int := 6; v_target_row int; begin if auth.uid() is null then raise exception 'not_authenticated' using errcode = '28000'; end if; select * into v_game from public.conversation_games where id = p_game_id for update; if not found then raise exception 'game_not_found' using errcode = 'P0002'; end if; if v_game.finished_at is not null then raise exception 'game_finished' using errcode = '42501'; end if; if v_game.current_turn_user_id is null or v_game.current_turn_user_id <> auth.uid() then raise exception 'not_your_turn' using errcode = '42501'; end if; if (v_game.players->>0)::uuid = auth.uid() then v_player_idx := 0; v_other_user := (v_game.players->>1)::uuid; elsif (v_game.players->>1)::uuid = auth.uid() then v_player_idx := 1; v_other_user := (v_game.players->>0)::uuid; else raise exception 'not_a_player' using errcode = '42501'; end if; v_state := v_game.state; v_kind := v_state->>'kind'; v_board := v_state->'board'; if v_kind = 'ttt' then if (p_move->'cell') is null or jsonb_typeof(p_move->'cell') <> 'number' then raise exception 'bad_move' using errcode = '22023'; end if; v_cell := (p_move->>'cell')::int; if v_cell < 0 or v_cell > 8 then raise exception 'bad_move' using errcode = '22023'; end if; if jsonb_typeof(v_board->v_cell) = 'number' then raise exception 'cell_taken' using errcode = '42501'; end if; v_board := jsonb_set(v_board, array[v_cell::text], to_jsonb(v_player_idx)); v_winner_idx := public.ttt_check_winner(v_board); elsif v_kind = 'c4' then if (p_move->'column') is null or jsonb_typeof(p_move->'column') <> 'number' then raise exception 'bad_move' using errcode = '22023'; end if; v_col := (p_move->>'column')::int; if v_col < 0 or v_col >= v_cols then raise exception 'bad_move' using errcode = '22023'; end if; v_target_row := -1; for v_row in reverse v_rows - 1 .. 0 loop if jsonb_typeof(v_board->(v_row * v_cols + v_col)) is distinct from 'number' then v_target_row := v_row; exit; end if; end loop; if v_target_row < 0 then raise exception 'column_full' using errcode = '42501'; end if; v_board := jsonb_set(v_board, array[(v_target_row * v_cols + v_col)::text], to_jsonb(v_player_idx)); v_winner_idx := public.c4_check_winner(v_board); else raise exception 'unknown_game_kind' using errcode = '42501'; end if; v_state := jsonb_set(v_state, '{board}', v_board); v_finished := v_winner_idx is not null or public.board_is_full(v_board); update public.conversation_games set state = v_state, current_turn_user_id = case when v_finished then null else v_other_user end, winner_user_id = case when v_winner_idx is not null then (v_game.players->>v_winner_idx)::uuid else null end, finished_at = case when v_finished then now() else null end where id = p_game_id; return v_state; end; $$; revoke all on function public.game_make_move(uuid, jsonb) from public; grant execute on function public.game_make_move(uuid, jsonb) to authenticated; ``` - [ ] **Step 2: Commit + push to prod** ```bash cd "D:\Programmieren\ChatApp-Electron\chat-app" git add supabase/migrations/20260516000009_mini_games.sql git commit -m "feat(P5B.T1): conversation_games table + game_make_move RPC + RLS + realtime" bash scripts/prod/push-migrations.sh mini_games ``` --- ## Task 2: Shared payload + wrappers + game-logic helpers + tests **Files:** - Modify: `packages/db-types/src/index.ts` - Modify: `packages/shared/src/chat/attachments.ts` - Create: `packages/shared/src/chat/games.ts` - Create: `packages/shared/src/chat/games.test.ts` - Modify: `packages/shared/src/chat/index.ts` - [ ] **Step 1: db-types โ€” add table + RPC** In `packages/db-types/src/index.ts`, after `conversation_watch_sessions` (P5A.T2 commit `4399d39`), add: ```ts conversation_games: { Row: { id: string; conversation_id: string; game_type: 'ttt' | 'c4'; state: { kind: 'ttt' | 'c4'; board: Array }; players: [string, string]; current_turn_user_id: string | null; winner_user_id: string | null; created_at: string; finished_at: string | null; }; Insert: { id?: string; conversation_id: string; game_type: 'ttt' | 'c4'; state: { kind: 'ttt' | 'c4'; board: Array }; players: [string, string]; current_turn_user_id: string | null; winner_user_id?: string | null; created_at?: string; finished_at?: string | null; }; Update: { id?: string; conversation_id?: string; game_type?: 'ttt' | 'c4'; state?: { kind: 'ttt' | 'c4'; board: Array }; players?: [string, string]; current_turn_user_id?: string | null; winner_user_id?: string | null; finished_at?: string | null; }; Relationships: []; }; ``` Find the `Functions` block in the same Database type (it should already have `revoke_device` from P3.T1). Add: ```ts game_make_move: { Args: { p_game_id: string; p_move: object }; Returns: { kind: 'ttt' | 'c4'; board: Array }; }; ``` If no `Functions` block exists, add the minimal shape that matches what supabase-js expects (`{ [fn: string]: { Args: ...; Returns: ... } }`). - [ ] **Step 2: Extend attachments.ts** In `packages/shared/src/chat/attachments.ts`, after `WatchTogetherPayload`, add: ```ts export interface GamePayload { v: 1; type: 'game'; game_id: string; game_type: 'ttt' | 'c4'; } ``` Extend the union: ```ts export type MessagePayload = | TextMessagePayload | CallEventPayload | PollPayload | WhiteboardPayload | WatchTogetherPayload | GamePayload; ``` Extend `ParsedMessagePayload`: ```ts | { kind: 'game'; gameId: string; gameType: 'ttt' | 'c4'; }; ``` In `parseMessagePayload`, after the `obj.type === 'watch_together'` branch and BEFORE the text fallback: ```ts if (obj.type === 'game') { const p = obj as Partial; const id = typeof p.game_id === 'string' && p.game_id.length > 0 ? p.game_id : ''; const t = p.game_type === 'ttt' || p.game_type === 'c4' ? p.game_type : 'ttt'; return { kind: 'game', gameId: id, gameType: t }; } ``` - [ ] **Step 3: Create the wrapper** Create `packages/shared/src/chat/games.ts`: ```ts import type { AppSupabaseClient } from '../supabase/client'; export type GameType = 'ttt' | 'c4'; export type Cell = 0 | 1 | null; export const TTT_CELLS = 9; export const C4_ROWS = 6; export const C4_COLS = 7; export const C4_CELLS = C4_ROWS * C4_COLS; export const TTT_LINES: ReadonlyArray = [ [0, 1, 2], [3, 4, 5], [6, 7, 8], [0, 3, 6], [1, 4, 7], [2, 5, 8], [0, 4, 8], [2, 4, 6], ]; export interface GameRecord { id: string; conversationId: string; gameType: GameType; state: { kind: GameType; board: Cell[] }; players: [string, string]; currentTurnUserId: string | null; winnerUserId: string | null; createdAt: string; finishedAt: string | null; } export function emptyTttBoard(): Cell[] { return new Array(TTT_CELLS).fill(null); } export function emptyC4Board(): Cell[] { return new Array(C4_CELLS).fill(null); } export function tttWinningLine(board: Cell[]): readonly [number, number, number] | null { for (const line of TTT_LINES) { const a = board[line[0]]; const b = board[line[1]]; const c = board[line[2]]; if (a !== null && a === b && b === c) return line; } return null; } export function c4WinningCells(board: Cell[]): readonly number[] | null { const directions: Array<[number, number]> = [ [0, 1], [1, 0], [1, 1], [1, -1], ]; for (let r = 0; r < C4_ROWS; r++) { for (let c = 0; c < C4_COLS; c++) { const base = board[r * C4_COLS + c]; if (base === null) continue; for (const [dr, dc] of directions) { const rEnd = r + dr * 3; const cEnd = c + dc * 3; if (rEnd < 0 || rEnd >= C4_ROWS || cEnd < 0 || cEnd >= C4_COLS) continue; let ok = true; const cells: number[] = [r * C4_COLS + c]; for (let i = 1; i < 4; i++) { const idx = (r + dr * i) * C4_COLS + (c + dc * i); if (board[idx] !== base) { ok = false; break; } cells.push(idx); } if (ok) return cells; } } } return null; } export function isBoardFull(board: Cell[]): boolean { return board.every((c) => c !== null); } export function c4DropRow(board: Cell[], column: number): number { for (let r = C4_ROWS - 1; r >= 0; r--) { if (board[r * C4_COLS + column] === null) return r; } return -1; } export async function createGame( client: AppSupabaseClient, params: { conversationId: string; gameType: GameType; opponentUserId: string }, ): Promise { const { data: session } = await client.auth.getUser(); if (!session.user) throw new Error('not authenticated'); const board = params.gameType === 'ttt' ? emptyTttBoard() : emptyC4Board(); const state = { kind: params.gameType, board }; const players: [string, string] = [session.user.id, params.opponentUserId]; const { data, error } = await client .from('conversation_games') .insert({ conversation_id: params.conversationId, game_type: params.gameType, state, players, current_turn_user_id: session.user.id, }) .select( 'id, conversation_id, game_type, state, players, current_turn_user_id, winner_user_id, created_at, finished_at', ) .single(); if (error) throw error; return mapRow(data); } export async function getGame( client: AppSupabaseClient, gameId: string, ): Promise { const { data, error } = await client .from('conversation_games') .select( 'id, conversation_id, game_type, state, players, current_turn_user_id, winner_user_id, created_at, finished_at', ) .eq('id', gameId) .maybeSingle(); if (error) throw error; return data ? mapRow(data) : null; } export async function makeGameMove( client: AppSupabaseClient, params: { gameId: string; move: object }, ): Promise { const { error } = await client.rpc('game_make_move', { p_game_id: params.gameId, p_move: params.move, }); if (error) throw new Error(error.message); } function mapRow(row: { id: string; conversation_id: string; game_type: string; state: unknown; players: unknown; current_turn_user_id: string | null; winner_user_id: string | null; created_at: string; finished_at: string | null; }): GameRecord { const rawState = (row.state ?? {}) as { kind?: string; board?: unknown }; const kind: GameType = rawState.kind === 'c4' ? 'c4' : 'ttt'; const rawBoard = Array.isArray(rawState.board) ? rawState.board : []; const board: Cell[] = rawBoard.map((c) => typeof c === 'number' && (c === 0 || c === 1) ? (c as Cell) : null, ); const players = Array.isArray(row.players) ? row.players : []; return { id: row.id, conversationId: row.conversation_id, gameType: row.game_type === 'c4' ? 'c4' : 'ttt', state: { kind, board }, players: [String(players[0] ?? ''), String(players[1] ?? '')], currentTurnUserId: row.current_turn_user_id, winnerUserId: row.winner_user_id, createdAt: row.created_at, finishedAt: row.finished_at, }; } ``` - [ ] **Step 4: Tests** Create `packages/shared/src/chat/games.test.ts`: ```ts import { describe, expect, it } from 'vitest'; import { c4DropRow, c4WinningCells, emptyC4Board, emptyTttBoard, isBoardFull, tttWinningLine, type Cell, } from './games'; describe('tttWinningLine', () => { it('detects a row win', () => { const b: Cell[] = [0, 0, 0, null, null, null, null, null, null]; expect(tttWinningLine(b)).toEqual([0, 1, 2]); }); it('detects a diagonal win', () => { const b: Cell[] = [1, null, null, null, 1, null, null, null, 1]; expect(tttWinningLine(b)).toEqual([0, 4, 8]); }); it('returns null when no winner', () => { expect(tttWinningLine(emptyTttBoard())).toBeNull(); const mixed: Cell[] = [0, 1, 0, 1, 0, 1, 1, 0, 1]; expect(tttWinningLine(mixed)).toBeNull(); }); }); describe('c4WinningCells', () => { it('detects a horizontal win', () => { const b = emptyC4Board(); b[35] = 1; b[36] = 1; b[37] = 1; b[38] = 1; expect(c4WinningCells(b)).toEqual([35, 36, 37, 38]); }); it('detects a vertical win', () => { const b = emptyC4Board(); b[14] = 0; b[21] = 0; b[28] = 0; b[35] = 0; expect(c4WinningCells(b)).toEqual([14, 21, 28, 35]); }); it('detects a diagonal โ†˜ win', () => { const b = emptyC4Board(); b[14] = 1; b[22] = 1; b[30] = 1; b[38] = 1; expect(c4WinningCells(b)).toEqual([14, 22, 30, 38]); }); it('returns null when no winner', () => { expect(c4WinningCells(emptyC4Board())).toBeNull(); }); }); describe('c4DropRow', () => { it('returns the bottom row on an empty column', () => { expect(c4DropRow(emptyC4Board(), 0)).toBe(5); }); it('stacks on top of an existing piece', () => { const b = emptyC4Board(); b[35] = 0; expect(c4DropRow(b, 0)).toBe(4); }); it('returns -1 when the column is full', () => { const b = emptyC4Board(); for (let r = 0; r < 6; r++) b[r * 7 + 3] = 0; expect(c4DropRow(b, 3)).toBe(-1); }); }); describe('isBoardFull', () => { it('true for a fully filled board, false otherwise', () => { expect(isBoardFull(emptyTttBoard())).toBe(false); const filled: Cell[] = [0, 1, 0, 1, 0, 1, 1, 0, 1]; expect(isBoardFull(filled)).toBe(true); }); }); ``` - [ ] **Step 5: Re-export** Append to `packages/shared/src/chat/index.ts`: ```ts export * from './games'; ``` - [ ] **Step 6: Tests + typecheck** ``` pnpm --filter @chat-app/shared test -- --run games pnpm --filter @chat-app/shared typecheck pnpm --filter @chat-app/shared test -- --run ``` Expected: ~13 new tests; full suite ~73/73. - [ ] **Step 7: Commit** ```bash git add packages/db-types/src/index.ts packages/shared/src/chat/attachments.ts packages/shared/src/chat/games.ts packages/shared/src/chat/games.test.ts packages/shared/src/chat/index.ts git commit -m "feat(P5B.T2): GamePayload + games wrappers + winner-detect helpers + tests" ``` --- ## Task 3: `useGame` hook **Files:** - Create: `apps/desktop/src/hooks/useGame.ts` - [ ] **Step 1: Write the hook** ```ts import { useCallback, useEffect, useState } from 'react'; import { getGame, makeGameMove, type GameRecord } from '@chat-app/shared/chat'; import { supabase } from '../lib/supabase'; export function useGame(gameId: string | null): { game: GameRecord | null; loading: boolean; error: string | null; makeMove: (move: object) => Promise; } { const [game, setGame] = useState(null); const [loading, setLoading] = useState(true); const [error, setError] = useState(null); useEffect(() => { if (!gameId) { setGame(null); setLoading(false); setError(null); return; } let cancelled = false; void (async () => { try { setLoading(true); setError(null); const fresh = await getGame(supabase, gameId); if (!cancelled) { setGame(fresh); setLoading(false); } } catch (err) { if (!cancelled) { setLoading(false); setError(err instanceof Error ? err.message : 'failed to load game'); } } })(); const channel = supabase .channel('game:' + gameId) .on( 'postgres_changes', { event: 'UPDATE', schema: 'public', table: 'conversation_games', filter: 'id=eq.' + gameId, }, () => { void getGame(supabase, gameId) .then((fresh) => { if (fresh) setGame(fresh); }) .catch((err) => { console.warn('game realtime refetch failed', err); }); }, ) .subscribe(); return () => { cancelled = true; void supabase.removeChannel(channel); }; }, [gameId]); const makeMove = useCallback(async (move: object) => { if (!gameId) return; try { await makeGameMove(supabase, { gameId, move }); } catch (err) { setError(err instanceof Error ? err.message : 'move failed'); throw err; } }, [gameId]); return { game, loading, error, makeMove }; } ``` - [ ] **Step 2: Typecheck** ``` pnpm --filter @chat-app/desktop typecheck ``` Expected: PASS. - [ ] **Step 3: Commit** ```bash git add apps/desktop/src/hooks/useGame.ts git commit -m "feat(P5B.T3): useGame hook with realtime + makeMove" ``` --- ## Task 4: Board components โ€” `TicTacToeBoard` + `ConnectFourBoard` **Files:** - Create: `apps/desktop/src/components/TicTacToeBoard.tsx` - Create: `apps/desktop/src/components/ConnectFourBoard.tsx` - [ ] **Step 1: TicTacToeBoard** ```tsx import { type Cell, tttWinningLine } from '@chat-app/shared/chat'; interface Props { board: Cell[]; myPlayerIdx: 0 | 1 | null; disabled: boolean; onMove: (cell: number) => void; } const MARK = ['ร—', 'โ—‹'] as const; export function TicTacToeBoard({ board, myPlayerIdx, disabled, onMove }: Props) { const winLine = tttWinningLine(board); const winSet = new Set(winLine ?? []); return (
{board.map((cell, i) => { const filled = cell !== null; const inWin = winSet.has(i); const canClick = !disabled && !filled && myPlayerIdx !== null; return ( ); })}
); } ``` - [ ] **Step 2: ConnectFourBoard** ```tsx import { C4_COLS, C4_ROWS, c4DropRow, c4WinningCells, type Cell, } from '@chat-app/shared/chat'; interface Props { board: Cell[]; myPlayerIdx: 0 | 1 | null; disabled: boolean; onMove: (column: number) => void; } export function ConnectFourBoard({ board, myPlayerIdx, disabled, onMove }: Props) { const winCells = c4WinningCells(board); const winSet = new Set(winCells ?? []); return (
{Array.from({ length: C4_ROWS * C4_COLS }, (_, idx) => { const cell = board[idx]; const col = idx % C4_COLS; const dropTo = c4DropRow(board, col); const canClickColumn = !disabled && dropTo >= 0 && myPlayerIdx !== null; const inWin = winSet.has(idx); return (
); } ``` - [ ] **Step 3: Typecheck** ``` pnpm --filter @chat-app/desktop typecheck ``` Expected: PASS. - [ ] **Step 4: Commit** ```bash git add apps/desktop/src/components/TicTacToeBoard.tsx apps/desktop/src/components/ConnectFourBoard.tsx git commit -m "feat(P5B.T4): TicTacToeBoard + ConnectFourBoard render-only components" ``` --- ## Task 5: `GameModal` โ€” fullscreen container + turn/winner status **Files:** - Create: `apps/desktop/src/components/GameModal.tsx` - [ ] **Step 1: Write the modal** ```tsx import { useEffect } from 'react'; import { useTranslation } from 'react-i18next'; import { useAuth } from '../context/AuthContext'; import { useGame } from '../hooks/useGame'; import { ConnectFourBoard } from './ConnectFourBoard'; import { TicTacToeBoard } from './TicTacToeBoard'; import { XIcon } from './icons'; interface Props { gameId: string; onClose: () => void; } export function GameModal({ gameId, onClose }: Props) { const { t } = useTranslation(); const { game, loading, error, makeMove } = useGame(gameId); const { session: auth } = useAuth(); useEffect(() => { const onKey = (e: KeyboardEvent) => { if (e.key === 'Escape') onClose(); }; window.addEventListener('keydown', onKey); return () => window.removeEventListener('keydown', onKey); }, [onClose]); const myUserId = auth?.user.id ?? null; const myPlayerIdx: 0 | 1 | null = !game || !myUserId ? null : game.players[0] === myUserId ? 0 : game.players[1] === myUserId ? 1 : null; const isMyTurn = !!game && game.currentTurnUserId === myUserId; const finished = !!game?.finishedAt; const winnerIdx: 0 | 1 | null = !game?.winnerUserId ? null : game.players[0] === game.winnerUserId ? 0 : game.players[1] === game.winnerUserId ? 1 : null; const title = game?.gameType === 'c4' ? t('app:game.c4', { defaultValue: 'Vier-Gewinnt' }) : t('app:game.ttt', { defaultValue: 'Tic-Tac-Toe' }); const statusLine = (() => { if (loading) return t('app:game.loading', { defaultValue: 'Lรคdtโ€ฆ' }); if (error) return error; if (!game) return t('app:game.missing', { defaultValue: 'Spiel nicht gefunden.' }); if (finished) { if (winnerIdx === null) return t('app:game.draw', { defaultValue: 'Unentschieden!' }); if (winnerIdx === myPlayerIdx) return t('app:game.you_win', { defaultValue: 'Du hast gewonnen!' }); return t('app:game.you_lose', { defaultValue: 'Du hast verloren.' }); } if (myPlayerIdx === null) return t('app:game.spectator', { defaultValue: 'Du schaust nur zu.' }); if (isMyTurn) return t('app:game.your_turn', { defaultValue: 'Du bist dran' }); return t('app:game.opponent_turn', { defaultValue: 'Gegner ist dranโ€ฆ' }); })(); return (

{title}

{game?.gameType === 'ttt' ? ( void makeMove({ cell }).catch(() => {})} /> ) : game?.gameType === 'c4' ? ( void makeMove({ column }).catch(() => {})} /> ) : null}
{statusLine}
); } ``` - [ ] **Step 2: Typecheck** ``` pnpm --filter @chat-app/desktop typecheck ``` Expected: PASS. - [ ] **Step 3: Commit** ```bash git add apps/desktop/src/components/GameModal.tsx git commit -m "feat(P5B.T5): GameModal โ€” picks board + turn/winner status" ``` --- ## Task 6: Composer button + game-type picker + MessageBubble + listener **Files:** - Modify: `apps/desktop/src/lib/conversationFeatures.ts` - Modify: `apps/desktop/src/pages/ConversationPage.tsx` - Modify: `apps/desktop/src/components/MessageBubble.tsx` - [ ] **Step 1: Payload helper** In `conversationFeatures.ts`, after `createWatchTogetherPayload` (P5A.T5), append: ```ts export function createGamePayload(gameId: string, gameType: 'ttt' | 'c4'): string { return serializeMessagePayload({ v: 1, type: 'game', game_id: gameId, game_type: gameType, }); } ``` Add `GamePayload` to the same `@chat-app/shared/chat` import that brings in `WatchTogetherPayload`. - [ ] **Step 2: Wire ConversationPage** **A. Imports:** ```ts import { createGamePayload } from '../lib/conversationFeatures'; import { GameModal } from '../components/GameModal'; import { createGame, type GameType } from '@chat-app/shared/chat'; ``` **B. State** โ€” near `openWatchSessionId`: ```ts const [openGameId, setOpenGameId] = useState(null); const [gameDialogOpen, setGameDialogOpen] = useState(false); const [gameError, setGameError] = useState(null); const [gameCreating, setGameCreating] = useState(false); ``` **C. Handler** โ€” near `handleStartWatchTogether`: ```ts const handleStartGame = useCallback(async (gameType: GameType) => { if (!id) return; if (!conversation || conversation.members.length !== 2) { setGameError('Spiele aktuell nur in 1:1-Chats.'); return; } const opponent = conversation.members.find((m) => m.userId !== myId); if (!opponent) { setGameError('Kein Gegner gefunden.'); return; } setGameCreating(true); setGameError(null); try { const game = await createGame(supabase, { conversationId: id, gameType, opponentUserId: opponent.userId, }); const payload = createGamePayload(game.id, gameType); await send(payload, [], replyTo?.id ?? null); setReplyTo(null); setStickToBottom(true); setGameDialogOpen(false); setOpenGameId(game.id); } catch (err: unknown) { setGameError(err instanceof Error ? err.message : 'Konnte Spiel nicht starten'); } finally { setGameCreating(false); } }, [id, conversation, myId, send, replyTo?.id]); ``` Verify `conversation.members[].userId` shape โ€” if the codebase uses `user_id`, switch. Grep `Grep -n "conversation.members" apps/desktop/src/pages/ConversationPage.tsx`. **D. Composer button** โ€” after the Watch-Together button (search `PlayBoxIcon`): ```tsx ``` Inline icon near the other inline icons: ```tsx function GameIcon(props: React.SVGProps) { return ( ); } ``` **E. Game-picker dialog** at the page root: ```tsx {gameDialogOpen && (
{ if (e.target === e.currentTarget) setGameDialogOpen(false); }} >

{t('app:game.pick_title', { defaultValue: 'Spiel auswรคhlen' })}

{gameError && (

{gameError}

)}
)} ``` **F. Modal mount** alongside WatchTogetherModal: ```tsx {openGameId && ( setOpenGameId(null)} /> )} ``` **G. Open-event listener** alongside the existing `chatapp:open-watch-together` listener: ```ts useEffect(() => { const onOpen = (e: Event) => { const detail = (e as CustomEvent<{ id?: string }>).detail; if (detail?.id) setOpenGameId(detail.id); }; window.addEventListener('chatapp:open-game', onOpen); return () => window.removeEventListener('chatapp:open-game', onOpen); }, []); ``` - [ ] **Step 3: Wire MessageBubble** In `apps/desktop/src/components/MessageBubble.tsx`, find the existing `parsed.kind === 'watch_together'` branch (P5A.T5). Add the game branch JUST BEFORE it: ```tsx if (parsed.kind === 'game') { const id = parsed.gameId; const gType = parsed.gameType; const label = gType === 'c4' ? 'Vier-Gewinnt' : 'Tic-Tac-Toe'; return (
{label}
Gemeinsam spielen
); } ``` - [ ] **Step 4: Typecheck** ``` pnpm --filter @chat-app/desktop typecheck ``` Expected: PASS. - [ ] **Step 5: Commit** ```bash git add apps/desktop/src/lib/conversationFeatures.ts apps/desktop/src/pages/ConversationPage.tsx apps/desktop/src/components/MessageBubble.tsx git commit -m "feat(P5B.T6): composer Spielen button + bubble dispatch + game-picker" ``` --- ## Final gate - [ ] **Step 1: Typecheck both packages** Run: `pnpm --filter @chat-app/shared typecheck && pnpm --filter @chat-app/desktop typecheck` Expected: both PASS. - [ ] **Step 2: Run shared tests** Run: `pnpm --filter @chat-app/shared test -- --run` Expected: PASS โ€” ~73 tests (60 baseline + ~13 new). - [ ] **Step 3: Verify no uncommitted changes** Run: `git status` Expected: clean. - [ ] **Step 4: Report** Report: "Phase 5B (Mini-Games) code-complete on `main`; migration + RPC applied to prod. Restart dev โ†’ 1:1 chat โ†’ composer โ†’ Spielen icon โ†’ pick Tic-Tac-Toe or Vier-Gewinnt โ†’ bubble appears + modal opens. Other account in the same DM clicks Spielen โ†’ modal opens, makes a move, owner sees it within ~1s. All five phases of the fifteen-features initiative now complete on main." --- ## Self-review (resolved inline) 1. **Spec coverage** (lines 126-130): - "New table `conversation_games`" โ†’ T1 - "Move RPC `game_make_move(...)` server-authoritative" โ†’ T1 - "Realtime subscription on the game row" โ†’ T3 useGame - "Tic-Tac-Toe + Vier-Gewinnt" โ†’ T4 boards - Disconnect-on-mid-game โ†’ realtime + initial fetch handle it - 24h auto-draw โ†’ server cron, out of scope 2. **Placeholders:** none. 3. **Type consistency:** - `Cell = 0 | 1 | null` everywhere downstream. - `GameType = 'ttt' | 'c4'` โ€” T1 DB check, T2 helpers, T4 boards, T5 modal, T6 composer. - `GamePayload.game_id`/`game_type` โ†” `ParsedMessagePayload.gameId`/`gameType` โ€” same camelCase pattern as `WhiteboardPayload`/`WatchTogetherPayload`. 4. **Server-authoritative win check:** `ttt_check_winner`/`c4_check_winner` PL/pgSQL mirror `tttWinningLine`/`c4WinningCells` TypeScript. Server enforces; client renders. 5. **Race-condition guard:** `select ... for update` in the RPC serializes concurrent moves. 6. **MVP scope cut:** game creation is gated to 2-member conversations. Groups can spectate via the bubble; only DMs can start.