42 KiB
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 =
messagesrow with payload{v:1, type:'game', game_id, game_type}.MessageBubbledispatches onparsed.kind === 'game'. - Client logic in
packages/shared/src/chat/games.tsis 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 + exposesmakeMove(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:
-- 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
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:
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:
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:
export interface GamePayload {
v: 1;
type: 'game';
game_id: string;
game_type: 'ttt' | 'c4';
}
Extend the union:
export type MessagePayload =
| TextMessagePayload
| CallEventPayload
| PollPayload
| WhiteboardPayload
| WatchTogetherPayload
| GamePayload;
Extend ParsedMessagePayload:
| {
kind: 'game';
gameId: string;
gameType: 'ttt' | 'c4';
};
In parseMessagePayload, after the obj.type === 'watch_together' branch and BEFORE the text fallback:
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:
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:
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:
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
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
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
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
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
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
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
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
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:
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:
import { createGamePayload } from '../lib/conversationFeatures';
import { GameModal } from '../components/GameModal';
import { createGame, type GameType } from '@chat-app/shared/chat';
B. State — near openWatchSessionId:
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:
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):
<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:
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:
{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:
{openGameId && (
<GameModal
gameId={openGameId}
onClose={() => setOpenGameId(null)}
/>
)}
G. Open-event listener alongside the existing chatapp:open-watch-together listener:
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:
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
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)
-
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
- "New table
-
Placeholders: none.
-
Type consistency:
Cell = 0 | 1 | nulleverywhere 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 asWhiteboardPayload/WatchTogetherPayload.
-
Server-authoritative win check:
ttt_check_winner/c4_check_winnerPL/pgSQL mirrortttWinningLine/c4WinningCellsTypeScript. Server enforces; client renders. -
Race-condition guard:
select ... for updatein the RPC serializes concurrent moves. -
MVP scope cut: game creation is gated to 2-member conversations. Groups can spectate via the bubble; only DMs can start.