guandan.dev

guandan.dev

https://git.tonybtw.com/guandan.dev.git git://git.tonybtw.com/guandan.dev.git

Initial commit.

Commit
a51e2008bcefc64a34f16598bb01d2c77aabb0a1
Author
tonybtw <tonybtw@tonybtw.com>
Date
2026-01-24 06:40:17

Diff

diff --git a/.gitignore b/.gitignore
new file mode 100644
index 0000000..20416ea
--- /dev/null
+++ b/.gitignore
@@ -0,0 +1,6 @@
+.direnv/
+.envrc
+notes/
+server/tmp/
+client/node_modules/
+client/dist/
diff --git a/assets/guandan.png b/assets/guandan.png
new file mode 100644
index 0000000..307f569
Binary files /dev/null and b/assets/guandan.png differ
diff --git a/client/index.html b/client/index.html
new file mode 100644
index 0000000..824f098
--- /dev/null
+++ b/client/index.html
@@ -0,0 +1,12 @@
+<!DOCTYPE html>
+<html lang="en">
+  <head>
+    <meta charset="UTF-8" />
+    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
+    <title>Guan Dan</title>
+  </head>
+  <body>
+    <div id="root"></div>
+    <script type="module" src="/src/main.tsx"></script>
+  </body>
+</html>
diff --git a/client/package-lock.json b/client/package-lock.json
new file mode 100644
index 0000000..97a1118
--- /dev/null
+++ b/client/package-lock.json
@@ -0,0 +1,1780 @@
+{
+  "name": "guandanbtw-client",
+  "version": "0.0.1",
+  "lockfileVersion": 3,
+  "requires": true,
+  "packages": {
+    "": {
+      "name": "guandanbtw-client",
+      "version": "0.0.1",
+      "dependencies": {
+        "framer-motion": "^10.16.0",
+        "react": "^18.2.0",
+        "react-dom": "^18.2.0"
+      },
+      "devDependencies": {
+        "@types/react": "^18.2.0",
+        "@types/react-dom": "^18.2.0",
+        "@vitejs/plugin-react": "^4.2.0",
+        "typescript": "^5.3.0",
+        "vite": "^5.0.0"
+      }
+    },
+    "node_modules/@babel/code-frame": {
+      "version": "7.28.6",
+      "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.28.6.tgz",
+      "integrity": "sha512-JYgintcMjRiCvS8mMECzaEn+m3PfoQiyqukOMCCVQtoJGYJw8j/8LBJEiqkHLkfwCcs74E3pbAUFNg7d9VNJ+Q==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "@babel/helper-validator-identifier": "^7.28.5",
+        "js-tokens": "^4.0.0",
+        "picocolors": "^1.1.1"
+      },
+      "engines": {
+        "node": ">=6.9.0"
+      }
+    },
+    "node_modules/@babel/compat-data": {
+      "version": "7.28.6",
+      "resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.28.6.tgz",
+      "integrity": "sha512-2lfu57JtzctfIrcGMz992hyLlByuzgIk58+hhGCxjKZ3rWI82NnVLjXcaTqkI2NvlcvOskZaiZ5kjUALo3Lpxg==",
+      "dev": true,
+      "license": "MIT",
+      "engines": {
+        "node": ">=6.9.0"
+      }
+    },
+    "node_modules/@babel/core": {
+      "version": "7.28.6",
+      "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.28.6.tgz",
+      "integrity": "sha512-H3mcG6ZDLTlYfaSNi0iOKkigqMFvkTKlGUYlD8GW7nNOYRrevuA46iTypPyv+06V3fEmvvazfntkBU34L0azAw==",
+      "dev": true,
+      "license": "MIT",
+      "peer": true,
+      "dependencies": {
+        "@babel/code-frame": "^7.28.6",
+        "@babel/generator": "^7.28.6",
+        "@babel/helper-compilation-targets": "^7.28.6",
+        "@babel/helper-module-transforms": "^7.28.6",
+        "@babel/helpers": "^7.28.6",
+        "@babel/parser": "^7.28.6",
+        "@babel/template": "^7.28.6",
+        "@babel/traverse": "^7.28.6",
+        "@babel/types": "^7.28.6",
+        "@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.28.6",
+      "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.28.6.tgz",
+      "integrity": "sha512-lOoVRwADj8hjf7al89tvQ2a1lf53Z+7tiXMgpZJL3maQPDxh0DgLMN62B2MKUOFcoodBHLMbDM6WAbKgNy5Suw==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "@babel/parser": "^7.28.6",
+        "@babel/types": "^7.28.6",
+        "@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.28.6",
+      "resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.28.6.tgz",
+      "integrity": "sha512-JYtls3hqi15fcx5GaSNL7SCTJ2MNmjrkHXg4FSpOA/grxK8KwyZ5bubHsCq8FXCkua6xhuaaBit+3b7+VZRfcA==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "@babel/compat-data": "^7.28.6",
+        "@babel/helper-validator-option": "^7.27.1",
+        "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.28.0",
+      "resolved": "https://registry.npmjs.org/@babel/helper-globals/-/helper-globals-7.28.0.tgz",
+      "integrity": "sha512-+W6cISkXFa1jXsDEdYA8HeevQT/FULhxzR99pxphltZcVaugps53THCeiWA8SguxxpSp3gKPiuYfSWopkLQ4hw==",
+      "dev": true,
+      "license": "MIT",
+      "engines": {
+        "node": ">=6.9.0"
+      }
+    },
+    "node_modules/@babel/helper-module-imports": {
+      "version": "7.28.6",
+      "resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.28.6.tgz",
+      "integrity": "sha512-l5XkZK7r7wa9LucGw9LwZyyCUscb4x37JWTPz7swwFE/0FMQAGpiWUZn8u9DzkSBWEcK25jmvubfpw2dnAMdbw==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "@babel/traverse": "^7.28.6",
+        "@babel/types": "^7.28.6"
+      },
+      "engines": {
+        "node": ">=6.9.0"
+      }
+    },
+    "node_modules/@babel/helper-module-transforms": {
+      "version": "7.28.6",
+      "resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.28.6.tgz",
+      "integrity": "sha512-67oXFAYr2cDLDVGLXTEABjdBJZ6drElUSI7WKp70NrpyISso3plG9SAGEF6y7zbha/wOzUByWWTJvEDVNIUGcA==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "@babel/helper-module-imports": "^7.28.6",
+        "@babel/helper-validator-identifier": "^7.28.5",
+        "@babel/traverse": "^7.28.6"
+      },
+      "engines": {
+        "node": ">=6.9.0"
+      },
+      "peerDependencies": {
+        "@babel/core": "^7.0.0"
+      }
+    },
+    "node_modules/@babel/helper-plugin-utils": {
+      "version": "7.28.6",
+      "resolved": "https://registry.npmjs.org/@babel/helper-plugin-utils/-/helper-plugin-utils-7.28.6.tgz",
+      "integrity": "sha512-S9gzZ/bz83GRysI7gAD4wPT/AI3uCnY+9xn+Mx/KPs2JwHJIz1W8PZkg2cqyt3RNOBM8ejcXhV6y8Og7ly/Dug==",
+      "dev": true,
+      "license": "MIT",
+      "engines": {
+        "node": ">=6.9.0"
+      }
+    },
+    "node_modules/@babel/helper-string-parser": {
+      "version": "7.27.1",
+      "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.27.1.tgz",
+      "integrity": "sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==",
+      "dev": true,
+      "license": "MIT",
+      "engines": {
+        "node": ">=6.9.0"
+      }
+    },
+    "node_modules/@babel/helper-validator-identifier": {
+      "version": "7.28.5",
+      "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.28.5.tgz",
+      "integrity": "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==",
+      "dev": true,
+      "license": "MIT",
+      "engines": {
+        "node": ">=6.9.0"
+      }
+    },
+    "node_modules/@babel/helper-validator-option": {
+      "version": "7.27.1",
+      "resolved": "https://registry.npmjs.org/@babel/helper-validator-option/-/helper-validator-option-7.27.1.tgz",
+      "integrity": "sha512-YvjJow9FxbhFFKDSuFnVCe2WxXk1zWc22fFePVNEaWJEu8IrZVlda6N0uHwzZrUM1il7NC9Mlp4MaJYbYd9JSg==",
+      "dev": true,
+      "license": "MIT",
+      "engines": {
+        "node": ">=6.9.0"
+      }
+    },
+    "node_modules/@babel/helpers": {
+      "version": "7.28.6",
+      "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.28.6.tgz",
+      "integrity": "sha512-xOBvwq86HHdB7WUDTfKfT/Vuxh7gElQ+Sfti2Cy6yIWNW05P8iUslOVcZ4/sKbE+/jQaukQAdz/gf3724kYdqw==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "@babel/template": "^7.28.6",
+        "@babel/types": "^7.28.6"
+      },
+      "engines": {
+        "node": ">=6.9.0"
+      }
+    },
+    "node_modules/@babel/parser": {
+      "version": "7.28.6",
+      "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.28.6.tgz",
+      "integrity": "sha512-TeR9zWR18BvbfPmGbLampPMW+uW1NZnJlRuuHso8i87QZNq2JRF9i6RgxRqtEq+wQGsS19NNTWr2duhnE49mfQ==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "@babel/types": "^7.28.6"
+      },
+      "bin": {
+        "parser": "bin/babel-parser.js"
+      },
+      "engines": {
+        "node": ">=6.0.0"
+      }
+    },
+    "node_modules/@babel/plugin-transform-react-jsx-self": {
+      "version": "7.27.1",
+      "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-self/-/plugin-transform-react-jsx-self-7.27.1.tgz",
+      "integrity": "sha512-6UzkCs+ejGdZ5mFFC/OCUrv028ab2fp1znZmCZjAOBKiBK2jXD1O+BPSfX8X2qjJ75fZBMSnQn3Rq2mrBJK2mw==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "@babel/helper-plugin-utils": "^7.27.1"
+      },
+      "engines": {
+        "node": ">=6.9.0"
+      },
+      "peerDependencies": {
+        "@babel/core": "^7.0.0-0"
+      }
+    },
+    "node_modules/@babel/plugin-transform-react-jsx-source": {
+      "version": "7.27.1",
+      "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-source/-/plugin-transform-react-jsx-source-7.27.1.tgz",
+      "integrity": "sha512-zbwoTsBruTeKB9hSq73ha66iFeJHuaFkUbwvqElnygoNbj/jHRsSeokowZFN3CZ64IvEqcmmkVe89OPXc7ldAw==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "@babel/helper-plugin-utils": "^7.27.1"
+      },
+      "engines": {
+        "node": ">=6.9.0"
+      },
+      "peerDependencies": {
+        "@babel/core": "^7.0.0-0"
+      }
+    },
+    "node_modules/@babel/template": {
+      "version": "7.28.6",
+      "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.28.6.tgz",
+      "integrity": "sha512-YA6Ma2KsCdGb+WC6UpBVFJGXL58MDA6oyONbjyF/+5sBgxY/dwkhLogbMT2GXXyU84/IhRw/2D1Os1B/giz+BQ==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "@babel/code-frame": "^7.28.6",
+        "@babel/parser": "^7.28.6",
+        "@babel/types": "^7.28.6"
+      },
+      "engines": {
+        "node": ">=6.9.0"
+      }
+    },
+    "node_modules/@babel/traverse": {
+      "version": "7.28.6",
+      "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.28.6.tgz",
+      "integrity": "sha512-fgWX62k02qtjqdSNTAGxmKYY/7FSL9WAS1o2Hu5+I5m9T0yxZzr4cnrfXQ/MX0rIifthCSs6FKTlzYbJcPtMNg==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "@babel/code-frame": "^7.28.6",
+        "@babel/generator": "^7.28.6",
+        "@babel/helper-globals": "^7.28.0",
+        "@babel/parser": "^7.28.6",
+        "@babel/template": "^7.28.6",
+        "@babel/types": "^7.28.6",
+        "debug": "^4.3.1"
+      },
+      "engines": {
+        "node": ">=6.9.0"
+      }
+    },
+    "node_modules/@babel/types": {
+      "version": "7.28.6",
+      "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.28.6.tgz",
+      "integrity": "sha512-0ZrskXVEHSWIqZM/sQZ4EV3jZJXRkio/WCxaqKZP1g//CEWEPSfeZFcms4XeKBCHU0ZKnIkdJeU/kF+eRp5lBg==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "@babel/helper-string-parser": "^7.27.1",
+        "@babel/helper-validator-identifier": "^7.28.5"
+      },
+      "engines": {
+        "node": ">=6.9.0"
+      }
+    },
+    "node_modules/@emotion/is-prop-valid": {
+      "version": "0.8.8",
+      "resolved": "https://registry.npmjs.org/@emotion/is-prop-valid/-/is-prop-valid-0.8.8.tgz",
+      "integrity": "sha512-u5WtneEAr5IDG2Wv65yhunPSMLIpuKsbuOktRojfrEiEvRyC85LgPMZI63cr7NUqT8ZIGdSVg8ZKGxIug4lXcA==",
+      "license": "MIT",
+      "optional": true,
+      "dependencies": {
+        "@emotion/memoize": "0.7.4"
+      }
+    },
+    "node_modules/@emotion/memoize": {
+      "version": "0.7.4",
+      "resolved": "https://registry.npmjs.org/@emotion/memoize/-/memoize-0.7.4.tgz",
+      "integrity": "sha512-Ja/Vfqe3HpuzRsG1oBtWTHk2PGZ7GR+2Vz5iYGelAw8dx32K0y7PjVuxK6z1nMpZOqAFsRUPCkK1YjJ56qJlgw==",
+      "license": "MIT",
+      "optional": true
+    },
+    "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.56.0",
+      "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.56.0.tgz",
+      "integrity": "sha512-LNKIPA5k8PF1+jAFomGe3qN3bbIgJe/IlpDBwuVjrDKrJhVWywgnJvflMt/zkbVNLFtF1+94SljYQS6e99klnw==",
+      "cpu": [
+        "arm"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "android"
+      ]
+    },
+    "node_modules/@rollup/rollup-android-arm64": {
+      "version": "4.56.0",
+      "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.56.0.tgz",
+      "integrity": "sha512-lfbVUbelYqXlYiU/HApNMJzT1E87UPGvzveGg2h0ktUNlOCxKlWuJ9jtfvs1sKHdwU4fzY7Pl8sAl49/XaEk6Q==",
+      "cpu": [
+        "arm64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "android"
+      ]
+    },
+    "node_modules/@rollup/rollup-darwin-arm64": {
+      "version": "4.56.0",
+      "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.56.0.tgz",
+      "integrity": "sha512-EgxD1ocWfhoD6xSOeEEwyE7tDvwTgZc8Bss7wCWe+uc7wO8G34HHCUH+Q6cHqJubxIAnQzAsyUsClt0yFLu06w==",
+      "cpu": [
+        "arm64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "darwin"
+      ]
+    },
+    "node_modules/@rollup/rollup-darwin-x64": {
+      "version": "4.56.0",
+      "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.56.0.tgz",
+      "integrity": "sha512-1vXe1vcMOssb/hOF8iv52A7feWW2xnu+c8BV4t1F//m9QVLTfNVpEdja5ia762j/UEJe2Z1jAmEqZAK42tVW3g==",
+      "cpu": [
+        "x64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "darwin"
+      ]
+    },
+    "node_modules/@rollup/rollup-freebsd-arm64": {
+      "version": "4.56.0",
+      "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.56.0.tgz",
+      "integrity": "sha512-bof7fbIlvqsyv/DtaXSck4VYQ9lPtoWNFCB/JY4snlFuJREXfZnm+Ej6yaCHfQvofJDXLDMTVxWscVSuQvVWUQ==",
+      "cpu": [
+        "arm64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "freebsd"
+      ]
+    },
+    "node_modules/@rollup/rollup-freebsd-x64": {
+      "version": "4.56.0",
+      "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.56.0.tgz",
+      "integrity": "sha512-KNa6lYHloW+7lTEkYGa37fpvPq+NKG/EHKM8+G/g9WDU7ls4sMqbVRV78J6LdNuVaeeK5WB9/9VAFbKxcbXKYg==",
+      "cpu": [
+        "x64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "freebsd"
+      ]
+    },
+    "node_modules/@rollup/rollup-linux-arm-gnueabihf": {
+      "version": "4.56.0",
+      "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.56.0.tgz",
+      "integrity": "sha512-E8jKK87uOvLrrLN28jnAAAChNq5LeCd2mGgZF+fGF5D507WlG/Noct3lP/QzQ6MrqJ5BCKNwI9ipADB6jyiq2A==",
+      "cpu": [
+        "arm"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "linux"
+      ]
+    },
+    "node_modules/@rollup/rollup-linux-arm-musleabihf": {
+      "version": "4.56.0",
+      "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.56.0.tgz",
+      "integrity": "sha512-jQosa5FMYF5Z6prEpTCCmzCXz6eKr/tCBssSmQGEeozA9tkRUty/5Vx06ibaOP9RCrW1Pvb8yp3gvZhHwTDsJw==",
+      "cpu": [
+        "arm"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "linux"
+      ]
+    },
+    "node_modules/@rollup/rollup-linux-arm64-gnu": {
+      "version": "4.56.0",
+      "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.56.0.tgz",
+      "integrity": "sha512-uQVoKkrC1KGEV6udrdVahASIsaF8h7iLG0U0W+Xn14ucFwi6uS539PsAr24IEF9/FoDtzMeeJXJIBo5RkbNWvQ==",
+      "cpu": [
+        "arm64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "linux"
+      ]
+    },
+    "node_modules/@rollup/rollup-linux-arm64-musl": {
+      "version": "4.56.0",
+      "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.56.0.tgz",
+      "integrity": "sha512-vLZ1yJKLxhQLFKTs42RwTwa6zkGln+bnXc8ueFGMYmBTLfNu58sl5/eXyxRa2RarTkJbXl8TKPgfS6V5ijNqEA==",
+      "cpu": [
+        "arm64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "linux"
+      ]
+    },
+    "node_modules/@rollup/rollup-linux-loong64-gnu": {
+      "version": "4.56.0",
+      "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.56.0.tgz",
+      "integrity": "sha512-FWfHOCub564kSE3xJQLLIC/hbKqHSVxy8vY75/YHHzWvbJL7aYJkdgwD/xGfUlL5UV2SB7otapLrcCj2xnF1dg==",
+      "cpu": [
+        "loong64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "linux"
+      ]
+    },
+    "node_modules/@rollup/rollup-linux-loong64-musl": {
+      "version": "4.56.0",
+      "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.56.0.tgz",
+      "integrity": "sha512-z1EkujxIh7nbrKL1lmIpqFTc/sr0u8Uk0zK/qIEFldbt6EDKWFk/pxFq3gYj4Bjn3aa9eEhYRlL3H8ZbPT1xvA==",
+      "cpu": [
+        "loong64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "linux"
+      ]
+    },
+    "node_modules/@rollup/rollup-linux-ppc64-gnu": {
+      "version": "4.56.0",
+      "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.56.0.tgz",
+      "integrity": "sha512-iNFTluqgdoQC7AIE8Q34R3AuPrJGJirj5wMUErxj22deOcY7XwZRaqYmB6ZKFHoVGqRcRd0mqO+845jAibKCkw==",
+      "cpu": [
+        "ppc64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "linux"
+      ]
+    },
+    "node_modules/@rollup/rollup-linux-ppc64-musl": {
+      "version": "4.56.0",
+      "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.56.0.tgz",
+      "integrity": "sha512-MtMeFVlD2LIKjp2sE2xM2slq3Zxf9zwVuw0jemsxvh1QOpHSsSzfNOTH9uYW9i1MXFxUSMmLpeVeUzoNOKBaWg==",
+      "cpu": [
+        "ppc64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "linux"
+      ]
+    },
+    "node_modules/@rollup/rollup-linux-riscv64-gnu": {
+      "version": "4.56.0",
+      "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.56.0.tgz",
+      "integrity": "sha512-in+v6wiHdzzVhYKXIk5U74dEZHdKN9KH0Q4ANHOTvyXPG41bajYRsy7a8TPKbYPl34hU7PP7hMVHRvv/5aCSew==",
+      "cpu": [
+        "riscv64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "linux"
+      ]
+    },
+    "node_modules/@rollup/rollup-linux-riscv64-musl": {
+      "version": "4.56.0",
+      "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.56.0.tgz",
+      "integrity": "sha512-yni2raKHB8m9NQpI9fPVwN754mn6dHQSbDTwxdr9SE0ks38DTjLMMBjrwvB5+mXrX+C0npX0CVeCUcvvvD8CNQ==",
+      "cpu": [
+        "riscv64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "linux"
+      ]
+    },
+    "node_modules/@rollup/rollup-linux-s390x-gnu": {
+      "version": "4.56.0",
+      "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.56.0.tgz",
+      "integrity": "sha512-zhLLJx9nQPu7wezbxt2ut+CI4YlXi68ndEve16tPc/iwoylWS9B3FxpLS2PkmfYgDQtosah07Mj9E0khc3Y+vQ==",
+      "cpu": [
+        "s390x"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "linux"
+      ]
+    },
+    "node_modules/@rollup/rollup-linux-x64-gnu": {
+      "version": "4.56.0",
+      "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.56.0.tgz",
+      "integrity": "sha512-MVC6UDp16ZSH7x4rtuJPAEoE1RwS8N4oK9DLHy3FTEdFoUTCFVzMfJl/BVJ330C+hx8FfprA5Wqx4FhZXkj2Kw==",
+      "cpu": [
+        "x64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "linux"
+      ]
+    },
+    "node_modules/@rollup/rollup-linux-x64-musl": {
+      "version": "4.56.0",
+      "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.56.0.tgz",
+      "integrity": "sha512-ZhGH1eA4Qv0lxaV00azCIS1ChedK0V32952Md3FtnxSqZTBTd6tgil4nZT5cU8B+SIw3PFYkvyR4FKo2oyZIHA==",
+      "cpu": [
+        "x64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "linux"
+      ]
+    },
+    "node_modules/@rollup/rollup-openbsd-x64": {
+      "version": "4.56.0",
+      "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.56.0.tgz",
+      "integrity": "sha512-O16XcmyDeFI9879pEcmtWvD/2nyxR9mF7Gs44lf1vGGx8Vg2DRNx11aVXBEqOQhWb92WN4z7fW/q4+2NYzCbBA==",
+      "cpu": [
+        "x64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "openbsd"
+      ]
+    },
+    "node_modules/@rollup/rollup-openharmony-arm64": {
+      "version": "4.56.0",
+      "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.56.0.tgz",
+      "integrity": "sha512-LhN/Reh+7F3RCgQIRbgw8ZMwUwyqJM+8pXNT6IIJAqm2IdKkzpCh/V9EdgOMBKuebIrzswqy4ATlrDgiOwbRcQ==",
+      "cpu": [
+        "arm64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "openharmony"
+      ]
+    },
+    "node_modules/@rollup/rollup-win32-arm64-msvc": {
+      "version": "4.56.0",
+      "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.56.0.tgz",
+      "integrity": "sha512-kbFsOObXp3LBULg1d3JIUQMa9Kv4UitDmpS+k0tinPBz3watcUiV2/LUDMMucA6pZO3WGE27P7DsfaN54l9ing==",
+      "cpu": [
+        "arm64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "win32"
+      ]
+    },
+    "node_modules/@rollup/rollup-win32-ia32-msvc": {
+      "version": "4.56.0",
+      "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.56.0.tgz",
+      "integrity": "sha512-vSSgny54D6P4vf2izbtFm/TcWYedw7f8eBrOiGGecyHyQB9q4Kqentjaj8hToe+995nob/Wv48pDqL5a62EWtg==",
+      "cpu": [
+        "ia32"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "win32"
+      ]
+    },
+    "node_modules/@rollup/rollup-win32-x64-gnu": {
+      "version": "4.56.0",
+      "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.56.0.tgz",
+      "integrity": "sha512-FeCnkPCTHQJFbiGG49KjV5YGW/8b9rrXAM2Mz2kiIoktq2qsJxRD5giEMEOD2lPdgs72upzefaUvS+nc8E3UzQ==",
+      "cpu": [
+        "x64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "win32"
+      ]
+    },
+    "node_modules/@rollup/rollup-win32-x64-msvc": {
+      "version": "4.56.0",
+      "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.56.0.tgz",
+      "integrity": "sha512-H8AE9Ur/t0+1VXujj90w0HrSOuv0Nq9r1vSZF2t5km20NTfosQsGGUXDaKdQZzwuLts7IyL1fYT4hM95TI9c4g==",
+      "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.8",
+      "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz",
+      "integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==",
+      "dev": true,
+      "license": "MIT"
+    },
+    "node_modules/@types/prop-types": {
+      "version": "15.7.15",
+      "resolved": "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.15.tgz",
+      "integrity": "sha512-F6bEyamV9jKGAFBEmlQnesRPGOQqS2+Uwi0Em15xenOxHaf2hv6L8YCVn3rPdPJOiJfPiCnLIRyvwVaqMY3MIw==",
+      "dev": true,
+      "license": "MIT"
+    },
+    "node_modules/@types/react": {
+      "version": "18.3.27",
+      "resolved": "https://registry.npmjs.org/@types/react/-/react-18.3.27.tgz",
+      "integrity": "sha512-cisd7gxkzjBKU2GgdYrTdtQx1SORymWyaAFhaxQPK9bYO9ot3Y5OikQRvY0VYQtvwjeQnizCINJAenh/V7MK2w==",
+      "dev": true,
+      "license": "MIT",
+      "peer": true,
+      "dependencies": {
+        "@types/prop-types": "*",
+        "csstype": "^3.2.2"
+      }
+    },
+    "node_modules/@types/react-dom": {
+      "version": "18.3.7",
+      "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-18.3.7.tgz",
+      "integrity": "sha512-MEe3UeoENYVFXzoXEWsvcpg6ZvlrFNlOQ7EOsvhI3CfAXwzPfO8Qwuxd40nepsYKqyyVQnTdEfv68q91yLcKrQ==",
+      "dev": true,
+      "license": "MIT",
+      "peerDependencies": {
+        "@types/react": "^18.0.0"
+      }
+    },
+    "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.9.17",
+      "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.9.17.tgz",
+      "integrity": "sha512-agD0MgJFUP/4nvjqzIB29zRPUuCF7Ge6mEv9s8dHrtYD7QWXRcx75rOADE/d5ah1NI+0vkDl0yorDd5U852IQQ==",
+      "dev": true,
+      "license": "Apache-2.0",
+      "bin": {
+        "baseline-browser-mapping": "dist/cli.js"
+      }
+    },
+    "node_modules/browserslist": {
+      "version": "4.28.1",
+      "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.1.tgz",
+      "integrity": "sha512-ZC5Bd0LgJXgwGqUknZY/vkUQ04r8NXnJZ3yYi4vDmSiZmC/pdSN0NbNRPxZpbtO4uAfDUAFffO8IZoM3Gj8IkA==",
+      "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",
+      "peer": true,
+      "dependencies": {
+        "baseline-browser-mapping": "^2.9.0",
+        "caniuse-lite": "^1.0.30001759",
+        "electron-to-chromium": "^1.5.263",
+        "node-releases": "^2.0.27",
+        "update-browserslist-db": "^1.2.0"
+      },
+      "bin": {
+        "browserslist": "cli.js"
+      },
+      "engines": {
+        "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7"
+      }
+    },
+    "node_modules/caniuse-lite": {
+      "version": "1.0.30001766",
+      "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001766.tgz",
+      "integrity": "sha512-4C0lfJ0/YPjJQHagaE9x2Elb69CIqEPZeG0anQt9SIvIoOH4a4uaRl73IavyO+0qZh6MDLH//DrXThEYKHkmYA==",
+      "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/csstype": {
+      "version": "3.2.3",
+      "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz",
+      "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==",
+      "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.278",
+      "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.278.tgz",
+      "integrity": "sha512-dQ0tM1svDRQOwxnXxm+twlGTjr9Upvt8UFWAgmLsxEzFQxhbti4VwxmMjsDxVC51Zo84swW7FVCXEV+VAkhuPw==",
+      "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/framer-motion": {
+      "version": "10.18.0",
+      "resolved": "https://registry.npmjs.org/framer-motion/-/framer-motion-10.18.0.tgz",
+      "integrity": "sha512-oGlDh1Q1XqYPksuTD/usb0I70hq95OUzmL9+6Zd+Hs4XV0oaISBa/UUMSjYiq6m8EUF32132mOJ8xVZS+I0S6w==",
+      "license": "MIT",
+      "dependencies": {
+        "tslib": "^2.4.0"
+      },
+      "optionalDependencies": {
+        "@emotion/is-prop-valid": "^0.8.2"
+      },
+      "peerDependencies": {
+        "react": "^18.0.0",
+        "react-dom": "^18.0.0"
+      },
+      "peerDependenciesMeta": {
+        "react": {
+          "optional": true
+        },
+        "react-dom": {
+          "optional": true
+        }
+      }
+    },
+    "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.11",
+      "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz",
+      "integrity": "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==",
+      "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.27",
+      "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.27.tgz",
+      "integrity": "sha512-nmh3lCkYZ3grZvqcCH+fjmQ7X+H0OeZgP40OierEaAptX4XofMh5kwNbWh7lBduUzCcV/8kZ+NDLCwm2iorIlA==",
+      "dev": true,
+      "license": "MIT"
+    },
+    "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.6",
+      "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.6.tgz",
+      "integrity": "sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg==",
+      "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.11",
+        "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",
+      "peer": true,
+      "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",
+      "peer": true,
+      "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.56.0",
+      "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.56.0.tgz",
+      "integrity": "sha512-9FwVqlgUHzbXtDg9RCMgodF3Ua4Na6Gau+Sdt9vyCN4RhHfVKX2DCHy3BjMLTDd47ITDhYAnTwGulWTblJSDLg==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "@types/estree": "1.0.8"
+      },
+      "bin": {
+        "rollup": "dist/bin/rollup"
+      },
+      "engines": {
+        "node": ">=18.0.0",
+        "npm": ">=8.0.0"
+      },
+      "optionalDependencies": {
+        "@rollup/rollup-android-arm-eabi": "4.56.0",
+        "@rollup/rollup-android-arm64": "4.56.0",
+        "@rollup/rollup-darwin-arm64": "4.56.0",
+        "@rollup/rollup-darwin-x64": "4.56.0",
+        "@rollup/rollup-freebsd-arm64": "4.56.0",
+        "@rollup/rollup-freebsd-x64": "4.56.0",
+        "@rollup/rollup-linux-arm-gnueabihf": "4.56.0",
+        "@rollup/rollup-linux-arm-musleabihf": "4.56.0",
+        "@rollup/rollup-linux-arm64-gnu": "4.56.0",
+        "@rollup/rollup-linux-arm64-musl": "4.56.0",
+        "@rollup/rollup-linux-loong64-gnu": "4.56.0",
+        "@rollup/rollup-linux-loong64-musl": "4.56.0",
+        "@rollup/rollup-linux-ppc64-gnu": "4.56.0",
+        "@rollup/rollup-linux-ppc64-musl": "4.56.0",
+        "@rollup/rollup-linux-riscv64-gnu": "4.56.0",
+        "@rollup/rollup-linux-riscv64-musl": "4.56.0",
+        "@rollup/rollup-linux-s390x-gnu": "4.56.0",
+        "@rollup/rollup-linux-x64-gnu": "4.56.0",
+        "@rollup/rollup-linux-x64-musl": "4.56.0",
+        "@rollup/rollup-openbsd-x64": "4.56.0",
+        "@rollup/rollup-openharmony-arm64": "4.56.0",
+        "@rollup/rollup-win32-arm64-msvc": "4.56.0",
+        "@rollup/rollup-win32-ia32-msvc": "4.56.0",
+        "@rollup/rollup-win32-x64-gnu": "4.56.0",
+        "@rollup/rollup-win32-x64-msvc": "4.56.0",
+        "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/tslib": {
+      "version": "2.8.1",
+      "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz",
+      "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==",
+      "license": "0BSD"
+    },
+    "node_modules/typescript": {
+      "version": "5.9.3",
+      "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz",
+      "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
+      "dev": true,
+      "license": "Apache-2.0",
+      "bin": {
+        "tsc": "bin/tsc",
+        "tsserver": "bin/tsserver"
+      },
+      "engines": {
+        "node": ">=14.17"
+      }
+    },
+    "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",
+      "peer": true,
+      "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..14c1cd1
--- /dev/null
+++ b/client/package.json
@@ -0,0 +1,23 @@
+{
+  "name": "guandanbtw-client",
+  "private": true,
+  "version": "0.0.1",
+  "type": "module",
+  "scripts": {
+    "dev": "vite",
+    "build": "tsc && vite build",
+    "preview": "vite preview"
+  },
+  "dependencies": {
+    "react": "^18.2.0",
+    "react-dom": "^18.2.0",
+    "framer-motion": "^10.16.0"
+  },
+  "devDependencies": {
+    "@types/react": "^18.2.0",
+    "@types/react-dom": "^18.2.0",
+    "@vitejs/plugin-react": "^4.2.0",
+    "typescript": "^5.3.0",
+    "vite": "^5.0.0"
+  }
+}
diff --git a/client/src/App.tsx b/client/src/App.tsx
new file mode 100644
index 0000000..fa831e6
--- /dev/null
+++ b/client/src/App.tsx
@@ -0,0 +1,259 @@
+import { useCallback, useEffect, useState } from 'react'
+import { use_websocket } from './hooks/use_websocket'
+import { Lobby } from './components/Lobby'
+import { Game } from './components/Game'
+import {
+  Card,
+  Player_Info,
+  Rank,
+  Rank_Two,
+  Message,
+} from './game/types'
+
+interface Deal_Cards_Payload {
+  cards: Card[]
+  level: Rank
+}
+
+interface Room_State_Payload {
+  room_id: string
+  players: Player_Info[]
+  game_active: boolean
+  your_id: string
+}
+
+interface Turn_Payload {
+  player_id: string
+  seat: number
+  can_pass: boolean
+}
+
+interface Play_Made_Payload {
+  player_id: string
+  seat: number
+  cards: Card[]
+  combo_type: string
+  is_pass: boolean
+}
+
+interface Error_Payload {
+  message: string
+}
+
+export default function App() {
+  const ws_url = `ws://${window.location.hostname}:8080/ws`
+  const { connected, send, on } = use_websocket(ws_url)
+
+  const [room_id, set_room_id] = useState<string | null>(null)
+  const [players, set_players] = useState<Player_Info[]>([])
+  const [game_active, set_game_active] = useState(false)
+
+  const [hand, set_hand] = useState<Card[]>([])
+  const [level, set_level] = useState<Rank>(Rank_Two)
+  const [selected_ids, set_selected_ids] = useState<Set<number>>(new Set())
+  const [current_turn, set_current_turn] = useState(0)
+  const [my_seat, set_my_seat] = useState(0)
+  const [can_pass, set_can_pass] = useState(false)
+  const [table_cards, set_table_cards] = useState<Card[]>([])
+  const [combo_type, set_combo_type] = useState('')
+  const [player_card_counts, set_player_card_counts] = useState([27, 27, 27, 27])
+  const [team_levels, set_team_levels] = useState<[number, number]>([0, 0])
+  const [error, set_error] = useState<string | null>(null)
+
+  useEffect(() => {
+    const unsub_room_state = on('room_state', (msg: Message) => {
+      const payload = msg.payload as Room_State_Payload
+      set_room_id(payload.room_id)
+      set_players(payload.players)
+      set_game_active(payload.game_active)
+
+      const me = payload.players.find((p) => p.id === payload.your_id)
+      if (me) {
+        set_my_seat(me.seat)
+      }
+    })
+
+    const unsub_deal = on('deal_cards', (msg: Message) => {
+      const payload = msg.payload as Deal_Cards_Payload
+      set_hand(sort_cards(payload.cards, payload.level))
+      set_level(payload.level)
+      set_game_active(true)
+      set_table_cards([])
+      set_combo_type('')
+      set_selected_ids(new Set())
+      set_player_card_counts([27, 27, 27, 27])
+    })
+
+    const unsub_turn = on('turn', (msg: Message) => {
+      const payload = msg.payload as Turn_Payload
+      set_current_turn(payload.seat)
+      set_can_pass(payload.can_pass)
+    })
+
+    const unsub_play_made = on('play_made', (msg: Message) => {
+      const payload = msg.payload as Play_Made_Payload
+      if (!payload.is_pass) {
+        set_table_cards(payload.cards)
+        set_combo_type(payload.combo_type)
+        set_player_card_counts((prev) => {
+          const next = [...prev]
+          next[payload.seat] -= payload.cards.length
+          return next as [number, number, number, number]
+        })
+        const played_ids = new Set(payload.cards.map((c) => c.Id))
+        set_hand((prev) => prev.filter((c) => !played_ids.has(c.Id)))
+      }
+    })
+
+    const unsub_hand_end = on('hand_end', (msg: Message) => {
+      const payload = msg.payload as { new_levels: [number, number] }
+      set_team_levels(payload.new_levels)
+    })
+
+    const unsub_error = on('error', (msg: Message) => {
+      const payload = msg.payload as Error_Payload
+      set_error(payload.message)
+      setTimeout(() => set_error(null), 3000)
+    })
+
+    return () => {
+      unsub_room_state()
+      unsub_deal()
+      unsub_turn()
+      unsub_play_made()
+      unsub_hand_end()
+      unsub_error()
+    }
+  }, [on])
+
+  const handle_create_room = useCallback(
+    (name: string) => {
+      send({
+        type: 'create_room',
+        payload: { player_name: name },
+      })
+    },
+    [send]
+  )
+
+  const handle_join_room = useCallback(
+    (room_code: string, name: string) => {
+      send({
+        type: 'join_room',
+        payload: { room_id: room_code, player_name: name },
+      })
+    },
+    [send]
+  )
+
+  const handle_card_click = useCallback((id: number) => {
+    set_selected_ids((prev) => {
+      const next = new Set(prev)
+      if (next.has(id)) {
+        next.delete(id)
+      } else {
+        next.add(id)
+      }
+      return next
+    })
+  }, [])
+
+  const handle_play = useCallback(() => {
+    if (selected_ids.size === 0) return
+
+    send({
+      type: 'play_cards',
+      payload: { card_ids: Array.from(selected_ids) },
+    })
+
+    set_selected_ids(new Set())
+  }, [send, selected_ids])
+
+  const handle_pass = useCallback(() => {
+    send({ type: 'pass', payload: {} })
+  }, [send])
+
+  if (!connected) {
+    return (
+      <div style={styles.connecting}>
+        <div>Connecting...</div>
+      </div>
+    )
+  }
+
+  if (!game_active) {
+    return (
+      <>
+        <Lobby
+          room_id={room_id}
+          players={players}
+          on_create_room={handle_create_room}
+          on_join_room={handle_join_room}
+        />
+        {error && <div style={styles.error}>{error}</div>}
+      </>
+    )
+  }
+
+  return (
+    <>
+      <Game
+        hand={hand}
+        level={level}
+        selected_ids={selected_ids}
+        on_card_click={handle_card_click}
+        on_play={handle_play}
+        on_pass={handle_pass}
+        table_cards={table_cards}
+        combo_type={combo_type}
+        current_turn={current_turn}
+        my_seat={my_seat}
+        can_pass={can_pass}
+        player_card_counts={player_card_counts}
+        team_levels={team_levels}
+      />
+      {error && <div style={styles.error}>{error}</div>}
+    </>
+  )
+}
+
+function sort_cards(cards: Card[], level: Rank): Card[] {
+  return [...cards].sort((a, b) => {
+    const va = card_sort_value(a, level)
+    const vb = card_sort_value(b, level)
+    if (va !== vb) return va - vb
+    return a.Suit - b.Suit
+  })
+}
+
+function card_sort_value(card: Card, level: Rank): number {
+  if (card.Rank === 14) return 100
+  if (card.Rank === 13) return 99
+  if (card.Rank === level) return 98
+
+  const base_order = [0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12]
+  return base_order[card.Rank] ?? 0
+}
+
+const styles: Record<string, React.CSSProperties> = {
+  connecting: {
+    display: 'flex',
+    justifyContent: 'center',
+    alignItems: 'center',
+    height: '100vh',
+    backgroundColor: '#1a1a2e',
+    color: '#fff',
+    fontSize: 24,
+  },
+  error: {
+    position: 'fixed',
+    bottom: 20,
+    left: '50%',
+    transform: 'translateX(-50%)',
+    padding: '12px 24px',
+    backgroundColor: '#dc3545',
+    color: '#fff',
+    borderRadius: 8,
+    zIndex: 1000,
+  },
+}
diff --git a/client/src/components/Card.tsx b/client/src/components/Card.tsx
new file mode 100644
index 0000000..790719d
--- /dev/null
+++ b/client/src/components/Card.tsx
@@ -0,0 +1,95 @@
+import { motion } from 'framer-motion'
+import { Card as Card_Type, get_suit_symbol, get_rank_symbol, is_red_suit, is_wild, Rank, Rank_Black_Joker, Rank_Red_Joker, Suit_Joker } from '../game/types'
+
+interface Card_Props {
+  card: Card_Type
+  level: Rank
+  selected: boolean
+  on_click: () => void
+}
+
+export function Card({ card, level, selected, on_click }: Card_Props) {
+  const is_joker = card.Suit === Suit_Joker
+  const is_red = is_joker ? card.Rank === Rank_Red_Joker : is_red_suit(card.Suit)
+  const is_wild_card = is_wild(card, level)
+
+  return (
+    <motion.div
+      onClick={on_click}
+      animate={{
+        y: selected ? -20 : 0,
+        scale: selected ? 1.05 : 1,
+      }}
+      whileHover={{ scale: 1.08 }}
+      transition={{ type: 'spring', stiffness: 400, damping: 25 }}
+      style={{
+        width: 70,
+        height: 100,
+        backgroundColor: is_wild_card ? '#fff3cd' : '#fff',
+        border: is_wild_card ? '3px solid #ffc107' : '2px solid #333',
+        borderRadius: 8,
+        position: 'relative',
+        cursor: 'pointer',
+        userSelect: 'none',
+        boxShadow: selected ? '0 8px 16px rgba(0,0,0,0.3)' : '0 2px 4px rgba(0,0,0,0.1)',
+        color: is_red ? '#dc3545' : '#000',
+        fontWeight: 'bold',
+      }}
+    >
+      <div style={{
+        position: 'absolute',
+        top: 4,
+        left: 6,
+        display: 'flex',
+        flexDirection: 'column',
+        alignItems: 'center',
+        lineHeight: 1,
+      }}>
+        <span style={{ fontSize: 16, fontWeight: 'bold' }}>{is_joker ? (card.Rank === Rank_Red_Joker ? 'R' : 'B') : get_rank_symbol(card.Rank)}</span>
+        <span style={{ fontSize: 18 }}>{is_joker ? '🃏' : get_suit_symbol(card.Suit)}</span>
+      </div>
+      <div style={{
+        position: 'absolute',
+        bottom: 4,
+        right: 6,
+        display: 'flex',
+        flexDirection: 'column',
+        alignItems: 'center',
+        lineHeight: 1,
+        transform: 'rotate(180deg)',
+      }}>
+        <span style={{ fontSize: 14 }}>{is_joker ? (card.Rank === Rank_Red_Joker ? 'R' : 'B') : get_rank_symbol(card.Rank)}</span>
+        <span style={{ fontSize: 12 }}>{is_joker ? '🃏' : get_suit_symbol(card.Suit)}</span>
+      </div>
+      <div style={{
+        position: 'absolute',
+        top: '50%',
+        left: '50%',
+        transform: 'translate(-50%, -50%)',
+        fontSize: is_joker ? 28 : 24,
+      }}>
+        {is_joker ? '🃏' : get_suit_symbol(card.Suit)}
+      </div>
+    </motion.div>
+  )
+}
+
+export function Card_Back() {
+  return (
+    <div
+      style={{
+        width: 70,
+        height: 100,
+        backgroundColor: '#1e3a5f',
+        border: '2px solid #0d1b2a',
+        borderRadius: 8,
+        display: 'flex',
+        alignItems: 'center',
+        justifyContent: 'center',
+        backgroundImage: 'repeating-linear-gradient(45deg, transparent, transparent 5px, rgba(255,255,255,0.1) 5px, rgba(255,255,255,0.1) 10px)',
+      }}
+    >
+      <div style={{ color: '#fff', fontSize: 24 }}>🀄</div>
+    </div>
+  )
+}
diff --git a/client/src/components/Game.tsx b/client/src/components/Game.tsx
new file mode 100644
index 0000000..2d32fec
--- /dev/null
+++ b/client/src/components/Game.tsx
@@ -0,0 +1,282 @@
+import { motion } from 'framer-motion'
+import { Card as Card_Type, Rank, get_rank_symbol } from '../game/types'
+import { Hand } from './Hand'
+import { Table } from './Table'
+import { Card_Back } from './Card'
+
+interface Game_Props {
+  hand: Card_Type[]
+  level: Rank
+  selected_ids: Set<number>
+  on_card_click: (id: number) => void
+  on_play: () => void
+  on_pass: () => void
+  table_cards: Card_Type[]
+  combo_type: string
+  current_turn: number
+  my_seat: number
+  can_pass: boolean
+  player_card_counts: number[]
+  team_levels: [number, number]
+}
+
+export function Game({
+  hand,
+  level,
+  selected_ids,
+  on_card_click,
+  on_play,
+  on_pass,
+  table_cards,
+  combo_type,
+  current_turn,
+  my_seat,
+  can_pass,
+  player_card_counts,
+  team_levels,
+}: Game_Props) {
+  const is_my_turn = current_turn === my_seat
+  const relative_positions = get_relative_positions(my_seat)
+
+  return (
+    <div style={styles.container}>
+      <div style={styles.info_bar}>
+        <div style={styles.level_badge}>
+          Level: {get_rank_symbol(level)}
+        </div>
+        <div style={styles.team_scores}>
+          <span style={{ color: '#2196f3' }}>Team 1: {get_rank_symbol(team_levels[0] as Rank)}</span>
+          <span style={{ marginLeft: 16, color: '#e91e63' }}>Team 2: {get_rank_symbol(team_levels[1] as Rank)}</span>
+        </div>
+      </div>
+
+      <div style={styles.game_area}>
+        <div style={styles.opponent_top}>
+          <Opponent_Hand
+            count={player_card_counts[relative_positions.top]}
+            is_turn={current_turn === relative_positions.top}
+            seat={relative_positions.top}
+          />
+        </div>
+
+        <div style={styles.middle_row}>
+          <div style={styles.opponent_side}>
+            <Opponent_Hand
+              count={player_card_counts[relative_positions.left]}
+              is_turn={current_turn === relative_positions.left}
+              seat={relative_positions.left}
+              vertical
+            />
+          </div>
+
+          <div style={styles.table_area}>
+            <Table cards={table_cards} level={level} combo_type={combo_type} />
+          </div>
+
+          <div style={styles.opponent_side}>
+            <Opponent_Hand
+              count={player_card_counts[relative_positions.right]}
+              is_turn={current_turn === relative_positions.right}
+              seat={relative_positions.right}
+              vertical
+            />
+          </div>
+        </div>
+
+        <div style={styles.my_area}>
+          <Hand
+            cards={hand}
+            level={level}
+            selected_ids={selected_ids}
+            on_card_click={on_card_click}
+          />
+
+          <div style={styles.actions}>
+            <motion.button
+              whileHover={{ scale: 1.05 }}
+              whileTap={{ scale: 0.95 }}
+              onClick={on_play}
+              disabled={!is_my_turn || selected_ids.size === 0}
+              style={{
+                ...styles.action_button,
+                backgroundColor: is_my_turn && selected_ids.size > 0 ? '#28a745' : '#444',
+              }}
+            >
+              Play
+            </motion.button>
+            <motion.button
+              whileHover={{ scale: 1.05 }}
+              whileTap={{ scale: 0.95 }}
+              onClick={on_pass}
+              disabled={!is_my_turn || !can_pass}
+              style={{
+                ...styles.action_button,
+                backgroundColor: is_my_turn && can_pass ? '#dc3545' : '#444',
+              }}
+            >
+              Pass
+            </motion.button>
+          </div>
+
+          {is_my_turn && (
+            <motion.div
+              initial={{ opacity: 0 }}
+              animate={{ opacity: 1 }}
+              style={styles.turn_indicator}
+            >
+              Your turn!
+            </motion.div>
+          )}
+        </div>
+      </div>
+    </div>
+  )
+}
+
+interface Opponent_Hand_Props {
+  count: number
+  is_turn: boolean
+  seat: number
+  vertical?: boolean
+}
+
+function Opponent_Hand({ count, is_turn, seat, vertical }: Opponent_Hand_Props) {
+  const display_count = Math.min(count, 10)
+  const overlap = vertical ? 15 : 20
+
+  return (
+    <div
+      style={{
+        display: 'flex',
+        flexDirection: vertical ? 'column' : 'row',
+        alignItems: 'center',
+        gap: 8,
+        padding: 8,
+        backgroundColor: is_turn ? 'rgba(255,193,7,0.2)' : 'transparent',
+        borderRadius: 8,
+        border: is_turn ? '2px solid #ffc107' : '2px solid transparent',
+      }}
+    >
+      <div
+        style={{
+          display: 'flex',
+          flexDirection: vertical ? 'column' : 'row',
+          position: 'relative',
+          width: vertical ? 50 : 50 + (display_count - 1) * overlap,
+          height: vertical ? 70 + (display_count - 1) * overlap : 70,
+        }}
+      >
+        {Array.from({ length: display_count }).map((_, i) => (
+          <div
+            key={i}
+            style={{
+              position: 'absolute',
+              left: vertical ? 0 : i * overlap,
+              top: vertical ? i * overlap : 0,
+              transform: 'scale(0.7)',
+              transformOrigin: 'top left',
+            }}
+          >
+            <Card_Back />
+          </div>
+        ))}
+      </div>
+      <div style={{ color: '#fff', fontSize: 12 }}>
+        Seat {seat + 1}: {count}
+      </div>
+    </div>
+  )
+}
+
+function get_relative_positions(my_seat: number) {
+  return {
+    top: (my_seat + 2) % 4,
+    left: (my_seat + 1) % 4,
+    right: (my_seat + 3) % 4,
+  }
+}
+
+const styles: Record<string, React.CSSProperties> = {
+  container: {
+    display: 'flex',
+    flexDirection: 'column',
+    height: '100vh',
+    backgroundColor: '#0f3460',
+  },
+  info_bar: {
+    display: 'flex',
+    justifyContent: 'space-between',
+    alignItems: 'center',
+    padding: '12px 24px',
+    backgroundColor: '#16213e',
+  },
+  level_badge: {
+    padding: '8px 16px',
+    backgroundColor: '#ffc107',
+    color: '#000',
+    borderRadius: 8,
+    fontWeight: 'bold',
+  },
+  team_scores: {
+    color: '#fff',
+    fontSize: 14,
+  },
+  game_area: {
+    flex: 1,
+    display: 'flex',
+    flexDirection: 'column',
+    padding: 20,
+  },
+  opponent_top: {
+    display: 'flex',
+    justifyContent: 'center',
+    marginBottom: 20,
+  },
+  middle_row: {
+    flex: 1,
+    display: 'flex',
+    alignItems: 'center',
+  },
+  opponent_side: {
+    width: 120,
+    display: 'flex',
+    justifyContent: 'center',
+  },
+  table_area: {
+    flex: 1,
+    display: 'flex',
+    justifyContent: 'center',
+    alignItems: 'center',
+    backgroundColor: 'rgba(0,0,0,0.2)',
+    borderRadius: 16,
+    margin: '0 20px',
+  },
+  my_area: {
+    display: 'flex',
+    flexDirection: 'column',
+    alignItems: 'center',
+    paddingTop: 20,
+    borderTop: '2px solid #333',
+  },
+  actions: {
+    display: 'flex',
+    gap: 16,
+    marginTop: 16,
+  },
+  action_button: {
+    padding: '12px 32px',
+    fontSize: 16,
+    border: 'none',
+    borderRadius: 8,
+    color: '#fff',
+    cursor: 'pointer',
+  },
+  turn_indicator: {
+    marginTop: 12,
+    padding: '8px 16px',
+    backgroundColor: '#ffc107',
+    color: '#000',
+    borderRadius: 8,
+    fontWeight: 'bold',
+  },
+}
diff --git a/client/src/components/Hand.tsx b/client/src/components/Hand.tsx
new file mode 100644
index 0000000..1a49a86
--- /dev/null
+++ b/client/src/components/Hand.tsx
@@ -0,0 +1,59 @@
+import { motion, AnimatePresence } from 'framer-motion'
+import { Card as Card_Type, Rank } from '../game/types'
+import { Card } from './Card'
+
+interface Hand_Props {
+  cards: Card_Type[]
+  level: Rank
+  selected_ids: Set<number>
+  on_card_click: (id: number) => void
+}
+
+export function Hand({ cards, level, selected_ids, on_card_click }: Hand_Props) {
+  const card_width = 70
+  const overlap = 35
+
+  return (
+    <div
+      style={{
+        display: 'flex',
+        justifyContent: 'center',
+        padding: 20,
+        minHeight: 140,
+      }}
+    >
+      <div
+        style={{
+          display: 'flex',
+          position: 'relative',
+          width: cards.length > 0 ? card_width + (cards.length - 1) * overlap : 0,
+          height: 100,
+        }}
+      >
+        <AnimatePresence>
+          {cards.map((card, index) => (
+            <motion.div
+              key={card.Id}
+              initial={{ opacity: 0, y: 50, scale: 0.8 }}
+              animate={{ opacity: 1, y: 0, scale: 1 }}
+              exit={{ opacity: 0, y: -50, scale: 0.8 }}
+              transition={{ delay: index * 0.02 }}
+              style={{
+                position: 'absolute',
+                left: index * overlap,
+                zIndex: index,
+              }}
+            >
+              <Card
+                card={card}
+                level={level}
+                selected={selected_ids.has(card.Id)}
+                on_click={() => on_card_click(card.Id)}
+              />
+            </motion.div>
+          ))}
+        </AnimatePresence>
+      </div>
+    </div>
+  )
+}
diff --git a/client/src/components/Lobby.tsx b/client/src/components/Lobby.tsx
new file mode 100644
index 0000000..6cbf30d
--- /dev/null
+++ b/client/src/components/Lobby.tsx
@@ -0,0 +1,261 @@
+import { useState } from 'react'
+import { motion } from 'framer-motion'
+import { Player_Info } from '../game/types'
+
+interface Lobby_Props {
+  room_id: string | null
+  players: Player_Info[]
+  on_create_room: (name: string) => void
+  on_join_room: (room_id: string, name: string) => void
+}
+
+export function Lobby({ room_id, players, on_create_room, on_join_room }: Lobby_Props) {
+  const [name, set_name] = useState('')
+  const [join_code, set_join_code] = useState('')
+  const [mode, set_mode] = useState<'select' | 'create' | 'join'>('select')
+
+  const handle_create = () => {
+    if (name.trim()) {
+      on_create_room(name.trim())
+    }
+  }
+
+  const handle_join = () => {
+    if (name.trim() && join_code.trim()) {
+      on_join_room(join_code.trim(), name.trim())
+    }
+  }
+
+  if (room_id) {
+    return (
+      <div style={styles.container}>
+        <motion.div
+          initial={{ opacity: 0, y: 20 }}
+          animate={{ opacity: 1, y: 0 }}
+          style={styles.card}
+        >
+          <h2 style={styles.title}>Room: {room_id}</h2>
+          <p style={styles.subtitle}>Waiting for players... ({players.length}/4)</p>
+
+          <div style={styles.players_grid}>
+            {[0, 1, 2, 3].map((seat) => {
+              const player = players.find((p) => p.seat === seat)
+              const team = seat % 2
+              return (
+                <motion.div
+                  key={seat}
+                  initial={{ opacity: 0, scale: 0.8 }}
+                  animate={{ opacity: 1, scale: 1 }}
+                  transition={{ delay: seat * 0.1 }}
+                  style={{
+                    ...styles.player_slot,
+                    backgroundColor: team === 0 ? '#e3f2fd' : '#fce4ec',
+                    borderColor: team === 0 ? '#2196f3' : '#e91e63',
+                  }}
+                >
+                  {player ? (
+                    <>
+                      <div style={styles.player_name}>{player.name}</div>
+                      <div style={styles.player_team}>Team {team + 1}</div>
+                    </>
+                  ) : (
+                    <div style={styles.empty_slot}>Empty</div>
+                  )}
+                </motion.div>
+              )
+            })}
+          </div>
+
+          <p style={styles.hint}>Share room code with friends to join</p>
+        </motion.div>
+      </div>
+    )
+  }
+
+  return (
+    <div style={styles.container}>
+      <motion.div
+        initial={{ opacity: 0, y: 20 }}
+        animate={{ opacity: 1, y: 0 }}
+        style={styles.card}
+      >
+        <h1 style={styles.logo}>掼蛋</h1>
+        <h2 style={styles.title}>Guan Dan</h2>
+
+        {mode === 'select' && (
+          <div style={styles.buttons}>
+            <motion.button
+              whileHover={{ scale: 1.05 }}
+              whileTap={{ scale: 0.95 }}
+              onClick={() => set_mode('create')}
+              style={styles.button}
+            >
+              Create Room
+            </motion.button>
+            <motion.button
+              whileHover={{ scale: 1.05 }}
+              whileTap={{ scale: 0.95 }}
+              onClick={() => set_mode('join')}
+              style={{ ...styles.button, backgroundColor: '#28a745' }}
+            >
+              Join Room
+            </motion.button>
+          </div>
+        )}
+
+        {mode === 'create' && (
+          <div style={styles.form}>
+            <input
+              type="text"
+              placeholder="Your name"
+              value={name}
+              onChange={(e) => set_name(e.target.value)}
+              style={styles.input}
+            />
+            <div style={styles.buttons}>
+              <motion.button
+                whileHover={{ scale: 1.05 }}
+                whileTap={{ scale: 0.95 }}
+                onClick={handle_create}
+                style={styles.button}
+              >
+                Create
+              </motion.button>
+              <motion.button
+                whileHover={{ scale: 1.05 }}
+                whileTap={{ scale: 0.95 }}
+                onClick={() => set_mode('select')}
+                style={{ ...styles.button, backgroundColor: '#6c757d' }}
+              >
+                Back
+              </motion.button>
+            </div>
+          </div>
+        )}
+
+        {mode === 'join' && (
+          <div style={styles.form}>
+            <input
+              type="text"
+              placeholder="Your name"
+              value={name}
+              onChange={(e) => set_name(e.target.value)}
+              style={styles.input}
+            />
+            <input
+              type="text"
+              placeholder="Room code"
+              value={join_code}
+              onChange={(e) => set_join_code(e.target.value)}
+              style={styles.input}
+            />
+            <div style={styles.buttons}>
+              <motion.button
+                whileHover={{ scale: 1.05 }}
+                whileTap={{ scale: 0.95 }}
+                onClick={handle_join}
+                style={{ ...styles.button, backgroundColor: '#28a745' }}
+              >
+                Join
+              </motion.button>
+              <motion.button
+                whileHover={{ scale: 1.05 }}
+                whileTap={{ scale: 0.95 }}
+                onClick={() => set_mode('select')}
+                style={{ ...styles.button, backgroundColor: '#6c757d' }}
+              >
+                Back
+              </motion.button>
+            </div>
+          </div>
+        )}
+      </motion.div>
+    </div>
+  )
+}
+
+const styles: Record<string, React.CSSProperties> = {
+  container: {
+    display: 'flex',
+    justifyContent: 'center',
+    alignItems: 'center',
+    minHeight: '100vh',
+    backgroundColor: '#1a1a2e',
+  },
+  card: {
+    backgroundColor: '#16213e',
+    padding: 40,
+    borderRadius: 16,
+    textAlign: 'center',
+    boxShadow: '0 8px 32px rgba(0,0,0,0.3)',
+    minWidth: 360,
+  },
+  logo: {
+    fontSize: 64,
+    margin: 0,
+    color: '#fff',
+  },
+  title: {
+    color: '#fff',
+    marginTop: 8,
+    marginBottom: 24,
+  },
+  subtitle: {
+    color: '#aaa',
+    marginBottom: 24,
+  },
+  buttons: {
+    display: 'flex',
+    gap: 12,
+    justifyContent: 'center',
+  },
+  button: {
+    padding: '12px 24px',
+    fontSize: 16,
+    border: 'none',
+    borderRadius: 8,
+    backgroundColor: '#007bff',
+    color: '#fff',
+    cursor: 'pointer',
+  },
+  form: {
+    display: 'flex',
+    flexDirection: 'column',
+    gap: 12,
+  },
+  input: {
+    padding: '12px 16px',
+    fontSize: 16,
+    border: '2px solid #333',
+    borderRadius: 8,
+    backgroundColor: '#0f3460',
+    color: '#fff',
+    outline: 'none',
+  },
+  players_grid: {
+    display: 'grid',
+    gridTemplateColumns: '1fr 1fr',
+    gap: 12,
+    marginBottom: 24,
+  },
+  player_slot: {
+    padding: 16,
+    borderRadius: 8,
+    border: '2px solid',
+  },
+  player_name: {
+    fontWeight: 'bold',
+    color: '#333',
+  },
+  player_team: {
+    fontSize: 12,
+    color: '#666',
+  },
+  empty_slot: {
+    color: '#999',
+  },
+  hint: {
+    color: '#666',
+    fontSize: 12,
+  },
+}
diff --git a/client/src/components/Table.tsx b/client/src/components/Table.tsx
new file mode 100644
index 0000000..90684d4
--- /dev/null
+++ b/client/src/components/Table.tsx
@@ -0,0 +1,94 @@
+import { motion, AnimatePresence } from 'framer-motion'
+import { Card as Card_Type, Rank } from '../game/types'
+import { Card } from './Card'
+
+interface Table_Props {
+  cards: Card_Type[]
+  level: Rank
+  combo_type: string
+}
+
+export function Table({ cards, level, combo_type }: Table_Props) {
+  const card_width = 70
+  const overlap = 40
+
+  return (
+    <div
+      style={{
+        display: 'flex',
+        flexDirection: 'column',
+        alignItems: 'center',
+        justifyContent: 'center',
+        minHeight: 180,
+        padding: 20,
+      }}
+    >
+      <div
+        style={{
+          display: 'flex',
+          position: 'relative',
+          width: cards.length > 0 ? card_width + (cards.length - 1) * overlap : 100,
+          height: 100,
+          justifyContent: 'center',
+        }}
+      >
+        <AnimatePresence mode="wait">
+          {cards.length > 0 ? (
+            cards.map((card, index) => (
+              <motion.div
+                key={`table-${card.Id}`}
+                initial={{ opacity: 0, scale: 0.5, y: 100 }}
+                animate={{ opacity: 1, scale: 1, y: 0 }}
+                exit={{ opacity: 0, scale: 0.5, y: -100 }}
+                transition={{ delay: index * 0.05, type: 'spring', stiffness: 300 }}
+                style={{
+                  position: 'absolute',
+                  left: index * overlap,
+                  zIndex: index,
+                }}
+              >
+                <Card
+                  card={card}
+                  level={level}
+                  selected={false}
+                  on_click={() => {}}
+                />
+              </motion.div>
+            ))
+          ) : (
+            <motion.div
+              initial={{ opacity: 0 }}
+              animate={{ opacity: 0.5 }}
+              style={{
+                display: 'flex',
+                alignItems: 'center',
+                justifyContent: 'center',
+                color: '#666',
+                fontSize: 14,
+              }}
+            >
+              No cards played
+            </motion.div>
+          )}
+        </AnimatePresence>
+      </div>
+      {combo_type && (
+        <motion.div
+          initial={{ opacity: 0, y: 10 }}
+          animate={{ opacity: 1, y: 0 }}
+          style={{
+            marginTop: 12,
+            padding: '4px 12px',
+            backgroundColor: '#333',
+            color: '#fff',
+            borderRadius: 4,
+            fontSize: 12,
+            textTransform: 'uppercase',
+          }}
+        >
+          {combo_type}
+        </motion.div>
+      )}
+    </div>
+  )
+}
diff --git a/client/src/game/types.ts b/client/src/game/types.ts
new file mode 100644
index 0000000..5265310
--- /dev/null
+++ b/client/src/game/types.ts
@@ -0,0 +1,99 @@
+export type Suit = 0 | 1 | 2 | 3 | 4
+
+export const Suit_Hearts: Suit = 0
+export const Suit_Diamonds: Suit = 1
+export const Suit_Clubs: Suit = 2
+export const Suit_Spades: Suit = 3
+export const Suit_Joker: Suit = 4
+
+export type Rank = 0 | 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14
+
+export const Rank_Two: Rank = 0
+export const Rank_Three: Rank = 1
+export const Rank_Four: Rank = 2
+export const Rank_Five: Rank = 3
+export const Rank_Six: Rank = 4
+export const Rank_Seven: Rank = 5
+export const Rank_Eight: Rank = 6
+export const Rank_Nine: Rank = 7
+export const Rank_Ten: Rank = 8
+export const Rank_Jack: Rank = 9
+export const Rank_Queen: Rank = 10
+export const Rank_King: Rank = 11
+export const Rank_Ace: Rank = 12
+export const Rank_Black_Joker: Rank = 13
+export const Rank_Red_Joker: Rank = 14
+
+export interface Card {
+  Suit: Suit
+  Rank: Rank
+  Id: number
+}
+
+export interface Player_Info {
+  id: string
+  name: string
+  seat: number
+  team: number
+  is_ready: boolean
+}
+
+export interface Room_State {
+  room_id: string
+  players: Player_Info[]
+  game_active: boolean
+}
+
+export interface Game_State {
+  hand: Card[]
+  level: Rank
+  current_turn: number
+  my_seat: number
+  can_pass: boolean
+  table_cards: Card[]
+  player_card_counts: number[]
+  finish_order: string[]
+  team_levels: [number, number]
+}
+
+export type Msg_Type =
+  | 'join_room'
+  | 'create_room'
+  | 'room_state'
+  | 'game_start'
+  | 'deal_cards'
+  | 'play_cards'
+  | 'pass'
+  | 'turn'
+  | 'play_made'
+  | 'hand_end'
+  | 'tribute'
+  | 'tribute_give'
+  | 'tribute_recv'
+  | 'game_end'
+  | 'error'
+  | 'player_joined'
+  | 'player_left'
+
+export interface Message<T = unknown> {
+  type: Msg_Type
+  payload: T
+}
+
+export function get_suit_symbol(suit: Suit): string {
+  const symbols = ['♥', '♦', '♣', '♠', '']
+  return symbols[suit]
+}
+
+export function get_rank_symbol(rank: Rank): string {
+  const symbols = ['2', '3', '4', '5', '6', '7', '8', '9', '10', 'J', 'Q', 'K', 'A', '🃏', '🃏']
+  return symbols[rank]
+}
+
+export function is_red_suit(suit: Suit): boolean {
+  return suit === Suit_Hearts || suit === Suit_Diamonds
+}
+
+export function is_wild(card: Card, level: Rank): boolean {
+  return card.Suit === Suit_Hearts && card.Rank === level
+}
diff --git a/client/src/hooks/use_websocket.ts b/client/src/hooks/use_websocket.ts
new file mode 100644
index 0000000..d25006d
--- /dev/null
+++ b/client/src/hooks/use_websocket.ts
@@ -0,0 +1,50 @@
+import { useCallback, useEffect, useRef, useState } from 'react'
+import { Message } from '../game/types'
+
+type Message_Handler = (msg: Message) => void
+
+export function use_websocket(url: string) {
+  const [connected, set_connected] = useState(false)
+  const ws_ref = useRef<WebSocket | null>(null)
+  const handlers_ref = useRef<Map<string, Message_Handler>>(new Map())
+
+  useEffect(() => {
+    const ws = new WebSocket(url)
+    ws_ref.current = ws
+
+    ws.onopen = () => {
+      set_connected(true)
+    }
+
+    ws.onclose = () => {
+      set_connected(false)
+    }
+
+    ws.onmessage = (event) => {
+      const msg: Message = JSON.parse(event.data)
+      const handler = handlers_ref.current.get(msg.type)
+      if (handler) {
+        handler(msg)
+      }
+    }
+
+    return () => {
+      ws.close()
+    }
+  }, [url])
+
+  const send = useCallback((msg: Message) => {
+    if (ws_ref.current && ws_ref.current.readyState === WebSocket.OPEN) {
+      ws_ref.current.send(JSON.stringify(msg))
+    }
+  }, [])
+
+  const on = useCallback((type: string, handler: Message_Handler) => {
+    handlers_ref.current.set(type, handler)
+    return () => {
+      handlers_ref.current.delete(type)
+    }
+  }, [])
+
+  return { connected, send, on }
+}
diff --git a/client/src/main.tsx b/client/src/main.tsx
new file mode 100644
index 0000000..d18d7ec
--- /dev/null
+++ b/client/src/main.tsx
@@ -0,0 +1,23 @@
+import React from 'react'
+import ReactDOM from 'react-dom/client'
+import App from './App'
+
+const style = document.createElement('style')
+style.textContent = `
+  * {
+    margin: 0;
+    padding: 0;
+    box-sizing: border-box;
+  }
+  body {
+    font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
+    background-color: #1a1a2e;
+  }
+`
+document.head.appendChild(style)
+
+ReactDOM.createRoot(document.getElementById('root')!).render(
+  <React.StrictMode>
+    <App />
+  </React.StrictMode>
+)
diff --git a/client/tsconfig.json b/client/tsconfig.json
new file mode 100644
index 0000000..3934b8f
--- /dev/null
+++ b/client/tsconfig.json
@@ -0,0 +1,21 @@
+{
+  "compilerOptions": {
+    "target": "ES2020",
+    "useDefineForClassFields": true,
+    "lib": ["ES2020", "DOM", "DOM.Iterable"],
+    "module": "ESNext",
+    "skipLibCheck": true,
+    "moduleResolution": "bundler",
+    "allowImportingTsExtensions": true,
+    "resolveJsonModule": true,
+    "isolatedModules": true,
+    "noEmit": true,
+    "jsx": "react-jsx",
+    "strict": true,
+    "noUnusedLocals": true,
+    "noUnusedParameters": true,
+    "noFallthroughCasesInSwitch": true
+  },
+  "include": ["src"],
+  "references": [{ "path": "./tsconfig.node.json" }]
+}
diff --git a/client/tsconfig.node.json b/client/tsconfig.node.json
new file mode 100644
index 0000000..42872c5
--- /dev/null
+++ b/client/tsconfig.node.json
@@ -0,0 +1,10 @@
+{
+  "compilerOptions": {
+    "composite": true,
+    "skipLibCheck": true,
+    "module": "ESNext",
+    "moduleResolution": "bundler",
+    "allowSyntheticDefaultImports": true
+  },
+  "include": ["vite.config.ts"]
+}
diff --git a/client/vite.config.ts b/client/vite.config.ts
new file mode 100644
index 0000000..d47b1b5
--- /dev/null
+++ b/client/vite.config.ts
@@ -0,0 +1,14 @@
+import { defineConfig } from 'vite'
+import react from '@vitejs/plugin-react'
+
+export default defineConfig({
+  plugins: [react()],
+  server: {
+    proxy: {
+      '/ws': {
+        target: 'ws://localhost:8080',
+        ws: true,
+      },
+    },
+  },
+})
diff --git a/flake.lock b/flake.lock
new file mode 100644
index 0000000..22a4992
--- /dev/null
+++ b/flake.lock
@@ -0,0 +1,27 @@
+{
+  "nodes": {
+    "nixpkgs": {
+      "locked": {
+        "lastModified": 1769018530,
+        "narHash": "sha256-MJ27Cy2NtBEV5tsK+YraYr2g851f3Fl1LpNHDzDX15c=",
+        "owner": "NixOS",
+        "repo": "nixpkgs",
+        "rev": "88d3861acdd3d2f0e361767018218e51810df8a1",
+        "type": "github"
+      },
+      "original": {
+        "owner": "NixOS",
+        "ref": "nixos-unstable",
+        "repo": "nixpkgs",
+        "type": "github"
+      }
+    },
+    "root": {
+      "inputs": {
+        "nixpkgs": "nixpkgs"
+      }
+    }
+  },
+  "root": "root",
+  "version": 7
+}
diff --git a/flake.nix b/flake.nix
new file mode 100644
index 0000000..7b0cdf2
--- /dev/null
+++ b/flake.nix
@@ -0,0 +1,34 @@
+{
+  description = "guandanbtw - Online Guan Dan card game";
+  inputs = {
+    nixpkgs.url = "github:NixOS/nixpkgs/nixos-unstable";
+  };
+  outputs = {
+    self,
+    nixpkgs,
+  }: let
+    systems = ["x86_64-linux" "aarch64-linux"];
+
+    forAllSystems = fn: nixpkgs.lib.genAttrs systems (system: fn nixpkgs.legacyPackages.${system});
+  in {
+    devShells = forAllSystems (pkgs: {
+      default = pkgs.mkShell {
+        packages = [
+          pkgs.go
+          pkgs.gopls
+          pkgs.gotools
+          pkgs.air
+          pkgs.nodejs
+          pkgs.nodePackages.typescript
+          pkgs.nodePackages.typescript-language-server
+          pkgs.just
+        ];
+        shellHook = ''
+          export PS1="(guandan-dev) $PS1"
+        '';
+      };
+    });
+
+    formatter = forAllSystems (pkgs: pkgs.alejandra);
+  };
+}
diff --git a/readme.org b/readme.org
new file mode 100644
index 0000000..cdef113
--- /dev/null
+++ b/readme.org
@@ -0,0 +1,115 @@
+#+AUTHOR: Tony
+#+STARTUP: overview
+
+* Guan Dan BTW
+A real-time multiplayer web implementation of Guan Dan (掼蛋), a popular Chinese climbing card game for 4 players in 2v2 teams.
+
+[[./assets/guandan.png]]
+
+* Table of Contents :toc:
+- [[#guan-dan-btw][Guan Dan BTW]]
+- [[#features][Features]]
+- [[#installation][Installation]]
+- [[#usage][Usage]]
+- [[#development][Development]]
+- [[#project-structure][Project Structure]]
+- [[#game-rules][Game Rules]]
+- [[#future-plans][Future Plans]]
+- [[#license][License]]
+
+* Features
+- Real-time multiplayer via WebSockets
+- Room-based lobbies (create/join with room code)
+- Full Guan Dan ruleset:
+  - Level system (2 through A)
+  - Wild cards (heart of current level)
+  - Bomb hierarchy (4-10 of a kind, straight flush, 4-joker)
+  - Tribute system between hands
+- Animated card UI with Framer Motion
+
+* Installation
+Requires Go 1.23+ and Node.js 18+.
+
+#+begin_src sh
+git clone https://github.com/tonybanters/guandanbtw
+cd guandanbtw
+nix develop  # or install go, node manually
+#+end_src
+
+* Usage
+Start the server:
+#+begin_src sh
+cd server
+air  # or: go run .
+#+end_src
+
+Start the client:
+#+begin_src sh
+cd client
+npm install
+npm run dev
+#+end_src
+
+Open =http://localhost:5173=, create a room, and share the room code with 3 friends.
+
+* Development
+Using Nix (recommended):
+#+begin_src sh
+nix develop
+#+end_src
+
+Dev shell includes: go, gopls, air, nodejs, typescript.
+
+* Project Structure
+#+begin_src
+server/
+├── main.go               [Entry point, HTTP/WS server]
+├── game/
+│   ├── card.go           [Card types, deck, ranking]
+│   ├── combination.go    [Valid play detection]
+│   ├── bomb.go           [Bomb hierarchy]
+│   └── state.go          [Game state machine]
+├── room/
+│   ├── hub.go            [WebSocket hub, room management]
+│   ├── room.go           [Room logic, game flow]
+│   └── client.go         [Client connection handling]
+└── protocol/
+    └── messages.go       [JSON message types]
+
+client/
+├── src/
+│   ├── App.tsx           [Main app, state management]
+│   ├── components/
+│   │   ├── Card.tsx      [Card component]
+│   │   ├── Hand.tsx      [Player hand]
+│   │   ├── Table.tsx     [Center play area]
+│   │   ├── Lobby.tsx     [Create/join room UI]
+│   │   └── Game.tsx      [Main game layout]
+│   ├── hooks/
+│   │   └── use_websocket.ts
+│   └── game/
+│       └── types.ts      [Shared types]
+└── package.json
+#+end_src
+
+* Game Rules
+- 4 players, 2v2 teams (partners sit opposite)
+- 2 decks + 4 jokers = 108 cards, 27 each
+- Teams race to empty their hands
+- Current level card ranks highest; heart of level is wild
+- Valid plays: singles, pairs, triples, full houses, straights, tubes, plates
+- Bombs beat everything (ranked by size, then straight flush, then 4-joker)
+- Winners advance levels based on finish order
+
+Full rules: [[https://www.pagat.com/climbing/guan_dan.html][pagat.com]]
+
+* Future Plans
+- Tribute UI on frontend
+- Sound effects and better animations
+- Card sorting options
+- Spectator mode
+- Mobile responsive layout
+- Deployment (fly.io/railway)
+
+* License
+[[https://www.gnu.org/licenses/gpl-3.0.en.html][GPL v3]]
diff --git a/server/.air.toml b/server/.air.toml
new file mode 100644
index 0000000..eeb5593
--- /dev/null
+++ b/server/.air.toml
@@ -0,0 +1,22 @@
+root = "."
+tmp_dir = "tmp"
+
+[build]
+cmd = "go build -o ./tmp/main ."
+bin = "./tmp/main"
+full_bin = "./tmp/main"
+include_ext = ["go"]
+exclude_dir = ["tmp", "vendor"]
+delay = 1000
+
+[log]
+time = false
+
+[color]
+main = "magenta"
+watcher = "cyan"
+build = "yellow"
+runner = "green"
+
+[misc]
+clean_on_exit = true
diff --git a/server/game/bomb.go b/server/game/bomb.go
new file mode 100644
index 0000000..ef81d42
--- /dev/null
+++ b/server/game/bomb.go
@@ -0,0 +1,147 @@
+package game
+
+func detect_bomb(cards []Card, level Rank) Combination {
+	n := len(cards)
+	if n == 4 && is_four_joker_bomb(cards) {
+		return Combination{
+			Type:       Comb_Bomb,
+			Cards:      cards,
+			Bomb_Power: 1000,
+		}
+	}
+	if is_straight_flush(cards, level) {
+		return Combination{
+			Type:       Comb_Bomb,
+			Cards:      cards,
+			Bomb_Power: 900 + straight_flush_value(cards, level),
+		}
+	}
+	if n >= 4 && n <= 10 {
+		if power, ok := is_n_of_kind_bomb(cards, level); ok {
+			return Combination{
+				Type:       Comb_Bomb,
+				Cards:      cards,
+				Bomb_Power: power,
+			}
+		}
+	}
+
+	return Combination{Type: Comb_Invalid}
+}
+
+func is_four_joker_bomb(cards []Card) bool {
+	if len(cards) != 4 {
+		return false
+	}
+
+	red_count := 0
+	black_count := 0
+
+	for _, c := range cards {
+		if c.Rank == Rank_Red_Joker {
+			red_count++
+		} else if c.Rank == Rank_Black_Joker {
+			black_count++
+		} else {
+			return false
+		}
+	}
+
+	return red_count == 2 && black_count == 2
+}
+
+func is_straight_flush(cards []Card, level Rank) bool {
+	if len(cards) < 5 {
+		return false
+	}
+
+	non_wild, wild := separate_wilds(cards, level)
+
+	if len(non_wild) == 0 {
+		return false
+	}
+
+	var suit Suit = -1
+	for _, c := range non_wild {
+		if c.Rank == Rank_Black_Joker || c.Rank == Rank_Red_Joker {
+			return false
+		}
+		if suit == -1 {
+			suit = c.Suit
+		} else if c.Suit != suit {
+			return false
+		}
+	}
+
+	rank_present := make(map[Rank]bool)
+	for _, c := range non_wild {
+		rank_present[c.Rank] = true
+	}
+
+	natural_order := []Rank{
+		Rank_Ace, Rank_Two, Rank_Three, Rank_Four, Rank_Five,
+		Rank_Six, Rank_Seven, Rank_Eight, Rank_Nine, Rank_Ten,
+		Rank_Jack, Rank_Queen, Rank_King, Rank_Ace,
+	}
+
+	needed_len := len(cards)
+	wilds_available := len(wild)
+
+	for start := 0; start <= len(natural_order)-needed_len; start++ {
+		gaps := 0
+		for i := 0; i < needed_len; i++ {
+			rank := natural_order[start+i]
+			if !rank_present[rank] {
+				gaps++
+			}
+		}
+		if gaps <= wilds_available {
+			return true
+		}
+	}
+
+	return false
+}
+
+func straight_flush_value(cards []Card, level Rank) int {
+	non_wild, _ := separate_wilds(cards, level)
+
+	max_val := 0
+	for _, c := range non_wild {
+		v := rank_value(c.Rank, level)
+		if v > max_val {
+			max_val = v
+		}
+	}
+
+	return len(cards)*10 + max_val
+}
+
+func is_n_of_kind_bomb(cards []Card, level Rank) (int, bool) {
+	n := len(cards)
+	if n < 4 || n > 10 {
+		return 0, false
+	}
+
+	non_wild, wild := separate_wilds(cards, level)
+
+	rank_counts := count_ranks(non_wild)
+
+	for rank, count := range rank_counts {
+		if rank == Rank_Black_Joker || rank == Rank_Red_Joker {
+			continue
+		}
+		total := count + len(wild)
+		if total >= n {
+			power := n*100 + rank_value(rank, level)
+			return power, true
+		}
+	}
+
+	if len(wild) >= n {
+		power := n*100 + rank_value(level, level)
+		return power, true
+	}
+
+	return 0, false
+}
diff --git a/server/game/card.go b/server/game/card.go
new file mode 100644
index 0000000..1b5ef27
--- /dev/null
+++ b/server/game/card.go
@@ -0,0 +1,126 @@
+package game
+
+type Suit int
+
+const (
+	Suit_Hearts Suit = iota
+	Suit_Diamonds
+	Suit_Clubs
+	Suit_Spades
+	Suit_Joker
+)
+
+type Rank int
+
+const (
+	Rank_Two Rank = iota
+	Rank_Three
+	Rank_Four
+	Rank_Five
+	Rank_Six
+	Rank_Seven
+	Rank_Eight
+	Rank_Nine
+	Rank_Ten
+	Rank_Jack
+	Rank_Queen
+	Rank_King
+	Rank_Ace
+	Rank_Black_Joker
+	Rank_Red_Joker
+)
+
+type Card struct {
+	Suit Suit
+	Rank Rank
+	Id   int
+}
+
+type Deck struct {
+	Cards []Card
+}
+
+func New_Deck() *Deck {
+	deck := &Deck{Cards: make([]Card, 0, 108)}
+	id := 0
+
+	for copy := 0; copy < 2; copy++ {
+		for suit := Suit_Hearts; suit <= Suit_Spades; suit++ {
+			for rank := Rank_Two; rank <= Rank_Ace; rank++ {
+				deck.Cards = append(deck.Cards, Card{Suit: suit, Rank: rank, Id: id})
+				id++
+			}
+		}
+		deck.Cards = append(deck.Cards, Card{Suit: Suit_Joker, Rank: Rank_Black_Joker, Id: id})
+		id++
+		deck.Cards = append(deck.Cards, Card{Suit: Suit_Joker, Rank: Rank_Red_Joker, Id: id})
+		id++
+	}
+
+	return deck
+}
+
+func (d *Deck) Shuffle() {
+	for i := len(d.Cards) - 1; i > 0; i-- {
+		j := rand_int(i + 1)
+		d.Cards[i], d.Cards[j] = d.Cards[j], d.Cards[i]
+	}
+}
+
+func (d *Deck) Deal() [4][]Card {
+	var hands [4][]Card
+	for i := 0; i < 4; i++ {
+		hands[i] = make([]Card, 0, 27)
+	}
+
+	for i, card := range d.Cards {
+		hands[i%4] = append(hands[i%4], card)
+	}
+
+	return hands
+}
+
+func rank_value(rank Rank, level Rank) int {
+	if rank == Rank_Red_Joker {
+		return 100
+	}
+	if rank == Rank_Black_Joker {
+		return 99
+	}
+	if rank == level {
+		return 98
+	}
+
+	base_order := []Rank{
+		Rank_Two, Rank_Three, Rank_Four, Rank_Five, Rank_Six,
+		Rank_Seven, Rank_Eight, Rank_Nine, Rank_Ten,
+		Rank_Jack, Rank_Queen, Rank_King, Rank_Ace,
+	}
+
+	for i, r := range base_order {
+		if r == rank {
+			return i
+		}
+	}
+	return -1
+}
+
+func card_value(card Card, level Rank) int {
+	return rank_value(card.Rank, level)
+}
+
+func Is_Wild(card Card, level Rank) bool {
+	return card.Suit == Suit_Hearts && card.Rank == level
+}
+
+func compare_cards(a, b Card, level Rank) int {
+	va := card_value(a, level)
+	vb := card_value(b, level)
+	if va > vb {
+		return 1
+	}
+	if va < vb {
+		return -1
+	}
+	return 0
+}
diff --git a/server/game/combination.go b/server/game/combination.go
new file mode 100644
index 0000000..233dae3
--- /dev/null
+++ b/server/game/combination.go
@@ -0,0 +1,417 @@
+package game
+
+type Combination_Type int
+
+const (
+	Comb_Invalid Combination_Type = iota
+	Comb_Single
+	Comb_Pair
+	Comb_Triple
+	Comb_Full_House
+	Comb_Straight
+	Comb_Tube
+	Comb_Plate
+	Comb_Bomb
+)
+
+type Combination struct {
+	Type       Combination_Type
+	Cards      []Card
+	Rank_Value int
+	Bomb_Power int
+}
+
+func Detect_Combination(cards []Card, level Rank) Combination {
+	n := len(cards)
+	if n == 0 {
+		return Combination{Type: Comb_Invalid}
+	}
+
+	non_wild, wild := separate_wilds(cards, level)
+
+	if bomb := detect_bomb(cards, level); bomb.Type == Comb_Bomb {
+		return bomb
+	}
+
+	switch n {
+	case 1:
+		return detect_single(non_wild, wild, level)
+	case 2:
+		return detect_pair(non_wild, wild, level)
+	case 3:
+		return detect_triple(non_wild, wild, level)
+	case 5:
+		if comb := detect_full_house(non_wild, wild, level); comb.Type != Comb_Invalid {
+			return comb
+		}
+		return detect_straight(non_wild, wild, level)
+	case 6:
+		if comb := detect_tube(non_wild, wild, level); comb.Type != Comb_Invalid {
+			return comb
+		}
+		return detect_plate(non_wild, wild, level)
+	}
+
+	return Combination{Type: Comb_Invalid}
+}
+
+func separate_wilds(cards []Card, level Rank) ([]Card, []Card) {
+	var non_wild, wild []Card
+	for _, c := range cards {
+		if Is_Wild(c, level) {
+			wild = append(wild, c)
+		} else {
+			non_wild = append(non_wild, c)
+		}
+	}
+	return non_wild, wild
+}
+
+func detect_single(non_wild, wild []Card, level Rank) Combination {
+	if len(non_wild) == 1 && len(wild) == 0 {
+		return Combination{
+			Type:       Comb_Single,
+			Cards:      non_wild,
+			Rank_Value: card_value(non_wild[0], level),
+		}
+	}
+	if len(non_wild) == 0 && len(wild) == 1 {
+		return Combination{
+			Type:       Comb_Single,
+			Cards:      wild,
+			Rank_Value: card_value(wild[0], level),
+		}
+	}
+	return Combination{Type: Comb_Invalid}
+}
+
+func detect_pair(non_wild, wild []Card, level Rank) Combination {
+	total := len(non_wild) + len(wild)
+	if total != 2 {
+		return Combination{Type: Comb_Invalid}
+	}
+
+	if len(non_wild) == 2 {
+		if non_wild[0].Rank == non_wild[1].Rank {
+			return Combination{
+				Type:       Comb_Pair,
+				Cards:      append(non_wild, wild...),
+				Rank_Value: card_value(non_wild[0], level),
+			}
+		}
+		return Combination{Type: Comb_Invalid}
+	}
+
+	if len(non_wild) == 1 && len(wild) == 1 {
+		return Combination{
+			Type:       Comb_Pair,
+			Cards:      append(non_wild, wild...),
+			Rank_Value: card_value(non_wild[0], level),
+		}
+	}
+
+	if len(wild) == 2 {
+		return Combination{
+			Type:       Comb_Pair,
+			Cards:      wild,
+			Rank_Value: card_value(wild[0], level),
+		}
+	}
+
+	return Combination{Type: Comb_Invalid}
+}
+
+func detect_triple(non_wild, wild []Card, level Rank) Combination {
+	total := len(non_wild) + len(wild)
+	if total != 3 {
+		return Combination{Type: Comb_Invalid}
+	}
+
+	rank_counts := count_ranks(non_wild)
+
+	for rank, count := range rank_counts {
+		needed := 3 - count
+		if needed <= len(wild) {
+			return Combination{
+				Type:       Comb_Triple,
+				Cards:      append(non_wild, wild...),
+				Rank_Value: rank_value(rank, level),
+			}
+		}
+	}
+
+	if len(wild) == 3 {
+		return Combination{
+			Type:       Comb_Triple,
+			Cards:      wild,
+			Rank_Value: card_value(wild[0], level),
+		}
+	}
+
+	return Combination{Type: Comb_Invalid}
+}
+
+func detect_full_house(non_wild, wild []Card, level Rank) Combination {
+	total := len(non_wild) + len(wild)
+	if total != 5 {
+		return Combination{Type: Comb_Invalid}
+	}
+
+	rank_counts := count_ranks(non_wild)
+	wilds_available := len(wild)
+
+	var triple_rank Rank = -1
+	var pair_rank Rank = -1
+
+	ranks := sorted_ranks(rank_counts, level)
+
+	for _, rank := range ranks {
+		count := rank_counts[rank]
+		if count >= 3 && triple_rank == -1 {
+			triple_rank = rank
+		} else if count >= 2 && pair_rank == -1 && triple_rank != rank {
+			pair_rank = rank
+		}
+	}
+
+	if triple_rank != -1 && pair_rank != -1 {
+		return Combination{
+			Type:       Comb_Full_House,
+			Cards:      append(non_wild, wild...),
+			Rank_Value: rank_value(triple_rank, level),
+		}
+	}
+
+	for _, rank := range ranks {
+		count := rank_counts[rank]
+		needed_for_triple := 3 - count
+		if needed_for_triple <= wilds_available {
+			remaining_wilds := wilds_available - needed_for_triple
+			for _, other_rank := range ranks {
+				if other_rank == rank {
+					continue
+				}
+				other_count := rank_counts[other_rank]
+				needed_for_pair := 2 - other_count
+				if needed_for_pair <= remaining_wilds {
+					return Combination{
+						Type:       Comb_Full_House,
+						Cards:      append(non_wild, wild...),
+						Rank_Value: rank_value(rank, level),
+					}
+				}
+			}
+			if remaining_wilds >= 2 {
+				return Combination{
+					Type:       Comb_Full_House,
+					Cards:      append(non_wild, wild...),
+					Rank_Value: rank_value(rank, level),
+				}
+			}
+		}
+	}
+
+	return Combination{Type: Comb_Invalid}
+}
+
+func detect_straight(non_wild, wild []Card, level Rank) Combination {
+	total := len(non_wild) + len(wild)
+	if total != 5 {
+		return Combination{Type: Comb_Invalid}
+	}
+
+	for _, card := range non_wild {
+		if card.Rank == Rank_Black_Joker || card.Rank == Rank_Red_Joker {
+			return Combination{Type: Comb_Invalid}
+		}
+	}
+
+	rank_counts := count_ranks(non_wild)
+	wilds_available := len(wild)
+
+	natural_order := []Rank{
+		Rank_Ace, Rank_Two, Rank_Three, Rank_Four, Rank_Five,
+		Rank_Six, Rank_Seven, Rank_Eight, Rank_Nine, Rank_Ten,
+		Rank_Jack, Rank_Queen, Rank_King, Rank_Ace,
+	}
+
+	for start := 0; start <= len(natural_order)-5; start++ {
+		needed := 0
+		valid := true
+		highest := natural_order[start+4]
+
+		for i := 0; i < 5; i++ {
+			rank := natural_order[start+i]
+			if rank_counts[rank] == 0 {
+				needed++
+			} else if rank_counts[rank] > 1 {
+				valid = false
+				break
+			}
+		}
+
+		if valid && needed <= wilds_available {
+			return Combination{
+				Type:       Comb_Straight,
+				Cards:      append(non_wild, wild...),
+				Rank_Value: straight_value(highest, level),
+			}
+		}
+	}
+
+	return Combination{Type: Comb_Invalid}
+}
+
+func straight_value(highest Rank, level Rank) int {
+	natural_order := []Rank{
+		Rank_Two, Rank_Three, Rank_Four, Rank_Five,
+		Rank_Six, Rank_Seven, Rank_Eight, Rank_Nine, Rank_Ten,
+		Rank_Jack, Rank_Queen, Rank_King, Rank_Ace,
+	}
+	for i, r := range natural_order {
+		if r == highest {
+			return i
+		}
+	}
+	return 0
+}
+
+func detect_tube(non_wild, wild []Card, level Rank) Combination {
+	total := len(non_wild) + len(wild)
+	if total != 6 {
+		return Combination{Type: Comb_Invalid}
+	}
+
+	for _, card := range non_wild {
+		if card.Rank == Rank_Black_Joker || card.Rank == Rank_Red_Joker {
+			return Combination{Type: Comb_Invalid}
+		}
+	}
+
+	rank_counts := count_ranks(non_wild)
+	wilds_available := len(wild)
+
+	natural_order := []Rank{
+		Rank_Ace, Rank_Two, Rank_Three, Rank_Four, Rank_Five,
+		Rank_Six, Rank_Seven, Rank_Eight, Rank_Nine, Rank_Ten,
+		Rank_Jack, Rank_Queen, Rank_King, Rank_Ace,
+	}
+
+	for start := 0; start <= len(natural_order)-3; start++ {
+		needed := 0
+		valid := true
+		highest := natural_order[start+2]
+
+		for i := 0; i < 3; i++ {
+			rank := natural_order[start+i]
+			count := rank_counts[rank]
+			if count < 2 {
+				needed += 2 - count
+			}
+		}
+
+		if valid && needed <= wilds_available {
+			return Combination{
+				Type:       Comb_Tube,
+				Cards:      append(non_wild, wild...),
+				Rank_Value: straight_value(highest, level),
+			}
+		}
+	}
+
+	return Combination{Type: Comb_Invalid}
+}
+
+func detect_plate(non_wild, wild []Card, level Rank) Combination {
+	total := len(non_wild) + len(wild)
+	if total != 6 {
+		return Combination{Type: Comb_Invalid}
+	}
+
+	for _, card := range non_wild {
+		if card.Rank == Rank_Black_Joker || card.Rank == Rank_Red_Joker {
+			return Combination{Type: Comb_Invalid}
+		}
+	}
+
+	rank_counts := count_ranks(non_wild)
+	wilds_available := len(wild)
+
+	natural_order := []Rank{
+		Rank_Ace, Rank_Two, Rank_Three, Rank_Four, Rank_Five,
+		Rank_Six, Rank_Seven, Rank_Eight, Rank_Nine, Rank_Ten,
+		Rank_Jack, Rank_Queen, Rank_King, Rank_Ace,
+	}
+
+	for start := 0; start <= len(natural_order)-2; start++ {
+		needed := 0
+		highest := natural_order[start+1]
+
+		for i := 0; i < 2; i++ {
+			rank := natural_order[start+i]
+			count := rank_counts[rank]
+			if count < 3 {
+				needed += 3 - count
+			}
+		}
+
+		if needed <= wilds_available {
+			return Combination{
+				Type:       Comb_Plate,
+				Cards:      append(non_wild, wild...),
+				Rank_Value: straight_value(highest, level),
+			}
+		}
+	}
+
+	return Combination{Type: Comb_Invalid}
+}
+
+func count_ranks(cards []Card) map[Rank]int {
+	counts := make(map[Rank]int)
+	for _, c := range cards {
+		counts[c.Rank]++
+	}
+	return counts
+}
+
+func sorted_ranks(counts map[Rank]int, level Rank) []Rank {
+	var ranks []Rank
+	for r := range counts {
+		ranks = append(ranks, r)
+	}
+
+	for i := 0; i < len(ranks)-1; i++ {
+		for j := i + 1; j < len(ranks); j++ {
+			if rank_value(ranks[i], level) < rank_value(ranks[j], level) {
+				ranks[i], ranks[j] = ranks[j], ranks[i]
+			}
+		}
+	}
+
+	return ranks
+}
+
+func Can_Beat(play, lead Combination) bool {
+	if play.Type == Comb_Invalid {
+		return false
+	}
+
+	if play.Type == Comb_Bomb && lead.Type != Comb_Bomb {
+		return true
+	}
+
+	if play.Type == Comb_Bomb && lead.Type == Comb_Bomb {
+		return play.Bomb_Power > lead.Bomb_Power
+	}
+
+	if play.Type != lead.Type {
+		return false
+	}
+
+	if len(play.Cards) != len(lead.Cards) {
+		return false
+	}
+
+	return play.Rank_Value > lead.Rank_Value
+}
diff --git a/server/game/rand.go b/server/game/rand.go
new file mode 100644
index 0000000..3a401db
--- /dev/null
+++ b/server/game/rand.go
@@ -0,0 +1,12 @@
+package game
+
+import (
+	"crypto/rand"
+	"encoding/binary"
+)
+
+func rand_int(max int) int {
+	var b [8]byte
+	rand.Read(b[:])
+	return int(binary.LittleEndian.Uint64(b[:]) % uint64(max))
+}
diff --git a/server/game/state.go b/server/game/state.go
new file mode 100644
index 0000000..6bfb067
--- /dev/null
+++ b/server/game/state.go
@@ -0,0 +1,199 @@
+package game
+
+type Game_Phase int
+
+const (
+	Phase_Waiting Game_Phase = iota
+	Phase_Deal
+	Phase_Play
+	Phase_Tribute
+	Phase_End
+)
+
+type Tribute_Info struct {
+	From_Seat int
+	To_Seat   int
+	Done      bool
+}
+
+type Game_State struct {
+	Phase          Game_Phase
+	Level          Rank
+	Team_Levels    [2]int
+	Hands          [4][]Card
+	Current_Turn   int
+	Current_Lead   Combination
+	Lead_Player    int
+	Pass_Count     int
+	Finish_Order   []int
+	Tributes       []Tribute_Info
+	Tribute_Leader int
+}
+
+func New_Game_State() *Game_State {
+	return &Game_State{
+		Phase:        Phase_Waiting,
+		Level:        Rank_Two,
+		Team_Levels:  [2]int{0, 0},
+		Finish_Order: make([]int, 0, 4),
+	}
+}
+
+func (g *Game_State) Get_Cards_By_Id(seat int, ids []int) []Card {
+	id_set := make(map[int]bool)
+	for _, id := range ids {
+		id_set[id] = true
+	}
+
+	var cards []Card
+	for _, card := range g.Hands[seat] {
+		if id_set[card.Id] {
+			cards = append(cards, card)
+			delete(id_set, card.Id)
+		}
+	}
+
+	if len(id_set) > 0 {
+		return nil
+	}
+
+	return cards
+}
+
+func (g *Game_State) Get_Card_By_Id(seat int, id int) *Card {
+	for i := range g.Hands[seat] {
+		if g.Hands[seat][i].Id == id {
+			return &g.Hands[seat][i]
+		}
+	}
+	return nil
+}
+
+func (g *Game_State) Remove_Cards(seat int, ids []int) {
+	id_set := make(map[int]bool)
+	for _, id := range ids {
+		id_set[id] = true
+	}
+
+	var remaining []Card
+	for _, card := range g.Hands[seat] {
+		if !id_set[card.Id] {
+			remaining = append(remaining, card)
+		}
+	}
+	g.Hands[seat] = remaining
+}
+
+func (g *Game_State) Setup_Tributes() {
+	g.Tributes = nil
+
+	if len(g.Finish_Order) < 2 {
+		return
+	}
+
+	first := g.Finish_Order[0]
+	winning_team := first % 2
+
+	var last_loser int = -1
+	var second_last_loser int = -1
+
+	for i := 3; i >= 0; i-- {
+		seat := -1
+		if i < len(g.Finish_Order) {
+			seat = g.Finish_Order[i]
+		} else {
+			for s := 0; s < 4; s++ {
+				found := false
+				for _, f := range g.Finish_Order {
+					if f == s {
+						found = true
+						break
+					}
+				}
+				if !found {
+					seat = s
+					break
+				}
+			}
+		}
+
+		if seat%2 != winning_team {
+			if last_loser == -1 {
+				last_loser = seat
+			} else if second_last_loser == -1 {
+				second_last_loser = seat
+				break
+			}
+		}
+	}
+
+	first_winner := g.Finish_Order[0]
+	var second_winner int = -1
+	for _, seat := range g.Finish_Order {
+		if seat%2 == winning_team && seat != first_winner {
+			second_winner = seat
+			break
+		}
+	}
+
+	if last_loser != -1 {
+		g.Tributes = append(g.Tributes, Tribute_Info{
+			From_Seat: last_loser,
+			To_Seat:   first_winner,
+		})
+	}
+
+	if g.is_double_win() && second_last_loser != -1 && second_winner != -1 {
+		g.Tributes = append(g.Tributes, Tribute_Info{
+			From_Seat: second_last_loser,
+			To_Seat:   second_winner,
+		})
+	}
+
+	g.Tribute_Leader = first_winner
+}
+
+func (g *Game_State) is_double_win() bool {
+	if len(g.Finish_Order) < 2 {
+		return false
+	}
+	return g.Finish_Order[0]%2 == g.Finish_Order[1]%2
+}
+
+func (g *Game_State) Get_Tribute_Info(seat int) *Tribute_Info {
+	for i := range g.Tributes {
+		if g.Tributes[i].From_Seat == seat && !g.Tributes[i].Done {
+			return &g.Tributes[i]
+		}
+	}
+	return nil
+}
+
+func (g *Game_State) Mark_Tribute_Done(seat int) {
+	for i := range g.Tributes {
+		if g.Tributes[i].From_Seat == seat {
+			g.Tributes[i].Done = true
+			break
+		}
+	}
+}
+
+func (g *Game_State) All_Tributes_Done() bool {
+	for _, t := range g.Tributes {
+		if !t.Done {
+			return false
+		}
+	}
+	return true
+}
+
+func (g *Game_State) Reset_Hand() {
+	g.Current_Lead = Combination{Type: Comb_Invalid}
+	g.Lead_Player = 0
+	g.Pass_Count = 0
+	g.Finish_Order = g.Finish_Order[:0]
+	g.Tributes = nil
+
+	winning_team := g.Tribute_Leader % 2
+	g.Level = Rank(g.Team_Levels[winning_team])
+}
diff --git a/server/go.mod b/server/go.mod
new file mode 100644
index 0000000..193363d
--- /dev/null
+++ b/server/go.mod
@@ -0,0 +1,5 @@
+module guandanbtw
+
+go 1.23
+
+require github.com/gorilla/websocket v1.5.3
diff --git a/server/go.sum b/server/go.sum
new file mode 100644
index 0000000..25a9fc4
--- /dev/null
+++ b/server/go.sum
@@ -0,0 +1,2 @@
+github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg=
+github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
diff --git a/server/main.go b/server/main.go
new file mode 100644
index 0000000..e9a559c
--- /dev/null
+++ b/server/main.go
@@ -0,0 +1,19 @@
+package main
+
+import (
+	"guandanbtw/room"
+	"log"
+	"net/http"
+)
+
+func main() {
+	hub := room.New_Hub()
+	go hub.Run()
+
+	http.HandleFunc("/ws", hub.Handle_Websocket)
+
+	http.Handle("/", http.FileServer(http.Dir("../client/dist")))
+
+	log.Println("server starting on :8080")
+	log.Fatal(http.ListenAndServe(":8080", nil))
+}
diff --git a/server/protocol/messages.go b/server/protocol/messages.go
new file mode 100644
index 0000000..128d338
--- /dev/null
+++ b/server/protocol/messages.go
@@ -0,0 +1,107 @@
+package protocol
+
+import "guandanbtw/game"
+
+type Msg_Type string
+
+const (
+	Msg_Join_Room     Msg_Type = "join_room"
+	Msg_Create_Room   Msg_Type = "create_room"
+	Msg_Room_State    Msg_Type = "room_state"
+	Msg_Game_Start    Msg_Type = "game_start"
+	Msg_Deal_Cards    Msg_Type = "deal_cards"
+	Msg_Play_Cards    Msg_Type = "play_cards"
+	Msg_Pass          Msg_Type = "pass"
+	Msg_Turn          Msg_Type = "turn"
+	Msg_Play_Made     Msg_Type = "play_made"
+	Msg_Hand_End      Msg_Type = "hand_end"
+	Msg_Tribute       Msg_Type = "tribute"
+	Msg_Tribute_Give  Msg_Type = "tribute_give"
+	Msg_Tribute_Recv  Msg_Type = "tribute_recv"
+	Msg_Game_End      Msg_Type = "game_end"
+	Msg_Error         Msg_Type = "error"
+	Msg_Player_Joined Msg_Type = "player_joined"
+	Msg_Player_Left   Msg_Type = "player_left"
+)
+
+type Message struct {
+	Type    Msg_Type    `json:"type"`
+	Payload interface{} `json:"payload"`
+}
+
+type Join_Room_Payload struct {
+	Room_Id     string `json:"room_id"`
+	Player_Name string `json:"player_name"`
+}
+
+type Create_Room_Payload struct {
+	Player_Name string `json:"player_name"`
+}
+
+type Room_State_Payload struct {
+	Room_Id     string        `json:"room_id"`
+	Players     []Player_Info `json:"players"`
+	Game_Active bool          `json:"game_active"`
+	Your_Id     string        `json:"your_id"`
+}
+
+type Player_Info struct {
+	Id       string `json:"id"`
+	Name     string `json:"name"`
+	Seat     int    `json:"seat"`
+	Team     int    `json:"team"`
+	Is_Ready bool   `json:"is_ready"`
+}
+
+type Deal_Cards_Payload struct {
+	Cards []game.Card `json:"cards"`
+	Level game.Rank   `json:"level"`
+}
+
+type Play_Cards_Payload struct {
+	Card_Ids []int `json:"card_ids"`
+}
+
+type Turn_Payload struct {
+	Player_Id       string           `json:"player_id"`
+	Seat            int              `json:"seat"`
+	Lead_Combo_Type game.Combination `json:"lead_combo_type,omitempty"`
+	Can_Pass        bool             `json:"can_pass"`
+}
+
+type Play_Made_Payload struct {
+	Player_Id  string      `json:"player_id"`
+	Seat       int         `json:"seat"`
+	Cards      []game.Card `json:"cards"`
+	Combo_Type string      `json:"combo_type"`
+	Is_Pass    bool        `json:"is_pass"`
+}
+
+type Hand_End_Payload struct {
+	Finish_Order  []string `json:"finish_order"`
+	Winning_Team  int      `json:"winning_team"`
+	Level_Advance int      `json:"level_advance"`
+	New_Levels    [2]int   `json:"new_levels"`
+}
+
+type Tribute_Payload struct {
+	From_Seat int `json:"from_seat"`
+	To_Seat   int `json:"to_seat"`
+}
+
+type Tribute_Give_Payload struct {
+	Card_Id int `json:"card_id"`
+}
+
+type Tribute_Recv_Payload struct {
+	Card game.Card `json:"card"`
+}
+
+type Game_End_Payload struct {
+	Winning_Team int    `json:"winning_team"`
+	Final_Levels [2]int `json:"final_levels"`
+}
+
+type Error_Payload struct {
+	Message string `json:"message"`
+}
diff --git a/server/room/client.go b/server/room/client.go
new file mode 100644
index 0000000..d596fe9
--- /dev/null
+++ b/server/room/client.go
@@ -0,0 +1,198 @@
+package room
+
+import (
+	"encoding/json"
+	"sync"
+	"time"
+
+	"github.com/gorilla/websocket"
+	"guandanbtw/protocol"
+)
+
+const (
+	write_wait       = 10 * time.Second
+	pong_wait        = 60 * time.Second
+	ping_period      = (pong_wait * 9) / 10
+	max_message_size = 4096
+)
+
+type Client struct {
+	id   string
+	name string
+	room *Room
+	conn *websocket.Conn
+	send chan []byte
+	mu   sync.Mutex
+}
+
+func new_client(id string, conn *websocket.Conn) *Client {
+	return &Client{
+		id:   id,
+		conn: conn,
+		send: make(chan []byte, 256),
+	}
+}
+
+func (c *Client) read_pump(hub *Hub) {
+	defer func() {
+		if c.room != nil {
+			c.room.leave <- c
+		}
+		hub.unregister <- c
+		c.conn.Close()
+	}()
+
+	c.conn.SetReadLimit(max_message_size)
+	c.conn.SetReadDeadline(time.Now().Add(pong_wait))
+	c.conn.SetPongHandler(func(string) error {
+		c.conn.SetReadDeadline(time.Now().Add(pong_wait))
+		return nil
+	})
+
+	for {
+		_, data, err := c.conn.ReadMessage()
+		if err != nil {
+			break
+		}
+
+		var msg protocol.Message
+		if err := json.Unmarshal(data, &msg); err != nil {
+			c.send_error("invalid message format")
+			continue
+		}
+
+		c.handle_message(hub, &msg)
+	}
+}
+
+func (c *Client) write_pump() {
+	ticker := time.NewTicker(ping_period)
+	defer func() {
+		ticker.Stop()
+		c.conn.Close()
+	}()
+
+	for {
+		select {
+		case message, ok := <-c.send:
+			c.conn.SetWriteDeadline(time.Now().Add(write_wait))
+			if !ok {
+				c.conn.WriteMessage(websocket.CloseMessage, []byte{})
+				return
+			}
+
+			w, err := c.conn.NextWriter(websocket.TextMessage)
+			if err != nil {
+				return
+			}
+			w.Write(message)
+
+			if err := w.Close(); err != nil {
+				return
+			}
+		case <-ticker.C:
+			c.conn.SetWriteDeadline(time.Now().Add(write_wait))
+			if err := c.conn.WriteMessage(websocket.PingMessage, nil); err != nil {
+				return
+			}
+		}
+	}
+}
+
+func (c *Client) handle_message(hub *Hub, msg *protocol.Message) {
+	switch msg.Type {
+	case protocol.Msg_Create_Room:
+		c.handle_create_room(hub, msg)
+	case protocol.Msg_Join_Room:
+		c.handle_join_room(hub, msg)
+	case protocol.Msg_Play_Cards:
+		c.handle_play_cards(msg)
+	case protocol.Msg_Pass:
+		c.handle_pass()
+	case protocol.Msg_Tribute_Give:
+		c.handle_tribute_give(msg)
+	}
+}
+
+func (c *Client) handle_create_room(hub *Hub, msg *protocol.Message) {
+	payload_bytes, _ := json.Marshal(msg.Payload)
+	var payload protocol.Create_Room_Payload
+	json.Unmarshal(payload_bytes, &payload)
+
+	c.name = payload.Player_Name
+	room := hub.create_room()
+	room.join <- c
+}
+
+func (c *Client) handle_join_room(hub *Hub, msg *protocol.Message) {
+	payload_bytes, _ := json.Marshal(msg.Payload)
+	var payload protocol.Join_Room_Payload
+	json.Unmarshal(payload_bytes, &payload)
+
+	c.name = payload.Player_Name
+	room := hub.get_room(payload.Room_Id)
+	if room == nil {
+		c.send_error("room not found")
+		return
+	}
+	room.join <- c
+}
+
+func (c *Client) handle_play_cards(msg *protocol.Message) {
+	if c.room == nil {
+		return
+	}
+
+	payload_bytes, _ := json.Marshal(msg.Payload)
+	var payload protocol.Play_Cards_Payload
+	json.Unmarshal(payload_bytes, &payload)
+
+	c.room.play <- Play_Action{
+		client:   c,
+		card_ids: payload.Card_Ids,
+	}
+}
+
+func (c *Client) handle_pass() {
+	if c.room == nil {
+		return
+	}
+
+	c.room.pass <- c
+}
+
+func (c *Client) handle_tribute_give(msg *protocol.Message) {
+	if c.room == nil {
+		return
+	}
+
+	payload_bytes, _ := json.Marshal(msg.Payload)
+	var payload protocol.Tribute_Give_Payload
+	json.Unmarshal(payload_bytes, &payload)
+
+	c.room.tribute <- Tribute_Action{
+		client:  c,
+		card_id: payload.Card_Id,
+	}
+}
+
+func (c *Client) send_message(msg *protocol.Message) {
+	data, err := json.Marshal(msg)
+	if err != nil {
+		return
+	}
+
+	select {
+	case c.send <- data:
+	default:
+	}
+}
+
+func (c *Client) send_error(message string) {
+	c.send_message(&protocol.Message{
+		Type: protocol.Msg_Error,
+		Payload: protocol.Error_Payload{
+			Message: message,
+		},
+	})
+}
diff --git a/server/room/hub.go b/server/room/hub.go
new file mode 100644
index 0000000..6e3c803
--- /dev/null
+++ b/server/room/hub.go
@@ -0,0 +1,96 @@
+package room
+
+import (
+	"crypto/rand"
+	"encoding/hex"
+	"github.com/gorilla/websocket"
+	"net/http"
+	"sync"
+)
+
+type Hub struct {
+	rooms      map[string]*Room
+	register   chan *Client
+	unregister chan *Client
+	mu         sync.RWMutex
+}
+
+var upgrader = websocket.Upgrader{
+	ReadBufferSize:  1024,
+	WriteBufferSize: 1024,
+	CheckOrigin: func(r *http.Request) bool {
+		return true
+	},
+}
+
+func New_Hub() *Hub {
+	return &Hub{
+		rooms:      make(map[string]*Room),
+		register:   make(chan *Client),
+		unregister: make(chan *Client),
+	}
+}
+
+func (h *Hub) Run() {
+	for {
+		select {
+		case client := <-h.register:
+			_ = client
+		case client := <-h.unregister:
+			if client.room != nil {
+				client.room.leave <- client
+			}
+		}
+	}
+}
+
+func (h *Hub) Handle_Websocket(w http.ResponseWriter, r *http.Request) {
+	conn, err := upgrader.Upgrade(w, r, nil)
+	if err != nil {
+		return
+	}
+
+	client := new_client(generate_id(), conn)
+
+	h.register <- client
+
+	go client.write_pump()
+	go client.read_pump(h)
+}
+
+func (h *Hub) create_room() *Room {
+	h.mu.Lock()
+	defer h.mu.Unlock()
+
+	room := new_room(generate_room_code())
+	h.rooms[room.id] = room
+	go room.run()
+
+	return room
+}
+
+func (h *Hub) get_room(id string) *Room {
+	h.mu.RLock()
+	defer h.mu.RUnlock()
+
+	return h.rooms[id]
+}
+
+func (h *Hub) delete_room(id string) {
+	h.mu.Lock()
+	defer h.mu.Unlock()
+
+	delete(h.rooms, id)
+}
+
+func generate_id() string {
+	b := make([]byte, 16)
+	rand.Read(b)
+	return hex.EncodeToString(b)
+}
+
+func generate_room_code() string {
+	b := make([]byte, 3)
+	rand.Read(b)
+	return hex.EncodeToString(b)
+}
diff --git a/server/room/room.go b/server/room/room.go
new file mode 100644
index 0000000..69e370a
--- /dev/null
+++ b/server/room/room.go
@@ -0,0 +1,526 @@
+package room
+
+import (
+	"guandanbtw/game"
+	"guandanbtw/protocol"
+)
+
+type Play_Action struct {
+	client   *Client
+	card_ids []int
+}
+
+type Tribute_Action struct {
+	client  *Client
+	card_id int
+}
+
+type Room struct {
+	id      string
+	clients [4]*Client
+	game    *game.Game_State
+	join    chan *Client
+	leave   chan *Client
+	play    chan Play_Action
+	pass    chan *Client
+	tribute chan Tribute_Action
+}
+
+func new_room(id string) *Room {
+	return &Room{
+		id:      id,
+		join:    make(chan *Client),
+		leave:   make(chan *Client),
+		play:    make(chan Play_Action),
+		pass:    make(chan *Client),
+		tribute: make(chan Tribute_Action),
+	}
+}
+
+func (r *Room) run() {
+	for {
+		select {
+		case client := <-r.join:
+			r.handle_join(client)
+		case client := <-r.leave:
+			r.handle_leave(client)
+		case action := <-r.play:
+			r.handle_play(action)
+		case client := <-r.pass:
+			r.handle_pass(client)
+		case action := <-r.tribute:
+			r.handle_tribute(action)
+		}
+	}
+}
+
+func (r *Room) handle_join(client *Client) {
+	seat := r.find_empty_seat()
+	if seat == -1 {
+		client.send_error("room is full")
+		return
+	}
+
+	r.clients[seat] = client
+	client.room = r
+
+	r.broadcast_room_state()
+
+	if r.is_full() {
+		r.start_game()
+	}
+}
+
+func (r *Room) handle_leave(client *Client) {
+	for i := range 4 {
+		if r.clients[i] == client {
+			r.clients[i] = nil
+			break
+		}
+	}
+	client.room = nil
+
+	r.broadcast(&protocol.Message{
+		Type: protocol.Msg_Player_Left,
+		Payload: protocol.Player_Info{
+			Id:   client.id,
+			Name: client.name,
+		},
+	})
+
+	r.broadcast_room_state()
+}
+
+func (r *Room) handle_play(action Play_Action) {
+	if r.game == nil {
+		return
+	}
+
+	seat := r.get_seat(action.client)
+	if seat == -1 || seat != r.game.Current_Turn {
+		action.client.send_error("not your turn")
+		return
+	}
+
+	cards := r.game.Get_Cards_By_Id(seat, action.card_ids)
+	if cards == nil {
+		action.client.send_error("invalid cards")
+		return
+	}
+
+	combo := game.Detect_Combination(cards, r.game.Level)
+	if combo.Type == game.Comb_Invalid {
+		action.client.send_error("invalid combination")
+		return
+	}
+
+	if r.game.Current_Lead.Type != game.Comb_Invalid {
+		if !game.Can_Beat(combo, r.game.Current_Lead) {
+			action.client.send_error("cannot beat current play")
+			return
+		}
+	}
+
+	r.game.Remove_Cards(seat, action.card_ids)
+	r.game.Current_Lead = combo
+	r.game.Lead_Player = seat
+	r.game.Pass_Count = 0
+
+	r.broadcast(&protocol.Message{
+		Type: protocol.Msg_Play_Made,
+		Payload: protocol.Play_Made_Payload{
+			Player_Id:  action.client.id,
+			Seat:       seat,
+			Cards:      cards,
+			Combo_Type: combo_type_name(combo.Type),
+			Is_Pass:    false,
+		},
+	})
+
+	if len(r.game.Hands[seat]) == 0 {
+		r.game.Finish_Order = append(r.game.Finish_Order, seat)
+		if r.check_hand_end() {
+			return
+		}
+	}
+
+	r.advance_turn()
+}
+
+func (r *Room) handle_pass(client *Client) {
+	if r.game == nil {
+		return
+	}
+
+	seat := r.get_seat(client)
+	if seat == -1 || seat != r.game.Current_Turn {
+		client.send_error("not your turn")
+		return
+	}
+
+	if r.game.Current_Lead.Type == game.Comb_Invalid {
+		client.send_error("cannot pass when leading")
+		return
+	}
+
+	r.game.Pass_Count++
+
+	r.broadcast(&protocol.Message{
+		Type: protocol.Msg_Play_Made,
+		Payload: protocol.Play_Made_Payload{
+			Player_Id: client.id,
+			Seat:      seat,
+			Is_Pass:   true,
+		},
+	})
+
+	if r.game.Pass_Count >= 3 {
+		r.game.Current_Lead = game.Combination{Type: game.Comb_Invalid}
+		r.game.Current_Turn = r.game.Lead_Player
+		r.game.Pass_Count = 0
+		r.send_turn_notification()
+		return
+	}
+
+	r.advance_turn()
+}
+
+func (r *Room) handle_tribute(action Tribute_Action) {
+	if r.game == nil || r.game.Phase != game.Phase_Tribute {
+		return
+	}
+
+	seat := r.get_seat(action.client)
+	tribute_info := r.game.Get_Tribute_Info(seat)
+	if tribute_info == nil {
+		action.client.send_error("you don't need to give tribute")
+		return
+	}
+
+	card := r.game.Get_Card_By_Id(seat, action.card_id)
+	if card == nil {
+		action.client.send_error("invalid card")
+		return
+	}
+
+	if game.Is_Wild(*card, r.game.Level) {
+		action.client.send_error("cannot tribute wild cards")
+		return
+	}
+
+	r.game.Remove_Cards(seat, []int{action.card_id})
+	r.game.Hands[tribute_info.To_Seat] = append(r.game.Hands[tribute_info.To_Seat], *card)
+
+	if r.clients[tribute_info.To_Seat] != nil {
+		r.clients[tribute_info.To_Seat].send_message(&protocol.Message{
+			Type: protocol.Msg_Tribute_Recv,
+			Payload: protocol.Tribute_Recv_Payload{
+				Card: *card,
+			},
+		})
+	}
+
+	r.game.Mark_Tribute_Done(seat)
+
+	if r.game.All_Tributes_Done() {
+		r.game.Phase = game.Phase_Play
+		r.game.Current_Turn = r.game.Tribute_Leader
+		r.send_turn_notification()
+	}
+}
+
+func (r *Room) start_game() {
+	r.game = game.New_Game_State()
+
+	deck := game.New_Deck()
+	deck.Shuffle()
+	hands := deck.Deal()
+
+	for i := 0; i < 4; i++ {
+		r.game.Hands[i] = hands[i]
+	}
+
+	for i := 0; i < 4; i++ {
+		if r.clients[i] != nil {
+			r.clients[i].send_message(&protocol.Message{
+				Type: protocol.Msg_Deal_Cards,
+				Payload: protocol.Deal_Cards_Payload{
+					Cards: r.game.Hands[i],
+					Level: r.game.Level,
+				},
+			})
+		}
+	}
+
+	r.game.Phase = game.Phase_Play
+	r.game.Current_Turn = 0
+	r.send_turn_notification()
+}
+
+func (r *Room) check_hand_end() bool {
+	if len(r.game.Finish_Order) < 2 {
+		return false
+	}
+
+	first := r.game.Finish_Order[0]
+	second := r.game.Finish_Order[1]
+
+	first_team := first % 2
+	second_team := second % 2
+
+	if first_team == second_team {
+		r.end_hand(first_team, r.calculate_level_advance())
+		return true
+	}
+
+	if len(r.game.Finish_Order) >= 3 {
+		third := r.game.Finish_Order[2]
+		third_team := third % 2
+
+		winning_team := first_team
+		r.end_hand(winning_team, r.calculate_level_advance())
+		return true
+		_ = third_team
+	}
+
+	return false
+}
+
+func (r *Room) calculate_level_advance() int {
+	if len(r.game.Finish_Order) < 2 {
+		return 0
+	}
+
+	first := r.game.Finish_Order[0]
+	first_team := first % 2
+
+	for i, seat := range r.game.Finish_Order {
+		if seat%2 != first_team {
+			partner_pos := -1
+			for j, s := range r.game.Finish_Order {
+				if s%2 == first_team && j != 0 {
+					partner_pos = j
+					break
+				}
+			}
+
+			if partner_pos == -1 {
+				partner_pos = 3
+			}
+
+			switch {
+			case i == 3 && partner_pos == 1:
+				return 4
+			case i == 2 && partner_pos == 1:
+				return 2
+			default:
+				return 1
+			}
+		}
+	}
+
+	return 4
+}
+
+func (r *Room) end_hand(winning_team int, level_advance int) {
+	old_level := r.game.Team_Levels[winning_team]
+	new_level := old_level + level_advance
+	if new_level > 12 {
+		new_level = 12
+	}
+	r.game.Team_Levels[winning_team] = new_level
+
+	r.broadcast(&protocol.Message{
+		Type: protocol.Msg_Hand_End,
+		Payload: protocol.Hand_End_Payload{
+			Finish_Order:  seats_to_ids(r.game.Finish_Order, r.clients),
+			Winning_Team:  winning_team,
+			Level_Advance: level_advance,
+			New_Levels:    r.game.Team_Levels,
+		},
+	})
+
+	if new_level >= 12 && game.Rank(old_level) == game.Rank_Ace {
+		r.broadcast(&protocol.Message{
+			Type: protocol.Msg_Game_End,
+			Payload: protocol.Game_End_Payload{
+				Winning_Team: winning_team,
+				Final_Levels: r.game.Team_Levels,
+			},
+		})
+		return
+	}
+
+	r.setup_tribute()
+}
+
+func (r *Room) setup_tribute() {
+	r.game.Setup_Tributes()
+
+	if len(r.game.Tributes) == 0 {
+		r.start_new_hand()
+		return
+	}
+
+	for _, t := range r.game.Tributes {
+		if r.clients[t.From_Seat] != nil {
+			r.clients[t.From_Seat].send_message(&protocol.Message{
+				Type: protocol.Msg_Tribute,
+				Payload: protocol.Tribute_Payload{
+					From_Seat: t.From_Seat,
+					To_Seat:   t.To_Seat,
+				},
+			})
+		}
+	}
+
+	r.game.Phase = game.Phase_Tribute
+}
+
+func (r *Room) start_new_hand() {
+	r.game.Reset_Hand()
+
+	deck := game.New_Deck()
+	deck.Shuffle()
+	hands := deck.Deal()
+
+	for i := 0; i < 4; i++ {
+		r.game.Hands[i] = hands[i]
+	}
+
+	for i := 0; i < 4; i++ {
+		if r.clients[i] != nil {
+			r.clients[i].send_message(&protocol.Message{
+				Type: protocol.Msg_Deal_Cards,
+				Payload: protocol.Deal_Cards_Payload{
+					Cards: r.game.Hands[i],
+					Level: r.game.Level,
+				},
+			})
+		}
+	}
+
+	r.game.Phase = game.Phase_Play
+	r.game.Current_Turn = r.game.Tribute_Leader
+	r.send_turn_notification()
+}
+
+func (r *Room) advance_turn() {
+	for i := 1; i <= 4; i++ {
+		next := (r.game.Current_Turn + i) % 4
+		if !r.is_finished(next) {
+			r.game.Current_Turn = next
+			r.send_turn_notification()
+			return
+		}
+	}
+}
+
+func (r *Room) is_finished(seat int) bool {
+	for _, s := range r.game.Finish_Order {
+		if s == seat {
+			return true
+		}
+	}
+	return false
+}
+
+func (r *Room) send_turn_notification() {
+	can_pass := r.game.Current_Lead.Type != game.Comb_Invalid
+
+	r.broadcast(&protocol.Message{
+		Type: protocol.Msg_Turn,
+		Payload: protocol.Turn_Payload{
+			Player_Id: r.clients[r.game.Current_Turn].id,
+			Seat:      r.game.Current_Turn,
+			Can_Pass:  can_pass,
+		},
+	})
+}
+
+func (r *Room) broadcast(msg *protocol.Message) {
+	for _, client := range r.clients {
+		if client != nil {
+			client.send_message(msg)
+		}
+	}
+}
+
+func (r *Room) broadcast_room_state() {
+	players := make([]protocol.Player_Info, 0)
+	for i, c := range r.clients {
+		if c != nil {
+			players = append(players, protocol.Player_Info{
+				Id:   c.id,
+				Name: c.name,
+				Seat: i,
+				Team: i % 2,
+			})
+		}
+	}
+
+	for _, client := range r.clients {
+		if client != nil {
+			client.send_message(&protocol.Message{
+				Type: protocol.Msg_Room_State,
+				Payload: protocol.Room_State_Payload{
+					Room_Id:     r.id,
+					Players:     players,
+					Game_Active: r.game != nil,
+					Your_Id:     client.id,
+				},
+			})
+		}
+	}
+}
+
+func (r *Room) find_empty_seat() int {
+	for i := 0; i < 4; i++ {
+		if r.clients[i] == nil {
+			return i
+		}
+	}
+	return -1
+}
+
+func (r *Room) is_full() bool {
+	for _, c := range r.clients {
+		if c == nil {
+			return false
+		}
+	}
+	return true
+}
+
+func (r *Room) get_seat(client *Client) int {
+	for i, c := range r.clients {
+		if c == client {
+			return i
+		}
+	}
+	return -1
+}
+
+func combo_type_name(t game.Combination_Type) string {
+	names := map[game.Combination_Type]string{
+		game.Comb_Single:     "single",
+		game.Comb_Pair:       "pair",
+		game.Comb_Triple:     "triple",
+		game.Comb_Full_House: "full_house",
+		game.Comb_Straight:   "straight",
+		game.Comb_Tube:       "tube",
+		game.Comb_Plate:      "plate",
+		game.Comb_Bomb:       "bomb",
+	}
+	return names[t]
+}
+
+func seats_to_ids(seats []int, clients [4]*Client) []string {
+	ids := make([]string, len(seats))
+	for i, seat := range seats {
+		if clients[seat] != nil {
+			ids[i] = clients[seat].id
+		}
+	}
+	return ids
+}