Browse Source

tic tac toe

drawing-pad
Stephanie Gredell 1 month ago
parent
commit
512760a558
  1. 68
      backend/package-lock.json
  2. 26
      backend/package.json
  3. 10
      backend/src/index.ts
  4. 289
      backend/src/services/game.service.ts
  5. 250
      backend/src/services/websocket.service.ts
  6. 10
      frontend/src/config/apps.ts
  7. 2
      frontend/src/pages/LandingPage.tsx
  8. 314
      frontend/src/pages/TicTacToeApp.tsx

68
backend/package-lock.json generated

@ -9,6 +9,12 @@
"version": "1.0.0", "version": "1.0.0",
"dependencies": { "dependencies": {
"@libsql/client": "^0.4.0", "@libsql/client": "^0.4.0",
"@types/bcrypt": "^5.0.2",
"@types/cookie-parser": "^1.4.6",
"@types/cors": "^2.8.17",
"@types/express": "^4.17.21",
"@types/jsonwebtoken": "^9.0.5",
"@types/node": "^20.10.5",
"axios": "^1.6.0", "axios": "^1.6.0",
"bcrypt": "^5.1.1", "bcrypt": "^5.1.1",
"cookie-parser": "^1.4.6", "cookie-parser": "^1.4.6",
@ -17,18 +23,14 @@
"express": "^4.18.2", "express": "^4.18.2",
"express-rate-limit": "^7.1.5", "express-rate-limit": "^7.1.5",
"jsonwebtoken": "^9.0.2", "jsonwebtoken": "^9.0.2",
"tsx": "^4.7.0",
"typescript": "^5.3.3",
"ws": "^8.18.3",
"zod": "^3.22.4" "zod": "^3.22.4"
}, },
"devDependencies": { "devDependencies": {
"@types/bcrypt": "^5.0.2", "@types/ws": "^8.18.1",
"@types/cookie-parser": "^1.4.6", "nodemon": "^3.0.2"
"@types/cors": "^2.8.17",
"@types/express": "^4.17.21",
"@types/jsonwebtoken": "^9.0.5",
"@types/node": "^20.10.5",
"nodemon": "^3.0.2",
"tsx": "^4.7.0",
"typescript": "^5.3.3"
} }
}, },
"node_modules/@esbuild/aix-ppc64": { "node_modules/@esbuild/aix-ppc64": {
@ -38,7 +40,6 @@
"cpu": [ "cpu": [
"ppc64" "ppc64"
], ],
"dev": true,
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@ -55,7 +56,6 @@
"cpu": [ "cpu": [
"arm" "arm"
], ],
"dev": true,
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@ -72,7 +72,6 @@
"cpu": [ "cpu": [
"arm64" "arm64"
], ],
"dev": true,
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@ -89,7 +88,6 @@
"cpu": [ "cpu": [
"x64" "x64"
], ],
"dev": true,
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@ -106,7 +104,6 @@
"cpu": [ "cpu": [
"arm64" "arm64"
], ],
"dev": true,
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@ -123,7 +120,6 @@
"cpu": [ "cpu": [
"x64" "x64"
], ],
"dev": true,
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@ -140,7 +136,6 @@
"cpu": [ "cpu": [
"arm64" "arm64"
], ],
"dev": true,
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@ -157,7 +152,6 @@
"cpu": [ "cpu": [
"x64" "x64"
], ],
"dev": true,
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@ -174,7 +168,6 @@
"cpu": [ "cpu": [
"arm" "arm"
], ],
"dev": true,
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@ -191,7 +184,6 @@
"cpu": [ "cpu": [
"arm64" "arm64"
], ],
"dev": true,
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@ -208,7 +200,6 @@
"cpu": [ "cpu": [
"ia32" "ia32"
], ],
"dev": true,
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@ -225,7 +216,6 @@
"cpu": [ "cpu": [
"loong64" "loong64"
], ],
"dev": true,
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@ -242,7 +232,6 @@
"cpu": [ "cpu": [
"mips64el" "mips64el"
], ],
"dev": true,
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@ -259,7 +248,6 @@
"cpu": [ "cpu": [
"ppc64" "ppc64"
], ],
"dev": true,
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@ -276,7 +264,6 @@
"cpu": [ "cpu": [
"riscv64" "riscv64"
], ],
"dev": true,
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@ -293,7 +280,6 @@
"cpu": [ "cpu": [
"s390x" "s390x"
], ],
"dev": true,
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@ -310,7 +296,6 @@
"cpu": [ "cpu": [
"x64" "x64"
], ],
"dev": true,
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@ -327,7 +312,6 @@
"cpu": [ "cpu": [
"arm64" "arm64"
], ],
"dev": true,
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@ -344,7 +328,6 @@
"cpu": [ "cpu": [
"x64" "x64"
], ],
"dev": true,
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@ -361,7 +344,6 @@
"cpu": [ "cpu": [
"arm64" "arm64"
], ],
"dev": true,
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@ -378,7 +360,6 @@
"cpu": [ "cpu": [
"x64" "x64"
], ],
"dev": true,
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@ -395,7 +376,6 @@
"cpu": [ "cpu": [
"arm64" "arm64"
], ],
"dev": true,
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@ -412,7 +392,6 @@
"cpu": [ "cpu": [
"x64" "x64"
], ],
"dev": true,
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@ -429,7 +408,6 @@
"cpu": [ "cpu": [
"arm64" "arm64"
], ],
"dev": true,
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@ -446,7 +424,6 @@
"cpu": [ "cpu": [
"ia32" "ia32"
], ],
"dev": true,
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@ -463,7 +440,6 @@
"cpu": [ "cpu": [
"x64" "x64"
], ],
"dev": true,
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@ -690,7 +666,6 @@
"version": "5.0.2", "version": "5.0.2",
"resolved": "https://registry.npmjs.org/@types/bcrypt/-/bcrypt-5.0.2.tgz", "resolved": "https://registry.npmjs.org/@types/bcrypt/-/bcrypt-5.0.2.tgz",
"integrity": "sha512-6atioO8Y75fNcbmj0G7UjI9lXN2pQ/IGJ2FWT4a/btd0Lk9lQalHLKhkgKVZ3r+spnmWUKfbMi1GEe9wyHQfNQ==", "integrity": "sha512-6atioO8Y75fNcbmj0G7UjI9lXN2pQ/IGJ2FWT4a/btd0Lk9lQalHLKhkgKVZ3r+spnmWUKfbMi1GEe9wyHQfNQ==",
"dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@types/node": "*" "@types/node": "*"
@ -700,7 +675,6 @@
"version": "1.19.6", "version": "1.19.6",
"resolved": "https://registry.npmjs.org/@types/body-parser/-/body-parser-1.19.6.tgz", "resolved": "https://registry.npmjs.org/@types/body-parser/-/body-parser-1.19.6.tgz",
"integrity": "sha512-HLFeCYgz89uk22N5Qg3dvGvsv46B8GLvKKo1zKG4NybA8U2DiEO3w9lqGg29t/tfLRJpJ6iQxnVw4OnB7MoM9g==", "integrity": "sha512-HLFeCYgz89uk22N5Qg3dvGvsv46B8GLvKKo1zKG4NybA8U2DiEO3w9lqGg29t/tfLRJpJ6iQxnVw4OnB7MoM9g==",
"dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@types/connect": "*", "@types/connect": "*",
@ -711,7 +685,6 @@
"version": "3.4.38", "version": "3.4.38",
"resolved": "https://registry.npmjs.org/@types/connect/-/connect-3.4.38.tgz", "resolved": "https://registry.npmjs.org/@types/connect/-/connect-3.4.38.tgz",
"integrity": "sha512-K6uROf1LD88uDQqJCktA4yzL1YYAK6NgfsI0v/mTgyPKWsX1CnJ0XPSDhViejru1GcRkLWb8RlzFYJRqGUbaug==", "integrity": "sha512-K6uROf1LD88uDQqJCktA4yzL1YYAK6NgfsI0v/mTgyPKWsX1CnJ0XPSDhViejru1GcRkLWb8RlzFYJRqGUbaug==",
"dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@types/node": "*" "@types/node": "*"
@ -721,7 +694,6 @@
"version": "1.4.10", "version": "1.4.10",
"resolved": "https://registry.npmjs.org/@types/cookie-parser/-/cookie-parser-1.4.10.tgz", "resolved": "https://registry.npmjs.org/@types/cookie-parser/-/cookie-parser-1.4.10.tgz",
"integrity": "sha512-B4xqkqfZ8Wek+rCOeRxsjMS9OgvzebEzzLYw7NHYuvzb7IdxOkI0ZHGgeEBX4PUM7QGVvNSK60T3OvWj3YfBRg==", "integrity": "sha512-B4xqkqfZ8Wek+rCOeRxsjMS9OgvzebEzzLYw7NHYuvzb7IdxOkI0ZHGgeEBX4PUM7QGVvNSK60T3OvWj3YfBRg==",
"dev": true,
"license": "MIT", "license": "MIT",
"peerDependencies": { "peerDependencies": {
"@types/express": "*" "@types/express": "*"
@ -731,7 +703,6 @@
"version": "2.8.19", "version": "2.8.19",
"resolved": "https://registry.npmjs.org/@types/cors/-/cors-2.8.19.tgz", "resolved": "https://registry.npmjs.org/@types/cors/-/cors-2.8.19.tgz",
"integrity": "sha512-mFNylyeyqN93lfe/9CSxOGREz8cpzAhH+E93xJ4xWQf62V8sQ/24reV2nyzUWM6H6Xji+GGHpkbLe7pVoUEskg==", "integrity": "sha512-mFNylyeyqN93lfe/9CSxOGREz8cpzAhH+E93xJ4xWQf62V8sQ/24reV2nyzUWM6H6Xji+GGHpkbLe7pVoUEskg==",
"dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@types/node": "*" "@types/node": "*"
@ -741,7 +712,6 @@
"version": "4.17.25", "version": "4.17.25",
"resolved": "https://registry.npmjs.org/@types/express/-/express-4.17.25.tgz", "resolved": "https://registry.npmjs.org/@types/express/-/express-4.17.25.tgz",
"integrity": "sha512-dVd04UKsfpINUnK0yBoYHDF3xu7xVH4BuDotC/xGuycx4CgbP48X/KF/586bcObxT0HENHXEU8Nqtu6NR+eKhw==", "integrity": "sha512-dVd04UKsfpINUnK0yBoYHDF3xu7xVH4BuDotC/xGuycx4CgbP48X/KF/586bcObxT0HENHXEU8Nqtu6NR+eKhw==",
"dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@types/body-parser": "*", "@types/body-parser": "*",
@ -754,7 +724,6 @@
"version": "4.19.7", "version": "4.19.7",
"resolved": "https://registry.npmjs.org/@types/express-serve-static-core/-/express-serve-static-core-4.19.7.tgz", "resolved": "https://registry.npmjs.org/@types/express-serve-static-core/-/express-serve-static-core-4.19.7.tgz",
"integrity": "sha512-FvPtiIf1LfhzsaIXhv/PHan/2FeQBbtBDtfX2QfvPxdUelMDEckK08SM6nqo1MIZY3RUlfA+HV8+hFUSio78qg==", "integrity": "sha512-FvPtiIf1LfhzsaIXhv/PHan/2FeQBbtBDtfX2QfvPxdUelMDEckK08SM6nqo1MIZY3RUlfA+HV8+hFUSio78qg==",
"dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@types/node": "*", "@types/node": "*",
@ -767,14 +736,12 @@
"version": "2.0.5", "version": "2.0.5",
"resolved": "https://registry.npmjs.org/@types/http-errors/-/http-errors-2.0.5.tgz", "resolved": "https://registry.npmjs.org/@types/http-errors/-/http-errors-2.0.5.tgz",
"integrity": "sha512-r8Tayk8HJnX0FztbZN7oVqGccWgw98T/0neJphO91KkmOzug1KkofZURD4UaD5uH8AqcFLfdPErnBod0u71/qg==", "integrity": "sha512-r8Tayk8HJnX0FztbZN7oVqGccWgw98T/0neJphO91KkmOzug1KkofZURD4UaD5uH8AqcFLfdPErnBod0u71/qg==",
"dev": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/@types/jsonwebtoken": { "node_modules/@types/jsonwebtoken": {
"version": "9.0.10", "version": "9.0.10",
"resolved": "https://registry.npmjs.org/@types/jsonwebtoken/-/jsonwebtoken-9.0.10.tgz", "resolved": "https://registry.npmjs.org/@types/jsonwebtoken/-/jsonwebtoken-9.0.10.tgz",
"integrity": "sha512-asx5hIG9Qmf/1oStypjanR7iKTv0gXQ1Ov/jfrX6kS/EO0OFni8orbmGCn0672NHR3kXHwpAwR+B368ZGN/2rA==", "integrity": "sha512-asx5hIG9Qmf/1oStypjanR7iKTv0gXQ1Ov/jfrX6kS/EO0OFni8orbmGCn0672NHR3kXHwpAwR+B368ZGN/2rA==",
"dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@types/ms": "*", "@types/ms": "*",
@ -785,14 +752,12 @@
"version": "1.3.5", "version": "1.3.5",
"resolved": "https://registry.npmjs.org/@types/mime/-/mime-1.3.5.tgz", "resolved": "https://registry.npmjs.org/@types/mime/-/mime-1.3.5.tgz",
"integrity": "sha512-/pyBZWSLD2n0dcHE3hq8s8ZvcETHtEuF+3E7XVt0Ig2nvsVQXdghHVcEkIWjy9A0wKfTn97a/PSDYohKIlnP/w==", "integrity": "sha512-/pyBZWSLD2n0dcHE3hq8s8ZvcETHtEuF+3E7XVt0Ig2nvsVQXdghHVcEkIWjy9A0wKfTn97a/PSDYohKIlnP/w==",
"dev": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/@types/ms": { "node_modules/@types/ms": {
"version": "2.1.0", "version": "2.1.0",
"resolved": "https://registry.npmjs.org/@types/ms/-/ms-2.1.0.tgz", "resolved": "https://registry.npmjs.org/@types/ms/-/ms-2.1.0.tgz",
"integrity": "sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA==", "integrity": "sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA==",
"dev": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/@types/node": { "node_modules/@types/node": {
@ -818,21 +783,18 @@
"version": "6.14.0", "version": "6.14.0",
"resolved": "https://registry.npmjs.org/@types/qs/-/qs-6.14.0.tgz", "resolved": "https://registry.npmjs.org/@types/qs/-/qs-6.14.0.tgz",
"integrity": "sha512-eOunJqu0K1923aExK6y8p6fsihYEn/BYuQ4g0CxAAgFc4b/ZLN4CrsRZ55srTdqoiLzU2B2evC+apEIxprEzkQ==", "integrity": "sha512-eOunJqu0K1923aExK6y8p6fsihYEn/BYuQ4g0CxAAgFc4b/ZLN4CrsRZ55srTdqoiLzU2B2evC+apEIxprEzkQ==",
"dev": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/@types/range-parser": { "node_modules/@types/range-parser": {
"version": "1.2.7", "version": "1.2.7",
"resolved": "https://registry.npmjs.org/@types/range-parser/-/range-parser-1.2.7.tgz", "resolved": "https://registry.npmjs.org/@types/range-parser/-/range-parser-1.2.7.tgz",
"integrity": "sha512-hKormJbkJqzQGhziax5PItDUTMAM9uE2XXQmM37dyd4hVM+5aVl7oVxMVUiVQn2oCQFN/LKCZdvSM0pFRqbSmQ==", "integrity": "sha512-hKormJbkJqzQGhziax5PItDUTMAM9uE2XXQmM37dyd4hVM+5aVl7oVxMVUiVQn2oCQFN/LKCZdvSM0pFRqbSmQ==",
"dev": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/@types/send": { "node_modules/@types/send": {
"version": "1.2.1", "version": "1.2.1",
"resolved": "https://registry.npmjs.org/@types/send/-/send-1.2.1.tgz", "resolved": "https://registry.npmjs.org/@types/send/-/send-1.2.1.tgz",
"integrity": "sha512-arsCikDvlU99zl1g69TcAB3mzZPpxgw0UQnaHeC1Nwb015xp8bknZv5rIfri9xTOcMuaVgvabfIRA7PSZVuZIQ==", "integrity": "sha512-arsCikDvlU99zl1g69TcAB3mzZPpxgw0UQnaHeC1Nwb015xp8bknZv5rIfri9xTOcMuaVgvabfIRA7PSZVuZIQ==",
"dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@types/node": "*" "@types/node": "*"
@ -842,7 +804,6 @@
"version": "1.15.10", "version": "1.15.10",
"resolved": "https://registry.npmjs.org/@types/serve-static/-/serve-static-1.15.10.tgz", "resolved": "https://registry.npmjs.org/@types/serve-static/-/serve-static-1.15.10.tgz",
"integrity": "sha512-tRs1dB+g8Itk72rlSI2ZrW6vZg0YrLI81iQSTkMmOqnqCaNr/8Ek4VwWcN5vZgCYWbg/JJSGBlUaYGAOP73qBw==", "integrity": "sha512-tRs1dB+g8Itk72rlSI2ZrW6vZg0YrLI81iQSTkMmOqnqCaNr/8Ek4VwWcN5vZgCYWbg/JJSGBlUaYGAOP73qBw==",
"dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@types/http-errors": "*", "@types/http-errors": "*",
@ -854,7 +815,6 @@
"version": "0.17.6", "version": "0.17.6",
"resolved": "https://registry.npmjs.org/@types/send/-/send-0.17.6.tgz", "resolved": "https://registry.npmjs.org/@types/send/-/send-0.17.6.tgz",
"integrity": "sha512-Uqt8rPBE8SY0RK8JB1EzVOIZ32uqy8HwdxCnoCOsYrvnswqmFZ/k+9Ikidlk/ImhsdvBsloHbAlewb2IEBV/Og==", "integrity": "sha512-Uqt8rPBE8SY0RK8JB1EzVOIZ32uqy8HwdxCnoCOsYrvnswqmFZ/k+9Ikidlk/ImhsdvBsloHbAlewb2IEBV/Og==",
"dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@types/mime": "^1", "@types/mime": "^1",
@ -1409,7 +1369,6 @@
"version": "0.25.12", "version": "0.25.12",
"resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.25.12.tgz", "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.25.12.tgz",
"integrity": "sha512-bbPBYYrtZbkt6Os6FiTLCTFxvq4tt3JKall1vRwshA3fdVztsLAatFaZobhkBC8/BrPetoa0oksYoKXoG4ryJg==", "integrity": "sha512-bbPBYYrtZbkt6Os6FiTLCTFxvq4tt3JKall1vRwshA3fdVztsLAatFaZobhkBC8/BrPetoa0oksYoKXoG4ryJg==",
"dev": true,
"hasInstallScript": true, "hasInstallScript": true,
"license": "MIT", "license": "MIT",
"bin": { "bin": {
@ -1686,7 +1645,6 @@
"version": "2.3.3", "version": "2.3.3",
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz",
"integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==",
"dev": true,
"hasInstallScript": true, "hasInstallScript": true,
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
@ -1768,7 +1726,6 @@
"version": "4.13.0", "version": "4.13.0",
"resolved": "https://registry.npmjs.org/get-tsconfig/-/get-tsconfig-4.13.0.tgz", "resolved": "https://registry.npmjs.org/get-tsconfig/-/get-tsconfig-4.13.0.tgz",
"integrity": "sha512-1VKTZJCwBrvbd+Wn3AOgQP/2Av+TfTCOlE4AcRJE72W1ksZXbAx8PPBR9RzgTeSPzlPMHrbANMH3LbltH73wxQ==", "integrity": "sha512-1VKTZJCwBrvbd+Wn3AOgQP/2Av+TfTCOlE4AcRJE72W1ksZXbAx8PPBR9RzgTeSPzlPMHrbANMH3LbltH73wxQ==",
"dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"resolve-pkg-maps": "^1.0.0" "resolve-pkg-maps": "^1.0.0"
@ -2643,7 +2600,6 @@
"version": "1.0.0", "version": "1.0.0",
"resolved": "https://registry.npmjs.org/resolve-pkg-maps/-/resolve-pkg-maps-1.0.0.tgz", "resolved": "https://registry.npmjs.org/resolve-pkg-maps/-/resolve-pkg-maps-1.0.0.tgz",
"integrity": "sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw==", "integrity": "sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw==",
"dev": true,
"license": "MIT", "license": "MIT",
"funding": { "funding": {
"url": "https://github.com/privatenumber/resolve-pkg-maps?sponsor=1" "url": "https://github.com/privatenumber/resolve-pkg-maps?sponsor=1"
@ -2976,7 +2932,6 @@
"version": "4.20.6", "version": "4.20.6",
"resolved": "https://registry.npmjs.org/tsx/-/tsx-4.20.6.tgz", "resolved": "https://registry.npmjs.org/tsx/-/tsx-4.20.6.tgz",
"integrity": "sha512-ytQKuwgmrrkDTFP4LjR0ToE2nqgy886GpvRSpU0JAnrdBYppuY5rLkRUYPU1yCryb24SsKBTL/hlDQAEFVwtZg==", "integrity": "sha512-ytQKuwgmrrkDTFP4LjR0ToE2nqgy886GpvRSpU0JAnrdBYppuY5rLkRUYPU1yCryb24SsKBTL/hlDQAEFVwtZg==",
"dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"esbuild": "~0.25.0", "esbuild": "~0.25.0",
@ -3009,7 +2964,6 @@
"version": "5.9.3", "version": "5.9.3",
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz",
"integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
"dev": true,
"license": "Apache-2.0", "license": "Apache-2.0",
"bin": { "bin": {
"tsc": "bin/tsc", "tsc": "bin/tsc",

26
backend/package.json

@ -10,28 +10,28 @@
"seed": "tsx src/db/seed.ts" "seed": "tsx src/db/seed.ts"
}, },
"dependencies": { "dependencies": {
"express": "^4.18.2",
"@libsql/client": "^0.4.0", "@libsql/client": "^0.4.0",
"@types/bcrypt": "^5.0.2",
"@types/cookie-parser": "^1.4.6",
"@types/cors": "^2.8.17",
"@types/express": "^4.17.21",
"@types/jsonwebtoken": "^9.0.5",
"@types/node": "^20.10.5",
"axios": "^1.6.0",
"bcrypt": "^5.1.1", "bcrypt": "^5.1.1",
"jsonwebtoken": "^9.0.2",
"cookie-parser": "^1.4.6", "cookie-parser": "^1.4.6",
"cors": "^2.8.5", "cors": "^2.8.5",
"dotenv": "^16.3.1", "dotenv": "^16.3.1",
"axios": "^1.6.0", "express": "^4.18.2",
"zod": "^3.22.4",
"express-rate-limit": "^7.1.5", "express-rate-limit": "^7.1.5",
"jsonwebtoken": "^9.0.2",
"tsx": "^4.7.0",
"typescript": "^5.3.3", "typescript": "^5.3.3",
"@types/express": "^4.17.21", "ws": "^8.18.3",
"@types/bcrypt": "^5.0.2", "zod": "^3.22.4"
"@types/jsonwebtoken": "^9.0.5",
"@types/cookie-parser": "^1.4.6",
"@types/cors": "^2.8.17",
"@types/node": "^20.10.5",
"tsx": "^4.7.0"
}, },
"devDependencies": { "devDependencies": {
"@types/ws": "^8.18.1",
"nodemon": "^3.0.2" "nodemon": "^3.0.2"
} }
} }

10
backend/src/index.ts

@ -1,6 +1,7 @@
import express from 'express'; import express from 'express';
import cookieParser from 'cookie-parser'; import cookieParser from 'cookie-parser';
import cors from 'cors'; import cors from 'cors';
import { createServer } from 'http';
import { validateEnv, env } from './config/env.js'; import { validateEnv, env } from './config/env.js';
import { runMigrations } from './db/migrate.js'; import { runMigrations } from './db/migrate.js';
import { createInitialAdmin } from './setup/initialSetup.js'; import { createInitialAdmin } from './setup/initialSetup.js';
@ -11,6 +12,7 @@ import settingsRoutes from './routes/settings.routes.js';
import wordGroupsRoutes from './routes/wordGroups.routes.js'; import wordGroupsRoutes from './routes/wordGroups.routes.js';
import { errorHandler } from './middleware/errorHandler.js'; import { errorHandler } from './middleware/errorHandler.js';
import { apiLimiter } from './middleware/rateLimiter.js'; import { apiLimiter } from './middleware/rateLimiter.js';
import { createWebSocketServer } from './services/websocket.service.js';
async function startServer() { async function startServer() {
try { try {
@ -52,8 +54,14 @@ async function startServer() {
// Error handling // Error handling
app.use(errorHandler); app.use(errorHandler);
// Create HTTP server
const server = createServer(app);
// Set up WebSocket server
createWebSocketServer(server);
// Start server // Start server
app.listen(env.port, () => { server.listen(env.port, () => {
console.log(`\n🚀 Server running on http://localhost:${env.port}`); console.log(`\n🚀 Server running on http://localhost:${env.port}`);
console.log(`📊 Environment: ${env.nodeEnv}`); console.log(`📊 Environment: ${env.nodeEnv}`);
console.log(`🔒 CORS origin: ${env.corsOrigin}`); console.log(`🔒 CORS origin: ${env.corsOrigin}`);

289
backend/src/services/game.service.ts

@ -0,0 +1,289 @@
import type { WebSocket as WS } from 'ws';
type Player = {
id: string;
ws: WS;
symbol: 'X' | 'O' | null;
};
type GameState = {
board: (string | null)[];
currentPlayer: 'X' | 'O';
winner: string | null;
isDraw: boolean;
players: Player[];
queue: string[]; // Player IDs waiting to play
};
const games = new Map<string, GameState>();
function createGame(roomId: string): GameState {
const game: GameState = {
board: Array(9).fill(null),
currentPlayer: 'X',
winner: null,
isDraw: false,
players: [],
queue: [],
};
games.set(roomId, game);
return game;
}
function getGame(roomId: string): GameState | undefined {
return games.get(roomId);
}
function checkWinner(board: (string | null)[]): string | null {
const lines = [
[0, 1, 2], [3, 4, 5], [6, 7, 8], // rows
[0, 3, 6], [1, 4, 7], [2, 5, 8], // columns
[0, 4, 8], [2, 4, 6], // diagonals
];
for (const [a, b, c] of lines) {
if (board[a] && board[a] === board[b] && board[a] === board[c]) {
return board[a];
}
}
return null;
}
function checkDraw(board: (string | null)[]): boolean {
return board.every(cell => cell !== null) && !checkWinner(board);
}
function makeMove(roomId: string, playerId: string, position: number): { success: boolean; error?: string; autoStartNext?: boolean } {
const game = games.get(roomId);
if (!game) {
return { success: false, error: 'Game not found' };
}
const player = game.players.find(p => p.id === playerId);
if (!player || !player.symbol) {
return { success: false, error: 'Player not in game' };
}
if (game.winner || game.isDraw) {
return { success: false, error: 'Game is over' };
}
if (game.currentPlayer !== player.symbol) {
return { success: false, error: 'Not your turn' };
}
if (game.board[position] !== null) {
return { success: false, error: 'Position already taken' };
}
game.board[position] = player.symbol;
const winner = checkWinner(game.board);
const isDraw = checkDraw(game.board);
if (winner) {
game.winner = winner;
// Auto-start next game if there's a queue
// Note: We set winner first, then the websocket handler will broadcast
// After broadcast, if there's a queue, we'll auto-start the next game
} else if (isDraw) {
game.isDraw = true;
} else {
game.currentPlayer = game.currentPlayer === 'X' ? 'O' : 'X';
}
return { success: true, autoStartNext: winner !== null && game.queue.length > 0 };
}
function addPlayer(roomId: string, playerId: string, ws: WS): { success: boolean; error?: string; symbol?: 'X' | 'O' | null } {
let game = games.get(roomId);
if (!game) {
game = createGame(roomId);
}
// Check if player already in game
if (game.players.find(p => p.id === playerId)) {
return { success: false, error: 'Player already in game' };
}
// If 2 players already playing, add to queue
const activePlayers = game.players.filter(p => p.symbol !== null);
if (activePlayers.length >= 2) {
game.queue.push(playerId);
const player: Player = { id: playerId, ws, symbol: null };
game.players.push(player);
return { success: true, symbol: null };
}
// Assign symbol (X or O)
const symbol = activePlayers.length === 0 ? 'X' : 'O';
const player: Player = { id: playerId, ws, symbol };
game.players.push(player);
return { success: true, symbol };
}
function removePlayer(roomId: string, playerId: string): void {
const game = games.get(roomId);
if (!game) return;
const playerIndex = game.players.findIndex(p => p.id === playerId);
if (playerIndex === -1) return;
const player = game.players[playerIndex];
const wasActive = player.symbol !== null;
// Remove from players
game.players.splice(playerIndex, 1);
// Remove from queue if there
const queueIndex = game.queue.indexOf(playerId);
if (queueIndex !== -1) {
game.queue.splice(queueIndex, 1);
}
// If an active player left, promote next in queue
if (wasActive && game.queue.length > 0) {
const nextPlayerId = game.queue.shift()!;
const nextPlayer = game.players.find(p => p.id === nextPlayerId);
if (nextPlayer) {
// Assign the symbol of the player who left
nextPlayer.symbol = player.symbol;
}
}
// If no players left, delete game
if (game.players.length === 0) {
games.delete(roomId);
} else if (game.players.filter(p => p.symbol !== null).length < 2 && !game.winner && !game.isDraw) {
// Reset game if not enough players
game.board = Array(9).fill(null);
game.currentPlayer = 'X';
game.winner = null;
game.isDraw = false;
}
}
function resetGame(roomId: string, resettingPlayerId?: string): void {
const game = games.get(roomId);
if (!game) return;
const activePlayers = game.players.filter(p => p.symbol !== null);
const previousWinner = game.winner;
const wasDraw = game.isDraw;
const previousLoser = previousWinner ? activePlayers.find(p => p.symbol !== previousWinner && p.symbol !== null) : null;
const hasQueue = game.queue.length > 0;
game.board = Array(9).fill(null);
game.currentPlayer = 'X';
game.winner = null;
game.isDraw = false;
// If there's a previous winner and others in queue, winner stays, loser goes to queue
if (previousWinner && hasQueue && previousLoser) {
// Winner stays with their symbol (no change needed)
// Loser goes to end of queue
previousLoser.symbol = null;
game.queue.push(previousLoser.id);
// Promote next player from queue to play against winner
const nextPlayerId = game.queue.shift();
if (nextPlayerId) {
const nextPlayer = game.players.find(p => p.id === nextPlayerId);
if (nextPlayer) {
// Assign the opposite symbol of the winner
nextPlayer.symbol = previousWinner === 'X' ? 'O' : 'X';
}
}
} else if (wasDraw && hasQueue && activePlayers.length === 2) {
// If it was a draw and there's a queue, both players go to queue
activePlayers.forEach(p => {
p.symbol = null;
game.queue.push(p.id);
});
// Promote next 2 from queue
const newPlayer1 = game.queue.shift();
const newPlayer2 = game.queue.shift();
if (newPlayer1) {
const p1 = game.players.find(p => p.id === newPlayer1);
if (p1) p1.symbol = 'X';
}
if (newPlayer2) {
const p2 = game.players.find(p => p.id === newPlayer2);
if (p2) p2.symbol = 'O';
}
} else if (activePlayers.length > 2) {
// If no winner or no queue, rotate all players
activePlayers.forEach(p => {
p.symbol = null;
game.queue.push(p.id);
});
// Promote next 2 from queue
const newPlayer1 = game.queue.shift();
const newPlayer2 = game.queue.shift();
if (newPlayer1) {
const p1 = game.players.find(p => p.id === newPlayer1);
if (p1) p1.symbol = 'X';
}
if (newPlayer2) {
const p2 = game.players.find(p => p.id === newPlayer2);
if (p2) p2.symbol = 'O';
}
}
}
function joinQueue(roomId: string, playerId: string): { success: boolean; error?: string } {
const game = games.get(roomId);
if (!game) {
return { success: false, error: 'Game not found' };
}
const player = game.players.find(p => p.id === playerId);
if (!player) {
return { success: false, error: 'Player not found' };
}
// If player is already in queue, do nothing
if (game.queue.includes(playerId)) {
return { success: true };
}
// If player is currently playing, they can't join queue
if (player.symbol !== null) {
return { success: false, error: 'Cannot join queue while playing' };
}
// Add to queue
game.queue.push(playerId);
return { success: true };
}
function broadcastToRoom(roomId: string, message: any, excludePlayerId?: string): void {
const game = games.get(roomId);
if (!game) return;
const messageStr = JSON.stringify(message);
game.players.forEach(player => {
if (player.id !== excludePlayerId && player.ws.readyState === 1) { // WebSocket.OPEN
player.ws.send(messageStr);
}
});
}
export {
createGame,
getGame,
makeMove,
addPlayer,
removePlayer,
resetGame,
joinQueue,
broadcastToRoom,
type GameState,
type Player,
};

250
backend/src/services/websocket.service.ts

@ -0,0 +1,250 @@
import { WebSocketServer, WebSocket as WS } from 'ws';
import { createServer } from 'http';
import { getGame, addPlayer, removePlayer, makeMove, resetGame, joinQueue, broadcastToRoom } from './game.service.js';
let wss: WebSocketServer | null = null;
export function createWebSocketServer(server: any) {
wss = new WebSocketServer({ server, path: '/ws' });
wss.on('connection', (ws: WS, req) => {
const url = new URL(req.url || '', `http://${req.headers.host}`);
const roomId = url.searchParams.get('room') || 'default';
const playerId = url.searchParams.get('playerId') || `player-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`;
console.log(`[WebSocket] Player ${playerId} connected to room ${roomId}`);
// Add player to game
const result = addPlayer(roomId, playerId, ws);
if (!result.success) {
ws.send(JSON.stringify({ type: 'error', message: result.error }));
ws.close();
return;
}
// Send initial game state
const initialGame = getGame(roomId);
if (initialGame) {
ws.send(JSON.stringify({
type: 'gameState',
game: {
board: initialGame.board,
currentPlayer: initialGame.currentPlayer,
winner: initialGame.winner,
isDraw: initialGame.isDraw,
yourSymbol: result.symbol,
players: initialGame.players.map(p => ({ id: p.id, symbol: p.symbol })),
queue: initialGame.queue,
},
}));
}
// Broadcast player joined to other players
const joinGame = getGame(roomId);
if (joinGame) {
joinGame.players.forEach(player => {
if (player.id !== playerId && player.ws.readyState === 1) {
player.ws.send(JSON.stringify({
type: 'playerJoined',
playerId,
symbol: result.symbol,
game: {
board: joinGame.board,
currentPlayer: joinGame.currentPlayer,
winner: joinGame.winner,
isDraw: joinGame.isDraw,
yourSymbol: player.symbol,
players: joinGame.players.map(p => ({ id: p.id, symbol: p.symbol })),
queue: joinGame.queue,
},
}));
}
});
}
// Handle messages
ws.on('message', (data: Buffer) => {
try {
const message = JSON.parse(data.toString());
switch (message.type) {
case 'move':
if (typeof message.position !== 'number' || message.position < 0 || message.position > 8) {
ws.send(JSON.stringify({ type: 'error', message: 'Invalid position' }));
return;
}
const moveResult = makeMove(roomId, playerId, message.position);
if (!moveResult.success) {
ws.send(JSON.stringify({ type: 'error', message: moveResult.error }));
return;
}
const updatedGame = getGame(roomId);
if (updatedGame) {
// Broadcast current game state (with winner if game ended)
updatedGame.players.forEach(player => {
if (player.ws.readyState === 1) { // WebSocket.OPEN
player.ws.send(JSON.stringify({
type: 'gameState',
game: {
board: updatedGame.board,
currentPlayer: updatedGame.currentPlayer,
winner: updatedGame.winner,
isDraw: updatedGame.isDraw,
yourSymbol: player.symbol,
players: updatedGame.players.map(p => ({ id: p.id, symbol: p.symbol })),
queue: updatedGame.queue,
},
}));
}
});
// Auto-start next game if there's a winner and queue
if (moveResult.autoStartNext && updatedGame.winner && updatedGame.queue.length > 0) {
const winner = updatedGame.winner;
const loser = updatedGame.players.find(p => p.symbol !== winner && p.symbol !== null);
if (loser) {
// Move loser to end of queue
loser.symbol = null;
updatedGame.queue.push(loser.id);
// Promote next player from queue
const nextPlayerId = updatedGame.queue.shift();
if (nextPlayerId) {
const nextPlayer = updatedGame.players.find(p => p.id === nextPlayerId);
if (nextPlayer) {
// Reset board and assign opposite symbol to new player
updatedGame.board = Array(9).fill(null);
updatedGame.currentPlayer = 'X';
updatedGame.winner = null;
updatedGame.isDraw = false;
nextPlayer.symbol = winner === 'X' ? 'O' : 'X';
// Winner keeps their symbol, so no change needed
// Broadcast new game state immediately
updatedGame.players.forEach(player => {
if (player.ws.readyState === 1) {
player.ws.send(JSON.stringify({
type: 'gameState',
game: {
board: updatedGame.board,
currentPlayer: updatedGame.currentPlayer,
winner: updatedGame.winner,
isDraw: updatedGame.isDraw,
yourSymbol: player.symbol,
players: updatedGame.players.map(p => ({ id: p.id, symbol: p.symbol })),
queue: updatedGame.queue,
},
}));
}
});
}
}
}
}
}
break;
case 'reset':
resetGame(roomId, playerId);
const resetGameState = getGame(roomId);
if (resetGameState) {
// Broadcast to all players with their individual symbols
resetGameState.players.forEach(player => {
if (player.ws.readyState === 1) { // WebSocket.OPEN
player.ws.send(JSON.stringify({
type: 'gameState',
game: {
board: resetGameState.board,
currentPlayer: resetGameState.currentPlayer,
winner: resetGameState.winner,
isDraw: resetGameState.isDraw,
yourSymbol: player.symbol,
players: resetGameState.players.map(p => ({ id: p.id, symbol: p.symbol })),
queue: resetGameState.queue,
},
}));
}
});
}
break;
case 'joinQueue':
const joinResult = joinQueue(roomId, playerId);
if (!joinResult.success) {
ws.send(JSON.stringify({ type: 'error', message: joinResult.error }));
return;
}
const queueGameState = getGame(roomId);
if (queueGameState) {
// Broadcast updated game state to all players
queueGameState.players.forEach(player => {
if (player.ws.readyState === 1) { // WebSocket.OPEN
player.ws.send(JSON.stringify({
type: 'gameState',
game: {
board: queueGameState.board,
currentPlayer: queueGameState.currentPlayer,
winner: queueGameState.winner,
isDraw: queueGameState.isDraw,
yourSymbol: player.symbol,
players: queueGameState.players.map(p => ({ id: p.id, symbol: p.symbol })),
queue: queueGameState.queue,
},
}));
}
});
}
break;
default:
ws.send(JSON.stringify({ type: 'error', message: 'Unknown message type' }));
}
} catch (error) {
console.error('[WebSocket] Error handling message:', error);
ws.send(JSON.stringify({ type: 'error', message: 'Invalid message format' }));
}
});
// Handle disconnect
ws.on('close', () => {
console.log(`[WebSocket] Player ${playerId} disconnected from room ${roomId}`);
removePlayer(roomId, playerId);
const game = getGame(roomId);
if (game) {
// Broadcast to all remaining players with their individual symbols
game.players.forEach(player => {
if (player.ws.readyState === 1) { // WebSocket.OPEN
player.ws.send(JSON.stringify({
type: 'playerLeft',
playerId,
game: {
board: game.board,
currentPlayer: game.currentPlayer,
winner: game.winner,
isDraw: game.isDraw,
yourSymbol: player.symbol,
players: game.players.map(p => ({ id: p.id, symbol: p.symbol })),
queue: game.queue,
},
}));
}
});
}
});
ws.on('error', (error) => {
console.error(`[WebSocket] Error for player ${playerId}:`, error);
});
});
console.log('✅ WebSocket server started on /ws');
}
export function getWebSocketServer(): WebSocketServer | null {
return wss;
}

10
frontend/src/config/apps.ts

@ -1,6 +1,7 @@
import React from 'react'; import React from 'react';
import { VideoApp } from '../pages/VideoApp'; import { VideoApp } from '../pages/VideoApp';
import { SpeechSoundsApp } from '../pages/SpeechSoundsApp'; import { SpeechSoundsApp } from '../pages/SpeechSoundsApp';
import { TicTacToeApp } from '../pages/TicTacToeApp';
export type App = { export type App = {
id: string; id: string;
@ -30,5 +31,14 @@ export const APPS: App[] = [
link: '/speech-sounds', link: '/speech-sounds',
disabled: false, disabled: false,
component: SpeechSoundsApp component: SpeechSoundsApp
},
{
id: 'tictactoe',
name: 'Tic Tac Toe',
description: 'Play multiplayer tic-tac-toe with friends!',
cta: 'Play Now',
link: '/tic-tac-toe',
disabled: false,
component: TicTacToeApp
} }
]; ];

2
frontend/src/pages/LandingPage.tsx

@ -4,12 +4,14 @@ import { APPS } from '../config/apps';
const categoryEmojis: { [key: string]: string } = { const categoryEmojis: { [key: string]: string } = {
videos: '📺', videos: '📺',
speechsounds: '🗣', speechsounds: '🗣',
tictactoe: '⭕',
all: '🎮', all: '🎮',
}; };
const categoryColors: { [key: string]: string } = { const categoryColors: { [key: string]: string } = {
videos: 'pink', videos: 'pink',
speechsounds: 'purple', speechsounds: 'purple',
tictactoe: 'blue',
}; };
const colorMap: { [key: string]: string } = { const colorMap: { [key: string]: string } = {

314
frontend/src/pages/TicTacToeApp.tsx

@ -0,0 +1,314 @@
import { useState, useEffect, useRef } from 'react';
interface GameState {
board: (string | null)[];
currentPlayer: 'X' | 'O';
winner: string | null;
isDraw: boolean;
yourSymbol: 'X' | 'O' | null;
players: Array<{ id: string; symbol: 'X' | 'O' | null }>;
queue: string[];
}
export function TicTacToeApp() {
const [gameState, setGameState] = useState<GameState | null>(null);
const [connected, setConnected] = useState(false);
const [error, setError] = useState<string | null>(null);
const wsRef = useRef<WebSocket | null>(null);
const playerIdRef = useRef<string>(`player-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`);
useEffect(() => {
// Get WebSocket URL - connect to backend server
const apiUrl = import.meta.env.VITE_API_URL || 'http://localhost:3000/api';
const backendUrl = apiUrl.replace('/api', '');
const wsProtocol = backendUrl.startsWith('https') ? 'wss:' : 'ws:';
const wsHost = backendUrl.replace(/^https?:\/\//, '');
const wsUrl = `${wsProtocol}//${wsHost}/ws?room=default&playerId=${playerIdRef.current}`;
const ws = new WebSocket(wsUrl);
wsRef.current = ws;
ws.onopen = () => {
console.log('WebSocket connected');
setConnected(true);
setError(null);
};
ws.onmessage = (event) => {
try {
const message = JSON.parse(event.data);
if (message.type === 'gameState') {
setGameState(message.game);
} else if (message.type === 'playerJoined' && message.game) {
// Update game state when player joins
setGameState(message.game);
} else if (message.type === 'playerLeft' && message.game) {
// Update game state when player leaves
setGameState(message.game);
} else if (message.type === 'error') {
setError(message.message);
}
} catch (err) {
console.error('Error parsing WebSocket message:', err);
}
};
ws.onerror = (err) => {
console.error('WebSocket error:', err);
setError('Connection error. Please refresh the page.');
setConnected(false);
};
ws.onclose = () => {
console.log('WebSocket disconnected');
setConnected(false);
};
return () => {
ws.close();
};
}, []);
const handleCellClick = (index: number) => {
if (!wsRef.current || wsRef.current.readyState !== WebSocket.OPEN) {
setError('Not connected to server');
return;
}
if (!gameState) return;
if (gameState.winner || gameState.isDraw) return;
if (gameState.yourSymbol !== gameState.currentPlayer) return;
if (gameState.board[index] !== null) return;
wsRef.current.send(JSON.stringify({
type: 'move',
position: index,
}));
};
const handleReset = () => {
if (!wsRef.current || wsRef.current.readyState !== WebSocket.OPEN) return;
wsRef.current.send(JSON.stringify({ type: 'reset' }));
};
const handleJoinQueue = () => {
if (!wsRef.current || wsRef.current.readyState !== WebSocket.OPEN) return;
wsRef.current.send(JSON.stringify({ type: 'joinQueue' }));
};
const getStatusMessage = () => {
if (!gameState) return 'Connecting...';
if (gameState.winner) {
const winnerSymbol = gameState.winner;
if (gameState.yourSymbol === winnerSymbol) {
return '🎉 You won!';
} else {
return `Player ${winnerSymbol} won!`;
}
}
if (gameState.isDraw) {
return "It's a draw!";
}
if (gameState.yourSymbol === null) {
return `Waiting in queue (${gameState.queue.indexOf(playerIdRef.current) + 1} of ${gameState.queue.length + gameState.players.filter(p => p.symbol !== null).length})...`;
}
if (gameState.currentPlayer === gameState.yourSymbol) {
return `Your turn (${gameState.yourSymbol})`;
}
return `Waiting for ${gameState.currentPlayer}...`;
};
return (
<div className="min-h-screen bg-background px-4 py-8">
<div className="max-w-2xl mx-auto">
<div className="text-center mb-8">
<h1 className="text-4xl font-bold text-primary mb-2">Tic Tac Toe</h1>
<p className="text-muted-foreground">Multiplayer game - play with friends!</p>
</div>
{!connected && (
<div className="bg-card border border-border rounded-xl p-6 text-center">
<p className="text-muted-foreground">Connecting to game server...</p>
</div>
)}
{error && (
<div className="bg-red-50 border border-red-200 rounded-xl p-4 mb-6 text-red-800 text-sm">
{error}
</div>
)}
{connected && gameState && (
<>
<div className="bg-card border-4 border-primary rounded-3xl p-6 mb-6 shadow-lg">
<div className="text-center mb-6">
<p className={`text-lg font-bold ${
gameState.winner && gameState.yourSymbol === gameState.winner
? 'text-green-600'
: gameState.winner
? 'text-red-600'
: gameState.yourSymbol === gameState.currentPlayer
? 'text-primary'
: 'text-muted-foreground'
}`}>
{getStatusMessage()}
</p>
{gameState.yourSymbol && (
<p className="text-sm text-muted-foreground mt-2">
You are playing as <span className="font-bold text-primary">{gameState.yourSymbol}</span>
</p>
)}
{gameState.players.length > 2 && (
<div className="mt-4 text-sm text-muted-foreground">
<p>Players: {gameState.players.filter(p => p.symbol !== null).length} playing, {gameState.queue.length} waiting</p>
</div>
)}
</div>
<div className="grid grid-cols-3 gap-3 max-w-md mx-auto">
{gameState.board.map((cell, index) => (
<button
key={index}
onClick={() => handleCellClick(index)}
disabled={
!gameState.yourSymbol ||
gameState.yourSymbol !== gameState.currentPlayer ||
cell !== null ||
gameState.winner !== null ||
gameState.isDraw
}
className={`
aspect-square text-4xl font-bold rounded-xl transition-all
${cell === null
? 'bg-muted hover:bg-muted/80 border-2 border-border'
: 'bg-card border-2 border-primary'
}
${gameState.yourSymbol === gameState.currentPlayer && cell === null && !gameState.winner && !gameState.isDraw
? 'cursor-pointer hover:scale-105 active:scale-95'
: 'cursor-not-allowed opacity-60'
}
${cell === 'X' ? 'text-primary' : cell === 'O' ? 'text-secondary' : ''}
`}
>
{cell || ''}
</button>
))}
</div>
{(gameState.winner || gameState.isDraw) && (
<div className="mt-6 text-center">
{(() => {
const isWinner = gameState.yourSymbol === gameState.winner;
const isLoser = gameState.yourSymbol !== null && gameState.yourSymbol !== gameState.winner && !gameState.isDraw;
const hasQueue = gameState.queue.length > 0;
const isInQueue = gameState.queue.includes(playerIdRef.current);
// If winner and queue exists, game auto-started - show nothing or a message
if (isWinner && hasQueue) {
return (
<p className="text-sm text-primary font-semibold">
New game started! Next player joined.
</p>
);
}
// Winner with no queue: can play again
if (isWinner && !hasQueue) {
return (
<button
onClick={handleReset}
className="px-6 py-3 bg-primary text-primary-foreground rounded-full font-semibold hover:bg-primary/90 transition-all active:scale-95 shadow-md"
>
Play Again
</button>
);
}
// Loser: automatically in queue if there was a queue, show "Get in line" if not already
if (isLoser) {
if (isInQueue) {
return (
<p className="text-sm text-muted-foreground">
You're in line to play again (position {gameState.queue.indexOf(playerIdRef.current) + 1})
</p>
);
} else if (hasQueue) {
return (
<button
onClick={handleJoinQueue}
className="px-6 py-3 bg-secondary text-primary-foreground rounded-full font-semibold hover:bg-secondary/90 transition-all active:scale-95 shadow-md"
>
Get in line to play again
</button>
);
} else {
return (
<button
onClick={handleReset}
className="px-6 py-3 bg-primary text-primary-foreground rounded-full font-semibold hover:bg-primary/90 transition-all active:scale-95 shadow-md"
>
Play Again
</button>
);
}
}
// Draw: if there's a queue, show "Get in line", otherwise "Play Again"
if (gameState.isDraw) {
if (hasQueue && !isInQueue) {
return (
<button
onClick={handleJoinQueue}
className="px-6 py-3 bg-secondary text-primary-foreground rounded-full font-semibold hover:bg-secondary/90 transition-all active:scale-95 shadow-md"
>
Get in line to play again
</button>
);
} else if (!hasQueue) {
return (
<button
onClick={handleReset}
className="px-6 py-3 bg-primary text-primary-foreground rounded-full font-semibold hover:bg-primary/90 transition-all active:scale-95 shadow-md"
>
Play Again
</button>
);
} else {
return (
<p className="text-sm text-muted-foreground">
You're in line to play again (position {gameState.queue.indexOf(playerIdRef.current) + 1})
</p>
);
}
}
return null;
})()}
</div>
)}
</div>
<div className="bg-card border border-border rounded-xl p-4">
<h3 className="font-bold text-foreground mb-3">Players</h3>
<div className="space-y-2">
{gameState.players.map((player, idx) => (
<div key={player.id} className="flex items-center justify-between text-sm">
<span className={player.id === playerIdRef.current ? 'font-bold text-primary' : 'text-foreground'}>
{player.id === playerIdRef.current ? 'You' : `Player ${idx + 1}`}
</span>
<span className="text-muted-foreground">
{player.symbol ? `Playing as ${player.symbol}` : 'Waiting in queue'}
</span>
</div>
))}
</div>
</div>
</>
)}
</div>
</div>
);
}
Loading…
Cancel
Save