From 4da335aaaab4c7dd2a02c810a7a20bf8fc0c7cdd Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sun, 7 Sep 2025 13:13:37 +0000 Subject: [PATCH] feat: Add multiplayer chess functionality --- mise.toml | 10 +- package.json | 5 +- pages/index.tsx | 8 + pages/multiplayer.tsx | 98 +++++ pnpm-lock.yaml | 209 +++++++++++ pnpm-workspace.yaml | 3 + src/game.ts | 12 + src/game/render-types.ts | 52 +++ src/game/render.ts | 6 +- src/game/state.ts | 3 +- src/game/types.ts | 22 ++ .../components/ConnectionManager.tsx | 172 +++++++++ .../components/MultiplayerGameBoard.tsx | 182 +++++++++ src/multiplayer/components/index.ts | 2 + src/multiplayer/hooks/index.ts | 2 + .../hooks/useMultiplayerConnection.ts | 136 +++++++ src/multiplayer/sketch.ts | 349 ++++++++++++++++++ src/multiplayer/utils/index.ts | 1 + src/multiplayer/utils/shareableLink.ts | 9 + src/multiplayer/yjs-game.ts | 290 +++++++++++++++ 20 files changed, 1565 insertions(+), 6 deletions(-) create mode 100644 pages/multiplayer.tsx create mode 100644 pnpm-workspace.yaml create mode 100644 src/game/render-types.ts create mode 100644 src/multiplayer/components/ConnectionManager.tsx create mode 100644 src/multiplayer/components/MultiplayerGameBoard.tsx create mode 100644 src/multiplayer/components/index.ts create mode 100644 src/multiplayer/hooks/index.ts create mode 100644 src/multiplayer/hooks/useMultiplayerConnection.ts create mode 100644 src/multiplayer/sketch.ts create mode 100644 src/multiplayer/utils/index.ts create mode 100644 src/multiplayer/utils/shareableLink.ts create mode 100644 src/multiplayer/yjs-game.ts diff --git a/mise.toml b/mise.toml index 32df973..fc79ea1 100644 --- a/mise.toml +++ b/mise.toml @@ -4,9 +4,17 @@ bun = "1.2.18" node = "20.5.1" pnpm = "10.12.1" +[tasks.dev] +description = "Run dev" +run = "pnpm run --quiet dev" + +[tasks.check] +description = "Run linting and type checking" +run = "pnpm run --quiet check" + [tasks."test"] description = "Run the tests" -run = "pnpm run --silent test" +run = "pnpm run --quiet test" [tasks."uci"] description = "Run the UCI interface" diff --git a/package.json b/package.json index 55568ca..1482654 100644 --- a/package.json +++ b/package.json @@ -24,6 +24,7 @@ "@mantine/hooks": "^6.0.21", "@p5-wrapper/next": "^0.2.0", "@p5-wrapper/react": "^4.2.0", + "@tabler/icons-react": "^3.34.1", "@vercel/analytics": "^1.2.2", "class-transformer": "^0.5.1", "howler": "^2.2.3", @@ -38,7 +39,9 @@ "react-dom": "18.3.0", "react-no-ssr": "^1.1.0", "reflect-metadata": "^0.1.14", - "ts-pattern": "^5.0.6" + "ts-pattern": "^5.0.6", + "y-webrtc": "^10.3.0", + "yjs": "^13.6.27" }, "devDependencies": { "@testing-library/jest-dom": "^6.1.3", diff --git a/pages/index.tsx b/pages/index.tsx index 85f406a..87491ff 100644 --- a/pages/index.tsx +++ b/pages/index.tsx @@ -289,6 +289,14 @@ export default function Home() { +
diff --git a/pages/multiplayer.tsx b/pages/multiplayer.tsx new file mode 100644 index 0000000..b276b22 --- /dev/null +++ b/pages/multiplayer.tsx @@ -0,0 +1,98 @@ +import React, { useState, useEffect } from 'react' +import Head from 'next/head' +import { useRouter } from 'next/router' +import { useElementSize } from '@mantine/hooks' +import { Stack } from '@mantine/core' + +import { GameProps } from '@/game/types' +import { useMultiplayerConnection } from '@/multiplayer/hooks' +import { ConnectionManager, MultiplayerGameBoard } from '@/multiplayer/components' +import { getShareableLink } from '@/multiplayer/utils' +import styles from '../styles/index.module.scss' + +/** + * Main multiplayer chess page component. + */ +export default function Multiplayer() { + const router = useRouter() + const { ref: containerRef, width, height } = useElementSize() + + const { + // State + multiplayerGame, + connectionStatus, + gameId, + joinGameId, + error, + isHost, + // Actions + setJoinGameId, + createGame, + joinGame, + disconnect, + } = useMultiplayerConnection() + + // Game state for the board + const [bestMove, setBestMove] = useState[0]>(null) + const [history, setHistory] = useState[0]>([]) + const [output, setOutput] = useState[0]>('') + const [gameStatus, setGameStatus] = + useState[0]>('playing') + + // Handle join link from URL + useEffect(() => { + const { join } = router.query + if (join && typeof join === 'string') { + setJoinGameId(join) + } + }, [router.query, setJoinGameId]) + + const handleGetShareableLink = () => getShareableLink(gameId) + + const showGameBoard = connectionStatus === 'connected' + + return ( + <> + + Multiplayer Chess - Ches + + + +
+
+ + + + + {/* Game Board - Only show when both players are connected */} + {showGameBoard && ( + + )} +
+
+ + ) +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 45e1f7f..bf17c62 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -32,6 +32,9 @@ importers: '@p5-wrapper/react': specifier: ^4.2.0 version: 4.2.0(react-dom@18.3.0(react@18.3.0))(react@18.3.0) + '@tabler/icons-react': + specifier: ^3.34.1 + version: 3.34.1(react@18.3.0) '@vercel/analytics': specifier: ^1.2.2 version: 1.2.2(next@14.2.3(@babel/core@7.22.20)(babel-plugin-macros@3.1.0)(react-dom@18.3.0(react@18.3.0))(react@18.3.0)(sass@1.66.1))(react@18.3.0) @@ -53,6 +56,9 @@ importers: p5: specifier: ^1.7.0 version: 1.7.0 + peerjs: + specifier: ^1.5.5 + version: 1.5.5 ramda: specifier: ^0.29.1 version: 0.29.1 @@ -77,6 +83,12 @@ importers: ts-pattern: specifier: ^5.0.6 version: 5.0.6 + y-webrtc: + specifier: ^10.3.0 + version: 10.3.0(yjs@13.6.27) + yjs: + specifier: ^13.6.27 + version: 13.6.27 devDependencies: '@testing-library/jest-dom': specifier: ^6.1.3 @@ -102,6 +114,9 @@ importers: '@types/p5': specifier: ^1.7.0 version: 1.7.0 + '@types/peerjs': + specifier: ^1.1.0 + version: 1.1.0 '@types/ramda': specifier: ^0.29.9 version: 0.29.9 @@ -681,6 +696,10 @@ packages: '@mdn/browser-compat-data@5.3.14': resolution: {integrity: sha512-Y9XQrphVcE6u9xMm+gIqN86opbU/5s2W1pdPyKRyFV5B7+2jWM2gLI5JpfhZncaoDKvhy6FYwK04aCz5UM/bTQ==} + '@msgpack/msgpack@2.8.0': + resolution: {integrity: sha512-h9u4u/jiIRKbq25PM+zymTyW6bhTzELvOoUd+AvYriWOAKpLGnIamaET3pnHYoI5iYphAHBI4ayx0MehR+VVPQ==} + engines: {node: '>= 10'} + '@next/env@14.2.3': resolution: {integrity: sha512-W7fd7IbkfmeeY2gXrzJYDx8D2lWKbVoTIj1o1ScPHNzvp30s1AuoEFSdr39bC5sjxJaxTtq3OTCZboNp0lNWHA==} @@ -847,6 +866,14 @@ packages: '@swc/helpers@0.5.5': resolution: {integrity: sha512-KGYxvIOXcceOAbEk4bi/dVLEK9z8sZ0uBB3Il5b1rhfClSpcX0yfRO0KmTkqR2cnQDymwLB+25ZyMzICg/cm/A==} + '@tabler/icons-react@3.34.1': + resolution: {integrity: sha512-Ld6g0NqOO05kyyHsfU8h787PdHBm7cFmOycQSIrGp45XcXYDuOK2Bs0VC4T2FWSKZ6bx5g04imfzazf/nqtk1A==} + peerDependencies: + react: '>= 16' + + '@tabler/icons@3.34.1': + resolution: {integrity: sha512-9gTnUvd7Fd/DmQgr3MKY+oJLa1RfNsQo8c/ir3TJAWghOuZXodbtbVp0QBY2DxWuuvrSZFys0HEbv1CoiI5y6A==} + '@testing-library/dom@9.3.3': resolution: {integrity: sha512-fB0R+fa3AUqbLHWyxXa2kGVtf1Fe1ZZFr0Zp6AIbIAzXb2mKbEXl+PCQNUOaq5lbTab5tfctfXRNsWXxa2f7Aw==} engines: {node: '>=14'} @@ -940,6 +967,10 @@ packages: '@types/parse-json@4.0.0': resolution: {integrity: sha512-//oorEZjL6sbPcKUaCdIGlIUeH26mgzimjBB77G6XRgnDl/L5wOnpyBGRe/Mmf5CVW3PwEBE1NjiMZ/ssFh4wA==} + '@types/peerjs@1.1.0': + resolution: {integrity: sha512-dVocsfYFg5QQuUB9OAxfrSvz4br4pyX+7M61ZJSRiYtE3NdayShk1p1Y8b9TmCj724TwHskVraeF7wyPl0rYcg==} + deprecated: This is a stub types definition. peerjs provides its own type definitions, so you do not need this installed. + '@types/prop-types@15.7.5': resolution: {integrity: sha512-JCB8C6SnDoQf0cNycqd/35A7MjcnK+ZTqE7judS6o7utxUCg6imJg3QK2qzHKszlTjcj2cn+NwMB2i96ubpj7w==} @@ -1245,6 +1276,9 @@ packages: balanced-match@1.0.2: resolution: {integrity: sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==} + base64-js@1.5.1: + resolution: {integrity: sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==} + binary-extensions@2.2.0: resolution: {integrity: sha512-jDctJ/IVQbZoJykoeHbhXpOlNBqGNcwXJKJog42E5HDPUwQTSdjCHdihjj0DlnheQ7blbT6dHOafNAiS8ooQKA==} engines: {node: '>=8'} @@ -1270,6 +1304,9 @@ packages: buffer-from@1.1.2: resolution: {integrity: sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==} + buffer@6.0.3: + resolution: {integrity: sha512-FTiCpNxtwiZZHEZbcbTIcZjERVICn9yq/pDFkTl95/AxzD1naBctN7YO68riM/gLSDY7sdrMby8hofADYuuqOA==} + busboy@1.6.0: resolution: {integrity: sha512-8SFQbg/0hQ9xy3UNTB0YEnsNBbWfhf7RtnzpL7TkBiTBRfrQ9Fxcnz7VJsleJpyp6rVLvXiuORqjlHi5q+PYuA==} engines: {node: '>=10.16.0'} @@ -1543,6 +1580,9 @@ packages: resolution: {integrity: sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==} engines: {node: '>=0.12'} + err-code@3.0.1: + resolution: {integrity: sha512-GiaH0KJUewYok+eeY05IIgjtAe4Yltygk9Wqp1V5yVWLdhf0hYZchRjNIT9bb0mSwRcIusT3cx7PJUf3zEIfUA==} + error-ex@1.3.2: resolution: {integrity: sha512-7dFHNmqeFSEt2ZBsCriorKnn3Z2pj+fd9kmI6QoWw4//DL+icEBfc0U7qJCisqrTsKTjw4fNFy2pW9OqStD84g==} @@ -1721,6 +1761,9 @@ packages: resolution: {integrity: sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==} engines: {node: '>=0.10.0'} + eventemitter3@4.0.7: + resolution: {integrity: sha512-8guHBZCwKnFhYdHr2ysuRWErTwhoN2X8XELRlrRwpmfeY2jjuUN4taQMsULKUVo1K4DvZl+0pgfyoysHxvmvEw==} + execa@5.1.1: resolution: {integrity: sha512-8uSpZZocAZRBAPIEINJj3Lo9HyGitllczc27Eh5YYojjMFMn8yHMDMaUHE2Jqfq05D/wucwI4JGURyXt1vchyg==} engines: {node: '>=10'} @@ -1817,6 +1860,9 @@ packages: resolution: {integrity: sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==} engines: {node: '>=6.9.0'} + get-browser-rtc@1.1.0: + resolution: {integrity: sha512-MghbMJ61EJrRsDe7w1Bvqt3ZsBuqhce5nrn/XAwgwOXhcsz53/ltdxOse1h/8eKXj5slzxdsz56g5rzOFSGwfQ==} + get-caller-file@2.0.5: resolution: {integrity: sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==} engines: {node: 6.* || 8.* || >= 10.*} @@ -1957,6 +2003,9 @@ packages: resolution: {integrity: sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==} engines: {node: '>=0.10.0'} + ieee754@1.2.1: + resolution: {integrity: sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==} + ignore@5.2.4: resolution: {integrity: sha512-MAb38BcSbH0eHNBxn7ql2NH/kX33OkB3lZ1BNdh7ENeRChHTYsTvWrMubiIAMNS2llXEEgZ1MUOBtXChP3kaFQ==} engines: {node: '>= 4'} @@ -2131,6 +2180,9 @@ packages: isexe@2.0.0: resolution: {integrity: sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==} + isomorphic.js@0.2.5: + resolution: {integrity: sha512-PIeMbHqMt4DnUP3MA/Flc0HElYjMXArsw1qwJZcm9sqR8mq3l8NYizFMty0pWwE/tzIGH3EKK5+jes5mAr85yw==} + istanbul-lib-coverage@3.2.0: resolution: {integrity: sha512-eOeJ5BHCmHYvQK7xt9GkdHuzuCGS1Y6g9Gvnx3Ym33fz/HpLRYxiS0wHNr+m/MBC8B647Xt608vCDEvhl9c6Mw==} engines: {node: '>=8'} @@ -2370,6 +2422,11 @@ packages: resolution: {integrity: sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==} engines: {node: '>= 0.8.0'} + lib0@0.2.114: + resolution: {integrity: sha512-gcxmNFzA4hv8UYi8j43uPlQ7CGcyMJ2KQb5kZASw6SnAKAf10hK12i2fjrS3Cl/ugZa5Ui6WwIu1/6MIXiHttQ==} + engines: {node: '>=16'} + hasBin: true + lines-and-columns@1.2.4: resolution: {integrity: sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==} @@ -2627,6 +2684,14 @@ packages: resolution: {integrity: sha512-gDKb8aZMDeD/tZWs9P6+q0J9Mwkdl6xMV8TjnGP3qJVJ06bdMgkbBlLU8IdfOsIsFz2BW1rNVT3XuNEl8zPAvw==} engines: {node: '>=8'} + peerjs-js-binarypack@2.1.0: + resolution: {integrity: sha512-YIwCC+pTzp3Bi8jPI9UFKO0t0SLo6xALnHkiNt/iUFmUUZG0fEEmEyFKvjsDKweiFitzHRyhuh6NvyJZ4nNxMg==} + engines: {node: '>= 14.0.0'} + + peerjs@1.5.5: + resolution: {integrity: sha512-viMUCPDL6CSfOu0ZqVcFqbWRXNHIbv2lPqNbrBIjbFYrflebOjItJ4hPfhjnuUCstqciHVu9vVJ7jFqqKi/EuQ==} + engines: {node: '>= 14'} + picocolors@1.0.0: resolution: {integrity: sha512-1fygroTLlHu66zi26VoTDv8yRgm0Fccecssto+MhsZ0D/DGW2sm8E8AjW7NU5VVTRt5GxbeZ5qBuJr+HyLYkjQ==} @@ -2691,6 +2756,9 @@ packages: random-seedable@1.0.8: resolution: {integrity: sha512-f6gzvNhAnZBht1Prn0e/tpukUNhkANntFF42uIdWDPriyEATYaRpyH8A9bYaGecUB3AL+dXeYtBUggy18fe3rw==} + randombytes@2.1.0: + resolution: {integrity: sha512-vYl3iOX+4CKUWuxGi9Ukhie6fsqXqS9FE2Zaic4tNFD2N2QQaXOMFbuKK4QmDHC0JO6B1Zp41J0LpT0oR68amQ==} + react-autosuggest@10.1.0: resolution: {integrity: sha512-/azBHmc6z/31s/lBf6irxPf/7eejQdR0IqnZUzjdSibtlS8+Rw/R79pgDAo6Ft5QqCUTyEQ+f0FhL+1olDQ8OA==} peerDependencies: @@ -2775,6 +2843,10 @@ packages: resolution: {integrity: sha512-RPutkJftSAldDibyrjuku7q11d3oy6wKOyPe5K1HA/HwwrXcEqBdHsLypkC2FFYjP7bPUa6gbzSBhw4sY2JcDg==} engines: {node: '>=0.10.0'} + readable-stream@3.6.2: + resolution: {integrity: sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==} + engines: {node: '>= 6'} + readdirp@3.6.0: resolution: {integrity: sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA==} engines: {node: '>=8.10.0'} @@ -2846,6 +2918,9 @@ packages: resolution: {integrity: sha512-vj6RsCsWBCf19jIeHEfkRMw8DPiBb+DMXklQ/1SGDHOMlHdPUkZXFQ2YdplS23zESTijAcurb1aSgJA3AgMu1Q==} engines: {node: '>=0.4'} + safe-buffer@5.2.1: + resolution: {integrity: sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==} + safe-regex-test@1.0.3: resolution: {integrity: sha512-CdASjNJPvRa7roO6Ra/gLYBTzYzzPyyBXxIMdGW3USQLyjWEls2RgW5UBTXaQVp+OrpeCK3bLem8smtmheoRuw==} engines: {node: '>= 0.4'} @@ -2865,6 +2940,9 @@ packages: scheduler@0.23.2: resolution: {integrity: sha512-UOShsPwz7NrMUqhR6t0hWjFduvOzbtv7toDH1/hIrfRNIDBnnBWd0CwJTGvTpngVlmwGCdP9/Zl/tVrDqcuYzQ==} + sdp@3.2.1: + resolution: {integrity: sha512-lwsAIzOPlH8/7IIjjz3K0zYBk7aBVVcvjMwt3M4fLxpjMYyy7i3I97SLHebgn4YBjirkzfp3RvRDWSKsh/+WFw==} + section-iterator@2.0.0: resolution: {integrity: sha512-xvTNwcbeDayXotnV32zLb3duQsP+4XosHpb/F+tu6VzEZFmIjzPdNk6/O+QOOx5XTh08KL2ufdXeCO33p380pQ==} @@ -2910,6 +2988,9 @@ packages: resolution: {integrity: sha512-MY2/qGx4enyjprQnFaZsHib3Yadh3IXyV2C321GY0pjGfVBu4un0uDJkwgdxqO+Rdx8JMT8IfJIRwbYVz3Ob3Q==} engines: {node: '>=14'} + simple-peer@9.11.1: + resolution: {integrity: sha512-D1SaWpOW8afq1CZGWB8xTfrT3FekjQmPValrqncJMX7QFl8YwhrPTZvMCANLtgBwwdS+7zURyqxDDEmY558tTw==} + sisteransi@1.0.5: resolution: {integrity: sha512-bLGGlR1QxBcynn2d5YmDX4MGjlZvy2MRBDRNHLJ8VI6l6+9FUiyTFNJ0IveOSP0bcXgVDPRcfGqA0pjaqUpfVg==} @@ -2978,6 +3059,9 @@ packages: resolution: {integrity: sha512-UXSH262CSZY1tfu3G3Secr6uGLCFVPMhIqHjlgCUtCCcgihYc/xKs9djMTMUOb2j1mVSeU8EU6NWc/iQKU6Gfg==} engines: {node: '>= 0.4'} + string_decoder@1.3.0: + resolution: {integrity: sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==} + strip-ansi@6.0.1: resolution: {integrity: sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==} engines: {node: '>=8'} @@ -3201,6 +3285,9 @@ packages: '@types/react': optional: true + util-deprecate@1.0.2: + resolution: {integrity: sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==} + v8-to-istanbul@9.1.0: resolution: {integrity: sha512-6z3GW9x8G1gd+JIIgQQQxXuiJtCXeAjp6RaPEPLv62mH3iPHPxV6W3robxtCzNErRo6ZwTmzWhsbNvjyEBKzKA==} engines: {node: '>=10.12.0'} @@ -3219,6 +3306,10 @@ packages: resolution: {integrity: sha512-VwddBukDzu71offAQR975unBIGqfKZpM+8ZX6ySk8nYhVoo5CYaZyzt3YBvYtRtO+aoGlqxPg/B87NGVZ/fu6g==} engines: {node: '>=12'} + webrtc-adapter@9.0.3: + resolution: {integrity: sha512-5fALBcroIl31OeXAdd1YUntxiZl1eHlZZWzNg3U4Fn+J9/cGL3eT80YlrsWGvj2ojuz1rZr2OXkgCzIxAZ7vRQ==} + engines: {node: '>=6.0.0', npm: '>=3.10.0'} + whatwg-encoding@2.0.0: resolution: {integrity: sha512-p41ogyeMUrw3jWclHWTQg1k05DSVXPLcVxRTYsXUk+ZooOCZLcoYgPZ/HL/D/N+uQPOtcp1me1WhBEaX02mhWg==} engines: {node: '>=12'} @@ -3287,6 +3378,19 @@ packages: xmlchars@2.2.0: resolution: {integrity: sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==} + y-protocols@1.0.6: + resolution: {integrity: sha512-vHRF2L6iT3rwj1jub/K5tYcTT/mEYDUppgNPXwp8fmLpui9f7Yeq3OEtTLVF012j39QnV+KEQpNqoN7CWU7Y9Q==} + engines: {node: '>=16.0.0', npm: '>=8.0.0'} + peerDependencies: + yjs: ^13.0.0 + + y-webrtc@10.3.0: + resolution: {integrity: sha512-KalJr7dCgUgyVFxoG3CQYbpS0O2qybegD0vI4bYnYHI0MOwoVbucED3RZ5f2o1a5HZb1qEssUKS0H/Upc6p1lA==} + engines: {node: '>=12'} + hasBin: true + peerDependencies: + yjs: ^13.6.8 + y18n@5.0.8: resolution: {integrity: sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==} engines: {node: '>=10'} @@ -3309,6 +3413,10 @@ packages: resolution: {integrity: sha512-7dSzzRQ++CKnNI/krKnYRV7JKKPUXMEh61soaHKg9mrWEhzFWhFnxPxGl+69cD1Ou63C13NUPCnmIcrvqCuM6w==} engines: {node: '>=12'} + yjs@13.6.27: + resolution: {integrity: sha512-OIDwaflOaq4wC6YlPBy2L6ceKeKuF7DeTxx+jPzv1FHn9tCZ0ZwSRnUBxD05E3yed46fv/FWJbvR+Ud7x0L7zw==} + engines: {node: '>=16.0.0', npm: '>=8.0.0'} + yocto-queue@0.1.0: resolution: {integrity: sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==} engines: {node: '>=10'} @@ -3943,6 +4051,8 @@ snapshots: '@mdn/browser-compat-data@5.3.14': {} + '@msgpack/msgpack@2.8.0': {} + '@next/env@14.2.3': {} '@next/eslint-plugin-next@14.2.3': @@ -4102,6 +4212,13 @@ snapshots: '@swc/counter': 0.1.3 tslib: 2.5.3 + '@tabler/icons-react@3.34.1(react@18.3.0)': + dependencies: + '@tabler/icons': 3.34.1 + react: 18.3.0 + + '@tabler/icons@3.34.1': {} + '@testing-library/dom@9.3.3': dependencies: '@babel/code-frame': 7.22.13 @@ -4204,6 +4321,10 @@ snapshots: '@types/parse-json@4.0.0': {} + '@types/peerjs@1.1.0': + dependencies: + peerjs: 1.5.5 + '@types/prop-types@15.7.5': {} '@types/ramda@0.29.9': @@ -4601,6 +4722,8 @@ snapshots: balanced-match@1.0.2: {} + base64-js@1.5.1: {} + binary-extensions@2.2.0: {} brace-expansion@1.1.11: @@ -4629,6 +4752,11 @@ snapshots: buffer-from@1.1.2: {} + buffer@6.0.3: + dependencies: + base64-js: 1.5.1 + ieee754: 1.2.1 + busboy@1.6.0: dependencies: streamsearch: 1.1.0 @@ -4896,6 +5024,8 @@ snapshots: entities@4.5.0: {} + err-code@3.0.1: {} + error-ex@1.3.2: dependencies: is-arrayish: 0.2.1 @@ -5251,6 +5381,8 @@ snapshots: esutils@2.0.3: {} + eventemitter3@4.0.7: {} + execa@5.1.1: dependencies: cross-spawn: 7.0.3 @@ -5359,6 +5491,8 @@ snapshots: gensync@1.0.0-beta.2: {} + get-browser-rtc@1.1.0: {} + get-caller-file@2.0.5: {} get-intrinsic@1.2.4: @@ -5504,6 +5638,8 @@ snapshots: dependencies: safer-buffer: 2.1.2 + ieee754@1.2.1: {} + ignore@5.2.4: {} immutable@4.3.0: {} @@ -5660,6 +5796,8 @@ snapshots: isexe@2.0.0: {} + isomorphic.js@0.2.5: {} + istanbul-lib-coverage@3.2.0: {} istanbul-lib-instrument@5.2.1: @@ -6154,6 +6292,10 @@ snapshots: prelude-ls: 1.2.1 type-check: 0.4.0 + lib0@0.2.114: + dependencies: + isomorphic.js: 0.2.5 + lines-and-columns@1.2.4: {} locate-path@5.0.0: @@ -6399,6 +6541,15 @@ snapshots: path-type@4.0.0: {} + peerjs-js-binarypack@2.1.0: {} + + peerjs@1.5.5: + dependencies: + '@msgpack/msgpack': 2.8.0 + eventemitter3: 4.0.7 + peerjs-js-binarypack: 2.1.0 + webrtc-adapter: 9.0.3 + picocolors@1.0.0: {} picomatch@2.3.1: {} @@ -6456,6 +6607,10 @@ snapshots: random-seedable@1.0.8: {} + randombytes@2.1.0: + dependencies: + safe-buffer: 5.2.1 + react-autosuggest@10.1.0(react@18.3.0): dependencies: es6-promise: 4.2.8 @@ -6548,6 +6703,12 @@ snapshots: dependencies: loose-envify: 1.4.0 + readable-stream@3.6.2: + dependencies: + inherits: 2.0.4 + string_decoder: 1.3.0 + util-deprecate: 1.0.2 + readdirp@3.6.0: dependencies: picomatch: 2.3.1 @@ -6623,6 +6784,8 @@ snapshots: has-symbols: 1.0.3 isarray: 2.0.5 + safe-buffer@5.2.1: {} + safe-regex-test@1.0.3: dependencies: call-bind: 1.0.7 @@ -6645,6 +6808,8 @@ snapshots: dependencies: loose-envify: 1.4.0 + sdp@3.2.1: {} + section-iterator@2.0.0: {} semver@6.3.1: {} @@ -6690,6 +6855,18 @@ snapshots: signal-exit@4.0.2: {} + simple-peer@9.11.1: + dependencies: + buffer: 6.0.3 + debug: 4.3.4 + err-code: 3.0.1 + get-browser-rtc: 1.1.0 + queue-microtask: 1.2.3 + randombytes: 2.1.0 + readable-stream: 3.6.2 + transitivePeerDependencies: + - supports-color + sisteransi@1.0.5: {} slash@3.0.0: {} @@ -6770,6 +6947,10 @@ snapshots: define-properties: 1.2.1 es-object-atoms: 1.0.0 + string_decoder@1.3.0: + dependencies: + safe-buffer: 5.2.1 + strip-ansi@6.0.1: dependencies: ansi-regex: 5.0.1 @@ -6978,6 +7159,8 @@ snapshots: optionalDependencies: '@types/react': 18.3.0 + util-deprecate@1.0.2: {} + v8-to-istanbul@9.1.0: dependencies: '@jridgewell/trace-mapping': 0.3.19 @@ -6998,6 +7181,10 @@ snapshots: webidl-conversions@7.0.0: {} + webrtc-adapter@9.0.3: + dependencies: + sdp: 3.2.1 + whatwg-encoding@2.0.0: dependencies: iconv-lite: 0.6.3 @@ -7078,6 +7265,24 @@ snapshots: xmlchars@2.2.0: {} + y-protocols@1.0.6(yjs@13.6.27): + dependencies: + lib0: 0.2.114 + yjs: 13.6.27 + + y-webrtc@10.3.0(yjs@13.6.27): + dependencies: + lib0: 0.2.114 + simple-peer: 9.11.1 + y-protocols: 1.0.6(yjs@13.6.27) + yjs: 13.6.27 + optionalDependencies: + ws: 8.14.2 + transitivePeerDependencies: + - bufferutil + - supports-color + - utf-8-validate + y18n@5.0.8: {} yallist@3.1.1: {} @@ -7098,4 +7303,8 @@ snapshots: y18n: 5.0.8 yargs-parser: 21.1.1 + yjs@13.6.27: + dependencies: + lib0: 0.2.114 + yocto-queue@0.1.0: {} diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml new file mode 100644 index 0000000..3e94409 --- /dev/null +++ b/pnpm-workspace.yaml @@ -0,0 +1,3 @@ +onlyBuiltDependencies: + - core-js + - esbuild diff --git a/src/game.ts b/src/game.ts index 5528b00..08060d5 100644 --- a/src/game.ts +++ b/src/game.ts @@ -133,6 +133,18 @@ export const sketch = (p5: P5CanvasInstance): GameMetho .with({ type: 'doNothing' }, () => { return }) + .with({ type: 'multiplayerMove' }, () => { + // Handled by multiplayer sketch + return + }) + .with({ type: 'multiplayerBoardState' }, () => { + // Handled by multiplayer sketch + return + }) + .with({ type: 'multiplayerReset' }, () => { + // Handled by multiplayer sketch + return + }) .exhaustive() } diff --git a/src/game/render-types.ts b/src/game/render-types.ts new file mode 100644 index 0000000..7a7a118 --- /dev/null +++ b/src/game/render-types.ts @@ -0,0 +1,52 @@ +import { Move } from '@/move' +import { Board } from '@/board' +import { Challenge } from '@/challenges/core' +import { Identity } from '@/identity' +import { Score } from '@/eval/score' +import { Coord } from '@/utils/coord' + +/** + * Best move evaluation result + */ +export interface BestMove { + move: Move | null + score: Score + time: number + line: Move[] + nodes: number +} + +/** + * Game status information - matches actual Chess class gameStatus field + */ +export type GameStatus = + | { status: 'playing' } + | { status: 'won'; reason: 'checkmate' } + | { status: 'lost'; reason: 'checkmate' | 'challengeFailed' | 'challengeNoMovesAvailable' } + | { status: 'draw'; reason: 'threefoldRepetition' | 'other' } + +/** + * Interface defining the chess game state contract for rendering. + * Uses duck typing to be compatible with both Chess class and mock objects. + */ +export interface IChessState { + board: Board + challenge: Challenge | null + identity: Identity | null + history: Array<{ move: Move; beforeMove: any; afterMove: any }> + bestMove: BestMove | null + gameStatus: GameStatus + isMoveAllowedByChallenge: (move: Move) => boolean + lastMove: () => Move | null +} + +/** + * Interface defining the complete game state contract for rendering + */ +export interface IRenderableGameState { + chess: IChessState + dragged: Coord | null + lastMoveTimestamp: number + makeMove: (move: Move) => void + updateBestMoveAndGameStatus: (options: { searchDepth: number }) => void +} diff --git a/src/game/render.ts b/src/game/render.ts index 548340c..87fe78b 100644 --- a/src/game/render.ts +++ b/src/game/render.ts @@ -1,6 +1,6 @@ import { P5CanvasInstance, SketchProps } from '@p5-wrapper/react' import { GameProps } from './types' -import { GameState } from './state' +import { IRenderableGameState } from './render-types' import { Board } from '@/board' import { DrawConstants } from '@/draw/constants' import { drawPiece, drawDraggedPiece } from '@/draw/piece' @@ -11,7 +11,7 @@ import { match } from 'ts-pattern' export function render( p5: P5CanvasInstance, - state: GameState, + state: IRenderableGameState, vars: GameProps ) { // Chess.com colors @@ -31,7 +31,7 @@ export function render( const highlights = state.chess.challenge?.highlightSquares?.({ board: state.chess.board, - identity: state.chess.identity, + identity: state.chess.identity!, history: state.chess.history, }) ?? [] p5.push() diff --git a/src/game/state.ts b/src/game/state.ts index 2036e97..5a58e1a 100644 --- a/src/game/state.ts +++ b/src/game/state.ts @@ -2,13 +2,14 @@ import { EvalNode } from '@/eval/node' import { Move } from '@/move' import { Coord } from '@/utils/coord' import { Chess } from './chess' +import { IRenderableGameState } from './render-types' /** * Internal state of the sketch. Not exposed to React. * * This should be enough to render the game. */ -export class GameState { +export class GameState implements IRenderableGameState { /** Chess state. */ chess: Chess diff --git a/src/game/types.ts b/src/game/types.ts index 542d439..859b16f 100644 --- a/src/game/types.ts +++ b/src/game/types.ts @@ -1,6 +1,21 @@ import { Challenge } from '@/challenges/core' import { Move } from '@/move' import { Chess } from './chess' +import { Coord } from '@/utils/coord' + +/** + * Compatible board state for multiplayer processing (with proper Coord objects) + */ +export interface CompatibleBoardState { + board: Uint8Array + side: number + kings: { white: Coord; black: Coord } + castlingRights: number + enPassantTargetSquare: Coord | null + fullMoveNumber: number + halfMoveNumber: number + lastMove: any | null +} /** * Like events in Elm. @@ -9,6 +24,9 @@ export type GameMessage = | { type: 'makeMove'; move: Move } | { type: 'updateBestMove' } | { type: 'doNothing' } + | { type: 'multiplayerMove'; move: Move } + | { type: 'multiplayerBoardState'; state: CompatibleBoardState } + | { type: 'multiplayerReset' } /** * A way to control the sketch from the outside world. @@ -42,4 +60,8 @@ export type GameProps = { onStatusChange: (status: 'playing' | 'won' | 'lost' | 'draw') => void /** Called when current game's history changes. */ onHistoryChange: (history: Chess['history']) => void + /** Multiplayer mode - if true, disables rule enforcement and AI */ + multiplayerMode?: boolean + /** Multiplayer game instance for P2P communication */ + multiplayerGame?: any } diff --git a/src/multiplayer/components/ConnectionManager.tsx b/src/multiplayer/components/ConnectionManager.tsx new file mode 100644 index 0000000..3a5e897 --- /dev/null +++ b/src/multiplayer/components/ConnectionManager.tsx @@ -0,0 +1,172 @@ +import React from 'react' +import { + Button, + Center, + Group, + Stack, + Text, + TextInput, + Alert, + Loader, + ActionIcon, + CopyButton, + Tooltip, +} from '@mantine/core' +import { IconCopy, IconCheck } from '@tabler/icons-react' +import { ConnectionStatus } from '../hooks/useMultiplayerConnection' + +/** + * Props for the ConnectionManager component + */ +interface ConnectionManagerProps { + // State + connectionStatus: ConnectionStatus + gameId: string + joinGameId: string + error: string + isHost: boolean + + // Actions + onCreateGame: () => Promise + onJoinGame: () => Promise + onDisconnect: () => void + onJoinGameIdChange: (id: string) => void + + // Utils + getShareableLink: () => string +} + +/** + * Manages the UI for creating, joining, and monitoring multiplayer game connections. + */ +export function ConnectionManager({ + connectionStatus, + gameId, + joinGameId, + error, + isHost, + onCreateGame, + onJoinGame, + onDisconnect, + onJoinGameIdChange, + getShareableLink, +}: ConnectionManagerProps) { + if (connectionStatus === 'disconnected') { + return ( + + + + + + + onJoinGameIdChange(e.target.value.toUpperCase())} + maxLength={6} + sx={{ maxWidth: 200 }} + /> + + {error && ( + + {error} + + )} + + ) + } + + if (connectionStatus === 'connecting') { + return ( +
+ + + Connecting... + +
+ ) + } + + if (connectionStatus === 'waiting') { + return ( + +
+ + + {isHost ? 'Waiting for second player...' : 'Waiting for connection...'} + +
+ + {gameId && ( + + Game ID: {gameId} + + + )} + + {isHost && gameId && ( + + + + {getShareableLink()} + + + {({ copied, copy }) => ( + + + {copied ? : } + + + )} + + + + )} +
+ ) + } + + if (connectionStatus === 'connected') { + return ( + + + + Game ID: {gameId} + {isHost && ( + + (Host) + + )} + + + + {({ copied, copy }) => ( + + + {copied ? : } + + + )} + + + + + + + Game is ready to play! + + + ) + } + + return null +} diff --git a/src/multiplayer/components/MultiplayerGameBoard.tsx b/src/multiplayer/components/MultiplayerGameBoard.tsx new file mode 100644 index 0000000..4db76a0 --- /dev/null +++ b/src/multiplayer/components/MultiplayerGameBoard.tsx @@ -0,0 +1,182 @@ +import React, { useRef } from 'react' +import { Box, Button, Center, Group, Stack, Text } from '@mantine/core' +import { NextReactP5Wrapper } from '@p5-wrapper/next' +import { P5CanvasInstance, SketchProps } from '@p5-wrapper/react' +import { useRouter } from 'next/router' +import NoSSR from 'react-no-ssr' +import { IconRefresh } from '@tabler/icons-react' + +import { MAX_CHESSBOARD_WIDTH } from '@/draw/constants' +import { multiplayerSketch } from '@/multiplayer/sketch' +import { GameMethods, GameProps } from '@/game/types' +import { YjsMultiplayerGame } from '@/multiplayer/yjs-game' + +/** + * Props for the MultiplayerGameBoard component + */ +interface MultiplayerGameBoardProps { + multiplayerGame: YjsMultiplayerGame | null + width: number + height: number + history: Parameters[0] + bestMove: Parameters[0] + gameStatus: Parameters[0] + output: Parameters[0] + onBestMoveChange: (move: Parameters[0]) => void + onStatusChange: (status: Parameters[0]) => void + onOutputChange: (output: Parameters[0]) => void + onHistoryChange: (history: Parameters[0]) => void +} + +// I spent like 8 hours overall trying to do things properly with refs and failed. I'll just use window for now. See https://github.com/P5-wrapper/react/issues/258 +/** + * Gets game methods from the global window object. + * Duplicated from pages/multiplayer.tsx due to P5 wrapper limitations. + */ +function gameMethods(): GameMethods | null { + // @ts-ignore + return window.gameMethods || null +} + +/** + * P5.js game sketch function for multiplayer mode. + * Duplicated from pages/multiplayer.tsx due to P5 wrapper limitations. + */ +const game = (p5: P5CanvasInstance) => { + console.debug('Creating multiplayer sketch') + const methods = multiplayerSketch(p5) + // @ts-ignore + window.gameMethods = methods +} + +/** + * Renders the chess board, move history, and game controls for multiplayer mode. + */ +export function MultiplayerGameBoard({ + multiplayerGame, + width, + height, + history, + bestMove, + gameStatus, + output, + onBestMoveChange, + onStatusChange, + onOutputChange, + onHistoryChange, +}: MultiplayerGameBoardProps) { + const router = useRouter() + const lastMoveRef = useRef(null) + + const resetGame = () => { + gameMethods()?.reset?.() + } + + return ( + + {/* Move History */} + ({ + overflowX: 'scroll', + overflowY: 'hidden', + whiteSpace: 'nowrap', + width: width, + backgroundColor: theme.colors.gray[3], + scrollbarWidth: 'none', + msOverflowStyle: 'none', + '&::-webkit-scrollbar': { display: 'none' }, + })} + > + + {history.length === 0 ? ( + ({ + fontFamily: theme.fontFamilyMonospace, + padding: '3px .35rem', + })} + > +   + + ) : ( + history.map(({ move }, i) => ( + ({ + padding: '3px .35rem', + fontFamily: theme.fontFamilyMonospace, + })} + > + {i + 1}.{' '} + {move.kind === 'normal' + ? `${move.from.toString()}-${move.to.toString()}` + : move.kind === 'castling' + ? 'O-O' + : move.kind === 'enPassant' + ? `${move.from.toString()}-${move.to.toString()}` + : 'Move'} + + )) + )} + + + + {/* Chess Board */} + + + + + {/* Controls */} + ({ + maxWidth: width, + [`@media (max-width: ${MAX_CHESSBOARD_WIDTH + 10 * 2}px)`]: { + paddingLeft: theme.spacing.sm, + paddingRight: theme.spacing.sm, + }, + })} + > + + + + + +
+ + Collaborative Mode - No rules enforced. Both players can move any piece freely. + +
+
+
+ ) +} diff --git a/src/multiplayer/components/index.ts b/src/multiplayer/components/index.ts new file mode 100644 index 0000000..9b21fd8 --- /dev/null +++ b/src/multiplayer/components/index.ts @@ -0,0 +1,2 @@ +export { ConnectionManager } from './ConnectionManager' +export { MultiplayerGameBoard } from './MultiplayerGameBoard' diff --git a/src/multiplayer/hooks/index.ts b/src/multiplayer/hooks/index.ts new file mode 100644 index 0000000..f3eab1a --- /dev/null +++ b/src/multiplayer/hooks/index.ts @@ -0,0 +1,2 @@ +export { useMultiplayerConnection } from './useMultiplayerConnection' +export type { ConnectionStatus, UseMultiplayerConnectionResult } from './useMultiplayerConnection' diff --git a/src/multiplayer/hooks/useMultiplayerConnection.ts b/src/multiplayer/hooks/useMultiplayerConnection.ts new file mode 100644 index 0000000..4b92bd4 --- /dev/null +++ b/src/multiplayer/hooks/useMultiplayerConnection.ts @@ -0,0 +1,136 @@ +import { useState, useCallback } from 'react' +import { YjsMultiplayerGame } from '../yjs-game' + +/** + * Connection status types for multiplayer game states + */ +export type ConnectionStatus = 'disconnected' | 'connecting' | 'connected' | 'waiting' + +/** + * Return type for the useMultiplayerConnection hook containing all state and actions + */ +export interface UseMultiplayerConnectionResult { + // State + multiplayerGame: YjsMultiplayerGame | null + connectionStatus: ConnectionStatus + gameId: string + joinGameId: string + error: string + isHost: boolean + + // Actions + setJoinGameId: (id: string) => void + setError: (error: string) => void + createGame: () => Promise + joinGame: () => Promise + disconnect: () => void +} + +/** + * Custom hook that manages all multiplayer connection state and actions. + */ +export function useMultiplayerConnection(): UseMultiplayerConnectionResult { + const [multiplayerGame, setMultiplayerGame] = useState(null) + const [connectionStatus, setConnectionStatus] = useState('disconnected') + const [gameId, setGameId] = useState('') + const [joinGameId, setJoinGameId] = useState('') + const [error, setError] = useState('') + const [isHost, setIsHost] = useState(false) + + // Generate a random game ID for hosting + const generateGameId = useCallback(() => { + return Math.random().toString(36).substring(2, 8).toUpperCase() + }, []) + + const createGame = useCallback(async () => { + try { + setError('') + setConnectionStatus('connecting') + const newGameId = generateGameId() + setGameId(newGameId) + setIsHost(true) + + const game = new YjsMultiplayerGame(newGameId, true) + + // Set up connection change callback + game.onConnectionChangeCallback((connected: boolean, peers: number) => { + if (peers > 0) { + setConnectionStatus('connected') + } else { + setConnectionStatus('waiting') + } + }) + + setMultiplayerGame(game) + + setConnectionStatus('waiting') + } catch (err) { + const errorMessage = `Failed to create game: ${String(err)}` + setError(errorMessage) + setConnectionStatus('disconnected') + } + }, [generateGameId]) + + const joinGame = useCallback(async () => { + if (!joinGameId.trim()) { + setError('Please enter a game ID') + return + } + + try { + setError('') + setConnectionStatus('connecting') + const gameIdToJoin = joinGameId.trim().toUpperCase() + setGameId(gameIdToJoin) + setIsHost(false) + + const game = new YjsMultiplayerGame(gameIdToJoin, false) + + // Set up connection change callback + game.onConnectionChangeCallback((connected: boolean, peers: number) => { + if (peers > 0) { + setConnectionStatus('connected') + } else { + setConnectionStatus('waiting') + } + }) + + setMultiplayerGame(game) + + setConnectionStatus('waiting') + } catch (err) { + const errorMessage = `Failed to join game: ${String(err)}` + setError(errorMessage) + setConnectionStatus('disconnected') + } + }, [joinGameId]) + + const disconnect = useCallback(() => { + if (multiplayerGame) { + multiplayerGame.disconnect() + setMultiplayerGame(null) + } + setConnectionStatus('disconnected') + setGameId('') + setJoinGameId('') + setError('') + setIsHost(false) + }, [multiplayerGame]) + + return { + // State + multiplayerGame, + connectionStatus, + gameId, + joinGameId, + error, + isHost, + + // Actions + setJoinGameId, + setError, + createGame, + joinGame, + disconnect, + } +} diff --git a/src/multiplayer/sketch.ts b/src/multiplayer/sketch.ts new file mode 100644 index 0000000..1638bd3 --- /dev/null +++ b/src/multiplayer/sketch.ts @@ -0,0 +1,349 @@ +import { P5CanvasInstance, SketchProps } from '@p5-wrapper/react' +import { Howl } from 'howler' +import { Board } from '@/board' +import { DrawConstants } from '@/draw/constants' +import { preloadPieceImages } from '@/draw/piece' +import { squareXY } from '@/draw/square' +import { GameMessage, GameMethods, GameProps } from '@/game/types' +import { Move, translateFromHumanMove } from '@/move' +import { Color, PieceEmpty } from '@/piece' +import { Coord } from '@/utils/coord' +import { match } from 'ts-pattern' +import { render } from '@/game/render' +import { IRenderableGameState, IChessState } from '@/game/render-types' +import { YjsBoardState } from './yjs-game' +import { CompatibleBoardState } from '@/game/types' + +export class MultiplayerGameState { + /** Chess board state. */ + board: Board + + /** Which piece is currently being dragged. */ + dragged: Coord | null = null + + /** Current multiplayer board state */ + multiplayerState: CompatibleBoardState | null = null + + constructor() { + this.board = new Board() + } + + /** Update board from multiplayer state */ + updateFromMultiplayerState(state: CompatibleBoardState) { + this.multiplayerState = state + + // Update the board + this.board.board = new Uint8Array(state.board) + this.board.side = state.side + // Update kings positions (already Coord instances in CompatibleBoardState) + this.board.kings = state.kings + // @ts-ignore - accessing private property for multiplayer sync + this.board.castlingRights = state.castlingRights + // Update en passant target square - convert plain object to Coord instance + this.board.enPassantTargetSquare = state.enPassantTargetSquare + ? new Coord(state.enPassantTargetSquare.x, state.enPassantTargetSquare.y) + : null + this.board.fullMoveNumber = state.fullMoveNumber + this.board.halfMoveNumber = state.halfMoveNumber + } + + /** Make a move without rule checking */ + makeMove(move: Move) { + // Handle different move types + switch (move.kind) { + case 'normal': { + const piece = this.board.at(move.from) + if (piece !== PieceEmpty) { + this.board.board[move.to.y * 8 + move.to.x] = move.promotion || piece + this.board.board[move.from.y * 8 + move.from.x] = PieceEmpty + + // Update kings position if needed + if (piece === 0x16) { + // WhiteKing + this.board.kings.white = move.to + } else if (piece === 0x26) { + // BlackKing + this.board.kings.black = move.to + } + } + break + } + case 'castling': { + // Move king + const king = this.board.at(move.kingFrom) + this.board.board[move.kingTo.y * 8 + move.kingTo.x] = king + this.board.board[move.kingFrom.y * 8 + move.kingFrom.x] = PieceEmpty + + // Move rook + const rook = this.board.at(move.rookFrom) + this.board.board[move.rookTo.y * 8 + move.rookTo.x] = rook + this.board.board[move.rookFrom.y * 8 + move.rookFrom.x] = PieceEmpty + + // Update kings position + if (king === 0x16) { + // WhiteKing + this.board.kings.white = move.kingTo + } else if (king === 0x26) { + // BlackKing + this.board.kings.black = move.kingTo + } + break + } + case 'enPassant': { + // Move pawn + const piece = this.board.at(move.from) + this.board.board[move.to.y * 8 + move.to.x] = piece + this.board.board[move.from.y * 8 + move.from.x] = PieceEmpty + + // Remove captured pawn + this.board.board[move.captureCoord.y * 8 + move.captureCoord.x] = PieceEmpty + break + } + } + + // Toggle side to move + this.board.side = this.board.side === Color.White ? Color.Black : Color.White + } + + /** Reset to starting position */ + reset() { + this.board = new Board() + this.multiplayerState = null + } +} + +export const multiplayerSketch = (p5: P5CanvasInstance): GameMethods => { + p5.disableFriendlyErrors = true + + let vars: GameProps = undefined as any + let state: MultiplayerGameState = undefined as any + + let messageQueue: GameMessage[] = [] + + const setupGlobals = () => { + state = new MultiplayerGameState() + } + + const reset = () => { + p5.noLoop() + messageQueue = [] + setupGlobals() + if (vars) { + vars.onOutputChange('') + vars.onBestMoveChange(null) + vars.onHistoryChange([]) + vars.onStatusChange('playing') + } + messageQueue.push({ type: 'doNothing' }) + p5.loop() + } + + reset() + + p5.updateWithProps = (props: GameProps) => { + console.debug('Updating multiplayer game vars', props) + vars = props + + // Set up multiplayer callbacks + if (props.multiplayerGame) { + props.multiplayerGame.onBoardStateChangeCallback((boardState: YjsBoardState) => { + // Convert YjsBoardState to compatible format for processing + const compatibleState = { + ...boardState, + kings: { + white: new Coord(boardState.kings.white.x, boardState.kings.white.y), + black: new Coord(boardState.kings.black.x, boardState.kings.black.y), + }, + enPassantTargetSquare: boardState.enPassantTargetSquare + ? new Coord(boardState.enPassantTargetSquare.x, boardState.enPassantTargetSquare.y) + : null, + } + messageQueue.push({ type: 'multiplayerBoardState', state: compatibleState }) + }) + + props.multiplayerGame.onMoveCallback((move: Move) => { + messageQueue.push({ type: 'multiplayerMove', move }) + }) + + props.multiplayerGame.onResetCallback(() => { + messageQueue.push({ type: 'multiplayerReset' }) + }) + } + } + + const sounds = { + move: new Howl({ + src: ['/assets/sounds/chess-blitz-sfx/piece-placement.mp3'], + }), + capture: new Howl({ + src: ['/assets/sounds/chess-blitz-sfx/piece-capture.mp3'], + volume: 0.4, + }), + } + + /** Is the mouse hovering over a specific square? */ + const isTouching = (square: Coord) => { + const xy = squareXY(p5, square) + const between = (left: number, right: number, a: number) => left <= a && a < right + return ( + between(xy.topLeft.x, xy.bottomRight.x, p5.mouseX) && + between(xy.topLeft.y, xy.bottomRight.y, p5.mouseY) + ) + } + + p5.preload = () => { + preloadPieceImages(p5) + for (const sound of Object.values(sounds)) { + sound.load() + } + } + + p5.setup = () => { + const renderer = p5.createCanvas(DrawConstants(p5).CELL * 8, DrawConstants(p5).CELL * 8 + 20) + stopTouchScrolling(renderer.elt) + } + + p5.windowResized = () => { + p5.resizeCanvas(DrawConstants(p5).CELL * 8, DrawConstants(p5).CELL * 8 + 20) + } + + const processMessage = (message: GameMessage) => { + console.debug('Processing multiplayer message', message) + match(message) + .with({ type: 'multiplayerBoardState' }, ({ state: boardState }) => { + state.updateFromMultiplayerState(boardState) + vars.onHistoryChange([]) // Clear history in multiplayer + }) + .with({ type: 'multiplayerMove' }, ({ move }) => { + state.makeMove(move) + vars.onHistoryChange([]) // Clear history in multiplayer + }) + .with({ type: 'multiplayerReset' }, () => { + state.reset() + vars.onHistoryChange([]) + vars.onStatusChange('playing') + }) + .with({ type: 'doNothing' }, () => { + return + }) + .with({ type: 'makeMove' }, () => { + // Not used in multiplayer mode + return + }) + .with({ type: 'updateBestMove' }, () => { + // Not used in multiplayer mode + return + }) + .exhaustive() + } + + p5.draw = () => { + // Process a message from the queue + const message = messageQueue.shift() + if (message) processMessage(message) + + // Draw the current state of the game + // Create a compatible state object for rendering + const chessState: IChessState = { + board: state.board, + challenge: null, + identity: null, + history: [], + bestMove: null, + gameStatus: { status: 'playing' as const }, + isMoveAllowedByChallenge: () => true, + lastMove: () => null, + } + + const renderState: IRenderableGameState = { + chess: chessState, + dragged: state.dragged, + lastMoveTimestamp: 0, + makeMove: (move: Move) => state.makeMove(move), + updateBestMoveAndGameStatus: () => {}, + } + + render(p5, renderState, vars) + } + + // If we are touching a piece when the mouse is pressed, start dragging it + p5.mousePressed = () => { + if (!vars.controlsEnabled) return + + state.dragged = null + for (const square of Board.allSquares()) { + if (isTouching(square) && state.board.isOccupied(square)) { + state.dragged = square + return + } + } + } + + p5.mouseReleased = () => { + if (!vars.controlsEnabled) return + + if (state.dragged !== null) { + let dest: Coord | null = Board.allSquares().find(isTouching) ?? null + if (dest) { + const move = translateFromHumanMove(state.board, { from: state.dragged, to: dest }) + if (move) { + // In multiplayer mode, allow any move + state.makeMove(move) + + // Send move to other player + if (vars.multiplayerGame) { + vars.multiplayerGame.makeMove(move) + } + + // Play sound + if (state.board.at(dest) !== PieceEmpty) { + sounds.capture.play() + } else { + sounds.move.play() + } + } + } + state.dragged = null + } + } + + p5.keyPressed = () => { + if (!vars.controlsEnabled) return + // No special key handling in multiplayer mode + } + + // Return methods / imperative handles + return { + reset: () => { + state.reset() + if (vars.multiplayerGame) { + vars.multiplayerGame.resetBoard() + } + }, + } +} + +/** Prevent scrolling when touching the canvas */ +function stopTouchScrolling(canvas: Element) { + document.body.addEventListener( + 'touchstart', + function (e) { + if (e.target === canvas) e.preventDefault() + }, + { passive: false } + ) + document.body.addEventListener( + 'touchend', + function (e) { + if (e.target === canvas) e.preventDefault() + }, + { passive: false } + ) + document.body.addEventListener( + 'touchmove', + function (e) { + if (e.target === canvas) e.preventDefault() + }, + { passive: false } + ) +} diff --git a/src/multiplayer/utils/index.ts b/src/multiplayer/utils/index.ts new file mode 100644 index 0000000..af18d7d --- /dev/null +++ b/src/multiplayer/utils/index.ts @@ -0,0 +1 @@ +export { getShareableLink } from './shareableLink' diff --git a/src/multiplayer/utils/shareableLink.ts b/src/multiplayer/utils/shareableLink.ts new file mode 100644 index 0000000..37ddf7b --- /dev/null +++ b/src/multiplayer/utils/shareableLink.ts @@ -0,0 +1,9 @@ +/** + * Generates a shareable URL for joining a multiplayer game. + */ +export function getShareableLink(gameId: string): string { + if (typeof window !== 'undefined' && gameId) { + return `${window.location.origin}/multiplayer?join=${gameId}` + } + return '' +} diff --git a/src/multiplayer/yjs-game.ts b/src/multiplayer/yjs-game.ts new file mode 100644 index 0000000..ebe9a72 --- /dev/null +++ b/src/multiplayer/yjs-game.ts @@ -0,0 +1,290 @@ +import * as Y from 'yjs' +import { WebrtcProvider } from 'y-webrtc' +import { Board } from '@/board' +import { Move } from '@/move' +import { Piece, PieceEmpty } from '@/piece' +import { Coord } from '@/utils/coord' + +/** + * Board state that gets synchronized via Yjs + */ +export interface YjsBoardState { + board: Uint8Array + side: number + kings: { white: { x: number; y: number }; black: { x: number; y: number } } + castlingRights: number + enPassantTargetSquare: { x: number; y: number } | null + fullMoveNumber: number + halfMoveNumber: number + lastMove: any | null +} + +/** + * Multiplayer chess game using Yjs for conflict-free collaboration + */ +export class YjsMultiplayerGame { + private ydoc: Y.Doc + private provider: WebrtcProvider + private gameState: Y.Map + private board: Board + private roomName: string + private isHost: boolean + + // Callbacks + private onBoardStateChange: ((state: YjsBoardState) => void) | null = null + private onMove: ((move: Move) => void) | null = null + private onReset: (() => void) | null = null + private onConnectionChange: ((connected: boolean, peers: number) => void) | null = null + + constructor(roomName: string, isHost: boolean) { + this.roomName = roomName + this.isHost = isHost + this.board = new Board() + + // Create Yjs document and WebRTC provider + this.ydoc = new Y.Doc() + this.provider = new WebrtcProvider(roomName, this.ydoc, { + signaling: ['wss://signaling.yjs.dev'], + }) + this.gameState = this.ydoc.getMap('gameState') + + this.setupEventHandlers() + + if (isHost) { + this.initializeBoard() + } + } + + /** + * Sets up Yjs and WebRTC event handlers + */ + private setupEventHandlers() { + // Listen for changes to the game state + this.gameState.observe((event) => { + this.handleStateUpdate() + }) + + // Listen for provider connection changes + this.provider.on('status', ({ connected }: { connected: boolean }) => { + const peerCount = this.provider.awareness.getStates().size + this.onConnectionChange?.(connected, peerCount) + }) + + this.provider.on( + 'peers', + (event: { added: string[]; removed: string[]; webrtcPeers: string[] }) => { + const peerCount = event.webrtcPeers.length + this.onConnectionChange?.(peerCount > 0, peerCount) + } + ) + + // Listen for moves + this.ydoc.getArray('moves').observe((event) => { + event.changes.added.forEach((item) => { + const moveData = item.content.getContent()[0] + if (moveData) { + const move = this.reconstructMove(moveData) + this.onMove?.(move) + } + }) + }) + } + + /** + * Initializes the board state (host only) + */ + private initializeBoard() { + if (!this.isHost) return + + // Set up starting position + this.board = new Board() // Reset by creating new board + + const boardState: YjsBoardState = { + board: this.board.board, + side: 0, // White to move + kings: { + white: { x: 4, y: 0 }, + black: { x: 4, y: 7 }, + }, + castlingRights: 15, // All castling rights + enPassantTargetSquare: null, + fullMoveNumber: 1, + halfMoveNumber: 0, + lastMove: null, + } + + this.gameState.set('boardState', boardState) + } + + /** + * Handles updates to the shared game state + */ + private handleStateUpdate() { + const boardState = this.gameState.get('boardState') as YjsBoardState + if (boardState) { + // Update local board + this.board.board = new Uint8Array(boardState.board) + this.onBoardStateChange?.(boardState) + } + } + + /** + * Reconstructs a Move object from plain data, ensuring Coord objects have proper methods + */ + private reconstructMove(rawMove: any): Move { + switch (rawMove.kind) { + case 'normal': + return { + kind: 'normal', + from: new Coord(rawMove.from.x, rawMove.from.y), + to: new Coord(rawMove.to.x, rawMove.to.y), + promotion: rawMove.promotion, + capture: rawMove.capture, + } + case 'castling': + return { + kind: 'castling', + kingFrom: new Coord(rawMove.kingFrom.x, rawMove.kingFrom.y), + kingTo: new Coord(rawMove.kingTo.x, rawMove.kingTo.y), + rookFrom: new Coord(rawMove.rookFrom.x, rawMove.rookFrom.y), + rookTo: new Coord(rawMove.rookTo.x, rawMove.rookTo.y), + } + case 'enPassant': + return { + kind: 'enPassant', + from: new Coord(rawMove.from.x, rawMove.from.y), + to: new Coord(rawMove.to.x, rawMove.to.y), + capture: rawMove.capture, + captureCoord: new Coord(rawMove.captureCoord.x, rawMove.captureCoord.y), + } + default: + throw new Error(`Unknown move kind: ${rawMove.kind}`) + } + } + + /** + * Makes a move and synchronizes it across all clients + */ + makeMove(move: Move) { + // Add move to shared moves array + const movesArray = this.ydoc.getArray('moves') + movesArray.push([move]) + + // Update board state if host + if (this.isHost) { + this.updateBoardState(move) + } + } + + /** + * Updates the shared board state (host only) + */ + private updateBoardState(move: Move) { + if (!this.isHost) return + + const currentState = this.gameState.get('boardState') as YjsBoardState + if (!currentState) return + + // Apply the move to the board + const newBoard = new Uint8Array(currentState.board) + + switch (move.kind) { + case 'normal': { + const piece = newBoard[move.from.y * 8 + move.from.x] + newBoard[move.to.y * 8 + move.to.x] = move.promotion || piece + newBoard[move.from.y * 8 + move.from.x] = PieceEmpty + break + } + case 'castling': { + // Move king + const king = newBoard[move.kingFrom.y * 8 + move.kingFrom.x] + newBoard[move.kingTo.y * 8 + move.kingTo.x] = king + newBoard[move.kingFrom.y * 8 + move.kingFrom.x] = PieceEmpty + + // Move rook + const rook = newBoard[move.rookFrom.y * 8 + move.rookFrom.x] + newBoard[move.rookTo.y * 8 + move.rookTo.x] = rook + newBoard[move.rookFrom.y * 8 + move.rookFrom.x] = PieceEmpty + break + } + case 'enPassant': { + // Move pawn + const piece = newBoard[move.from.y * 8 + move.from.x] + newBoard[move.to.y * 8 + move.to.x] = piece + newBoard[move.from.y * 8 + move.from.x] = PieceEmpty + + // Remove captured pawn + newBoard[move.captureCoord.y * 8 + move.captureCoord.x] = PieceEmpty + break + } + } + + // Update the shared state + const newState: YjsBoardState = { + ...currentState, + board: newBoard, + side: currentState.side === 0 ? 1 : 0, // Toggle side + fullMoveNumber: + currentState.side === 1 ? currentState.fullMoveNumber + 1 : currentState.fullMoveNumber, + halfMoveNumber: currentState.halfMoveNumber + 1, + lastMove: move, + } + + this.gameState.set('boardState', newState) + } + + /** + * Resets the board to starting position + */ + resetBoard() { + if (this.isHost) { + this.ydoc.getArray('moves').delete(0, this.ydoc.getArray('moves').length) + this.initializeBoard() + this.onReset?.() + } + } + + /** + * Gets the current board state + */ + getBoardState(): YjsBoardState | null { + return (this.gameState.get('boardState') as YjsBoardState) || null + } + + /** + * Sets callback for board state changes + */ + onBoardStateChangeCallback(callback: (state: YjsBoardState) => void) { + this.onBoardStateChange = callback + } + + /** + * Sets callback for move events + */ + onMoveCallback(callback: (move: Move) => void) { + this.onMove = callback + } + + /** + * Sets callback for reset events + */ + onResetCallback(callback: () => void) { + this.onReset = callback + } + + /** + * Sets callback for connection status changes + */ + onConnectionChangeCallback(callback: (connected: boolean, peers: number) => void) { + this.onConnectionChange = callback + } + + /** + * Disconnects from the multiplayer session + */ + disconnect() { + this.provider.disconnect() + this.provider.destroy() + this.ydoc.destroy() + } +}