From 922bc6a1f52d8f01600e9a61ce31963075ec59a5 Mon Sep 17 00:00:00 2001
From: nsfisis <nsfisis@gmail.com>
Date: Thu, 22 Aug 2024 00:46:06 +0900
Subject: refactor(frontend): organize PlayerInfo

---
 frontend/app/components/GolfPlayApp.client.tsx | 20 +++++++++++++-------
 1 file changed, 13 insertions(+), 7 deletions(-)

(limited to 'frontend/app/components/GolfPlayApp.client.tsx')

diff --git a/frontend/app/components/GolfPlayApp.client.tsx b/frontend/app/components/GolfPlayApp.client.tsx
index 42f0250..80cfc40 100644
--- a/frontend/app/components/GolfPlayApp.client.tsx
+++ b/frontend/app/components/GolfPlayApp.client.tsx
@@ -2,7 +2,7 @@ import { useEffect, useState } from "react";
 import { useDebouncedCallback } from "use-debounce";
 import type { components } from "../.server/api/schema";
 import useWebSocket, { ReadyState } from "../hooks/useWebSocket";
-import type { PlayerInfo } from "../models/PlayerInfo";
+import type { PlayerState } from "../types/PlayerState";
 import GolfPlayAppConnecting from "./GolfPlayApps/GolfPlayAppConnecting";
 import GolfPlayAppFinished from "./GolfPlayApps/GolfPlayAppFinished";
 import GolfPlayAppGaming from "./GolfPlayApps/GolfPlayAppGaming";
@@ -72,9 +72,12 @@ export default function GolfPlayApp({
 		}
 	}, [gameState, startedAt, game.duration_seconds]);
 
