diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index 09e8b1f..3989ebd 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -37,8 +37,7 @@ jobs:
node-version-file: "package.json"
cache: "pnpm"
- run: pnpm install --prefer-offline --frozen-lockfile
- #- run: pnpm run typecheck
- - run: echo "Type Checks are disabled ✅"
+ - run: pnpm run typecheck
vitest:
needs: typecheck
diff --git a/.vscode/settings.json b/.vscode/settings.json
index 00b93fb..fb52593 100644
--- a/.vscode/settings.json
+++ b/.vscode/settings.json
@@ -39,5 +39,8 @@
"Readme*": "AUTHORS, Authors, BACKERS*, Backers*, CHANGELOG*, CITATION*, CODEOWNERS, CODE_OF_CONDUCT*, CONTRIBUTING*, CONTRIBUTORS, COPYING*, CREDITS, Changelog*, Citation*, Code_Of_Conduct*, Codeowners, Contributing*, Contributors, Copying*, Credits, GOVERNANCE.MD, Governance.md, HISTORY.MD, History.md, LICENSE*, License*, MAINTAINERS, Maintainers, README-*, README_*, RELEASE_NOTES*, ROADMAP.MD, Readme-*, Readme_*, Release_Notes*, Roadmap.md, SECURITY.MD, SPONSORS*, Security.md, Sponsors*, authors, backers*, changelog*, citation*, code_of_conduct*, codeowners, contributing*, contributors, copying*, credits, governance.md, history.md, license*, maintainers, readme-*, readme_*, release_notes*, roadmap.md, security.md, sponsors*",
"README*": "AUTHORS, Authors, BACKERS*, Backers*, CHANGELOG*, CITATION*, CODEOWNERS, CODE_OF_CONDUCT*, CONTRIBUTING*, CONTRIBUTORS, COPYING*, CREDITS, Changelog*, Citation*, Code_Of_Conduct*, Codeowners, Contributing*, Contributors, Copying*, Credits, GOVERNANCE.MD, Governance.md, HISTORY.MD, History.md, LICENSE*, License*, MAINTAINERS, Maintainers, README-*, README_*, RELEASE_NOTES*, ROADMAP.MD, Readme-*, Readme_*, Release_Notes*, Roadmap.md, SECURITY.MD, SPONSORS*, Security.md, Sponsors*, authors, backers*, changelog*, citation*, code_of_conduct*, codeowners, contributing*, contributors, copying*, credits, governance.md, history.md, license*, maintainers, readme-*, readme_*, release_notes*, roadmap.md, security.md, sponsors*",
"Dockerfile": "*.dockerfile, .devcontainer.*, .dockerignore, captain-definition, compose.*, docker-compose.*, dockerfile*"
+ },
+ "[typescriptreact]": {
+ "editor.defaultFormatter": "biomejs.biome"
}
}
diff --git a/app/components/game/PlayerCursors.tsx b/app/components/game/PlayerCursors.tsx
deleted file mode 100644
index 3aa9cfa..0000000
--- a/app/components/game/PlayerCursors.tsx
+++ /dev/null
@@ -1,23 +0,0 @@
-import type { Player } from "~/routes/rooms.$roomId"
-
-export function PlayerCursors({ players }: { players: Player[] }) {
- return (
- <>
- {players.map(
- (player) =>
- player.isActive && (
-
-
{player.player?.name}
-
- )
- )}
- >
- )
-}
diff --git a/app/realtime/player.ts b/app/realtime/player.ts
new file mode 100644
index 0000000..f479e5b
--- /dev/null
+++ b/app/realtime/player.ts
@@ -0,0 +1,38 @@
+import type { RealtimeMessage } from "@supabase/supabase-js"
+import { queryClient } from "~/root"
+import type { RoomLoaderData } from "~/routes/rooms.$roomId"
+
+export const handlePlayerUpdate =
+ (roomId: string, serverLoader: () => Promise) => async (payload: RealtimeMessage["payload"]) => {
+ const newItem = payload.new as {
+ player_id: string
+ isActive: boolean
+ score: number
+ id: string
+ scrollPosition: {
+ x: number
+ y: number
+ }
+ }
+ // biome-ignore lint/style/noNonNullAssertion: This will be there
+ const data = queryClient.getQueryData(["room", roomId])!
+ if (!data.players.find((p) => p.playerId === newItem.player_id)) {
+ const data = await serverLoader()
+
+ return queryClient.setQueryData(["room", roomId], data)
+ }
+ queryClient.setQueryData(["room", roomId], {
+ ...data,
+ players: data.players.map((player) => {
+ if (player.playerId === newItem.player_id) {
+ return {
+ ...player,
+ isActive: newItem.isActive,
+ score: newItem.score,
+ scrollPosition: newItem.scrollPosition,
+ }
+ }
+ return player
+ }),
+ })
+ }
diff --git a/app/realtime/room.ts b/app/realtime/room.ts
new file mode 100644
index 0000000..8406724
--- /dev/null
+++ b/app/realtime/room.ts
@@ -0,0 +1,20 @@
+import type { RealtimeMessage } from "@supabase/supabase-js"
+import { queryClient } from "~/root"
+import type { RoomLoaderData } from "~/routes/rooms.$roomId"
+
+export const handleRoomUpdate = (roomId: string) => (payload: RealtimeMessage["payload"]) => {
+ const newRoomInfo = payload.new
+ // biome-ignore lint/style/noNonNullAssertion: This will be there
+ const data = queryClient.getQueryData(["room", roomId])!
+ if (payload.new.id !== data.room.id) return
+ queryClient.setQueryData(["room", data.room.id], {
+ ...data,
+ room: {
+ ...data.room,
+ ...newRoomInfo,
+ flippedIndices: newRoomInfo.flippedIndices ?? data.room.flippedIndices,
+ matchedPairs: newRoomInfo.matchedPairs ?? data.room.matchedPairs,
+ currentTurn: newRoomInfo.current_turn,
+ },
+ })
+}
diff --git a/app/root.tsx b/app/root.tsx
index 2772926..ff5c830 100644
--- a/app/root.tsx
+++ b/app/root.tsx
@@ -1,3 +1,4 @@
+import { QueryClient, QueryClientProvider } from "@tanstack/react-query"
import { useTranslation } from "react-i18next"
import {
Form,
@@ -16,7 +17,8 @@ import type { Route } from "./+types/root"
import { getUserFromRequest } from "./queries/user.server"
import { commitServerSession, getServerSession } from "./session.server"
import tailwindcss from "./tailwind.css?url"
-
+// Create a client
+export const queryClient = new QueryClient()
export async function loader({ context, request }: Route.LoaderArgs) {
const { lang, clientEnv } = context
const player = await getUserFromRequest(request)
@@ -75,7 +77,7 @@ export const Layout = ({ children }: { children: React.ReactNode }) => {
- {children}
+ {children}
diff --git a/app/routes/rooms.$roomId.tsx b/app/routes/rooms.$roomId.tsx
index 7ec20fe..05f7eae 100644
--- a/app/routes/rooms.$roomId.tsx
+++ b/app/routes/rooms.$roomId.tsx
@@ -1,11 +1,15 @@
+import { useQuery } from "@tanstack/react-query"
import { QRCodeSVG } from "qrcode.react"
-import { useEffect, useState } from "react"
-import { type LoaderFunctionArgs, redirect, useRevalidator, useSubmit } from "react-router"
+import { useEffect } from "react"
+import { type LoaderFunctionArgs, redirect, useSubmit } from "react-router"
import { Grid } from "~/components/game/Grid"
import { Leaderboard } from "~/components/game/Leaderboard"
import { VictoryScreen } from "~/components/game/VictoryScreen"
import { db } from "~/db.server"
import { getUserFromRequest } from "~/queries/user.server"
+import { handlePlayerUpdate } from "~/realtime/player"
+import { handleRoomUpdate } from "~/realtime/room"
+import { queryClient } from "~/root"
import { supabase } from "~/utils/supabase"
import type { Route } from "./+types/rooms.$roomId"
@@ -51,6 +55,10 @@ export async function loader({ params, request }: LoaderFunctionArgs) {
return { room, players: room.activePlayers, cards: room.cards, user, qrCode }
}
+export type RoomLoaderData = Awaited>
+export type Player = Awaited>["players"][0]
+export type Room = Awaited>["room"]
+
export const action = async ({ request }: Route.ActionArgs) => {
const formData = await request.formData()
const playerId = formData.get("playerId") as string | null
@@ -70,25 +78,60 @@ export const action = async ({ request }: Route.ActionArgs) => {
return redirect("/")
}
-export type Player = Awaited>["players"][0]
-export type Room = Awaited>["room"]
+export const clientLoader = async ({ serverLoader, params }: Route.ClientLoaderArgs) => {
+ // Try to get the data from the cache
+ const cachedData = queryClient.getQueryData(["room", params.roomId])
+ // Either used the cached data or fetch it from the server
+ const data = cachedData ?? (await serverLoader())
+ // Don't set the data if it's already there
+ if (!cachedData) {
+ queryClient.setQueryData(["room", params.roomId], data)
+ }
+ // Subscribe to real-time updates on the room
+ const roomSubscription = supabase
+ .channel("rooms")
+ .on("postgres_changes", { event: "*", schema: "public", table: "rooms" }, handleRoomUpdate(params.roomId))
+ .subscribe()
+ // Subscribe to real-time updates on the active players
+ const playerSubscription = supabase
+ .channel("active_players")
+ .on(
+ "postgres_changes",
+ { event: "*", schema: "public", table: "active_players" },
+ handlePlayerUpdate(params.roomId, serverLoader)
+ )
+ .subscribe()
+
+ const unsubscribe = () => {
+ roomSubscription.unsubscribe()
+ playerSubscription.unsubscribe()
+ }
+ return {
+ ...data,
+ unsubscribe,
+ }
+}
-export default function Room({ loaderData }: Route.ComponentProps) {
- const { room, players, cards, user, qrCode } = loaderData
- const [activeRoom, setActiveRoom] = useState(room)
- const [activePlayers, setActivePlayers] = useState(players)
- const showVictory = activeRoom.status === "complete"
- const submit = useSubmit()
- useEffect(() => {
- setActiveRoom(room)
- }, [room])
+clientLoader.hydrate = true
+export default function Room({ loaderData, params }: Route.ComponentProps) {
+ const { room, cards, user, qrCode } = loaderData
+ const submit = useSubmit()
+ const { data } = useQuery({ queryKey: ["room", params.roomId] })
+ // Close the channels when the user leaves the page
useEffect(() => {
- setActivePlayers(players)
- }, [players])
+ return () => {
+ if ("unsubscribe" in loaderData) {
+ loaderData.unsubscribe()
+ }
+ }
+ }, [loaderData])
+ if (!data) return Loading...
+ const activeRoom = data.room
+ const activePlayers = data.players
+ const showVictory = activeRoom.status === "complete"
- const { revalidate } = useRevalidator()
- const markAsInactive = () => {
+ const leaveGame = () => {
const formData = new FormData()
const activePlayer = activePlayers.find((p) => p.playerId === user.id)
if (!activePlayer) return
@@ -96,113 +139,51 @@ export default function Room({ loaderData }: Route.ComponentProps) {
formData.append("playerId", user.id)
submit(formData, { method: "POST" })
}
- // Subscribe to real-time updates
- useEffect(() => {
- const roomSubscription = supabase
- .channel("rooms")
- .on("postgres_changes", { event: "*", schema: "public", table: "rooms" }, (payload) => {
- const newItem = payload.new as {
- id: string
- created_at: Date
- gridSize: number
- status: string
- created_by: string
- current_turn: string | null
- winnerId: string | null
- matchedPairs: string[]
- flippedIndices: number[]
- cards: string[]
- name: string
- }
- setActiveRoom({
- ...room,
- ...newItem,
- flippedIndices: newItem.flippedIndices ?? room.flippedIndices,
- matchedPairs: newItem.matchedPairs ?? room.matchedPairs,
- currentTurn: newItem.current_turn,
- })
- })
- .subscribe()
-
- const playerSubscription = supabase
- .channel("active_players")
- .on("postgres_changes", { event: "*", schema: "public", table: "active_players" }, (payload) => {
- const newItem = payload.new as {
- player_id: string
- isActive: boolean
- score: number
- id: string
- scrollPosition: {
- x: number
- y: number
- }
- }
-
- if (!activePlayers.find((p) => p.playerId === newItem.player_id)) {
- revalidate()
- }
- setActivePlayers(
- activePlayers.map((player) => {
- if (player.playerId === newItem.player_id) {
- return {
- ...player,
- isActive: newItem.isActive,
- score: newItem.score,
- scrollPosition: newItem.scrollPosition,
- }
- }
- return player
- })
- )
-
- //revalidate()
- })
- .subscribe()
-
- return () => {
- roomSubscription.unsubscribe()
- playerSubscription.unsubscribe()
- }
- }, [revalidate, activePlayers, room])
-
+ // Update room data
+ const updateRoom = async (data: Partial) => {
+ await supabase.from("rooms").update(data).eq("id", activeRoom.id)
+ }
+ const updatePlayer = async (data: Partial) => {
+ await supabase.from("active_players").update(data).eq("room_id", activeRoom.id).eq("player_id", user.id)
+ }
// Handle card clicks
const handleCardClick = async (index: number) => {
+ // Don't do anything if there are already two flipped cards
if (activeRoom.flippedIndices.length === 2) return
+ // Add the clicked card to the flipped indices
const newFlippedIndices = [...activeRoom.flippedIndices, index]
- await supabase
- .from("rooms")
- .update({ flippedIndices: [...newFlippedIndices] })
- .eq("id", room.id)
+ // Update the room with the new flipped indices so everyone can see the change
+ await updateRoom({ flippedIndices: [...newFlippedIndices] })
+ // Check if it's a match if two cards are flipped
if (activeRoom.flippedIndices.length === 1) {
+ // Check if the two cards are the same
const isMatch = cards[activeRoom.flippedIndices[0]] === cards[index]
const currentPlayerIndex = activePlayers.findIndex((p) => p.player?.name === activeRoom.currentTurn)
const currentPlayer = activePlayers[currentPlayerIndex]
+ // If it's a match, update the player's score and the room's status
if (isMatch) {
const newMatchedPairs = [...activeRoom.matchedPairs, cards[index]]
- const isFinished = newMatchedPairs.length === room.gridSize * 2
- await supabase
- .from("active_players")
- .update({ score: currentPlayer.score + 1 })
- .eq("room_id", room.id)
- .eq("player_id", user.id)
- await supabase
- .from("rooms")
- .update({ matchedPairs: newMatchedPairs, status: isFinished ? "complete" : "waiting" })
- .eq("id", room.id)
- if (isFinished) {
- return
- }
+ // Check if the game is finished and update the room and the player
+ const isFinished = newMatchedPairs.length === activeRoom.gridSize * 2
+ // Update the player's score
+ await updatePlayer({ score: currentPlayer.score + 1 })
+ // Update the room's status and matched pairs
+ await updateRoom({ matchedPairs: newMatchedPairs, status: isFinished ? "complete" : "waiting" })
+ // Revert the flipped cards so the game can go on
+ setTimeout(async () => {
+ await updateRoom({ flippedIndices: [] })
+ }, 1000)
+ // Exit early so that we don't go into the setTimeout below
+ return
}
setTimeout(async () => {
- await supabase.from("rooms").update({ flippedIndices: [] }).eq("id", room.id)
-
const nextPlayer = activePlayers[currentPlayerIndex + 1]
- if (nextPlayer === undefined) {
- await supabase.from("rooms").update({ current_turn: activePlayers[0].player?.name }).eq("id", room.id)
- } else {
- await supabase.from("rooms").update({ current_turn: nextPlayer.player?.name }).eq("id", room.id)
- }
+ // Move to the next player
+ await updateRoom({
+ flippedIndices: [],
+ current_turn: !nextPlayer ? activePlayers[0].player?.name : nextPlayer.player?.name,
+ })
}, 1000)
}
}
@@ -215,7 +196,7 @@ export default function Room({ loaderData }: Route.ComponentProps) {
{activeRoom.name}
-
@@ -224,7 +205,7 @@ export default function Room({ loaderData }: Route.ComponentProps) {
- size={activeRoom.gridSize as any}
+ size={room.gridSize as any}
cards={cards}
flippedIndices={activeRoom.flippedIndices}
matchedPairs={activeRoom.matchedPairs}
diff --git a/package.json b/package.json
index 368f62c..b6976aa 100644
--- a/package.json
+++ b/package.json
@@ -22,7 +22,7 @@
"test": "vitest run",
"test:ui": "vitest --ui --api 9527",
"test:cov": "vitest run --coverage",
- "typecheck": "tsc",
+ "typecheck": "react-router typegen && tsc",
"validate": "pnpm run check && pnpm run typecheck && pnpm run test && pnpm run check:unused",
"check": "biome check .",
"check:fix": "biome check --fix .",
@@ -38,6 +38,7 @@
"@prisma/client": "6.0.1",
"@react-router/node": "7.0.1",
"@supabase/supabase-js": "2.39.3",
+ "@tanstack/react-query": "5.62.2",
"clsx": "2.1.1",
"hono": "4.6.12",
"i18next": "23.15.2",
@@ -86,7 +87,7 @@
},
"engines": {
"node": ">=v22.11.0",
- "pnpm": ">=9.14.4"
+ "pnpm": ">=9.14.2"
},
- "packageManager": "pnpm@9.14.4"
+ "packageManager": "pnpm@9.14.2"
}
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index 4e1e10c..86368c4 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -20,6 +20,9 @@ importers:
'@supabase/supabase-js':
specifier: 2.39.3
version: 2.39.3
+ '@tanstack/react-query':
+ specifier: 5.62.2
+ version: 5.62.2(react@18.3.1)
clsx:
specifier: 2.1.1
version: 2.1.1
@@ -1423,6 +1426,14 @@ packages:
'@supabase/supabase-js@2.39.3':
resolution: {integrity: sha512-NoltJSaJNKDJNutO5sJPAAi5RIWrn1z2XH+ig1+cHDojT6BTN7TvZPNa3Kq3gFQWfO5H1N9El/bCTZJ3iFW2kQ==}
+ '@tanstack/query-core@5.62.2':
+ resolution: {integrity: sha512-LcwVcC5qpsDpHcqlXUUL5o9SaOBwhNkGeV+B06s0GBoyBr8FqXPuXT29XzYXR36lchhnerp6XO+CWc84/vh7Zg==}
+
+ '@tanstack/react-query@5.62.2':
+ resolution: {integrity: sha512-fkTpKKfwTJtVPKVR+ag7YqFgG/7TRVVPzduPAUF9zRCiiA8Wu305u+KJl8rCrh98Qce77vzIakvtUyzWLtaPGA==}
+ peerDependencies:
+ react: ^18 || ^19
+
'@types/cookie@0.6.0':
resolution: {integrity: sha512-4Kh9a6B2bQciAhf7FSuMRRkUWecJgJu9nPnx3yzpsfXX/c50REIqpHY4C82bXP90qrLtXtkDxTZosYO3UpOwlA==}
@@ -4462,6 +4473,13 @@ snapshots:
- bufferutil
- utf-8-validate
+ '@tanstack/query-core@5.62.2': {}
+
+ '@tanstack/react-query@5.62.2(react@18.3.1)':
+ dependencies:
+ '@tanstack/query-core': 5.62.2
+ react: 18.3.1
+
'@types/cookie@0.6.0': {}
'@types/d3-hierarchy@1.1.11': {}