diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..284abb6 --- /dev/null +++ b/.env.example @@ -0,0 +1,7 @@ +# Snake Environment Variables +# Copy to .env.local for local development + +# Supabase (optional - for global leaderboard) +# Get from Supabase: Project Settings > API +# VITE_SUPABASE_URL=https://xxxxx.supabase.co +# VITE_SUPABASE_ANON_KEY=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9... diff --git a/README.md b/README.md index b4d70c1..9dc64b2 100644 --- a/README.md +++ b/README.md @@ -53,6 +53,18 @@ Try Snake here: https://snake-beryl-six.vercel.app/ --- +## πŸ† Supabase Leaderboard (Optional) + +To enable the global leaderboard: + +1. Create a [Supabase](https://supabase.com) project +2. Run the migration in `supabase/migrations/001_snake_scores.sql` in the SQL Editor +3. Copy `.env.example` to `.env.local` and add your `VITE_SUPABASE_URL` and `VITE_SUPABASE_ANON_KEY` + +Without Supabase, the game still works with local-only score storage. + +--- + ## Contributing We love contributions of all kinds! Whether it’s fixing a bug, suggesting a feature, or polishing docs, your help makes this game better. @@ -124,18 +136,28 @@ Meet all our amazing [Contributors](./CONTRIBUTORS.md) β”‚ β”œβ”€β”€ assets β”‚ β”‚ └── snake-gameplay.gif β”‚ β”œβ”€β”€ components -β”‚ β”‚ β”œβ”€β”€ Board.tsx +β”‚ β”‚ β”œβ”€β”€ CountdownOverlay.tsx +β”‚ β”‚ β”œβ”€β”€ GameOverOverlay.tsx β”‚ β”‚ β”œβ”€β”€ HUD.tsx +β”‚ β”‚ β”œβ”€β”€ HowToPlayModal.tsx +β”‚ β”‚ β”œβ”€β”€ LeaderboardModal.tsx +β”‚ β”‚ β”œβ”€β”€ MenuScreen.tsx +β”‚ β”‚ β”œβ”€β”€ MobileControls.tsx +β”‚ β”‚ β”œβ”€β”€ SettingsModal.tsx β”‚ β”‚ └── SnakeCanvas.tsx β”‚ β”œβ”€β”€ constants β”‚ β”‚ └── game.ts β”‚ β”œβ”€β”€ hooks β”‚ β”‚ β”œβ”€β”€ useBestScore.ts β”‚ β”‚ β”œβ”€β”€ useCanvas2D.ts +β”‚ β”‚ β”œβ”€β”€ useGameScale.ts +β”‚ β”‚ β”œβ”€β”€ useGameSetup.ts β”‚ β”‚ β”œβ”€β”€ useInput.ts +β”‚ β”‚ β”œβ”€β”€ useLeaderboard.ts β”‚ β”‚ β”œβ”€β”€ usePauseHotkey.ts -β”‚ β”‚ β”œβ”€β”€ useSnake.ts +β”‚ β”‚ β”œβ”€β”€ useSettings.ts β”‚ β”‚ β”œβ”€β”€ useSnakeGame.ts +β”‚ β”‚ β”œβ”€β”€ useSwipe.ts β”‚ β”‚ └── useTicker.ts β”‚ β”œβ”€β”€ main.tsx β”‚ β”œβ”€β”€ styles @@ -144,9 +166,15 @@ Meet all our amazing [Contributors](./CONTRIBUTORS.md) β”‚ β”‚ β”œβ”€β”€ game.ts β”‚ β”‚ β”œβ”€β”€ index.ts β”‚ β”‚ └── ui.ts +β”‚ β”œβ”€β”€ services +β”‚ β”‚ └── leaderboardService.ts +β”‚ β”œβ”€β”€ supabase +β”‚ β”‚ └── client.ts β”‚ └── utils β”‚ β”œβ”€β”€ canvas.ts -β”‚ └── logic.ts +β”‚ β”œβ”€β”€ gameTick.ts +β”‚ β”œβ”€β”€ logic.ts +β”‚ └── speed.ts β”œβ”€β”€ tsconfig.json β”œβ”€β”€ tsconfig.node.json └── vite.config.ts diff --git a/index.html b/index.html index fb560dc..93925ab 100644 --- a/index.html +++ b/index.html @@ -1,5 +1,11 @@ - + + + + diff --git a/package-lock.json b/package-lock.json index a25c086..0409580 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9,8 +9,10 @@ "version": "0.0.0", "dependencies": { "@formkit/auto-animate": "^0.8.2", + "@supabase/supabase-js": "^2.94.1", "canvas-confetti": "^1.9.3", "husky": "^9.1.7", + "lucide-react": "^0.563.0", "react": "^19.1.1", "react-dom": "^19.1.1" }, @@ -56,29 +58,6 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/@asamuzakjp/css-color": { - "version": "3.2.0", - "resolved": "https://registry.npmjs.org/@asamuzakjp/css-color/-/css-color-3.2.0.tgz", - "integrity": "sha512-K1A6z8tS3XsmCMM86xoWdn7Fkdn9m6RSVtocUrJYIwZnFVkng/PvkEoWtOWmP+Scc6saYWHWZYbndEEXxl24jw==", - "dev": true, - "license": "MIT", - "optional": true, - "dependencies": { - "@csstools/css-calc": "^2.1.3", - "@csstools/css-color-parser": "^3.0.9", - "@csstools/css-parser-algorithms": "^3.0.4", - "@csstools/css-tokenizer": "^3.0.3", - "lru-cache": "^10.4.3" - } - }, - "node_modules/@asamuzakjp/css-color/node_modules/lru-cache": { - "version": "10.4.3", - "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-10.4.3.tgz", - "integrity": "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==", - "dev": true, - "license": "ISC", - "optional": true - }, "node_modules/@babel/code-frame": { "version": "7.27.1", "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.27.1.tgz", @@ -416,81 +395,6 @@ "@keyv/serialize": "^1.1.1" } }, - "node_modules/@csstools/color-helpers": { - "version": "5.1.0", - "resolved": "https://registry.npmjs.org/@csstools/color-helpers/-/color-helpers-5.1.0.tgz", - "integrity": "sha512-S11EXWJyy0Mz5SYvRmY8nJYTFFd1LCNV+7cXyAgQtOOuzb4EsgfqDufL+9esx72/eLhsRdGZwaldu/h+E4t4BA==", - "dev": true, - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/csstools" - }, - { - "type": "opencollective", - "url": "https://opencollective.com/csstools" - } - ], - "license": "MIT-0", - "optional": true, - "engines": { - "node": ">=18" - } - }, - "node_modules/@csstools/css-calc": { - "version": "2.1.4", - "resolved": "https://registry.npmjs.org/@csstools/css-calc/-/css-calc-2.1.4.tgz", - "integrity": "sha512-3N8oaj+0juUw/1H3YwmDDJXCgTB1gKU6Hc/bB502u9zR0q2vd786XJH9QfrKIEgFlZmhZiq6epXl4rHqhzsIgQ==", - "dev": true, - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/csstools" - }, - { - "type": "opencollective", - "url": "https://opencollective.com/csstools" - } - ], - "license": "MIT", - "optional": true, - "engines": { - "node": ">=18" - }, - "peerDependencies": { - "@csstools/css-parser-algorithms": "^3.0.5", - "@csstools/css-tokenizer": "^3.0.4" - } - }, - "node_modules/@csstools/css-color-parser": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/@csstools/css-color-parser/-/css-color-parser-3.1.0.tgz", - "integrity": "sha512-nbtKwh3a6xNVIp/VRuXV64yTKnb1IjTAEEh3irzS+HkKjAOYLTGNb9pmVNntZ8iVBHcWDA2Dof0QtPgFI1BaTA==", - "dev": true, - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/csstools" - }, - { - "type": "opencollective", - "url": "https://opencollective.com/csstools" - } - ], - "license": "MIT", - "optional": true, - "dependencies": { - "@csstools/color-helpers": "^5.1.0", - "@csstools/css-calc": "^2.1.4" - }, - "engines": { - "node": ">=18" - }, - "peerDependencies": { - "@csstools/css-parser-algorithms": "^3.0.5", - "@csstools/css-tokenizer": "^3.0.4" - } - }, "node_modules/@csstools/css-parser-algorithms": { "version": "3.0.5", "resolved": "https://registry.npmjs.org/@csstools/css-parser-algorithms/-/css-parser-algorithms-3.0.5.tgz", @@ -1683,6 +1587,86 @@ "win32" ] }, + "node_modules/@supabase/auth-js": { + "version": "2.94.1", + "resolved": "https://registry.npmjs.org/@supabase/auth-js/-/auth-js-2.94.1.tgz", + "integrity": "sha512-Wt/SdmAtNNiqrcBbPlzWojLcE1bQ9OYb8PTaYF6QccFX5JeXZI0sZ01MLNE+E83UK6cK0lw4YznX0D2g08UQng==", + "license": "MIT", + "dependencies": { + "tslib": "2.8.1" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@supabase/functions-js": { + "version": "2.94.1", + "resolved": "https://registry.npmjs.org/@supabase/functions-js/-/functions-js-2.94.1.tgz", + "integrity": "sha512-A7Bx0gnclDNZ4m8+mnO2IEEzMxtUSg7cpPEBF6Ek1LpjIQkC7vvoidiV/RuntnKX43IiVcWV1f2FsAppMagEmQ==", + "license": "MIT", + "dependencies": { + "tslib": "2.8.1" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@supabase/postgrest-js": { + "version": "2.94.1", + "resolved": "https://registry.npmjs.org/@supabase/postgrest-js/-/postgrest-js-2.94.1.tgz", + "integrity": "sha512-N6MTghjHnMZddT48rAj8dIFgedCU97cc1ahQM74Tc+DF4UH7y2+iEfdYV3unJsylpaiWlu92Fy8Lj14Jbrmxog==", + "license": "MIT", + "dependencies": { + "tslib": "2.8.1" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@supabase/realtime-js": { + "version": "2.94.1", + "resolved": "https://registry.npmjs.org/@supabase/realtime-js/-/realtime-js-2.94.1.tgz", + "integrity": "sha512-Wq8olpCAGmN4y2DH2kUdlcakdzNHRCde72BFS8zK5ub46bBeSUoE9DqrfeNFWKaF2gCE/cmK8aTUTorZD9jdtQ==", + "license": "MIT", + "dependencies": { + "@types/phoenix": "^1.6.6", + "@types/ws": "^8.18.1", + "tslib": "2.8.1", + "ws": "^8.18.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@supabase/storage-js": { + "version": "2.94.1", + "resolved": "https://registry.npmjs.org/@supabase/storage-js/-/storage-js-2.94.1.tgz", + "integrity": "sha512-/Mi18LGyrugPwtfqETfAqEGcBQotY/7IMsTGYgEFdqr8cQq280BVQWjN2wI9KibWtshPp0Ryvil5Uzd5YfM7kA==", + "license": "MIT", + "dependencies": { + "iceberg-js": "^0.8.1", + "tslib": "2.8.1" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@supabase/supabase-js": { + "version": "2.94.1", + "resolved": "https://registry.npmjs.org/@supabase/supabase-js/-/supabase-js-2.94.1.tgz", + "integrity": "sha512-87vOY8n3WHB3m+a/KeySj07djOQVuRA5qgX5E7db1eDkaZ1of5M+3t/tv6eYYy4BfqxuHMZuCe5uVrO/oyvoow==", + "license": "MIT", + "dependencies": { + "@supabase/auth-js": "2.94.1", + "@supabase/functions-js": "2.94.1", + "@supabase/postgrest-js": "2.94.1", + "@supabase/realtime-js": "2.94.1", + "@supabase/storage-js": "2.94.1" + }, + "engines": { + "node": ">=20.0.0" + } + }, "node_modules/@tailwindcss/node": { "version": "4.1.14", "resolved": "https://registry.npmjs.org/@tailwindcss/node/-/node-4.1.14.tgz", @@ -2054,13 +2038,17 @@ "version": "24.7.2", "resolved": "https://registry.npmjs.org/@types/node/-/node-24.7.2.tgz", "integrity": "sha512-/NbVmcGTP+lj5oa4yiYxxeBjRivKQ5Ns1eSZeB99ExsEQ6rX5XYU1Zy/gGxY/ilqtD4Etx9mKyrPxZRetiahhA==", - "dev": true, "license": "MIT", - "peer": true, "dependencies": { "undici-types": "~7.14.0" } }, + "node_modules/@types/phoenix": { + "version": "1.6.7", + "resolved": "https://registry.npmjs.org/@types/phoenix/-/phoenix-1.6.7.tgz", + "integrity": "sha512-oN9ive//QSBkf19rfDv45M7eZPi0eEXylht2OLEXicu5b4KoQ1OzXIw+xDSGWxSxe1JmepRR/ZH283vsu518/Q==", + "license": "MIT" + }, "node_modules/@types/react": { "version": "19.2.2", "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.2.tgz", @@ -2082,6 +2070,15 @@ "@types/react": "^19.2.0" } }, + "node_modules/@types/ws": { + "version": "8.18.1", + "resolved": "https://registry.npmjs.org/@types/ws/-/ws-8.18.1.tgz", + "integrity": "sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg==", + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, "node_modules/@typescript-eslint/eslint-plugin": { "version": "8.46.1", "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.46.1.tgz", @@ -2514,17 +2511,6 @@ "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" } }, - "node_modules/agent-base": { - "version": "7.1.4", - "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.4.tgz", - "integrity": "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==", - "dev": true, - "license": "MIT", - "optional": true, - "engines": { - "node": ">= 14" - } - }, "node_modules/ajv": { "version": "6.12.6", "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.12.6.tgz", @@ -3169,21 +3155,6 @@ "node": ">=4" } }, - "node_modules/cssstyle": { - "version": "4.6.0", - "resolved": "https://registry.npmjs.org/cssstyle/-/cssstyle-4.6.0.tgz", - "integrity": "sha512-2z+rWdzbbSZv6/rhtvzvqeZQHrBaqgogqt85sqFNbabZOuFbCVFb8kPeEtZjiKkbrm395irpNKiYeFeLiQnFPg==", - "dev": true, - "license": "MIT", - "optional": true, - "dependencies": { - "@asamuzakjp/css-color": "^3.2.0", - "rrweb-cssom": "^0.8.0" - }, - "engines": { - "node": ">=18" - } - }, "node_modules/csstype": { "version": "3.1.3", "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.1.3.tgz", @@ -3191,21 +3162,6 @@ "dev": true, "license": "MIT" }, - "node_modules/data-urls": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/data-urls/-/data-urls-5.0.0.tgz", - "integrity": "sha512-ZYP5VBHshaDAiVZxjbRVcFJpc+4xGgT0bK3vzy1HLN8jTO975HEbuYzZJcHoQEY5K1a0z8YayJkyVETa08eNTg==", - "dev": true, - "license": "MIT", - "optional": true, - "dependencies": { - "whatwg-mimetype": "^4.0.0", - "whatwg-url": "^14.0.0" - }, - "engines": { - "node": ">=18" - } - }, "node_modules/data-view-buffer": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/data-view-buffer/-/data-view-buffer-1.0.2.tgz", @@ -3278,14 +3234,6 @@ } } }, - "node_modules/decimal.js": { - "version": "10.6.0", - "resolved": "https://registry.npmjs.org/decimal.js/-/decimal.js-10.6.0.tgz", - "integrity": "sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg==", - "dev": true, - "license": "MIT", - "optional": true - }, "node_modules/deep-eql": { "version": "5.0.2", "resolved": "https://registry.npmjs.org/deep-eql/-/deep-eql-5.0.2.tgz", @@ -3418,20 +3366,6 @@ "node": ">=10.13.0" } }, - "node_modules/entities": { - "version": "6.0.1", - "resolved": "https://registry.npmjs.org/entities/-/entities-6.0.1.tgz", - "integrity": "sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g==", - "dev": true, - "license": "BSD-2-Clause", - "optional": true, - "engines": { - "node": ">=0.12" - }, - "funding": { - "url": "https://github.com/fb55/entities?sponsor=1" - } - }, "node_modules/env-paths": { "version": "2.2.1", "resolved": "https://registry.npmjs.org/env-paths/-/env-paths-2.2.1.tgz", @@ -4642,20 +4576,6 @@ "dev": true, "license": "MIT" }, - "node_modules/html-encoding-sniffer": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/html-encoding-sniffer/-/html-encoding-sniffer-4.0.0.tgz", - "integrity": "sha512-Y22oTqIU4uuPgEemfz7NDJz6OeKf12Lsu+QC+s3BVpda64lTiMYCyGwg5ki4vFxkMwQdeZDl2adZoqUgdFuTgQ==", - "dev": true, - "license": "MIT", - "optional": true, - "dependencies": { - "whatwg-encoding": "^3.1.1" - }, - "engines": { - "node": ">=18" - } - }, "node_modules/html-tags": { "version": "3.3.1", "resolved": "https://registry.npmjs.org/html-tags/-/html-tags-3.3.1.tgz", @@ -4669,36 +4589,6 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/http-proxy-agent": { - "version": "7.0.2", - "resolved": "https://registry.npmjs.org/http-proxy-agent/-/http-proxy-agent-7.0.2.tgz", - "integrity": "sha512-T1gkAiYYDWYx3V5Bmyu7HcfcvL7mUrTWiM6yOfa3PIphViJ/gFPbvidQ+veqSOHci/PxBcDabeUNCzpOODJZig==", - "dev": true, - "license": "MIT", - "optional": true, - "dependencies": { - "agent-base": "^7.1.0", - "debug": "^4.3.4" - }, - "engines": { - "node": ">= 14" - } - }, - "node_modules/https-proxy-agent": { - "version": "7.0.6", - "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-7.0.6.tgz", - "integrity": "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==", - "dev": true, - "license": "MIT", - "optional": true, - "dependencies": { - "agent-base": "^7.1.2", - "debug": "4" - }, - "engines": { - "node": ">= 14" - } - }, "node_modules/husky": { "version": "9.1.7", "resolved": "https://registry.npmjs.org/husky/-/husky-9.1.7.tgz", @@ -4714,18 +4604,13 @@ "url": "https://github.com/sponsors/typicode" } }, - "node_modules/iconv-lite": { - "version": "0.6.3", - "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz", - "integrity": "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==", - "dev": true, + "node_modules/iceberg-js": { + "version": "0.8.1", + "resolved": "https://registry.npmjs.org/iceberg-js/-/iceberg-js-0.8.1.tgz", + "integrity": "sha512-1dhVQZXhcHje7798IVM+xoo/1ZdVfzOMIc8/rgVSijRK38EDqOJoGula9N/8ZI5RD8QTxNQtK/Gozpr+qUqRRA==", "license": "MIT", - "optional": true, - "dependencies": { - "safer-buffer": ">= 2.1.2 < 3.0.0" - }, "engines": { - "node": ">=0.10.0" + "node": ">=20.0.0" } }, "node_modules/ignore": { @@ -5068,14 +4953,6 @@ "node": ">=0.10.0" } }, - "node_modules/is-potential-custom-element-name": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/is-potential-custom-element-name/-/is-potential-custom-element-name-1.0.1.tgz", - "integrity": "sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ==", - "dev": true, - "license": "MIT", - "optional": true - }, "node_modules/is-regex": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/is-regex/-/is-regex-1.2.1.tgz", @@ -5713,6 +5590,15 @@ "yallist": "^3.0.2" } }, + "node_modules/lucide-react": { + "version": "0.563.0", + "resolved": "https://registry.npmjs.org/lucide-react/-/lucide-react-0.563.0.tgz", + "integrity": "sha512-8dXPB2GI4dI8jV4MgUDGBeLdGk8ekfqVZ0BdLcrRzocGgG75ltNEmWS+gE7uokKF/0oSUuczNDT+g9hFJ23FkA==", + "license": "ISC", + "peerDependencies": { + "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, "node_modules/magic-string": { "version": "0.30.19", "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.19.tgz", @@ -5874,14 +5760,6 @@ "node": ">=0.10.0" } }, - "node_modules/nwsapi": { - "version": "2.2.22", - "resolved": "https://registry.npmjs.org/nwsapi/-/nwsapi-2.2.22.tgz", - "integrity": "sha512-ujSMe1OWVn55euT1ihwCI1ZcAaAU3nxUiDwfDQldc51ZXaB9m2AyOn6/jh1BLe2t/G8xd6uKG1UBF2aZJeg2SQ==", - "dev": true, - "license": "MIT", - "optional": true - }, "node_modules/object-assign": { "version": "4.1.1", "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", @@ -6100,20 +5978,6 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/parse5": { - "version": "7.3.0", - "resolved": "https://registry.npmjs.org/parse5/-/parse5-7.3.0.tgz", - "integrity": "sha512-IInvU7fabl34qmi9gY8XOVxhYyMyuH2xUNpb2q8/Y+7552KlejkRvqvD19nMoUW/uQGGbqNpA6Tufu5FL5BZgw==", - "dev": true, - "license": "MIT", - "optional": true, - "dependencies": { - "entities": "^6.0.0" - }, - "funding": { - "url": "https://github.com/inikulin/parse5?sponsor=1" - } - }, "node_modules/path": { "version": "0.12.7", "resolved": "https://registry.npmjs.org/path/-/path-0.12.7.tgz", @@ -6730,14 +6594,6 @@ "fsevents": "~2.3.2" } }, - "node_modules/rrweb-cssom": { - "version": "0.8.0", - "resolved": "https://registry.npmjs.org/rrweb-cssom/-/rrweb-cssom-0.8.0.tgz", - "integrity": "sha512-guoltQEx+9aMf2gDZ0s62EcV8lsXR+0w8915TC3ITdn2YueuNjdAYh/levpU9nFaoChh9RUS5ZdQMrKfVEN9tw==", - "dev": true, - "license": "MIT", - "optional": true - }, "node_modules/run-parallel": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/run-parallel/-/run-parallel-1.2.0.tgz", @@ -6817,28 +6673,6 @@ "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/safer-buffer": { - "version": "2.1.2", - "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", - "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", - "dev": true, - "license": "MIT", - "optional": true - }, - "node_modules/saxes": { - "version": "6.0.0", - "resolved": "https://registry.npmjs.org/saxes/-/saxes-6.0.0.tgz", - "integrity": "sha512-xAg7SOnEhrm5zI3puOOKyy1OMcMlIJZYNJY7xLBwSze0UjhPLnWfj2GF2EpT0jmzaJKIWKHLsaSSajf35bcYnA==", - "dev": true, - "license": "ISC", - "optional": true, - "dependencies": { - "xmlchars": "^2.2.0" - }, - "engines": { - "node": ">=v12.22.7" - } - }, "node_modules/scheduler": { "version": "0.27.0", "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.27.0.tgz", @@ -7488,14 +7322,6 @@ "integrity": "sha512-ovssysQTa+luh7A5Weu3Rta6FJlFBBbInjOh722LIt6klpU2/HtdUbszju/G4devcvk8PGt7FCLv5wftu3THUA==", "dev": true }, - "node_modules/symbol-tree": { - "version": "3.2.4", - "resolved": "https://registry.npmjs.org/symbol-tree/-/symbol-tree-3.2.4.tgz", - "integrity": "sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw==", - "dev": true, - "license": "MIT", - "optional": true - }, "node_modules/synckit": { "version": "0.11.11", "resolved": "https://registry.npmjs.org/synckit/-/synckit-0.11.11.tgz", @@ -7729,28 +7555,6 @@ "node": ">=14.0.0" } }, - "node_modules/tldts": { - "version": "6.1.86", - "resolved": "https://registry.npmjs.org/tldts/-/tldts-6.1.86.tgz", - "integrity": "sha512-WMi/OQ2axVTf/ykqCQgXiIct+mSQDFdH2fkwhPwgEwvJ1kSzZRiinb0zF2Xb8u4+OqPChmyI6MEu4EezNJz+FQ==", - "dev": true, - "license": "MIT", - "optional": true, - "dependencies": { - "tldts-core": "^6.1.86" - }, - "bin": { - "tldts": "bin/cli.js" - } - }, - "node_modules/tldts-core": { - "version": "6.1.86", - "resolved": "https://registry.npmjs.org/tldts-core/-/tldts-core-6.1.86.tgz", - "integrity": "sha512-Je6p7pkk+KMzMv2XXKmAE3McmolOQFdxkKw0R8EYNr7sELW46JqnNeTX8ybPiQgvg1ymCoF8LXs5fzFaZvJPTA==", - "dev": true, - "license": "MIT", - "optional": true - }, "node_modules/to-regex-range": { "version": "5.0.1", "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz", @@ -7764,34 +7568,6 @@ "node": ">=8.0" } }, - "node_modules/tough-cookie": { - "version": "5.1.2", - "resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-5.1.2.tgz", - "integrity": "sha512-FVDYdxtnj0G6Qm/DhNPSb8Ju59ULcup3tuJxkFb5K8Bv2pUXILbf0xZWU8PX8Ov19OXljbUyveOFwRMwkXzO+A==", - "dev": true, - "license": "BSD-3-Clause", - "optional": true, - "dependencies": { - "tldts": "^6.1.32" - }, - "engines": { - "node": ">=16" - } - }, - "node_modules/tr46": { - "version": "5.1.1", - "resolved": "https://registry.npmjs.org/tr46/-/tr46-5.1.1.tgz", - "integrity": "sha512-hdF5ZgjTqgAntKkklYw0R03MG2x/bSzTtkxmIRw/sTNV8YXsCJ1tfLAX23lhxhHJlEf3CRCOCGGWw3vI3GaSPw==", - "dev": true, - "license": "MIT", - "optional": true, - "dependencies": { - "punycode": "^2.3.1" - }, - "engines": { - "node": ">=18" - } - }, "node_modules/trim-repeated": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/trim-repeated/-/trim-repeated-1.0.0.tgz", @@ -7828,6 +7604,12 @@ "typescript": ">=4.8.4" } }, + "node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "license": "0BSD" + }, "node_modules/type-check": { "version": "0.4.0", "resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz", @@ -7981,7 +7763,6 @@ "version": "7.14.0", "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.14.0.tgz", "integrity": "sha512-QQiYxHuyZ9gQUIrmPo3IA+hUl4KYk8uSA7cHrcKd/l3p1OTpZcM0Tbp9x7FAtXdAYhlasd60ncPpgu6ihG6TOA==", - "dev": true, "license": "MIT" }, "node_modules/universalify": { @@ -8269,71 +8050,6 @@ "url": "https://github.com/sponsors/jonschlinkert" } }, - "node_modules/w3c-xmlserializer": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/w3c-xmlserializer/-/w3c-xmlserializer-5.0.0.tgz", - "integrity": "sha512-o8qghlI8NZHU1lLPrpi2+Uq7abh4GGPpYANlalzWxyWteJOCsr/P+oPBA49TOLu5FTZO4d3F9MnWJfiMo4BkmA==", - "dev": true, - "license": "MIT", - "optional": true, - "dependencies": { - "xml-name-validator": "^5.0.0" - }, - "engines": { - "node": ">=18" - } - }, - "node_modules/webidl-conversions": { - "version": "7.0.0", - "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-7.0.0.tgz", - "integrity": "sha512-VwddBukDzu71offAQR975unBIGqfKZpM+8ZX6ySk8nYhVoo5CYaZyzt3YBvYtRtO+aoGlqxPg/B87NGVZ/fu6g==", - "dev": true, - "license": "BSD-2-Clause", - "optional": true, - "engines": { - "node": ">=12" - } - }, - "node_modules/whatwg-encoding": { - "version": "3.1.1", - "resolved": "https://registry.npmjs.org/whatwg-encoding/-/whatwg-encoding-3.1.1.tgz", - "integrity": "sha512-6qN4hJdMwfYBtE3YBTTHhoeuUrDBPZmbQaxWAqSALV/MeEnR5z1xd8UKud2RAkFoPkmB+hli1TZSnyi84xz1vQ==", - "dev": true, - "license": "MIT", - "optional": true, - "dependencies": { - "iconv-lite": "0.6.3" - }, - "engines": { - "node": ">=18" - } - }, - "node_modules/whatwg-mimetype": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/whatwg-mimetype/-/whatwg-mimetype-4.0.0.tgz", - "integrity": "sha512-QaKxh0eNIi2mE9p2vEdzfagOKHCcj1pJ56EEHGQOVxp8r9/iszLUUV7v89x9O1p/T+NlTM5W7jW6+cz4Fq1YVg==", - "dev": true, - "license": "MIT", - "optional": true, - "engines": { - "node": ">=18" - } - }, - "node_modules/whatwg-url": { - "version": "14.2.0", - "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-14.2.0.tgz", - "integrity": "sha512-De72GdQZzNTUBBChsXueQUnPKDkg/5A5zp7pFDuQAj5UFoENpiACU0wlCvzpAGnTkj++ihpKwKyYewn/XNUbKw==", - "dev": true, - "license": "MIT", - "optional": true, - "dependencies": { - "tr46": "^5.1.0", - "webidl-conversions": "^7.0.0" - }, - "engines": { - "node": ">=18" - } - }, "node_modules/which": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", @@ -8484,9 +8200,7 @@ "version": "8.18.3", "resolved": "https://registry.npmjs.org/ws/-/ws-8.18.3.tgz", "integrity": "sha512-PEIGCY5tSlUt50cqyMXfCzX+oOPqN0vuGqWzbcJ2xvnkzkq46oOpz7dQaTDBdfICb4N14+GARUDw2XV2N4tvzg==", - "dev": true, "license": "MIT", - "optional": true, "engines": { "node": ">=10.0.0" }, @@ -8503,25 +8217,6 @@ } } }, - "node_modules/xml-name-validator": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/xml-name-validator/-/xml-name-validator-5.0.0.tgz", - "integrity": "sha512-EvGK8EJ3DhaHfbRlETOWAS5pO9MZITeauHKJyb8wyajUfQUenkIg2MvLDTZ4T/TgIcm3HU0TFBgWWboAZ30UHg==", - "dev": true, - "license": "Apache-2.0", - "optional": true, - "engines": { - "node": ">=18" - } - }, - "node_modules/xmlchars": { - "version": "2.2.0", - "resolved": "https://registry.npmjs.org/xmlchars/-/xmlchars-2.2.0.tgz", - "integrity": "sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==", - "dev": true, - "license": "MIT", - "optional": true - }, "node_modules/yallist": { "version": "3.1.1", "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz", diff --git a/package.json b/package.json index dee9564..464a93c 100644 --- a/package.json +++ b/package.json @@ -30,8 +30,10 @@ }, "dependencies": { "@formkit/auto-animate": "^0.8.2", + "@supabase/supabase-js": "^2.94.1", "canvas-confetti": "^1.9.3", "husky": "^9.1.7", + "lucide-react": "^0.563.0", "react": "^19.1.1", "react-dom": "^19.1.1" }, diff --git a/src/app/components/Board.tsx b/src/app/components/Board.tsx deleted file mode 100644 index 2863926..0000000 --- a/src/app/components/Board.tsx +++ /dev/null @@ -1,72 +0,0 @@ -// src/app/components/Board.tsx -import type { XY, Food } from '@/types'; -import type { GameTuning } from '@/constants/game'; - -type Props = { - snake: XY[]; - food: Food | null; // supports emoji if present - T: GameTuning; // pass DIFFICULTY_PRESETS[difficulty] - onCellClick?: (p: XY) => void; -}; - -export default function Board({ snake, food, T, onCellClick }: Props) { - const isSnake = (x: number, y: number) => - snake.some((p) => p.x === x && p.y === y); - const isFood = (x: number, y: number) => - !!food && food.x === x && food.y === y; - - return ( -
- {Array.from({ length: T.ROWS * T.COLS }, (_, i) => { - const x = i % T.COLS; - const y = Math.floor(i / T.COLS); - const s = isSnake(x, y); - const f = isFood(x, y); - - return ( -
onCellClick?.({ x, y })} - style={{ - position: 'absolute', - left: x * T.CELL, - top: y * T.CELL, - width: T.CELL, - height: T.CELL, - boxSizing: 'border-box', - border: '1px solid #222', - background: s ? '#4caf50' : f ? '#111' : '#111', - display: 'flex', - alignItems: 'center', - justifyContent: 'center', - fontSize: Math.floor(T.CELL * 0.75), - lineHeight: 1, - userSelect: 'none', - }} - > - {f ? ( - food?.emoji ? ( - food.emoji - ) : ( - // fallback red square if no emoji -
- ) - ) : null} -
- ); - })} -
- ); -} diff --git a/src/app/components/CountdownOverlay.tsx b/src/app/components/CountdownOverlay.tsx new file mode 100644 index 0000000..6723b19 --- /dev/null +++ b/src/app/components/CountdownOverlay.tsx @@ -0,0 +1,40 @@ +import { useState, useEffect } from 'react'; + +type Props = { + visible: boolean; + onComplete: () => void; +}; + +const STEPS = ['3', '2', '1', 'Go!']; +const STEP_MS = 800; + +export default function CountdownOverlay({ visible, onComplete }: Props) { + const [step, setStep] = useState(0); + + useEffect(() => { + if (!visible) { + setStep(0); + return; + } + if (step >= STEPS.length) { + onComplete(); + return; + } + const id = setTimeout(() => setStep((s) => s + 1), STEP_MS); + return () => clearTimeout(id); + }, [visible, step, onComplete]); + + if (!visible || step >= STEPS.length) return null; + + return ( +
+ + {STEPS[step]} + +
+ ); +} diff --git a/src/app/components/GameOverOverlay.tsx b/src/app/components/GameOverOverlay.tsx new file mode 100644 index 0000000..16fb2b4 --- /dev/null +++ b/src/app/components/GameOverOverlay.tsx @@ -0,0 +1,60 @@ +import { useEffect, useRef } from 'react'; +import confetti from 'canvas-confetti'; + +type Props = { + score: number; + best: number; + isNewBest: boolean; + playTimeMs: number; + snakeLength: number; + onRestart: () => void; +}; + +function formatTime(ms: number) { + const s = Math.floor(ms / 1000); + const m = Math.floor(s / 60); + const sec = s % 60; + return m > 0 ? `${m}:${sec.toString().padStart(2, '0')}` : `${s}s`; +} + +export default function GameOverOverlay({ + score, + best, + isNewBest, + playTimeMs, + snakeLength, + onRestart, +}: Props) { + const firedRef = useRef(false); + useEffect(() => { + if (isNewBest && !firedRef.current) { + firedRef.current = true; + confetti({ particleCount: 80, spread: 70, origin: { y: 0.6 } }); + } + }, [isNewBest]); + + return ( +
+

Game Over

+ {isNewBest && ( +

+ New high score! +

+ )} +
+

+ Score: {score} +

+

Best: {best}

+

Length: {snakeLength}

+

Time: {formatTime(playTimeMs)}

+
+ +
+ ); +} diff --git a/src/app/components/HowToPlayModal.tsx b/src/app/components/HowToPlayModal.tsx new file mode 100644 index 0000000..ae74667 --- /dev/null +++ b/src/app/components/HowToPlayModal.tsx @@ -0,0 +1,79 @@ +import { X } from 'lucide-react'; + +type Props = { + isOpen: boolean; + onClose: () => void; +}; + +export default function HowToPlayModal({ isOpen, onClose }: Props) { + if (!isOpen) return null; + + return ( +
+
e.stopPropagation()} + > +
+

+ How to Play +

+ +
+ +
+

+ Guide the snake to eat food and grow. Don't hit walls, + obstacles, or yourself! +

+
+

Controls

+
    +
  • Arrow keys or WASD
  • +
  • Swipe on mobile
  • +
  • Tap the D-pad on mobile
  • +
  • P to pause
  • +
+
+
+

Power-ups

+
    +
  • + ⭐ Golden β€” +5 points +
  • +
  • + ❄️ Freeze β€” Pause timer + briefly +
  • +
  • + πŸ‘» Ghost β€” Pass through + yourself once +
  • +
  • + πŸ”₯ Multiplier β€” +3 + points +
  • +
+
+
+

Difficulty

+

+ Choose Relaxed for wrap-around edges, or Classic/Expert for + obstacles. Blitz starts fast! +

+
+
+
+
+ ); +} diff --git a/src/app/components/Leaderboard.tsx b/src/app/components/Leaderboard.tsx deleted file mode 100644 index 37ad13f..0000000 --- a/src/app/components/Leaderboard.tsx +++ /dev/null @@ -1,43 +0,0 @@ -import type { LeaderboardEntry } from '@/hooks/useLeaderboard'; -import { DIFFICULTY_PRESETS } from '@/constants/game'; -import type { GameDifficulty } from '@/constants/game'; - -type Props = { - entries: LeaderboardEntry[]; -}; - -function formatDate(ts: number) { - const d = new Date(ts); - return d.toLocaleDateString(undefined, { - month: 'short', - day: 'numeric', - }); -} - -export default function Leaderboard({ entries }: Props) { - if (entries.length === 0) return null; - - return ( -
-

- Top scores -

- -
- ); -} diff --git a/src/app/components/LeaderboardModal.tsx b/src/app/components/LeaderboardModal.tsx new file mode 100644 index 0000000..0200597 --- /dev/null +++ b/src/app/components/LeaderboardModal.tsx @@ -0,0 +1,125 @@ +import { useState, useEffect } from 'react'; +import type { GameDifficulty } from '@/constants/game'; +import { DIFFICULTY_PRESETS } from '@/constants/game'; +import { + fetchLeaderboard, + type LeaderboardScore, +} from '@/services/leaderboardService'; +import { X } from 'lucide-react'; + +const DIFFICULTIES: GameDifficulty[] = [ + 'relaxed', + 'classic', + 'expert', + 'blitz', + 'endless', +]; + +type Props = { + isOpen: boolean; + onClose: () => void; +}; + +function formatDate(iso: string) { + try { + const d = new Date(iso); + return d.toLocaleDateString(undefined, { month: 'short', day: 'numeric' }); + } catch { + return ''; + } +} + +export default function LeaderboardModal({ isOpen, onClose }: Props) { + const [difficulty, setDifficulty] = useState('classic'); + const [scores, setScores] = useState([]); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(null); + + useEffect(() => { + if (!isOpen) return; + setLoading(true); + setError(null); + fetchLeaderboard(difficulty) + .then(setScores) + .catch(() => setError('Failed to load leaderboard')) + .finally(() => setLoading(false)); + }, [isOpen, difficulty]); + + if (!isOpen) return null; + + return ( +
+
e.stopPropagation()} + > +
+

+ Leaderboard +

+ +
+ +
+ {DIFFICULTIES.map((d) => ( + + ))} +
+ +
+ {loading && ( +

Loading...

+ )} + {error &&

{error}

} + {!loading && !error && scores.length === 0 && ( +

+ No scores yet. Be the first! +

+ )} + {!loading && !error && scores.length > 0 && ( +
    + {scores.map((s) => ( +
  • + #{s.rank} + + {s.score} + + + {s.playerName} + + + {formatDate(s.createdAt)} + +
  • + ))} +
+ )} +
+
+
+ ); +} diff --git a/src/app/components/MenuScreen.tsx b/src/app/components/MenuScreen.tsx new file mode 100644 index 0000000..c5af425 --- /dev/null +++ b/src/app/components/MenuScreen.tsx @@ -0,0 +1,111 @@ +import { useState, useEffect } from 'react'; +import type { GameDifficulty } from '@/constants/game'; +import { DIFFICULTY_PRESETS } from '@/constants/game'; +import { Play, Trophy, Settings, HelpCircle } from 'lucide-react'; +import LeaderboardModal from './LeaderboardModal'; +import SettingsModal from './SettingsModal'; +import HowToPlayModal from './HowToPlayModal'; + +const DIFFICULTIES: GameDifficulty[] = [ + 'relaxed', + 'classic', + 'expert', + 'blitz', + 'endless', +]; + +type Props = { + difficulty: GameDifficulty; + onDifficultyChange: (d: GameDifficulty) => void; + onStart: () => void; + muted: boolean; + onMutedChange: (v: boolean) => void; + playerName: string; + onPlayerNameChange: (v: string) => void; +}; + +export default function MenuScreen({ + difficulty, + onDifficultyChange, + onStart, + muted, + onMutedChange, + playerName, + onPlayerNameChange, +}: Props) { + const [showLeaderboard, setShowLeaderboard] = useState(false); + const [showSettings, setShowSettings] = useState(false); + const [showHowToPlay, setShowHowToPlay] = useState(false); + + const T = DIFFICULTY_PRESETS[difficulty]; + + const btnBase = + 'flex flex-col items-center justify-center gap-2 rounded-xl border border-zinc-600 bg-zinc-800/80 px-4 py-5 text-white transition hover:bg-zinc-700 active:bg-zinc-600 min-h-[100px]'; + + return ( +
+

Snake

+ +
+ + +

{T.description}

+
+ +
+ + + + + + + +
+ + setShowLeaderboard(false)} + /> + setShowSettings(false)} + muted={muted} + onMutedChange={onMutedChange} + playerName={playerName} + onPlayerNameChange={onPlayerNameChange} + /> + setShowHowToPlay(false)} + /> +
+ ); +} diff --git a/src/app/components/MobileControls.tsx b/src/app/components/MobileControls.tsx new file mode 100644 index 0000000..dcd3371 --- /dev/null +++ b/src/app/components/MobileControls.tsx @@ -0,0 +1,90 @@ +import type { Dir } from '@/types'; + +type Props = { + visible: boolean; + onDirection: (dir: Dir) => void; +}; + +const BTN_CLASS = + 'flex items-center justify-center rounded-xl bg-zinc-700/95 text-white active:bg-emerald-500 transition-colors select-none touch-manipulation min-w-[56px] min-h-[56px]'; + +export default function MobileControls({ visible, onDirection }: Props) { + if (!visible) return null; + + return ( +
+
+
+ +
+ +
+ βŒ‚ +
+ +
+ +
+
+
+ ); +} diff --git a/src/app/components/SettingsModal.tsx b/src/app/components/SettingsModal.tsx new file mode 100644 index 0000000..16e0694 --- /dev/null +++ b/src/app/components/SettingsModal.tsx @@ -0,0 +1,82 @@ +import { X } from 'lucide-react'; + +type Props = { + isOpen: boolean; + onClose: () => void; + muted: boolean; + onMutedChange: (v: boolean) => void; + playerName: string; + onPlayerNameChange: (v: string) => void; +}; + +export default function SettingsModal({ + isOpen, + onClose, + muted, + onMutedChange, + playerName, + onPlayerNameChange, +}: Props) { + if (!isOpen) return null; + + return ( +
+
e.stopPropagation()} + > +
+

+ Settings +

+ +
+ +
+ + +
+ + onPlayerNameChange(e.target.value)} + placeholder='Anonymous' + className='w-full rounded-lg border border-zinc-600 bg-zinc-800 px-3 py-2 text-white placeholder-zinc-500' + maxLength={20} + /> +
+
+
+
+ ); +} diff --git a/src/app/components/SnakeCanvas.tsx b/src/app/components/SnakeCanvas.tsx index 826f916..7a2103f 100644 --- a/src/app/components/SnakeCanvas.tsx +++ b/src/app/components/SnakeCanvas.tsx @@ -1,8 +1,10 @@ import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; -import { DIFFICULTY_PRESETS, type GameDifficulty } from '@/constants/game'; -import type { XY, Dir } from '@/types'; -import { inferDirFromSnake } from '@/utils/logic'; +import confetti from 'canvas-confetti'; +import { type GameDifficulty } from '@/constants/game'; +import type { Dir } from '@/types'; +import { inferDirFromSnake, isOpposite } from '@/utils/logic'; import { drawFrame } from '@/utils/canvas'; +import { computeDelayMs } from '@/utils/speed'; import { useTicker } from '@/hooks/useTicker'; import { useSnakeGame } from '@/hooks/useSnakeGame'; import { useInput } from '@/hooks/useInput'; @@ -11,44 +13,43 @@ import { useBestScore } from '@/hooks/useBestScore'; import { usePauseHotkey } from '@/hooks/usePauseHotkey'; import { useSwipe } from '@/hooks/useSwipe'; import { useLeaderboard } from '@/hooks/useLeaderboard'; +import { useGameSetup } from '@/hooks/useGameSetup'; +import { useGameScale } from '@/hooks/useGameScale'; +import { useSettings } from '@/hooks/useSettings'; +import MenuScreen from '@/components/MenuScreen'; import HUD from '@/components/HUD'; -import Leaderboard from '@/components/Leaderboard'; -import { isOpposite } from '@/utils/logic'; - -type Phase = 'menu' | 'playing' | 'gameover'; - -const DIFFICULTIES: GameDifficulty[] = [ - 'relaxed', - 'classic', - 'expert', - 'blitz', - 'endless', -]; - -function computeDelayMs( - T: (typeof DIFFICULTY_PRESETS)[GameDifficulty], - score: number -): number { - const linear = T.TICK_START_MS - score * T.TICK_STEP_MS; - const curve = T.speedCurve; - let delay = linear; - if (curve === 'ease-in') { - delay = T.TICK_START_MS - score * score * 0.08; - } else if (curve === 'ease-out') { - delay = T.TICK_START_MS - score * T.TICK_STEP_MS * 0.85; - } - return Math.max(T.TICK_MIN_MS, delay); -} +import MobileControls from '@/components/MobileControls'; +import CountdownOverlay from '@/components/CountdownOverlay'; +import GameOverOverlay from '@/components/GameOverOverlay'; export default function SnakeCanvas() { const { canvasRef, ctxRef } = useCanvas2D(); + const { + muted, + setMuted, + playerName, + setPlayerName, + persistDifficulty, + initialDifficulty, + } = useSettings(); + + const { + phase, + setPhase, + difficulty, + setDifficulty, + paused, + setPaused, + bump, + triggerBump, + tuning: T, + sounds: { playEat, playDie, playMove }, + } = useGameSetup({ muted, initialDifficulty }); - const [bump, setBump] = useState(false); - const [paused, setPaused] = useState(false); - const [phase, setPhase] = useState('menu'); - const [difficulty, setDifficulty] = useState('classic'); + useEffect(() => { + persistDifficulty(difficulty); + }, [difficulty, persistDifficulty]); - const T = DIFFICULTY_PRESETS[difficulty]; const gameConfig = useMemo( () => ({ cols: T.COLS, @@ -60,34 +61,18 @@ export default function SnakeCanvas() { [T] ); - const eatSnd = useMemo(() => new Audio('/sounds/food.mp3'), []); - const dieSnd = useMemo(() => new Audio('/sounds/gameover.mp3'), []); - const keySnd = useMemo(() => new Audio('/sounds/move.mp3'), []); - - useEffect(() => { - eatSnd.volume = 0.7; - dieSnd.volume = 0.9; - keySnd.volume = 0.4; - }, [eatSnd, dieSnd, keySnd]); - - const play = useCallback((a: HTMLAudioElement) => { - try { - a.currentTime = 0; - void a.play(); - } catch (err) { - console.error('Audio play exception:', err); - } - }, []); - const { alive, score, snakeRef, foodRef, obstaclesRef, reset, turn, tick } = useSnakeGame(gameConfig, { - onEat: () => play(eatSnd), - onDie: () => play(dieSnd), + onEat: (value, kind) => { + playEat(); + confetti({ particleCount: 12, spread: 60, origin: { y: 0.5 } }); + }, + onDie: playDie, }); useEffect(() => { if (!alive && phase === 'playing') setPhase('gameover'); - }, [alive, phase]); + }, [alive, phase, setPhase]); const getCurrentDir = useCallback<() => Dir>( () => inferDirFromSnake(snakeRef.current), @@ -107,22 +92,19 @@ export default function SnakeCanvas() { ); }, [alive, ctxRef, foodRef, snakeRef, obstaclesRef, T]); - useEffect(() => { - setBump(true); - const id = setTimeout(() => setBump(false), 200); - return () => clearTimeout(id); - }, [score]); + useEffect(triggerBump, [score, triggerBump]); const best = useBestScore(score); - const { entries, submitScore } = useLeaderboard(); + const { submitScore } = useLeaderboard(); const submittedRef = useRef(false); + const gameStartTimeRef = useRef(0); useEffect(() => { if (!alive && phase === 'gameover' && score > 0 && !submittedRef.current) { submittedRef.current = true; - submitScore(score, difficulty); + submitScore(score, difficulty, playerName || undefined); } - }, [alive, phase, score, difficulty, submitScore]); + }, [alive, phase, score, difficulty, playerName, submitScore]); useEffect(() => { if (phase === 'playing') submittedRef.current = false; @@ -141,7 +123,7 @@ export default function SnakeCanvas() { // eslint-disable-next-line react-hooks/exhaustive-deps }, []); - const delayMs = computeDelayMs(T, score); + const delayMs = computeDelayMs(difficulty, score); useTicker( delayMs, @@ -158,12 +140,17 @@ export default function SnakeCanvas() { setPaused(false); reset(); draw(); + gameStartTimeRef.current = Date.now(); + setPhase('countdown'); + }, [reset, draw, setPaused, setPhase]); + + const startPlaying = useCallback(() => { setPhase('playing'); - }, [reset, draw]); + }, [setPhase]); useEffect(() => { const onKey = (e: KeyboardEvent) => { - if (phase !== 'playing' && e.code === 'Space') { + if ((phase === 'menu' || phase === 'gameover') && e.code === 'Space') { e.preventDefault(); startGame(); } @@ -172,98 +159,119 @@ export default function SnakeCanvas() { return () => window.removeEventListener('keydown', onKey); }, [phase, startGame]); - useInput({ - alive, - getCurrentDir, - onTurn: turn, - onRestart: startGame, - onMoveKey: () => play(keySnd), - }); - - const handleSwipe = useCallback( + const handleTurn = useCallback( (d: Dir) => { const cur = getCurrentDir(); if (!isOpposite(cur, d)) { turn(d); - play(keySnd); + playMove(); } }, - [getCurrentDir, turn, keySnd, play] + [getCurrentDir, turn, playMove] ); + useInput({ + alive, + getCurrentDir, + onTurn: turn, + onRestart: startGame, + onMoveKey: playMove, + }); + useSwipe({ enabled: phase === 'playing' && alive && !paused, - onSwipe: handleSwipe, + onSwipe: handleTurn, }); usePauseHotkey(alive && phase === 'playing', () => setPaused((p) => !p)); + const gameWidth = T.COLS * T.CELL; + const gameHeight = T.ROWS * T.CELL; + const scale = useGameScale(gameWidth, gameHeight); + + const isNewBest = !alive && score > 0 && score >= best; + return ( -
- {phase !== 'playing' && ( -
-

- Snake -

-
- - -
-

- {T.description} -

- -
+
+ {(phase === 'menu' || phase === 'gameover') && ( + )}
- - {paused && phase === 'playing' && ( -
- Paused (P) -
- )} +
+ + {phase === 'countdown' && ( + + )} + {paused && phase === 'playing' && ( +
+ Paused (P) +
+ )} + {!alive && phase === 'gameover' && ( + + )} +
- + {(phase === 'playing' || phase === 'countdown') && ( + + )} -
- {(1000 / delayMs).toFixed(1)} moves/s Β· Arrows / WASD / Swipe Β· P pause -
+ {(phase === 'playing' || phase === 'countdown') && ( +
+ {(1000 / delayMs).toFixed(1)} moves/s Β· Arrows / WASD / Swipe / Tap Β· + P pause +
+ )} + +
); } diff --git a/src/app/hooks/useGameScale.ts b/src/app/hooks/useGameScale.ts new file mode 100644 index 0000000..dc03dbe --- /dev/null +++ b/src/app/hooks/useGameScale.ts @@ -0,0 +1,23 @@ +import { useEffect, useState } from 'react'; + +export function useGameScale(gameWidth: number, gameHeight: number) { + const [scale, setScale] = useState(1); + + useEffect(() => { + const update = () => { + const padding = 24; + const controlsHeight = 180; + const maxW = window.innerWidth - padding * 2; + const maxH = window.innerHeight - controlsHeight - padding * 2; + const scaleW = maxW / gameWidth; + const scaleH = maxH / gameHeight; + setScale(Math.min(1, scaleW, scaleH)); + }; + + update(); + window.addEventListener('resize', update); + return () => window.removeEventListener('resize', update); + }, [gameWidth, gameHeight]); + + return scale; +} diff --git a/src/app/hooks/useGameSetup.ts b/src/app/hooks/useGameSetup.ts new file mode 100644 index 0000000..1c165c1 --- /dev/null +++ b/src/app/hooks/useGameSetup.ts @@ -0,0 +1,63 @@ +import { useCallback, useEffect, useMemo, useState } from 'react'; +import { DIFFICULTY_PRESETS, type GameDifficulty } from '@/constants/game'; + +export type Phase = 'menu' | 'countdown' | 'playing' | 'gameover'; + +export function useGameSetup(opts?: { + muted?: boolean; + initialDifficulty?: GameDifficulty; +}) { + const muted = opts?.muted ?? false; + const [phase, setPhase] = useState('menu'); + const [difficulty, setDifficulty] = useState( + opts?.initialDifficulty ?? 'classic' + ); + const [paused, setPaused] = useState(false); + const [bump, setBump] = useState(false); + + const eatSnd = useMemo(() => new Audio('/sounds/food.mp3'), []); + const dieSnd = useMemo(() => new Audio('/sounds/gameover.mp3'), []); + const keySnd = useMemo(() => new Audio('/sounds/move.mp3'), []); + + useEffect(() => { + eatSnd.volume = 0.7; + dieSnd.volume = 0.9; + keySnd.volume = 0.4; + }, [eatSnd, dieSnd, keySnd]); + + const play = useCallback( + (a: HTMLAudioElement) => { + if (muted) return; + try { + a.currentTime = 0; + void a.play(); + } catch (err) { + console.error('Audio play exception:', err); + } + }, + [muted] + ); + + const playEat = useCallback(() => play(eatSnd), [play, eatSnd]); + const playDie = useCallback(() => play(dieSnd), [play, dieSnd]); + const playMove = useCallback(() => play(keySnd), [play, keySnd]); + + const triggerBump = useCallback(() => { + setBump(true); + const id = setTimeout(() => setBump(false), 200); + return () => clearTimeout(id); + }, []); + + return { + phase, + setPhase, + difficulty, + setDifficulty, + paused, + setPaused, + bump, + triggerBump, + tuning: DIFFICULTY_PRESETS[difficulty], + sounds: { playEat, playDie, playMove }, + }; +} diff --git a/src/app/hooks/useLeaderboard.ts b/src/app/hooks/useLeaderboard.ts index 23ffe8a..f96082b 100644 --- a/src/app/hooks/useLeaderboard.ts +++ b/src/app/hooks/useLeaderboard.ts @@ -1,7 +1,9 @@ -import { useCallback, useState } from 'react'; +import { useCallback, useEffect, useState } from 'react'; import type { GameDifficulty } from '@/constants/game'; +import { submitScore as submitToSupabase } from '@/services/leaderboardService'; +import { isSupabaseConfigured } from '@/supabase/client'; -export type LeaderboardEntry = { +export type LocalLeaderboardEntry = { score: number; difficulty: GameDifficulty; date: number; @@ -10,18 +12,18 @@ export type LeaderboardEntry = { const STORAGE_KEY = 'snake-leaderboard'; const MAX_ENTRIES = 10; -function loadLeaderboard(): LeaderboardEntry[] { +function loadLocalLeaderboard(): LocalLeaderboardEntry[] { try { const raw = localStorage.getItem(STORAGE_KEY); if (!raw) return []; - const parsed = JSON.parse(raw) as LeaderboardEntry[]; + const parsed = JSON.parse(raw) as LocalLeaderboardEntry[]; return Array.isArray(parsed) ? parsed : []; } catch { return []; } } -function saveLeaderboard(entries: LeaderboardEntry[]) { +function saveLocalLeaderboard(entries: LocalLeaderboardEntry[]) { try { localStorage.setItem(STORAGE_KEY, JSON.stringify(entries)); } catch { @@ -30,21 +32,25 @@ function saveLeaderboard(entries: LeaderboardEntry[]) { } export function useLeaderboard() { - const [entries, setEntries] = useState(loadLeaderboard); + const [localEntries, setLocalEntries] = + useState(loadLocalLeaderboard); const submitScore = useCallback( - (score: number, difficulty: GameDifficulty) => { - const next: LeaderboardEntry[] = [ - ...entries, + (score: number, difficulty: GameDifficulty, playerName?: string) => { + if (isSupabaseConfigured()) { + void submitToSupabase(score, difficulty, playerName); + } + const next: LocalLeaderboardEntry[] = [ + ...localEntries, { score, difficulty, date: Date.now() }, ] .sort((a, b) => b.score - a.score) .slice(0, MAX_ENTRIES); - setEntries(next); - saveLeaderboard(next); + setLocalEntries(next); + saveLocalLeaderboard(next); }, - [entries] + [localEntries] ); - return { entries, submitScore }; + return { localEntries, submitScore }; } diff --git a/src/app/hooks/useSettings.ts b/src/app/hooks/useSettings.ts new file mode 100644 index 0000000..ce9e98f --- /dev/null +++ b/src/app/hooks/useSettings.ts @@ -0,0 +1,79 @@ +import { useCallback, useEffect, useState } from 'react'; +import type { GameDifficulty } from '@/constants/game'; + +const MUTE_KEY = 'snake-muted'; +const DIFFICULTY_KEY = 'snake-difficulty'; +const PLAYER_NAME_KEY = 'snake-player-name'; + +function loadMuted(): boolean { + try { + return localStorage.getItem(MUTE_KEY) === 'true'; + } catch { + return false; + } +} + +function loadDifficulty(): GameDifficulty { + try { + const v = localStorage.getItem(DIFFICULTY_KEY); + if ( + v === 'relaxed' || + v === 'classic' || + v === 'expert' || + v === 'blitz' || + v === 'endless' + ) + return v; + } catch { + // ignore + } + return 'classic'; +} + +function loadPlayerName(): string { + try { + return localStorage.getItem(PLAYER_NAME_KEY) ?? ''; + } catch { + return ''; + } +} + +export function useSettings() { + const [muted, setMutedState] = useState(loadMuted); + const [playerName, setPlayerNameState] = useState(loadPlayerName); + + const setMuted = useCallback((v: boolean) => { + setMutedState(v); + try { + localStorage.setItem(MUTE_KEY, String(v)); + } catch { + // ignore + } + }, []); + + const setPlayerName = useCallback((v: string) => { + setPlayerNameState(v); + try { + localStorage.setItem(PLAYER_NAME_KEY, v); + } catch { + // ignore + } + }, []); + + const persistDifficulty = useCallback((d: GameDifficulty) => { + try { + localStorage.setItem(DIFFICULTY_KEY, d); + } catch { + // ignore + } + }, []); + + return { + muted, + setMuted, + playerName, + setPlayerName, + persistDifficulty, + initialDifficulty: loadDifficulty(), + }; +} diff --git a/src/app/hooks/useSnake.ts b/src/app/hooks/useSnake.ts deleted file mode 100644 index 704e1a1..0000000 --- a/src/app/hooks/useSnake.ts +++ /dev/null @@ -1,14 +0,0 @@ -import { useState, useCallback } from 'react'; -import type { XY, Dir } from '@/types'; - -export function useSnake(initial: XY[]) { - const [snake, setSnake] = useState(initial); - const [dir, setDir] = useState('right'); - - const turn = useCallback((d: Dir) => setDir(d), []); - const step = useCallback((nextHead: XY, grow = false) => { - setSnake((s) => (grow ? [nextHead, ...s] : [nextHead, ...s.slice(0, -1)])); - }, []); - - return { snake, dir, turn, step, setSnake, setDir }; -} diff --git a/src/app/hooks/useSnakeGame.ts b/src/app/hooks/useSnakeGame.ts index c30a488..b140d04 100644 --- a/src/app/hooks/useSnakeGame.ts +++ b/src/app/hooks/useSnakeGame.ts @@ -2,12 +2,16 @@ import { useCallback, useRef, useState } from 'react'; import type { XY, Dir, Food, FoodKind } from '@/types'; import { eq, - nextHead, initSnake, - wrapPoint, randomFreeCell, generateObstacles, } from '@/utils/logic'; +import { + computeNextHead, + isOutOfBounds, + checkCollisions, + willEatFood, +} from '@/utils/gameTick'; import { FOOD_EMOJIS, POWER_UP_CONFIG } from '@/constants/game'; export type GameConfig = { @@ -36,6 +40,8 @@ export function useSnakeGame( const snakeRef = useRef(initSnake(cols, rows)); const foodRef = useRef(null); const obstaclesRef = useRef([]); + const freezeUntilRef = useRef(0); + const ghostUntilRef = useRef(0); const [alive, setAlive] = useState(true); const [score, setScore] = useState(0); @@ -74,6 +80,8 @@ export function useSnakeGame( nextDirRef.current = null; obstaclesRef.current = generateObstacles(obstacleCount, cols, rows, snake); foodRef.current = spawnFood(snake); + freezeUntilRef.current = 0; + ghostUntilRef.current = 0; setAlive(true); setScore(0); }, [cols, rows, obstacleCount, spawnFood]); @@ -83,6 +91,9 @@ export function useSnakeGame( }, []); const tick = useCallback(() => { + const now = Date.now(); + if (now < freezeUntilRef.current) return; + if (nextDirRef.current) { dirRef.current = nextDirRef.current; nextDirRef.current = null; @@ -92,38 +103,39 @@ export function useSnakeGame( const head = snake[0]; if (!head) return; - let nh = nextHead(head, dirRef.current); + const nh = computeNextHead(head, dirRef.current, cols, rows, wrap); - if (wrap) { - nh = wrapPoint(nh, cols, rows); - } else { - const oob = nh.x < 0 || nh.x >= cols || nh.y < 0 || nh.y >= rows; - if (oob) { - setAlive(false); - opts?.onDie?.(); - return; - } + if (!wrap && isOutOfBounds(nh, cols, rows)) { + setAlive(false); + opts?.onDie?.(); + return; } const food = foodRef.current; - const willEat = !!food && eq(nh, food); - const bodyToCheck = willEat ? snake : snake.slice(0, -1); - - const hitBody = bodyToCheck.some((s) => eq(s, nh)); - const hitObstacle = obstaclesRef.current.some((o) => eq(o, nh)); - - if (hitBody || hitObstacle) { + const eating = willEatFood(nh, food); + const bodyToCheck = eating ? snake : snake.slice(0, -1); + const ghostActive = now < ghostUntilRef.current; + const collision = checkCollisions( + nh, + bodyToCheck, + obstaclesRef.current, + ghostActive + ); + + if (collision !== 'none') { setAlive(false); opts?.onDie?.(); return; } - if (willEat) { + if (eating && food) { snake.unshift(nh); const value = food.value ?? 1; setScore((s) => s + value); foodRef.current = spawnFood(snake); opts?.onEat?.(value, food.kind); + if (food.kind === 'freeze') freezeUntilRef.current = now + 2500; + if (food.kind === 'ghost') ghostUntilRef.current = now + 4000; } else { snake.unshift(nh); snake.pop(); diff --git a/src/app/services/leaderboardService.ts b/src/app/services/leaderboardService.ts new file mode 100644 index 0000000..6acae9a --- /dev/null +++ b/src/app/services/leaderboardService.ts @@ -0,0 +1,52 @@ +import { supabase, isSupabaseConfigured } from '@/supabase/client'; +import type { GameDifficulty } from '@/constants/game'; + +export type LeaderboardScore = { + rank: number; + score: number; + difficulty: GameDifficulty; + playerName: string | null; + createdAt: string; +}; + +/** Fetch global leaderboard for a difficulty. */ +export async function fetchLeaderboard( + difficulty: GameDifficulty, + limit = 10 +): Promise { + if (!isSupabaseConfigured() || !supabase) return []; + + const { data, error } = await supabase + .from('snake_scores') + .select('score, player_name, created_at') + .eq('difficulty', difficulty) + .order('score', { ascending: false }) + .limit(limit); + + if (error) return []; + + return (data ?? []).map((row, i) => ({ + rank: i + 1, + score: row.score, + difficulty, + playerName: row.player_name ?? 'Anonymous', + createdAt: row.created_at, + })); +} + +/** Submit a score to the global leaderboard. */ +export async function submitScore( + score: number, + difficulty: GameDifficulty, + playerName?: string +): Promise { + if (!isSupabaseConfigured() || !supabase) return false; + + const { error } = await supabase.from('snake_scores').insert({ + score, + difficulty, + player_name: playerName?.trim() || null, + }); + + return !error; +} diff --git a/src/app/styles/App.css b/src/app/styles/App.css index ad17777..a065869 100644 --- a/src/app/styles/App.css +++ b/src/app/styles/App.css @@ -26,12 +26,18 @@ } /* β€”β€”β€” Optional extras β€”β€”β€” */ -/* Slight body padding + dark background (helps with layout) */ body { @apply bg-black font-mono text-white; + -webkit-tap-highlight-color: transparent; + touch-action: manipulation; } -/* Center canvas nicely when needed */ canvas { @apply mx-auto block rounded; } + +/* Prevent pull-to-refresh and overscroll on mobile */ +html, +body { + overscroll-behavior: none; +} diff --git a/src/app/supabase/client.ts b/src/app/supabase/client.ts new file mode 100644 index 0000000..a7ef291 --- /dev/null +++ b/src/app/supabase/client.ts @@ -0,0 +1,13 @@ +import { createClient, type SupabaseClient } from '@supabase/supabase-js'; + +const supabaseUrl = String(import.meta.env.VITE_SUPABASE_URL ?? '').trim(); +const supabaseAnonKey = String( + import.meta.env.VITE_SUPABASE_ANON_KEY ?? '' +).trim(); + +export const supabase: SupabaseClient | null = + supabaseUrl && supabaseAnonKey + ? createClient(supabaseUrl, supabaseAnonKey) + : null; + +export const isSupabaseConfigured = (): boolean => !!supabase; diff --git a/src/app/utils/gameTick.ts b/src/app/utils/gameTick.ts new file mode 100644 index 0000000..6ee8366 --- /dev/null +++ b/src/app/utils/gameTick.ts @@ -0,0 +1,35 @@ +import type { XY, Dir, Food } from '@/types'; +import { eq, nextHead, wrapPoint } from './logic'; + +export function computeNextHead( + head: XY, + dir: Dir, + cols: number, + rows: number, + wrap: boolean +): XY { + let nh = nextHead(head, dir); + if (wrap) nh = wrapPoint(nh, cols, rows); + return nh; +} + +export function isOutOfBounds(p: XY, cols: number, rows: number): boolean { + return p.x < 0 || p.x >= cols || p.y < 0 || p.y >= rows; +} + +export function checkCollisions( + nh: XY, + bodyToCheck: XY[], + obstacles: XY[], + ghostActive: boolean +): 'body' | 'obstacle' | 'none' { + const hitBody = bodyToCheck.some((s) => eq(s, nh)); + const hitObstacle = obstacles.some((o) => eq(o, nh)); + if (hitBody && !ghostActive) return 'body'; + if (hitObstacle) return 'obstacle'; + return 'none'; +} + +export function willEatFood(nh: XY, food: Food | null): boolean { + return !!food && eq(nh, food); +} diff --git a/src/app/utils/speed.ts b/src/app/utils/speed.ts new file mode 100644 index 0000000..0e7b6d1 --- /dev/null +++ b/src/app/utils/speed.ts @@ -0,0 +1,18 @@ +import type { GameDifficulty } from '@/constants/game'; +import { DIFFICULTY_PRESETS } from '@/constants/game'; + +export function computeDelayMs( + difficulty: GameDifficulty, + score: number +): number { + const T = DIFFICULTY_PRESETS[difficulty]; + const linear = T.TICK_START_MS - score * T.TICK_STEP_MS; + const curve = T.speedCurve; + let delay = linear; + if (curve === 'ease-in') { + delay = T.TICK_START_MS - score * score * 0.08; + } else if (curve === 'ease-out') { + delay = T.TICK_START_MS - score * T.TICK_STEP_MS * 0.85; + } + return Math.max(T.TICK_MIN_MS, delay); +} diff --git a/supabase/migrations/001_snake_scores.sql b/supabase/migrations/001_snake_scores.sql new file mode 100644 index 0000000..762f50f --- /dev/null +++ b/supabase/migrations/001_snake_scores.sql @@ -0,0 +1,23 @@ +-- Snake game leaderboard (anonymous, no auth required) +CREATE TABLE IF NOT EXISTS snake_scores ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + score INTEGER NOT NULL, + difficulty TEXT NOT NULL, + player_name TEXT, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +CREATE INDEX IF NOT EXISTS idx_snake_scores_difficulty_score + ON snake_scores(difficulty, score DESC); + +CREATE INDEX IF NOT EXISTS idx_snake_scores_created + ON snake_scores(created_at DESC); + +-- Allow anonymous inserts and reads (no RLS for simplicity, or enable RLS with permissive policies) +ALTER TABLE snake_scores ENABLE ROW LEVEL SECURITY; + +CREATE POLICY "Anyone can insert scores" ON snake_scores + FOR INSERT WITH CHECK (true); + +CREATE POLICY "Anyone can read scores" ON snake_scores + FOR SELECT USING (true);