-	const [playerInfo, setPlayerInfo] = useState<Omit<PlayerInfo, "code">>({
+	const playerProfile = {
 		displayName: player.display_name,
 		iconPath: player.icon_path ?? null,
+	};
+	const [playerState, setPlayerState] = useState<PlayerState>({
+		code: "",
 		score: null,
 		submitResult: {
 			status: "waiting_submission",
@@ -105,7 +108,7 @@ export default function GolfPlayApp({
 			type: "player:c2s:submit",
 			data: { code },
 		});
-		setPlayerInfo((prev) => ({
+		setPlayerState((prev) => ({
 			...prev,
 			submitResult: {
 				status: "running",
@@ -147,7 +150,7 @@ export default function GolfPlayApp({
 					}
 				} else if (lastJsonMessage.type === "player:s2c:execresult") {
 					const { testcase_id, status, stdout, stderr } = lastJsonMessage.data;
-					setPlayerInfo((prev) => {
+					setPlayerState((prev) => {
 						const ret = { ...prev };
 						ret.submitResult = {
 							...prev.submitResult,
@@ -166,7 +169,7 @@ export default function GolfPlayApp({
 					});
 				} else if (lastJsonMessage.type === "player:s2c:submitresult") {
 					const { status, score } = lastJsonMessage.data;
-					setPlayerInfo((prev) => {
+					setPlayerState((prev) => {
 						const ret = { ...prev };
 						ret.submitResult = {
 							...prev.submitResult,
@@ -228,7 +231,7 @@ export default function GolfPlayApp({
 		return (
 			<GolfPlayAppWaiting
 				gameDisplayName={game.display_name}
-				playerInfo={playerInfo}
+				playerProfile={playerProfile}
 			/>
 		);
 	} else if (gameState === "starting") {
@@ -244,7 +247,10 @@ export default function GolfPlayApp({
 				gameDisplayName={game.display_name}
 				gameDurationSeconds={game.duration_seconds}
 				leftTimeSeconds={leftTimeSeconds!}
-				playerInfo={playerInfo}
+				playerInfo={{
+					profile: playerProfile,
+					state: playerState,
+				}}
 				problemTitle={game.problem.title}
 				problemDescription={game.problem.description}
 				onCodeChange={onCodeChange}
-- 
cgit v1.3.1


From a2b6ed9cd67f1406a6656bce9b3d51b55378ac1e Mon Sep 17 00:00:00 2001
From: nsfisis <nsfisis@gmail.com>
Date: Thu, 22 Aug 2024 03:57:05 +0900
Subject: feat(frontend): jotai for play app

---
 frontend/app/components/GolfPlayApp.client.tsx     | 246 +++++++--------------
 .../GolfPlayApps/GolfPlayAppFinished.tsx           |   2 +-
 .../components/GolfPlayApps/GolfPlayAppGaming.tsx  |  42 ++--
 .../GolfPlayApps/GolfPlayAppStarting.tsx           |  13 +-
 frontend/app/routes/golf.$gameId.play.tsx          |  97 +++++++-
 frontend/app/states/play.ts                        | 185 ++++++++++++++++
 frontend/app/states/watch.ts                       |   0
 frontend/package-lock.json                         |  22 ++
 frontend/package.json                              |   1 +
 9 files changed, 416 insertions(+), 192 deletions(-)
 create mode 100644 frontend/app/states/play.ts
 create mode 100644 frontend/app/states/watch.ts

(limited to 'frontend/app/components/GolfPlayApp.client.tsx')

diff --git a/frontend/app/components/GolfPlayApp.client.tsx b/frontend/app/components/GolfPlayApp.client.tsx
index 80cfc40..0230426 100644
--- a/frontend/app/components/GolfPlayApp.client.tsx
+++ b/frontend/app/components/GolfPlayApp.client.tsx
@@ -1,8 +1,20 @@
-import { useEffect, useState } from "react";
+import { useAtomValue, useSetAtom } from "jotai";
+import { useEffect } from "react";
+import { useTimer } from "react-use-precision-timer";
 import { useDebouncedCallback } from "use-debounce";
 import type { components } from "../.server/api/schema";
 import useWebSocket, { ReadyState } from "../hooks/useWebSocket";
-import type { PlayerState } from "../types/PlayerState";
+import {
+	gameStartAtom,
+	gameStateKindAtom,
+	handleSubmitCodeAtom,
+	handleWsConnectionClosedAtom,
+	handleWsExecResultMessageAtom,
+	handleWsSubmitResultMessageAtom,
+	setCurrentTimestampAtom,
+	setGameStateConnectingAtom,
+	setGameStateWaitingAtom,
+} from "../states/play";
 import GolfPlayAppConnecting from "./GolfPlayApps/GolfPlayAppConnecting";
 import GolfPlayAppFinished from "./GolfPlayApps/GolfPlayAppFinished";
 import GolfPlayAppGaming from "./GolfPlayApps/GolfPlayAppGaming";
@@ -15,81 +27,47 @@ type GamePlayerMessageC2S = components["schemas"]["GamePlayerMessageC2S"];
 type Game = components["schemas"]["Game"];
 type User = components["schemas"]["User"];
 
-type GameState = "connecting" | "waiting" | "starting" | "gaming" | "finished";
+type Props = {
+	game: Game;
+	player: User;
+	initialCode: string;
+	sockToken: string;
+};
 
 export default function GolfPlayApp({
 	game,
 	player,
+	initialCode,
 	sockToken,
-}: {
-	game: Game;
-	player: User;
-	sockToken: string;
-}) {
+}: Props) {
 	const socketUrl =
 		process.env.NODE_ENV === "development"
 			? `ws://localhost:8002/iosdc-japan/2024/code-battle/sock/golf/${game.game_id}/play?token=${sockToken}`
 			: `wss://t.nil.ninja/iosdc-japan/2024/code-battle/sock/golf/${game.game_id}/play?token=${sockToken}`;
 
+	const gameStateKind = useAtomValue(gameStateKindAtom);
+	const setCurrentTimestamp = useSetAtom(setCurrentTimestampAtom);
+	const gameStart = useSetAtom(gameStartAtom);
+	const setGameStateConnecting = useSetAtom(setGameStateConnectingAtom);
+	const setGameStateWaiting = useSetAtom(setGameStateWaitingAtom);
+	const handleWsConnectionClosed = useSetAtom(handleWsConnectionClosedAtom);
+	const handleWsExecResultMessage = useSetAtom(handleWsExecResultMessageAtom);
+	const handleWsSubmitResultMessage = useSetAtom(
+		handleWsSubmitResultMessageAtom,
+	);
+	const handleSubmitCode = useSetAtom(handleSubmitCodeAtom);
+
+	useTimer({ delay: 1000, startImmediately: true }, setCurrentTimestamp);
+
 	const { sendJsonMessage, lastJsonMessage, readyState } = useWebSocket<
 		GamePlayerMessageS2C,
 		GamePlayerMessageC2S
 	>(socketUrl);
 
-	const [gameState, setGameState] = useState<GameState>("connecting");
-
-	const [startedAt, setStartedAt] = useState<number | null>(null);
-
-	const [leftTimeSeconds, setLeftTimeSeconds] = useState<number | null>(null);
-
-	useEffect(() => {
-		if (
-			(gameState === "starting" || gameState === "gaming") &&
-			startedAt !== null
-		) {
-			const timer = setInterval(() => {
-				setLeftTimeSeconds((prev) => {
-					if (prev === null) {
-						return null;
-					}
-					if (prev <= 1) {
-						const nowSec = Math.floor(Date.now() / 1000);
-						const finishedAt = startedAt + game.duration_seconds;
-						if (nowSec >= finishedAt) {
-							clearInterval(timer);
-							setGameState("finished");
-						} else {
-							setGameState("gaming");
-						}
-					}
-					return prev - 1;
-				});
-			}, 1000);
-
-			return () => {
-				clearInterval(timer);
-			};
-		}
-	}, [gameState, startedAt, game.duration_seconds]);
-
 	const playerProfile = {
 		displayName: player.display_name,
 		iconPath: player.icon_path ?? null,
 	};
-	const [playerState, setPlayerState] = useState<PlayerState>({
-		code: "",
-		score: null,
-		submitResult: {
-			status: "waiting_submission",
-			execResults: game.exec_steps.map((r) => ({
-				testcase_id: r.testcase_id,
-				status: "waiting_submission",
-				label: r.label,
-				stdout: "",
-				stderr: "",
-			})),
-		},
-	});
 
 	const onCodeChange = useDebouncedCallback((code: string) => {
 		console.log("player:c2s:code");
@@ -97,6 +75,8 @@ export default function GolfPlayApp({
 			type: "player:c2s:code",
 			data: { code },
 		});
+		const baseKey = `playerState:${game.game_id}:${player.user_id}`;
+		window.localStorage.setItem(`${baseKey}:code`, code);
 	}, 1000);
 
 	const onCodeSubmit = useDebouncedCallback((code: string) => {
@@ -108,18 +88,7 @@ export default function GolfPlayApp({
 			type: "player:c2s:submit",
 			data: { code },
 		});
-		setPlayerState((prev) => ({
-			...prev,
-			submitResult: {
-				status: "running",
-				execResults: prev.submitResult.execResults.map((r) => ({
-					...r,
-					status: "running",
-					stdout: "",
-					stderr: "",
-				})),
-			},
-		}));
+		handleSubmitCode();
 	}, 1000);
 
 	if (readyState === ReadyState.UNINSTANTIATED) {
@@ -128,136 +97,89 @@ export default function GolfPlayApp({
 
 	useEffect(() => {
 		if (readyState === ReadyState.CLOSING || readyState === ReadyState.CLOSED) {
-			if (gameState !== "finished") {
-				setGameState("connecting");
-			}
+			handleWsConnectionClosed();
 		} else if (readyState === ReadyState.CONNECTING) {
-			setGameState("connecting");
+			setGameStateConnecting();
 		} else if (readyState === ReadyState.OPEN) {
 			if (lastJsonMessage !== null) {
 				console.log(lastJsonMessage.type);
 				if (lastJsonMessage.type === "player:s2c:start") {
-					if (
-						gameState !== "starting" &&
-						gameState !== "gaming" &&
-						gameState !== "finished"
-					) {
-						const { start_at } = lastJsonMessage.data;
-						setStartedAt(start_at);
-						const nowSec = Math.floor(Date.now() / 1000);
-						setLeftTimeSeconds(start_at - nowSec);
-						setGameState("starting");
-					}
+					const { start_at } = lastJsonMessage.data;
+					gameStart(start_at);
 				} else if (lastJsonMessage.type === "player:s2c:execresult") {
-					const { testcase_id, status, stdout, stderr } = lastJsonMessage.data;
-					setPlayerState((prev) => {
-						const ret = { ...prev };
-						ret.submitResult = {
-							...prev.submitResult,
-							execResults: prev.submitResult.execResults.map((r) =>
-								r.testcase_id === testcase_id && r.status === "running"
-									? {
-											...r,
-											status,
-											stdout,
-											stderr,
-										}
-									: r,
-							),
-						};
-						return ret;
-					});
+					handleWsExecResultMessage(
+						lastJsonMessage.data,
+						(submissionResult) => {
+							const baseKey = `playerState:${game.game_id}:${player.user_id}`;
+							window.localStorage.setItem(
+								`${baseKey}:submissionResult`,
+								JSON.stringify(submissionResult),
+							);
+						},
+					);
 				} else if (lastJsonMessage.type === "player:s2c:submitresult") {
-					const { status, score } = lastJsonMessage.data;
-					setPlayerState((prev) => {
-						const ret = { ...prev };
-						ret.submitResult = {
-							...prev.submitResult,
-							status,
-						};
-						if (status === "success") {
-							if (score) {
-								if (ret.score === null || score < ret.score) {
-									ret.score = score;
-								}
-							}
-						} else {
-							ret.submitResult.execResults = prev.submitResult.execResults.map(
-								(r) =>
-									r.status === "running" ? { ...r, status: "canceled" } : r,
+					handleWsSubmitResultMessage(
+						lastJsonMessage.data,
+						(submissionResult, score) => {
+							const baseKey = `playerState:${game.game_id}:${player.user_id}`;
+							window.localStorage.setItem(
+								`${baseKey}:submissionResult`,
+								JSON.stringify(submissionResult),
+							);
+							window.localStorage.setItem(
+								`${baseKey}:score`,
+								score === null ? "" : score.toString(),
 							);
-						}
-						return ret;
-					});
+						},
+					);
 				}
 			} else {
 				if (game.started_at) {
-					const nowSec = Math.floor(Date.now() / 1000);
-					if (game.started_at <= nowSec) {
-						// The game has already started.
-						if (gameState !== "gaming" && gameState !== "finished") {
-							setStartedAt(game.started_at);
-							setLeftTimeSeconds(game.started_at - nowSec);
-							setGameState("gaming");
-						}
-					} else {
-						// The game is starting.
-						if (
-							gameState !== "starting" &&
-							gameState !== "gaming" &&
-							gameState !== "finished"
-						) {
-							setStartedAt(game.started_at);
-							setLeftTimeSeconds(game.started_at - nowSec);
-							setGameState("starting");
-						}
-					}
+					gameStart(game.started_at);
 				} else {
-					setGameState("waiting");
+					setGameStateWaiting();
 				}
 			}
 		}
 	}, [
+		game.game_id,
 		game.started_at,
+		player.user_id,
 		sendJsonMessage,
 		lastJsonMessage,
 		readyState,
-		gameState,
+		gameStart,
+		handleWsConnectionClosed,
+		handleWsExecResultMessage,
+		handleWsSubmitResultMessage,
+		setGameStateConnecting,
+		setGameStateWaiting,
 	]);
 
-	if (gameState === "connecting") {
+	if (gameStateKind === "connecting") {
 		return <GolfPlayAppConnecting />;
-	} else if (gameState === "waiting") {
+	} else if (gameStateKind === "waiting") {
 		return (
 			<GolfPlayAppWaiting
 				gameDisplayName={game.display_name}
 				playerProfile={playerProfile}
 			/>
 		);
-	} else if (gameState === "starting") {
-		return (
-			<GolfPlayAppStarting
-				gameDisplayName={game.display_name}
-				leftTimeSeconds={leftTimeSeconds!}
-			/>
-		);
-	} else if (gameState === "gaming") {
+	} else if (gameStateKind === "starting") {
+		return <GolfPlayAppStarting gameDisplayName={game.display_name} />;
+	} else if (gameStateKind === "gaming") {
 		return (
 			<GolfPlayAppGaming
 				gameDisplayName={game.display_name}
-				gameDurationSeconds={game.duration_seconds}
-				leftTimeSeconds={leftTimeSeconds!}
-				playerInfo={{
-					profile: playerProfile,
-					state: playerState,
-				}}
+				playerProfile={playerProfile}
 				problemTitle={game.problem.title}
 				problemDescription={game.problem.description}
+				initialCode={initialCode}
 				onCodeChange={onCodeChange}
 				onCodeSubmit={onCodeSubmit}
 			/>
 		);
-	} else if (gameState === "finished") {
+	} else if (gameStateKind === "finished") {
 		return <GolfPlayAppFinished />;
 	} else {
 		return null;
diff --git a/frontend/app/components/GolfPlayApps/GolfPlayAppFinished.tsx b/frontend/app/components/GolfPlayApps/GolfPlayAppFinished.tsx
index c3ef2d4..c218414 100644
--- a/frontend/app/components/GolfPlayApps/GolfPlayAppFinished.tsx
+++ b/frontend/app/components/GolfPlayApps/GolfPlayAppFinished.tsx
@@ -2,7 +2,7 @@ export default function GolfPlayAppFinished() {
 	return (
 		<div className="min-h-screen bg-gray-100 flex items-center justify-center">
 			<div className="text-center">
-				<h1 className="text-4xl font-bold text-black-600 mb-4">Finished</h1>
+				<div className="text-6xl font-bold text-black">終了</div>
 			</div>
 		</div>
 	);
diff --git a/frontend/app/components/GolfPlayApps/GolfPlayAppGaming.tsx b/frontend/app/components/GolfPlayApps/GolfPlayAppGaming.tsx
index 38516bc..0aa6b3d 100644
--- a/frontend/app/components/GolfPlayApps/GolfPlayAppGaming.tsx
+++ b/frontend/app/components/GolfPlayApps/GolfPlayAppGaming.tsx
@@ -1,32 +1,40 @@
 import { Link } from "@remix-run/react";
+import { useAtomValue } from "jotai";
 import React, { useRef } from "react";
 import SubmitButton from "../../components/SubmitButton";
-import type { PlayerInfo } from "../../types/PlayerInfo";
+import {
+	gamingLeftTimeSecondsAtom,
+	scoreAtom,
+	submitResultAtom,
+} from "../../states/play";
+import type { PlayerProfile } from "../../types/PlayerProfile";
 import BorderedContainer from "../BorderedContainer";
 import SubmitResult from "../Gaming/SubmitResult";
 import UserIcon from "../UserIcon";
 
 type Props = {
 	gameDisplayName: string;
-	gameDurationSeconds: number;
-	leftTimeSeconds: number;
-	playerInfo: PlayerInfo;
+	playerProfile: PlayerProfile;
 	problemTitle: string;
 	problemDescription: string;
+	initialCode: string;
 	onCodeChange: (code: string) => void;
 	onCodeSubmit: (code: string) => void;
 };
 
 export default function GolfPlayAppGaming({
 	gameDisplayName,
-	gameDurationSeconds,
-	leftTimeSeconds,
-	playerInfo,
+	playerProfile,
 	problemTitle,
 	problemDescription,
+	initialCode,
 	onCodeChange,
 	onCodeSubmit,
 }: Props) {
+	const leftTimeSeconds = useAtomValue(gamingLeftTimeSecondsAtom)!;
+	const score = useAtomValue(scoreAtom);
+	const submitResult = useAtomValue(submitResultAtom);
+
 	const textareaRef = useRef<HTMLTextAreaElement>(null);
 
 	const handleTextChange = (e: React.ChangeEvent<HTMLTextAreaElement>) => {
@@ -40,9 +48,8 @@ export default function GolfPlayAppGaming({
 	};
 
 	const leftTime = (() => {
-		const k = gameDurationSeconds + leftTimeSeconds;
-		const m = Math.floor(k / 60);
-		const s = k % 60;
+		const m = Math.floor(leftTimeSeconds / 60);
+		const s = leftTimeSeconds % 60;
 		return `${m.toString().padStart(2, "0")}:${s.toString().padStart(2, "0")}`;
 	})();
 
@@ -55,15 +62,15 @@ export default function GolfPlayAppGaming({
 				</div>
 				<Link to={"/dashboard"}>
 					<div className="flex gap-4 my-auto font-bold">
-						<div className="text-6xl">{playerInfo.state.score}</div>
+						<div className="text-6xl">{score}</div>
 						<div className="text-end">
 							<div className="text-gray-100">Player 1</div>
-							<div className="text-2xl">{playerInfo.profile.displayName}</div>
+							<div className="text-2xl">{playerProfile.displayName}</div>
 						</div>
-						{playerInfo.profile.iconPath && (
+						{playerProfile.iconPath && (
 							<UserIcon
-								iconPath={playerInfo.profile.iconPath}
-								displayName={playerInfo.profile.displayName}
+								iconPath={playerProfile.iconPath}
+								displayName={playerProfile.displayName}
 								className="w-12 h-12 my-auto"
 							/>
 						)}
@@ -82,13 +89,14 @@ export default function GolfPlayAppGaming({
 				<div className="p-4">
 					<textarea
 						ref={textareaRef}
+						defaultValue={initialCode}
 						onChange={handleTextChange}
 						className="resize-none h-full w-full rounded-lg border border-gray-300 p-2 focus:outline-none focus:ring-2 focus:ring-gray-400 transition duration-300"
-					></textarea>
+					/>
 				</div>
 				<div className="p-4">
 					<SubmitResult
-						result={playerInfo.state.submitResult}
+						result={submitResult}
 						submitButton={
 							<SubmitButton onClick={handleSubmitButtonClick}>
 								提出
diff --git a/frontend/app/components/GolfPlayApps/GolfPlayAppStarting.tsx b/frontend/app/components/GolfPlayApps/GolfPlayAppStarting.tsx
index a42c883..b41dfed 100644
--- a/frontend/app/components/GolfPlayApps/GolfPlayAppStarting.tsx
+++ b/frontend/app/components/GolfPlayApps/GolfPlayAppStarting.tsx
@@ -1,18 +1,19 @@
+import { useAtomValue } from "jotai";
+import { startingLeftTimeSecondsAtom } from "../../states/play";
+
 type Props = {
 	gameDisplayName: string;
-	leftTimeSeconds: number;
 };
 
-export default function GolfPlayAppStarting({
-	gameDisplayName,
-	leftTimeSeconds,
-}: Props) {
+export default function GolfPlayAppStarting({ gameDisplayName }: Props) {
+	const leftTimeSeconds = useAtomValue(startingLeftTimeSecondsAtom)!;
+
 	return (
 		<div className="min-h-screen bg-gray-100 flex flex-col">
 			<div className="text-white bg-iosdc-japan p-10 text-center">
 				<div className="text-4xl font-bold">{gameDisplayName}</div>
 			</div>
-			<div className="text-center text-black font-black text-10xl animate-ping">
+			<div className="text-center text-black font-black text-10xl">
 				{leftTimeSeconds}
 			</div>
 		</div>
diff --git a/frontend/app/routes/golf.$gameId.play.tsx b/frontend/app/routes/golf.$gameId.play.tsx
index ea1b8fd..a2860dd 100644
--- a/frontend/app/routes/golf.$gameId.play.tsx
+++ b/frontend/app/routes/golf.$gameId.play.tsx
@@ -1,10 +1,17 @@
 import type { LoaderFunctionArgs, MetaFunction } from "@remix-run/node";
-import { useLoaderData } from "@remix-run/react";
-import { ClientOnly } from "remix-utils/client-only";
+import { ClientLoaderFunctionArgs, useLoaderData } from "@remix-run/react";
+import { useHydrateAtoms } from "jotai/utils";
 import { apiGetGame, apiGetToken } from "../.server/api/client";
 import { ensureUserLoggedIn } from "../.server/auth";
 import GolfPlayApp from "../components/GolfPlayApp.client";
 import GolfPlayAppConnecting from "../components/GolfPlayApps/GolfPlayAppConnecting";
+import {
+	scoreAtom,
+	setCurrentTimestampAtom,
+	setDurationSecondsAtom,
+	submitResultAtom,
+} from "../states/play";
+import { PlayerState } from "../types/PlayerState";
 
 export const meta: MetaFunction<typeof loader> = ({ data }) => [
 	{
@@ -25,19 +32,97 @@ export async function loader({ params, request }: LoaderFunctionArgs) {
 	};
 
 	const [game, sockToken] = await Promise.all([fetchGame(), fetchSockToken()]);
+
+	const playerState: PlayerState = {
+		code: "",
+		score: null,
+		submitResult: {
+			status: "waiting_submission",
+			execResults: game.exec_steps.map((r) => ({
+				testcase_id: r.testcase_id,
+				status: "waiting_submission",
+				label: r.label,
+				stdout: "",
+				stderr: "",
+			})),
+		},
+	};
+
 	return {
 		game,
 		player: user,
 		sockToken,
+		playerState,
 	};
 }
 
+export async function clientLoader({ serverLoader }: ClientLoaderFunctionArgs) {
+	const data = await serverLoader<typeof loader>();
+	const baseKey = `playerState:${data.game.game_id}:${data.player.user_id}`;
+
+	const localCode = (() => {
+		const rawValue = window.localStorage.getItem(`${baseKey}:code`);
+		if (rawValue === null) {
+			return null;
+		}
+		return rawValue;
+	})();
+
+	const localScore = (() => {
+		const rawValue = window.localStorage.getItem(`${baseKey}:score`);
+		if (rawValue === null || rawValue === "") {
+			return null;
+		}
+		return Number(rawValue);
+	})();
+
+	const localSubmissionResult = (() => {
+		const rawValue = window.localStorage.getItem(`${baseKey}:submissionResult`);
+		if (rawValue === null) {
+			return null;
+		}
+		const parsed = JSON.parse(rawValue);
+		if (typeof parsed !== "object") {
+			return null;
+		}
+		return parsed;
+	})();
+
+	if (localCode !== null) {
+		data.playerState.code = localCode;
+	}
+	if (localScore !== null) {
+		data.playerState.score = localScore;
+	}
+	if (localSubmissionResult !== null) {
+		data.playerState.submitResult = localSubmissionResult;
+	}
+
+	return data;
+}
+clientLoader.hydrate = true;
+
+export function HydrateFallback() {
+	return <GolfPlayAppConnecting />;
+}
+
 export default function GolfPlay() {
-	const { game, player, sockToken } = useLoaderData<typeof loader>();
+	const { game, player, sockToken, playerState } =
+		useLoaderData<typeof loader>();
+
+	useHydrateAtoms([
+		[setCurrentTimestampAtom, undefined],
+		[setDurationSecondsAtom, game.duration_seconds],
+		[scoreAtom, playerState.score],
+		[submitResultAtom, playerState.submitResult],
+	]);
 
 	return (
-		<ClientOnly fallback={<GolfPlayAppConnecting />}>
-			{() => <GolfPlayApp game={game} player={player} sockToken={sockToken} />}
-		</ClientOnly>
+		<GolfPlayApp
+			game={game}
+			player={player}
+			initialCode={playerState.code}
+			sockToken={sockToken}
+		/>
 	);
 }
diff --git a/frontend/app/states/play.ts b/frontend/app/states/play.ts
new file mode 100644
index 0000000..13bd39f
--- /dev/null
+++ b/frontend/app/states/play.ts
@@ -0,0 +1,185 @@
+import { atom } from "jotai";
+import type { components } from "../.server/api/schema";
+import type { SubmitResult } from "../types/SubmitResult";
+
+type RawGameState =
+	| {
+			kind: "connecting";
+			startedAtTimestamp: null;
+	  }
+	| {
+			kind: "waiting";
+			startedAtTimestamp: null;
+	  }
+	| {
+			kind: "starting";
+			startedAtTimestamp: number;
+	  };
+
+const rawGameStateAtom = atom<RawGameState>({
+	kind: "connecting",
+	startedAtTimestamp: null,
+});
+
+export type GameStateKind =
+	| "connecting"
+	| "waiting"
+	| "starting"
+	| "gaming"
+	| "finished";
+
+export const gameStateKindAtom = atom<GameStateKind>((get) => {
+	const { kind: rawKind, startedAtTimestamp } = get(rawGameStateAtom);
+	if (rawKind === "connecting" || rawKind === "waiting") {
+		return rawKind;
+	} else {
+		const durationSeconds = get(rawDurationSecondsAtom);
+		const finishedAtTimestamp = startedAtTimestamp + durationSeconds;
+		const currentTimestamp = get(rawCurrentTimestampAtom);
+		if (currentTimestamp < startedAtTimestamp) {
+			return "starting";
+		} else if (currentTimestamp < finishedAtTimestamp) {
+			return "gaming";
+		} else {
+			return "finished";
+		}
+	}
+});
+
+export const gameStartAtom = atom(null, (get, set, value: number) => {
+	const { kind } = get(rawGameStateAtom);
+	if (kind === "starting") {
+		return;
+	}
+	set(rawGameStateAtom, {
+		kind: "starting",
+		startedAtTimestamp: value,
+	});
+});
+export const setGameStateConnectingAtom = atom(null, (_, set) =>
+	set(rawGameStateAtom, { kind: "connecting", startedAtTimestamp: null }),
+);
+export const setGameStateWaitingAtom = atom(null, (_, set) =>
+	set(rawGameStateAtom, { kind: "waiting", startedAtTimestamp: null }),
+);
+
+const rawCurrentTimestampAtom = atom(0);
+export const setCurrentTimestampAtom = atom(null, (_, set) =>
+	set(rawCurrentTimestampAtom, Math.floor(Date.now() / 1000)),
+);
+
+const rawDurationSecondsAtom = atom<number>(0);
+export const setDurationSecondsAtom = atom(null, (_, set, value: number) =>
+	set(rawDurationSecondsAtom, value),
+);
+
+export const startingLeftTimeSecondsAtom = atom<number | null>((get) => {
+	const { startedAtTimestamp } = get(rawGameStateAtom);
+	if (startedAtTimestamp === null) {
+		return null;
+	}
+	const currentTimestamp = get(rawCurrentTimestampAtom);
+	return Math.max(0, startedAtTimestamp - currentTimestamp);
+});
+
+export const gamingLeftTimeSecondsAtom = atom<number | null>((get) => {
+	const { startedAtTimestamp } = get(rawGameStateAtom);
+	if (startedAtTimestamp === null) {
+		return null;
+	}
+	const durationSeconds = get(rawDurationSecondsAtom);
+	const finishedAtTimestamp = startedAtTimestamp + durationSeconds;
+	const currentTimestamp = get(rawCurrentTimestampAtom);
+	return Math.min(
+		durationSeconds,
+		Math.max(0, finishedAtTimestamp - currentTimestamp),
+	);
+});
+
+export const handleWsConnectionClosedAtom = atom(null, (get, set) => {
+	const kind = get(gameStateKindAtom);
+	if (kind !== "finished") {
+		set(setGameStateConnectingAtom);
+	}
+});
+
+export const scoreAtom = atom<number | null>(null);
+export const submitResultAtom = atom<SubmitResult>({
+	status: "waiting_submission",
+	execResults: [],
+});
+
+export const handleSubmitCodeAtom = atom(null, (_, set) => {
+	set(submitResultAtom, (prev) => ({
+		status: "running",
+		execResults: prev.execResults.map((r) => ({
+			...r,
+			status: "running",
+			stdout: "",
+			stderr: "",
+		})),
+	}));
+});
+
+type GamePlayerMessageS2CExecResultPayload =
+	components["schemas"]["GamePlayerMessageS2CExecResultPayload"];
+type GamePlayerMessageS2CSubmitResultPayload =
+	components["schemas"]["GamePlayerMessageS2CSubmitResultPayload"];
+
+export const handleWsExecResultMessageAtom = atom(
+	null,
+	(
+		get,
+		set,
+		data: GamePlayerMessageS2CExecResultPayload,
+		callback: (submissionResult: SubmitResult) => void,
+	) => {
+		const { testcase_id, status, stdout, stderr } = data;
+		const prev = get(submitResultAtom);
+		const newResult = {
+			...prev,
+			execResults: prev.execResults.map((r) =>
+				r.testcase_id === testcase_id && r.status === "running"
+					? {
+							...r,
+							status,
+							stdout,
+							stderr,
+						}
+					: r,
+			),
+		};
+		set(submitResultAtom, newResult);
+		callback(newResult);
+	},
+);
+
+export const handleWsSubmitResultMessageAtom = atom(
+	null,
+	(
+		get,
+		set,
+		data: GamePlayerMessageS2CSubmitResultPayload,
+		callback: (submissionResult: SubmitResult, score: number | null) => void,
+	) => {
+		const { status, score } = data;
+		const prev = get(submitResultAtom);
+		const newResult = {
+			...prev,
+			status,
+		};
+		if (status !== "success") {
+			newResult.execResults = prev.execResults.map((r) =>
+				r.status === "running" ? { ...r, status: "canceled" } : r,
+			);
+		}
+		set(submitResultAtom, newResult);
+		if (status === "success" && score !== null) {
+			const currentScore = get(scoreAtom);
+			if (currentScore === null || score < currentScore) {
+				set(scoreAtom, score);
+			}
+		}
+		callback(newResult, score);
+	},
+);
diff --git a/frontend/app/states/watch.ts b/frontend/app/states/watch.ts
new file mode 100644
index 0000000..e69de29
diff --git a/frontend/package-lock.json b/frontend/package-lock.json
index 6953cef..538ceeb 100644
--- a/frontend/package-lock.json
+++ b/frontend/package-lock.json
@@ -21,6 +21,7 @@
 				"prismjs": "^1.29.0",
 				"react": "^18.3.1",
 				"react-dom": "^18.3.1",
+				"react-use-precision-timer": "^3.5.5",
 				"react-use-websocket": "^4.8.1",
 				"remix-auth": "^3.7.0",
 				"remix-auth-form": "^1.5.0",
@@ -9232,6 +9233,27 @@
 				"react-dom": ">=16.8"
 			}
 		},
+		"node_modules/react-sub-unsub": {
+			"version": "2.2.7",
+			"resolved": "https://registry.npmjs.org/react-sub-unsub/-/react-sub-unsub-2.2.7.tgz",
+			"integrity": "sha512-b2o0mIW8G4Yb3aaKxFB9iiCCHxCDGmogy+493oQpEJHjBy/hl6uf+6RhAinqKWRwi1fvO6mGIMVGsf2XYLL38g==",
+			"peerDependencies": {
+				"react": "^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^20.0.0 || ^21.0.0 || ^22.0.0",
+				"react-dom": "^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^20.0.0 || ^21.0.0 || ^22.0.0"
+			}
+		},
+		"node_modules/react-use-precision-timer": {
+			"version": "3.5.5",
+			"resolved": "https://registry.npmjs.org/react-use-precision-timer/-/react-use-precision-timer-3.5.5.tgz",
+			"integrity": "sha512-fPf9d1fAb4CCJrJCnErvvB/GFVDm+bzb07WilkiW3hcJUjqS3ep6pCLKUguT76gpPvyOuKp9KSD8z06uM3LzAA==",
+			"dependencies": {
+				"react-sub-unsub": "^2.2.2"
+			},
+			"peerDependencies": {
+				"react": "^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^20.0.0 || ^21.0.0 || ^22.0.0",
+				"react-dom": "^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^20.0.0 || ^21.0.0 || ^22.0.0"
+			}
+		},
 		"node_modules/react-use-websocket": {
 			"version": "4.8.1",
 			"resolved": "https://registry.npmjs.org/react-use-websocket/-/react-use-websocket-4.8.1.tgz",
diff --git a/frontend/package.json b/frontend/package.json
index 1238349..bb11725 100644
--- a/frontend/package.json
+++ b/frontend/package.json
@@ -29,6 +29,7 @@
 		"prismjs": "^1.29.0",
 		"react": "^18.3.1",
 		"react-dom": "^18.3.1",
+		"react-use-precision-timer": "^3.5.5",
 		"react-use-websocket": "^4.8.1",
 		"remix-auth": "^3.7.0",
 		"remix-auth-form": "^1.5.0",
-- 
cgit v1.3.1

