Repository: 42-Gang/frontend
Pull request #151: feat: 매칭 페이지 UI 추가
Author: login: HJ0311
Labels: name: ✨ Feature
description: 기능 개발
name: 🎨 Html&css
description: 마크업 & 스타일링
Created: 2025-05-17T05:20:26Z | Merged: 2025-05-18T04:02:50Z

## 🔗 반영 브랜치
(#146) feature/GameMatchingPage => dev

## 📝 작업 내용
1. Custom 모드 1vs1과 tournament 페이지 구현
  - Auto 모드와 페이지 디자인은 유사하나 방장에게 타이틀이 붙는다는 차이가 있음
  - 방장은 매칭이 완료 되면 Game start라는 버튼이 나오지만 일반 유저는 매칭 완료 문구만 나온다.
 2. 폴더 구조 변경
 3. 본인이 방장일 경우 커스텀 모드에 입장하면 친구를 초대할 수 있는 아이콘이 생성된다.
4. 방장일 경우 클릭할 수 있는 아이콘을 클릭하면 출력되는 팝업 UI 완료

### 📸 스크린샷
![Screenshot from 2025-05-17 13-35-57](https://github.com/user-attachments/assets/dbfbc361-2908-4431-8d8e-bd168ef609a4)
![Screenshot from 2025-05-17 13-36-45](https://github.com/user-attachments/assets/9380c19b-3aa9-4784-8802-770ffa55f272)
![Screenshot from 2025-05-17 14-14-11](https://github.com/user-attachments/assets/d26dac79-9e5f-45ff-a3b8-0a43b92cf8a4)
![Screenshot from 2025-05-17 14-14-20](https://github.com/user-attachments/assets/216a0784-9a69-445f-b8e6-0200a99a14d4)
![Screenshot from 2025-05-17 16-27-30](https://github.com/user-attachments/assets/e4d16ea0-179a-40e9-8a66-6943449fdbc8)
![Screenshot from 2025-05-17 16-27-34](https://github.com/user-attachments/assets/b708373a-b680-43d6-9edd-3c08993f8e81)
![Screenshot from 2025-05-17 16-22-02](https://github.com/user-attachments/assets/5c975e23-83d4-4b77-9abf-bf5651568d87)
![Screenshot from 2025-05-17 16-22-06](https://github.com/user-attachments/assets/721d8cd9-ba4e-4461-820b-b6af896104fd)




Diff:
diff --git a/public/assets/images/invite-friend.svg b/public/assets/images/invite-friend.svg
new file mode 100644
index 00000000..34a41f95
--- /dev/null
+++ b/public/assets/images/invite-friend.svg
@@ -0,0 +1,14 @@
+<svg width="60" height="60" viewBox="0 0 60 60" fill="none" xmlns="http://www.w3.org/2000/svg">
+<mask id="mask0_1170_51579" style="mask-type:luminance" maskUnits="userSpaceOnUse" x="0" y="0" width="60" height="60">
+<path d="M60 0H0V60H60V0Z" fill="white"/>
+</mask>
+<g mask="url(#mask0_1170_51579)">
+<path d="M58.2857 31.8751C58.2857 32.3724 58.0882 32.8493 57.7366 33.2009C57.385 33.5525 56.908 33.7501 56.4107 33.7501H52.6607V37.5001C52.6607 37.9974 52.4632 38.4743 52.1116 38.8259C51.76 39.1775 51.283 39.3751 50.7857 39.3751C50.2884 39.3751 49.8115 39.1775 49.4599 38.8259C49.1083 38.4743 48.9107 37.9974 48.9107 37.5001V33.7501H45.1607C44.6634 33.7501 44.1865 33.5525 43.8349 33.2009C43.4833 32.8493 43.2857 32.3724 43.2857 31.8751C43.2857 31.3778 43.4833 30.9009 43.8349 30.5493C44.1865 30.1977 44.6634 30.0001 45.1607 30.0001H48.9107V26.2501C48.9107 25.7528 49.1083 25.2759 49.4599 24.9243C49.8115 24.5727 50.2884 24.3751 50.7857 24.3751C51.283 24.3751 51.76 24.5727 52.1116 24.9243C52.4632 25.2759 52.6607 25.7528 52.6607 26.2501V30.0001H56.4107C56.908 30.0001 57.385 30.1977 57.7366 30.5493C58.0882 30.9009 58.2857 31.3778 58.2857 31.8751ZM32.0357 36.9563C34.975 35.1256 37.2381 32.3875 38.4826 29.1561C39.7271 25.9246 39.8853 22.3758 38.9332 19.0465C37.9812 15.7171 35.9708 12.7884 33.2061 10.7035C30.4413 8.6185 27.0727 7.49072 23.6099 7.49072C20.1472 7.49072 16.7786 8.6185 14.0139 10.7035C11.2492 12.7884 9.23864 15.7171 8.28662 19.0465C7.33459 22.3758 7.49279 25.9246 8.7373 29.1561C9.98178 32.3875 12.2449 35.1256 15.1841 36.9563C10.3444 38.5408 5.98963 41.5009 2.48807 45.6681C2.25882 45.9406 2.11192 46.2727 2.06453 46.6256C2.01714 46.9786 2.07123 47.3376 2.22047 47.6609C2.3697 47.9842 2.60792 48.2584 2.90725 48.4512C3.20658 48.6442 3.55464 48.7478 3.91073 48.7501H43.2857C43.643 48.7504 43.993 48.6487 44.2943 48.4569C44.5958 48.265 44.8361 47.9911 44.9871 47.6673C45.138 47.3434 45.1933 46.9833 45.1464 46.6291C45.0995 46.2748 44.9524 45.9415 44.7225 45.6681C41.2186 41.5009 36.8638 38.5408 32.0357 36.9563Z" fill="white"/>
+<mask id="mask1_1170_51579" style="mask-type:luminance" maskUnits="userSpaceOnUse" x="0" y="0" width="60" height="60">
+<path d="M60 0H0V60H60V0Z" fill="white"/>
+</mask>
+<g mask="url(#mask1_1170_51579)">
+<path d="M61.2857 31.8751C61.2857 32.3724 61.0882 32.8493 60.7366 33.2009C60.385 33.5525 59.908 33.7501 59.4107 33.7501H55.6607V37.5001C55.6607 37.9974 55.4632 38.4743 55.1116 38.8259C54.76 39.1775 54.283 39.3751 53.7857 39.3751C53.2884 39.3751 52.8115 39.1775 52.4599 38.8259C52.1083 38.4743 51.9107 37.9974 51.9107 37.5001V33.7501H48.1607C47.6634 33.7501 47.1865 33.5525 46.8349 33.2009C46.4833 32.8493 46.2857 32.3724 46.2857 31.8751C46.2857 31.3778 46.4833 30.9009 46.8349 30.5493C47.1865 30.1977 47.6634 30.0001 48.1607 30.0001H51.9107V26.2501C51.9107 25.7528 52.1083 25.2759 52.4599 24.9243C52.8115 24.5727 53.2884 24.3751 53.7857 24.3751C54.283 24.3751 54.76 24.5727 55.1116 24.9243C55.4632 25.2759 55.6607 25.7528 55.6607 26.2501V30.0001H59.4107C59.908 30.0001 60.385 30.1977 60.7366 30.5493C61.0882 30.9009 61.2857 31.3778 61.2857 31.8751ZM35.0357 36.9563C37.975 35.1256 40.2381 32.3875 41.4826 29.1561C42.7271 25.9246 42.8853 22.3758 41.9332 19.0465C40.9812 15.7171 38.9708 12.7884 36.2061 10.7035C33.4413 8.6185 30.0727 7.49072 26.6099 7.49072C23.1472 7.49072 19.7786 8.6185 17.0139 10.7035C14.2492 12.7884 12.2386 15.7171 11.2866 19.0465C10.3346 22.3758 10.4928 25.9246 11.7373 29.1561C12.9818 32.3875 15.2449 35.1256 18.1841 36.9563C13.3444 38.5408 8.98963 41.5009 5.48807 45.6681C5.25882 45.9406 5.11192 46.2727 5.06453 46.6256C5.01714 46.9786 5.07123 47.3376 5.22047 47.6609C5.3697 47.9842 5.60792 48.2584 5.90725 48.4512C6.20658 48.6442 6.55464 48.7478 6.91073 48.7501H46.2857C46.643 48.7504 46.993 48.6487 47.2943 48.4569C47.5958 48.265 47.8361 47.9911 47.9871 47.6673C48.138 47.3434 48.1933 46.9833 48.1464 46.6291C48.0995 46.2748 47.9524 45.9415 47.7225 45.6681C44.2186 41.5009 39.8638 38.5408 35.0357 36.9563Z" fill="black"/>
+</g>
+</g>
+</svg>
diff --git a/src/App.tsx b/src/App.tsx
index 106147f6..4358aa9e 100644
--- a/src/App.tsx
+++ b/src/App.tsx
@@ -25,6 +25,7 @@ import {
   FriendChatRoomPage,
   GameSelectPage,
   GameAutoMatchingPage,
+  GameCustomMatchingPage,
 } from '@/pages';
 
 const App = () => {
@@ -82,6 +83,7 @@ const App = () => {
         { path: PATH.FRIEND_CHATROOM, element: <FriendChatRoomPage /> },
         { path: PATH.GAME_SELECT, element: <GameSelectPage /> },
         { path: PATH.GAME_AUTO_MATCHING, element: <GameAutoMatchingPage /> },
+        { path: PATH.GAME_CUSTOM_MATCHING, element: <GameCustomMatchingPage /> },
       ],
     },
   ];
diff --git a/src/constants/routes.ts b/src/constants/routes.ts
index edaeac0e..d9b9c007 100644
--- a/src/constants/routes.ts
+++ b/src/constants/routes.ts
@@ -6,7 +6,8 @@ export const PATH = {
   SIGNUP: '/signup',
   HISTORY: '/history',
   GAME_SELECT: '/game/select',
-  GAME_AUTO_MATCHING: '/game/automatching',
+  GAME_AUTO_MATCHING: '/game/auto/matching',
+  GAME_CUSTOM_MATCHING: '/game/custom/matching',
 
   FRIEND: '/friend',
   FRIEND_CHATROOM: '/friend/chatroom',
diff --git a/src/pages/GamePage/AutoGameMatchingPage/Auto1vs1/index.tsx b/src/pages/GamePage/AutoGameMatchingPage/Auto1vs1/index.tsx
deleted file mode 100644
index 7b2536cc..00000000
--- a/src/pages/GamePage/AutoGameMatchingPage/Auto1vs1/index.tsx
+++ /dev/null
@@ -1,70 +0,0 @@
-import { useState } from 'react';
-
-import { useUsersMe } from '@/api';
-import { Flex } from '@/components/system';
-import { BackButton } from '@/components/ui';
-
-import * as styles from './styles.css';
-import { UserCard } from '../components/user-card';
-import { WaitingMessage } from '../components/waiting-message';
-
-export const Game1vs1MatchingPage = () => {
-  const { data } = useUsersMe();
-  const playerAvatar = data?.data?.avatarUrl;
-  const playerNickname = data?.data?.nickname;
-
-  // TODO: 상대방이 들어왔을 때의 로직 구현
-  const opponentAvatar = null;
-  const isOpponentWaiting = !opponentAvatar;
-
-  const [isPlayerFirst] = useState(() => {
-    return !opponentAvatar;
-  });
-
-  const playerCard = (
-    <UserCard
-      userAvatar={playerAvatar ?? null}
-      userNickname={playerNickname ?? ''}
-      isPlayer={true}
-      isWaiting={false}
-      mode="1vs1"
-      position={isPlayerFirst ? 'left' : 'right'}
-    />
-  );
-
-  const opponentCard = (
-    <UserCard
-      userAvatar={opponentAvatar}
-      userNickname="OPPONENT"
-      isPlayer={false}
-      isWaiting={isOpponentWaiting}
-      mode="1vs1"
-      position={isPlayerFirst ? 'right' : 'left'}
-    />
-  );
-
-  return (
-    <Flex direction="column" style={{ height: '100%' }}>
-      <BackButton />
-      <h2 className={styles.title}>AUTO SOLO</h2>
-
-      <div className={styles.matchArea}>
-        {isPlayerFirst ? (
-          <>
-            {playerCard}
-            <span className={styles.vs}>VS</span>
-            {opponentCard}
-          </>
-        ) : (
-          <>
-            {opponentCard}
-            <span className={styles.vs}>VS</span>
-            {playerCard}
-          </>
-        )}
-      </div>
-
-      <WaitingMessage isWaiting={isOpponentWaiting} />
-    </Flex>
-  );
-};
diff --git a/src/pages/GamePage/AutoGameMatchingPage/components/user-card/index.tsx b/src/pages/GamePage/AutoGameMatchingPage/components/user-card/index.tsx
deleted file mode 100644
index fc31ce6a..00000000
--- a/src/pages/GamePage/AutoGameMatchingPage/components/user-card/index.tsx
+++ /dev/null
@@ -1,44 +0,0 @@
-import * as styles from './styles.css';
-
-type UserCardProps = {
-  userAvatar: string | null;
-  userNickname: string;
-  isPlayer: boolean;
-  isWaiting?: boolean;
-  mode?: '1vs1' | 'tournament';
-  position: 'left' | 'right';
-};
-
-export const UserCard = ({
-  userAvatar,
-  userNickname,
-  isPlayer,
-  position,
-  isWaiting = false,
-  mode = '1vs1',
-}: UserCardProps) => {
-  const shouldFlipGun = mode === '1vs1' && position === 'right';
-  const userCardClass = `${styles.userCard} ${isPlayer ? styles.playerColor : styles.opponentColor}`;
-
-  return (
-    <div className={userCardClass}>
-      <div className={styles.avatar}>
-        {isWaiting ? (
-          <img src="/assets/images/spinner.svg" alt="spinner" className={styles.spinner} />
-        ) : (
-          <img
-            src={userAvatar ?? ''}
-            alt={isPlayer ? 'user avatar' : 'opponent avatar'}
-            className={styles.avatarImage}
-          />
-        )}
-      </div>
-      <img
-        src="/assets/images/gun.svg"
-        alt="gun"
-        className={shouldFlipGun ? styles.gunReversed : styles.gun}
-      />
-      <p className={styles.nickname}>{isWaiting ? '-' : userNickname}</p>
-    </div>
-  );
-};
diff --git a/src/pages/GamePage/AutoGameMatchingPage/components/user-card/styles.css.ts b/src/pages/GamePage/AutoGameMatchingPage/components/user-card/styles.css.ts
deleted file mode 100644
index 7aa9415d..00000000
--- a/src/pages/GamePage/AutoGameMatchingPage/components/user-card/styles.css.ts
+++ /dev/null
@@ -1,66 +0,0 @@
-import { keyframes, style } from '@vanilla-extract/css';
-
-import { rem, theme } from '@/styles';
-
-export const userCard = style({
-  width: rem(200),
-  height: rem(340),
-  display: 'flex',
-  flexDirection: 'column',
-  justifyContent: 'center',
-  alignItems: 'center',
-  gap: rem(20),
-});
-
-export const playerColor = style({
-  backgroundColor: theme.color.playerCard,
-});
-
-export const opponentColor = style({
-  backgroundColor: theme.color.opponentCard,
-});
-
-export const avatar = style({
-  width: rem(120),
-  height: rem(120),
-  borderRadius: '50%',
-  backgroundColor: theme.color.white,
-  display: 'flex',
-  alignItems: 'center',
-  justifyContent: 'center',
-  overflow: 'hidden',
-});
-
-const spin = keyframes({
-  '0%': { transform: 'rotate(0deg)' },
-  '100%': { transform: 'rotate(360deg)' },
-});
-
-export const spinner = style({
-  width: rem(40),
-  height: rem(40),
-  animation: `${spin} ${theme.animation.spinnerSlow}`,
-});
-
-export const avatarImage = style({
-  width: '100%',
-  height: '100%',
-  objectFit: 'cover',
-});
-
-export const gun = style({
-  width: rem(110),
-  height: rem(110),
-});
-
-export const gunReversed = style([
-  gun,
-  {
-    transform: 'scaleX(-1)',
-  },
-]);
-
-export const nickname = style({
-  color: theme.color.white,
-  fontSize: rem(20),
-});
diff --git a/src/pages/GamePage/AutoGameMatchingPage/components/waiting-message/index.tsx b/src/pages/GamePage/AutoGameMatchingPage/components/waiting-message/index.tsx
deleted file mode 100644
index d7ab5053..00000000
--- a/src/pages/GamePage/AutoGameMatchingPage/components/waiting-message/index.tsx
+++ /dev/null
@@ -1,28 +0,0 @@
-import { useEffect, useState } from 'react';
-
-import * as styles from './styles.css';
-
-type WaitingMessageProps = {
-  isWaiting: boolean;
-};
-
-export const WaitingMessage = ({ isWaiting }: WaitingMessageProps) => {
-  const [dotCount, setDotCount] = useState(1);
-
-  useEffect(() => {
-    if (!isWaiting) return;
-
-    const interval = setInterval(() => {
-      setDotCount((prev) => (prev % 5) + 1);
-    }, 500);
-
-    return () => clearInterval(interval);
-  }, [isWaiting]);
-
-  const dots = '.'.repeat(dotCount);
-  const text = isWaiting ? `Waiting for opponents${dots}` : "You're matched!";
-
-  const className = `${styles.waiting} ${isWaiting ? styles.pending : styles.matched}`;
-
-  return <p className={className}>{text}</p>;
-};
diff --git a/src/pages/GamePage/AutoGameMatchingPage/components/waiting-message/styles.css.ts b/src/pages/GamePage/AutoGameMatchingPage/components/waiting-message/styles.css.ts
deleted file mode 100644
index 00b64d7a..00000000
--- a/src/pages/GamePage/AutoGameMatchingPage/components/waiting-message/styles.css.ts
+++ /dev/null
@@ -1,18 +0,0 @@
-import { style } from '@vanilla-extract/css';
-
-import { rem, theme } from '@/styles';
-
-export const waiting = style({
-  fontFamily: 'Tiny5, sans-serif',
-  marginTop: rem(30),
-  textAlign: 'center',
-  fontSize: rem(40),
-});
-
-export const pending = style({
-  color: theme.color.waiting,
-});
-
-export const matched = style({
-  color: theme.color.matched,
-});
diff --git a/src/pages/GamePage/GameMatchingPage/AutoGameMatchingPage/Auto1vs1/index.tsx b/src/pages/GamePage/GameMatchingPage/AutoGameMatchingPage/Auto1vs1/index.tsx
new file mode 100644
index 00000000..91aaf696
--- /dev/null
+++ b/src/pages/GamePage/GameMatchingPage/AutoGameMatchingPage/Auto1vs1/index.tsx
@@ -0,0 +1,64 @@
+import { useState } from 'react';
+
+import { useUsersMe } from '@/api';
+import { Flex } from '@/components/system';
+import { BackButton } from '@/components/ui';
+
+import * as styles from './styles.css';
+import { UserCard } from '../../components/user-card';
+import { WaitingMessage } from '../../components/waiting-message';
+
+export const Game1vs1MatchingPage = () => {
+  const { data } = useUsersMe();
+  const playerAvatar = data?.data?.avatarUrl ?? null;
+  const playerNickname = data?.data?.nickname ?? '';
+
+  const opponentAvatar: string | null = null;
+  const isOpponentWaiting = !opponentAvatar;
+  const [isPlayerFirst] = useState(() => !opponentAvatar);
+
+  const playerProps = {
+    userAvatar: playerAvatar,
+    userNickname: playerNickname,
+    isPlayer: true,
+    isWaiting: false,
+    mode: '1vs1' as const,
+    option: 'auto' as const,
+    isHostUser: false,
+  };
+
+  const opponentProps = {
+    userAvatar: opponentAvatar,
+    userNickname: 'OPPONENT',
+    isPlayer: false,
+    isWaiting: isOpponentWaiting,
+    mode: '1vs1' as const,
+    option: 'auto' as const,
+    isHostUser: false,
+  };
+
+  return (
+    <Flex direction="column" style={{ height: '100%' }}>
+      <BackButton />
+      <h2 className={styles.title}>AUTO SOLO</h2>
+
+      <div className={styles.matchArea}>
+        {isPlayerFirst ? (
+          <>
+            <UserCard key="player" {...playerProps} position="left" />
+            <span className={styles.vs}>VS</span>
+            <UserCard key="opponent" {...opponentProps} position="right" />
+          </>
+        ) : (
+          <>
+            <UserCard key="opponent" {...opponentProps} position="left" />
+            <span className={styles.vs}>VS</span>
+            <UserCard key="player" {...playerProps} position="right" />
+          </>
+        )}
+      </div>
+
+      <WaitingMessage isWaiting={isOpponentWaiting} option="auto" isHost={false} />
+    </Flex>
+  );
+};
diff --git a/src/pages/GamePage/AutoGameMatchingPage/Auto1vs1/styles.css.ts b/src/pages/GamePage/GameMatchingPage/AutoGameMatchingPage/Auto1vs1/styles.css.ts
similarity index 100%
rename from src/pages/GamePage/AutoGameMatchingPage/Auto1vs1/styles.css.ts
rename to src/pages/GamePage/GameMatchingPage/AutoGameMatchingPage/Auto1vs1/styles.css.ts
diff --git a/src/pages/GamePage/AutoGameMatchingPage/AutoCustom/index.tsx b/src/pages/GamePage/GameMatchingPage/AutoGameMatchingPage/AutoTournament/index.tsx
similarity index 67%
rename from src/pages/GamePage/AutoGameMatchingPage/AutoCustom/index.tsx
rename to src/pages/GamePage/GameMatchingPage/AutoGameMatchingPage/AutoTournament/index.tsx
index 9ac6f690..de84a9e2 100644
--- a/src/pages/GamePage/AutoGameMatchingPage/AutoCustom/index.tsx
+++ b/src/pages/GamePage/GameMatchingPage/AutoGameMatchingPage/AutoTournament/index.tsx
@@ -3,13 +3,13 @@ import { Flex } from '@/components/system';
 import { BackButton } from '@/components/ui';
 
 import * as styles from './styles.css';
-import { UserCard } from '../components/user-card';
-import { WaitingMessage } from '../components/waiting-message';
+import { UserCard } from '../../components/user-card';
+import { WaitingMessage } from '../../components/waiting-message';
 
 export const GameTournamentMatchingPage = () => {
   const { data } = useUsersMe();
-  const playerAvatar = data?.data?.avatarUrl;
-  const playerNickname = data?.data?.nickname;
+  const playerAvatar = data?.data?.avatarUrl ?? null;
+  const playerNickname = data?.data?.nickname ?? '';
 
   // TODO: 상대방이 들어왔을 경우에 대한 처리
   const slots = ['/assets/images/sample1.png', playerAvatar, null, '/assets/images/sample2.png'];
@@ -29,20 +29,19 @@ export const GameTournamentMatchingPage = () => {
           return (
             <UserCard
               key={index}
-              userAvatar={avatar || null}
-              userNickname={
-                isWaiting ? '-' : isPlayer ? (playerNickname ?? '') : `OPPONENT ${index + 1}`
-              }
+              userAvatar={avatar ?? null}
+              userNickname={isWaiting ? '-' : isPlayer ? playerNickname : `OPPONENT ${index + 1}`}
               isPlayer={isPlayer}
               isWaiting={isWaiting}
               mode="tournament"
-              position={index % 2 === 0 ? 'left' : 'right'}
+              option="auto"
+              isHostUser={false}
             />
           );
         })}
       </div>
 
-      <WaitingMessage isWaiting={!allMatched} />
+      <WaitingMessage isWaiting={!allMatched} option="auto" isHost={false} />
     </Flex>
   );
 };
diff --git a/src/pages/GamePage/AutoGameMatchingPage/AutoCustom/styles.css.ts b/src/pages/GamePage/GameMatchingPage/AutoGameMatchingPage/AutoTournament/styles.css.ts
similarity index 100%
rename from src/pages/GamePage/AutoGameMatchingPage/AutoCustom/styles.css.ts
rename to src/pages/GamePage/GameMatchingPage/AutoGameMatchingPage/AutoTournament/styles.css.ts
diff --git a/src/pages/GamePage/AutoGameMatchingPage/index.tsx b/src/pages/GamePage/GameMatchingPage/AutoGameMatchingPage/index.tsx
similarity index 86%
rename from src/pages/GamePage/AutoGameMatchingPage/index.tsx
rename to src/pages/GamePage/GameMatchingPage/AutoGameMatchingPage/index.tsx
index 1cff9e70..827a0e78 100644
--- a/src/pages/GamePage/AutoGameMatchingPage/index.tsx
+++ b/src/pages/GamePage/GameMatchingPage/AutoGameMatchingPage/index.tsx
@@ -1,7 +1,7 @@
 import { useSearchParams } from 'react-router-dom';
 
 import { Game1vs1MatchingPage } from './Auto1vs1';
-import { GameTournamentMatchingPage } from './AutoCustom';
+import { GameTournamentMatchingPage } from './AutoTournament';
 
 export const GameAutoMatchingPage = () => {
   const [searchParams] = useSearchParams();
diff --git a/src/pages/GamePage/GameMatchingPage/CustomGameMatchingPage/Custom1vs1/index.tsx b/src/pages/GamePage/GameMatchingPage/CustomGameMatchingPage/Custom1vs1/index.tsx
new file mode 100644
index 00000000..46f84aa6
--- /dev/null
+++ b/src/pages/GamePage/GameMatchingPage/CustomGameMatchingPage/Custom1vs1/index.tsx
@@ -0,0 +1,78 @@
+import { useUsersMe } from '@/api';
+import { Flex } from '@/components/system';
+import { BackButton } from '@/components/ui';
+
+import * as styles from './styles.css';
+import { UserCard } from '../../components/user-card';
+import { WaitingMessage } from '../../components/waiting-message';
+
+// 테스트 시나리오
+const TEST_SCENARIO = {
+  opponentExists: false,
+  iAmHost: true,
+};
+
+export const Game1vs1MatchingPage = () => {
+  const { data } = useUsersMe();
+  const playerAvatar = data?.data?.avatarUrl ?? null;
+  const playerNickname = data?.data?.nickname ?? '';
+
+  const opponentAvatar: string | null = TEST_SCENARIO.opponentExists
+    ? '/assets/images/sample-avatar.png'
+    : null;
+
+  const isOpponentWaiting = !opponentAvatar;
+  const isPlayerHost = TEST_SCENARIO.iAmHost;
+  const opponentIsHost = !isPlayerHost;
+
+  const handleInviteFriend = () => {
+    console.error('Invite friend clicked');
+  };
+
+  const playerProps = {
+    userAvatar: playerAvatar,
+    userNickname: playerNickname,
+    isPlayer: true,
+    isWaiting: false,
+    mode: '1vs1' as const,
+    option: 'custom' as const,
+    isHostUser: isPlayerHost,
+  };
+
+  const opponentProps = {
+    userAvatar: opponentAvatar,
+    userNickname: 'OPPONENT',
+    isPlayer: false,
+    isWaiting: isOpponentWaiting,
+    mode: '1vs1' as const,
+    option: 'custom' as const,
+    isHostUser: isPlayerHost,
+    isPlayerHost: opponentIsHost,
+    onClickAdd: handleInviteFriend,
+  };
+
+  const [leftCard, rightCard] = isPlayerHost
+    ? [
+        <UserCard key="player" {...playerProps} position="left" />,
+        <UserCard key="opponent" {...opponentProps} position="right" />,
+      ]
+    : [
+        <UserCard key="opponent" {...opponentProps} position="left" />,
+        <UserCard key="player" {...playerProps} position="right" />,
+      ];
+
+  return (
+    <Flex direction="column" style={{ height: '100%' }}>
+      <BackButton />
+      <h2 className={styles.title}>CUSTOM SOLO</h2>
+
+      <div className={styles.matchArea}>
+        {leftCard}
+        <span className={styles.vs}>VS</span>
+        {rightCard}
+      </div>
+
+      <WaitingMessage isWaiting={isOpponentWaiting} option="custom" isHost={isPlayerHost} />
+    </Flex>
+  );
+};
diff --git a/src/pages/GamePage/GameMatchingPage/CustomGameMatchingPage/Custom1vs1/styles.css.ts b/src/pages/GamePage/GameMatchingPage/CustomGameMatchingPage/Custom1vs1/styles.css.ts
new file mode 100644
index 00000000..3063d299
--- /dev/null
+++ b/src/pages/GamePage/GameMatchingPage/CustomGameMatchingPage/Custom1vs1/styles.css.ts
@@ -0,0 +1,25 @@
+import { style } from '@vanilla-extract/css';
+
+import { rem, theme } from '@/styles';
+
+export const title = style({
+  marginBottom: rem(20),
+  color: theme.color.white,
+  fontFamily: 'Tiny5, sans-serif',
+  fontSize: rem(50),
+  textAlign: 'center',
+});
+
+export const matchArea = style({
+  display: 'flex',
+  justifyContent: 'center',
+  alignItems: 'center',
+});
+
+export const vs = style({
+  color: theme.color.white,
+  fontFamily: 'Tiny5, sans-serif',
+  fontSize: rem(60),
+  marginLeft: rem(80),
+  marginRight: rem(80),
+});
diff --git a/src/pages/GamePage/GameMatchingPage/CustomGameMatchingPage/CustomTournament/index.tsx b/src/pages/GamePage/GameMatchingPage/CustomGameMatchingPage/CustomTournament/index.tsx
new file mode 100644
index 00000000..6482d98d
--- /dev/null
+++ b/src/pages/GamePage/GameMatchingPage/CustomGameMatchingPage/CustomTournament/index.tsx
@@ -0,0 +1,70 @@
+import { useUsersMe } from '@/api';
+import { Flex } from '@/components/system';
+import { BackButton } from '@/components/ui';
+
+import * as styles from './styles.css';
+import { UserCard } from '../../components/user-card';
+import { WaitingMessage } from '../../components/waiting-message';
+
+const TEST_SCENARIO = {
+  playerIndex: 2, // 현재 유저의 슬롯 번호 (0~3)
+  hostIndex: 2, // 방장의 슬롯 번호
+  opponentStates: [
+    true, // 0번 슬롯: 상대 있음 (방장임)
+    false, // 1번 슬롯: 나
+    true, // 2번 슬롯: 대기중
+    true, // 3번 슬롯: 상대 있음
+  ],
+};
+
+export const GameTournamentMatchingPage = () => {
+  const { data } = useUsersMe();
+  const playerAvatar = data?.data?.avatarUrl ?? null;
+  const playerNickname = data?.data?.nickname ?? '';
+
+  const isHostUser = TEST_SCENARIO.playerIndex === TEST_SCENARIO.hostIndex;
+
+  const slots = TEST_SCENARIO.opponentStates.map((exists, index) => {
+    if (index === TEST_SCENARIO.playerIndex) return playerAvatar;
+    return exists ? `/assets/images/sample${index + 1}.png` : null;
+  });
+
+  const allMatched = slots.every((avatar) => avatar !== null);
+
+  return (
+    <Flex direction="column" style={{ height: '100%' }}>
+      <BackButton />
+      <h2 className={styles.title}>CUSTOM TOURNAMENT</h2>
+
+      <div className={styles.matchArea}>
+        {slots.map((avatar, index) => {
+          const isPlayer = index === TEST_SCENARIO.playerIndex;
+          const isWaiting = avatar === null;
+          const isPlayerHost = index === TEST_SCENARIO.hostIndex;
+
+          return (
+            <UserCard
+              key={index}
+              userAvatar={avatar}
+              userNickname={isWaiting ? '-' : isPlayer ? playerNickname : `OPPONENT ${index + 1}`}
+              isPlayer={isPlayer}
+              isWaiting={isWaiting}
+              position={index % 2 === 0 ? 'left' : 'right'}
+              mode="tournament"
+              option="custom"
+              isHostUser={isHostUser}
+              isPlayerHost={isPlayerHost}
+              onClickAdd={
+                !isPlayer && isWaiting && isHostUser
+                  ? () => console.error(`Invite slot ${index}`)
+                  : undefined
+              }
+            />
+          );
+        })}
+      </div>
+
+      <WaitingMessage isWaiting={!allMatched} option="custom" isHost={isHostUser} />
+    </Flex>
+  );
+};
diff --git a/src/pages/GamePage/GameMatchingPage/CustomGameMatchingPage/CustomTournament/styles.css.ts b/src/pages/GamePage/GameMatchingPage/CustomGameMatchingPage/CustomTournament/styles.css.ts
new file mode 100644
index 00000000..4b1a80b5
--- /dev/null
+++ b/src/pages/GamePage/GameMatchingPage/CustomGameMatchingPage/CustomTournament/styles.css.ts
@@ -0,0 +1,26 @@
+import { style } from '@vanilla-extract/css';
+
+import { rem, theme } from '@/styles';
+
+export const title = style({
+  marginBottom: rem(20),
+  color: theme.color.white,
+  fontFamily: 'Tiny5, sans-serif',
+  fontSize: rem(50),
+  textAlign: 'center',
+});
+
+export const matchArea = style({
+  display: 'flex',
+  justifyContent: 'center',
+  alignItems: 'center',
+  gap: rem(20),
+});
+
+export const vs = style({
+  color: theme.color.white,
+  fontFamily: 'Tiny5, sans-serif',
+  fontSize: rem(60),
+  marginLeft: rem(80),
+  marginRight: rem(80),
+});
diff --git a/src/pages/GamePage/GameMatchingPage/CustomGameMatchingPage/index.tsx b/src/pages/GamePage/GameMatchingPage/CustomGameMatchingPage/index.tsx
new file mode 100644
index 00000000..e07ed70d
--- /dev/null
+++ b/src/pages/GamePage/GameMatchingPage/CustomGameMatchingPage/index.tsx
@@ -0,0 +1,18 @@
+import { useSearchParams } from 'react-router-dom';
+
+import { Game1vs1MatchingPage } from './Custom1vs1';
+import { GameTournamentMatchingPage } from './CustomTournament';
+
+export const GameCustomMatchingPage = () => {
+  const [searchParams] = useSearchParams();
+  const mode = searchParams.get('mode');
+
+  if (!mode) return;
+
+  return (
+    <div>
+      {mode === '1vs1' && <Game1vs1MatchingPage />}
+      {mode === 'tournament' && <GameTournamentMatchingPage />}
+    </div>
+  );
+};
diff --git a/src/pages/GamePage/GameMatchingPage/components/invite-friend/index.tsx b/src/pages/GamePage/GameMatchingPage/components/invite-friend/index.tsx
new file mode 100644
index 00000000..835215f4
--- /dev/null
+++ b/src/pages/GamePage/GameMatchingPage/components/invite-friend/index.tsx
@@ -0,0 +1,65 @@
+import { useState } from 'react';
+
+import { Flex } from '@/components/system';
+
+import * as styles from './styles.css.ts';
+
+type User = {
+  id: number;
+  nickname: string;
+  avatarUrl: string;
+};
+
+const mockUsers: User[] = [
+  { id: 1, nickname: 'Woongbi', avatarUrl: '/assets/images/sample-avatar.png' },
+  { id: 2, nickname: 'WOfagonSlayer', avatarUrl: '/assets/images/sample-avatar.png' },
+  { id: 3, nickname: 'PixelQueen', avatarUrl: '/assets/images/sample-avatar.png' },
+  { id: 4, nickname: 'Knightmare', avatarUrl: '/assets/images/sample-avatar.png' },
+];
+
+export const InviteFriend = () => {
+  const [searchTerm, setSearchTerm] = useState('');
+  const [requestedIds, setRequestedIds] = useState<number[]>([]);
+
+  const filteredUsers = mockUsers.filter((user) =>
+    user.nickname.toLowerCase().startsWith(searchTerm.toLowerCase()),
+  );
+
+  const handleRequest = (id: number, nickname: string) => {
+    if (requestedIds.includes(id)) return;
+    setRequestedIds((prev) => [...prev, id]);
+    alert(`Friend request sent to ${nickname}`);
+  };
+
+  return (
+    <Flex direction="column" alignItems="center" style={{ height: '100%' }}>
+      <h2 className={styles.title}>Add friend</h2>
+
+      <input
+        className={styles.input}
+        placeholder="Please enter nickname."
+        value={searchTerm}
+        onChange={(e) => setSearchTerm(e.target.value)}
+      />
+
+      <div className={styles.userList}>
+        {filteredUsers.map((user) => (
+          <div key={user.id} className={styles.userCard}>
+            <div className={styles.leftInfo}>
+              <img src={user.avatarUrl} alt={user.nickname} className={styles.avatar} />
+              <span className={styles.nickname}>{user.nickname}</span>
+            </div>
+
+            <button
+              className={
+                requestedIds.includes(user.id) ? styles.requestedButton : styles.inviteRequestButton
+              }
+              onClick={() => handleRequest(user.id, user.nickname)}
+              aria-label="Send friend request"
+            />
+          </div>
+        ))}
+      </div>
+    </Flex>
+  );
+};
diff --git a/src/pages/GamePage/GameMatchingPage/components/invite-friend/styles.css.ts b/src/pages/GamePage/GameMatchingPage/components/invite-friend/styles.css.ts
new file mode 100644
index 00000000..9f8c19d1
--- /dev/null
+++ b/src/pages/GamePage/GameMatchingPage/components/invite-friend/styles.css.ts
@@ -0,0 +1,87 @@
+import { style } from '@vanilla-extract/css';
+
+import { rem, theme } from '@/styles';
+
+export const title = style({
+  marginBottom: rem(20),
+  color: theme.color.white,
+  fontFamily: theme.fontFamily.branding,
+  fontSize: rem(25),
+  lineHeight: 1.2,
+});
+
+export const input = style({
+  width: rem(350),
+  height: rem(36),
+  fontSize: rem(16),
+  borderRadius: rem(12),
+  marginBottom: rem(24),
+  padding: `0 ${rem(12)}`,
+  boxSizing: 'border-box',
+  textAlign: 'center',
+});
+
+export const userList = style({
+  display: 'flex',
+  flexDirection: 'column',
+  gap: rem(10),
+  maxHeight: rem(200),
+  overflowY: 'auto',
+  scrollbarGutter: 'stable',
+  paddingRight: rem(4),
+});
+
+export const userCard = style({
+  width: rem(340),
+  height: rem(60),
+  border: `${rem(3)} solid ${theme.color.white}`,
+  borderRadius: rem(10),
+  display: 'flex',
+  alignItems: 'center',
+  justifyContent: 'space-between',
+  padding: `0 ${rem(20)}`,
+  boxSizing: 'border-box',
+  transition: 'background-color 0.2s ease-in-out',
+  flexShrink: 0,
+
+  selectors: {
+    '&:hover': {
+      backgroundColor: theme.color.friendHover,
+    },
+  },
+});
+
+export const leftInfo = style({
+  display: 'flex',
+  alignItems: 'center',
+  gap: rem(12),
+});
+
+export const avatar = style({
+  width: rem(49),
+  height: rem(49),
+  marginTop: rem(4),
+  marginLeft: rem(10),
+  marginRight: rem(30),
+});
+
+export const nickname = style({
+  color: theme.color.white,
+  fontSize: rem(20),
+});
+
+export const inviteRequestButton = style({
+  width: rem(36),
+  height: rem(36),
+  marginRight: rem(20),
+  background: "url('/assets/images/request-friend.svg') center/contain no-repeat",
+});
+
+export const requestedButton = style([
+  inviteRequestButton,
+  {
+    background: "url('/assets/images/request-success.svg') center/contain no-repeat",
+    cursor: 'default',
+    opacity: 1,
+  },
+]);
diff --git a/src/pages/GamePage/GameMatchingPage/components/request-list-dialog/index.tsx b/src/pages/GamePage/GameMatchingPage/components/request-list-dialog/index.tsx
new file mode 100644
index 00000000..4c4899c2
--- /dev/null
+++ b/src/pages/GamePage/GameMatchingPage/components/request-list-dialog/index.tsx
@@ -0,0 +1,42 @@
+import { ReactNode } from 'react';
+
+import {
+  Dialog,
+  DialogClose,
+  DialogContent,
+  DialogOverlay,
+  DialogTrigger,
+} from '@/components/system/dialog';
+
+import * as styles from './styles.css.ts';
+import { InviteFriend } from '../invite-friend/index.tsx';
+
+type DialogType = 'inviteFriend';
+
+type RequestListDialogProps = {
+  type: DialogType;
+  children?: ReactNode;
+};
+
+export const RequestListDialog = ({ type, children }: RequestListDialogProps) => {
+  const renderContent = () => {
+    switch (type) {
+      case 'inviteFriend':
+        return <InviteFriend />;
+      default:
+        return null;
+    }
+  };
+
+  return (
+    <Dialog>
+      <DialogTrigger>{children}</DialogTrigger>
+      <DialogOverlay />
+
+      <DialogContent className={styles.fixedDialogContent}>
+        {renderContent()}
+        <DialogClose />
+      </DialogContent>
+    </Dialog>
+  );
+};
diff --git a/src/pages/GamePage/GameMatchingPage/components/request-list-dialog/styles.css.ts b/src/pages/GamePage/GameMatchingPage/components/request-list-dialog/styles.css.ts
new file mode 100644
index 00000000..3afb8e38
--- /dev/null
+++ b/src/pages/GamePage/GameMatchingPage/components/request-list-dialog/styles.css.ts
@@ -0,0 +1,10 @@
+import { style } from '@vanilla-extract/css';
+
+import { rem } from '@/styles';
+
+export const fixedDialogContent = style({
+  width: rem(495),
+  minHeight: rem(385),
+  padding: rem(24),
+  borderRadius: rem(12),
+});
diff --git a/src/pages/GamePage/GameMatchingPage/components/user-card/index.tsx b/src/pages/GamePage/GameMatchingPage/components/user-card/index.tsx
new file mode 100644
index 00000000..9202c6cc
--- /dev/null
+++ b/src/pages/GamePage/GameMatchingPage/components/user-card/index.tsx
@@ -0,0 +1,86 @@
+import { RequestListDialog } from '../request-list-dialog';
+import * as styles from './styles.css';
+
+type UserCardProps = {
+  userAvatar: string | null;
+  userNickname: string;
+  isPlayer: boolean; // 현재 유저인지 여부
+  isWaiting: boolean;
+  mode: '1vs1' | 'tournament';
+  option: 'auto' | 'custom';
+  position?: 'left' | 'right';
+  isHostUser: boolean; // 현재 유저가 방장인지
+  isPlayerHost?: boolean;
+  onClickAdd?: () => void;
+};
+
+export const UserCard = ({
+  userAvatar,
+  userNickname,
+  isPlayer,
+  isWaiting,
+  mode,
+  option,
+  position,
+  isHostUser,
+  isPlayerHost,
+  onClickAdd,
+}: UserCardProps) => {
+  const shouldFlipGun = mode === '1vs1' && position === 'right';
+
+  const isCurrentUser = isPlayer;
+
+  // 카드 주인이 방장인지: 외부에서 전달되면 사용, 없으면 내부 계산
+  const isCardOwnerHost = isPlayerHost ?? (isPlayer && isHostUser);
+
+  const userCardClass = [
+    styles.userCard[mode],
+    isPlayer ? styles.playerColor : styles.opponentColor,
+    isCardOwnerHost ? styles.hostBorder : '',
+  ].join(' ');
+
+  const renderAvatar = () => {
+    if (option === 'custom' && !isCurrentUser && isWaiting && isHostUser) {
+      return (
+        <RequestListDialog type="inviteFriend">
+          <button className={styles.addButton} onClick={onClickAdd} />
+        </RequestListDialog>
+      );
+    }
+
+    if (!isWaiting) {
+      return (
+        <img
+          src={userAvatar ?? ''}
+          alt={isPlayer ? 'user avatar' : 'opponent avatar'}
+          className={styles.avatarImage}
+        />
+      );
+    }
+
+    if (option === 'auto') {
+      return <img src="/assets/images/spinner.svg" alt="spinner" className={styles.spinner} />;
+    }
+
+    return null;
+  };
+
+  return (
+    <div className={userCardClass}>
+      <div className={styles.avatarWrapper}>
+        <div className={styles.avatar}>{renderAvatar()}</div>
+        {isCardOwnerHost && <p className={styles.masterLabel}>Master</p>}
+      </div>
+
+      <div className={styles.gunWrapper}>
+        <img
+          src="/assets/images/gun.svg"
+          alt="gun"
+          className={shouldFlipGun ? styles.gunReversed : styles.gun}
+        />
+      </div>
+
+      <p className={styles.nickname}>{isWaiting ? '-' : userNickname}</p>
+    </div>
+  );
+};
diff --git a/src/pages/GamePage/GameMatchingPage/components/user-card/styles.css.ts b/src/pages/GamePage/GameMatchingPage/components/user-card/styles.css.ts
new file mode 100644
index 00000000..1d8b095c
--- /dev/null
+++ b/src/pages/GamePage/GameMatchingPage/components/user-card/styles.css.ts
@@ -0,0 +1,123 @@
+import { styleVariants, keyframes, style } from '@vanilla-extract/css';
+
+import { rem, theme } from '@/styles';
+
+export const userCard = styleVariants({
+  '1vs1': {
+    width: rem(200),
+    height: rem(340),
+    backgroundColor: theme.color.playerCard,
+    display: 'flex',
+    flexDirection: 'column',
+    alignItems: 'center',
+    justifyContent: 'space-between',
+    padding: rem(16),
+    boxSizing: 'border-box',
+    position: 'relative',
+    flexShrink: 0,
+  },
+  tournament: {
+    width: rem(170),
+    height: rem(340),
+    backgroundColor: theme.color.playerCard,
+    display: 'flex',
+    flexDirection: 'column',
+    alignItems: 'center',
+    justifyContent: 'space-between',
+    padding: rem(16),
+    boxSizing: 'border-box',
+    position: 'relative',
+    flexShrink: 0,
+  },
+});
+
+export const playerColor = style({
+  backgroundColor: theme.color.playerCard,
+});
+
+export const opponentColor = style({
+  backgroundColor: theme.color.opponentCard,
+});
+
+export const hostBorder = style({
+  boxShadow: `0 0 0 ${rem(4)} ${theme.color.yellow}`,
+});
+
+export const avatarWrapper = style({
+  position: 'relative',
+  width: rem(120),
+  height: rem(140),
+  display: 'flex',
+  justifyContent: 'center',
+});
+
+export const avatar = style({
+  width: rem(120),
+  height: rem(120),
+  borderRadius: '50%',
+  overflow: 'hidden',
+  backgroundColor: theme.color.white,
+  display: 'flex',
+  alignItems: 'center',
+  justifyContent: 'center',
+});
+
+export const avatarImage = style({
+  width: '100%',
+  height: '100%',
+  objectFit: 'cover',
+});
+
+export const masterLabel = style({
+  position: 'absolute',
+  top: rem(122),
+  fontSize: rem(16),
+  fontFamily: 'Tiny5',
+  color: theme.color.yellow,
+  textAlign: 'center',
+  width: '100%',
+});
+
+export const gunWrapper = style({
+  display: 'flex',
+  justifyContent: 'center',
+  alignItems: 'center',
+  width: '100%',
+  height: rem(110),
+});
+
+export const gun = style({
+  width: rem(110),
+  height: rem(110),
+  objectFit: 'contain',
+});
+
+export const gunReversed = style([
+  gun,
+  {
+    transform: 'scaleX(-1)',
+  },
+]);
+
+export const nickname = style({
+  color: theme.color.white,
+  fontSize: rem(20),
+  textAlign: 'center',
+});
+
+const spin = keyframes({
+  '0%': { transform: 'rotate(0deg)' },
+  '100%': { transform: 'rotate(360deg)' },
+});
+
+export const spinner = style({
+  width: rem(40),
+  height: rem(40),
+  animation: `${spin} ${theme.animation.spinnerSlow}`,
+});
+
+export const addButton = style({
+  width: rem(60),
+  height: rem(60),
+  background: "url('/assets/images/invite-friend.svg') center/contain no-repeat",
+});
diff --git a/src/pages/GamePage/GameMatchingPage/components/waiting-message/index.tsx b/src/pages/GamePage/GameMatchingPage/components/waiting-message/index.tsx
new file mode 100644
index 00000000..1766a7ce
--- /dev/null
+++ b/src/pages/GamePage/GameMatchingPage/components/waiting-message/index.tsx
@@ -0,0 +1,55 @@
+import { useEffect, useState } from 'react';
+
+import * as styles from './styles.css';
+
+type WaitingMessageProps = {
+  isWaiting: boolean;
+  option: 'auto' | 'custom';
+  isHost?: boolean;
+  onStartGame?: () => void;
+};
+
+export const WaitingMessage = ({ isWaiting, option, isHost, onStartGame }: WaitingMessageProps) => {
+  const [dotCount, setDotCount] = useState(1);
+
+  useEffect(() => {
+    if (!isWaiting) return;
+
+    const interval = setInterval(() => {
+      setDotCount((prev) => (prev % 5) + 1);
+    }, 500);
+
+    return () => clearInterval(interval);
+  }, [isWaiting]);
+
+  const dots = '.'.repeat(dotCount);
+
+  if (option == 'auto') {
+    const text = isWaiting ? `Waiting for opponent${dots}` : "You're matched!";
+    const className = `${styles.waiting} ${isWaiting ? styles.pending : styles.matched}`;
+
+    return <p className={className}>{text}</p>;
+  }
+
+  if (option === 'custom') {
+    if (isWaiting) {
+      return <p className={`${styles.waiting} ${styles.pending}`}>Invite your friend{dots}</p>;
+    }
+
+    if (!isWaiting && isHost) {
+      return (
+        <div className={styles.readyContainer}>
+          <button className={styles.startButton} onClick={onStartGame}>
+            Start Game
+          </button>
+        </div>
+      );
+    }
+
+    if (!isWaiting && !isHost) {
+      return <p className={`${styles.waiting} ${styles.matched}`}>You are matched!</p>;
+    }
+  }
+
+  return null;
+};
diff --git a/src/pages/GamePage/GameMatchingPage/components/waiting-message/styles.css.ts b/src/pages/GamePage/GameMatchingPage/components/waiting-message/styles.css.ts
new file mode 100644
index 00000000..acfd1d6c
--- /dev/null
+++ b/src/pages/GamePage/GameMatchingPage/components/waiting-message/styles.css.ts
@@ -0,0 +1,45 @@
+import { style } from '@vanilla-extract/css';
+
+import { rem, theme } from '@/styles';
+
+export const waiting = style({
+  fontFamily: 'Tiny5, sans-serif',
+  marginTop: rem(30),
+  textAlign: 'center',
+  fontSize: rem(40),
+});
+
+export const pending = style({
+  color: theme.color.waiting,
+});
+
+export const matched = style({
+  color: theme.color.matched,
+});
+
+export const readyContainer = style({
+  marginTop: rem(30),
+  display: 'flex',
+  justifyContent: 'center',
+});
+
+export const startButton = style({
+  fontFamily: 'Tiny5',
+  fontSize: rem(28),
+  padding: `${rem(7)} ${rem(24)}`,
+  backgroundColor: 'black',
+  color: theme.color.matched,
+  border: `${rem(2)} solid ${theme.color.white}`,
+  borderRadius: rem(8),
+  cursor: 'pointer',
+  transition: 'transform 0.2s ease',
+
+  selectors: {
+    '&:hover': {
+      transform: 'scale(1.05)',
+    },
+    '&:active': {
+      transform: 'scale(0.98)',
+    },
+  },
+});
diff --git a/src/pages/GamePage/GameSelectPage/index.tsx b/src/pages/GamePage/GameSelectPage/index.tsx
index 153992b3..c675131b 100644
--- a/src/pages/GamePage/GameSelectPage/index.tsx
+++ b/src/pages/GamePage/GameSelectPage/index.tsx
@@ -24,7 +24,7 @@ export const GameSelectPage = () => {
   const handleModeSelect = (selectedMode: '1vs1' | 'tournament') => {
     setMode(selectedMode);
     if (option) {
-      const path = option === 'auto' ? PATH.GAME_AUTO_MATCHING : 'game/custom-matching';
+      const path = option === 'auto' ? PATH.GAME_AUTO_MATCHING : PATH.GAME_CUSTOM_MATCHING;
 
       navigate(`${path}?mode=${selectedMode}`);
     }
diff --git a/src/pages/index.ts b/src/pages/index.ts
index 267f3697..0bf2cf09 100644
--- a/src/pages/index.ts
+++ b/src/pages/index.ts
@@ -7,4 +7,5 @@ export * from './SignUpPage';
 export * from './HistoryPage';
 export * from './ProfilePage';
 export * from './GamePage/GameSelectPage';
-export * from './GamePage/AutoGameMatchingPage';
+export * from './GamePage/GameMatchingPage/AutoGameMatchingPage';
+export * from './GamePage/GameMatchingPage/CustomGameMatchingPage';


Reviews:
user: login: hakilee
body: 고생하셨습니다. 궁금한 부분이 두 가지 정도 있어서 코멘트 남겨요.

먼저, 프로필 아바타가 없는 경우 `null`로 할당을 시키고 있는데, 이 부분이 꼭 필요할까에 대한 의문이 들어요. 이미 옵셔널을 통해 `undefined`이 할당되고 있어 꼭 필요하지 않다는 생각이 들어요.

두 번째는 이전 `FriendPage`의 리뷰에도 제가 작성했던 내용과 동일한데요, `request-list-dialog/index.tsx` 부분에, 타입에 따라서 렌더링을 다르게 하고 계신 것 같아요. 저는 하나의 파일은 하나의 책임을 져야 한다고 생각해요. 따라서, InvateFriendDialog 와 같은 이름의 다이얼로그를 따로 만드는 게 어떨까 싶습니다.

잠깐 생각해 보았을 때, `RequestListDialog`라는 이름만으로는 어떤 요청을 보내는지 알기 힘들고, Props로 받는 type도 어떠한 타입인지 모호해서 해당 파일을 한 번 접속해서 확인해야 하는 불편함이 생깁니다.

물론 이를 '추상화'했다. 라고 표현할 수 있겠지만, 같은 Dialog 스타일을 공유한다면, `@/components/ui/ListDialog` 와 같은 파일을 하나 만드시고, 이를 확장한 `InviteFriendDialog`를 만들어서 이 다이얼로그를 사용하는 곳에서는 어떤 기능을 하는 다이얼로그인지 명확하게 알게 하는 것도 좋을 듯합니다.

이 두 부분은 개인의 취향이 들어가는 영역이라 어떤 의도를 가지고 구현하셨는지에 대한 의견을 듣고 싶습니다.
state: APPROVED
author_association: CONTRIBUTOR
submitted_at: 2025-05-17T11:42:48Z
user: login: yeni023
body: 수고하셨습니다! 불필요한 속성 몇 개만 수정 부탁드립니다
state: APPROVED
author_association: CONTRIBUTOR
submitted_at: 2025-05-17T12:07:00Z

Review comments:
diff_hunk: @@ -0,0 +1,123 @@
+import { styleVariants, keyframes, style } from '@vanilla-extract/css';
+
+import { rem, theme } from '@/styles';
+
+export const userCard = styleVariants({
+  '1vs1': {
+    width: rem(200),
+    height: rem(340),
+    backgroundColor: theme.color.playerCard,
+    display: 'flex',
+    flexDirection: 'column',
+    alignItems: 'center',
+    justifyContent: 'space-between',
+    padding: rem(16),
+    boxSizing: 'border-box',
path: src/pages/GamePage/GameMatchingPage/components/user-card/styles.css.ts
user: login: yeni023
body: `boxSizing: 'border-box'` 부분은 CSS cascading 되기 때문에 불필요한 속성으로 알고 있습니다
created_at: 2025-05-17T12:03:58Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,45 @@
+import { style } from '@vanilla-extract/css';
+
+import { rem, theme } from '@/styles';
+
+export const waiting = style({
+  fontFamily: 'Tiny5, sans-serif',
+  marginTop: rem(30),
+  textAlign: 'center',
+  fontSize: rem(40),
+});
+
+export const pending = style({
+  color: theme.color.waiting,
+});
+
+export const matched = style({
+  color: theme.color.matched,
+});
+
+export const readyContainer = style({
+  marginTop: rem(30),
+  display: 'flex',
+  justifyContent: 'center',
+});
+
+export const startButton = style({
+  fontFamily: 'Tiny5',
+  fontSize: rem(28),
+  padding: `${rem(7)} ${rem(24)}`,
+  backgroundColor: 'black',
+  color: theme.color.matched,
+  border: `${rem(2)} solid ${theme.color.white}`,
+  borderRadius: rem(8),
+  cursor: 'pointer',
path: src/pages/GamePage/GameMatchingPage/components/waiting-message/styles.css.ts
user: login: yeni023
body: `cursor: 'pointer'` 부분도 src/styles/reset.css.ts 여기 파일에 이미 지정되어 있는 걸로 알고 있습니다!
created_at: 2025-05-17T12:06:26Z
author_association: CONTRIBUTOR

PR comments:
user: login: vercel[bot]
created_at: 2025-05-17T05:20:31Z
body: [vc]: #5X3U1IM+KFAuEZQPL0K9/VCiOHpT0DAsy/Hn6GAMMjg=:eyJpc01vbm9yZXBvIjp0cnVlLCJ0eXBlIjoiZ2l0aHViIiwicHJvamVjdHMiOlt7Im5hbWUiOiJwaW5ncG9uZy1nYW5nIiwicm9vdERpcmVjdG9yeSI6bnVsbCwibGl2ZUZlZWRiYWNrIjp7InJlc29sdmVkIjowLCJ1bnJlc29sdmVkIjowLCJ0b3RhbCI6MCwibGluayI6InBpbmdwb25nLWdhbmctZ2l0LWZlYXR1cmUtZ2FtZW1hdGNoaW5ncGFnZS1oYWt1aS52ZXJjZWwuYXBwIn0sImluc3BlY3RvclVybCI6Imh0dHBzOi8vdmVyY2VsLmNvbS9oYWt1aS9waW5ncG9uZy1nYW5nL0oxVnBTczZEQkdjTUVRUjNKZ1RQdUIyZmVGVEQiLCJwcmV2aWV3VXJsIjoicGluZ3BvbmctZ2FuZy1naXQtZmVhdHVyZS1nYW1lbWF0Y2hpbmdwYWdlLWhha3VpLnZlcmNlbC5hcHAiLCJuZXh0Q29tbWl0U3RhdHVzIjoiREVQTE9ZRUQifSx7Im5hbWUiOiJwaW5ncG9uZy1nYW5nLWRldiIsInJvb3REaXJlY3RvcnkiOm51bGwsImxpdmVGZWVkYmFjayI6eyJyZXNvbHZlZCI6MCwidW5yZXNvbHZlZCI6MCwidG90YWwiOjAsImxpbmsiOiJwaW5ncG9uZy1nYW5nLWRldi1naXQtZmVhdHVyZS1nYW1lbWF0Y2hpbmdwYWdlLWhha3VpLnZlcmNlbC5hcHAifSwiaW5zcGVjdG9yVXJsIjoiaHR0cHM6Ly92ZXJjZWwuY29tL2hha3VpL3Bpbmdwb25nLWdhbmctZGV2LzlhSzRWM0ZpdGJHaGN5UEJESk54ZTlGYVdoc1AiLCJwcmV2aWV3VXJsIjoicGluZ3BvbmctZ2FuZy1kZXYtZ2l0LWZlYXR1cmUtZ2FtZW1hdGNoaW5ncGFnZS1oYWt1aS52ZXJjZWwuYXBwIiwibmV4dENvbW1pdFN0YXR1cyI6IkRFUExPWUVEIn1dfQ==
**The latest updates on your projects**. Learn more about [Vercel for Git ↗︎](https://vercel.link/github-learn-more)

| Name | Status | Preview | Comments | Updated (UTC) |
| :--- | :----- | :------ | :------- | :------ |
| **pingpong-gang** | ✅ Ready ([Inspect](https://vercel.com/hakui/pingpong-gang/J1VpSs6DBGcMEQR3JgTPuB2feFTD)) | [Visit Preview](https://pingpong-gang-git-feature-gamematchingpage-hakui.vercel.app) | 💬 [**Add feedback**](https://vercel.live/open-feedback/pingpong-gang-git-feature-gamematchingpage-hakui.vercel.app?via=pr-comment-feedback-link) | May 17, 2025 7:23am |
| **pingpong-gang-dev** | ✅ Ready ([Inspect](https://vercel.com/hakui/pingpong-gang-dev/9aK4V3FitbGhcyPBDJNxe9FaWhsP)) | [Visit Preview](https://pingpong-gang-dev-git-feature-gamematchingpage-hakui.vercel.app) | 💬 [**Add feedback**](https://vercel.live/open-feedback/pingpong-gang-dev-git-feature-gamematchingpage-hakui.vercel.app?via=pr-comment-feedback-link) | May 17, 2025 7:23am |



author_association: NONE
user: login: HJ0311
created_at: 2025-05-18T03:24:32Z
body: > 고생하셨습니다. 궁금한 부분이 두 가지 정도 있어서 코멘트 남겨요.
> 
> 먼저, 프로필 아바타가 없는 경우 `null`로 할당을 시키고 있는데, 이 부분이 꼭 필요할까에 대한 의문이 들어요. 이미 옵셔널을 통해 `undefined`이 할당되고 있어 꼭 필요하지 않다는 생각이 들어요.
> 
> 두 번째는 이전 `FriendPage`의 리뷰에도 제가 작성했던 내용과 동일한데요, `request-list-dialog/index.tsx` 부분에, 타입에 따라서 렌더링을 다르게 하고 계신 것 같아요. 저는 하나의 파일은 하나의 책임을 져야 한다고 생각해요. 따라서, InvateFriendDialog 와 같은 이름의 다이얼로그를 따로 만드는 게 어떨까 싶습니다.
> 
> 잠깐 생각해 보았을 때, `RequestListDialog`라는 이름만으로는 어떤 요청을 보내는지 알기 힘들고, Props로 받는 type도 어떠한 타입인지 모호해서 해당 파일을 한 번 접속해서 확인해야 하는 불편함이 생깁니다.
> 
> 물론 이를 '추상화'했다. 라고 표현할 수 있겠지만, 같은 Dialog 스타일을 공유한다면, `@/components/ui/ListDialog` 와 같은 파일을 하나 만드시고, 이를 확장한 `InviteFriendDialog`를 만들어서 이 다이얼로그를 사용하는 곳에서는 어떤 기능을 하는 다이얼로그인지 명확하게 알게 하는 것도 좋을 듯합니다.
> 
> 이 두 부분은 개인의 취향이 들어가는 영역이라 어떤 의도를 가지고 구현하셨는지에 대한 의견을 듣고 싶습니다.

피드백 읽어보았습니다! 일단 첫 번째에 답변을 드리자면 아직 소켓이나 API가 적용 안 된 상태에서 임시 데이터를 넣어 상대방의 유무에 따라 어떻게 UI가 동작하는지 확인이 필요했습니다. 그래서 추가하게 된 부분입니다. 그리고 옵셔널을 통해 undefined로 할당되고 있는 게 맞기 때문에 이 부분은 고치는 것이 더 좋아 보입니다. 전체적으로 확인 후 수정하겠습니다.

그리고 두 번째 피드백에 답변을 드리자면 이 부분은 학님이 friendlist에서 만든 부분을 가져와서 수정한 코드인데요. type을 굳이 명시해둔 이유는 확장성을 고려했기 때문이었습니다. friendlist에서도 type에 따라 다른 렌더링을 하긴 하는데 기본적인 틀 자체는 학님이 짜둔 다이얼로그 안에서 렌더링이 되고, 혹시 매칭 페이지 내에서 이러한 다이얼로그 틀을 재활용 하여 다른 기능을 추가하게 된다면 확장성을 고려해 짜는 것이 낫겠다 생각하여 type을 굳이 없애지 않고 유지하게 되었습니다. 생각해보니 매칭 페이지 내부에서 기능을 확장할 필요도 없을 것 같고 해서 이 부분 또한 수정하는 게 나을 것 같습니다.

세 번째 피드백에 대한 답변은 제가 그냥 friendlist에 있던 부분을 그대로 복사해왔던 탓에 이름 바꾸는 것을 까먹었기 때문에 수정이 안 되어 있었습니다. 맞는 이름으로 수정하겠습니다.
폴더 구조에 대한 것은 일단 친구 페이지에서 쓰이는 다이얼로그와 매칭 페이지에서 쓰이는 다이얼로그는 크기 정도를 제외하면 거의 달라지는 부분이 없긴 합니다. 이 부분은 전체적으로 봤을 때는 크기 말고는 달라지는 부분이 없어서 아예 공통 컴포넌트로 빼면 더 좋을 것 같습니다.
author_association: CONTRIBUTOR