diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md
deleted file mode 100644
index 613e2ee..0000000
--- a/.github/PULL_REQUEST_TEMPLATE.md
+++ /dev/null
@@ -1,43 +0,0 @@
-# Buildathon Submission Pull Request
-
-Thank you for submitting your project for the Build Beyond Limits 2.0 - powered by Valkey and Breeth AI, hosted by React Hyderabad.
-
-Please fill in the details below before raising your Pull Request.
-
----
-
-## Submission Details
-
-**Project Name:**
-**Attendee Name:**
-**Problem Statement Selected:**
-
-Choose one:
-
-- [ ] HR Cost Intelligence Engine
-- [ ] Intelligent Slack Knowledge Base
-
----
-
-## Checklist
-
-Please confirm the following:
-
-- [ ] I have created my submission file inside the `submissions/` folder.
-- [ ] My submission file follows this naming format: `projectname_attendeeName.md`.
-- [ ] I have used the `SUBMISSION_TEMPLATE.md` format.
-- [ ] I have added my GitHub project repository link.
-- [ ] I have added my LinkedIn profile link.
-- [ ] I have added my GitHub username.
-- [ ] My project repository is public.
-- [ ] I have not added API keys, passwords, tokens, or private credentials.
-- [ ] I understand that my actual project code should be in my own GitHub repository, not in this repository.
-
----
-
-## Project Repository Link
-
-Add your project GitHub repository link here:
-
-```txt
-PASTE_LINK_HERE
\ No newline at end of file
diff --git a/.gitignore b/.gitignore
new file mode 100644
index 0000000..27e6c94
--- /dev/null
+++ b/.gitignore
@@ -0,0 +1,6 @@
+node_modules/
+dist/
+.env
+*.log
+.DS_Store
+Thumbs.db
diff --git a/CODE_OF_CONDUCT.md b/CODE_OF_CONDUCT.md
deleted file mode 100644
index 25412a4..0000000
--- a/CODE_OF_CONDUCT.md
+++ /dev/null
@@ -1,84 +0,0 @@
-
----
-
-# `CODE_OF_CONDUCT.md`
-
-```md
-# Code of Conduct
-
-## Our Promise
-
-React Hyderabad is committed to creating a welcoming, respectful, and inclusive environment for everyone participating in this buildathon.
-
-This applies to all participants, mentors, organizers, volunteers, judges, and community members.
-
-We expect everyone to treat each other with kindness, patience, and respect.
-
----
-
-## Expected Behaviour
-
-All participants are expected to:
-
-- Be respectful towards others
-- Collaborate in a healthy and professional way
-- Give constructive feedback
-- Respect different skill levels and backgrounds
-- Help others when possible
-- Follow the event and submission guidelines
-- Keep the community safe and welcoming
-
----
-
-## Unacceptable Behaviour
-
-The following behaviour will not be tolerated:
-
-- Harassment or bullying
-- Discrimination of any kind
-- Offensive comments or personal attacks
-- Inappropriate language or behaviour
-- Sharing someone’s private information without permission
-- Plagiarism or claiming someone else’s work as your own
-- Misuse of GitHub issues, pull requests, or discussions
-- Any behaviour that makes others feel unsafe or unwelcome
-
----
-
-## Project and Submission Ethics
-
-Participants are expected to submit their own work.
-
-You may use AI tools, open-source libraries, APIs, documentation, and online resources, but you should understand what you are building and be able to explain your solution.
-
-If you use existing code, templates, or external resources, please give proper credit wherever required.
-
-Do not submit copied projects as original work.
-
----
-
-## Reporting Issues
-
-If you experience or notice unacceptable behaviour, please reach out to the React Hyderabad organizing team.
-
-The organizing team will review the concern and take appropriate action.
-
----
-
-## Enforcement
-
-Depending on the situation, the organizing team may take actions such as:
-
-- Giving a warning
-- Asking for corrective action
-- Rejecting a submission
-- Removing a participant from the event or repository
-- Taking further action if required
-
----
-
-## Final Note
-
-This buildathon is about learning, building, collaboration, and community.
-
-Please help us keep it respectful, safe, and meaningful for everyone.
\ No newline at end of file
diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md
deleted file mode 100644
index e69de29..0000000
diff --git a/PROBLEM_STATEMENTS.md b/PROBLEM_STATEMENTS.md
deleted file mode 100644
index fc5a465..0000000
--- a/PROBLEM_STATEMENTS.md
+++ /dev/null
@@ -1,23 +0,0 @@
-Here's the link to access the problem statements -
-
-All the problem statements here - [https://github.com/opensource-for-valkey/valkey-community/tree/main/hackathons/hyd13_2026-06-20](https://github.com/opensource-for-valkey/valkey-community/tree/main/hackathons/hyd13_2026-06-20)
-
-There are three important criterias for building your project -
-
-1. You must use Valkey in your app.
-Valkey Intro - [https://github.com/pnbrown/valkey-intro](https://github.com/pnbrown/valkey-intro)
-2. You should use Breeth AI in your app. Documentation is available - [https://thebreeth.com/](https://thebreeth.com/)
-3. Please use React.js as your frontend library.
-
-
-
-Please make sure you only pick the problem statements as described above!
-
-We will be sharing the credits link of Breeth AI when we go live!
-
-
-
-Please join the discord channel for getting the credits - [https://discord.gg/mPC3AeUC](https://discord.gg/mPC3AeUC)
-
-
-
diff --git a/README.md b/README.md
index d14c3c6..04c618b 100644
--- a/README.md
+++ b/README.md
@@ -1,156 +1,87 @@
-# build-beyond-limits-2.0
-PR submissions for the Build Beyond Limits 2.0 - Powered by Valkey, Hosted by React Hyderabad
-Credit Partner - https://thebreeth.com/
+# Slither Quest — Multiplayer Snake Game
-Instructions releasing live on 20th June, at 3 PM
+A Slither.io-style multiplayer game built with React + Canvas, Node.js + WebSocket, Valkey (Redis), and Breeth AI for player memory.
-Join here - https://youtube.com/live/kTT2Kovs-y8?feature=share
+## Architecture
-
-# React Hyderabad x Valkey
-
-Welcome to the official open-source submission repository for the **Build Beyond Limits 2.0 - powered by Valkey and Breeth AI, and hosted by React Hyderabad**.
-
-This buildathon is focused on solving **real-world business problems using AI**. The goal is not just to write code, but to understand a problem, think from a business perspective, use the right tools, build a working solution, and present it clearly.
-
-This repository will act as the central place where all participant submissions are listed.
-
-Your actual project/code should be maintained in your own GitHub repository. This repo is only for submitting your project details.
-
----
-
-## How to Participate
-
-1. Fork this repository.
-
-2. Create your own project repository on GitHub.
-
-3. Pick one of the problem statements from `PROBLEM_STATEMENTS.md`.
-
-4. Build your project in your own GitHub repository.
-
-5. In your fork of this repository, go to the `submissions/` folder.
-
-6. Create a new markdown file using this naming format:
-
-```txt
-projectname_attendeeName.md
```
-
-OR
-
-```txt
-projectname_teamName.md
+Browser ──input {dir, boost}──► Node.js game loop (25 Hz)
+ ◄──snapshot (world)──
+ │
+ ├── Valkey (ioredis): leaderboard, pub/sub, world snapshot
+ └── Breeth AI: per-player memory (join greeting / death log)
```
-Example:
-
-```txt
-ai-sales-copilot_shlok-srivastava.md
-```
-
-7. Use `SUBMISSION_TEMPLATE.md` as the blueprint for your submission.
-
-8. Fill in all the required details:
-
- * Project name
- * Your name
- * Problem statement selected
- * Project description
- * Approach
- * Tools and technologies used
- * GitHub project link
- * LinkedIn profile
- * GitHub username
-
-9. Raise a Pull Request to this repository.
+- **Server-authoritative**: client sends only mouse direction + boost flag; server owns all positions, growth, and collision.
+- **25 ticks/sec** game loop; Valkey updated ~2×/sec to avoid remote round-trip costs.
+- **Pub/sub killfeed**: deaths published to `game:events`, subscriber relays to all WebSocket clients.
-10. Get your Pull Request merged.
+## Run Steps
----
+### 1. Server
-## Important Submission Note
-
-Please make sure your Pull Request is raised by today.
-
-Even if your project is incomplete, you should still submit your project details by creating a Pull Request.
-
-You can continue building and improving your actual project in your own GitHub repository after the submission PR is merged.
-
-The purpose of this repository is to make the hackathon open, transparent, and community-driven.
-
----
-
-## Submission Rules
-
-1. Each participant must submit their own project.
-
-2. Your actual project code should be in your own GitHub repository.
-
-3. Do not push your project code directly into this repository.
-
-4. This repository should only contain participant submission files.
-
-5. Each submission file must be added inside the `submissions/` folder.
-
-6. Use the correct file naming format:
-
-```txt
-projectname_attendeeName.md
-```
-OR
-```txt
-projectname_attendeeName.md
+```bash
+cd server
+npm install
+cp .env.example .env # edit VALKEY_URL and optionally BREETH_API_KEY
+npm run dev
```
-7. Make sure your project repository is public.
-
-8. Do not commit API keys, private tokens, passwords, or secret credentials in your project repository.
-
-9. If your project is still a work in progress, mention that clearly in your submission.
+### 2. Client
-10. Keep your submission clear, honest, and easy to understand.
-
----
+```bash
+cd client
+npm install
+# Optional: edit .env if server is not on localhost:3001
+npm run dev
+```
-## Pull Request Title Format
+Open **http://localhost:5173** in your browser.
-Please use this format for your Pull Request title:
+## Environment Variables
-```txt
-Submission: Project Name - Attendee Name/ Team Name
-```
+### server/.env
-Example:
-
-```txt
-Submission: AI Sales Copilot - Shlok Srivastava
-```
+| Variable | Description |
+|---|---|
+| `PORT` | WebSocket/HTTP port (default `3001`) |
+| `VALKEY_URL` | `rediss://user:pass@host:port` — Aiven or local Redis |
+| `BREETH_API_KEY` | `ck_live_…` — optional; game runs with in-memory fallback |
----
+### client/.env
-## What Makes a Good Submission?
+| Variable | Description |
+|---|---|
+| `VITE_WS_URL` | WebSocket URL (default `ws://localhost:3001`) |
-A good submission should clearly explain:
+## Valkey Data Model
-* What problem you selected
-* What your project does
-* Why your solution is useful
-* How you approached the problem
-* What AI tools or technologies you used
-* What is already working
-* What you plan to improve next
+| Key | Type | Usage |
+|---|---|---|
+| `game:leaderboard` | Sorted Set | `ZADD score playerId`, read with `ZREVRANGE 0 9 WITHSCORES` |
+| `game:names` | Hash | `HSET playerId name` |
+| `game:world` | String | JSON snapshot, `EX 5` TTL |
+| `game:events` | Pub/Sub | join / death events |
-The project does not have to be perfect. We care about your thought process, execution, learning, and ability to solve a real-world problem.
+## Breeth AI
----
+On **join**: `POST /v1/search` with player name → personalized greeting showing best length and last killer.
+On **death**: `POST /v1/episodes` stores the run narrative for future recall.
+Falls back to in-memory store automatically when `BREETH_API_KEY` is not set.
-## About the Build Beyond Limits 2.0
+## Controls
-This hackathon is hosted by **React Hyderabad** in collaboration with **Valkey** and **Breeth AI**.
+| Action | Control |
+|---|---|
+| Steer | Mouse movement |
+| Boost | Hold left-click or Space |
+| Respawn | Click RESPAWN button (3 s cooldown) |
-The goal is to bring builders together to solve meaningful problems using AI, frontend, backend, cloud, automation, and modern development tools.
+## Game Mechanics
-Build with intent.
-Solve with clarity.
-Ship with confidence.
+- World is **4000×4000** with a grid background; camera follows your snake head.
+- Eating pellets grows your snake and increases score.
+- Boosting increases speed but burns length and drops pellets.
+- Colliding **head-first into another snake's body** kills you.
+- Hitting the **world border** kills you.
+- Dead snakes explode into pellets.
diff --git a/SECURITY.md b/SECURITY.md
deleted file mode 100644
index edd9514..0000000
--- a/SECURITY.md
+++ /dev/null
@@ -1,50 +0,0 @@
-
----
-
-# `SECURITY.md`
-
-```md
-# Security Policy
-
-This repository is used only for collecting hackathon project submissions.
-
-Participants should not add sensitive information to this repository or to their public project repositories.
-
----
-
-## Do Not Commit Secrets
-
-Please do not commit:
-
-- API keys
-- Access tokens
-- Passwords
-- Private credentials
-- `.env` files
-- Database connection strings
-- Cloud service credentials
-- Personal identification documents
-- Any confidential business data
-
----
-
-## If You Accidentally Commit a Secret
-
-If you accidentally commit a secret:
-
-1. Remove it from your repository immediately.
-2. Rotate or revoke the exposed key/token from the provider dashboard.
-3. Update your project with a safe placeholder value.
-4. Inform the organizers if the secret was committed to this repository.
-
----
-
-## Recommended Practice
-
-Use environment variables for local development.
-
-Example:
-
-```txt
-OPENAI_API_KEY=your_api_key_here
-DATABASE_URL=your_database_url_here
\ No newline at end of file
diff --git a/SUBMISSION_TEMPLATE.md b/SUBMISSION_TEMPLATE.md
deleted file mode 100644
index a6e1d6e..0000000
--- a/SUBMISSION_TEMPLATE.md
+++ /dev/null
@@ -1,183 +0,0 @@
-# Project Submission Template
-
-Create a new file inside the `submissions/` folder using this naming format:
-
-```txt
-projectname_attendeeName.md
-```
-
-Example:
-
-```txt
-ai-sales-copilot_shlok-srivastava.md
-```
-
-Copy the template below into your new file and fill in the details.
-
----
-
-# Project Name
-
-Write your project name here.
-
----
-
-## Attendee/Team Details
-
-Add all the names of all the team members if you are participating in teams.
-
-**Name:** Your full name
-**GitHub Username:** Your GitHub username
-**LinkedIn Profile:** Your LinkedIn profile link
-**GitHub Project Repository:** Your project repository link
-
----
-
-## Problem Statement Selected
-
-Mention the title of the problem statement you selected.
-
-Choose one:
-
-```txt
-Problem statement 1
-```
-
-or
-
-```txt
-Problem statement 2
-```
-
----
-
-## Project Description
-
-Briefly explain what your project does.
-
-Try to answer:
-
-* What is the project about?
-* Who is it for?
-* What problem does it solve?
-* How does it help the user?
-
-Write your answer here.
-
----
-
-## Approach
-
-Explain how you approached the problem.
-
-You can include:
-
-* How you understood the problem
-* What user flow you designed
-* What features you decided to build
-* How AI is used in your solution
-* What makes your approach useful or different
-
-Write your answer here.
-
----
-
-## Tech Stack and Tools Used
-
-Mention the tools, technologies, frameworks, and platforms you used.
-
-Example:
-
-**Frontend:** React, Next.js, HTML, CSS, Tailwind, Material UI
-**Backend:** Node.js, Express, FastAPI, Django, Flask
-**Database:** MongoDB, PostgreSQL, Firebase, Supabase
-**AI Tools/API:** OpenAI, Gemini, Claude, LangChain, Hugging Face, local LLMs
-**Cloud/Deployment:** Vercel, Netlify, Render, Railway, AWS, GCP
-**Other Tools:** Cursor, GitHub Copilot, Figma, Postman, Docker
-
-Fill your details below:
-
-**Frontend:**
-**Backend:**
-**Database:**
-**AI Tools/API:**
-**Cloud/Deployment:**
-**Other Tools:**
-
----
-
-## Key Features
-
-List the key features of your project.
-
-Example:
-
-1. Feature one
-2. Feature two
-3. Feature three
-
-Write your features here:
-
-1.
-2.
-3.
-
----
-
-## What is Working?
-
-Mention what is currently working in your project.
-
-This is important even if your project is incomplete.
-
-Write your answer here.
-
----
-
-## What is Still in Progress?
-
-Mention what you are still building or planning to improve.
-
-Write your answer here.
-
----
-
-## Screenshots or Demo
-
-Add screenshots, demo video link, or deployed project link if available.
-
-**Deployed Link:**
-**Demo Video Link:**
-**Screenshots:**
-
----
-
-## Challenges Faced
-
-Mention any challenges you faced while building this project.
-
-Write your answer here.
-
----
-
-## Learnings
-
-Mention what you learned while building this project.
-
-Write your answer here.
-
----
-
-## Future Improvements
-
-Mention what you would improve if you had more time.
-
-Write your answer here.
-
----
-
-## Final Note
-
-Use this space to add anything else you want mentors, judges, or the community to know about your project.
-
-Write your answer here.
diff --git a/client/index.html b/client/index.html
new file mode 100644
index 0000000..c62109e
--- /dev/null
+++ b/client/index.html
@@ -0,0 +1,17 @@
+
+
+
+
+
+ Slither Quest
+
+
+
+
+
+
+
diff --git a/client/package-lock.json b/client/package-lock.json
new file mode 100644
index 0000000..3ee9f4a
--- /dev/null
+++ b/client/package-lock.json
@@ -0,0 +1,1680 @@
+{
+ "name": "slither-client",
+ "version": "1.0.0",
+ "lockfileVersion": 3,
+ "requires": true,
+ "packages": {
+ "": {
+ "name": "slither-client",
+ "version": "1.0.0",
+ "dependencies": {
+ "react": "^18.3.1",
+ "react-dom": "^18.3.1"
+ },
+ "devDependencies": {
+ "@vitejs/plugin-react": "^4.3.1",
+ "vite": "^5.4.0"
+ }
+ },
+ "node_modules/@babel/code-frame": {
+ "version": "7.29.7",
+ "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.7.tgz",
+ "integrity": "sha512-Aup7aUOfpbAUg2ROOJN6Iw5f9DMBlzu0mIkm/malLQFN/YQgO48wCj0Kxa3sEHJvPVFg7siR+qRInwXd2qhQKw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/helper-validator-identifier": "^7.29.7",
+ "js-tokens": "^4.0.0",
+ "picocolors": "^1.1.1"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ }
+ },
+ "node_modules/@babel/compat-data": {
+ "version": "7.29.7",
+ "resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.29.7.tgz",
+ "integrity": "sha512-locTkQyKvwIEgBzVrn8693ebc97F2U8ZHjbXwDXJ5Fn2TCpNwTlKcaKLkdHop5c/icOFE7qt7Q9JC5hnKNa6Gg==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=6.9.0"
+ }
+ },
+ "node_modules/@babel/core": {
+ "version": "7.29.7",
+ "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.29.7.tgz",
+ "integrity": "sha512-RgHBCvtjbOK2gXSNBNIkNoEc9qoVEtau3hj8gEqKQuL3HZAibKarWFEI3Lfm6EYKkLalOh8eSrj9b+ch9H/VBA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/code-frame": "^7.29.7",
+ "@babel/generator": "^7.29.7",
+ "@babel/helper-compilation-targets": "^7.29.7",
+ "@babel/helper-module-transforms": "^7.29.7",
+ "@babel/helpers": "^7.29.7",
+ "@babel/parser": "^7.29.7",
+ "@babel/template": "^7.29.7",
+ "@babel/traverse": "^7.29.7",
+ "@babel/types": "^7.29.7",
+ "@jridgewell/remapping": "^2.3.5",
+ "convert-source-map": "^2.0.0",
+ "debug": "^4.1.0",
+ "gensync": "^1.0.0-beta.2",
+ "json5": "^2.2.3",
+ "semver": "^6.3.1"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/babel"
+ }
+ },
+ "node_modules/@babel/generator": {
+ "version": "7.29.7",
+ "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.29.7.tgz",
+ "integrity": "sha512-DkXD5OJQaAQIdZ1bt3UZdEnHAn9Imd3IVBdX03UFe+ony9Ojw5pzr9YVKGDY1jt+Gcn/FnGkNf8r+Vj5NOJWtQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/parser": "^7.29.7",
+ "@babel/types": "^7.29.7",
+ "@jridgewell/gen-mapping": "^0.3.12",
+ "@jridgewell/trace-mapping": "^0.3.28",
+ "jsesc": "^3.0.2"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ }
+ },
+ "node_modules/@babel/helper-compilation-targets": {
+ "version": "7.29.7",
+ "resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.29.7.tgz",
+ "integrity": "sha512-wem6WaBj4NaVYVdNhLPPVacES6ZJ+KBBfSkTMD3YZxbP3rm3Di85tJU5ljaUNhaOynt+Aj0xruhYuzQBt8n71g==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/compat-data": "^7.29.7",
+ "@babel/helper-validator-option": "^7.29.7",
+ "browserslist": "^4.24.0",
+ "lru-cache": "^5.1.1",
+ "semver": "^6.3.1"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ }
+ },
+ "node_modules/@babel/helper-globals": {
+ "version": "7.29.7",
+ "resolved": "https://registry.npmjs.org/@babel/helper-globals/-/helper-globals-7.29.7.tgz",
+ "integrity": "sha512-3nQVUAtvkKH9zahfWgw96Jc/uFOmjACE1kQz82E2lqWmHBgjzbNlsC22nuQTfahmWeQtTq5nQ/4Nnd2A1wj4zA==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=6.9.0"
+ }
+ },
+ "node_modules/@babel/helper-module-imports": {
+ "version": "7.29.7",
+ "resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.29.7.tgz",
+ "integrity": "sha512-ejHwrQQYcm9xnTivShn2IDOlIzInN34AXskvq9QicvCtEzq1Vzclu/tKF8Jq1Cg8JG2GL6/EmjgsCT7lXepE3g==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/traverse": "^7.29.7",
+ "@babel/types": "^7.29.7"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ }
+ },
+ "node_modules/@babel/helper-module-transforms": {
+ "version": "7.29.7",
+ "resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.29.7.tgz",
+ "integrity": "sha512-UPUVSyXbOh627KiCIGQSgwWzGeBKLkaJ9PJEdrngIwMSzxLR4jS4+f1f1jb7VzBbg8nFLaYotvVPFCTqdrmTAg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/helper-module-imports": "^7.29.7",
+ "@babel/helper-validator-identifier": "^7.29.7",
+ "@babel/traverse": "^7.29.7"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ },
+ "peerDependencies": {
+ "@babel/core": "^7.0.0"
+ }
+ },
+ "node_modules/@babel/helper-plugin-utils": {
+ "version": "7.29.7",
+ "resolved": "https://registry.npmjs.org/@babel/helper-plugin-utils/-/helper-plugin-utils-7.29.7.tgz",
+ "integrity": "sha512-G7sHYigPY17oO5SYWnfD/0MTBwVR781S/JI643e/JhUYgVgWE/61SoW3NH9KWUKyKq5LVh3npif99Wkt6j86Jw==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=6.9.0"
+ }
+ },
+ "node_modules/@babel/helper-string-parser": {
+ "version": "7.29.7",
+ "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.29.7.tgz",
+ "integrity": "sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=6.9.0"
+ }
+ },
+ "node_modules/@babel/helper-validator-identifier": {
+ "version": "7.29.7",
+ "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.29.7.tgz",
+ "integrity": "sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=6.9.0"
+ }
+ },
+ "node_modules/@babel/helper-validator-option": {
+ "version": "7.29.7",
+ "resolved": "https://registry.npmjs.org/@babel/helper-validator-option/-/helper-validator-option-7.29.7.tgz",
+ "integrity": "sha512-N9ZErrD+yW5geCDtBqnOoxmR8+tNKiGuxKlDpuJxfsqpa2dFcexaziGAE/qoHLiDDreVNMupxGmSoNlyvsA3gw==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=6.9.0"
+ }
+ },
+ "node_modules/@babel/helpers": {
+ "version": "7.29.7",
+ "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.29.7.tgz",
+ "integrity": "sha512-1k2lAGRMfHTcwuNYcCNUmaUffmQv8KWMfh2iJUUeRlwlwH4FdNG7mfPI10NPfLHJFThE4Tyr4mv7kTNZOiPuBg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/template": "^7.29.7",
+ "@babel/types": "^7.29.7"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ }
+ },
+ "node_modules/@babel/parser": {
+ "version": "7.29.7",
+ "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.7.tgz",
+ "integrity": "sha512-hnORnjP/1P/zFEndoeX+n+t1RwWRJiJpM/jO7FW32Kn9r5+sJB2JWOdYo4L6k78j15eCwY3Gm/7364B1EMwtNg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/types": "^7.29.7"
+ },
+ "bin": {
+ "parser": "bin/babel-parser.js"
+ },
+ "engines": {
+ "node": ">=6.0.0"
+ }
+ },
+ "node_modules/@babel/plugin-transform-react-jsx-self": {
+ "version": "7.29.7",
+ "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-self/-/plugin-transform-react-jsx-self-7.29.7.tgz",
+ "integrity": "sha512-TL0hMc9xzy86VD31nUiwzd5otRAcyEPcsegCxolO0PvcXuH1v0kECe/UIznYFihpkvU5wg/jk4v0TTEFfm53fw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/helper-plugin-utils": "^7.29.7"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ },
+ "peerDependencies": {
+ "@babel/core": "^7.0.0-0"
+ }
+ },
+ "node_modules/@babel/plugin-transform-react-jsx-source": {
+ "version": "7.29.7",
+ "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-source/-/plugin-transform-react-jsx-source-7.29.7.tgz",
+ "integrity": "sha512-06IyK09H3wi4cGbhDBwp5gUGo0IKtnYa8tyTiephirPCK6fbobVGiXMMI5zLQ4aKEYP3wZ3ArU44o+8KMrSG/Q==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/helper-plugin-utils": "^7.29.7"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ },
+ "peerDependencies": {
+ "@babel/core": "^7.0.0-0"
+ }
+ },
+ "node_modules/@babel/template": {
+ "version": "7.29.7",
+ "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.29.7.tgz",
+ "integrity": "sha512-puq+Gf35oI24FeN11LkoUQFqv9uwNeWpxXZi/Ji3rRIoKAzKnxRaZ+Gkj0vKS9ZCiTESfng1N9LyOyXvo+m+Gg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/code-frame": "^7.29.7",
+ "@babel/parser": "^7.29.7",
+ "@babel/types": "^7.29.7"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ }
+ },
+ "node_modules/@babel/traverse": {
+ "version": "7.29.7",
+ "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.29.7.tgz",
+ "integrity": "sha512-EhlfNQtZ+NK22w5BM61ciuiq1m58ed33Wr1Xan//ZRTy6hgjnwyCffRYwzsGXdASJSUJ1guZILsErh1eQcl+zw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/code-frame": "^7.29.7",
+ "@babel/generator": "^7.29.7",
+ "@babel/helper-globals": "^7.29.7",
+ "@babel/parser": "^7.29.7",
+ "@babel/template": "^7.29.7",
+ "@babel/types": "^7.29.7",
+ "debug": "^4.3.1"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ }
+ },
+ "node_modules/@babel/types": {
+ "version": "7.29.7",
+ "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.7.tgz",
+ "integrity": "sha512-4zBIxpPzowiZpusoFkyGVwakdRJUyuH5PxQ/PrqghfdFWWasvnCdPfQXHrenDai+gyLARulZjZowCOj6fjT4pA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/helper-string-parser": "^7.29.7",
+ "@babel/helper-validator-identifier": "^7.29.7"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ }
+ },
+ "node_modules/@esbuild/aix-ppc64": {
+ "version": "0.21.5",
+ "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.21.5.tgz",
+ "integrity": "sha512-1SDgH6ZSPTlggy1yI6+Dbkiz8xzpHJEVAlF/AM1tHPLsf5STom9rwtjE4hKAF20FfXXNTFqEYXyJNWh1GiZedQ==",
+ "cpu": [
+ "ppc64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "aix"
+ ],
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/@esbuild/android-arm": {
+ "version": "0.21.5",
+ "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.21.5.tgz",
+ "integrity": "sha512-vCPvzSjpPHEi1siZdlvAlsPxXl7WbOVUBBAowWug4rJHb68Ox8KualB+1ocNvT5fjv6wpkX6o/iEpbDrf68zcg==",
+ "cpu": [
+ "arm"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "android"
+ ],
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/@esbuild/android-arm64": {
+ "version": "0.21.5",
+ "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.21.5.tgz",
+ "integrity": "sha512-c0uX9VAUBQ7dTDCjq+wdyGLowMdtR/GoC2U5IYk/7D1H1JYC0qseD7+11iMP2mRLN9RcCMRcjC4YMclCzGwS/A==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "android"
+ ],
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/@esbuild/android-x64": {
+ "version": "0.21.5",
+ "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.21.5.tgz",
+ "integrity": "sha512-D7aPRUUNHRBwHxzxRvp856rjUHRFW1SdQATKXH2hqA0kAZb1hKmi02OpYRacl0TxIGz/ZmXWlbZgjwWYaCakTA==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "android"
+ ],
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/@esbuild/darwin-arm64": {
+ "version": "0.21.5",
+ "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.21.5.tgz",
+ "integrity": "sha512-DwqXqZyuk5AiWWf3UfLiRDJ5EDd49zg6O9wclZ7kUMv2WRFr4HKjXp/5t8JZ11QbQfUS6/cRCKGwYhtNAY88kQ==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/@esbuild/darwin-x64": {
+ "version": "0.21.5",
+ "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.21.5.tgz",
+ "integrity": "sha512-se/JjF8NlmKVG4kNIuyWMV/22ZaerB+qaSi5MdrXtd6R08kvs2qCN4C09miupktDitvh8jRFflwGFBQcxZRjbw==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/@esbuild/freebsd-arm64": {
+ "version": "0.21.5",
+ "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.21.5.tgz",
+ "integrity": "sha512-5JcRxxRDUJLX8JXp/wcBCy3pENnCgBR9bN6JsY4OmhfUtIHe3ZW0mawA7+RDAcMLrMIZaf03NlQiX9DGyB8h4g==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "freebsd"
+ ],
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/@esbuild/freebsd-x64": {
+ "version": "0.21.5",
+ "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.21.5.tgz",
+ "integrity": "sha512-J95kNBj1zkbMXtHVH29bBriQygMXqoVQOQYA+ISs0/2l3T9/kj42ow2mpqerRBxDJnmkUDCaQT/dfNXWX/ZZCQ==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "freebsd"
+ ],
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/@esbuild/linux-arm": {
+ "version": "0.21.5",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.21.5.tgz",
+ "integrity": "sha512-bPb5AHZtbeNGjCKVZ9UGqGwo8EUu4cLq68E95A53KlxAPRmUyYv2D6F0uUI65XisGOL1hBP5mTronbgo+0bFcA==",
+ "cpu": [
+ "arm"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/@esbuild/linux-arm64": {
+ "version": "0.21.5",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.21.5.tgz",
+ "integrity": "sha512-ibKvmyYzKsBeX8d8I7MH/TMfWDXBF3db4qM6sy+7re0YXya+K1cem3on9XgdT2EQGMu4hQyZhan7TeQ8XkGp4Q==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/@esbuild/linux-ia32": {
+ "version": "0.21.5",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.21.5.tgz",
+ "integrity": "sha512-YvjXDqLRqPDl2dvRODYmmhz4rPeVKYvppfGYKSNGdyZkA01046pLWyRKKI3ax8fbJoK5QbxblURkwK/MWY18Tg==",
+ "cpu": [
+ "ia32"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/@esbuild/linux-loong64": {
+ "version": "0.21.5",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.21.5.tgz",
+ "integrity": "sha512-uHf1BmMG8qEvzdrzAqg2SIG/02+4/DHB6a9Kbya0XDvwDEKCoC8ZRWI5JJvNdUjtciBGFQ5PuBlpEOXQj+JQSg==",
+ "cpu": [
+ "loong64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/@esbuild/linux-mips64el": {
+ "version": "0.21.5",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.21.5.tgz",
+ "integrity": "sha512-IajOmO+KJK23bj52dFSNCMsz1QP1DqM6cwLUv3W1QwyxkyIWecfafnI555fvSGqEKwjMXVLokcV5ygHW5b3Jbg==",
+ "cpu": [
+ "mips64el"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/@esbuild/linux-ppc64": {
+ "version": "0.21.5",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.21.5.tgz",
+ "integrity": "sha512-1hHV/Z4OEfMwpLO8rp7CvlhBDnjsC3CttJXIhBi+5Aj5r+MBvy4egg7wCbe//hSsT+RvDAG7s81tAvpL2XAE4w==",
+ "cpu": [
+ "ppc64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/@esbuild/linux-riscv64": {
+ "version": "0.21.5",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.21.5.tgz",
+ "integrity": "sha512-2HdXDMd9GMgTGrPWnJzP2ALSokE/0O5HhTUvWIbD3YdjME8JwvSCnNGBnTThKGEB91OZhzrJ4qIIxk/SBmyDDA==",
+ "cpu": [
+ "riscv64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/@esbuild/linux-s390x": {
+ "version": "0.21.5",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.21.5.tgz",
+ "integrity": "sha512-zus5sxzqBJD3eXxwvjN1yQkRepANgxE9lgOW2qLnmr8ikMTphkjgXu1HR01K4FJg8h1kEEDAqDcZQtbrRnB41A==",
+ "cpu": [
+ "s390x"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/@esbuild/linux-x64": {
+ "version": "0.21.5",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.21.5.tgz",
+ "integrity": "sha512-1rYdTpyv03iycF1+BhzrzQJCdOuAOtaqHTWJZCWvijKD2N5Xu0TtVC8/+1faWqcP9iBCWOmjmhoH94dH82BxPQ==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/@esbuild/netbsd-x64": {
+ "version": "0.21.5",
+ "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.21.5.tgz",
+ "integrity": "sha512-Woi2MXzXjMULccIwMnLciyZH4nCIMpWQAs049KEeMvOcNADVxo0UBIQPfSmxB3CWKedngg7sWZdLvLczpe0tLg==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "netbsd"
+ ],
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/@esbuild/openbsd-x64": {
+ "version": "0.21.5",
+ "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.21.5.tgz",
+ "integrity": "sha512-HLNNw99xsvx12lFBUwoT8EVCsSvRNDVxNpjZ7bPn947b8gJPzeHWyNVhFsaerc0n3TsbOINvRP2byTZ5LKezow==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "openbsd"
+ ],
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/@esbuild/sunos-x64": {
+ "version": "0.21.5",
+ "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.21.5.tgz",
+ "integrity": "sha512-6+gjmFpfy0BHU5Tpptkuh8+uw3mnrvgs+dSPQXQOv3ekbordwnzTVEb4qnIvQcYXq6gzkyTnoZ9dZG+D4garKg==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "sunos"
+ ],
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/@esbuild/win32-arm64": {
+ "version": "0.21.5",
+ "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.21.5.tgz",
+ "integrity": "sha512-Z0gOTd75VvXqyq7nsl93zwahcTROgqvuAcYDUr+vOv8uHhNSKROyU961kgtCD1e95IqPKSQKH7tBTslnS3tA8A==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/@esbuild/win32-ia32": {
+ "version": "0.21.5",
+ "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.21.5.tgz",
+ "integrity": "sha512-SWXFF1CL2RVNMaVs+BBClwtfZSvDgtL//G/smwAc5oVK/UPu2Gu9tIaRgFmYFFKrmg3SyAjSrElf0TiJ1v8fYA==",
+ "cpu": [
+ "ia32"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/@esbuild/win32-x64": {
+ "version": "0.21.5",
+ "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.21.5.tgz",
+ "integrity": "sha512-tQd/1efJuzPC6rCFwEvLtci/xNFcTZknmXs98FYDfGE4wP9ClFV98nyKrzJKVPMhdDnjzLhdUyMX4PsQAPjwIw==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/@jridgewell/gen-mapping": {
+ "version": "0.3.13",
+ "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz",
+ "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@jridgewell/sourcemap-codec": "^1.5.0",
+ "@jridgewell/trace-mapping": "^0.3.24"
+ }
+ },
+ "node_modules/@jridgewell/remapping": {
+ "version": "2.3.5",
+ "resolved": "https://registry.npmjs.org/@jridgewell/remapping/-/remapping-2.3.5.tgz",
+ "integrity": "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@jridgewell/gen-mapping": "^0.3.5",
+ "@jridgewell/trace-mapping": "^0.3.24"
+ }
+ },
+ "node_modules/@jridgewell/resolve-uri": {
+ "version": "3.1.2",
+ "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz",
+ "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=6.0.0"
+ }
+ },
+ "node_modules/@jridgewell/sourcemap-codec": {
+ "version": "1.5.5",
+ "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz",
+ "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/@jridgewell/trace-mapping": {
+ "version": "0.3.31",
+ "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz",
+ "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@jridgewell/resolve-uri": "^3.1.0",
+ "@jridgewell/sourcemap-codec": "^1.4.14"
+ }
+ },
+ "node_modules/@rolldown/pluginutils": {
+ "version": "1.0.0-beta.27",
+ "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-beta.27.tgz",
+ "integrity": "sha512-+d0F4MKMCbeVUJwG96uQ4SgAznZNSq93I3V+9NHA4OpvqG8mRCpGdKmK8l/dl02h2CCDHwW2FqilnTyDcAnqjA==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/@rollup/rollup-android-arm-eabi": {
+ "version": "4.62.2",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.62.2.tgz",
+ "integrity": "sha512-6o7ZLZK+BeenkZCFNDXqpbjw9bD6nuWonvS/lwQJp7NoVVxm6p3qE7qQ5jGuBjiFsgvqjD8mZAU5oWxTmbOeOg==",
+ "cpu": [
+ "arm"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "android"
+ ]
+ },
+ "node_modules/@rollup/rollup-android-arm64": {
+ "version": "4.62.2",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.62.2.tgz",
+ "integrity": "sha512-BaH7BllCACHoH1LguOU56UItGfUWjujlO65kS9LAodViaN4bwIKd7oeW/ZHJ/4ljr/7MIiENnNy3HJ0zXv8Zkw==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "android"
+ ]
+ },
+ "node_modules/@rollup/rollup-darwin-arm64": {
+ "version": "4.62.2",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.62.2.tgz",
+ "integrity": "sha512-v39RCCvj4He82I9sFmk+M1VZ0PLM9sfsLVikjfx2hYBNALhrrOR2D3JjQA6AhlaSOgcR+RzrKY7e1+bT6SUO/A==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "darwin"
+ ]
+ },
+ "node_modules/@rollup/rollup-darwin-x64": {
+ "version": "4.62.2",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.62.2.tgz",
+ "integrity": "sha512-yl0y2vq3S3lHeuXhEdss6TWfKW8vkujImO12tn4ZkG/4oghr09LvdYm2RElVjokTQiUvDUGXLGsYeLqUMCKpGA==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "darwin"
+ ]
+ },
+ "node_modules/@rollup/rollup-freebsd-arm64": {
+ "version": "4.62.2",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.62.2.tgz",
+ "integrity": "sha512-tT4pvt4qXD+vEoezupCWi+a1F0vvDiksiHc+PxRlYTOH1I6/X4id9jPxTP+Fg+545euaFT1jJVs4CEdHZAU1vw==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "freebsd"
+ ]
+ },
+ "node_modules/@rollup/rollup-freebsd-x64": {
+ "version": "4.62.2",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.62.2.tgz",
+ "integrity": "sha512-6nU5F2wCW+qvCBhTn1pdIU3bzsIoF7EUwsCDRxilWGprQR6yd508YnH9+OKFCwpfS8pjZqDUmnCAr7exax0XCg==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "freebsd"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-arm-gnueabihf": {
+ "version": "4.62.2",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.62.2.tgz",
+ "integrity": "sha512-n1GJHPOvpIfhi3TmrCeh6S6URt9BFCt0KQE3qvexyGCTAKpR4Lg+eWvNZEqu7epxwus/8ElT3hacYEucm49SZg==",
+ "cpu": [
+ "arm"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-arm-musleabihf": {
+ "version": "4.62.2",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.62.2.tgz",
+ "integrity": "sha512-JqgflS8wEB+UXV/vS1RpRbifGBeN4D5lz8D8oOFbFZw4vedvdOgCFAjfBmIMdW3yL10XpQQ0Ambepw6MXrhOnA==",
+ "cpu": [
+ "arm"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-arm64-gnu": {
+ "version": "4.62.2",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.62.2.tgz",
+ "integrity": "sha512-wnFJkogWvN4jm/hQRF2UBaeUmk20j5+DmHvoyWii2b8HJDyvz1MF2OU/6ynXt2KR63rbZLWkFpoytpdc/yBuSA==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-arm64-musl": {
+ "version": "4.62.2",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.62.2.tgz",
+ "integrity": "sha512-HVu2bp0zhvJ8xHEV9+UUs7S90VadmBSY3LcIMvozbPo4AuMGDWlz3ymHLHZPX4hR67TKTt8Qp5PJ5RBg/i+RMQ==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-loong64-gnu": {
+ "version": "4.62.2",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.62.2.tgz",
+ "integrity": "sha512-mQqqAV8QaoSgr9I2fKDLY2BAVvmKjWoGiu/cSYQonsLvtqwEn1E4QYfnCOcp5zoEqNhsDYin1s6jx/VJmrxlZg==",
+ "cpu": [
+ "loong64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-loong64-musl": {
+ "version": "4.62.2",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.62.2.tgz",
+ "integrity": "sha512-IxKLoxCQ2IWi6bT2akyDUBGsOImDKB+sPp4EsTmwFQ/fMwpCKm8uLSSgP/Kx/QYUgKis6SEZ5/Nlhup0DIA0PQ==",
+ "cpu": [
+ "loong64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-ppc64-gnu": {
+ "version": "4.62.2",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.62.2.tgz",
+ "integrity": "sha512-Mk5ha2RQSgyFfmYYLkBpPnUk8D8FriBxesO1u9O75X0mHgXL1UQcH5Itl2lurWL2tj0RxV9b9tJgipac0hRY9A==",
+ "cpu": [
+ "ppc64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-ppc64-musl": {
+ "version": "4.62.2",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.62.2.tgz",
+ "integrity": "sha512-CjvEnqJL/0/TQ3TXX3OPIJ/kmBellrWd4heXUmHeJlTnmwjKpSJzoehLaL6Xk0ZnMHBu9dZuFADNOrtjF4v+2w==",
+ "cpu": [
+ "ppc64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-riscv64-gnu": {
+ "version": "4.62.2",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.62.2.tgz",
+ "integrity": "sha512-1SiZbzwdkaDURsew/tSOrooKiYy7EQGT6m8ufavAi9NEyQb/6VuIxFXAL1fqa4iZe3g4NbNk4P7J32z2tw5Mgg==",
+ "cpu": [
+ "riscv64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-riscv64-musl": {
+ "version": "4.62.2",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.62.2.tgz",
+ "integrity": "sha512-nQts12zJ3NQRoE6uYljOH89v7szzLDvG2JD/vsX+vGXU8w/At1GowTZ5/7qeFQ8m7L55rpR8Okugnuo5bgjy2Q==",
+ "cpu": [
+ "riscv64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-s390x-gnu": {
+ "version": "4.62.2",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.62.2.tgz",
+ "integrity": "sha512-E9/ll019jhPIJgpzfZoIkBGhcz+kKNgVWYRY0zr9srBdPPFVpvOKW8VaJKUbeK+eZXyQF9ltME+Kk6affeaPgg==",
+ "cpu": [
+ "s390x"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-x64-gnu": {
+ "version": "4.62.2",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.62.2.tgz",
+ "integrity": "sha512-5BqxR/pshjey51iliyzTD5Xi3EN0aLmQ2lZ3lvefVV9c82BvrLo2/6OT55iifpWBufs6kdwWbuOKS841DrmK9A==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-x64-musl": {
+ "version": "4.62.2",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.62.2.tgz",
+ "integrity": "sha512-uNN83XxQrRAh/w0/pmAfibcwyb6YWt4gP+dpnQKPVJshAloQ785ii8CT8ZCIxkGg9opVsvAlGhFitSm6D1Jjpg==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-openbsd-x64": {
+ "version": "4.62.2",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.62.2.tgz",
+ "integrity": "sha512-srjEIxSH3LRnJN6THczDHWQplqEMFiAJrTab0msUryh9kwNpkICf3Ea6q6MN/2cZwRFUNx5w+h6Hpi4QuHS6Zg==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "openbsd"
+ ]
+ },
+ "node_modules/@rollup/rollup-openharmony-arm64": {
+ "version": "4.62.2",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.62.2.tgz",
+ "integrity": "sha512-8hOJnxgbyObnCm5AlRA3A931xX19xq80RjVTKgJOvEKWqJruP/Uf12IbAOaDjjEXYRewwHLfmF0YRIdK3OwKWA==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "openharmony"
+ ]
+ },
+ "node_modules/@rollup/rollup-win32-arm64-msvc": {
+ "version": "4.62.2",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.62.2.tgz",
+ "integrity": "sha512-mmF4AY1i0hG/bLWUctUq59gtmgaSIRa3cu/A3JFRp/sCNEme2bgDEiDS22P9FbnJB8NJNF4jPJiSP5RHQpUTDg==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ]
+ },
+ "node_modules/@rollup/rollup-win32-ia32-msvc": {
+ "version": "4.62.2",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.62.2.tgz",
+ "integrity": "sha512-DZgkknc6jhHrk46V25vbAM0zZkyP0nSDkJB8/dRkLTxv470dOmWDqGoEJl/9A0dFfS7yE3REOwNDxpHwSLSt0Q==",
+ "cpu": [
+ "ia32"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ]
+ },
+ "node_modules/@rollup/rollup-win32-x64-gnu": {
+ "version": "4.62.2",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.62.2.tgz",
+ "integrity": "sha512-T6xr6ucWSFto+VGajA8YH26LdpHRuP4YLHEKAtCWvJDOlnmWcDZVCI2Jmjr+IFHDlt2zRaTAKE4tfjTaWLgJBg==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ]
+ },
+ "node_modules/@rollup/rollup-win32-x64-msvc": {
+ "version": "4.62.2",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.62.2.tgz",
+ "integrity": "sha512-BfzEnDJOt9T8M989/lA37EcJgat01wLRnoi5dQf3QzOH7jzpqTAzdDbVfRljVr5r+jzKqpbHeyOfAaXxAd0PAA==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ]
+ },
+ "node_modules/@types/babel__core": {
+ "version": "7.20.5",
+ "resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz",
+ "integrity": "sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/parser": "^7.20.7",
+ "@babel/types": "^7.20.7",
+ "@types/babel__generator": "*",
+ "@types/babel__template": "*",
+ "@types/babel__traverse": "*"
+ }
+ },
+ "node_modules/@types/babel__generator": {
+ "version": "7.27.0",
+ "resolved": "https://registry.npmjs.org/@types/babel__generator/-/babel__generator-7.27.0.tgz",
+ "integrity": "sha512-ufFd2Xi92OAVPYsy+P4n7/U7e68fex0+Ee8gSG9KX7eo084CWiQ4sdxktvdl0bOPupXtVJPY19zk6EwWqUQ8lg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/types": "^7.0.0"
+ }
+ },
+ "node_modules/@types/babel__template": {
+ "version": "7.4.4",
+ "resolved": "https://registry.npmjs.org/@types/babel__template/-/babel__template-7.4.4.tgz",
+ "integrity": "sha512-h/NUaSyG5EyxBIp8YRxo4RMe2/qQgvyowRwVMzhYhBCONbW8PUsg4lkFMrhgZhUe5z3L3MiLDuvyJ/CaPa2A8A==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/parser": "^7.1.0",
+ "@babel/types": "^7.0.0"
+ }
+ },
+ "node_modules/@types/babel__traverse": {
+ "version": "7.28.0",
+ "resolved": "https://registry.npmjs.org/@types/babel__traverse/-/babel__traverse-7.28.0.tgz",
+ "integrity": "sha512-8PvcXf70gTDZBgt9ptxJ8elBeBjcLOAcOtoO/mPJjtji1+CdGbHgm77om1GrsPxsiE+uXIpNSK64UYaIwQXd4Q==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/types": "^7.28.2"
+ }
+ },
+ "node_modules/@types/estree": {
+ "version": "1.0.9",
+ "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz",
+ "integrity": "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/@vitejs/plugin-react": {
+ "version": "4.7.0",
+ "resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-4.7.0.tgz",
+ "integrity": "sha512-gUu9hwfWvvEDBBmgtAowQCojwZmJ5mcLn3aufeCsitijs3+f2NsrPtlAWIR6OPiqljl96GVCUbLe0HyqIpVaoA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/core": "^7.28.0",
+ "@babel/plugin-transform-react-jsx-self": "^7.27.1",
+ "@babel/plugin-transform-react-jsx-source": "^7.27.1",
+ "@rolldown/pluginutils": "1.0.0-beta.27",
+ "@types/babel__core": "^7.20.5",
+ "react-refresh": "^0.17.0"
+ },
+ "engines": {
+ "node": "^14.18.0 || >=16.0.0"
+ },
+ "peerDependencies": {
+ "vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0"
+ }
+ },
+ "node_modules/baseline-browser-mapping": {
+ "version": "2.10.38",
+ "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.38.tgz",
+ "integrity": "sha512-31/02mVB4yuQU6adKk5SlY6m+mxDwUq5KZkyYgnLrrKl7TEm1+3PyDtDBz2kOv/wxZz41GHsvV1A/u6RmiyBvw==",
+ "dev": true,
+ "license": "Apache-2.0",
+ "bin": {
+ "baseline-browser-mapping": "dist/cli.cjs"
+ },
+ "engines": {
+ "node": ">=6.0.0"
+ }
+ },
+ "node_modules/browserslist": {
+ "version": "4.28.2",
+ "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.2.tgz",
+ "integrity": "sha512-48xSriZYYg+8qXna9kwqjIVzuQxi+KYWp2+5nCYnYKPTr0LvD89Jqk2Or5ogxz0NUMfIjhh2lIUX/LyX9B4oIg==",
+ "dev": true,
+ "funding": [
+ {
+ "type": "opencollective",
+ "url": "https://opencollective.com/browserslist"
+ },
+ {
+ "type": "tidelift",
+ "url": "https://tidelift.com/funding/github/npm/browserslist"
+ },
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/ai"
+ }
+ ],
+ "license": "MIT",
+ "dependencies": {
+ "baseline-browser-mapping": "^2.10.12",
+ "caniuse-lite": "^1.0.30001782",
+ "electron-to-chromium": "^1.5.328",
+ "node-releases": "^2.0.36",
+ "update-browserslist-db": "^1.2.3"
+ },
+ "bin": {
+ "browserslist": "cli.js"
+ },
+ "engines": {
+ "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7"
+ }
+ },
+ "node_modules/caniuse-lite": {
+ "version": "1.0.30001799",
+ "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001799.tgz",
+ "integrity": "sha512-hG1bReV+OUU+MOqK4t/ZWI0tZOyz3rqS9XuhOUz1cIcbwBKjOyJEJuw9ER5JuNyqxNk8u/JUVbGibBOL1yrjFw==",
+ "dev": true,
+ "funding": [
+ {
+ "type": "opencollective",
+ "url": "https://opencollective.com/browserslist"
+ },
+ {
+ "type": "tidelift",
+ "url": "https://tidelift.com/funding/github/npm/caniuse-lite"
+ },
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/ai"
+ }
+ ],
+ "license": "CC-BY-4.0"
+ },
+ "node_modules/convert-source-map": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz",
+ "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/debug": {
+ "version": "4.4.3",
+ "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
+ "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "ms": "^2.1.3"
+ },
+ "engines": {
+ "node": ">=6.0"
+ },
+ "peerDependenciesMeta": {
+ "supports-color": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/electron-to-chromium": {
+ "version": "1.5.376",
+ "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.376.tgz",
+ "integrity": "sha512-cUVA7/RvbFTEuw/i3obUwDTRIXojaxkResf+ibByPFxjc6XK3VNtcQXV0NSbAlJ0FMjcJGgftVVB4Qo184EXvA==",
+ "dev": true,
+ "license": "ISC"
+ },
+ "node_modules/esbuild": {
+ "version": "0.21.5",
+ "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.21.5.tgz",
+ "integrity": "sha512-mg3OPMV4hXywwpoDxu3Qda5xCKQi+vCTZq8S9J/EpkhB2HzKXq4SNFZE3+NK93JYxc8VMSep+lOUSC/RVKaBqw==",
+ "dev": true,
+ "hasInstallScript": true,
+ "license": "MIT",
+ "bin": {
+ "esbuild": "bin/esbuild"
+ },
+ "engines": {
+ "node": ">=12"
+ },
+ "optionalDependencies": {
+ "@esbuild/aix-ppc64": "0.21.5",
+ "@esbuild/android-arm": "0.21.5",
+ "@esbuild/android-arm64": "0.21.5",
+ "@esbuild/android-x64": "0.21.5",
+ "@esbuild/darwin-arm64": "0.21.5",
+ "@esbuild/darwin-x64": "0.21.5",
+ "@esbuild/freebsd-arm64": "0.21.5",
+ "@esbuild/freebsd-x64": "0.21.5",
+ "@esbuild/linux-arm": "0.21.5",
+ "@esbuild/linux-arm64": "0.21.5",
+ "@esbuild/linux-ia32": "0.21.5",
+ "@esbuild/linux-loong64": "0.21.5",
+ "@esbuild/linux-mips64el": "0.21.5",
+ "@esbuild/linux-ppc64": "0.21.5",
+ "@esbuild/linux-riscv64": "0.21.5",
+ "@esbuild/linux-s390x": "0.21.5",
+ "@esbuild/linux-x64": "0.21.5",
+ "@esbuild/netbsd-x64": "0.21.5",
+ "@esbuild/openbsd-x64": "0.21.5",
+ "@esbuild/sunos-x64": "0.21.5",
+ "@esbuild/win32-arm64": "0.21.5",
+ "@esbuild/win32-ia32": "0.21.5",
+ "@esbuild/win32-x64": "0.21.5"
+ }
+ },
+ "node_modules/escalade": {
+ "version": "3.2.0",
+ "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz",
+ "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=6"
+ }
+ },
+ "node_modules/fsevents": {
+ "version": "2.3.3",
+ "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz",
+ "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==",
+ "dev": true,
+ "hasInstallScript": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": "^8.16.0 || ^10.6.0 || >=11.0.0"
+ }
+ },
+ "node_modules/gensync": {
+ "version": "1.0.0-beta.2",
+ "resolved": "https://registry.npmjs.org/gensync/-/gensync-1.0.0-beta.2.tgz",
+ "integrity": "sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=6.9.0"
+ }
+ },
+ "node_modules/js-tokens": {
+ "version": "4.0.0",
+ "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz",
+ "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==",
+ "license": "MIT"
+ },
+ "node_modules/jsesc": {
+ "version": "3.1.0",
+ "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz",
+ "integrity": "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==",
+ "dev": true,
+ "license": "MIT",
+ "bin": {
+ "jsesc": "bin/jsesc"
+ },
+ "engines": {
+ "node": ">=6"
+ }
+ },
+ "node_modules/json5": {
+ "version": "2.2.3",
+ "resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz",
+ "integrity": "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==",
+ "dev": true,
+ "license": "MIT",
+ "bin": {
+ "json5": "lib/cli.js"
+ },
+ "engines": {
+ "node": ">=6"
+ }
+ },
+ "node_modules/loose-envify": {
+ "version": "1.4.0",
+ "resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz",
+ "integrity": "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==",
+ "license": "MIT",
+ "dependencies": {
+ "js-tokens": "^3.0.0 || ^4.0.0"
+ },
+ "bin": {
+ "loose-envify": "cli.js"
+ }
+ },
+ "node_modules/lru-cache": {
+ "version": "5.1.1",
+ "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz",
+ "integrity": "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==",
+ "dev": true,
+ "license": "ISC",
+ "dependencies": {
+ "yallist": "^3.0.2"
+ }
+ },
+ "node_modules/ms": {
+ "version": "2.1.3",
+ "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
+ "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/nanoid": {
+ "version": "3.3.14",
+ "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.14.tgz",
+ "integrity": "sha512-U9kYi5bpVMEI31yC8iw4bJJp0avcHXA0W8/wNfLfnvJYzihQo2ZRPYPvpAAd570HAcCBjCTN7vnr+v4StKl1IQ==",
+ "dev": true,
+ "funding": [
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/ai"
+ }
+ ],
+ "license": "MIT",
+ "bin": {
+ "nanoid": "bin/nanoid.cjs"
+ },
+ "engines": {
+ "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1"
+ }
+ },
+ "node_modules/node-releases": {
+ "version": "2.0.48",
+ "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.48.tgz",
+ "integrity": "sha512-1uz8041X6LoI6ZSdZacM9lVY28vuzDlSKitnpbSNK0RfKoIJkX29NBPVEFXhnuSuEOA9Ww0xnPJ+ILWbGAv8DA==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/picocolors": {
+ "version": "1.1.1",
+ "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",
+ "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==",
+ "dev": true,
+ "license": "ISC"
+ },
+ "node_modules/postcss": {
+ "version": "8.5.15",
+ "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.15.tgz",
+ "integrity": "sha512-FfR8sjd4em2T6fb3I2MwAJU7HWVMr9zba+enmQeeWFfCbm+UOC/0X4DS8XtpUTMwWMGbjKYP7xjfNekzyGmB3A==",
+ "dev": true,
+ "funding": [
+ {
+ "type": "opencollective",
+ "url": "https://opencollective.com/postcss/"
+ },
+ {
+ "type": "tidelift",
+ "url": "https://tidelift.com/funding/github/npm/postcss"
+ },
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/ai"
+ }
+ ],
+ "license": "MIT",
+ "dependencies": {
+ "nanoid": "^3.3.12",
+ "picocolors": "^1.1.1",
+ "source-map-js": "^1.2.1"
+ },
+ "engines": {
+ "node": "^10 || ^12 || >=14"
+ }
+ },
+ "node_modules/react": {
+ "version": "18.3.1",
+ "resolved": "https://registry.npmjs.org/react/-/react-18.3.1.tgz",
+ "integrity": "sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==",
+ "license": "MIT",
+ "dependencies": {
+ "loose-envify": "^1.1.0"
+ },
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/react-dom": {
+ "version": "18.3.1",
+ "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-18.3.1.tgz",
+ "integrity": "sha512-5m4nQKp+rZRb09LNH59GM4BxTh9251/ylbKIbpe7TpGxfJ+9kv6BLkLBXIjjspbgbnIBNqlI23tRnTWT0snUIw==",
+ "license": "MIT",
+ "dependencies": {
+ "loose-envify": "^1.1.0",
+ "scheduler": "^0.23.2"
+ },
+ "peerDependencies": {
+ "react": "^18.3.1"
+ }
+ },
+ "node_modules/react-refresh": {
+ "version": "0.17.0",
+ "resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.17.0.tgz",
+ "integrity": "sha512-z6F7K9bV85EfseRCp2bzrpyQ0Gkw1uLoCel9XBVWPg/TjRj94SkJzUTGfOa4bs7iJvBWtQG0Wq7wnI0syw3EBQ==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/rollup": {
+ "version": "4.62.2",
+ "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.62.2.tgz",
+ "integrity": "sha512-RFnrW4lhXA3s3eqHDZvN654g8OTjzRfqpIRJYczCGB6HzphckVAi/Qh4tbPUbRuDi7s1Llv8g/NspLkttY3gTA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@types/estree": "1.0.9"
+ },
+ "bin": {
+ "rollup": "dist/bin/rollup"
+ },
+ "engines": {
+ "node": ">=18.0.0",
+ "npm": ">=8.0.0"
+ },
+ "optionalDependencies": {
+ "@rollup/rollup-android-arm-eabi": "4.62.2",
+ "@rollup/rollup-android-arm64": "4.62.2",
+ "@rollup/rollup-darwin-arm64": "4.62.2",
+ "@rollup/rollup-darwin-x64": "4.62.2",
+ "@rollup/rollup-freebsd-arm64": "4.62.2",
+ "@rollup/rollup-freebsd-x64": "4.62.2",
+ "@rollup/rollup-linux-arm-gnueabihf": "4.62.2",
+ "@rollup/rollup-linux-arm-musleabihf": "4.62.2",
+ "@rollup/rollup-linux-arm64-gnu": "4.62.2",
+ "@rollup/rollup-linux-arm64-musl": "4.62.2",
+ "@rollup/rollup-linux-loong64-gnu": "4.62.2",
+ "@rollup/rollup-linux-loong64-musl": "4.62.2",
+ "@rollup/rollup-linux-ppc64-gnu": "4.62.2",
+ "@rollup/rollup-linux-ppc64-musl": "4.62.2",
+ "@rollup/rollup-linux-riscv64-gnu": "4.62.2",
+ "@rollup/rollup-linux-riscv64-musl": "4.62.2",
+ "@rollup/rollup-linux-s390x-gnu": "4.62.2",
+ "@rollup/rollup-linux-x64-gnu": "4.62.2",
+ "@rollup/rollup-linux-x64-musl": "4.62.2",
+ "@rollup/rollup-openbsd-x64": "4.62.2",
+ "@rollup/rollup-openharmony-arm64": "4.62.2",
+ "@rollup/rollup-win32-arm64-msvc": "4.62.2",
+ "@rollup/rollup-win32-ia32-msvc": "4.62.2",
+ "@rollup/rollup-win32-x64-gnu": "4.62.2",
+ "@rollup/rollup-win32-x64-msvc": "4.62.2",
+ "fsevents": "~2.3.2"
+ }
+ },
+ "node_modules/scheduler": {
+ "version": "0.23.2",
+ "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.23.2.tgz",
+ "integrity": "sha512-UOShsPwz7NrMUqhR6t0hWjFduvOzbtv7toDH1/hIrfRNIDBnnBWd0CwJTGvTpngVlmwGCdP9/Zl/tVrDqcuYzQ==",
+ "license": "MIT",
+ "dependencies": {
+ "loose-envify": "^1.1.0"
+ }
+ },
+ "node_modules/semver": {
+ "version": "6.3.1",
+ "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz",
+ "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==",
+ "dev": true,
+ "license": "ISC",
+ "bin": {
+ "semver": "bin/semver.js"
+ }
+ },
+ "node_modules/source-map-js": {
+ "version": "1.2.1",
+ "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz",
+ "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==",
+ "dev": true,
+ "license": "BSD-3-Clause",
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/update-browserslist-db": {
+ "version": "1.2.3",
+ "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.2.3.tgz",
+ "integrity": "sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w==",
+ "dev": true,
+ "funding": [
+ {
+ "type": "opencollective",
+ "url": "https://opencollective.com/browserslist"
+ },
+ {
+ "type": "tidelift",
+ "url": "https://tidelift.com/funding/github/npm/browserslist"
+ },
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/ai"
+ }
+ ],
+ "license": "MIT",
+ "dependencies": {
+ "escalade": "^3.2.0",
+ "picocolors": "^1.1.1"
+ },
+ "bin": {
+ "update-browserslist-db": "cli.js"
+ },
+ "peerDependencies": {
+ "browserslist": ">= 4.21.0"
+ }
+ },
+ "node_modules/vite": {
+ "version": "5.4.21",
+ "resolved": "https://registry.npmjs.org/vite/-/vite-5.4.21.tgz",
+ "integrity": "sha512-o5a9xKjbtuhY6Bi5S3+HvbRERmouabWbyUcpXXUA1u+GNUKoROi9byOJ8M0nHbHYHkYICiMlqxkg1KkYmm25Sw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "esbuild": "^0.21.3",
+ "postcss": "^8.4.43",
+ "rollup": "^4.20.0"
+ },
+ "bin": {
+ "vite": "bin/vite.js"
+ },
+ "engines": {
+ "node": "^18.0.0 || >=20.0.0"
+ },
+ "funding": {
+ "url": "https://github.com/vitejs/vite?sponsor=1"
+ },
+ "optionalDependencies": {
+ "fsevents": "~2.3.3"
+ },
+ "peerDependencies": {
+ "@types/node": "^18.0.0 || >=20.0.0",
+ "less": "*",
+ "lightningcss": "^1.21.0",
+ "sass": "*",
+ "sass-embedded": "*",
+ "stylus": "*",
+ "sugarss": "*",
+ "terser": "^5.4.0"
+ },
+ "peerDependenciesMeta": {
+ "@types/node": {
+ "optional": true
+ },
+ "less": {
+ "optional": true
+ },
+ "lightningcss": {
+ "optional": true
+ },
+ "sass": {
+ "optional": true
+ },
+ "sass-embedded": {
+ "optional": true
+ },
+ "stylus": {
+ "optional": true
+ },
+ "sugarss": {
+ "optional": true
+ },
+ "terser": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/yallist": {
+ "version": "3.1.1",
+ "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz",
+ "integrity": "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==",
+ "dev": true,
+ "license": "ISC"
+ }
+ }
+}
diff --git a/client/package.json b/client/package.json
new file mode 100644
index 0000000..fcdbe73
--- /dev/null
+++ b/client/package.json
@@ -0,0 +1,19 @@
+{
+ "name": "slither-client",
+ "private": true,
+ "version": "1.0.0",
+ "type": "module",
+ "scripts": {
+ "dev": "vite",
+ "build": "vite build",
+ "preview": "vite preview"
+ },
+ "dependencies": {
+ "react": "^18.3.1",
+ "react-dom": "^18.3.1"
+ },
+ "devDependencies": {
+ "@vitejs/plugin-react": "^4.3.1",
+ "vite": "^5.4.0"
+ }
+}
diff --git a/client/src/App.jsx b/client/src/App.jsx
new file mode 100644
index 0000000..a527b9c
--- /dev/null
+++ b/client/src/App.jsx
@@ -0,0 +1,317 @@
+import { useState, useEffect, useRef, useCallback } from 'react';
+import GameCanvas from './components/GameCanvas.jsx';
+import HUD from './components/HUD.jsx';
+import LobbyScreen from './components/LobbyScreen.jsx';
+import AuthScreen from './components/AuthScreen.jsx';
+import DeathScreen from './components/DeathScreen.jsx';
+import GameOverScreen from './components/GameOverScreen.jsx';
+import AchievementToast from './components/AchievementToast.jsx';
+import { usePlayerStats } from './hooks/usePlayerStats.js';
+import { useAchievements } from './hooks/useAchievements.js';
+import { audio } from './audio/AudioManager.js';
+
+const WS_URL = import.meta.env.VITE_WS_URL ||
+ (typeof window !== 'undefined'
+ ? (window.location.protocol === 'https:' ? 'wss://' : 'ws://') + window.location.host
+ : 'ws://localhost:3001');
+
+export default function App() {
+ const wsRef = useRef(null);
+ const { stats, recordDeath } = usePlayerStats();
+ const { unlocked, toasts, tryUnlock, dismissToast } = useAchievements();
+
+ // ── Phase + core state ────────────────────────────────────────────────────
+ const [phase, setPhase] = useState('auth'); // auth|lobby|playing|dead|gameover
+ const [playerId, setPlayerId] = useState(null);
+ const [playerName, setPlayerName] = useState('');
+ const [playerPin, setPlayerPin] = useState('');
+ const [roomCode, setRoomCode] = useState(null);
+ const [greeting, setGreeting] = useState(null);
+ const [lobbyError, setLobbyError] = useState(null);
+ const [lobbyLoading, setLobbyLoading] = useState(false);
+ const [createdCode, setCreatedCode] = useState(null);
+ const [snapshot, setSnapshot] = useState(null);
+ const [leaderboard, setLeaderboard] = useState([]);
+ const [killfeed, setKillfeed] = useState([]);
+ const [deathInfo, setDeathInfo] = useState(null);
+ const [gameOverData, setGameOverData] = useState(null);
+ const [timeLeftMs, setTimeLeftMs] = useState(null);
+
+ // ── Refs ──────────────────────────────────────────────────────────────────
+ const playerIdRef = useRef(null);
+ const playerNameRef = useRef('');
+ const phaseRef = useRef('lobby');
+ const pendingRef = useRef({});
+ const leaderboardRef = useRef([]);
+
+ useEffect(() => { playerIdRef.current = playerId; }, [playerId]);
+ useEffect(() => { playerNameRef.current = playerName; }, [playerName]);
+ useEffect(() => { phaseRef.current = phase; }, [phase]);
+ useEffect(() => { leaderboardRef.current = leaderboard; }, [leaderboard]);
+
+ // ── Session runtime tracking ──────────────────────────────────────────────
+ const sessRef = useRef({
+ kills: 0, startMs: null,
+ boostStartMs: null, maxBoostSec: 0,
+ eatTimestamps: [], maxQuickEat: 0,
+ prevScore: 0,
+ });
+
+ const resetSession = () => {
+ sessRef.current = {
+ kills: 0, startMs: Date.now(),
+ boostStartMs: null, maxBoostSec: 0,
+ eatTimestamps: [], maxQuickEat: 0,
+ prevScore: 0,
+ };
+ };
+
+ // ── WebSocket ──────────────────────────────────────────────────────────────
+ useEffect(() => {
+ let ws, reconnectTimer;
+
+ function connect() {
+ ws = new WebSocket(WS_URL);
+ wsRef.current = ws;
+
+ ws.onopen = () => { console.log('[WS] connected'); audio.resume(); };
+
+ ws.onmessage = (e) => {
+ let msg;
+ try { msg = JSON.parse(e.data); } catch { return; }
+
+ // Lobby responses
+ if (msg.type === 'name_status') { pendingRef.current.nameStatus?.(msg); return; }
+ if (msg.type === 'room_info') { pendingRef.current.roomInfo?.(msg); return; }
+
+ // Room created
+ if (msg.type === 'room_created') {
+ setLobbyLoading(false);
+ setCreatedCode(msg.roomCode);
+ setTimeout(() => {
+ setPlayerId(msg.id); playerIdRef.current = msg.id;
+ setRoomCode(msg.roomCode);
+ setGreeting(msg.greeting);
+ setLobbyError(null);
+ setPhase('playing'); phaseRef.current = 'playing';
+ resetSession();
+ audio.join();
+ }, 2000);
+ return;
+ }
+
+ // Joined existing room
+ if (msg.type === 'joined') {
+ setLobbyLoading(false);
+ setPlayerId(msg.id); playerIdRef.current = msg.id;
+ setRoomCode(msg.roomCode);
+ setGreeting(msg.greeting);
+ setLobbyError(null);
+ setCreatedCode(null);
+ setPhase('playing'); phaseRef.current = 'playing';
+ resetSession();
+ audio.join();
+ return;
+ }
+
+ // Error
+ if (msg.type === 'error') {
+ setLobbyLoading(false);
+ setLobbyError(msg.message || 'Something went wrong.');
+ return;
+ }
+
+ // Snapshot
+ if (msg.type === 'snapshot') {
+ setSnapshot(msg.data);
+ if (msg.leaderboard?.length > 0) {
+ setLeaderboard(msg.leaderboard);
+ leaderboardRef.current = msg.leaderboard;
+ }
+ if (msg.timeLeftMs !== undefined) setTimeLeftMs(msg.timeLeftMs);
+
+ // Runtime tracking
+ const me = msg.data?.snakes?.find(s => s.id === playerIdRef.current);
+ if (me && phaseRef.current === 'playing') {
+ const sess = sessRef.current;
+ const now = Date.now();
+
+ // Boost tracking
+ if (me.boosting) {
+ if (!sess.boostStartMs) sess.boostStartMs = now;
+ const sec = (now - sess.boostStartMs) / 1000;
+ if (sec > sess.maxBoostSec) sess.maxBoostSec = sec;
+ audio.startBoost();
+ } else {
+ if (sess.boostStartMs) { sess.boostStartMs = null; audio.stopBoost(); }
+ }
+
+ // Eat tracking (score increase = pellet eaten)
+ if (me.score > sess.prevScore) {
+ audio.eat();
+ const diff = me.score - sess.prevScore;
+ for (let i = 0; i < diff; i++) sess.eatTimestamps.push(now);
+ const recent = sess.eatTimestamps.filter(t => t > now - 5000);
+ sess.eatTimestamps = recent;
+ if (recent.length > sess.maxQuickEat) sess.maxQuickEat = recent.length;
+ }
+ sess.prevScore = me.score;
+ }
+ return;
+ }
+
+ // Death
+ if (msg.type === 'death') {
+ audio.stopBoost();
+ const sess = sessRef.current;
+ const survivalSec = sess.startMs ? Math.round((Date.now() - sess.startMs) / 1000) : 0;
+ const d = msg.data || {};
+ const lb = leaderboardRef.current;
+ const rank = lb.length > 0 ? (lb.findIndex(e => e.id === playerIdRef.current) + 1) || null : null;
+
+ recordDeath({ score: d.score || 0, survivalSec, kills: sess.kills, length: d.length || 0, rank });
+
+ const updatedStats = {
+ ...stats,
+ highScore: Math.max(stats.highScore, d.score || 0),
+ totalKills: stats.totalKills + sess.kills,
+ highLength: Math.max(stats.highLength, d.length || 0),
+ };
+ tryUnlock(updatedStats, { maxBoostSec: sess.maxBoostSec, maxQuickEat: sess.maxQuickEat });
+
+ setDeathInfo({ ...d, sessionKills: sess.kills, survivalSec });
+ setPhase('dead'); phaseRef.current = 'dead';
+ audio.die();
+ return;
+ }
+
+ // Respawned (also covers new round auto-respawn)
+ if (msg.type === 'respawned') {
+ setDeathInfo(null);
+ setGameOverData(null);
+ setPhase('playing'); phaseRef.current = 'playing';
+ resetSession();
+ audio.join();
+ return;
+ }
+
+ // Game over — 5-minute round ended
+ if (msg.type === 'game_over') {
+ audio.stopBoost();
+ setGameOverData({ leaderboard: msg.leaderboard || [], winner: msg.winner || null });
+ setPhase('gameover'); phaseRef.current = 'gameover';
+ return;
+ }
+
+ // Killfeed
+ if (msg.type === 'killfeed') {
+ const kd = msg.data;
+ setKillfeed(prev => [{ ...kd, key: Date.now() + Math.random() }, ...prev].slice(0, 6));
+ if (kd.killerName && playerNameRef.current && kd.killerName === playerNameRef.current) {
+ sessRef.current.kills++;
+ audio.kill();
+ }
+ return;
+ }
+ };
+
+ ws.onclose = () => { reconnectTimer = setTimeout(connect, 2000); };
+ ws.onerror = () => ws.close();
+ }
+
+ connect();
+ return () => { clearTimeout(reconnectTimer); ws?.close(); };
+ }, []); // eslint-disable-line
+
+ const wsSend = useCallback((obj) => {
+ if (wsRef.current?.readyState === WebSocket.OPEN) wsRef.current.send(JSON.stringify(obj));
+ }, []);
+
+ // ── Lobby actions ──────────────────────────────────────────────────────────
+ const handleCreate = useCallback((name, pin, roomPassword) => {
+ setLobbyError(null); setLobbyLoading(true);
+ setPlayerName(name); playerNameRef.current = name;
+ setPlayerPin(pin);
+ wsSend({ type: 'create_room', name, pin, roomPassword });
+ }, [wsSend]);
+
+ const handleJoin = useCallback((code, name, pin, password) => {
+ setLobbyError(null); setLobbyLoading(true);
+ setPlayerName(name); playerNameRef.current = name;
+ setPlayerPin(pin);
+ wsSend({ type: 'join_room', roomCode: code, name, pin, password });
+ }, [wsSend]);
+
+ const handleCheckRoom = useCallback((code) => {
+ return new Promise(resolve => {
+ pendingRef.current.roomInfo = resolve;
+ wsSend({ type: 'check_room', roomCode: code });
+ setTimeout(() => {
+ if (pendingRef.current.roomInfo === resolve) {
+ pendingRef.current.roomInfo = null;
+ resolve({ exists: false, passwordRequired: false });
+ }
+ }, 3000);
+ });
+ }, [wsSend]);
+
+ const handleInput = useCallback((dir, boost) => wsSend({ type: 'input', dir, boost }), [wsSend]);
+ const handleRespawn = useCallback(() => wsSend({ type: 'respawn' }), [wsSend]);
+
+ const inGame = phase === 'playing' || phase === 'dead' || phase === 'gameover';
+
+ const handleAuth = useCallback((name, pin) => {
+ setPlayerName(name); playerNameRef.current = name;
+ setPlayerPin(pin);
+ setPhase('lobby'); phaseRef.current = 'lobby';
+ }, []);
+
+ return (
+
+ {phase === 'auth' && (
+
+ )}
+
+ {phase === 'lobby' && (
+
{ setPhase('auth'); phaseRef.current = 'auth'; }}
+ />
+ )}
+
+ {inGame && (
+ <>
+
+ setGreeting(null)}
+ timeLeftMs={timeLeftMs}
+ />
+ {phase === 'dead' && (
+
+ )}
+ {phase === 'gameover' && (
+ {}} // auto-handled when server sends respawned
+ />
+ )}
+ >
+ )}
+
+
+
+ );
+}
diff --git a/client/src/audio/AudioManager.js b/client/src/audio/AudioManager.js
new file mode 100644
index 0000000..1769338
--- /dev/null
+++ b/client/src/audio/AudioManager.js
@@ -0,0 +1,99 @@
+class AudioManager {
+ constructor() {
+ this._ctx = null;
+ this._master = null;
+ this._boostOsc = null;
+ this._boostGain = null;
+ this.muted = false;
+ }
+
+ _init() {
+ if (this._ctx) return;
+ this._ctx = new (window.AudioContext || window.webkitAudioContext)();
+ this._master = this._ctx.createGain();
+ this._master.gain.value = 0.35;
+ this._master.connect(this._ctx.destination);
+ }
+
+ resume() {
+ try { this._init(); if (this._ctx.state === 'suspended') this._ctx.resume(); } catch {}
+ }
+
+ _tone(freq, dur, type = 'sine', vol = 0.3, delay = 0) {
+ if (this.muted) return;
+ try {
+ this._init();
+ const t = this._ctx.currentTime + delay;
+ const osc = this._ctx.createOscillator();
+ const gain = this._ctx.createGain();
+ osc.type = type;
+ osc.frequency.setValueAtTime(freq, t);
+ gain.gain.setValueAtTime(vol, t);
+ gain.gain.exponentialRampToValueAtTime(0.001, t + dur);
+ osc.connect(gain);
+ gain.connect(this._master);
+ osc.start(t);
+ osc.stop(t + dur + 0.05);
+ } catch {}
+ }
+
+ click() { this._tone(900, 0.05, 'square', 0.1); }
+ eat() { this._tone(1400, 0.07, 'sine', 0.15); this._tone(1800, 0.05, 'sine', 0.1, 0.05); }
+ kill() { this._tone(200, 0.1, 'sawtooth', 0.3); this._tone(150, 0.25, 'sawtooth', 0.2, 0.1); }
+ navSwitch() { this._tone(600, 0.06, 'sine', 0.08); }
+ join() { [440, 554, 659, 880].forEach((f, i) => this._tone(f, 0.18, 'sine', 0.2, i * 0.1)); }
+ achievement() { [523, 659, 784, 1047, 1047].forEach((f, i) => this._tone(f, 0.22, 'sine', 0.25, i * 0.13)); }
+
+ die() {
+ if (this.muted) return;
+ try {
+ this._init();
+ const t = this._ctx.currentTime;
+ const osc = this._ctx.createOscillator();
+ const gain = this._ctx.createGain();
+ osc.type = 'sawtooth';
+ osc.frequency.setValueAtTime(300, t);
+ osc.frequency.exponentialRampToValueAtTime(40, t + 2);
+ gain.gain.setValueAtTime(0.4, t);
+ gain.gain.exponentialRampToValueAtTime(0.001, t + 2);
+ osc.connect(gain);
+ gain.connect(this._master);
+ osc.start(t);
+ osc.stop(t + 2.1);
+ } catch {}
+ }
+
+ startBoost() {
+ if (this.muted || this._boostOsc) return;
+ try {
+ this._init();
+ this._boostOsc = this._ctx.createOscillator();
+ this._boostGain = this._ctx.createGain();
+ this._boostOsc.type = 'sawtooth';
+ this._boostOsc.frequency.value = 75;
+ this._boostGain.gain.value = 0.07;
+ this._boostOsc.connect(this._boostGain);
+ this._boostGain.connect(this._master);
+ this._boostOsc.start();
+ } catch {}
+ }
+
+ stopBoost() {
+ if (!this._boostOsc) return;
+ try {
+ const t = this._ctx.currentTime;
+ this._boostGain.gain.exponentialRampToValueAtTime(0.001, t + 0.15);
+ this._boostOsc.stop(t + 0.16);
+ } catch {}
+ this._boostOsc = null;
+ this._boostGain = null;
+ }
+
+ setMuted(val) {
+ this.muted = val;
+ if (this._master) this._master.gain.value = val ? 0 : 0.35;
+ if (val) this.stopBoost();
+ }
+}
+
+export const audio = new AudioManager();
diff --git a/client/src/components/AchievementToast.jsx b/client/src/components/AchievementToast.jsx
new file mode 100644
index 0000000..6a6643c
--- /dev/null
+++ b/client/src/components/AchievementToast.jsx
@@ -0,0 +1,54 @@
+import { useEffect, useState } from 'react';
+import { audio } from '../audio/AudioManager.js';
+
+function Toast({ a, onDone }) {
+ const [alive, setAlive] = useState(true);
+
+ useEffect(() => {
+ audio.achievement();
+ const t1 = setTimeout(() => setAlive(false), 4200);
+ const t2 = setTimeout(onDone, 4700);
+ return () => { clearTimeout(t1); clearTimeout(t2); };
+ }, []);
+
+ return (
+
+
{a.icon}
+
+
+ ACHIEVEMENT UNLOCKED
+
+
{a.name}
+
{a.desc}
+
+
+ );
+}
+
+export default function AchievementToast({ toasts, onDismiss }) {
+ if (!toasts.length) return null;
+ return (
+
+ {toasts.map(a => (
+ onDismiss(a.key)} />
+ ))}
+
+ );
+}
diff --git a/client/src/components/AnimatedBg.jsx b/client/src/components/AnimatedBg.jsx
new file mode 100644
index 0000000..622ca49
--- /dev/null
+++ b/client/src/components/AnimatedBg.jsx
@@ -0,0 +1,250 @@
+import { useEffect, useRef } from 'react';
+
+// ── Config ────────────────────────────────────────────────────────────────────
+const SNAKE_COUNT = 7;
+const PREY_COUNT = 11;
+const SNAKE_SPEED = 1.15;
+const BODY_LEN = 26;
+const TURN_RATE = 0.048;
+const SNAKE_HUES = [160, 200, 280, 35, 110, 340, 190];
+
+function rnd(a, b) { return a + Math.random() * (b - a); }
+
+export default function AnimatedBg() {
+ const canvasRef = useRef(null);
+
+ useEffect(() => {
+ const canvas = canvasRef.current;
+ if (!canvas) return;
+ const ctx = canvas.getContext('2d');
+ let W = 0, H = 0, raf, t = 0;
+
+ const resize = () => {
+ W = canvas.width = window.innerWidth;
+ H = canvas.height = window.innerHeight;
+ };
+ resize();
+ window.addEventListener('resize', resize);
+
+ // ── Particles (rising) ────────────────────────────────────────────────────
+ const particles = Array.from({ length: 50 }, () => ({
+ x: Math.random(), y: Math.random(),
+ r: rnd(0.5, 2.5),
+ vy: -(rnd(0.00012, 0.00035)),
+ hue: Math.random() < 0.5 ? rnd(170, 210) : rnd(260, 290),
+ a: rnd(0.1, 0.45),
+ }));
+
+ // ── Prey (glowing food dots) ───────────────────────────────────────────────
+ const prey = Array.from({ length: PREY_COUNT }, (_, i) => ({
+ x: rnd(80, window.innerWidth - 80),
+ y: rnd(80, window.innerHeight - 80),
+ hue: i * 33,
+ alive: true,
+ respawnIn: 0,
+ phase: rnd(0, Math.PI * 2),
+ wx: rnd(0, Math.PI * 2), // wander angle x
+ wy: rnd(0, Math.PI * 2), // wander angle y
+ }));
+
+ // ── Snakes ────────────────────────────────────────────────────────────────
+ const snakes = Array.from({ length: SNAKE_COUNT }, (_, i) => {
+ const sx = rnd(120, window.innerWidth - 120);
+ const sy = rnd(120, window.innerHeight - 120);
+ const ang = rnd(0, Math.PI * 2);
+ return {
+ x: sx, y: sy, angle: ang,
+ body: Array.from({ length: BODY_LEN }, (_, j) => [
+ sx - Math.cos(ang) * j * 7,
+ sy - Math.sin(ang) * j * 7,
+ ]),
+ hue: SNAKE_HUES[i % SNAKE_HUES.length],
+ targetIdx: i % PREY_COUNT,
+ wander: ang,
+ };
+ });
+
+ // ── Main loop ─────────────────────────────────────────────────────────────
+ const draw = () => {
+ raf = requestAnimationFrame(draw);
+ t += 0.007;
+
+ // ── Background ──
+ const rg = ctx.createRadialGradient(W / 2, H * 0.42, 0, W / 2, H * 0.42, Math.max(W, H) * 0.85);
+ rg.addColorStop(0, '#0a1228');
+ rg.addColorStop(0.5, '#060914');
+ rg.addColorStop(1, '#03060f');
+ ctx.fillStyle = rg;
+ ctx.fillRect(0, 0, W, H);
+
+ // ── Neon block grid (wave-animated) ──
+ const COLS = 14, ROWS = 9;
+ const cw = W / COLS, ch = H / ROWS;
+ for (let c = 0; c < COLS; c++) {
+ for (let r = 0; r < ROWS; r++) {
+ const wave = Math.sin(t * 0.9 + c * 0.55 + r * 1.0) * 0.5 + 0.5;
+ const bright= Math.sin(t * 0.3 + c * 0.4 - r * 0.7) * 0.5 + 0.5;
+ const hue = 185 + c * 4 + r * 2;
+ ctx.strokeStyle = `hsla(${hue},85%,65%,${wave * 0.065 + 0.01})`;
+ ctx.lineWidth = 1;
+ ctx.strokeRect(c * cw + 2, r * ch + 2, cw - 4, ch - 4);
+ if (Math.sin(t * 0.55 + c * 1.7 + r * 2.3) > 0.94) {
+ ctx.fillStyle = `hsla(${hue},90%,70%,${bright * 0.045})`;
+ ctx.fillRect(c * cw + 2, r * ch + 2, cw - 4, ch - 4);
+ }
+ }
+ }
+
+ // Faint grid lines
+ ctx.strokeStyle = 'rgba(0,200,255,0.02)';
+ ctx.lineWidth = 1;
+ for (let c = 0; c <= COLS; c++) {
+ ctx.beginPath(); ctx.moveTo(c * cw, 0); ctx.lineTo(c * cw, H); ctx.stroke();
+ }
+ for (let r = 0; r <= ROWS; r++) {
+ ctx.beginPath(); ctx.moveTo(0, r * ch); ctx.lineTo(W, r * ch); ctx.stroke();
+ }
+
+ // ── Rising particles ──
+ for (const p of particles) {
+ p.y += p.vy;
+ if (p.y < -0.02) { p.y = 1.02; p.x = Math.random(); }
+ const fade = p.a * (1 - Math.abs(p.y - 0.5) * 1.5);
+ if (fade <= 0) continue;
+ ctx.beginPath();
+ ctx.arc(p.x * W, p.y * H, p.r, 0, Math.PI * 2);
+ ctx.fillStyle = `hsla(${p.hue},100%,78%,${fade})`;
+ ctx.fill();
+ }
+
+ // ── Prey ──
+ prey.forEach((p) => {
+ if (!p.alive) {
+ p.respawnIn--;
+ if (p.respawnIn <= 0) {
+ p.x = rnd(60, W - 60);
+ p.y = rnd(60, H - 60);
+ p.alive = true;
+ p.hue = rnd(0, 360);
+ p.wx = rnd(0, Math.PI * 2);
+ p.wy = rnd(0, Math.PI * 2);
+ }
+ return;
+ }
+ p.phase += 0.045;
+ p.wx += (Math.random() - 0.5) * 0.025;
+ p.wy += (Math.random() - 0.5) * 0.025;
+ p.x += Math.cos(p.wx) * 0.45;
+ p.y += Math.sin(p.wy) * 0.45;
+ p.x = Math.max(30, Math.min(W - 30, p.x));
+ p.y = Math.max(30, Math.min(H - 30, p.y));
+
+ const pulse = 0.5 + 0.5 * Math.sin(p.phase);
+ const gr = 3 + pulse * 1.8;
+
+ const grd = ctx.createRadialGradient(p.x, p.y, 0, p.x, p.y, gr * 5);
+ grd.addColorStop(0, `hsla(${p.hue},100%,78%,0.65)`);
+ grd.addColorStop(0.4, `hsla(${p.hue},100%,60%,0.2)`);
+ grd.addColorStop(1, `hsla(${p.hue},100%,40%,0)`);
+ ctx.beginPath();
+ ctx.arc(p.x, p.y, gr * 5, 0, Math.PI * 2);
+ ctx.fillStyle = grd;
+ ctx.fill();
+
+ ctx.beginPath();
+ ctx.arc(p.x, p.y, gr, 0, Math.PI * 2);
+ ctx.fillStyle = `hsla(${p.hue},100%,90%,${0.8 + pulse * 0.2})`;
+ ctx.fill();
+ });
+
+ // ── Snakes ──
+ snakes.forEach(s => {
+ // Find alive target
+ let tgt = prey[s.targetIdx];
+ if (!tgt?.alive) {
+ const ni = prey.findIndex(p => p.alive);
+ if (ni >= 0) { s.targetIdx = ni; tgt = prey[ni]; }
+ }
+
+ if (tgt?.alive) {
+ const dx = tgt.x - s.x, dy = tgt.y - s.y;
+ const dist = Math.hypot(dx, dy);
+ if (dist < 12) {
+ tgt.alive = false;
+ tgt.respawnIn = 110;
+ const ni = prey.findIndex((p, i) => i !== s.targetIdx && p.alive);
+ if (ni >= 0) s.targetIdx = ni;
+ } else {
+ let ta = Math.atan2(dy, dx);
+ let diff = ta - s.angle;
+ while (diff > Math.PI) diff -= Math.PI * 2;
+ while (diff < -Math.PI) diff += Math.PI * 2;
+ s.angle += Math.sign(diff) * Math.min(Math.abs(diff), TURN_RATE);
+ }
+ } else {
+ s.wander += (Math.random() - 0.5) * 0.07;
+ s.angle += (s.wander - s.angle) * 0.04;
+ }
+
+ s.x += Math.cos(s.angle) * SNAKE_SPEED;
+ s.y += Math.sin(s.angle) * SNAKE_SPEED;
+ if (s.x < -40) s.x = W + 40;
+ if (s.x > W + 40) s.x = -40;
+ if (s.y < -40) s.y = H + 40;
+ if (s.y > H + 40) s.y = -40;
+
+ s.body.unshift([s.x, s.y]);
+ if (s.body.length > BODY_LEN) s.body.length = BODY_LEN;
+
+ // Body segments (back-to-front so head is on top)
+ for (let j = s.body.length - 1; j >= 0; j--) {
+ const [bx, by] = s.body[j];
+ const frac = 1 - j / s.body.length;
+ const r = 1.5 + frac * 4.5;
+ const a = 0.06 + frac * 0.42;
+ ctx.beginPath();
+ ctx.arc(bx, by, r, 0, Math.PI * 2);
+ ctx.fillStyle = `hsla(${s.hue},80%,60%,${a})`;
+ ctx.fill();
+ }
+
+ // Head with glow
+ ctx.shadowColor = `hsl(${s.hue},100%,65%)`;
+ ctx.shadowBlur = 12;
+ ctx.beginPath();
+ ctx.arc(s.x, s.y, 5.5, 0, Math.PI * 2);
+ ctx.fillStyle = `hsl(${s.hue},100%,78%)`;
+ ctx.fill();
+ ctx.shadowBlur = 0;
+ });
+
+ // ── Diagonal light sweep ──
+ ctx.save();
+ ctx.globalAlpha = 0.018 + Math.sin(t * 0.3) * 0.006;
+ const ray = ctx.createLinearGradient(0, 0, W, H);
+ ray.addColorStop(0, 'transparent');
+ ray.addColorStop(0.5, '#00e8ff');
+ ray.addColorStop(1, 'transparent');
+ ctx.fillStyle = ray;
+ ctx.fillRect(0, 0, W, H);
+ ctx.restore();
+
+ // ── Vignette ──
+ const vig = ctx.createRadialGradient(W / 2, H / 2, 0, W / 2, H / 2, Math.max(W, H) * 0.65);
+ vig.addColorStop(0, 'transparent');
+ vig.addColorStop(1, 'rgba(0,0,0,0.6)');
+ ctx.fillStyle = vig;
+ ctx.fillRect(0, 0, W, H);
+ };
+
+ draw();
+ return () => { cancelAnimationFrame(raf); window.removeEventListener('resize', resize); };
+ }, []);
+
+ return (
+
+ );
+}
diff --git a/client/src/components/ArenaPanel.jsx b/client/src/components/ArenaPanel.jsx
new file mode 100644
index 0000000..fe03e16
--- /dev/null
+++ b/client/src/components/ArenaPanel.jsx
@@ -0,0 +1,244 @@
+import { useState } from 'react';
+import { audio } from '../audio/AudioManager.js';
+
+const COLORS = [
+ { id: 'emerald', label: 'Emerald', head: '#22c55e', body: '#16a34a' },
+ { id: 'cobalt', label: 'Cobalt', head: '#3b82f6', body: '#2563eb' },
+ { id: 'crimson', label: 'Crimson', head: '#ef4444', body: '#dc2626' },
+ { id: 'amber', label: 'Amber', head: '#f59e0b', body: '#d97706' },
+ { id: 'violet', label: 'Violet', head: '#a855f7', body: '#9333ea' },
+ { id: 'cyber', label: 'Cyber', head: '#06b6d4', body: '#0891b2' },
+ { id: 'rose', label: 'Rose', head: '#f43f5e', body: '#e11d48' },
+ { id: 'venom', label: 'Venom', head: '#84cc16', body: '#65a30d' },
+];
+
+const EMOJIS = ['🐍', '👾', '🤖', '🦦', '🐲', '🦅', '🐺', '💀'];
+
+const loadAvatar = () => {
+ try { return JSON.parse(localStorage.getItem('sq_avatar') || '{"colorId":"emerald","emoji":"🐍"}'); }
+ catch { return { colorId: 'emerald', emoji: '🐍' }; }
+};
+
+const loadCredentials = () => ({
+ name: (() => { try { return localStorage.getItem('sq_last_name') || ''; } catch { return ''; } })(),
+ pin: (() => { try { return localStorage.getItem('sq_last_pin') || ''; } catch { return ''; } })(),
+});
+
+function Label({ children }) {
+ return (
+
+ {children}
+
+ );
+}
+
+function SnakePreview({ color, emoji }) {
+ return (
+
+ {[40, 32, 26, 20, 16].map((size, i) => (
+
+ {i === 0 ? emoji : null}
+
+ ))}
+
+ );
+}
+
+export default function ArenaPanel() {
+ const [avatar, setAvatar] = useState(loadAvatar);
+ const creds = loadCredentials();
+ const [credName, setCredName] = useState(creds.name);
+ const [credPin, setCredPin] = useState(creds.pin);
+ const [saved, setSaved] = useState(false);
+
+ const color = COLORS.find(c => c.id === avatar.colorId) || COLORS[0];
+
+ const saveAvatar = (next) => {
+ setAvatar(next);
+ audio.click();
+ try { localStorage.setItem('sq_avatar', JSON.stringify(next)); } catch {}
+ };
+
+ const saveCredentials = () => {
+ try {
+ if (credName.trim()) localStorage.setItem('sq_last_name', credName.trim());
+ if (credPin.length === 4) localStorage.setItem('sq_last_pin', credPin);
+ } catch {}
+ audio.click();
+ setSaved(true);
+ setTimeout(() => setSaved(false), 2200);
+ };
+
+ const credReady = credName.trim().length >= 2 && credPin.length === 4;
+
+ const inputStyle = {
+ background: 'rgba(255,255,255,0.05)',
+ border: '1px solid rgba(255,255,255,0.1)',
+ borderRadius: 10, padding: '11px 14px',
+ color: '#f1f5f9', fontSize: 14,
+ outline: 'none', width: '100%',
+ fontFamily: 'inherit', boxSizing: 'border-box',
+ };
+
+ return (
+
+
+ {/* ── Snake preview ── */}
+
+
+
+
+ {avatar.emoji} {credName || 'Your Snake'}
+
+
{color.label} skin
+ {credName && credPin.length === 4 && (
+
✓ Credentials set
+ )}
+
+
+
+ {/* ── Player Credentials (editable) ── */}
+
+
+ PLAYER CREDENTIALS
+
+
+ Set your name and PIN here — they'll auto-fill when you create or join a room.
+
+
+ {/* Name */}
+
+ YOUR NAME
+ setCredName(e.target.value)}
+ onFocus={e => { e.target.style.borderColor = 'rgba(0,245,255,0.35)'; }}
+ onBlur={e => { e.target.style.borderColor = 'rgba(255,255,255,0.1)'; }}
+ />
+
+
+ {/* PIN */}
+
+
4-DIGIT PIN
+
setCredPin(e.target.value.replace(/\D/g, '').slice(0, 4))}
+ onFocus={e => { e.target.style.borderColor = 'rgba(0,245,255,0.35)'; }}
+ onBlur={e => { e.target.style.borderColor = 'rgba(255,255,255,0.1)'; }}
+ />
+
+ PIN locks your identity across sessions. First use registers, return use verifies.
+
+
+
+ {/* Save button */}
+
+ {saved ? '✓ CREDENTIALS SAVED' : 'SAVE CREDENTIALS'}
+
+
+
+ {/* ── Color picker ── */}
+
+
SNAKE SKIN
+
+ {COLORS.map(c => (
+ saveAvatar({ ...avatar, colorId: c.id })}
+ style={{
+ width: 44, height: 44, borderRadius: 12, cursor: 'pointer',
+ background: `linear-gradient(135deg, ${c.head}, ${c.body})`,
+ border: avatar.colorId === c.id ? '3px solid #fff' : '3px solid transparent',
+ boxShadow: avatar.colorId === c.id ? `0 0 16px ${c.head}cc` : 'none',
+ transition: 'all 0.2s', outline: 'none',
+ }} />
+ ))}
+
+
+
+ {/* ── Emoji picker ── */}
+
+
AVATAR ICON
+
+ {EMOJIS.map(e => (
+ saveAvatar({ ...avatar, emoji: e })}
+ style={{
+ fontSize: 22, padding: '8px 10px',
+ background: avatar.emoji === e ? 'rgba(0,200,255,0.12)' : 'rgba(255,255,255,0.04)',
+ border: avatar.emoji === e ? '1px solid rgba(0,200,255,0.4)' : '1px solid rgba(255,255,255,0.07)',
+ borderRadius: 10, cursor: 'pointer',
+ boxShadow: avatar.emoji === e ? '0 0 12px rgba(0,200,255,0.25)' : 'none',
+ transition: 'all 0.2s', outline: 'none',
+ }}>{e}
+ ))}
+
+
+
+ {/* ── Room browser notice ── */}
+
+
🔭
+
Open Room Browser
+
+ Browse and join open rooms from the{' '}
+ JOIN ROOM tab
+
+
+
+ );
+}
diff --git a/client/src/components/AuthScreen.jsx b/client/src/components/AuthScreen.jsx
new file mode 100644
index 0000000..1dcf5ca
--- /dev/null
+++ b/client/src/components/AuthScreen.jsx
@@ -0,0 +1,290 @@
+import { useState, useEffect, useRef } from 'react';
+import AnimatedBg from './AnimatedBg.jsx';
+import { audio } from '../audio/AudioManager.js';
+
+const FONT = "'Space Grotesk','Inter',system-ui,sans-serif";
+
+const T = {
+ teal: '#00d4aa',
+ tealGlow: 'rgba(0,212,170,0.3)',
+ tealDim: 'rgba(0,212,170,0.1)',
+ blue: '#38bdf8',
+ panel: 'rgba(6,10,24,0.92)',
+ border: 'rgba(255,255,255,0.07)',
+ tealBorder: 'rgba(0,212,170,0.3)',
+ text: '#e8f4ff',
+ muted: '#4a6080',
+ error: '#f87171',
+};
+
+const inp = {
+ background: 'rgba(4,8,20,0.9)',
+ border: '1px solid rgba(255,255,255,0.08)',
+ borderRadius: 10,
+ padding: '13px 16px',
+ color: T.text,
+ fontSize: 15,
+ outline: 'none',
+ width: '100%',
+ fontFamily: FONT,
+ boxSizing: 'border-box',
+ transition: 'border-color 0.2s',
+};
+
+export default function AuthScreen({ wsSend, pendingRef, onAuth }) {
+ const [name, setName] = useState(() => { try { return localStorage.getItem('sq_last_name') || ''; } catch { return ''; } });
+ const [pin, setPin] = useState('');
+ const [mode, setMode] = useState(null); // null | 'register' | 'login'
+ const [step, setStep] = useState('name'); // 'name' | 'pin'
+ const [loading, setLoading] = useState(false);
+ const [error, setError] = useState('');
+ const [shake, setShake] = useState(false);
+ const nameRef = useRef(null);
+ const pinRef = useRef(null);
+
+ useEffect(() => { nameRef.current?.focus(); }, []);
+ useEffect(() => { if (step === 'pin') pinRef.current?.focus(); }, [step]);
+
+ const triggerShake = () => {
+ setShake(true);
+ setTimeout(() => setShake(false), 500);
+ };
+
+ const checkName = async () => {
+ const n = name.trim();
+ if (n.length < 2) { setError('Name must be at least 2 characters.'); triggerShake(); return; }
+ setError('');
+ setLoading(true);
+ audio.click();
+
+ const result = await new Promise(resolve => {
+ pendingRef.current.nameStatus = resolve;
+ wsSend({ type: 'check_name', name: n });
+ setTimeout(() => {
+ if (pendingRef.current.nameStatus === resolve) {
+ pendingRef.current.nameStatus = null;
+ resolve(null);
+ }
+ }, 4000);
+ });
+
+ setLoading(false);
+ if (!result) { setError('Connection error. Try again.'); triggerShake(); return; }
+ setMode(result.isNew ? 'register' : 'login');
+ setStep('pin');
+ };
+
+ const submit = () => {
+ if (pin.length !== 4) { setError('PIN must be 4 digits.'); triggerShake(); return; }
+ setError('');
+ audio.click();
+ try { localStorage.setItem('sq_last_name', name.trim()); } catch {}
+ try { localStorage.setItem('sq_last_pin', pin); } catch {}
+ onAuth(name.trim(), pin);
+ };
+
+ const back = () => {
+ setStep('name');
+ setMode(null);
+ setPin('');
+ setError('');
+ setTimeout(() => nameRef.current?.focus(), 50);
+ };
+
+ const nameTrimmed = name.trim();
+
+ return (
+
+
+
+ {/* Dark overlay */}
+
+
+ {/* Center card */}
+
+
+
+ {/* Logo */}
+
+
🐍
+
SLITHER QUEST
+
+ {step === 'name'
+ ? 'ENTER YOUR IDENTITY'
+ : mode === 'register'
+ ? '🆕 CREATE YOUR PIN'
+ : '🔒 WELCOME BACK'}
+
+
+
+ {/* Step: Name */}
+ {step === 'name' && (
+
+
+
+ PLAYER NAME
+
+
{ setName(e.target.value); setError(''); }}
+ onFocus={e => { e.target.style.borderColor = T.tealBorder; }}
+ onBlur={e => { e.target.style.borderColor = 'rgba(255,255,255,0.08)'; }}
+ onKeyDown={e => e.key === 'Enter' && !loading && nameTrimmed.length >= 2 && checkName()}
+ />
+
+
+ {error &&
{error} }
+
+
+ {loading ? 'CHECKING…' : 'CONTINUE →'}
+
+
+ )}
+
+ {/* Step: PIN */}
+ {step === 'pin' && (
+
+ {/* Who */}
+
+
👤
+
+
{name.trim()}
+
+ {mode === 'register' ? 'New player — set your PIN' : 'Returning player — enter PIN'}
+
+
+
change
+
+
+
+
+ 4-DIGIT PIN
+
+
{ setPin(e.target.value.replace(/\D/g, '').slice(0, 4)); setError(''); }}
+ onFocus={e => { e.target.style.borderColor = T.tealBorder; }}
+ onBlur={e => { e.target.style.borderColor = 'rgba(255,255,255,0.08)'; }}
+ onKeyDown={e => e.key === 'Enter' && pin.length === 4 && submit()}
+ />
+
+ {mode === 'register'
+ ? 'Choose a 4-digit PIN — it locks your identity.'
+ : 'Enter your PIN to verify identity.'}
+
+
+
+ {error &&
{error} }
+
+
+ {mode === 'register' ? '🎮 CREATE ACCOUNT' : '🔓 LOGIN'}
+
+
+ )}
+
+ {/* Footer */}
+
+ Your PIN is hashed and stored securely.
+
+
+
+
+
+
+ );
+}
+
+function Btn({ children, onClick, disabled, loading }) {
+ return (
+
+ {children}
+
+ );
+}
+
+function ErrorMsg({ children }) {
+ return (
+
+ ⚠ {children}
+
+ );
+}
diff --git a/client/src/components/DeathScreen.jsx b/client/src/components/DeathScreen.jsx
new file mode 100644
index 0000000..7e8f134
--- /dev/null
+++ b/client/src/components/DeathScreen.jsx
@@ -0,0 +1,128 @@
+import { useEffect, useState, useRef } from 'react';
+import { audio } from '../audio/AudioManager.js';
+
+export default function DeathScreen({ info, onRespawn, sessionStats }) {
+ const [countdown, setCountdown] = useState(3);
+ const played = useRef(false);
+
+ useEffect(() => {
+ setCountdown(3);
+ played.current = false;
+ }, [info]);
+
+ useEffect(() => {
+ if (!played.current) { audio.die(); played.current = true; }
+ const t = setInterval(() => {
+ setCountdown(p => { if (p <= 1) { clearInterval(t); return 0; } return p - 1; });
+ }, 1000);
+ return () => clearInterval(t);
+ }, [info]);
+
+ const ready = countdown === 0;
+ const fmtTime = s => s >= 60 ? `${Math.floor(s / 60)}m ${s % 60}s` : `${s}s`;
+
+ // SVG countdown ring
+ const R = 22, CIRC = 2 * Math.PI * R;
+ const dash = ready ? CIRC : CIRC * (1 - countdown / 3);
+
+ return (
+
+
+
+ {/* Skull */}
+
💀
+
+ {/* Title */}
+
YOU DIED
+
+ {/* Cause */}
+ {info && (
+
+ {info.killerName
+ ?
Devoured by {info.killerName}
+ :
Crashed into the wall
}
+ {info.length > 0 &&
Length reached {info.length}
}
+ {info.score > 0 &&
Score {info.score}
}
+
+ )}
+
+ {/* Session stats chips */}
+ {sessionStats && (sessionStats.kills > 0 || sessionStats.survivalSec > 0) && (
+
+ {sessionStats.kills > 0 && }
+ {sessionStats.survivalSec > 0 && }
+
+ )}
+
+ {/* Countdown ring + respawn button */}
+
+ {!ready && (
+
+ )}
+
+ {ready ? 'RESPAWN →' : `Respawning in ${countdown}…`}
+
+
+
+
+
+
+ );
+}
+
+function StatChip({ label, val, color }) {
+ return (
+
+ );
+}
diff --git a/client/src/components/GameCanvas.jsx b/client/src/components/GameCanvas.jsx
new file mode 100644
index 0000000..60be09b
--- /dev/null
+++ b/client/src/components/GameCanvas.jsx
@@ -0,0 +1,555 @@
+import { useRef, useEffect } from 'react';
+
+const GRID_SIZE = 100;
+
+// ── Grid ──────────────────────────────────────────────────────────────────────
+
+function drawGrid(ctx, camX, camY, w, h) {
+ ctx.strokeStyle = 'rgba(255,255,255,0.025)';
+ ctx.lineWidth = 1;
+ const x0 = Math.floor((camX - w / 2) / GRID_SIZE) * GRID_SIZE;
+ const y0 = Math.floor((camY - h / 2) / GRID_SIZE) * GRID_SIZE;
+ ctx.beginPath();
+ for (let x = x0; x < camX + w / 2 + GRID_SIZE; x += GRID_SIZE) {
+ ctx.moveTo(x, camY - h / 2); ctx.lineTo(x, camY + h / 2);
+ }
+ for (let y = y0; y < camY + h / 2 + GRID_SIZE; y += GRID_SIZE) {
+ ctx.moveTo(camX - w / 2, y); ctx.lineTo(camX + w / 2, y);
+ }
+ ctx.stroke();
+}
+
+// ── World border ──────────────────────────────────────────────────────────────
+
+function drawWorldBorder(ctx, worldSize) {
+ ctx.shadowColor = '#ff3355';
+ ctx.shadowBlur = 18;
+ ctx.strokeStyle = '#ff3355';
+ ctx.lineWidth = 5;
+ ctx.strokeRect(0, 0, worldSize, worldSize);
+ ctx.shadowBlur = 0;
+}
+
+// ── Pellets ───────────────────────────────────────────────────────────────────
+
+const FRUITS = ['🍎', '🍊', '🍋', '🍇', '🍓', '🫐', '🍑', '🍒'];
+
+function drawPellets(ctx, pellets, frame) {
+ for (const [id, x, y, hue] of pellets) {
+ const t = id % 4;
+ const pulse = 0.6 + 0.4 * Math.sin(frame * 0.06 + id * 0.37);
+
+ if (t === 3) {
+ // Fruit emoji (every 4th pellet)
+ ctx.shadowBlur = 0;
+ ctx.globalAlpha = 0.92;
+ ctx.font = `${(11 + pulse * 4) | 0}px serif`;
+ ctx.textAlign = 'center';
+ ctx.textBaseline = 'middle';
+ ctx.fillText(FRUITS[id % FRUITS.length], x, y);
+ ctx.globalAlpha = 1;
+ ctx.textBaseline = 'alphabetic';
+ } else {
+ // Standard glowing dot
+ const r = 4 + pulse * 1.8;
+ ctx.shadowColor = `hsl(${hue},100%,65%)`;
+ ctx.shadowBlur = 10;
+ const grd = ctx.createRadialGradient(x, y, 0, x, y, r * 1.8);
+ grd.addColorStop(0, `hsl(${hue},100%,88%)`);
+ grd.addColorStop(0.5, `hsl(${hue},90%,62%)`);
+ grd.addColorStop(1, `hsla(${hue},80%,40%,0)`);
+ ctx.beginPath();
+ ctx.arc(x, y, r, 0, Math.PI * 2);
+ ctx.fillStyle = grd;
+ ctx.fill();
+ ctx.shadowBlur = 0;
+ }
+ }
+ // Ensure clean state after pellets
+ ctx.shadowBlur = 0;
+ ctx.globalAlpha = 1;
+ ctx.textBaseline = 'alphabetic';
+}
+
+// ── Snake ─────────────────────────────────────────────────────────────────────
+
+function drawSnake(ctx, snake, isOwn) {
+ const body = snake.body; // [[x,y], ...]
+ const hw = isOwn ? 12 : 9.5;
+
+ // Body polyline (only if we have body data)
+ if (body && body.length > 1) {
+ if (isOwn) {
+ // Outer glow
+ ctx.beginPath();
+ ctx.moveTo(snake.x, snake.y);
+ for (const [bx, by] of body) ctx.lineTo(bx, by);
+ ctx.strokeStyle = `${snake.color}44`;
+ ctx.lineWidth = hw * 2 + 10;
+ ctx.lineCap = 'round';
+ ctx.lineJoin = 'round';
+ ctx.stroke();
+ }
+
+ // Main body
+ ctx.beginPath();
+ ctx.moveTo(snake.x, snake.y);
+ for (const [bx, by] of body) ctx.lineTo(bx, by);
+ ctx.strokeStyle = snake.color;
+ ctx.lineWidth = hw * 2;
+ ctx.lineCap = 'round';
+ ctx.lineJoin = 'round';
+ ctx.stroke();
+
+ // Dark spine
+ ctx.strokeStyle = 'rgba(0,0,0,0.28)';
+ ctx.lineWidth = hw * 0.65;
+ ctx.stroke();
+
+ // Light highlight
+ ctx.strokeStyle = 'rgba(255,255,255,0.1)';
+ ctx.lineWidth = hw * 0.3;
+ ctx.stroke();
+ }
+
+ // Head — always drawn
+ const headR = hw + 2;
+ ctx.shadowColor = snake.color;
+ ctx.shadowBlur = 14;
+ ctx.beginPath();
+ ctx.arc(snake.x, snake.y, headR, 0, Math.PI * 2);
+ ctx.fillStyle = snake.color;
+ ctx.fill();
+ ctx.shadowBlur = 0;
+ ctx.strokeStyle = 'rgba(255,255,255,0.4)';
+ ctx.lineWidth = 2;
+ ctx.stroke();
+
+ // Eyes
+ const eyeDist = headR * 0.55;
+ for (const off of [-0.42, 0.42]) {
+ const ea = snake.dir + off;
+ const ex = snake.x + Math.cos(ea) * eyeDist;
+ const ey = snake.y + Math.sin(ea) * eyeDist;
+ ctx.beginPath();
+ ctx.arc(ex, ey, 3.5, 0, Math.PI * 2);
+ ctx.fillStyle = '#fff';
+ ctx.fill();
+ ctx.beginPath();
+ ctx.arc(
+ ex + Math.cos(snake.dir) * 1.2,
+ ey + Math.sin(snake.dir) * 1.2,
+ 1.8, 0, Math.PI * 2,
+ );
+ ctx.fillStyle = '#111';
+ ctx.fill();
+ }
+
+ // Boost spark at tail
+ if (snake.boosting && body && body.length > 0) {
+ const tail = body[body.length - 1];
+ ctx.shadowColor = snake.color;
+ ctx.shadowBlur = 16;
+ ctx.beginPath();
+ ctx.arc(tail[0], tail[1], 7, 0, Math.PI * 2);
+ ctx.fillStyle = `${snake.color}bb`;
+ ctx.fill();
+ ctx.shadowBlur = 0;
+ }
+
+ // Name label
+ const ly = snake.y - headR - 11;
+ ctx.font = isOwn ? 'bold 14px sans-serif' : '12px sans-serif';
+ ctx.textAlign = 'center';
+ ctx.textBaseline = 'alphabetic';
+ ctx.fillStyle = 'rgba(0,0,0,0.6)';
+ ctx.fillText(snake.name, snake.x + 1, ly + 1);
+ ctx.fillStyle = isOwn ? '#ffe566' : '#e2e8f0';
+ ctx.fillText(snake.name, snake.x, ly);
+}
+
+// ── Explosion system ──────────────────────────────────────────────────────────
+
+function addExplosion(list, x, y, color) {
+ const COUNT = 32;
+ const particles = [];
+ for (let i = 0; i < COUNT; i++) {
+ const angle = (i / COUNT) * Math.PI * 2 + (Math.random() - 0.5) * 0.5;
+ const spd = 1.5 + Math.random() * 7;
+ const spark = Math.random() < 0.4;
+ particles.push({
+ x, y,
+ vx: Math.cos(angle) * spd,
+ vy: Math.sin(angle) * spd,
+ r: 2 + Math.random() * 3,
+ col: spark ? `hsl(${20 + Math.random() * 35 | 0},100%,65%)` : color,
+ life: 1.0,
+ dec: 0.015 + Math.random() * 0.012,
+ });
+ }
+ list.push({ particles, ring: 0, ringMax: 65, x, y, color });
+}
+
+function updateDrawExplosions(ctx, list) {
+ for (let i = list.length - 1; i >= 0; i--) {
+ const e = list[i];
+ let alive = false;
+
+ // Expanding ring
+ if (e.ring < e.ringMax) {
+ e.ring += 3.5;
+ const alpha = (1 - e.ring / e.ringMax) * 0.65;
+ ctx.globalAlpha = alpha;
+ ctx.beginPath();
+ ctx.arc(e.x, e.y, e.ring, 0, Math.PI * 2);
+ ctx.strokeStyle = e.color;
+ ctx.lineWidth = 2.5;
+ ctx.stroke();
+ ctx.globalAlpha = 1;
+ alive = true;
+ }
+
+ // Particles
+ for (const p of e.particles) {
+ if (p.life <= 0) continue;
+ alive = true;
+ p.x += p.vx; p.y += p.vy;
+ p.vx *= 0.93; p.vy *= 0.93;
+ p.life -= p.dec;
+ ctx.globalAlpha = Math.max(0, p.life);
+ ctx.beginPath();
+ ctx.arc(p.x, p.y, Math.max(0.5, p.r * p.life), 0, Math.PI * 2);
+ ctx.fillStyle = p.col;
+ ctx.fill();
+ }
+
+ ctx.globalAlpha = 1;
+ if (!alive) list.splice(i, 1);
+ }
+}
+
+// ── Minimap ───────────────────────────────────────────────────────────────────
+
+function rrPath(ctx, x, y, w, h, r) {
+ ctx.beginPath();
+ ctx.moveTo(x + r, y);
+ ctx.lineTo(x + w - r, y);
+ ctx.quadraticCurveTo(x + w, y, x + w, y + r);
+ ctx.lineTo(x + w, y + h - r);
+ ctx.quadraticCurveTo(x + w, y + h, x + w - r, y + h);
+ ctx.lineTo(x + r, y + h);
+ ctx.quadraticCurveTo(x, y + h, x, y + h - r);
+ ctx.lineTo(x, y + r);
+ ctx.quadraticCurveTo(x, y, x + r, y);
+ ctx.closePath();
+}
+
+function drawMinimap(ctx, snap, mySnake, cw, ch) {
+ const SIZE = 180, PAD = 16, R = 12;
+ const mx = cw - SIZE - PAD;
+ const my = ch - SIZE - PAD;
+ const scale = SIZE / snap.worldSize;
+
+ // ── Clipped map interior ──
+ ctx.save();
+ rrPath(ctx, mx, my, SIZE, SIZE, R);
+ ctx.clip();
+
+ ctx.globalAlpha = 0.88;
+ ctx.fillStyle = 'rgba(4,8,24,0.95)';
+ ctx.fillRect(mx, my, SIZE, SIZE);
+
+ // Grid
+ ctx.globalAlpha = 0.09;
+ ctx.strokeStyle = '#00f5ff';
+ ctx.lineWidth = 0.5;
+ ctx.beginPath();
+ for (let i = 1; i < 4; i++) {
+ const step = SIZE / 4;
+ ctx.moveTo(mx + i * step, my); ctx.lineTo(mx + i * step, my + SIZE);
+ ctx.moveTo(mx, my + i * step); ctx.lineTo(mx + SIZE, my + i * step);
+ }
+ ctx.stroke();
+
+ // World border hint
+ ctx.globalAlpha = 0.3;
+ ctx.strokeStyle = '#ff3355';
+ ctx.lineWidth = 1.5;
+ ctx.strokeRect(mx + 1, my + 1, SIZE - 2, SIZE - 2);
+
+ // Pellets
+ ctx.globalAlpha = 0.65;
+ for (const [, px, py, hue] of snap.pellets) {
+ ctx.fillStyle = `hsl(${hue ?? 180},100%,72%)`;
+ ctx.fillRect(mx + px * scale - 1, my + py * scale - 1, 2, 2);
+ }
+
+ // Other snakes
+ ctx.globalAlpha = 0.9;
+ for (const s of snap.snakes) {
+ if (s.id === mySnake?.id) continue;
+ ctx.beginPath();
+ ctx.arc(mx + s.x * scale, my + s.y * scale, 2.5, 0, Math.PI * 2);
+ ctx.fillStyle = s.color;
+ ctx.fill();
+ }
+
+ // Viewport rect
+ if (mySnake) {
+ const vx = mx + (mySnake.x - cw / 2) * scale;
+ const vy = my + (mySnake.y - ch / 2) * scale;
+ ctx.globalAlpha = 0.14;
+ ctx.fillStyle = '#00f5ff';
+ ctx.fillRect(vx, vy, cw * scale, ch * scale);
+ ctx.globalAlpha = 0.5;
+ ctx.strokeStyle = '#00f5ff';
+ ctx.lineWidth = 0.8;
+ ctx.strokeRect(vx, vy, cw * scale, ch * scale);
+ }
+
+ // Own snake marker + direction arrow
+ if (mySnake) {
+ const sx = mx + mySnake.x * scale;
+ const sy = my + mySnake.y * scale;
+ const dir = mySnake.dir ?? 0;
+
+ ctx.globalAlpha = 1;
+ ctx.shadowColor = '#fff';
+ ctx.shadowBlur = 6;
+ ctx.beginPath();
+ ctx.arc(sx, sy, 3.5, 0, Math.PI * 2);
+ ctx.fillStyle = '#fff';
+ ctx.fill();
+ ctx.shadowBlur = 0;
+
+ // Arrow tip
+ const tip = { x: sx + Math.cos(dir) * 10, y: sy + Math.sin(dir) * 10 };
+ const l = { x: sx + Math.cos(dir + 2.5) * 2.5, y: sy + Math.sin(dir + 2.5) * 2.5 };
+ const r2 = { x: sx + Math.cos(dir - 2.5) * 2.5, y: sy + Math.sin(dir - 2.5) * 2.5 };
+ ctx.globalAlpha = 0.95;
+ ctx.beginPath();
+ ctx.moveTo(tip.x, tip.y);
+ ctx.lineTo(l.x, l.y);
+ ctx.lineTo(r2.x, r2.y);
+ ctx.closePath();
+ ctx.fillStyle = '#00f5ff';
+ ctx.fill();
+ }
+
+ ctx.globalAlpha = 1;
+ ctx.restore();
+
+ // ── Glowing border + labels (outside clip) ──
+ ctx.save();
+ ctx.shadowColor = '#00c8ff';
+ ctx.shadowBlur = 12;
+ ctx.strokeStyle = 'rgba(0,200,255,0.85)';
+ ctx.lineWidth = 1.5;
+ rrPath(ctx, mx, my, SIZE, SIZE, R);
+ ctx.stroke();
+ ctx.shadowBlur = 0;
+
+ ctx.globalAlpha = 0.7;
+ ctx.fillStyle = '#00f5ff';
+ ctx.font = 'bold 8px monospace';
+ ctx.textAlign = 'left';
+ ctx.textBaseline = 'top';
+ ctx.fillText('MAP', mx + 9, my + 6);
+
+ ctx.globalAlpha = 0.5;
+ ctx.fillStyle = '#94a3b8';
+ ctx.textAlign = 'right';
+ ctx.fillText(`${snap.snakes.length}P`, mx + SIZE - 9, my + 6);
+
+ ctx.globalAlpha = 1;
+ ctx.restore();
+}
+
+// ── Component ─────────────────────────────────────────────────────────────────
+
+export default function GameCanvas({ snapshot, playerId, onInput, active }) {
+ const canvasRef = useRef(null);
+ const snapshotRef = useRef(null);
+ const playerIdRef = useRef(null);
+ const boostRef = useRef(false);
+ const rafRef = useRef(null);
+ const frameRef = useRef(0);
+ const explosionsRef = useRef([]);
+ const prevSnakesRef = useRef(new Map());
+
+ useEffect(() => { snapshotRef.current = snapshot; }, [snapshot]);
+ useEffect(() => { playerIdRef.current = playerId; }, [playerId]);
+
+ // ── Input ──────────────────────────────────────────────────────────────────
+
+ const keysRef = useRef(new Set());
+ const mouseDirRef = useRef(0);
+
+ useEffect(() => {
+ if (!active) return;
+ const canvas = canvasRef.current;
+ if (!canvas) return;
+
+ const keyDir = () => {
+ const k = keysRef.current;
+ let dx = 0, dy = 0;
+ if (k.has('ArrowUp') || k.has('KeyW')) dy -= 1;
+ if (k.has('ArrowDown') || k.has('KeyS')) dy += 1;
+ if (k.has('ArrowLeft') || k.has('KeyA')) dx -= 1;
+ if (k.has('ArrowRight') || k.has('KeyD')) dx += 1;
+ if (dx === 0 && dy === 0) return null;
+ return Math.atan2(dy, dx);
+ };
+
+ let lastMouseSend = 0;
+ const onMouseMove = (e) => {
+ const now = Date.now();
+ if (now - lastMouseSend < 30) return;
+ lastMouseSend = now;
+ const rect = canvas.getBoundingClientRect();
+ mouseDirRef.current = Math.atan2(
+ e.clientY - rect.top - canvas.height / 2,
+ e.clientX - rect.left - canvas.width / 2,
+ );
+ onInput(mouseDirRef.current, boostRef.current);
+ };
+
+ const onMouseDown = (e) => {
+ if (e.button === 0) { boostRef.current = true; onInput(mouseDirRef.current, true); }
+ };
+ const onMouseUp = (e) => {
+ if (e.button === 0) { boostRef.current = false; onInput(mouseDirRef.current, false); }
+ };
+
+ const DIRS = new Set(['ArrowUp','ArrowDown','ArrowLeft','ArrowRight','KeyW','KeyA','KeyS','KeyD']);
+ const onKeyDown = (e) => {
+ if (DIRS.has(e.code)) {
+ e.preventDefault();
+ keysRef.current.add(e.code);
+ const d = keyDir();
+ if (d !== null) onInput(d, boostRef.current);
+ }
+ if (e.code === 'Space') {
+ e.preventDefault();
+ boostRef.current = true;
+ onInput(keyDir() ?? mouseDirRef.current, true);
+ }
+ };
+ const onKeyUp = (e) => {
+ keysRef.current.delete(e.code);
+ if (e.code === 'Space') { boostRef.current = false; onInput(mouseDirRef.current, false); }
+ const d = keyDir();
+ if (d !== null) onInput(d, boostRef.current);
+ };
+
+ const keyInterval = setInterval(() => {
+ const d = keyDir();
+ if (d !== null) onInput(d, boostRef.current);
+ }, 33);
+
+ canvas.addEventListener('mousemove', onMouseMove);
+ canvas.addEventListener('mousedown', onMouseDown);
+ canvas.addEventListener('mouseup', onMouseUp);
+ window.addEventListener('keydown', onKeyDown);
+ window.addEventListener('keyup', onKeyUp);
+ return () => {
+ clearInterval(keyInterval);
+ canvas.removeEventListener('mousemove', onMouseMove);
+ canvas.removeEventListener('mousedown', onMouseDown);
+ canvas.removeEventListener('mouseup', onMouseUp);
+ window.removeEventListener('keydown', onKeyDown);
+ window.removeEventListener('keyup', onKeyUp);
+ };
+ }, [active, onInput]);
+
+ // ── Render loop ────────────────────────────────────────────────────────────
+
+ useEffect(() => {
+ const canvas = canvasRef.current;
+ const ctx = canvas.getContext('2d');
+
+ const resize = () => {
+ canvas.width = window.innerWidth;
+ canvas.height = window.innerHeight;
+ };
+ resize();
+ window.addEventListener('resize', resize);
+
+ const render = () => {
+ rafRef.current = requestAnimationFrame(render);
+ frameRef.current++;
+
+ const snap = snapshotRef.current;
+ const w = canvas.width;
+ const h = canvas.height;
+
+ if (!snap) {
+ ctx.fillStyle = '#080c18';
+ ctx.fillRect(0, 0, w, h);
+ ctx.fillStyle = '#334155';
+ ctx.font = '18px sans-serif';
+ ctx.textAlign = 'center';
+ ctx.textBaseline = 'middle';
+ ctx.fillText('Connecting…', w / 2, h / 2);
+ ctx.textBaseline = 'alphabetic';
+ return;
+ }
+
+ // ── Detect snake deaths → explosions ──
+ const curMap = new Map(snap.snakes.map(s => [s.id, s]));
+ if (prevSnakesRef.current.size > 0) {
+ for (const [id, prev] of prevSnakesRef.current) {
+ if (!curMap.has(id)) {
+ addExplosion(explosionsRef.current, prev.x, prev.y, prev.color);
+ }
+ }
+ }
+ prevSnakesRef.current = new Map(
+ snap.snakes.map(s => [s.id, { x: s.x, y: s.y, color: s.color }])
+ );
+
+ const mySnake = snap.snakes.find(s => s.id === playerIdRef.current);
+ const camX = mySnake ? mySnake.x : snap.worldSize / 2;
+ const camY = mySnake ? mySnake.y : snap.worldSize / 2;
+
+ // ── Background ──
+ ctx.fillStyle = '#080c18';
+ ctx.fillRect(0, 0, w, h);
+
+ // ── World transform ──
+ ctx.save();
+ ctx.translate(w / 2 - camX, h / 2 - camY);
+
+ drawGrid(ctx, camX, camY, w, h);
+ drawWorldBorder(ctx, snap.worldSize);
+ drawPellets(ctx, snap.pellets, frameRef.current);
+
+ // Draw snakes — others first, own on top
+ for (const s of snap.snakes) {
+ if (s.id !== playerIdRef.current) drawSnake(ctx, s, false);
+ }
+ if (mySnake) drawSnake(ctx, mySnake, true);
+
+ // Explosions
+ updateDrawExplosions(ctx, explosionsRef.current);
+
+ ctx.restore();
+
+ // ── Minimap ──
+ drawMinimap(ctx, snap, mySnake, w, h);
+ };
+
+ rafRef.current = requestAnimationFrame(render);
+ return () => {
+ cancelAnimationFrame(rafRef.current);
+ window.removeEventListener('resize', resize);
+ };
+ }, []);
+
+ return (
+
+ );
+}
diff --git a/client/src/components/GameOverScreen.jsx b/client/src/components/GameOverScreen.jsx
new file mode 100644
index 0000000..d9cd346
--- /dev/null
+++ b/client/src/components/GameOverScreen.jsx
@@ -0,0 +1,130 @@
+import { useEffect, useState } from 'react';
+import { audio } from '../audio/AudioManager.js';
+
+const MEDAL_ICONS = ['\uD83E\uDD47', '\uD83E\uDD48', '\uD83E\uDD49'];
+const PODIUM_COLS = ['#fbbf24', '#e2e8f0', '#cd7f32'];
+const PODIUM_H = [120, 90, 70];
+// Display order: 2nd | 1st | 3rd
+const PODIUM_ORDER = [1, 0, 2];
+
+export default function GameOverScreen({ leaderboard = [], winner, onClose }) {
+ const [countdown, setCountdown] = useState(10);
+
+ useEffect(() => {
+ audio.join();
+ const t = setInterval(() => {
+ setCountdown(p => {
+ if (p <= 1) { clearInterval(t); onClose?.(); return 0; }
+ return p - 1;
+ });
+ }, 1000);
+ return () => clearInterval(t);
+ }, []);
+
+ const top3 = leaderboard.slice(0, 3);
+
+ return (
+
+
+
+ {/* Header */}
+
+
🏆
+
ROUND OVER
+ {winner && (
+
+ {winner.name}
+ {' '}wins with{' '}
+ {winner.score} points!
+
+ )}
+
+
+ {/* Podium */}
+ {top3.length > 0 && (
+
+ {PODIUM_ORDER.map((rank, vi) => {
+ const p = top3[rank];
+ if (!p) return
;
+ return (
+
+
{p.name}
+
{p.score} pts
+
+ {MEDAL_ICONS[rank]}
+
+
+ );
+ })}
+
+ )}
+
+ {/* Leaderboard rows 4+ */}
+ {leaderboard.length > 3 && (
+
+ {leaderboard.slice(3).map((p, i) => (
+
+ #{i + 4}
+ {p.name}
+ {p.score}
+
+ ))}
+
+ )}
+
+ {/* Countdown */}
+
+
+ NEW ROUND STARTING IN
+
+
{countdown}
+
+ All players auto-respawn — get ready!
+
+
+
+
+
+
+ );
+}
diff --git a/client/src/components/HUD.jsx b/client/src/components/HUD.jsx
new file mode 100644
index 0000000..d77f832
--- /dev/null
+++ b/client/src/components/HUD.jsx
@@ -0,0 +1,290 @@
+import { useState, useEffect } from 'react';
+
+const FONT = "'Space Grotesk','Inter',system-ui,sans-serif";
+
+const BASE = {
+ position: 'absolute', top: 0, left: 0,
+ width: '100%', height: '100%',
+ pointerEvents: 'none', userSelect: 'none',
+ fontFamily: FONT,
+};
+
+// ── Round timer ───────────────────────────────────────────────────────────────
+
+function GameTimer({ timeLeftMs }) {
+ if (timeLeftMs === null || timeLeftMs === undefined) return null;
+ const total = Math.max(0, Math.ceil(timeLeftMs / 1000));
+ const m = Math.floor(total / 60);
+ const s = total % 60;
+ const urgent = total <= 30;
+ const warn = total <= 60;
+
+ return (
+
+
ROUND
+
{`${m}:${String(s).padStart(2, '0')}`}
+
+ );
+}
+
+// ── Leaderboard ───────────────────────────────────────────────────────────────
+
+const MEDALS = ['🥇', '🥈', '🥉'];
+
+function Leaderboard({ leaderboard, playerId }) {
+ return (
+
+
LEADERBOARD
+
+ {leaderboard.length === 0 && (
+
No players yet
+ )}
+
+ {leaderboard.map((e, i) => {
+ const isMe = e.id === playerId;
+ return (
+
+ {/* Rank */}
+
+ {i < 3 ? MEDALS[i] : #{i + 1} }
+
+
+ {/* Color dot */}
+
+
+ {/* Name */}
+
{e.name}
+
+ {/* Score */}
+
{e.score}
+
+ );
+ })}
+
+ );
+}
+
+// ── Killfeed ──────────────────────────────────────────────────────────────────
+
+function Killfeed({ entries }) {
+ return (
+
+ {entries.map((e, i) => (
+
+ {e.killerName
+ ? <>{e.killerName}
+ ⚡
+ {e.victimName}
+ +{e.length} >
+ : <>{e.victimName}
+ hit the wall >}
+
+ ))}
+
+ );
+}
+
+// ── Score bar ─────────────────────────────────────────────────────────────────
+
+function ScoreBar({ snapshot, playerId }) {
+ const me = snapshot?.snakes?.find(s => s.id === playerId);
+ if (!me) return null;
+ return (
+
+
+
+
+ {me.boosting && (
+ <>
+
+
⚡ BOOST
+ >
+ )}
+
+ );
+}
+
+function Pill({ label, value, color }) {
+ return (
+
+ );
+}
+
+// ── Room badge ────────────────────────────────────────────────────────────────
+
+function RoomBadge({ roomCode }) {
+ const [copied, setCopied] = useState(false);
+ if (!roomCode) return null;
+ const copy = () => {
+ navigator.clipboard.writeText(roomCode).catch(() => {});
+ setCopied(true);
+ setTimeout(() => setCopied(false), 2000);
+ };
+ return (
+
+
ROOM
+
{copied ? '✓ COPIED' : roomCode}
+
+ );
+}
+
+// ── Greeting banner ───────────────────────────────────────────────────────────
+
+function Greeting({ text, onDismiss }) {
+ const [vis, setVis] = useState(true);
+ useEffect(() => {
+ const t = setTimeout(() => { setVis(false); setTimeout(onDismiss, 500); }, 5500);
+ return () => clearTimeout(t);
+ }, []);
+ if (!text) return null;
+ return (
+ {text}
+ );
+}
+
+// ── Controls hint ─────────────────────────────────────────────────────────────
+
+function ControlsHint() {
+ return (
+
+ Steer: mouse / WASD / arrows
+ Boost: hold click / space
+
+ );
+}
+
+// ── Main HUD ──────────────────────────────────────────────────────────────────
+
+export default function HUD({
+ leaderboard, killfeed, snapshot, playerId,
+ roomCode, greeting, onGreetingDismiss, timeLeftMs,
+}) {
+ return (
+
+
+
+
+
+
+ {greeting && }
+
+
+
+
+ );
+}
diff --git a/client/src/components/HallOfFame.jsx b/client/src/components/HallOfFame.jsx
new file mode 100644
index 0000000..a605267
--- /dev/null
+++ b/client/src/components/HallOfFame.jsx
@@ -0,0 +1,154 @@
+import { useState } from 'react';
+import { audio } from '../audio/AudioManager.js';
+
+const TABS = [
+ { id: 'global', label: 'Global All-Time', icon: '\uD83C\uDF0D' },
+ { id: 'weekly', label: 'Weekly Arena', icon: '\uD83D\uDCC5' },
+ { id: 'daily', label: 'Daily Blitz', icon: '\u26A1' },
+];
+
+export default function HallOfFame({ stats }) {
+ const [tab, setTab] = useState('global');
+ return (
+
+
+ {TABS.map(t => (
+ { setTab(t.id); audio.navSwitch(); }}
+ style={{
+ flex: 1, padding: '10px 4px', border: 'none', borderRadius: 9, cursor: 'pointer',
+ fontWeight: 700, fontSize: 11, letterSpacing: 1, transition: 'all 0.2s',
+ background: tab === t.id ? 'rgba(0,200,255,0.1)' : 'transparent',
+ color: tab === t.id ? '#00f5ff' : '#475569',
+ borderBottom: tab === t.id ? '2px solid #00f5ff' : '2px solid transparent',
+ }}>
+ {t.icon} {t.label}
+
+ ))}
+
+ {tab === 'global' &&
}
+ {tab !== 'global' &&
}
+
+ );
+}
+
+function GlobalTab({ stats }) {
+ const fmtTime = s => {
+ if (!s) return '--';
+ const m = Math.floor(s / 60), sec = s % 60;
+ return m > 0 ? `${m}m ${sec}s` : `${s}s`;
+ };
+ const hasData = stats.gamesPlayed > 0;
+ const rows = [
+ { label: 'Best Score', val: stats.highScore, color: '#fbbf24', icon: '\u2B50' },
+ { label: 'Record Length', val: stats.highLength, color: '#22c55e', icon: '\uD83D\uDC0D' },
+ { label: 'Best Rank', val: stats.bestRank !== null ? `#${stats.bestRank}` : '--', color: '#a855f7', icon: '\uD83D\uDC51' },
+ { label: 'Longest Run', val: fmtTime(stats.highSurvivalSec), color: '#06b6d4', icon: '\u23F1\uFE0F' },
+ { label: 'Total Kills', val: stats.totalKills, color: '#ef4444', icon: '\uD83D\uDC80' },
+ { label: 'Games Played', val: stats.gamesPlayed, color: '#f97316', icon: '\uD83C\uDFAE' },
+ ];
+
+ return (
+
+ {!hasData ? (
+
+
🏆
+
No records yet
+
Play your first game to claim the throne
+
+ ) : (
+ <>
+ {/* Hero card */}
+
+
+
👑
+
+
PERSONAL BEST
+
+ {stats.highScore}
+ points
+
+
+
+
Length record
+
{stats.highLength}
+
+
+
+ {/* Stat grid */}
+
+ {rows.map(s => (
+
+ ))}
+
+ >
+ )}
+
+
+
🌐
+
+ Global rankings — cross-server aggregation coming soon. Stats saved locally.
+
+
+
+
+
+ );
+}
+
+function ComingSoon({ tab }) {
+ const t = TABS.find(x => x.id === tab);
+ return (
+
+
{t?.icon}
+
{t?.label}
+
+ {tab === 'weekly'
+ ? 'Weekly Arena resets every Monday. Compete globally for the highest weekly rank.'
+ : 'Daily Blitz — ultra-competitive 5-minute ranked sessions, reset every midnight.'}
+
+
COMING SOON
+
+ );
+}
diff --git a/client/src/components/JoinScreen.jsx b/client/src/components/JoinScreen.jsx
new file mode 100644
index 0000000..f8972d1
--- /dev/null
+++ b/client/src/components/JoinScreen.jsx
@@ -0,0 +1,144 @@
+import { useState, useEffect } from 'react';
+
+const HTTP_URL = typeof window !== 'undefined' ? window.location.origin : 'http://localhost:3001';
+
+export default function JoinScreen({ onJoin, error }) {
+ const [name, setName] = useState('');
+ const [password, setPassword] = useState('');
+ const [needsPassword, setNeedsPassword] = useState(false);
+
+ // Ask server if this room needs a password
+ useEffect(() => {
+ fetch(`${HTTP_URL}/api/health`)
+ .then(r => r.json())
+ .then(d => setNeedsPassword(!!d.passwordRequired))
+ .catch(() => {});
+ }, []);
+
+ const ready = name.trim() && (!needsPassword || password.length > 0);
+
+ const submit = (e) => {
+ e.preventDefault();
+ if (ready) onJoin(name.trim(), password);
+ };
+
+ const inputStyle = {
+ background: 'rgba(255,255,255,0.06)',
+ border: '1px solid rgba(255,255,255,0.15)',
+ borderRadius: 10, padding: '12px 16px',
+ color: '#fff', fontSize: 16,
+ outline: 'none', width: '100%',
+ };
+
+ return (
+
+ {/* Decorative grid */}
+
+
+
+
+
+
+
+
+
+
+ {/* Title */}
+
+
+ SLITHER QUEST
+
+
+ Eat pellets • Grow • Outlast everyone
+
+
+
+ {/* Error */}
+ {error && (
+
+ {error}
+
+ )}
+
+ {/* Form */}
+
+
+ {needsPassword && (
+
+ 🔒 Private room — password required
+
+ )}
+
+ {/* Tips */}
+
+ Steer: mouse or WASD / arrow keys • Boost: hold click or space
+ Boost burns length but drops pellets • Hit another snake to die
+
+
+
+ );
+}
diff --git a/client/src/components/LobbyScreen.jsx b/client/src/components/LobbyScreen.jsx
new file mode 100644
index 0000000..f88f23f
--- /dev/null
+++ b/client/src/components/LobbyScreen.jsx
@@ -0,0 +1,701 @@
+import { useState, useEffect, useCallback } from 'react';
+import AnimatedBg from './AnimatedBg.jsx';
+import ProfilePanel from './ProfilePanel.jsx';
+import ArenaPanel from './ArenaPanel.jsx';
+import HallOfFame from './HallOfFame.jsx';
+import { audio } from '../audio/AudioManager.js';
+
+const HTTP_BASE = (() => {
+ const ws = import.meta.env.VITE_WS_URL || '';
+ if (ws) return ws.replace('wss://', 'https://').replace('ws://', 'http://');
+ return typeof window !== 'undefined' ? window.location.origin : 'http://localhost:3001';
+})();
+
+// ── Design system ─────────────────────────────────────────────────────────────
+const T = {
+ teal: '#00d4aa',
+ tealGlow: 'rgba(0,212,170,0.25)',
+ tealDim: 'rgba(0,212,170,0.12)',
+ tealBorder: 'rgba(0,212,170,0.28)',
+ blue: '#38bdf8',
+ purple: '#a78bfa',
+ panel: 'rgba(8,13,30,0.96)',
+ input: 'rgba(4,8,20,0.9)',
+ glass: 'rgba(10,16,34,0.85)',
+ border: 'rgba(255,255,255,0.07)',
+ text: '#e8f4ff',
+ muted: '#4a6080',
+ faint: '#1e2d42',
+};
+
+const FONT = "'Space Grotesk','Inter',system-ui,sans-serif";
+
+const SECTIONS = [
+ { id: 'play', label: 'PLAY', icon: '▶' },
+ { id: 'arena', label: 'ARENA', icon: '🎮' },
+ { id: 'profile', label: 'PROFILE', icon: '👤' },
+ { id: 'hall', label: 'HALL OF FAME', icon: '🏆' },
+];
+
+// ── Base input style ──────────────────────────────────────────────────────────
+const INPUT = {
+ background: T.input,
+ border: `1px solid ${T.border}`,
+ borderRadius: 10, padding: '12px 16px',
+ color: T.text, fontSize: 15,
+ outline: 'none', width: '100%',
+ fontFamily: FONT, boxSizing: 'border-box',
+ transition: 'border-color 0.2s',
+};
+
+// ── Shared atoms ──────────────────────────────────────────────────────────────
+
+function Label({ children, color }) {
+ return (
+ {children}
+ );
+}
+
+function FInput({ label, hint, style: extra, ...props }) {
+ const [focused, setFocused] = useState(false);
+ return (
+
+ {label &&
{label} }
+
setFocused(true)}
+ onBlur={() => setFocused(false)}
+ {...props}
+ />
+ {hint && (
+
{hint}
+ )}
+
+ );
+}
+
+function Btn({ children, onClick, disabled, variant = 'primary', small = false }) {
+ const variants = {
+ primary: {
+ background: disabled
+ ? 'rgba(255,255,255,0.06)'
+ : `linear-gradient(135deg, ${T.teal} 0%, #00b0e0 100%)`,
+ color: disabled ? T.muted : '#040e18',
+ boxShadow: disabled ? 'none' : `0 0 28px ${T.tealGlow}`,
+ border: 'none',
+ },
+ secondary: {
+ background: 'transparent',
+ color: T.muted,
+ boxShadow: 'none',
+ border: `1px solid ${T.border}`,
+ },
+ ghost: {
+ background: T.tealDim,
+ color: T.teal,
+ boxShadow: 'none',
+ border: `1px solid ${T.tealBorder}`,
+ },
+ };
+ const v = variants[variant] || variants.primary;
+ return (
+ { if (!disabled) { audio.click(); onClick?.(); } }}
+ disabled={disabled}
+ style={{
+ width: '100%',
+ padding: small ? '9px 16px' : '13px',
+ borderRadius: 10, cursor: disabled ? 'not-allowed' : 'pointer',
+ fontWeight: 800, fontSize: small ? 11 : 13,
+ letterSpacing: 1.5, fontFamily: FONT,
+ transition: 'all 0.2s',
+ ...v,
+ }}
+ >{children}
+ );
+}
+
+function ErrBox({ msg }) {
+ if (!msg) return null;
+ return (
+ {msg}
+ );
+}
+
+function Divider({ label }) {
+ return (
+
+
+ {label &&
{label} }
+
+
+ );
+}
+
+// ── Create Tab ────────────────────────────────────────────────────────────────
+
+function CreateTab({ onCreate, error, loading, createdCode, onCopyCode }) {
+ const savedName = () => { try { return localStorage.getItem('sq_last_name') || ''; } catch { return ''; } };
+ const savedPin = () => { try { return localStorage.getItem('sq_last_pin') || ''; } catch { return ''; } };
+ const name = savedName();
+ const pin = savedPin();
+ const [roomPw, setRoomPw] = useState('');
+ const ready = name.trim().length >= 2 && pin.length === 4;
+
+ const submit = (e) => {
+ e.preventDefault();
+ if (!ready || loading) return;
+ onCreate(name.trim(), pin, roomPw.trim() || null);
+ };
+
+ if (createdCode) {
+ return (
+
+
+ SHARE THIS CODE WITH FRIENDS
+
+
{createdCode}
+
COPY CODE
+
Entering arena…
+
+ );
+ }
+
+ return (
+
+ );
+}
+
+// ── Open Rooms Browser ────────────────────────────────────────────────────────
+
+function fmtLeft(ms) {
+ if (!ms || ms <= 0) return 'N/A';
+ const s = Math.floor(ms / 1000);
+ return `${Math.floor(s / 60)}:${String(s % 60).padStart(2, '0')}`;
+}
+
+function OpenRoomsBrowser({ onSelectRoom }) {
+ const [rooms, setRooms] = useState(null);
+ const [loading, setLoading] = useState(false);
+ const [error, setError] = useState(null);
+
+ const refresh = useCallback(async () => {
+ setLoading(true); setError(null);
+ try {
+ const data = await fetch(`${HTTP_BASE}/api/rooms`).then(r => r.json());
+ setRooms(Array.isArray(data) ? data : []);
+ } catch { setError('Could not reach server'); setRooms([]); }
+ setLoading(false);
+ }, []);
+
+ useEffect(() => { refresh(); }, []);
+
+ return (
+
+
+ OPEN ROOMS
+ {loading ? '↻' : '↻ Refresh'}
+
+
+ {loading && (
+
+ Scanning rooms…
+
+ )}
+ {error &&
{error}
}
+
+ {!loading && rooms?.length === 0 && (
+
+
🌌
+
No open rooms right now
+
Create one and invite friends!
+
+ )}
+
+ {!loading && rooms?.map(r => (
+
{ audio.click(); onSelectRoom(r.code); }}
+ style={{
+ display: 'flex', alignItems: 'center', justifyContent: 'space-between',
+ background: T.tealDim, border: `1px solid ${T.tealBorder}`,
+ borderRadius: 10, padding: '12px 16px', cursor: 'pointer',
+ transition: 'all 0.18s', textAlign: 'left',
+ width: '100%', fontFamily: FONT,
+ }}>
+
+
+ {r.code}
+
+
+ {r.players} {r.players === 1 ? 'player' : 'players'} · {r.passwordRequired ? '🔒 Private' : '🔓 Open'}
+
+
+
+
{fmtLeft(r.timeLeftMs)} left
+
JOIN →
+
+
+ ))}
+
+ );
+}
+
+// ── Join Tab ──────────────────────────────────────────────────────────────────
+
+function JoinTab({ onJoin, onCheckRoom, error, loading }) {
+ const savedName = () => { try { return localStorage.getItem('sq_last_name') || ''; } catch { return ''; } };
+ const savedPin = () => { try { return localStorage.getItem('sq_last_pin') || ''; } catch { return ''; } };
+ const name = savedName();
+ const pin = savedPin();
+ const [mode, setMode] = useState('code');
+ const [code, setCode] = useState('');
+ const [roomPw, setRoomPw] = useState('');
+ const [info, setInfo] = useState(null);
+ const [checking, setChecking] = useState(false);
+
+ useEffect(() => {
+ if (code.length === 4) {
+ setChecking(true);
+ onCheckRoom(code).then(r => { setInfo(r); setChecking(false); });
+ } else { setInfo(null); }
+ }, [code]);
+
+ const ready = code.length === 4 && name.trim().length >= 2 && pin.length === 4
+ && info?.exists && (!info?.passwordRequired || roomPw.length > 0);
+
+ const submit = (e) => {
+ e.preventDefault();
+ if (!ready || loading) return;
+ try { localStorage.setItem('sq_last_name', name.trim()); } catch {}
+ onJoin(code, name.trim(), pin, roomPw.trim() || '');
+ };
+
+ const ModeBtn = ({ id, label }) => (
+ { setMode(id); audio.navSwitch(); }} style={{
+ flex: 1, padding: '9px', borderRadius: 8, cursor: 'pointer',
+ fontWeight: 700, fontSize: 10, letterSpacing: 1.5,
+ fontFamily: FONT, transition: 'all 0.18s',
+ background: mode === id ? T.tealDim : 'transparent',
+ color: mode === id ? T.teal : T.muted,
+ border: mode === id ? `1px solid ${T.tealBorder}` : '1px solid transparent',
+ }}>{label}
+ );
+
+ return (
+
+ );
+}
+
+// ── Play Section ──────────────────────────────────────────────────────────────
+
+function PlaySection({ onCreate, onJoin, onCheckRoom, error, loading, createdCode }) {
+ const [sub, setSub] = useState('create');
+
+ return (
+
+
+ {/* Pill badge */}
+
+
+
+ 5-MIN ROUNDS · REAL-TIME · MULTIPLAYER
+
+
+
+ {/* Create / Join switcher */}
+ {!createdCode && (
+
+ {[['create', '⊕ CREATE ROOM'], ['join', '→ JOIN ROOM']].map(([id, label]) => (
+ { setSub(id); audio.navSwitch(); }}
+ style={{
+ flex: 1, padding: '11px', borderRadius: 9,
+ fontWeight: 800, fontSize: 11, letterSpacing: 1.5,
+ cursor: 'pointer', fontFamily: FONT, transition: 'all 0.18s',
+ background: sub === id
+ ? `linear-gradient(135deg, ${T.teal}, #00a8d8)`
+ : 'transparent',
+ color: sub === id ? '#030f18' : T.muted,
+ border: 'none',
+ boxShadow: sub === id ? `0 0 20px ${T.tealGlow}` : 'none',
+ }}>{label}
+ ))}
+
+ )}
+
+ {(sub === 'create' || createdCode) && (
+
{ navigator.clipboard.writeText(createdCode).catch(() => {}); audio.click(); }} />
+ )}
+ {sub === 'join' && !createdCode && (
+
+ )}
+
+
+ Steer: mouse / WASD / arrows · Boost: hold click or space
+
+
+ );
+}
+
+// ── Section header ────────────────────────────────────────────────────────────
+
+function SectionHeader({ icon, title, sub, color }) {
+ return (
+
+
{icon}
+
+
{title}
+ {sub &&
{sub}
}
+
+
+ );
+}
+
+// ── Glass panel ───────────────────────────────────────────────────────────────
+
+function Panel({ children }) {
+ return (
+
+ {children}
+
+ );
+}
+
+// ── Main lobby ────────────────────────────────────────────────────────────────
+
+export default function LobbyScreen({
+ onCreate, onJoin, onCheckRoom,
+ error, loading, createdCode,
+ stats, unlocked,
+ playerName, onSwitchAuth,
+}) {
+ const [section, setSection] = useState('play');
+ const [muted, setMuted] = useState(false);
+
+ const go = (id) => { audio.navSwitch(); audio.resume(); setSection(id); };
+ const toggleMute = () => {
+ const next = !muted;
+ setMuted(next);
+ audio.setMuted(next);
+ audio.click();
+ };
+
+ const sectionTitle = {
+ play: ['⚔️', 'Enter the Arena', 'Create a room or join an existing one', T.teal],
+ arena: ['🎨', 'Player Arena', 'Set your credentials, skin & avatar', T.purple],
+ profile: ['👤', 'Player Profile', 'Your stats and achievements', '#fbbf24'],
+ hall: ['🏆', 'Hall of Fame', 'Your personal records', '#fbbf24'],
+ };
+
+ return (
+
+
+
+
+
+ {/* ════ Navbar ════ */}
+
+
+ {/* Logo */}
+
+
+ {/* Center nav */}
+
+ {SECTIONS.map(s => {
+ const active = section === s.id;
+ return (
+ go(s.id)}
+ style={{
+ display: 'flex', alignItems: 'center', gap: 6,
+ padding: '6px 14px', borderRadius: 8,
+ cursor: 'pointer', fontWeight: 700,
+ fontSize: 11, letterSpacing: 1.2,
+ fontFamily: FONT, transition: 'all 0.18s',
+ background: active ? T.tealDim : 'transparent',
+ color: active ? T.teal : T.muted,
+ border: active ? `1px solid ${T.tealBorder}` : '1px solid transparent',
+ boxShadow: active ? `0 0 12px ${T.tealGlow}` : 'none',
+ }}>
+ {s.icon}
+ {s.label}
+
+ );
+ })}
+
+
+ {/* Right side: user badge + mute */}
+
+ {playerName && (
+
+ )}
+
+
{muted ? '🔇' : '🔊'}
+
+
+
+ {/* ════ Content ════ */}
+
+
+ {section === 'play' && (
+
+
+
+
+ )}
+
+ {section === 'arena' && (
+
+
+
+
+ )}
+
+ {section === 'profile' && (
+
+
+
+
+ )}
+
+ {section === 'hall' && (
+
+
+
+
+ )}
+
+
+
+
+
+
+ );
+}
diff --git a/client/src/components/ProfilePanel.jsx b/client/src/components/ProfilePanel.jsx
new file mode 100644
index 0000000..fa2869a
--- /dev/null
+++ b/client/src/components/ProfilePanel.jsx
@@ -0,0 +1,150 @@
+import { ACHIEVEMENTS } from '../hooks/useAchievements.js';
+
+const fmtTime = sec => {
+ if (!sec) return '--';
+ const m = Math.floor(sec / 60), s = sec % 60;
+ return m > 0 ? `${m}m ${s}s` : `${s}s`;
+};
+
+function SectionLabel({ children }) {
+ return (
+
+ );
+}
+
+function StatCard({ icon, label, value, color }) {
+ return (
+
+ {/* Glow bleed */}
+
+
{icon}
+
{value}
+
{label}
+
+ );
+}
+
+function AchCard({ ach, isUnlocked }) {
+ return (
+
+ {isUnlocked && (
+
+ )}
+
+ {ach.icon}
+
+
+
+ {ach.name}
+
+
{ach.desc}
+
+
+ {isUnlocked ? (
+
DONE
+ ) : (
+
🔒
+ )}
+
+
+ );
+}
+
+export default function ProfilePanel({ stats, unlocked }) {
+ const avg = stats.gamesPlayed > 0 ? Math.round(stats.totalScore / stats.gamesPlayed) : 0;
+ const doneCount = [...unlocked].length;
+
+ return (
+
+
+ {/* ── Stats ── */}
+
+
PLAYER STATS
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {/* ── Achievements ── */}
+
+
ACHIEVEMENTS {doneCount}/{ACHIEVEMENTS.length}
+ {/* Progress bar */}
+
+
+
+ {doneCount === ACHIEVEMENTS.length ? '🎉 ALL UNLOCKED' : `${ACHIEVEMENTS.length - doneCount} remaining`}
+
+
+
+ {ACHIEVEMENTS.map(a => (
+
+ ))}
+
+
+
+
+
+ );
+}
diff --git a/client/src/hooks/useAchievements.js b/client/src/hooks/useAchievements.js
new file mode 100644
index 0000000..a7e3fa9
--- /dev/null
+++ b/client/src/hooks/useAchievements.js
@@ -0,0 +1,51 @@
+import { useState, useCallback } from 'react';
+
+const KEY = 'sq_ach_v2';
+
+export const ACHIEVEMENTS = [
+ { id: 'first_blood', name: 'First Blood', desc: 'Eliminate your first opponent', icon: '\u2694\uFE0F', color: '#ef4444', glow: 'rgba(239,68,68,0.35)' },
+ { id: 'titanium_tail', name: 'Titanium Tail', desc: 'Reach a body length of 100', icon: '\uD83D\uDD29', color: '#94a3b8', glow: 'rgba(148,163,184,0.35)' },
+ { id: 'speed_demon', name: 'Speed Demon', desc: 'Boost for 30 consecutive seconds', icon: '\u26A1', color: '#f97316', glow: 'rgba(249,115,22,0.35)' },
+ { id: 'cosmic_giant', name: 'Cosmic Giant', desc: 'Achieve a score of 500 in one game', icon: '\uD83C\uDF0C', color: '#a855f7', glow: 'rgba(168,85,247,0.35)' },
+ { id: 'black_hole_eater', name: 'Black Hole Eater', desc: 'Eat 10 pellets in 5 seconds', icon: '\uD83D\uDD73\uFE0F', color: '#3b82f6', glow: 'rgba(59,130,246,0.35)' },
+];
+
+const loadSet = () => {
+ try { return new Set(JSON.parse(localStorage.getItem(KEY) || '[]')); }
+ catch { return new Set(); }
+};
+
+export function useAchievements() {
+ const [unlocked, setUnlocked] = useState(loadSet);
+ const [toasts, setToasts] = useState([]);
+
+ const tryUnlock = useCallback((stats, rt = {}) => {
+ const newOnes = [];
+ setUnlocked(prev => {
+ const next = new Set(prev);
+ for (const a of ACHIEVEMENTS) {
+ if (next.has(a.id)) continue;
+ let earn = false;
+ if (a.id === 'first_blood') earn = (stats.totalKills || 0) >= 1;
+ if (a.id === 'titanium_tail') earn = (stats.highLength || 0) >= 100;
+ if (a.id === 'speed_demon') earn = (rt.maxBoostSec || 0) >= 30;
+ if (a.id === 'cosmic_giant') earn = (stats.highScore || 0) >= 500;
+ if (a.id === 'black_hole_eater') earn = (rt.maxQuickEat || 0) >= 10;
+ if (earn) { next.add(a.id); newOnes.push(a); }
+ }
+ if (newOnes.length) {
+ try { localStorage.setItem(KEY, JSON.stringify([...next])); } catch {}
+ }
+ return next;
+ });
+ if (newOnes.length) {
+ setToasts(p => [...p, ...newOnes.map(a => ({ ...a, key: Date.now() + Math.random() }))]);
+ }
+ }, []);
+
+ const dismissToast = useCallback(key => {
+ setToasts(p => p.filter(t => t.key !== key));
+ }, []);
+
+ return { unlocked, toasts, tryUnlock, dismissToast };
+}
diff --git a/client/src/hooks/usePlayerStats.js b/client/src/hooks/usePlayerStats.js
new file mode 100644
index 0000000..aad90bf
--- /dev/null
+++ b/client/src/hooks/usePlayerStats.js
@@ -0,0 +1,46 @@
+import { useState, useCallback } from 'react';
+
+const KEY = 'sq_stats_v2';
+
+const defaults = {
+ gamesPlayed: 0,
+ highScore: 0,
+ totalScore: 0,
+ highSurvivalSec: 0,
+ totalKills: 0,
+ highLength: 0,
+ bestRank: null,
+};
+
+const load = () => {
+ try { return { ...defaults, ...JSON.parse(localStorage.getItem(KEY) || '{}') }; }
+ catch { return { ...defaults }; }
+};
+
+export function usePlayerStats() {
+ const [stats, setStats] = useState(load);
+
+ const recordDeath = useCallback(({ score = 0, survivalSec = 0, kills = 0, length = 0, rank = null } = {}) => {
+ setStats(prev => {
+ const next = {
+ ...prev,
+ gamesPlayed: prev.gamesPlayed + 1,
+ highScore: Math.max(prev.highScore, score),
+ totalScore: prev.totalScore + score,
+ highSurvivalSec: Math.max(prev.highSurvivalSec, survivalSec),
+ totalKills: prev.totalKills + kills,
+ highLength: Math.max(prev.highLength, length),
+ bestRank:
+ prev.bestRank === null
+ ? rank
+ : rank !== null && rank < prev.bestRank
+ ? rank
+ : prev.bestRank,
+ };
+ try { localStorage.setItem(KEY, JSON.stringify(next)); } catch {}
+ return next;
+ });
+ }, []);
+
+ return { stats, recordDeath };
+}
diff --git a/client/src/main.jsx b/client/src/main.jsx
new file mode 100644
index 0000000..6f4d655
--- /dev/null
+++ b/client/src/main.jsx
@@ -0,0 +1,9 @@
+import React from 'react';
+import ReactDOM from 'react-dom/client';
+import App from './App.jsx';
+
+ReactDOM.createRoot(document.getElementById('root')).render(
+
+
+
+);
diff --git a/client/vercel.json b/client/vercel.json
new file mode 100644
index 0000000..4e57fc3
--- /dev/null
+++ b/client/vercel.json
@@ -0,0 +1,7 @@
+{
+ "framework": "vite",
+ "buildCommand": "npm run build",
+ "outputDirectory": "dist",
+ "installCommand": "npm install",
+ "rewrites": [{ "source": "/(.*)", "destination": "/index.html" }]
+}
diff --git a/client/vite.config.js b/client/vite.config.js
new file mode 100644
index 0000000..3054c75
--- /dev/null
+++ b/client/vite.config.js
@@ -0,0 +1,7 @@
+import { defineConfig } from 'vite';
+import react from '@vitejs/plugin-react';
+
+export default defineConfig({
+ plugins: [react()],
+ server: { port: 5173, host: true },
+});
diff --git a/server/.env.example b/server/.env.example
new file mode 100644
index 0000000..fb7e6cb
--- /dev/null
+++ b/server/.env.example
@@ -0,0 +1,3 @@
+PORT=3001
+VALKEY_URL=rediss://default:PASSWORD@host:port
+BREETH_API_KEY=ck_live_...
diff --git a/server/Procfile b/server/Procfile
new file mode 100644
index 0000000..1da0cd6
--- /dev/null
+++ b/server/Procfile
@@ -0,0 +1 @@
+web: node index.js
diff --git a/server/breeth.js b/server/breeth.js
new file mode 100644
index 0000000..72f7ca8
--- /dev/null
+++ b/server/breeth.js
@@ -0,0 +1,124 @@
+'use strict';
+
+const BASE_URL = 'https://api.thebreeth.com/v1';
+
+// ─── In-memory fallback store ────────────────────────────────────────────────
+
+const memoryStore = new Map(); // playerName -> { bestLength, lastKiller, runCount }
+
+function getLocal(name) {
+ return memoryStore.get(name) || null;
+}
+
+function saveLocal(name, data) {
+ memoryStore.set(name, { ...(memoryStore.get(name) || {}), ...data });
+}
+
+// ─── Breeth REST helpers ─────────────────────────────────────────────────────
+
+async function breethPost(path, body, apiKey) {
+ const res = await fetch(`${BASE_URL}${path}`, {
+ method: 'POST',
+ headers: {
+ 'Authorization': `Bearer ${apiKey}`,
+ 'Content-Type': 'application/json',
+ },
+ body: JSON.stringify(body),
+ signal: AbortSignal.timeout(4000),
+ });
+ if (!res.ok) throw new Error(`Breeth ${path} → HTTP ${res.status}`);
+ return res.json();
+}
+
+// ─── Public API ──────────────────────────────────────────────────────────────
+
+class BreethClient {
+ constructor(apiKey) {
+ this.apiKey = apiKey || null;
+ this.enabled = !!apiKey;
+ }
+
+ /**
+ * Called on join. Returns greeting text (may be null).
+ */
+ async recallPlayer(name) {
+ // Always update local memory first for fallback
+ const local = getLocal(name);
+
+ if (!this.enabled) {
+ return local ? this._buildGreeting(name, local) : null;
+ }
+
+ try {
+ const result = await breethPost(
+ '/search',
+ { query: `snake game player "${name}" history best length killer`, limit: 5 },
+ this.apiKey,
+ );
+
+ const edges = result.edges || [];
+ if (edges.length === 0) return null;
+
+ // Parse out useful facts from edges
+ let bestLength = null;
+ let lastKiller = null;
+ for (const edge of edges) {
+ const fact = (edge.fact || '').toLowerCase();
+ const m = fact.match(/length[^0-9]*(\d+)/);
+ if (m) bestLength = Math.max(bestLength || 0, parseInt(m[1], 10));
+ const k = fact.match(/killed by ([^,.]+)/i);
+ if (k) lastKiller = k[1].trim();
+ }
+
+ // Merge with local
+ const merged = {
+ bestLength: bestLength || (local && local.bestLength),
+ lastKiller: lastKiller || (local && local.lastKiller),
+ runCount: local ? local.runCount : 0,
+ };
+ saveLocal(name, merged);
+ return this._buildGreeting(name, merged);
+ } catch (err) {
+ console.warn(`[Breeth] recallPlayer failed (${err.message}), using local fallback`);
+ return local ? this._buildGreeting(name, local) : null;
+ }
+ }
+
+ /**
+ * Called on death. Stores the run in Breeth.
+ */
+ async rememberDeath(name, length, killerName) {
+ // Update local
+ const prev = getLocal(name) || {};
+ const updated = {
+ bestLength: Math.max(prev.bestLength || 0, length),
+ lastKiller: killerName || prev.lastKiller || null,
+ runCount: (prev.runCount || 0) + 1,
+ };
+ saveLocal(name, updated);
+
+ if (!this.enabled) return;
+
+ const killerPart = killerName ? ` and was killed by ${killerName}` : ' and hit a wall';
+ const content =
+ `Snake game: player "${name}" completed a run reaching length ${length}${killerPart}. ` +
+ `This is run #${updated.runCount}. Best length so far: ${updated.bestLength}.`;
+
+ try {
+ await breethPost('/episodes', { content, extract_intent: true }, this.apiKey);
+ } catch (err) {
+ console.warn(`[Breeth] rememberDeath failed (${err.message})`);
+ }
+ }
+
+ _buildGreeting(name, data) {
+ if (!data) return null;
+ const parts = [];
+ if (data.runCount > 1) parts.push(`Run #${data.runCount + 1} for ${name}!`);
+ if (data.bestLength) parts.push(`Your best was length ${data.bestLength}.`);
+ if (data.lastKiller) parts.push(`Last time you were eaten by ${data.lastKiller}.`);
+ return parts.length > 0 ? parts.join(' ') : null;
+ }
+}
+
+module.exports = { BreethClient };
diff --git a/server/flight-test.js b/server/flight-test.js
new file mode 100644
index 0000000..e9df4ef
--- /dev/null
+++ b/server/flight-test.js
@@ -0,0 +1,310 @@
+'use strict';
+/**
+ * Flight-test suite — runs against the live server (must be running on PORT 3001).
+ * Usage: node flight-test.js
+ */
+
+require('dotenv').config();
+const WebSocket = require('ws');
+const Redis = require('ioredis');
+
+const SERVER_HTTP = 'http://localhost:3001';
+const SERVER_WS = 'ws://localhost:3001';
+const VALKEY_URL = process.env.VALKEY_URL;
+const BREETH_KEY = process.env.BREETH_API_KEY;
+
+let passed = 0, failed = 0;
+
+function ok(label, cond, detail = '') {
+ if (cond) {
+ console.log(` ✅ ${label}`);
+ passed++;
+ } else {
+ console.error(` ❌ ${label}${detail ? ' — ' + detail : ''}`);
+ failed++;
+ }
+}
+
+async function sleep(ms) { return new Promise(r => setTimeout(r, ms)); }
+
+async function httpGet(path) {
+ const res = await fetch(`${SERVER_HTTP}${path}`);
+ return { status: res.status, body: await res.json() };
+}
+
+// ─── Test helpers ─────────────────────────────────────────────────────────────
+
+function connectPlayer(name) {
+ return new Promise((resolve, reject) => {
+ const ws = new WebSocket(SERVER_WS);
+ const state = { ws, id: null, snapshots: [], killfeed: [], death: null, greeting: null };
+
+ ws.on('open', () => {
+ ws.send(JSON.stringify({ type: 'join', name }));
+ });
+
+ ws.on('message', (raw) => {
+ const msg = JSON.parse(raw);
+ if (msg.type === 'joined') { state.id = msg.id; state.greeting = msg.greeting; resolve(state); }
+ if (msg.type === 'snapshot') state.snapshots.push(msg.data);
+ if (msg.type === 'killfeed') state.killfeed.push(msg.data);
+ if (msg.type === 'death') state.death = msg.data;
+ });
+
+ ws.on('error', reject);
+ setTimeout(() => reject(new Error('join timeout')), 5000);
+ });
+}
+
+function sendInput(state, dir, boost = false) {
+ if (state.ws.readyState === WebSocket.OPEN) {
+ state.ws.send(JSON.stringify({ type: 'input', dir, boost }));
+ }
+}
+
+function disconnect(state) {
+ if (state.ws.readyState === WebSocket.OPEN) state.ws.close();
+}
+
+// ─── Test suites ──────────────────────────────────────────────────────────────
+
+async function testHealth() {
+ console.log('\n── 1. Health & Server ──────────────────────────────────');
+ const { status, body } = await httpGet('/api/health');
+ ok('HTTP 200 from /api/health', status === 200);
+ ok('ok: true', body.ok === true);
+ ok('Valkey flag present', 'valkey' in body);
+ ok('Breeth flag present', 'breeth' in body);
+ console.log(` valkey=${body.valkey} breeth=${body.breeth} players=${body.players}`);
+}
+
+async function testWebSocket() {
+ console.log('\n── 2. WebSocket — Join & Snapshot ──────────────────────');
+ const p1 = await connectPlayer('TestPilot');
+ ok('Player joined and got an id', !!p1.id, p1.id);
+
+ // Wait for a few snapshots
+ await sleep(300);
+ ok('Receiving snapshots', p1.snapshots.length > 0,
+ `got ${p1.snapshots.length} snapshots`);
+
+ const snap = p1.snapshots[p1.snapshots.length - 1];
+ ok('Snapshot has snakes array', Array.isArray(snap?.snakes));
+ ok('Snapshot has pellets array', Array.isArray(snap?.pellets));
+ ok('Snapshot has worldSize', typeof snap?.worldSize === 'number');
+
+ const me = snap?.snakes?.find(s => s.id === p1.id);
+ ok('Own snake in snapshot', !!me, me ? `length=${me.length}` : 'not found');
+ ok('Snake has body nodes', Array.isArray(me?.body) && me.body.length > 0);
+
+ disconnect(p1);
+ return p1;
+}
+
+async function testMultiplayer() {
+ console.log('\n── 3. Multiplayer — 3 simultaneous players ─────────────');
+ const [p1, p2, p3] = await Promise.all([
+ connectPlayer('Alpha'),
+ connectPlayer('Beta'),
+ connectPlayer('Gamma'),
+ ]);
+
+ ok('All 3 players joined', !!p1.id && !!p2.id && !!p3.id);
+ ok('All ids are unique', new Set([p1.id, p2.id, p3.id]).size === 3);
+
+ // Send inputs
+ sendInput(p1, 0); // East
+ sendInput(p2, Math.PI); // West
+ sendInput(p3, Math.PI / 2); // South
+
+ await sleep(400);
+
+ const snap = p1.snapshots[p1.snapshots.length - 1];
+ const ids = new Set(snap?.snakes?.map(s => s.id));
+ ok('All 3 snakes visible in snapshot', [p1.id, p2.id, p3.id].every(id => ids.has(id)),
+ `visible: ${ids.size}`);
+
+ // Check leaderboard
+ const { body: lb } = await httpGet('/api/leaderboard');
+ ok('/api/leaderboard returns array', Array.isArray(lb));
+ ok('Leaderboard entries have name+score', lb.every(e => e.name && typeof e.score === 'number'));
+
+ disconnect(p1); disconnect(p2); disconnect(p3);
+ return { p1, p2, p3 };
+}
+
+async function testBoostAndMovement() {
+ console.log('\n── 4. Input — movement & boost ─────────────────────────');
+ const p = await connectPlayer('SpeedTest');
+ await sleep(200);
+
+ const snap0 = p.snapshots[p.snapshots.length - 1];
+ const me0 = snap0?.snakes?.find(s => s.id === p.id);
+ const x0 = me0?.x ?? 0, y0 = me0?.y ?? 0;
+
+ // Move East for 500ms
+ sendInput(p, 0, false);
+ await sleep(500);
+
+ const snap1 = p.snapshots[p.snapshots.length - 1];
+ const me1 = snap1?.snakes?.find(s => s.id === p.id);
+ ok('Snake moved after input', me1 && (me1.x !== x0 || me1.y !== y0),
+ `(${x0},${y0}) → (${me1?.x},${me1?.y})`);
+
+ // Boost
+ const len0 = me1?.length ?? 0;
+ sendInput(p, 0, true);
+ await sleep(600);
+ const snap2 = p.snapshots[p.snapshots.length - 1];
+ const me2 = snap2?.snakes?.find(s => s.id === p.id);
+ ok('Boost flag visible in snapshot', me2?.boosting === true);
+
+ disconnect(p);
+}
+
+async function testValkey() {
+ console.log('\n── 5. Valkey ────────────────────────────────────────────');
+ if (!VALKEY_URL) {
+ console.log(' ⚠️ VALKEY_URL not set — skipping Valkey tests');
+ return;
+ }
+ const v = new Redis(VALKEY_URL, {
+ tls: VALKEY_URL.startsWith('rediss://') ? {} : undefined,
+ maxRetriesPerRequest: 1,
+ retryStrategy: () => null,
+ });
+
+ try {
+ await v.ping();
+ ok('Valkey PING', true);
+
+ // Join a player and wait for persist cycle (2 s)
+ const p = await connectPlayer('ValkeyTester');
+ sendInput(p, 0);
+ await sleep(2500); // wait for the 2s persist cycle
+
+ const world = await v.get('game:world');
+ ok('game:world key exists (SET EX 5)', !!world, world ? `${world.length} bytes` : 'null');
+
+ const worldObj = world ? JSON.parse(world) : null;
+ ok('game:world is valid JSON snapshot', !!worldObj?.snakes && !!worldObj?.pellets);
+
+ const lbEntries = await v.zrevrange('game:leaderboard', 0, 9, 'WITHSCORES');
+ ok('game:leaderboard sorted set has entries', lbEntries.length > 0,
+ `${lbEntries.length / 2} entries`);
+
+ const names = await v.hgetall('game:names');
+ ok('game:names hash populated', Object.keys(names || {}).length > 0);
+
+ // Test pub/sub round-trip
+ let pubSubReceived = false;
+ const sub = new Redis(VALKEY_URL, { tls: { rejectUnauthorized: false }, retryStrategy: () => null });
+ await sub.subscribe('game:events');
+ sub.on('message', () => { pubSubReceived = true; });
+ await v.publish('game:events', JSON.stringify({ type: 'test', ts: Date.now() }));
+ await sleep(300);
+ ok('Pub/sub round-trip works', pubSubReceived);
+ sub.disconnect();
+
+ disconnect(p);
+ } catch (err) {
+ ok('Valkey connection', false, err.message);
+ } finally {
+ v.disconnect();
+ }
+}
+
+async function testBreeth() {
+ console.log('\n── 6. Breeth AI ─────────────────────────────────────────');
+ if (!BREETH_KEY) {
+ console.log(' ⚠️ BREETH_API_KEY not set — skipping Breeth tests');
+ return;
+ }
+
+ // Direct API test
+ try {
+ const writeRes = await fetch('https://api.thebreeth.com/v1/episodes', {
+ method: 'POST',
+ headers: { 'Authorization': `Bearer ${BREETH_KEY}`, 'Content-Type': 'application/json' },
+ body: JSON.stringify({ content: 'FlightTest player reached length 99 and was killed by TestBot.', extract_intent: true }),
+ signal: AbortSignal.timeout(6000),
+ });
+ ok('Breeth POST /v1/episodes reachable', writeRes.status === 200,
+ `HTTP ${writeRes.status}`);
+
+ const searchRes = await fetch('https://api.thebreeth.com/v1/search', {
+ method: 'POST',
+ headers: { 'Authorization': `Bearer ${BREETH_KEY}`, 'Content-Type': 'application/json' },
+ body: JSON.stringify({ query: 'FlightTest player snake game', limit: 5 }),
+ signal: AbortSignal.timeout(6000),
+ });
+ ok('Breeth POST /v1/search reachable', searchRes.status === 200,
+ `HTTP ${searchRes.status}`);
+ const sBody = await searchRes.json();
+ ok('Search returns edges array', Array.isArray(sBody.edges));
+ } catch (err) {
+ ok('Breeth API reachable', false, err.message);
+ }
+
+ // In-game: join with a known name → check greeting after a death cycle
+ console.log(' Joining as "FlightTest" to trigger Breeth recall…');
+ const p = await connectPlayer('FlightTest');
+ await sleep(500);
+ console.log(` Greeting received: ${p.greeting ?? '(none yet — first run)'}`);
+ ok('Player joined successfully (Breeth did not crash join)', !!p.id);
+ disconnect(p);
+}
+
+async function testRespawn() {
+ console.log('\n── 7. Respawn flow ──────────────────────────────────────');
+ const p = await connectPlayer('RespawnTest');
+ ok('Joined', !!p.id);
+
+ // Drive snake into a wall instantly
+ p.ws.send(JSON.stringify({ type: 'input', dir: 0 }));
+ // Force it to the wall by teleporting via many ticks (just wait and steer toward x=4001)
+ // Instead we test the respawn message directly after simulated death
+ // (wall deaths take time; just verify respawn message works)
+ p.ws.send(JSON.stringify({ type: 'respawn' }));
+ await sleep(200);
+
+ // Should still be receiving snapshots
+ const prevLen = p.snapshots.length;
+ await sleep(300);
+ ok('Snapshots still flowing after respawn message', p.snapshots.length > prevLen);
+ disconnect(p);
+}
+
+// ─── Runner ───────────────────────────────────────────────────────────────────
+
+(async () => {
+ console.log('╔══════════════════════════════════════════════════════════╗');
+ console.log('║ Slither Quest — Flight Test Suite ║');
+ console.log('╚══════════════════════════════════════════════════════════╝');
+ console.log(` Server : ${SERVER_WS}`);
+ console.log(` Valkey : ${VALKEY_URL ? '✅ configured' : '⚠️ not set'}`);
+ console.log(` Breeth : ${BREETH_KEY ? '✅ configured' : '⚠️ not set'}`);
+
+ try {
+ await testHealth();
+ await testWebSocket();
+ await testMultiplayer();
+ await testBoostAndMovement();
+ await testValkey();
+ await testBreeth();
+ await testRespawn();
+ } catch (err) {
+ console.error('\n FATAL:', err.message);
+ failed++;
+ }
+
+ console.log('\n══════════════════════════════════════════════════════════');
+ console.log(` Results: ${passed} passed, ${failed} failed`);
+ if (failed === 0) {
+ console.log(' 🚀 All systems go!');
+ } else {
+ console.log(' ⚠️ Some checks failed — see above.');
+ }
+ console.log('══════════════════════════════════════════════════════════\n');
+ process.exit(failed > 0 ? 1 : 0);
+})();
diff --git a/server/game.js b/server/game.js
new file mode 100644
index 0000000..9dfb923
--- /dev/null
+++ b/server/game.js
@@ -0,0 +1,236 @@
+'use strict';
+
+const WORLD_SIZE = 4000;
+const BASE_SPEED = 3;
+const BOOST_SPEED = 5;
+const SEGMENT_SPACING = 8; // pixels between sampled body nodes
+const HEAD_RADIUS = 10;
+const SEGMENT_RADIUS = 8;
+const PELLET_RADIUS = 5;
+const MIN_LENGTH = 5;
+const INITIAL_LENGTH = 20;
+const MAX_PELLETS = 400;
+const BOOST_DRAIN = 0.05; // length lost per tick while boosting
+const MAX_TURN_RATE = 0.08; // radians per tick
+
+let _nextPelletId = 1;
+
+class GameEngine {
+ constructor() {
+ this.snakes = new Map(); // id -> snake
+ this.pellets = new Map(); // id -> pellet
+ this.tickCount = 0;
+
+ for (let i = 0; i < MAX_PELLETS; i++) this._spawnPellet();
+ }
+
+ // ─── Pellets ────────────────────────────────────────────────────────────────
+
+ _spawnPellet(x, y) {
+ const id = _nextPelletId++;
+ const pellet = {
+ id,
+ x: x !== undefined
+ ? Math.max(10, Math.min(WORLD_SIZE - 10, x + (Math.random() - 0.5) * 40))
+ : 10 + Math.random() * (WORLD_SIZE - 20),
+ y: y !== undefined
+ ? Math.max(10, Math.min(WORLD_SIZE - 10, y + (Math.random() - 0.5) * 40))
+ : 10 + Math.random() * (WORLD_SIZE - 20),
+ hue: Math.floor(Math.random() * 360),
+ };
+ this.pellets.set(id, pellet);
+ return pellet;
+ }
+
+ // ─── Snakes ─────────────────────────────────────────────────────────────────
+
+ addSnake(id, name) {
+ const x = 300 + Math.random() * (WORLD_SIZE - 600);
+ const y = 300 + Math.random() * (WORLD_SIZE - 600);
+ const dir = Math.random() * Math.PI * 2;
+
+ // Pre-fill path so body is visible immediately
+ const path = [];
+ const pathLen = (INITIAL_LENGTH + 5) * SEGMENT_SPACING;
+ for (let i = 0; i < pathLen; i++) {
+ path.push({ x: x - Math.cos(dir) * i, y: y - Math.sin(dir) * i });
+ }
+
+ const snake = {
+ id, name,
+ x, y, dir,
+ path,
+ length: INITIAL_LENGTH,
+ score: 0,
+ boosting: false,
+ alive: true,
+ color: `hsl(${Math.floor(Math.random() * 360)},80%,55%)`,
+ input: { dir, boost: false },
+ };
+
+ this.snakes.set(id, snake);
+ return snake;
+ }
+
+ removeSnake(id) {
+ this.snakes.delete(id);
+ }
+
+ setInput(id, input) {
+ const s = this.snakes.get(id);
+ if (s && s.alive) s.input = input;
+ }
+
+ // ─── Body nodes ─────────────────────────────────────────────────────────────
+
+ _getBodyNodes(snake) {
+ const nodes = [];
+ const count = Math.ceil(snake.length);
+ for (let i = 0; i < count; i++) {
+ const idx = i * SEGMENT_SPACING;
+ if (idx < snake.path.length) nodes.push(snake.path[idx]);
+ }
+ return nodes;
+ }
+
+ // ─── Kill ───────────────────────────────────────────────────────────────────
+
+ _killSnake(id, killerId, events) {
+ const s = this.snakes.get(id);
+ if (!s || !s.alive) return;
+ s.alive = false;
+
+ // Drop pellets along body
+ const body = this._getBodyNodes(s);
+ const step = Math.max(1, Math.floor(body.length / 40));
+ for (let i = 0; i < body.length; i += step) {
+ if (this.pellets.size < MAX_PELLETS * 2) this._spawnPellet(body[i].x, body[i].y);
+ }
+
+ const killer = killerId ? this.snakes.get(killerId) : null;
+ events.push({
+ type: 'death',
+ victimId: id,
+ victimName: s.name,
+ killerId: killerId || null,
+ killerName: killer ? killer.name : null,
+ length: Math.floor(s.length),
+ timestamp: Date.now(),
+ });
+ }
+
+ // ─── Game tick ──────────────────────────────────────────────────────────────
+
+ tick() {
+ this.tickCount++;
+ const events = [];
+ const alive = [];
+
+ // Move all snakes
+ for (const [id, s] of this.snakes) {
+ if (!s.alive) continue;
+ alive.push(s);
+
+ // Smooth turn
+ let diff = s.input.dir - s.dir;
+ while (diff > Math.PI) diff -= Math.PI * 2;
+ while (diff < -Math.PI) diff += Math.PI * 2;
+ s.dir += Math.max(-MAX_TURN_RATE, Math.min(MAX_TURN_RATE, diff));
+
+ // Speed & boost
+ const canBoost = s.input.boost && s.length > MIN_LENGTH + 1;
+ s.boosting = canBoost;
+ const speed = canBoost ? BOOST_SPEED : BASE_SPEED;
+
+ s.x += Math.cos(s.dir) * speed;
+ s.y += Math.sin(s.dir) * speed;
+ s.path.unshift({ x: s.x, y: s.y });
+
+ if (canBoost) {
+ const prevFloor = Math.floor(s.length);
+ s.length -= BOOST_DRAIN;
+ if (Math.floor(s.length) < prevFloor) this._spawnPellet(s.x, s.y);
+ if (s.length < MIN_LENGTH) s.length = MIN_LENGTH;
+ }
+
+ // Trim path to needed length
+ const maxLen = Math.ceil(s.length) * SEGMENT_SPACING + SEGMENT_SPACING + 1;
+ if (s.path.length > maxLen) s.path.length = maxLen;
+
+ // Wall death
+ if (s.x < 0 || s.x > WORLD_SIZE || s.y < 0 || s.y > WORLD_SIZE) {
+ this._killSnake(id, null, events);
+ }
+ }
+
+ // Pellet collisions
+ for (const s of alive) {
+ if (!s.alive) continue;
+ for (const [pid, p] of this.pellets) {
+ const dx = s.x - p.x, dy = s.y - p.y;
+ if (dx * dx + dy * dy < (HEAD_RADIUS + PELLET_RADIUS) ** 2) {
+ s.length += 1;
+ s.score += 1;
+ this.pellets.delete(pid);
+ this._spawnPellet();
+ }
+ }
+ }
+
+ // Snake vs snake
+ for (const s of alive) {
+ if (!s.alive) continue;
+ for (const other of alive) {
+ if (!other.alive || s.id === other.id) continue;
+ const body = this._getBodyNodes(other);
+ for (let i = 0; i < body.length; i++) {
+ const node = body[i];
+ const dx = s.x - node.x, dy = s.y - node.y;
+ // Head-to-head uses doubled radius; body uses combined radii
+ const r = i === 0 ? HEAD_RADIUS * 1.8 : HEAD_RADIUS + SEGMENT_RADIUS;
+ if (dx * dx + dy * dy < r * r) {
+ this._killSnake(s.id, i === 0 ? null : other.id, events);
+ break;
+ }
+ }
+ }
+ }
+
+ // Replenish pellets
+ while (this.pellets.size < MAX_PELLETS) this._spawnPellet();
+
+ return events;
+ }
+
+ // ─── Snapshot ───────────────────────────────────────────────────────────────
+
+ getSnapshot() {
+ const snakes = [];
+ for (const s of this.snakes.values()) {
+ if (!s.alive) continue;
+ snakes.push({
+ id: s.id,
+ name: s.name,
+ x: Math.round(s.x),
+ y: Math.round(s.y),
+ dir: +s.dir.toFixed(3),
+ length: Math.floor(s.length),
+ score: s.score,
+ boosting: s.boosting,
+ color: s.color,
+ // Compact body: array of [x, y] pairs
+ body: this._getBodyNodes(s).map(p => [Math.round(p.x), Math.round(p.y)]),
+ });
+ }
+
+ // Compact pellets: [id, x, y, hue]
+ const pellets = [];
+ for (const p of this.pellets.values()) {
+ pellets.push([p.id, Math.round(p.x), Math.round(p.y), p.hue]);
+ }
+
+ return { t: this.tickCount, snakes, pellets, worldSize: WORLD_SIZE };
+ }
+}
+
+module.exports = { GameEngine, WORLD_SIZE };
diff --git a/server/index.js b/server/index.js
new file mode 100644
index 0000000..fc2dbc6
--- /dev/null
+++ b/server/index.js
@@ -0,0 +1,356 @@
+'use strict';
+
+require('dotenv').config();
+
+const express = require('express');
+const http = require('http');
+const WebSocket = require('ws');
+const crypto = require('crypto');
+const path = require('path');
+const { v4: uuidv4 } = require('uuid');
+const { RoomManager } = require('./room-manager');
+const { GameEngine } = require('./game');
+const { BreethClient } = require('./breeth');
+
+const PORT = parseInt(process.env.PORT || '3001', 10);
+const TICK_MS = 1000 / 25; // 25 Hz
+
+const hashPin = (pin) => crypto.createHash('sha256').update(String(pin)).digest('hex');
+
+// ─── Express ─────────────────────────────────────────────────────────────────
+
+const app = express();
+app.use(express.json());
+app.use((_, res, next) => { res.setHeader('Access-Control-Allow-Origin', '*'); next(); });
+app.use(express.static(path.join(__dirname, '../client/dist')));
+
+const server = http.createServer(app);
+const wss = new WebSocket.Server({ server, perMessageDeflate: true });
+
+// ─── Valkey ──────────────────────────────────────────────────────────────────
+
+let valkey = null, valkeyPub = null, valkeySub = null;
+const VALKEY_URL = process.env.VALKEY_URL;
+
+if (VALKEY_URL) {
+ const Redis = require('ioredis');
+ const mkClient = () => new Redis(VALKEY_URL, {
+ tls: VALKEY_URL.startsWith('rediss://') ? {} : undefined,
+ lazyConnect: false,
+ maxRetriesPerRequest: 1,
+ enableReadyCheck: false,
+ retryStrategy: (t) => t > 3 ? null : t * 500,
+ });
+ valkey = mkClient();
+ valkeyPub = mkClient();
+ valkeySub = mkClient();
+
+ valkey.on('error', e => console.warn('[Valkey] main:', e.message));
+ valkeyPub.on('error', e => console.warn('[Valkey] pub:', e.message));
+ valkeySub.on('error', e => console.warn('[Valkey] sub:', e.message));
+
+ valkeySub.subscribe('game:events', err => {
+ if (err) console.warn('[Valkey] subscribe error:', err.message);
+ });
+ // Relay pub/sub kill events to the right room
+ valkeySub.on('message', (_ch, raw) => {
+ try {
+ const event = JSON.parse(raw);
+ if (event.roomCode) {
+ const room = roomManager.get(event.roomCode);
+ if (room) room.broadcastAll({ type: 'killfeed', data: event });
+ }
+ } catch (_) {}
+ });
+ console.log('[Valkey] connecting…');
+} else {
+ console.warn('[Valkey] VALKEY_URL not set — running without persistence');
+}
+
+// ─── PIN / user store ─────────────────────────────────────────────────────────
+// Valkey hash: game:users → { lowerName: hashedPin }
+// In-memory fallback when Valkey unavailable
+const localUsers = new Map(); // lowerName → hashedPin
+
+async function getStoredPin(name) {
+ const key = name.toLowerCase();
+ if (valkey) {
+ try { return await valkey.hget('game:users', key); } catch (_) {}
+ }
+ return localUsers.get(key) || null;
+}
+
+async function savePin(name, pin) {
+ const key = name.toLowerCase();
+ const h = hashPin(pin);
+ localUsers.set(key, h);
+ if (valkey) {
+ try { await valkey.hset('game:users', key, h); } catch (_) {}
+ }
+ return h;
+}
+
+// ─── Room manager ─────────────────────────────────────────────────────────────
+
+const roomManager = new RoomManager();
+const breeth = new BreethClient(process.env.BREETH_API_KEY);
+
+// clientId → roomCode (global lookup so we can clean up on disconnect)
+const clientRoom = new Map();
+
+// ─── Helpers ─────────────────────────────────────────────────────────────────
+
+function computeLeaderboard(snapshot) {
+ return snapshot.snakes.slice().sort((a, b) => b.score - a.score).slice(0, 10)
+ .map(s => ({ id: s.id, name: s.name, score: s.score }));
+}
+
+async function persistRoom(room, snapshot) {
+ if (!valkey) return;
+ try {
+ const pipe = valkey.pipeline();
+ for (const s of snapshot.snakes) {
+ pipe.zadd(`game:${room.code}:leaderboard`, s.score, s.id);
+ pipe.hset(`game:${room.code}:names`, s.id, s.name);
+ }
+ pipe.set(`game:${room.code}:world`, JSON.stringify(snapshot), 'EX', 5);
+ await pipe.exec();
+
+ const raw = await valkey.zrevrange(`game:${room.code}:leaderboard`, 0, 9, 'WITHSCORES');
+ const lb = [];
+ for (let i = 0; i < raw.length; i += 2) {
+ const id = raw[i];
+ const score = parseInt(raw[i + 1], 10);
+ const name = await valkey.hget(`game:${room.code}:names`, id);
+ lb.push({ id, name: name || 'Unknown', score });
+ }
+ room.leaderboard = lb;
+ } catch (err) {
+ console.warn(`[Valkey] persist room ${room.code}:`, err.message);
+ }
+}
+
+async function handleRoomDeath(room, event) {
+ room.send(event.victimId, { type: 'death', data: event });
+
+ if (valkeyPub) {
+ valkeyPub.publish('game:events', JSON.stringify({ ...event, roomCode: room.code }))
+ .catch(() => room.broadcastAll({ type: 'killfeed', data: event }));
+ } else {
+ room.broadcastAll({ type: 'killfeed', data: event });
+ }
+
+ const player = room.players.get(event.victimId);
+ if (player) breeth.rememberDeath(player.name, event.length, event.killerName).catch(() => {});
+}
+
+// ─── Master game loop — ticks all active rooms ────────────────────────────────
+
+setInterval(() => {
+ const now = Date.now();
+ for (const room of roomManager.rooms.values()) {
+ if (room.clients.size === 0) continue;
+
+ const events = room.game.tick();
+ const snapshot = room.game.getSnapshot();
+
+ for (const ev of events) {
+ if (ev.type === 'death') handleRoomDeath(room, ev).catch(() => {});
+ }
+
+ if (now - room.lastPersist >= 2000) {
+ room.lastPersist = now;
+ if (valkey) persistRoom(room, snapshot).catch(() => {});
+ else room.leaderboard = computeLeaderboard(snapshot);
+ }
+
+ room.broadcastAll({ type: 'snapshot', data: snapshot, leaderboard: room.leaderboard, timeLeftMs: room.timeLeftMs() });
+
+ // ── 5-minute round end ──────────────────────────────────────────────────
+ if (room.shouldEndGame()) {
+ room.inIntermission = true;
+ const finalLB = [...snapshot.snakes]
+ .sort((a, b) => b.score - a.score)
+ .slice(0, 10)
+ .map(s => ({ id: s.id, name: s.name, score: s.score }));
+ const winner = finalLB[0] || null;
+
+ room.broadcastAll({ type: 'game_over', leaderboard: finalLB, winner, nextRoundIn: 10 });
+ console.log(`[Room] ${room.code} round ended. Winner: ${winner?.name || 'none'}`);
+
+ // Persist final leaderboard to Valkey
+ if (valkey && finalLB.length > 0) {
+ const pipe = valkey.pipeline();
+ for (const s of finalLB) {
+ pipe.zadd(`game:${room.code}:leaderboard`, s.score, s.id);
+ }
+ pipe.exec().catch(() => {});
+ }
+
+ // Auto-reset after 10 seconds — new round for all players
+ setTimeout(() => {
+ if (!roomManager.rooms.has(room.code)) return; // room was deleted
+ room.game = new GameEngine();
+ room.startRound();
+ for (const [cId, player] of room.players) {
+ room.game.addSnake(cId, player.name);
+ room.send(cId, { type: 'respawned', id: cId });
+ }
+ console.log(`[Room] ${room.code} new round started.`);
+ }, 10_000);
+ }
+ }
+}, TICK_MS);
+
+// ─── WebSocket ───────────────────────────────────────────────────────────────
+
+wss.on('connection', (ws) => {
+ const clientId = uuidv4();
+
+ const send = (obj) => {
+ if (ws.readyState === WebSocket.OPEN) ws.send(JSON.stringify(obj));
+ };
+
+ ws.on('message', async (raw) => {
+ let msg;
+ try { msg = JSON.parse(raw); } catch { return; }
+
+ // ── check_name ──────────────────────────────────────────────────────────
+ if (msg.type === 'check_name') {
+ const name = String(msg.name || '').trim();
+ if (!name) return;
+ const stored = await getStoredPin(name);
+ send({ type: 'name_status', isNew: !stored });
+ return;
+ }
+
+ // ── check_room ──────────────────────────────────────────────────────────
+ if (msg.type === 'check_room') {
+ const room = roomManager.get(msg.roomCode);
+ send({ type: 'room_info', exists: !!room, passwordRequired: room ? !!room.password : false });
+ return;
+ }
+
+ // ── create_room ──────────────────────────────────────────────────────────
+ if (msg.type === 'create_room') {
+ const name = String(msg.name || '').trim().slice(0, 20) || 'Anonymous';
+ const pin = String(msg.pin || '').trim();
+ const roomPass = String(msg.roomPassword || '').trim() || null;
+
+ if (!pin || pin.length < 4) { send({ type: 'error', code: 'pin_required', message: 'PIN must be 4 digits.' }); return; }
+
+ // Authenticate / register PIN
+ const stored = await getStoredPin(name);
+ if (stored) {
+ if (stored !== hashPin(pin)) { send({ type: 'error', code: 'wrong_pin', message: 'Wrong PIN for this name.' }); return; }
+ } else {
+ await savePin(name, pin);
+ }
+
+ let room;
+ try { room = roomManager.create(roomPass); }
+ catch (err) { send({ type: 'error', code: 'server_full', message: err.message }); return; }
+
+ room.addClient(clientId, ws);
+ room.players.set(clientId, { name });
+ room.game.addSnake(clientId, name);
+ clientRoom.set(clientId, room.code);
+ room.startRound(); // begin 5-minute timer
+
+ let greeting = null;
+ try { greeting = await breeth.recallPlayer(name); } catch (_) {}
+ if (valkey) valkey.hset('game:users:display', name.toLowerCase(), name).catch(() => {});
+
+ send({ type: 'room_created', roomCode: room.code, id: clientId, greeting, passwordRequired: !!roomPass });
+ return;
+ }
+
+ // ── join_room ────────────────────────────────────────────────────────────
+ if (msg.type === 'join_room') {
+ const roomCode = String(msg.roomCode || '').toUpperCase().trim();
+ const name = String(msg.name || '').trim().slice(0, 20) || 'Anonymous';
+ const pin = String(msg.pin || '').trim();
+ const password = String(msg.password || '').trim();
+
+ if (!pin || pin.length < 4) { send({ type: 'error', code: 'pin_required', message: 'PIN must be 4 digits.' }); return; }
+
+ const room = roomManager.get(roomCode);
+ if (!room) { send({ type: 'error', code: 'room_not_found', message: `Room "${roomCode}" does not exist.` }); return; }
+
+ if (room.password && password !== room.password) {
+ send({ type: 'error', code: 'wrong_password', message: 'Wrong room password.' }); return;
+ }
+
+ // Authenticate / register PIN
+ const stored = await getStoredPin(name);
+ if (stored) {
+ if (stored !== hashPin(pin)) { send({ type: 'error', code: 'wrong_pin', message: 'Wrong PIN for this name.' }); return; }
+ } else {
+ await savePin(name, pin);
+ }
+
+ room.addClient(clientId, ws);
+ room.players.set(clientId, { name });
+ room.game.addSnake(clientId, name);
+ clientRoom.set(clientId, room.code);
+
+ let greeting = null;
+ try { greeting = await breeth.recallPlayer(name); } catch (_) {}
+ if (valkey) valkey.hset('game:users:display', name.toLowerCase(), name).catch(() => {});
+
+ send({ type: 'joined', id: clientId, roomCode: room.code, greeting });
+ return;
+ }
+
+ // ── input ────────────────────────────────────────────────────────────────
+ if (msg.type === 'input') {
+ const room = roomManager.get(clientRoom.get(clientId));
+ if (room) room.game.setInput(clientId, { dir: msg.dir || 0, boost: !!msg.boost });
+ return;
+ }
+
+ // ── respawn ──────────────────────────────────────────────────────────────
+ if (msg.type === 'respawn') {
+ const room = roomManager.get(clientRoom.get(clientId));
+ if (room) {
+ const player = room.players.get(clientId);
+ if (player) {
+ room.game.addSnake(clientId, player.name);
+ send({ type: 'respawned', id: clientId });
+ }
+ }
+ return;
+ }
+ });
+
+ ws.on('close', () => {
+ const code = clientRoom.get(clientId);
+ if (code) {
+ const room = roomManager.get(code);
+ if (room) room.removeClient(clientId, (c) => roomManager.delete(c));
+ clientRoom.delete(clientId);
+ }
+ });
+
+ ws.on('error', () => ws.close());
+});
+
+// ─── REST ─────────────────────────────────────────────────────────────────────
+
+app.get('/api/rooms', (_req, res) => res.json(roomManager.list()));
+
+app.get('/api/health', (_req, res) => res.json({
+ ok: true,
+ rooms: roomManager.rooms.size,
+ totalPlayers: [...roomManager.rooms.values()].reduce((n, r) => n + r.clients.size, 0),
+ valkey: !!valkey,
+ breeth: breeth.enabled,
+}));
+
+// ─── Start ────────────────────────────────────────────────────────────────────
+
+server.listen(PORT, () => {
+ console.log(`[Server] ws://localhost:${PORT}`);
+ console.log(`[Valkey] ${VALKEY_URL ? 'connected' : 'disabled'}`);
+ console.log(`[Breeth] ${breeth.enabled ? 'enabled' : 'fallback'}`);
+});
diff --git a/server/package-lock.json b/server/package-lock.json
new file mode 100644
index 0000000..51123b4
--- /dev/null
+++ b/server/package-lock.json
@@ -0,0 +1,974 @@
+{
+ "name": "slither-server",
+ "version": "1.0.0",
+ "lockfileVersion": 3,
+ "requires": true,
+ "packages": {
+ "": {
+ "name": "slither-server",
+ "version": "1.0.0",
+ "dependencies": {
+ "dotenv": "^16.4.5",
+ "express": "^4.19.2",
+ "ioredis": "^5.3.2",
+ "uuid": "^9.0.1",
+ "ws": "^8.17.1"
+ }
+ },
+ "node_modules/@ioredis/commands": {
+ "version": "1.10.0",
+ "resolved": "https://registry.npmjs.org/@ioredis/commands/-/commands-1.10.0.tgz",
+ "integrity": "sha512-UmeW7z4LfctwoQ5wkhVzgq8tXkreED2xZGpX+Bg+zA+WJFZCT6c062AfCK/Dfk81xZnnwdhJCUMkitihRaoC2Q==",
+ "license": "MIT"
+ },
+ "node_modules/accepts": {
+ "version": "1.3.8",
+ "resolved": "https://registry.npmjs.org/accepts/-/accepts-1.3.8.tgz",
+ "integrity": "sha512-PYAthTa2m2VKxuvSD3DPC/Gy+U+sOA1LAuT8mkmRuvw+NACSaeXEQ+NHcVF7rONl6qcaxV3Uuemwawk+7+SJLw==",
+ "license": "MIT",
+ "dependencies": {
+ "mime-types": "~2.1.34",
+ "negotiator": "0.6.3"
+ },
+ "engines": {
+ "node": ">= 0.6"
+ }
+ },
+ "node_modules/array-flatten": {
+ "version": "1.1.1",
+ "resolved": "https://registry.npmjs.org/array-flatten/-/array-flatten-1.1.1.tgz",
+ "integrity": "sha512-PCVAQswWemu6UdxsDFFX/+gVeYqKAod3D3UVm91jHwynguOwAvYPhx8nNlM++NqRcK6CxxpUafjmhIdKiHibqg==",
+ "license": "MIT"
+ },
+ "node_modules/body-parser": {
+ "version": "1.20.5",
+ "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-1.20.5.tgz",
+ "integrity": "sha512-3grm+/2tUOvu2cjJkvsIxrv/wVpfXQW4PsQHYm7yk4vfpu7Ekl6nEsYBoJUL6qDwZUx8wUhQ8tR2qz+ad9c9OA==",
+ "license": "MIT",
+ "dependencies": {
+ "bytes": "~3.1.2",
+ "content-type": "~1.0.5",
+ "debug": "2.6.9",
+ "depd": "2.0.0",
+ "destroy": "~1.2.0",
+ "http-errors": "~2.0.1",
+ "iconv-lite": "~0.4.24",
+ "on-finished": "~2.4.1",
+ "qs": "~6.15.1",
+ "raw-body": "~2.5.3",
+ "type-is": "~1.6.18",
+ "unpipe": "~1.0.0"
+ },
+ "engines": {
+ "node": ">= 0.8",
+ "npm": "1.2.8000 || >= 1.4.16"
+ }
+ },
+ "node_modules/bytes": {
+ "version": "3.1.2",
+ "resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz",
+ "integrity": "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.8"
+ }
+ },
+ "node_modules/call-bind-apply-helpers": {
+ "version": "1.0.2",
+ "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz",
+ "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==",
+ "license": "MIT",
+ "dependencies": {
+ "es-errors": "^1.3.0",
+ "function-bind": "^1.1.2"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ }
+ },
+ "node_modules/call-bound": {
+ "version": "1.0.4",
+ "resolved": "https://registry.npmjs.org/call-bound/-/call-bound-1.0.4.tgz",
+ "integrity": "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==",
+ "license": "MIT",
+ "dependencies": {
+ "call-bind-apply-helpers": "^1.0.2",
+ "get-intrinsic": "^1.3.0"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/cluster-key-slot": {
+ "version": "1.1.1",
+ "resolved": "https://registry.npmjs.org/cluster-key-slot/-/cluster-key-slot-1.1.1.tgz",
+ "integrity": "sha512-rwHwUfXL40Chm1r08yrhU3qpUvdVlgkKNeyeGPOxnW8/SyVDvgRaed/Uz54AqWNaTCAThlj6QAs3TZcKI0xDEw==",
+ "license": "Apache-2.0",
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/content-disposition": {
+ "version": "0.5.4",
+ "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-0.5.4.tgz",
+ "integrity": "sha512-FveZTNuGw04cxlAiWbzi6zTAL/lhehaWbTtgluJh4/E95DqMwTmha3KZN1aAWA8cFIhHzMZUvLevkw5Rqk+tSQ==",
+ "license": "MIT",
+ "dependencies": {
+ "safe-buffer": "5.2.1"
+ },
+ "engines": {
+ "node": ">= 0.6"
+ }
+ },
+ "node_modules/content-type": {
+ "version": "1.0.5",
+ "resolved": "https://registry.npmjs.org/content-type/-/content-type-1.0.5.tgz",
+ "integrity": "sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.6"
+ }
+ },
+ "node_modules/cookie": {
+ "version": "0.7.2",
+ "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.2.tgz",
+ "integrity": "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.6"
+ }
+ },
+ "node_modules/cookie-signature": {
+ "version": "1.0.7",
+ "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.0.7.tgz",
+ "integrity": "sha512-NXdYc3dLr47pBkpUCHtKSwIOQXLVn8dZEuywboCOJY/osA0wFSLlSawr3KN8qXJEyX66FcONTH8EIlVuK0yyFA==",
+ "license": "MIT"
+ },
+ "node_modules/debug": {
+ "version": "2.6.9",
+ "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz",
+ "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==",
+ "license": "MIT",
+ "dependencies": {
+ "ms": "2.0.0"
+ }
+ },
+ "node_modules/denque": {
+ "version": "2.1.0",
+ "resolved": "https://registry.npmjs.org/denque/-/denque-2.1.0.tgz",
+ "integrity": "sha512-HVQE3AAb/pxF8fQAoiqpvg9i3evqug3hoiwakOyZAwJm+6vZehbkYXZ0l4JxS+I3QxM97v5aaRNhj8v5oBhekw==",
+ "license": "Apache-2.0",
+ "engines": {
+ "node": ">=0.10"
+ }
+ },
+ "node_modules/depd": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz",
+ "integrity": "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.8"
+ }
+ },
+ "node_modules/destroy": {
+ "version": "1.2.0",
+ "resolved": "https://registry.npmjs.org/destroy/-/destroy-1.2.0.tgz",
+ "integrity": "sha512-2sJGJTaXIIaR1w4iJSNoN0hnMY7Gpc/n8D4qSCJw8QqFWXf7cuAgnEHxBpweaVcPevC2l3KpjYCx3NypQQgaJg==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.8",
+ "npm": "1.2.8000 || >= 1.4.16"
+ }
+ },
+ "node_modules/dotenv": {
+ "version": "16.6.1",
+ "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-16.6.1.tgz",
+ "integrity": "sha512-uBq4egWHTcTt33a72vpSG0z3HnPuIl6NqYcTrKEg2azoEyl2hpW0zqlxysq2pK9HlDIHyHyakeYaYnSAwd8bow==",
+ "license": "BSD-2-Clause",
+ "engines": {
+ "node": ">=12"
+ },
+ "funding": {
+ "url": "https://dotenvx.com"
+ }
+ },
+ "node_modules/dunder-proto": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz",
+ "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==",
+ "license": "MIT",
+ "dependencies": {
+ "call-bind-apply-helpers": "^1.0.1",
+ "es-errors": "^1.3.0",
+ "gopd": "^1.2.0"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ }
+ },
+ "node_modules/ee-first": {
+ "version": "1.1.1",
+ "resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz",
+ "integrity": "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==",
+ "license": "MIT"
+ },
+ "node_modules/encodeurl": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-2.0.0.tgz",
+ "integrity": "sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.8"
+ }
+ },
+ "node_modules/es-define-property": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz",
+ "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.4"
+ }
+ },
+ "node_modules/es-errors": {
+ "version": "1.3.0",
+ "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz",
+ "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.4"
+ }
+ },
+ "node_modules/es-object-atoms": {
+ "version": "1.1.2",
+ "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.2.tgz",
+ "integrity": "sha512-HWcBoN6NileqtSydK2FqHbS/LoDd2pqrnQHLyJzBj4kOp/ky2MWMN694xOfkK8/SnUsW2DH7EfyVlydKCsm1Zw==",
+ "license": "MIT",
+ "dependencies": {
+ "es-errors": "^1.3.0"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ }
+ },
+ "node_modules/escape-html": {
+ "version": "1.0.3",
+ "resolved": "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz",
+ "integrity": "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==",
+ "license": "MIT"
+ },
+ "node_modules/etag": {
+ "version": "1.8.1",
+ "resolved": "https://registry.npmjs.org/etag/-/etag-1.8.1.tgz",
+ "integrity": "sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.6"
+ }
+ },
+ "node_modules/express": {
+ "version": "4.22.2",
+ "resolved": "https://registry.npmjs.org/express/-/express-4.22.2.tgz",
+ "integrity": "sha512-IuL+Elrou2ZvCFHs18/CIzy2Nzvo25nZ1/D2eIZlz7c+QUayAcYoiM2BthCjs+EBHVpjYjcuLDAiCWgeIX3X1Q==",
+ "license": "MIT",
+ "dependencies": {
+ "accepts": "~1.3.8",
+ "array-flatten": "1.1.1",
+ "body-parser": "~1.20.5",
+ "content-disposition": "~0.5.4",
+ "content-type": "~1.0.4",
+ "cookie": "~0.7.1",
+ "cookie-signature": "~1.0.6",
+ "debug": "2.6.9",
+ "depd": "2.0.0",
+ "encodeurl": "~2.0.0",
+ "escape-html": "~1.0.3",
+ "etag": "~1.8.1",
+ "finalhandler": "~1.3.1",
+ "fresh": "~0.5.2",
+ "http-errors": "~2.0.0",
+ "merge-descriptors": "1.0.3",
+ "methods": "~1.1.2",
+ "on-finished": "~2.4.1",
+ "parseurl": "~1.3.3",
+ "path-to-regexp": "~0.1.12",
+ "proxy-addr": "~2.0.7",
+ "qs": "~6.15.1",
+ "range-parser": "~1.2.1",
+ "safe-buffer": "5.2.1",
+ "send": "~0.19.0",
+ "serve-static": "~1.16.2",
+ "setprototypeof": "1.2.0",
+ "statuses": "~2.0.1",
+ "type-is": "~1.6.18",
+ "utils-merge": "1.0.1",
+ "vary": "~1.1.2"
+ },
+ "engines": {
+ "node": ">= 0.10.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/express"
+ }
+ },
+ "node_modules/finalhandler": {
+ "version": "1.3.2",
+ "resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-1.3.2.tgz",
+ "integrity": "sha512-aA4RyPcd3badbdABGDuTXCMTtOneUCAYH/gxoYRTZlIJdF0YPWuGqiAsIrhNnnqdXGswYk6dGujem4w80UJFhg==",
+ "license": "MIT",
+ "dependencies": {
+ "debug": "2.6.9",
+ "encodeurl": "~2.0.0",
+ "escape-html": "~1.0.3",
+ "on-finished": "~2.4.1",
+ "parseurl": "~1.3.3",
+ "statuses": "~2.0.2",
+ "unpipe": "~1.0.0"
+ },
+ "engines": {
+ "node": ">= 0.8"
+ }
+ },
+ "node_modules/forwarded": {
+ "version": "0.2.0",
+ "resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz",
+ "integrity": "sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.6"
+ }
+ },
+ "node_modules/fresh": {
+ "version": "0.5.2",
+ "resolved": "https://registry.npmjs.org/fresh/-/fresh-0.5.2.tgz",
+ "integrity": "sha512-zJ2mQYM18rEFOudeV4GShTGIQ7RbzA7ozbU9I/XBpm7kqgMywgmylMwXHxZJmkVoYkna9d2pVXVXPdYTP9ej8Q==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.6"
+ }
+ },
+ "node_modules/function-bind": {
+ "version": "1.1.2",
+ "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz",
+ "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==",
+ "license": "MIT",
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/get-intrinsic": {
+ "version": "1.3.0",
+ "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz",
+ "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==",
+ "license": "MIT",
+ "dependencies": {
+ "call-bind-apply-helpers": "^1.0.2",
+ "es-define-property": "^1.0.1",
+ "es-errors": "^1.3.0",
+ "es-object-atoms": "^1.1.1",
+ "function-bind": "^1.1.2",
+ "get-proto": "^1.0.1",
+ "gopd": "^1.2.0",
+ "has-symbols": "^1.1.0",
+ "hasown": "^2.0.2",
+ "math-intrinsics": "^1.1.0"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/get-proto": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz",
+ "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==",
+ "license": "MIT",
+ "dependencies": {
+ "dunder-proto": "^1.0.1",
+ "es-object-atoms": "^1.0.0"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ }
+ },
+ "node_modules/gopd": {
+ "version": "1.2.0",
+ "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz",
+ "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/has-symbols": {
+ "version": "1.1.0",
+ "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz",
+ "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/hasown": {
+ "version": "2.0.4",
+ "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.4.tgz",
+ "integrity": "sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==",
+ "license": "MIT",
+ "dependencies": {
+ "function-bind": "^1.1.2"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ }
+ },
+ "node_modules/http-errors": {
+ "version": "2.0.1",
+ "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.1.tgz",
+ "integrity": "sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==",
+ "license": "MIT",
+ "dependencies": {
+ "depd": "~2.0.0",
+ "inherits": "~2.0.4",
+ "setprototypeof": "~1.2.0",
+ "statuses": "~2.0.2",
+ "toidentifier": "~1.0.1"
+ },
+ "engines": {
+ "node": ">= 0.8"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/express"
+ }
+ },
+ "node_modules/iconv-lite": {
+ "version": "0.4.24",
+ "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.4.24.tgz",
+ "integrity": "sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA==",
+ "license": "MIT",
+ "dependencies": {
+ "safer-buffer": ">= 2.1.2 < 3"
+ },
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/inherits": {
+ "version": "2.0.4",
+ "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz",
+ "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==",
+ "license": "ISC"
+ },
+ "node_modules/ioredis": {
+ "version": "5.11.1",
+ "resolved": "https://registry.npmjs.org/ioredis/-/ioredis-5.11.1.tgz",
+ "integrity": "sha512-ehuGcf94bQXhfagULNXrJdfnWO38v070jxSx/qE87Kjzmu2fU7ro5EFAb+OPituLqgfyuQaym5DlrNydW2sJ9A==",
+ "license": "MIT",
+ "dependencies": {
+ "@ioredis/commands": "1.10.0",
+ "cluster-key-slot": "1.1.1",
+ "debug": "4.4.3",
+ "denque": "2.1.0",
+ "redis-errors": "1.2.0",
+ "redis-parser": "3.0.0",
+ "standard-as-callback": "2.1.0"
+ },
+ "engines": {
+ "node": ">=12.22.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/ioredis"
+ }
+ },
+ "node_modules/ioredis/node_modules/debug": {
+ "version": "4.4.3",
+ "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
+ "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==",
+ "license": "MIT",
+ "dependencies": {
+ "ms": "^2.1.3"
+ },
+ "engines": {
+ "node": ">=6.0"
+ },
+ "peerDependenciesMeta": {
+ "supports-color": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/ioredis/node_modules/ms": {
+ "version": "2.1.3",
+ "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
+ "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
+ "license": "MIT"
+ },
+ "node_modules/ipaddr.js": {
+ "version": "1.9.1",
+ "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz",
+ "integrity": "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.10"
+ }
+ },
+ "node_modules/math-intrinsics": {
+ "version": "1.1.0",
+ "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz",
+ "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.4"
+ }
+ },
+ "node_modules/media-typer": {
+ "version": "0.3.0",
+ "resolved": "https://registry.npmjs.org/media-typer/-/media-typer-0.3.0.tgz",
+ "integrity": "sha512-dq+qelQ9akHpcOl/gUVRTxVIOkAJ1wR3QAvb4RsVjS8oVoFjDGTc679wJYmUmknUF5HwMLOgb5O+a3KxfWapPQ==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.6"
+ }
+ },
+ "node_modules/merge-descriptors": {
+ "version": "1.0.3",
+ "resolved": "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-1.0.3.tgz",
+ "integrity": "sha512-gaNvAS7TZ897/rVaZ0nMtAyxNyi/pdbjbAwUpFQpN70GqnVfOiXpeUUMKRBmzXaSQ8DdTX4/0ms62r2K+hE6mQ==",
+ "license": "MIT",
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/methods": {
+ "version": "1.1.2",
+ "resolved": "https://registry.npmjs.org/methods/-/methods-1.1.2.tgz",
+ "integrity": "sha512-iclAHeNqNm68zFtnZ0e+1L2yUIdvzNoauKU4WBA3VvH/vPFieF7qfRlwUZU+DA9P9bPXIS90ulxoUoCH23sV2w==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.6"
+ }
+ },
+ "node_modules/mime": {
+ "version": "1.6.0",
+ "resolved": "https://registry.npmjs.org/mime/-/mime-1.6.0.tgz",
+ "integrity": "sha512-x0Vn8spI+wuJ1O6S7gnbaQg8Pxh4NNHb7KSINmEWKiPE4RKOplvijn+NkmYmmRgP68mc70j2EbeTFRsrswaQeg==",
+ "license": "MIT",
+ "bin": {
+ "mime": "cli.js"
+ },
+ "engines": {
+ "node": ">=4"
+ }
+ },
+ "node_modules/mime-db": {
+ "version": "1.52.0",
+ "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz",
+ "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.6"
+ }
+ },
+ "node_modules/mime-types": {
+ "version": "2.1.35",
+ "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz",
+ "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==",
+ "license": "MIT",
+ "dependencies": {
+ "mime-db": "1.52.0"
+ },
+ "engines": {
+ "node": ">= 0.6"
+ }
+ },
+ "node_modules/ms": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz",
+ "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==",
+ "license": "MIT"
+ },
+ "node_modules/negotiator": {
+ "version": "0.6.3",
+ "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-0.6.3.tgz",
+ "integrity": "sha512-+EUsqGPLsM+j/zdChZjsnX51g4XrHFOIXwfnCVPGlQk/k5giakcKsuxCObBRu6DSm9opw/O6slWbJdghQM4bBg==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.6"
+ }
+ },
+ "node_modules/object-inspect": {
+ "version": "1.13.4",
+ "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz",
+ "integrity": "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/on-finished": {
+ "version": "2.4.1",
+ "resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz",
+ "integrity": "sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==",
+ "license": "MIT",
+ "dependencies": {
+ "ee-first": "1.1.1"
+ },
+ "engines": {
+ "node": ">= 0.8"
+ }
+ },
+ "node_modules/parseurl": {
+ "version": "1.3.3",
+ "resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz",
+ "integrity": "sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.8"
+ }
+ },
+ "node_modules/path-to-regexp": {
+ "version": "0.1.13",
+ "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-0.1.13.tgz",
+ "integrity": "sha512-A/AGNMFN3c8bOlvV9RreMdrv7jsmF9XIfDeCd87+I8RNg6s78BhJxMu69NEMHBSJFxKidViTEdruRwEk/WIKqA==",
+ "license": "MIT"
+ },
+ "node_modules/proxy-addr": {
+ "version": "2.0.7",
+ "resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz",
+ "integrity": "sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==",
+ "license": "MIT",
+ "dependencies": {
+ "forwarded": "0.2.0",
+ "ipaddr.js": "1.9.1"
+ },
+ "engines": {
+ "node": ">= 0.10"
+ }
+ },
+ "node_modules/qs": {
+ "version": "6.15.2",
+ "resolved": "https://registry.npmjs.org/qs/-/qs-6.15.2.tgz",
+ "integrity": "sha512-Rzq0KEyX/w/tEybncDgdkZrJgVUsUMk3xjh3t5bv3S1HTAtg+uOYt72+ZfwiQwKdysThkTBdL/rTi6HDmX9Ddw==",
+ "license": "BSD-3-Clause",
+ "dependencies": {
+ "side-channel": "^1.1.0"
+ },
+ "engines": {
+ "node": ">=0.6"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/range-parser": {
+ "version": "1.2.1",
+ "resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.2.1.tgz",
+ "integrity": "sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.6"
+ }
+ },
+ "node_modules/raw-body": {
+ "version": "2.5.3",
+ "resolved": "https://registry.npmjs.org/raw-body/-/raw-body-2.5.3.tgz",
+ "integrity": "sha512-s4VSOf6yN0rvbRZGxs8Om5CWj6seneMwK3oDb4lWDH0UPhWcxwOWw5+qk24bxq87szX1ydrwylIOp2uG1ojUpA==",
+ "license": "MIT",
+ "dependencies": {
+ "bytes": "~3.1.2",
+ "http-errors": "~2.0.1",
+ "iconv-lite": "~0.4.24",
+ "unpipe": "~1.0.0"
+ },
+ "engines": {
+ "node": ">= 0.8"
+ }
+ },
+ "node_modules/redis-errors": {
+ "version": "1.2.0",
+ "resolved": "https://registry.npmjs.org/redis-errors/-/redis-errors-1.2.0.tgz",
+ "integrity": "sha512-1qny3OExCf0UvUV/5wpYKf2YwPcOqXzkwKKSmKHiE6ZMQs5heeE/c8eXK+PNllPvmjgAbfnsbpkGZWy8cBpn9w==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=4"
+ }
+ },
+ "node_modules/redis-parser": {
+ "version": "3.0.0",
+ "resolved": "https://registry.npmjs.org/redis-parser/-/redis-parser-3.0.0.tgz",
+ "integrity": "sha512-DJnGAeenTdpMEH6uAJRK/uiyEIH9WVsUmoLwzudwGJUwZPp80PDBWPHXSAGNPwNvIXAbe7MSUB1zQFugFml66A==",
+ "license": "MIT",
+ "dependencies": {
+ "redis-errors": "^1.0.0"
+ },
+ "engines": {
+ "node": ">=4"
+ }
+ },
+ "node_modules/safe-buffer": {
+ "version": "5.2.1",
+ "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz",
+ "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==",
+ "funding": [
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/feross"
+ },
+ {
+ "type": "patreon",
+ "url": "https://www.patreon.com/feross"
+ },
+ {
+ "type": "consulting",
+ "url": "https://feross.org/support"
+ }
+ ],
+ "license": "MIT"
+ },
+ "node_modules/safer-buffer": {
+ "version": "2.1.2",
+ "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz",
+ "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==",
+ "license": "MIT"
+ },
+ "node_modules/send": {
+ "version": "0.19.2",
+ "resolved": "https://registry.npmjs.org/send/-/send-0.19.2.tgz",
+ "integrity": "sha512-VMbMxbDeehAxpOtWJXlcUS5E8iXh6QmN+BkRX1GARS3wRaXEEgzCcB10gTQazO42tpNIya8xIyNx8fll1OFPrg==",
+ "license": "MIT",
+ "dependencies": {
+ "debug": "2.6.9",
+ "depd": "2.0.0",
+ "destroy": "1.2.0",
+ "encodeurl": "~2.0.0",
+ "escape-html": "~1.0.3",
+ "etag": "~1.8.1",
+ "fresh": "~0.5.2",
+ "http-errors": "~2.0.1",
+ "mime": "1.6.0",
+ "ms": "2.1.3",
+ "on-finished": "~2.4.1",
+ "range-parser": "~1.2.1",
+ "statuses": "~2.0.2"
+ },
+ "engines": {
+ "node": ">= 0.8.0"
+ }
+ },
+ "node_modules/send/node_modules/ms": {
+ "version": "2.1.3",
+ "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
+ "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
+ "license": "MIT"
+ },
+ "node_modules/serve-static": {
+ "version": "1.16.3",
+ "resolved": "https://registry.npmjs.org/serve-static/-/serve-static-1.16.3.tgz",
+ "integrity": "sha512-x0RTqQel6g5SY7Lg6ZreMmsOzncHFU7nhnRWkKgWuMTu5NN0DR5oruckMqRvacAN9d5w6ARnRBXl9xhDCgfMeA==",
+ "license": "MIT",
+ "dependencies": {
+ "encodeurl": "~2.0.0",
+ "escape-html": "~1.0.3",
+ "parseurl": "~1.3.3",
+ "send": "~0.19.1"
+ },
+ "engines": {
+ "node": ">= 0.8.0"
+ }
+ },
+ "node_modules/setprototypeof": {
+ "version": "1.2.0",
+ "resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz",
+ "integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==",
+ "license": "ISC"
+ },
+ "node_modules/side-channel": {
+ "version": "1.1.1",
+ "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.1.tgz",
+ "integrity": "sha512-6x6dK6zJdpTzF4sQeNYxwtvBzf6Eg4GtlesS94HOvTudUeyK2WXAaIfmDgsyslYrRBeFIlsi54AYsFGUuhmvrQ==",
+ "license": "MIT",
+ "dependencies": {
+ "es-errors": "^1.3.0",
+ "object-inspect": "^1.13.4",
+ "side-channel-list": "^1.0.1",
+ "side-channel-map": "^1.0.1",
+ "side-channel-weakmap": "^1.0.2"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/side-channel-list": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.1.tgz",
+ "integrity": "sha512-mjn/0bi/oUURjc5Xl7IaWi/OJJJumuoJFQJfDDyO46+hBWsfaVM65TBHq2eoZBhzl9EchxOijpkbRC8SVBQU0w==",
+ "license": "MIT",
+ "dependencies": {
+ "es-errors": "^1.3.0",
+ "object-inspect": "^1.13.4"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/side-channel-map": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/side-channel-map/-/side-channel-map-1.0.1.tgz",
+ "integrity": "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==",
+ "license": "MIT",
+ "dependencies": {
+ "call-bound": "^1.0.2",
+ "es-errors": "^1.3.0",
+ "get-intrinsic": "^1.2.5",
+ "object-inspect": "^1.13.3"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/side-channel-weakmap": {
+ "version": "1.0.2",
+ "resolved": "https://registry.npmjs.org/side-channel-weakmap/-/side-channel-weakmap-1.0.2.tgz",
+ "integrity": "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==",
+ "license": "MIT",
+ "dependencies": {
+ "call-bound": "^1.0.2",
+ "es-errors": "^1.3.0",
+ "get-intrinsic": "^1.2.5",
+ "object-inspect": "^1.13.3",
+ "side-channel-map": "^1.0.1"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/standard-as-callback": {
+ "version": "2.1.0",
+ "resolved": "https://registry.npmjs.org/standard-as-callback/-/standard-as-callback-2.1.0.tgz",
+ "integrity": "sha512-qoRRSyROncaz1z0mvYqIE4lCd9p2R90i6GxW3uZv5ucSu8tU7B5HXUP1gG8pVZsYNVaXjk8ClXHPttLyxAL48A==",
+ "license": "MIT"
+ },
+ "node_modules/statuses": {
+ "version": "2.0.2",
+ "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz",
+ "integrity": "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.8"
+ }
+ },
+ "node_modules/toidentifier": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz",
+ "integrity": "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=0.6"
+ }
+ },
+ "node_modules/type-is": {
+ "version": "1.6.18",
+ "resolved": "https://registry.npmjs.org/type-is/-/type-is-1.6.18.tgz",
+ "integrity": "sha512-TkRKr9sUTxEH8MdfuCSP7VizJyzRNMjj2J2do2Jr3Kym598JVdEksuzPQCnlFPW4ky9Q+iA+ma9BGm06XQBy8g==",
+ "license": "MIT",
+ "dependencies": {
+ "media-typer": "0.3.0",
+ "mime-types": "~2.1.24"
+ },
+ "engines": {
+ "node": ">= 0.6"
+ }
+ },
+ "node_modules/unpipe": {
+ "version": "1.0.0",
+ "resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz",
+ "integrity": "sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.8"
+ }
+ },
+ "node_modules/utils-merge": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/utils-merge/-/utils-merge-1.0.1.tgz",
+ "integrity": "sha512-pMZTvIkT1d+TFGvDOqodOclx0QWkkgi6Tdoa8gC8ffGAAqz9pzPTZWAybbsHHoED/ztMtkv/VoYTYyShUn81hA==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.4.0"
+ }
+ },
+ "node_modules/uuid": {
+ "version": "9.0.1",
+ "resolved": "https://registry.npmjs.org/uuid/-/uuid-9.0.1.tgz",
+ "integrity": "sha512-b+1eJOlsR9K8HJpow9Ok3fiWOWSIcIzXodvv0rQjVoOVNpWMpxf1wZNpt4y9h10odCNrqnYp1OBzRktckBe3sA==",
+ "deprecated": "uuid@10 and below is no longer supported. For ESM codebases, update to uuid@latest. For CommonJS codebases, use uuid@11 (but be aware this version will likely be deprecated in 2028).",
+ "funding": [
+ "https://github.com/sponsors/broofa",
+ "https://github.com/sponsors/ctavan"
+ ],
+ "license": "MIT",
+ "bin": {
+ "uuid": "dist/bin/uuid"
+ }
+ },
+ "node_modules/vary": {
+ "version": "1.1.2",
+ "resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz",
+ "integrity": "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.8"
+ }
+ },
+ "node_modules/ws": {
+ "version": "8.21.0",
+ "resolved": "https://registry.npmjs.org/ws/-/ws-8.21.0.tgz",
+ "integrity": "sha512-Vsp28b7DRcimFQvrqu2Wek3z1iYxDCWqHYB8Qsnk/S4RfaCQzPGPyBNuVjJV3cd6UiKtUtp6sNM77gWvzcCH+g==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=10.0.0"
+ },
+ "peerDependencies": {
+ "bufferutil": "^4.0.1",
+ "utf-8-validate": ">=5.0.2"
+ },
+ "peerDependenciesMeta": {
+ "bufferutil": {
+ "optional": true
+ },
+ "utf-8-validate": {
+ "optional": true
+ }
+ }
+ }
+ }
+}
diff --git a/server/package.json b/server/package.json
new file mode 100644
index 0000000..676b929
--- /dev/null
+++ b/server/package.json
@@ -0,0 +1,16 @@
+{
+ "name": "slither-server",
+ "version": "1.0.0",
+ "main": "index.js",
+ "scripts": {
+ "dev": "node --watch index.js",
+ "start": "node index.js"
+ },
+ "dependencies": {
+ "dotenv": "^16.4.5",
+ "express": "^4.19.2",
+ "ioredis": "^5.3.2",
+ "uuid": "^9.0.1",
+ "ws": "^8.17.1"
+ }
+}
diff --git a/server/render.yaml b/server/render.yaml
new file mode 100644
index 0000000..0729320
--- /dev/null
+++ b/server/render.yaml
@@ -0,0 +1,16 @@
+services:
+ - type: web
+ name: slither-quest-server
+ runtime: node
+ plan: free
+ buildCommand: npm install
+ startCommand: node index.js
+ envVars:
+ - key: PORT
+ value: 3001
+ - key: VALKEY_URL
+ sync: false
+ - key: BREETH_API_KEY
+ sync: false
+ - key: ROOM_PASSWORD
+ sync: false
diff --git a/server/room-manager.js b/server/room-manager.js
new file mode 100644
index 0000000..800fadc
--- /dev/null
+++ b/server/room-manager.js
@@ -0,0 +1,128 @@
+'use strict';
+
+const { GameEngine } = require('./game');
+const WebSocket = require('ws');
+
+const ROOM_CLEANUP_MS = 30_000;
+const MAX_ROOMS = 50;
+const GAME_DURATION_MS = 5 * 60 * 1000; // 5-minute rounds
+
+function genCode() {
+ const chars = 'ABCDEFGHJKLMNPQRSTUVWXYZ23456789';
+ return Array.from({ length: 4 }, () => chars[Math.floor(Math.random() * chars.length)]).join('');
+}
+
+// ─── Room ────────────────────────────────────────────────────────────────────
+
+class Room {
+ constructor(code, roomPassword) {
+ this.code = code;
+ this.password = roomPassword || null;
+ this.game = new GameEngine();
+ this.clients = new Map(); // clientId → ws
+ this.players = new Map(); // clientId → { name }
+ this.leaderboard = [];
+ this.lastPersist = 0;
+ this.createdAt = Date.now();
+ this._cleanupTimer = null;
+
+ // 5-minute round timer
+ this.gameStartMs = null;
+ this.inIntermission = false;
+ }
+
+ // ── Timer ──────────────────────────────────────────────────────────────────
+
+ startRound() {
+ this.gameStartMs = Date.now();
+ this.inIntermission = false;
+ }
+
+ timeLeftMs() {
+ if (!this.gameStartMs || this.inIntermission) return GAME_DURATION_MS;
+ return Math.max(0, GAME_DURATION_MS - (Date.now() - this.gameStartMs));
+ }
+
+ shouldEndGame() {
+ return (
+ this.gameStartMs !== null &&
+ !this.inIntermission &&
+ this.clients.size > 0 &&
+ this.timeLeftMs() === 0
+ );
+ }
+
+ // ── Client management ──────────────────────────────────────────────────────
+
+ addClient(clientId, ws) {
+ this.clients.set(clientId, ws);
+ clearTimeout(this._cleanupTimer);
+ this._cleanupTimer = null;
+ }
+
+ removeClient(clientId, onEmpty) {
+ this.clients.delete(clientId);
+ this.players.delete(clientId);
+ this.game.removeSnake(clientId);
+ if (this.clients.size === 0) {
+ this._cleanupTimer = setTimeout(() => onEmpty(this.code), ROOM_CLEANUP_MS);
+ }
+ }
+
+ send(clientId, obj) {
+ const ws = this.clients.get(clientId);
+ if (ws && ws.readyState === WebSocket.OPEN) ws.send(JSON.stringify(obj));
+ }
+
+ broadcastAll(obj) {
+ const msg = JSON.stringify(obj);
+ for (const ws of this.clients.values()) {
+ if (ws.readyState === WebSocket.OPEN) ws.send(msg);
+ }
+ }
+}
+
+// ─── RoomManager ─────────────────────────────────────────────────────────────
+
+class RoomManager {
+ constructor() {
+ this.rooms = new Map(); // code → Room
+ }
+
+ create(roomPassword) {
+ if (this.rooms.size >= MAX_ROOMS) throw new Error('Server is full — too many rooms');
+ let code, tries = 0;
+ do {
+ code = genCode();
+ if (++tries > 200) throw new Error('Cannot generate unique room code');
+ } while (this.rooms.has(code));
+
+ const room = new Room(code, roomPassword);
+ this.rooms.set(code, room);
+ console.log(`[Room] ${code} created. Active: ${this.rooms.size}`);
+ return room;
+ }
+
+ get(code) {
+ return this.rooms.get((code || '').toUpperCase().trim()) || null;
+ }
+
+ delete(code) {
+ this.rooms.delete(code);
+ console.log(`[Room] ${code} deleted (empty). Active: ${this.rooms.size}`);
+ }
+
+ list() {
+ return Array.from(this.rooms.values())
+ .filter(r => !r.inIntermission)
+ .map(r => ({
+ code: r.code,
+ players: r.clients.size,
+ passwordRequired: !!r.password,
+ timeLeftMs: r.timeLeftMs(),
+ createdAt: r.createdAt,
+ }));
+ }
+}
+
+module.exports = { RoomManager, GAME_DURATION_MS };
diff --git a/submissions/SlitherQuest_ICSCoreTeam.md b/submissions/SlitherQuest_ICSCoreTeam.md
new file mode 100644
index 0000000..7b45c19
--- /dev/null
+++ b/submissions/SlitherQuest_ICSCoreTeam.md
@@ -0,0 +1,135 @@
+# Slither Quest
+
+---
+
+## Attendee/Team Details
+
+**Team Name:** ICS Core Team
+**Name:** Prabhas Satti
+**GitHub Username:** prabii
+**LinkedIn Profile:** https://www.linkedin.com/in/prabii
+**GitHub Project Repository:** https://github.com/prabii/slither-quest-game
+
+---
+
+## Problem Statement Selected
+
+Custom — Real-time multiplayer game powered by Valkey and Breeth AI
+
+---
+
+## Project Description
+
+Slither Quest is a real-time browser-based multiplayer snake game (Slither.io-style) built for the Build Beyond Limits 2.0 Hackathon, powered by **Valkey** and **Breeth AI**.
+
+- **What it does:** Players control a growing snake on a shared 4000×4000 canvas. Eat pellets, grow longer, avoid other snakes. Boost burns your length but drops pellets for others. The last snake alive wins.
+- **Who it's for:** Anyone who wants to play a real-time multiplayer game in the browser — no install needed.
+- **Problem solved:** Demonstrates how Valkey (Redis-protocol) can serve as a low-latency distributed state store and pub/sub event bus, and how Breeth AI can give a stateless web game persistent, personalised memory across sessions.
+- **How it helps:** Players get a personalised greeting on join ("Run #3 — best was length 45, last killed by Bob") and every death is stored so the AI recalls it next session.
+
+---
+
+## Approach
+
+- **Server-authoritative 25Hz game loop**: browser sends only `{dir, boost}`, server owns all positions, growth, and collision. Prevents cheating, keeps all players in sync.
+- **Multi-room system**: Each room has an isolated game world, a 4-char code (e.g. `XK92`), an optional password. Rooms auto-delete 30s after the last player leaves.
+- **PIN-based login**: 4-digit PIN stored SHA-256 hashed in Valkey (`game:users` hash). First use = register, return use = verify. Tied to Breeth AI memory so history follows you.
+- **Valkey**: Sorted-set leaderboard, player name hash, TTL world snapshot, pub/sub killfeed — all namespaced per room. Updated every 2s to avoid per-tick round trips to the remote Aiven instance.
+- **Breeth AI**: On join → `POST /v1/search` recalls player history → personalised greeting. On death → `POST /v1/episodes` stores run for future recall. Full in-memory fallback if no key.
+
+---
+
+## Tech Stack and Tools Used
+
+**Frontend:** React 18, Vite, HTML5 Canvas (no game engine)
+**Backend:** Node.js, Express, `ws` WebSocket
+**Database:** Valkey (Aiven cloud) via `ioredis` — sorted set, hash, pub/sub, TTL string
+**AI Tools/API:** Breeth AI — `POST /v1/episodes` (write memory), `POST /v1/search` (recall memory)
+**Cloud/Deployment:** Aiven (Valkey cloud), localtunnel / ngrok for public tunnel hosting
+**Other Tools:** SHA-256 PIN hashing via Node.js built-in `crypto`
+
+---
+
+## Key Features
+
+1. **Multi-room system** — create rooms with 4-char codes, optional password, isolated game worlds
+2. **PIN-based login** — 4-digit PIN protects your name and ties to Breeth AI memory across sessions
+3. **Server-authoritative 25Hz game loop** — all physics and collision run on the server
+4. **Valkey leaderboard + killfeed** — sorted-set leaderboard per room, pub/sub kill events broadcast to room
+5. **Breeth AI player memory** — personalised greeting on join; death stored as episode for future recall
+6. **Boost mechanic** — burns snake length, drops pellets, creates strategic trade-offs
+7. **WASD + arrow keys + mouse** — full keyboard steering with diagonal support
+8. **Room code badge** — shown in-game HUD, click to copy and share with friends
+9. **One-URL hosting** — Express serves built client; shareable via localtunnel/ngrok
+10. **30/30 automated flight-test suite** — `node server/flight-test.js`
+
+---
+
+## What is Working?
+
+- Full real-time multiplayer (tested with multiple simultaneous players)
+- Multi-room: create room (get shareable 4-char code), join room (enter code + PIN)
+- PIN auth: register on first use, verify on return — SHA-256 hashed and stored in Valkey
+- Snake movement, growth, boost, death/respawn cycle
+- Server-side collision detection (head vs body, wall boundary)
+- Valkey: PING, per-room world snapshot (EX 5), leaderboard sorted set, names hash, pub/sub killfeed — all verified
+- Breeth AI: episodes write and search recall — verified; in-game greeting banner shown on join
+- HUD: leaderboard (top-right), room code badge (top-left), killfeed (bottom-left), score bar (bottom-center), mini-map (bottom-right)
+- Death screen with 3-second cooldown and respawn
+- Flight test: **30/30 passing**
+
+---
+
+## What is Still in Progress?
+
+- Mobile touch controls
+- Spectator mode using Valkey world snapshots
+- Skin/colour customisation persisted via Breeth AI
+- Persistent score history across server restarts
+- Room browser (list all active rooms)
+
+---
+
+## Screenshots or Demo
+
+**Deployed Link:** Run locally — see README in project repo
+**Demo Video Link:** —
+**Screenshots:** —
+
+---
+
+## Challenges Faced
+
+- **Valkey remote latency:** Updating every tick caused lag. Fixed by batching writes every 2 seconds and caching leaderboard in memory between syncs.
+- **Windows filesystem bug:** Upstream repo has `SECURITY.md ` (trailing space) — Windows cannot check it out. Resolved using `git commit-tree` to link branch histories without touching the working directory.
+- **Multi-room game loop:** Moved from a single `setInterval` to a master loop that ticks all active rooms. Rooms with zero clients are skipped entirely.
+- **Breeth cold-start:** First search on a new name returns no edges. Handled gracefully — no greeting shown on first run, no crash or fallback error.
+
+---
+
+## Learnings
+
+- Valkey pub/sub requires a **separate ioredis connection** for the subscriber — using the same connection blocks the event loop.
+- Breeth stores knowledge as a graph of entities and edges — framing memories as natural-language sentences lets the search extract structured facts automatically.
+- Server-authoritative game loops are simpler than expected: one `setInterval`, broadcast the result, trust nothing from the client.
+- `git commit-tree` can create merge commits without touching the working directory — very useful for cross-platform filename issues.
+
+---
+
+## Future Improvements
+
+- Deploy to a cloud VM for persistent public access
+- Breeth-powered seasonal events and win-streak messages
+- Valkey streams instead of pub/sub for a persistent, replayable kill log
+- Leaderboard history across server restarts (persist player scores permanently)
+
+---
+
+## Final Note
+
+Built end-to-end as a working prototype — server, client, Valkey integration, Breeth AI memory, multi-room system, PIN-based login, and a 30-check automated flight test suite. Everything runs with two commands. Both Valkey and Breeth have graceful in-memory fallbacks so the game is always playable even without credentials.
+
+```bash
+cd client && npm install && npm run build
+cd server && npm install && npm run dev
+```
diff --git a/submissions/putYourSubmissionsHere.md b/submissions/putYourSubmissionsHere.md
deleted file mode 100644
index e69de29..0000000