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

42 KiB
Raw Blame History

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:

-- 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)

  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_typeParsedMessagePayload.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.