Files
ChatApp/docs/superpowers/plans/2026-05-16-phase5b-mini-games.md
T

1374 lines
42 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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:<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<number | null> };
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<number | null> };
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<number | null> };
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<number | null> };
};
```
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<GamePayload>;
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<readonly [number, number, number]> = [
[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<GameRecord> {
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<GameRecord | null> {
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<void> {
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<void>;
} {
const [game, setGame] = useState<GameRecord | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(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<number>(winLine ?? []);
return (
<div
className="mx-auto grid w-full max-w-md grid-cols-3 gap-2 p-4"
style={{ aspectRatio: '1 / 1' }}
role="grid"
aria-label="Tic-Tac-Toe"
>
{board.map((cell, i) => {
const filled = cell !== null;
const inWin = winSet.has(i);
const canClick = !disabled && !filled && myPlayerIdx !== null;
return (
<button
key={i}
type="button"
onClick={() => canClick && onMove(i)}
disabled={!canClick}
aria-label={'Feld ' + (i + 1) + (filled ? ' belegt' : ' frei')}
className={
'flex aspect-square items-center justify-center rounded-xl border-2 text-5xl font-bold transition ' +
(inWin
? 'border-emerald-400 bg-emerald-400/20 text-emerald-200'
: filled
? cell === 0
? 'border-rose-500/60 bg-rose-500/10 text-rose-300'
: 'border-sky-500/60 bg-sky-500/10 text-sky-300'
: canClick
? 'cursor-pointer border-line bg-surface-2 text-fg-muted hover:bg-surface-3 hover:text-fg'
: 'cursor-not-allowed border-line bg-surface-2 text-fg-muted opacity-60')
}
>
{filled ? MARK[cell as 0 | 1] : ''}
</button>
);
})}
</div>
);
}
```
- [ ] **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<number>(winCells ?? []);
return (
<div
className="mx-auto w-full max-w-2xl rounded-2xl bg-sky-900/40 p-3"
role="grid"
aria-label="Vier-Gewinnt"
>
<div
className="grid gap-1.5"
style={{ gridTemplateColumns: 'repeat(' + C4_COLS + ', minmax(0, 1fr))' }}
>
{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 (
<button
key={idx}
type="button"
onClick={() => canClickColumn && onMove(col)}
disabled={!canClickColumn}
aria-label={'Spalte ' + (col + 1) + (cell !== null ? ' belegt' : '')}
className={
'flex aspect-square items-center justify-center rounded-full border-2 transition ' +
(inWin
? 'border-emerald-300 bg-emerald-400 shadow-[0_0_12px_rgba(110,231,183,0.7)]'
: cell === 0
? 'border-rose-300 bg-rose-500'
: cell === 1
? 'border-amber-300 bg-amber-400'
: canClickColumn
? 'cursor-pointer border-sky-700 bg-sky-950 hover:bg-sky-900'
: 'cursor-not-allowed border-sky-800 bg-sky-950 opacity-80')
}
/>
);
})}
</div>
</div>
);
}
```
- [ ] **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 (
<div
role="dialog"
aria-modal="true"
aria-label={title}
className="fixed inset-0 z-[80] flex flex-col bg-ink-950/95"
>
<header className="flex shrink-0 items-center justify-between border-b border-line/40 bg-surface-2 px-4 py-2">
<h2 className="font-display text-sm font-semibold text-fg">{title}</h2>
<button
type="button"
onClick={onClose}
aria-label={t('app:game.close', { defaultValue: 'Schließen' })}
className="flex h-7 w-7 cursor-pointer items-center justify-center rounded-md text-fg-muted hover:bg-surface-3 hover:text-fg"
>
<XIcon className="h-3.5 w-3.5" />
</button>
</header>
<div className="flex min-h-0 flex-1 items-center justify-center overflow-auto p-6">
{game?.gameType === 'ttt' ? (
<TicTacToeBoard
board={game.state.board}
myPlayerIdx={myPlayerIdx}
disabled={!isMyTurn || finished}
onMove={(cell) => void makeMove({ cell }).catch(() => {})}
/>
) : game?.gameType === 'c4' ? (
<ConnectFourBoard
board={game.state.board}
myPlayerIdx={myPlayerIdx}
disabled={!isMyTurn || finished}
onMove={(column) => void makeMove({ column }).catch(() => {})}
/>
) : null}
</div>
<footer className="flex shrink-0 items-center justify-center border-t border-line/40 bg-surface-2 px-4 py-3 text-sm font-medium text-fg">
{statusLine}
</footer>
</div>
);
}
```
- [ ] **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<string | null>(null);
const [gameDialogOpen, setGameDialogOpen] = useState(false);
const [gameError, setGameError] = useState<string | null>(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
<button
type="button"
onClick={() => setGameDialogOpen(true)}
title={t('app:composer.game', { defaultValue: 'Spielen' })}
aria-label={t('app:composer.game', { defaultValue: 'Spielen' })}
className="flex h-9 w-9 cursor-pointer items-center justify-center rounded-md text-fg-muted transition hover:bg-surface-3 hover:text-fg"
>
<GameIcon className="h-4 w-4" />
</button>
```
Inline icon near the other inline icons:
```tsx
function GameIcon(props: React.SVGProps<SVGSVGElement>) {
return (
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2}
strokeLinecap="round" strokeLinejoin="round" {...props}>
<rect x="3" y="6" width="18" height="12" rx="3" />
<path d="M8 12h4M10 10v4M16 11v.01M16 14v.01" />
</svg>
);
}
```
**E. Game-picker dialog** at the page root:
```tsx
{gameDialogOpen && (
<div
role="dialog"
aria-modal="true"
className="fixed inset-0 z-[70] flex items-center justify-center bg-ink-950/90 p-6"
onClick={(e) => {
if (e.target === e.currentTarget) setGameDialogOpen(false);
}}
>
<div className="w-full max-w-sm rounded-2xl border border-line bg-surface-2 p-5 shadow-xl">
<h2 className="mb-3 font-display text-lg font-semibold text-fg">
{t('app:game.pick_title', { defaultValue: 'Spiel auswählen' })}
</h2>
{gameError && (
<p className="mb-2 text-xs text-rose-400">{gameError}</p>
)}
<div className="grid grid-cols-2 gap-3">
<button
type="button"
onClick={() => void handleStartGame('ttt')}
disabled={gameCreating}
className="flex flex-col items-center gap-2 rounded-xl border border-line bg-surface-3 p-4 text-fg transition hover:border-accent/40 hover:bg-surface disabled:cursor-not-allowed disabled:opacity-50"
>
<span className="text-3xl">×</span>
<span className="text-xs font-semibold">Tic-Tac-Toe</span>
</button>
<button
type="button"
onClick={() => void handleStartGame('c4')}
disabled={gameCreating}
className="flex flex-col items-center gap-2 rounded-xl border border-line bg-surface-3 p-4 text-fg transition hover:border-accent/40 hover:bg-surface disabled:cursor-not-allowed disabled:opacity-50"
>
<span className="text-3xl">🔴🟡</span>
<span className="text-xs font-semibold">Vier-Gewinnt</span>
</button>
</div>
<div className="mt-4 flex justify-end">
<button
type="button"
onClick={() => { setGameDialogOpen(false); setGameError(null); }}
className="cursor-pointer rounded-md border border-line bg-surface-3 px-3 py-1.5 text-xs font-medium text-fg-muted transition hover:bg-surface"
>
{t('app:game.cancel', { defaultValue: 'Abbrechen' })}
</button>
</div>
</div>
</div>
)}
```
**F. Modal mount** alongside WatchTogetherModal:
```tsx
{openGameId && (
<GameModal
gameId={openGameId}
onClose={() => 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 (
<div className="flex items-center gap-3 rounded-2xl border border-accent/30 bg-accent/5 px-4 py-3">
<div className="flex h-10 w-10 shrink-0 items-center justify-center rounded-lg bg-accent/20 text-accent">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2}
strokeLinecap="round" strokeLinejoin="round" className="h-5 w-5">
<rect x="3" y="6" width="18" height="12" rx="3" />
<path d="M8 12h4M10 10v4M16 11v.01M16 14v.01" />
</svg>
</div>
<div className="min-w-0 flex-1">
<div className="text-sm font-semibold text-fg">{label}</div>
<div className="text-xs text-fg-muted">Gemeinsam spielen</div>
</div>
<button
type="button"
onClick={() => {
window.dispatchEvent(
new CustomEvent('chatapp:open-game', { detail: { id } }),
);
}}
disabled={!id}
className="cursor-pointer rounded-md bg-accent px-3 py-1.5 text-xs font-semibold text-accent-fg transition hover:bg-accent/90 disabled:cursor-not-allowed disabled:opacity-50"
>
Spielen
</button>
</div>
);
}
```
- [ ] **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.