summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--.gitignore147
-rw-r--r--bun.lock130
-rw-r--r--index.html12
-rw-r--r--package.json16
-rwxr-xr-xserver/cyberjumpbin0 -> 9442714 bytes
-rw-r--r--server/go.mod7
-rw-r--r--server/go.sum4
-rw-r--r--server/main.go263
-rw-r--r--src/game/AudioEngine.ts100
-rw-r--r--src/game/CyberJumpApp.ts888
-rw-r--r--src/game/content.ts123
-rw-r--r--src/main.ts11
-rw-r--r--src/network/NetworkClient.ts180
-rw-r--r--src/rendering/shaders/world.frag57
-rw-r--r--src/rendering/shaders/world.vert8
-rw-r--r--src/styles.css200
-rw-r--r--src/vite-env.d.ts1
-rw-r--r--tsconfig.json20
-rw-r--r--vite.config.ts10
19 files changed, 2177 insertions, 0 deletions
diff --git a/.gitignore b/.gitignore
new file mode 100644
index 0000000..08748cc
--- /dev/null
+++ b/.gitignore
@@ -0,0 +1,147 @@
+# Created by https://gitignore.org
+# Node.gitignore
+
+# Logs
+logs
+*.log
+npm-debug.log*
+yarn-debug.log*
+yarn-error.log*
+lerna-debug.log*
+
+# Diagnostic reports (https://nodejs.org/api/report.html)
+report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json
+
+# Runtime data
+pids
+*.pid
+*.seed
+*.pid.lock
+
+# Directory for instrumented libs generated by jscoverage/JSCover
+lib-cov
+
+# Coverage directory used by tools like istanbul
+coverage
+*.lcov
+
+# nyc test coverage
+.nyc_output
+
+# Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files)
+.grunt
+
+# Bower dependency directory (https://bower.io/)
+bower_components
+
+# node-waf configuration
+.lock-wscript
+
+# Compiled binary addons (https://nodejs.org/api/addons.html)
+build/Release
+
+# Dependency directories
+node_modules/
+jspm_packages/
+
+# Snowpack dependency directory (https://snowpack.dev/)
+web_modules/
+
+# TypeScript cache
+*.tsbuildinfo
+
+# Optional npm cache directory
+.npm
+
+# Optional eslint cache
+.eslintcache
+
+# Optional stylelint cache
+.stylelintcache
+
+# Optional REPL history
+.node_repl_history
+
+# Output of 'npm pack'
+*.tgz
+
+# Yarn Integrity file
+.yarn-integrity
+
+# dotenv environment variable files
+.env
+.env.*
+!.env.example
+
+# parcel-bundler cache (https://parceljs.org/)
+.cache
+.parcel-cache
+
+# Next.js build output
+.next
+out
+
+# Nuxt.js build / generate output
+.nuxt
+dist
+.output
+
+# Gatsby files
+.cache/
+# Comment in the public line in if your project uses Gatsby and not Next.js
+# https://nextjs.org/blog/next-9-1#public-directory-support
+# public
+
+# vuepress build output
+.vuepress/dist
+
+# vuepress v2.x temp directory
+.temp
+
+# Sveltekit cache directory
+.svelte-kit/
+
+# vitepress build output
+**/.vitepress/dist
+
+# vitepress cache directory
+**/.vitepress/cache
+
+# Docusaurus cache and generated files
+.docusaurus
+
+# Serverless directories
+.serverless/
+
+# FuseBox cache
+.fusebox/
+
+# DynamoDB Local files
+.dynamodb/
+
+# Firebase cache directory
+.firebase/
+
+# TernJS port file
+.tern-port
+
+# Stores VSCode versions used for testing VSCode extensions
+.vscode-test
+
+# pnpm
+.pnpm-store
+
+# yarn v3
+.pnp.*
+.yarn/*
+!.yarn/patches
+!.yarn/plugins
+!.yarn/releases
+!.yarn/sdks
+!.yarn/versions
+
+# Vite files
+vite.config.js.timestamp-*
+vite.config.ts.timestamp-*
+.vite/
+
diff --git a/bun.lock b/bun.lock
new file mode 100644
index 0000000..c42f922
--- /dev/null
+++ b/bun.lock
@@ -0,0 +1,130 @@
+{
+ "lockfileVersion": 1,
+ "configVersion": 1,
+ "workspaces": {
+ "": {
+ "name": "cyberjump",
+ "devDependencies": {
+ "typescript": "^5.4.0",
+ "vite": "^5.2.0",
+ },
+ },
+ },
+ "packages": {
+ "@esbuild/aix-ppc64": ["@esbuild/aix-ppc64@0.21.5", "", { "os": "aix", "cpu": "ppc64" }, "sha512-1SDgH6ZSPTlggy1yI6+Dbkiz8xzpHJEVAlF/AM1tHPLsf5STom9rwtjE4hKAF20FfXXNTFqEYXyJNWh1GiZedQ=="],
+
+ "@esbuild/android-arm": ["@esbuild/android-arm@0.21.5", "", { "os": "android", "cpu": "arm" }, "sha512-vCPvzSjpPHEi1siZdlvAlsPxXl7WbOVUBBAowWug4rJHb68Ox8KualB+1ocNvT5fjv6wpkX6o/iEpbDrf68zcg=="],
+
+ "@esbuild/android-arm64": ["@esbuild/android-arm64@0.21.5", "", { "os": "android", "cpu": "arm64" }, "sha512-c0uX9VAUBQ7dTDCjq+wdyGLowMdtR/GoC2U5IYk/7D1H1JYC0qseD7+11iMP2mRLN9RcCMRcjC4YMclCzGwS/A=="],
+
+ "@esbuild/android-x64": ["@esbuild/android-x64@0.21.5", "", { "os": "android", "cpu": "x64" }, "sha512-D7aPRUUNHRBwHxzxRvp856rjUHRFW1SdQATKXH2hqA0kAZb1hKmi02OpYRacl0TxIGz/ZmXWlbZgjwWYaCakTA=="],
+
+ "@esbuild/darwin-arm64": ["@esbuild/darwin-arm64@0.21.5", "", { "os": "darwin", "cpu": "arm64" }, "sha512-DwqXqZyuk5AiWWf3UfLiRDJ5EDd49zg6O9wclZ7kUMv2WRFr4HKjXp/5t8JZ11QbQfUS6/cRCKGwYhtNAY88kQ=="],
+
+ "@esbuild/darwin-x64": ["@esbuild/darwin-x64@0.21.5", "", { "os": "darwin", "cpu": "x64" }, "sha512-se/JjF8NlmKVG4kNIuyWMV/22ZaerB+qaSi5MdrXtd6R08kvs2qCN4C09miupktDitvh8jRFflwGFBQcxZRjbw=="],
+
+ "@esbuild/freebsd-arm64": ["@esbuild/freebsd-arm64@0.21.5", "", { "os": "freebsd", "cpu": "arm64" }, "sha512-5JcRxxRDUJLX8JXp/wcBCy3pENnCgBR9bN6JsY4OmhfUtIHe3ZW0mawA7+RDAcMLrMIZaf03NlQiX9DGyB8h4g=="],
+
+ "@esbuild/freebsd-x64": ["@esbuild/freebsd-x64@0.21.5", "", { "os": "freebsd", "cpu": "x64" }, "sha512-J95kNBj1zkbMXtHVH29bBriQygMXqoVQOQYA+ISs0/2l3T9/kj42ow2mpqerRBxDJnmkUDCaQT/dfNXWX/ZZCQ=="],
+
+ "@esbuild/linux-arm": ["@esbuild/linux-arm@0.21.5", "", { "os": "linux", "cpu": "arm" }, "sha512-bPb5AHZtbeNGjCKVZ9UGqGwo8EUu4cLq68E95A53KlxAPRmUyYv2D6F0uUI65XisGOL1hBP5mTronbgo+0bFcA=="],
+
+ "@esbuild/linux-arm64": ["@esbuild/linux-arm64@0.21.5", "", { "os": "linux", "cpu": "arm64" }, "sha512-ibKvmyYzKsBeX8d8I7MH/TMfWDXBF3db4qM6sy+7re0YXya+K1cem3on9XgdT2EQGMu4hQyZhan7TeQ8XkGp4Q=="],
+
+ "@esbuild/linux-ia32": ["@esbuild/linux-ia32@0.21.5", "", { "os": "linux", "cpu": "ia32" }, "sha512-YvjXDqLRqPDl2dvRODYmmhz4rPeVKYvppfGYKSNGdyZkA01046pLWyRKKI3ax8fbJoK5QbxblURkwK/MWY18Tg=="],
+
+ "@esbuild/linux-loong64": ["@esbuild/linux-loong64@0.21.5", "", { "os": "linux", "cpu": "none" }, "sha512-uHf1BmMG8qEvzdrzAqg2SIG/02+4/DHB6a9Kbya0XDvwDEKCoC8ZRWI5JJvNdUjtciBGFQ5PuBlpEOXQj+JQSg=="],
+
+ "@esbuild/linux-mips64el": ["@esbuild/linux-mips64el@0.21.5", "", { "os": "linux", "cpu": "none" }, "sha512-IajOmO+KJK23bj52dFSNCMsz1QP1DqM6cwLUv3W1QwyxkyIWecfafnI555fvSGqEKwjMXVLokcV5ygHW5b3Jbg=="],
+
+ "@esbuild/linux-ppc64": ["@esbuild/linux-ppc64@0.21.5", "", { "os": "linux", "cpu": "ppc64" }, "sha512-1hHV/Z4OEfMwpLO8rp7CvlhBDnjsC3CttJXIhBi+5Aj5r+MBvy4egg7wCbe//hSsT+RvDAG7s81tAvpL2XAE4w=="],
+
+ "@esbuild/linux-riscv64": ["@esbuild/linux-riscv64@0.21.5", "", { "os": "linux", "cpu": "none" }, "sha512-2HdXDMd9GMgTGrPWnJzP2ALSokE/0O5HhTUvWIbD3YdjME8JwvSCnNGBnTThKGEB91OZhzrJ4qIIxk/SBmyDDA=="],
+
+ "@esbuild/linux-s390x": ["@esbuild/linux-s390x@0.21.5", "", { "os": "linux", "cpu": "s390x" }, "sha512-zus5sxzqBJD3eXxwvjN1yQkRepANgxE9lgOW2qLnmr8ikMTphkjgXu1HR01K4FJg8h1kEEDAqDcZQtbrRnB41A=="],
+
+ "@esbuild/linux-x64": ["@esbuild/linux-x64@0.21.5", "", { "os": "linux", "cpu": "x64" }, "sha512-1rYdTpyv03iycF1+BhzrzQJCdOuAOtaqHTWJZCWvijKD2N5Xu0TtVC8/+1faWqcP9iBCWOmjmhoH94dH82BxPQ=="],
+
+ "@esbuild/netbsd-x64": ["@esbuild/netbsd-x64@0.21.5", "", { "os": "none", "cpu": "x64" }, "sha512-Woi2MXzXjMULccIwMnLciyZH4nCIMpWQAs049KEeMvOcNADVxo0UBIQPfSmxB3CWKedngg7sWZdLvLczpe0tLg=="],
+
+ "@esbuild/openbsd-x64": ["@esbuild/openbsd-x64@0.21.5", "", { "os": "openbsd", "cpu": "x64" }, "sha512-HLNNw99xsvx12lFBUwoT8EVCsSvRNDVxNpjZ7bPn947b8gJPzeHWyNVhFsaerc0n3TsbOINvRP2byTZ5LKezow=="],
+
+ "@esbuild/sunos-x64": ["@esbuild/sunos-x64@0.21.5", "", { "os": "sunos", "cpu": "x64" }, "sha512-6+gjmFpfy0BHU5Tpptkuh8+uw3mnrvgs+dSPQXQOv3ekbordwnzTVEb4qnIvQcYXq6gzkyTnoZ9dZG+D4garKg=="],
+
+ "@esbuild/win32-arm64": ["@esbuild/win32-arm64@0.21.5", "", { "os": "win32", "cpu": "arm64" }, "sha512-Z0gOTd75VvXqyq7nsl93zwahcTROgqvuAcYDUr+vOv8uHhNSKROyU961kgtCD1e95IqPKSQKH7tBTslnS3tA8A=="],
+
+ "@esbuild/win32-ia32": ["@esbuild/win32-ia32@0.21.5", "", { "os": "win32", "cpu": "ia32" }, "sha512-SWXFF1CL2RVNMaVs+BBClwtfZSvDgtL//G/smwAc5oVK/UPu2Gu9tIaRgFmYFFKrmg3SyAjSrElf0TiJ1v8fYA=="],
+
+ "@esbuild/win32-x64": ["@esbuild/win32-x64@0.21.5", "", { "os": "win32", "cpu": "x64" }, "sha512-tQd/1efJuzPC6rCFwEvLtci/xNFcTZknmXs98FYDfGE4wP9ClFV98nyKrzJKVPMhdDnjzLhdUyMX4PsQAPjwIw=="],
+
+ "@rollup/rollup-android-arm-eabi": ["@rollup/rollup-android-arm-eabi@4.60.1", "", { "os": "android", "cpu": "arm" }, "sha512-d6FinEBLdIiK+1uACUttJKfgZREXrF0Qc2SmLII7W2AD8FfiZ9Wjd+rD/iRuf5s5dWrr1GgwXCvPqOuDquOowA=="],
+
+ "@rollup/rollup-android-arm64": ["@rollup/rollup-android-arm64@4.60.1", "", { "os": "android", "cpu": "arm64" }, "sha512-YjG/EwIDvvYI1YvYbHvDz/BYHtkY4ygUIXHnTdLhG+hKIQFBiosfWiACWortsKPKU/+dUwQQCKQM3qrDe8c9BA=="],
+
+ "@rollup/rollup-darwin-arm64": ["@rollup/rollup-darwin-arm64@4.60.1", "", { "os": "darwin", "cpu": "arm64" }, "sha512-mjCpF7GmkRtSJwon+Rq1N8+pI+8l7w5g9Z3vWj4T7abguC4Czwi3Yu/pFaLvA3TTeMVjnu3ctigusqWUfjZzvw=="],
+
+ "@rollup/rollup-darwin-x64": ["@rollup/rollup-darwin-x64@4.60.1", "", { "os": "darwin", "cpu": "x64" }, "sha512-haZ7hJ1JT4e9hqkoT9R/19XW2QKqjfJVv+i5AGg57S+nLk9lQnJ1F/eZloRO3o9Scy9CM3wQ9l+dkXtcBgN5Ew=="],
+
+ "@rollup/rollup-freebsd-arm64": ["@rollup/rollup-freebsd-arm64@4.60.1", "", { "os": "freebsd", "cpu": "arm64" }, "sha512-czw90wpQq3ZsAVBlinZjAYTKduOjTywlG7fEeWKUA7oCmpA8xdTkxZZlwNJKWqILlq0wehoZcJYfBvOyhPTQ6w=="],
+
+ "@rollup/rollup-freebsd-x64": ["@rollup/rollup-freebsd-x64@4.60.1", "", { "os": "freebsd", "cpu": "x64" }, "sha512-KVB2rqsxTHuBtfOeySEyzEOB7ltlB/ux38iu2rBQzkjbwRVlkhAGIEDiiYnO2kFOkJp+Z7pUXKyrRRFuFUKt+g=="],
+
+ "@rollup/rollup-linux-arm-gnueabihf": ["@rollup/rollup-linux-arm-gnueabihf@4.60.1", "", { "os": "linux", "cpu": "arm" }, "sha512-L+34Qqil+v5uC0zEubW7uByo78WOCIrBvci69E7sFASRl0X7b/MB6Cqd1lky/CtcSVTydWa2WZwFuWexjS5o6g=="],
+
+ "@rollup/rollup-linux-arm-musleabihf": ["@rollup/rollup-linux-arm-musleabihf@4.60.1", "", { "os": "linux", "cpu": "arm" }, "sha512-n83O8rt4v34hgFzlkb1ycniJh7IR5RCIqt6mz1VRJD6pmhRi0CXdmfnLu9dIUS6buzh60IvACM842Ffb3xd6Gg=="],
+
+ "@rollup/rollup-linux-arm64-gnu": ["@rollup/rollup-linux-arm64-gnu@4.60.1", "", { "os": "linux", "cpu": "arm64" }, "sha512-Nql7sTeAzhTAja3QXeAI48+/+GjBJ+QmAH13snn0AJSNL50JsDqotyudHyMbO2RbJkskbMbFJfIJKWA6R1LCJQ=="],
+
+ "@rollup/rollup-linux-arm64-musl": ["@rollup/rollup-linux-arm64-musl@4.60.1", "", { "os": "linux", "cpu": "arm64" }, "sha512-+pUymDhd0ys9GcKZPPWlFiZ67sTWV5UU6zOJat02M1+PiuSGDziyRuI/pPue3hoUwm2uGfxdL+trT6Z9rxnlMA=="],
+
+ "@rollup/rollup-linux-loong64-gnu": ["@rollup/rollup-linux-loong64-gnu@4.60.1", "", { "os": "linux", "cpu": "none" }, "sha512-VSvgvQeIcsEvY4bKDHEDWcpW4Yw7BtlKG1GUT4FzBUlEKQK0rWHYBqQt6Fm2taXS+1bXvJT6kICu5ZwqKCnvlQ=="],
+
+ "@rollup/rollup-linux-loong64-musl": ["@rollup/rollup-linux-loong64-musl@4.60.1", "", { "os": "linux", "cpu": "none" }, "sha512-4LqhUomJqwe641gsPp6xLfhqWMbQV04KtPp7/dIp0nzPxAkNY1AbwL5W0MQpcalLYk07vaW9Kp1PBhdpZYYcEw=="],
+
+ "@rollup/rollup-linux-ppc64-gnu": ["@rollup/rollup-linux-ppc64-gnu@4.60.1", "", { "os": "linux", "cpu": "ppc64" }, "sha512-tLQQ9aPvkBxOc/EUT6j3pyeMD6Hb8QF2BTBnCQWP/uu1lhc9AIrIjKnLYMEroIz/JvtGYgI9dF3AxHZNaEH0rw=="],
+
+ "@rollup/rollup-linux-ppc64-musl": ["@rollup/rollup-linux-ppc64-musl@4.60.1", "", { "os": "linux", "cpu": "ppc64" }, "sha512-RMxFhJwc9fSXP6PqmAz4cbv3kAyvD1etJFjTx4ONqFP9DkTkXsAMU4v3Vyc5BgzC+anz7nS/9tp4obsKfqkDHg=="],
+
+ "@rollup/rollup-linux-riscv64-gnu": ["@rollup/rollup-linux-riscv64-gnu@4.60.1", "", { "os": "linux", "cpu": "none" }, "sha512-QKgFl+Yc1eEk6MmOBfRHYF6lTxiiiV3/z/BRrbSiW2I7AFTXoBFvdMEyglohPj//2mZS4hDOqeB0H1ACh3sBbg=="],
+
+ "@rollup/rollup-linux-riscv64-musl": ["@rollup/rollup-linux-riscv64-musl@4.60.1", "", { "os": "linux", "cpu": "none" }, "sha512-RAjXjP/8c6ZtzatZcA1RaQr6O1TRhzC+adn8YZDnChliZHviqIjmvFwHcxi4JKPSDAt6Uhf/7vqcBzQJy0PDJg=="],
+
+ "@rollup/rollup-linux-s390x-gnu": ["@rollup/rollup-linux-s390x-gnu@4.60.1", "", { "os": "linux", "cpu": "s390x" }, "sha512-wcuocpaOlaL1COBYiA89O6yfjlp3RwKDeTIA0hM7OpmhR1Bjo9j31G1uQVpDlTvwxGn2nQs65fBFL5UFd76FcQ=="],
+
+ "@rollup/rollup-linux-x64-gnu": ["@rollup/rollup-linux-x64-gnu@4.60.1", "", { "os": "linux", "cpu": "x64" }, "sha512-77PpsFQUCOiZR9+LQEFg9GClyfkNXj1MP6wRnzYs0EeWbPcHs02AXu4xuUbM1zhwn3wqaizle3AEYg5aeoohhg=="],
+
+ "@rollup/rollup-linux-x64-musl": ["@rollup/rollup-linux-x64-musl@4.60.1", "", { "os": "linux", "cpu": "x64" }, "sha512-5cIATbk5vynAjqqmyBjlciMJl1+R/CwX9oLk/EyiFXDWd95KpHdrOJT//rnUl4cUcskrd0jCCw3wpZnhIHdD9w=="],
+
+ "@rollup/rollup-openbsd-x64": ["@rollup/rollup-openbsd-x64@4.60.1", "", { "os": "openbsd", "cpu": "x64" }, "sha512-cl0w09WsCi17mcmWqqglez9Gk8isgeWvoUZ3WiJFYSR3zjBQc2J5/ihSjpl+VLjPqjQ/1hJRcqBfLjssREQILw=="],
+
+ "@rollup/rollup-openharmony-arm64": ["@rollup/rollup-openharmony-arm64@4.60.1", "", { "os": "none", "cpu": "arm64" }, "sha512-4Cv23ZrONRbNtbZa37mLSueXUCtN7MXccChtKpUnQNgF010rjrjfHx3QxkS2PI7LqGT5xXyYs1a7LbzAwT0iCA=="],
+
+ "@rollup/rollup-win32-arm64-msvc": ["@rollup/rollup-win32-arm64-msvc@4.60.1", "", { "os": "win32", "cpu": "arm64" }, "sha512-i1okWYkA4FJICtr7KpYzFpRTHgy5jdDbZiWfvny21iIKky5YExiDXP+zbXzm3dUcFpkEeYNHgQ5fuG236JPq0g=="],
+
+ "@rollup/rollup-win32-ia32-msvc": ["@rollup/rollup-win32-ia32-msvc@4.60.1", "", { "os": "win32", "cpu": "ia32" }, "sha512-u09m3CuwLzShA0EYKMNiFgcjjzwqtUMLmuCJLeZWjjOYA3IT2Di09KaxGBTP9xVztWyIWjVdsB2E9goMjZvTQg=="],
+
+ "@rollup/rollup-win32-x64-gnu": ["@rollup/rollup-win32-x64-gnu@4.60.1", "", { "os": "win32", "cpu": "x64" }, "sha512-k+600V9Zl1CM7eZxJgMyTUzmrmhB/0XZnF4pRypKAlAgxmedUA+1v9R+XOFv56W4SlHEzfeMtzujLJD22Uz5zg=="],
+
+ "@rollup/rollup-win32-x64-msvc": ["@rollup/rollup-win32-x64-msvc@4.60.1", "", { "os": "win32", "cpu": "x64" }, "sha512-lWMnixq/QzxyhTV6NjQJ4SFo1J6PvOX8vUx5Wb4bBPsEb+8xZ89Bz6kOXpfXj9ak9AHTQVQzlgzBEc1SyM27xQ=="],
+
+ "@types/estree": ["@types/estree@1.0.8", "", {}, "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w=="],
+
+ "esbuild": ["esbuild@0.21.5", "", { "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" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-mg3OPMV4hXywwpoDxu3Qda5xCKQi+vCTZq8S9J/EpkhB2HzKXq4SNFZE3+NK93JYxc8VMSep+lOUSC/RVKaBqw=="],
+
+ "fsevents": ["fsevents@2.3.3", "", { "os": "darwin" }, "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw=="],
+
+ "nanoid": ["nanoid@3.3.11", "", { "bin": { "nanoid": "bin/nanoid.cjs" } }, "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w=="],
+
+ "picocolors": ["picocolors@1.1.1", "", {}, "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA=="],
+
+ "postcss": ["postcss@8.5.9", "", { "dependencies": { "nanoid": "^3.3.11", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" } }, "sha512-7a70Nsot+EMX9fFU3064K/kdHWZqGVY+BADLyXc8Dfv+mTLLVl6JzJpPaCZ2kQL9gIJvKXSLMHhqdRRjwQeFtw=="],
+
+ "rollup": ["rollup@4.60.1", "", { "dependencies": { "@types/estree": "1.0.8" }, "optionalDependencies": { "@rollup/rollup-android-arm-eabi": "4.60.1", "@rollup/rollup-android-arm64": "4.60.1", "@rollup/rollup-darwin-arm64": "4.60.1", "@rollup/rollup-darwin-x64": "4.60.1", "@rollup/rollup-freebsd-arm64": "4.60.1", "@rollup/rollup-freebsd-x64": "4.60.1", "@rollup/rollup-linux-arm-gnueabihf": "4.60.1", "@rollup/rollup-linux-arm-musleabihf": "4.60.1", "@rollup/rollup-linux-arm64-gnu": "4.60.1", "@rollup/rollup-linux-arm64-musl": "4.60.1", "@rollup/rollup-linux-loong64-gnu": "4.60.1", "@rollup/rollup-linux-loong64-musl": "4.60.1", "@rollup/rollup-linux-ppc64-gnu": "4.60.1", "@rollup/rollup-linux-ppc64-musl": "4.60.1", "@rollup/rollup-linux-riscv64-gnu": "4.60.1", "@rollup/rollup-linux-riscv64-musl": "4.60.1", "@rollup/rollup-linux-s390x-gnu": "4.60.1", "@rollup/rollup-linux-x64-gnu": "4.60.1", "@rollup/rollup-linux-x64-musl": "4.60.1", "@rollup/rollup-openbsd-x64": "4.60.1", "@rollup/rollup-openharmony-arm64": "4.60.1", "@rollup/rollup-win32-arm64-msvc": "4.60.1", "@rollup/rollup-win32-ia32-msvc": "4.60.1", "@rollup/rollup-win32-x64-gnu": "4.60.1", "@rollup/rollup-win32-x64-msvc": "4.60.1", "fsevents": "~2.3.2" }, "bin": { "rollup": "dist/bin/rollup" } }, "sha512-VmtB2rFU/GroZ4oL8+ZqXgSA38O6GR8KSIvWmEFv63pQ0G6KaBH9s07PO8XTXP4vI+3UJUEypOfjkGfmSBBR0w=="],
+
+ "source-map-js": ["source-map-js@1.2.1", "", {}, "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA=="],
+
+ "typescript": ["typescript@5.9.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw=="],
+
+ "vite": ["vite@5.4.21", "", { "dependencies": { "esbuild": "^0.21.3", "postcss": "^8.4.43", "rollup": "^4.20.0" }, "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" }, "optionalPeers": ["@types/node", "less", "lightningcss", "sass", "sass-embedded", "stylus", "sugarss", "terser"], "bin": { "vite": "bin/vite.js" } }, "sha512-o5a9xKjbtuhY6Bi5S3+HvbRERmouabWbyUcpXXUA1u+GNUKoROi9byOJ8M0nHbHYHkYICiMlqxkg1KkYmm25Sw=="],
+ }
+}
diff --git a/index.html b/index.html
new file mode 100644
index 0000000..cdb8d8e
--- /dev/null
+++ b/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>CyberJump</title>
+</head>
+<body>
+ <div id="app"></div>
+ <script type="module" src="/src/main.ts"></script>
+</body>
+</html>
diff --git a/package.json b/package.json
new file mode 100644
index 0000000..ed888de
--- /dev/null
+++ b/package.json
@@ -0,0 +1,16 @@
+{
+ "name": "cyberjump",
+ "version": "1.0.0",
+ "type": "module",
+ "scripts": {
+ "dev": "vite",
+ "build": "tsc && vite build",
+ "preview": "vite preview",
+ "server": "cd server && go run ."
+ },
+ "dependencies": {},
+ "devDependencies": {
+ "typescript": "^5.4.0",
+ "vite": "^5.2.0"
+ }
+}
diff --git a/server/cyberjump b/server/cyberjump
new file mode 100755
index 0000000..00d4ec1
--- /dev/null
+++ b/server/cyberjump
Binary files differ
diff --git a/server/go.mod b/server/go.mod
new file mode 100644
index 0000000..5ef24cc
--- /dev/null
+++ b/server/go.mod
@@ -0,0 +1,7 @@
+module cyberjump
+
+go 1.21
+
+require github.com/gorilla/websocket v1.5.1
+
+require golang.org/x/net v0.17.0 // indirect
diff --git a/server/go.sum b/server/go.sum
new file mode 100644
index 0000000..272772f
--- /dev/null
+++ b/server/go.sum
@@ -0,0 +1,4 @@
+github.com/gorilla/websocket v1.5.1 h1:gmztn0JnHVt9JZquRuzLw3g4wouNVzKL15iLr/zn/QY=
+github.com/gorilla/websocket v1.5.1/go.mod h1:x3kM2JMyaluk02fnUJpQuwD2dCS5NDG2ZHL0uE0tcaY=
+golang.org/x/net v0.17.0 h1:pVaXccu2ozPjCXewfr1S7xza/zcXTity9cCdXQYSjIM=
+golang.org/x/net v0.17.0/go.mod h1:NxSsAGuq816PNPmqtQdLE42eU2Fs7NoRIZrHJAlaCOE=
diff --git a/server/main.go b/server/main.go
new file mode 100644
index 0000000..cd31356
--- /dev/null
+++ b/server/main.go
@@ -0,0 +1,263 @@
+package main
+
+import (
+ "encoding/json"
+ "flag"
+ "fmt"
+ "log"
+ "net/http"
+ "sync"
+ "sync/atomic"
+ "time"
+
+ "github.com/gorilla/websocket"
+)
+
+type playerState struct {
+ ID string `json:"id"`
+ X float64 `json:"x"`
+ Y float64 `json:"y"`
+ VX float64 `json:"vx"`
+ VY float64 `json:"vy"`
+ Height float64 `json:"height"`
+ Layer string `json:"layer"`
+ Cycle int `json:"cycle"`
+ UpdatedAt int64 `json:"updatedAt"`
+}
+
+type stateMessage struct {
+ Type string `json:"type"`
+ X float64 `json:"x"`
+ Y float64 `json:"y"`
+ VX float64 `json:"vx"`
+ VY float64 `json:"vy"`
+ Height float64 `json:"height"`
+ Layer string `json:"layer"`
+ Cycle int `json:"cycle"`
+}
+
+type welcomeMessage struct {
+ Type string `json:"type"`
+ ID string `json:"id"`
+}
+
+type snapshotMessage struct {
+ Type string `json:"type"`
+ Players []playerState `json:"players"`
+}
+
+type client struct {
+ id string
+ conn *websocket.Conn
+ hub *hub
+ mu sync.Mutex
+}
+
+type hub struct {
+ mu sync.RWMutex
+ clients map[string]*client
+ states map[string]playerState
+ ids atomic.Uint64
+}
+
+var upgrader = websocket.Upgrader{
+ ReadBufferSize: 1024,
+ WriteBufferSize: 1024,
+ CheckOrigin: func(r *http.Request) bool {
+ return true
+ },
+}
+
+func newHub() *hub {
+ return &hub{
+ clients: make(map[string]*client),
+ states: make(map[string]playerState),
+ }
+}
+
+func (h *hub) addClient(conn *websocket.Conn) *client {
+ id := fmt.Sprintf("ghost-%04d", h.ids.Add(1))
+ client := &client{id: id, conn: conn, hub: h}
+
+ h.mu.Lock()
+ h.clients[id] = client
+ h.mu.Unlock()
+
+ return client
+}
+
+func (h *hub) removeClient(id string) {
+ h.mu.Lock()
+ delete(h.clients, id)
+ delete(h.states, id)
+ h.mu.Unlock()
+ h.broadcastSnapshot()
+}
+
+func (h *hub) updateState(id string, message stateMessage) {
+ h.mu.Lock()
+ h.states[id] = playerState{
+ ID: id,
+ X: message.X,
+ Y: message.Y,
+ VX: message.VX,
+ VY: message.VY,
+ Height: message.Height,
+ Layer: message.Layer,
+ Cycle: message.Cycle,
+ UpdatedAt: time.Now().UnixMilli(),
+ }
+ h.mu.Unlock()
+ h.broadcastSnapshot()
+}
+
+func (h *hub) snapshot() []playerState {
+ h.mu.RLock()
+ defer h.mu.RUnlock()
+
+ players := make([]playerState, 0, len(h.states))
+ for _, state := range h.states {
+ players = append(players, state)
+ }
+ return players
+}
+
+func (h *hub) clientCount() int {
+ h.mu.RLock()
+ defer h.mu.RUnlock()
+ return len(h.clients)
+}
+
+func (h *hub) broadcastSnapshot() {
+ payload, err := json.Marshal(snapshotMessage{Type: "snapshot", Players: h.snapshot()})
+ if err != nil {
+ log.Printf("snapshot marshal error: %v", err)
+ return
+ }
+
+ h.mu.RLock()
+ clients := make([]*client, 0, len(h.clients))
+ for _, connected := range h.clients {
+ clients = append(clients, connected)
+ }
+ h.mu.RUnlock()
+
+ for _, connected := range clients {
+ connected.writeJSON(payload)
+ }
+}
+
+func (c *client) writeJSON(payload []byte) {
+ c.mu.Lock()
+ defer c.mu.Unlock()
+ if err := c.conn.WriteMessage(websocket.TextMessage, payload); err != nil {
+ log.Printf("write error for %s: %v", c.id, err)
+ }
+}
+
+func (c *client) sendWelcome() error {
+ payload, err := json.Marshal(welcomeMessage{Type: "welcome", ID: c.id})
+ if err != nil {
+ return err
+ }
+ c.writeJSON(payload)
+ return nil
+}
+
+func (c *client) readLoop() {
+ defer func() {
+ c.hub.removeClient(c.id)
+ _ = c.conn.Close()
+ }()
+
+ if err := c.sendWelcome(); err != nil {
+ log.Printf("welcome error for %s: %v", c.id, err)
+ return
+ }
+
+ for {
+ _, payload, err := c.conn.ReadMessage()
+ if err != nil {
+ if websocket.IsUnexpectedCloseError(err, websocket.CloseGoingAway, websocket.CloseAbnormalClosure) {
+ log.Printf("read error for %s: %v", c.id, err)
+ }
+ return
+ }
+
+ var message stateMessage
+ if err := json.Unmarshal(payload, &message); err != nil {
+ continue
+ }
+
+ if message.Type != "state" {
+ continue
+ }
+
+ c.hub.updateState(c.id, message)
+ }
+}
+
+func main() {
+ addr := flag.String("addr", ":8080", "HTTP service address")
+ flag.Parse()
+
+ h := newHub()
+ mux := http.NewServeMux()
+
+ mux.HandleFunc("/ws", func(w http.ResponseWriter, r *http.Request) {
+ conn, err := upgrader.Upgrade(w, r, nil)
+ if err != nil {
+ log.Printf("websocket upgrade error: %v", err)
+ return
+ }
+
+ client := h.addClient(conn)
+ go client.readLoop()
+ })
+
+ mux.HandleFunc("/ping", func(w http.ResponseWriter, _ *http.Request) {
+ withCORS(w)
+ w.WriteHeader(http.StatusOK)
+ _, _ = w.Write([]byte("pong"))
+ })
+
+ mux.HandleFunc("/status", func(w http.ResponseWriter, _ *http.Request) {
+ withCORS(w)
+ w.Header().Set("Content-Type", "application/json")
+ _ = json.NewEncoder(w).Encode(map[string]any{
+ "status": "online",
+ "players": h.clientCount(),
+ })
+ })
+
+ mux.HandleFunc("/servers", func(w http.ResponseWriter, r *http.Request) {
+ withCORS(w)
+ w.Header().Set("Content-Type", "application/json")
+
+ host := r.Host
+ if host == "" {
+ host = "localhost:8080"
+ }
+
+ scheme := "ws"
+ if r.TLS != nil {
+ scheme = "wss"
+ }
+
+ _ = json.NewEncoder(w).Encode(map[string]any{
+ "servers": []map[string]string{{
+ "url": fmt.Sprintf("%s://%s/ws", scheme, host),
+ "region": "local",
+ }},
+ })
+ })
+
+ log.Printf("CyberJump relay listening on %s", *addr)
+ if err := http.ListenAndServe(*addr, mux); err != nil {
+ log.Fatal(err)
+ }
+}
+
+func withCORS(w http.ResponseWriter) {
+ w.Header().Set("Access-Control-Allow-Origin", "*")
+}
diff --git a/src/game/AudioEngine.ts b/src/game/AudioEngine.ts
new file mode 100644
index 0000000..1a6f907
--- /dev/null
+++ b/src/game/AudioEngine.ts
@@ -0,0 +1,100 @@
+type AudioContextCtor = typeof AudioContext;
+
+export class AudioEngine {
+ private context?: AudioContext;
+ private master?: GainNode;
+ private drone?: OscillatorNode;
+ private shimmer?: OscillatorNode;
+ private active = false;
+
+ activate(): void {
+ if (this.active) {
+ void this.context?.resume();
+ return;
+ }
+
+ const ctor = window.AudioContext ?? (window as Window & { webkitAudioContext?: AudioContextCtor }).webkitAudioContext;
+ if (!ctor) {
+ return;
+ }
+
+ this.context = new ctor();
+ this.master = this.context.createGain();
+ this.master.gain.value = 0.07;
+ this.master.connect(this.context.destination);
+
+ this.drone = this.context.createOscillator();
+ this.drone.type = 'sawtooth';
+ this.drone.frequency.value = 82;
+ const droneGain = this.context.createGain();
+ droneGain.gain.value = 0.18;
+ this.drone.connect(droneGain);
+ droneGain.connect(this.master);
+ this.drone.start();
+
+ this.shimmer = this.context.createOscillator();
+ this.shimmer.type = 'triangle';
+ this.shimmer.frequency.value = 164;
+ const shimmerGain = this.context.createGain();
+ shimmerGain.gain.value = 0.08;
+ this.shimmer.connect(shimmerGain);
+ shimmerGain.connect(this.master);
+ this.shimmer.start();
+
+ this.active = true;
+ }
+
+ setMood(fidelity: number, cycle: number, layerIndex: number): void {
+ if (!this.context || !this.master || !this.drone || !this.shimmer) {
+ return;
+ }
+
+ const now = this.context.currentTime;
+ const base = 82 + cycle * 8 + layerIndex * 3;
+ this.drone.frequency.linearRampToValueAtTime(base + fidelity * 18, now + 0.18);
+ this.shimmer.frequency.linearRampToValueAtTime(base * 2 + (1 - fidelity) * 70, now + 0.22);
+ this.master.gain.linearRampToValueAtTime(0.045 + fidelity * 0.04, now + 0.25);
+ }
+
+ pulseJump(boost = false): void {
+ if (!this.context || !this.master) {
+ return;
+ }
+
+ const osc = this.context.createOscillator();
+ const gain = this.context.createGain();
+ osc.type = boost ? 'square' : 'sine';
+ osc.frequency.value = boost ? 420 : 260;
+ gain.gain.value = 0.0001;
+ osc.connect(gain);
+ gain.connect(this.master);
+
+ const now = this.context.currentTime;
+ gain.gain.exponentialRampToValueAtTime(0.06, now + 0.01);
+ gain.gain.exponentialRampToValueAtTime(0.0001, now + 0.24);
+ osc.frequency.exponentialRampToValueAtTime(boost ? 170 : 120, now + 0.24);
+ osc.start(now);
+ osc.stop(now + 0.26);
+ }
+
+ pulseSnap(): void {
+ if (!this.context || !this.master) {
+ return;
+ }
+
+ const osc = this.context.createOscillator();
+ const gain = this.context.createGain();
+ osc.type = 'triangle';
+ osc.frequency.value = 680;
+ gain.gain.value = 0.0001;
+ osc.connect(gain);
+ gain.connect(this.master);
+
+ const now = this.context.currentTime;
+ gain.gain.exponentialRampToValueAtTime(0.08, now + 0.01);
+ gain.gain.exponentialRampToValueAtTime(0.0001, now + 0.45);
+ osc.frequency.exponentialRampToValueAtTime(240, now + 0.45);
+ osc.start(now);
+ osc.stop(now + 0.5);
+ }
+}
diff --git a/src/game/CyberJumpApp.ts b/src/game/CyberJumpApp.ts
new file mode 100644
index 0000000..db69b08
--- /dev/null
+++ b/src/game/CyberJumpApp.ts
@@ -0,0 +1,888 @@
+import { AudioEngine } from './AudioEngine';
+import { CYCLE_HEIGHT, LAYERS, PALETTES, THOUGHT_LINES, WORLD_WIDTH, type LayerDescriptor, type Palette } from './content';
+import { NetworkClient, type GhostState, type NetworkStatus } from '../network/NetworkClient';
+
+type PlatformKind = 'stable' | 'drift' | 'boost' | 'fragile';
+
+interface Platform {
+ id: number;
+ kind: PlatformKind;
+ x: number;
+ y: number;
+ width: number;
+ height: number;
+ baseX: number;
+ drift: number;
+ phase: number;
+ broken: boolean;
+}
+
+interface Player {
+ x: number;
+ y: number;
+ vx: number;
+ vy: number;
+ width: number;
+ height: number;
+}
+
+interface HudRefs {
+ height: HTMLSpanElement;
+ layer: HTMLSpanElement;
+ fidelity: HTMLSpanElement;
+ ghosts: HTMLSpanElement;
+ palette: HTMLSpanElement;
+ thought: HTMLParagraphElement;
+ subtle: HTMLParagraphElement;
+ status: HTMLDivElement;
+ gameOver: HTMLDivElement;
+}
+
+const GRAVITY = -37;
+const MOVE_SPEED = 11;
+const AIR_ACCEL = 26;
+const BASE_JUMP = 15.8;
+const BOOST_JUMP = 22.5;
+const PLATFORM_HEIGHT = 0.36;
+const MIN_PLATFORM_STEP = 1.7;
+const PLATFORM_STEP_PADDING = 0.38;
+const HORIZONTAL_REACH_FACTOR = 0.68;
+const HORIZONTAL_REACH_BUFFER = 0.72;
+const VIEW_BUFFER = 30;
+const PLAYER_TRAIL = 8;
+
+export class CyberJumpApp {
+ private readonly root: HTMLDivElement;
+ private readonly canvas: HTMLCanvasElement;
+ private readonly context: CanvasRenderingContext2D;
+ private readonly hud: HudRefs;
+ private readonly audio = new AudioEngine();
+ private readonly network: NetworkClient;
+ private readonly input = { left: false, right: false };
+
+ private player: Player = { x: 0, y: 0, vx: 0, vy: 0, width: 0.9, height: 1.2 };
+ private platforms: Platform[] = [];
+ private ghosts: GhostState[] = [];
+ private networkStatus: NetworkStatus = { label: 'network', detail: 'offline', peers: 0 };
+ private trail: Array<{ x: number; y: number }> = [];
+
+ private lastTime = 0;
+ private devicePixelRatio = Math.max(1, window.devicePixelRatio || 1);
+ private cameraBottom = -4;
+ private highestY = 0;
+ private nextPlatformY = 0;
+ private nextPlatformId = 1;
+ private cycle = 0;
+ private thoughtTimer = 0;
+ private thoughtIndex = 0;
+ private gameOver = false;
+
+ constructor(root: HTMLDivElement) {
+ this.root = root;
+ this.root.innerHTML = this.buildMarkup();
+
+ this.canvas = this.query<HTMLCanvasElement>('[data-role="canvas"]');
+ const context = this.canvas.getContext('2d');
+ if (!context) {
+ throw new Error('Canvas 2D context is unavailable.');
+ }
+ this.context = context;
+
+ this.hud = {
+ height: this.query<HTMLSpanElement>('[data-role="height"]'),
+ layer: this.query<HTMLSpanElement>('[data-role="layer"]'),
+ fidelity: this.query<HTMLSpanElement>('[data-role="fidelity"]'),
+ ghosts: this.query<HTMLSpanElement>('[data-role="ghosts"]'),
+ palette: this.query<HTMLSpanElement>('[data-role="palette"]'),
+ thought: this.query<HTMLParagraphElement>('[data-role="thought"]'),
+ subtle: this.query<HTMLParagraphElement>('[data-role="subtle"]'),
+ status: this.query<HTMLDivElement>('[data-role="status"]'),
+ gameOver: this.query<HTMLDivElement>('[data-role="game-over"]')
+ };
+
+ this.network = new NetworkClient(
+ (players) => {
+ this.ghosts = players;
+ },
+ (status) => {
+ this.networkStatus = status;
+ }
+ );
+
+ this.attachEvents();
+ this.resize();
+ this.reset();
+ this.network.start();
+ }
+
+ start(): void {
+ window.requestAnimationFrame(this.loop);
+ }
+
+ private readonly loop = (timestamp: number): void => {
+ if (!this.lastTime) {
+ this.lastTime = timestamp;
+ }
+
+ const deltaSeconds = Math.min(0.033, (timestamp - this.lastTime) / 1000);
+ this.lastTime = timestamp;
+
+ this.update(deltaSeconds, timestamp / 1000);
+ this.render(timestamp / 1000);
+
+ window.requestAnimationFrame(this.loop);
+ };
+
+ private attachEvents(): void {
+ window.addEventListener('resize', this.resize);
+ window.addEventListener('pointerdown', this.activateAudio, { passive: true });
+ window.addEventListener('keydown', this.handleKeyDown);
+ window.addEventListener('keyup', this.handleKeyUp);
+ }
+
+ private readonly resize = (): void => {
+ this.devicePixelRatio = Math.max(1, window.devicePixelRatio || 1);
+ const width = Math.floor(window.innerWidth * this.devicePixelRatio);
+ const height = Math.floor(window.innerHeight * this.devicePixelRatio);
+ this.canvas.width = width;
+ this.canvas.height = height;
+ };
+
+ private readonly activateAudio = (): void => {
+ this.audio.activate();
+ };
+
+ private readonly handleKeyDown = (event: KeyboardEvent): void => {
+ if (event.code === 'ArrowLeft' || event.code === 'KeyA') {
+ this.input.left = true;
+ this.activateAudio();
+ }
+
+ if (event.code === 'ArrowRight' || event.code === 'KeyD') {
+ this.input.right = true;
+ this.activateAudio();
+ }
+
+ if ((event.code === 'Space' || event.code === 'ArrowUp' || event.code === 'KeyW') && this.gameOver) {
+ this.reset();
+ this.activateAudio();
+ }
+ };
+
+ private readonly handleKeyUp = (event: KeyboardEvent): void => {
+ if (event.code === 'ArrowLeft' || event.code === 'KeyA') {
+ this.input.left = false;
+ }
+
+ if (event.code === 'ArrowRight' || event.code === 'KeyD') {
+ this.input.right = false;
+ }
+ };
+
+ private reset(): void {
+ this.player = { x: 0, y: 1.2, vx: 0, vy: 0, width: 0.9, height: 1.2 };
+ this.platforms = [];
+ this.ghosts = [];
+ this.trail = [];
+ this.highestY = 1.2;
+ this.cameraBottom = -4;
+ this.nextPlatformY = 0;
+ this.nextPlatformId = 1;
+ this.cycle = 0;
+ this.thoughtTimer = 1;
+ this.thoughtIndex = 0;
+ this.gameOver = false;
+
+ this.platforms.push({
+ id: this.nextPlatformId++,
+ kind: 'stable',
+ x: 0,
+ y: -0.1,
+ width: 4.4,
+ height: PLATFORM_HEIGHT,
+ baseX: 0,
+ drift: 0,
+ phase: 0,
+ broken: false
+ });
+
+ this.nextPlatformY = 2.8;
+ this.generatePlatforms(this.cameraBottom + VIEW_BUFFER);
+ this.setThought('Signal reacquired. Ascend until the world sheds another layer.');
+ this.hud.gameOver.classList.remove('is-visible');
+ }
+
+ private update(deltaSeconds: number, elapsedSeconds: number): void {
+ const height = Math.max(0, this.highestY);
+ const layer = this.getLayer(height);
+ const fidelity = this.getFidelity(height);
+ const cycle = Math.floor(height / CYCLE_HEIGHT);
+
+ this.audio.setMood(fidelity, cycle, layer.index);
+ this.updateThoughts(deltaSeconds, cycle, layer);
+
+ if (this.gameOver) {
+ this.syncHud(height, layer, fidelity);
+ return;
+ }
+
+ const moveAxis = (this.input.right ? 1 : 0) - (this.input.left ? 1 : 0);
+ const targetVelocity = moveAxis * MOVE_SPEED;
+ this.player.vx = lerp(this.player.vx, targetVelocity, Math.min(1, AIR_ACCEL * deltaSeconds));
+ this.player.vy += GRAVITY * deltaSeconds;
+
+ const previousY = this.player.y;
+ this.player.x += this.player.vx * deltaSeconds;
+ this.player.y += this.player.vy * deltaSeconds;
+
+ const wrapLimit = WORLD_WIDTH * 0.5 + 1.2;
+ if (this.player.x < -wrapLimit) {
+ this.player.x = wrapLimit;
+ } else if (this.player.x > wrapLimit) {
+ this.player.x = -wrapLimit;
+ }
+
+ this.updatePlatforms(elapsedSeconds);
+ this.resolveCollisions(previousY);
+
+ this.highestY = Math.max(this.highestY, this.player.y);
+ this.cameraBottom = Math.max(this.cameraBottom, this.player.y - 6.2);
+ this.generatePlatforms(this.cameraBottom + VIEW_BUFFER);
+ this.platforms = this.platforms.filter((platform) => platform.y > this.cameraBottom - 8 || !platform.broken);
+
+ if (cycle > this.cycle) {
+ this.cycle = cycle;
+ this.audio.pulseSnap();
+ this.setThought(`Cycle ${cycle + 1} initialized. ${layer.descriptor.caption}`);
+ }
+
+ if (this.player.y < this.cameraBottom - 5.5) {
+ this.gameOver = true;
+ this.hud.gameOver.classList.add('is-visible');
+ this.setThought('You dropped out of the visible stack. Space rebinds your trajectory.');
+ }
+
+ this.pushTrail();
+ this.network.updateState({
+ x: this.player.x,
+ y: this.player.y,
+ vx: this.player.vx,
+ vy: this.player.vy,
+ height: this.highestY,
+ layer: layer.descriptor.name,
+ cycle
+ });
+
+ this.syncHud(height, layer, fidelity);
+ }
+
+ private updatePlatforms(elapsedSeconds: number): void {
+ for (const platform of this.platforms) {
+ if (platform.kind !== 'drift' || platform.broken) {
+ continue;
+ }
+
+ platform.x = platform.baseX + Math.sin(elapsedSeconds * 1.15 + platform.phase) * platform.drift;
+ }
+ }
+
+ private resolveCollisions(previousY: number): void {
+ if (this.player.vy >= 0) {
+ return;
+ }
+
+ const previousBottom = previousY - this.player.height * 0.5;
+ const currentBottom = this.player.y - this.player.height * 0.5;
+
+ for (const platform of this.platforms) {
+ if (platform.broken) {
+ continue;
+ }
+
+ const platformTop = platform.y + platform.height * 0.5;
+ const overlapX = Math.abs(this.player.x - platform.x) <= platform.width * 0.5 + this.player.width * 0.38;
+ const crossedTop = previousBottom >= platformTop && currentBottom <= platformTop;
+
+ if (!overlapX || !crossedTop) {
+ continue;
+ }
+
+ this.player.y = platformTop + this.player.height * 0.5;
+ this.player.vy = platform.kind === 'boost' ? BOOST_JUMP : BASE_JUMP;
+ if (platform.kind === 'fragile') {
+ platform.broken = true;
+ }
+ this.audio.pulseJump(platform.kind === 'boost');
+ return;
+ }
+ }
+
+ private generatePlatforms(targetY: number): void {
+ while (this.nextPlatformY < targetY) {
+ const previousPlatform = this.platforms[this.platforms.length - 1];
+ const difficulty = clamp(this.nextPlatformY / 3600, 0, 1);
+ const step = this.choosePlatformStep(previousPlatform, difficulty);
+ const kind = this.choosePlatformKind(difficulty);
+ const width = this.getPlatformWidth(kind, difficulty);
+ const y = previousPlatform.y + step;
+ const baseX = this.choosePlatformX(previousPlatform, width, step, difficulty);
+
+ this.platforms.push({
+ id: this.nextPlatformId++,
+ kind,
+ x: baseX,
+ y,
+ width,
+ height: PLATFORM_HEIGHT,
+ baseX,
+ drift: kind === 'drift' ? 0.9 + difficulty * 1.2 + Math.random() * 0.9 : 0,
+ phase: Math.random() * Math.PI * 2,
+ broken: false
+ });
+
+ this.nextPlatformY = y;
+ }
+ }
+
+ private choosePlatformKind(difficulty: number): PlatformKind {
+ const roll = Math.random();
+ const stableWeight = 0.73 - difficulty * 0.25;
+ const driftWeight = 0.15 + difficulty * 0.12;
+ const fragileWeight = 0.07 + difficulty * 0.1;
+
+ if (roll < stableWeight) {
+ return 'stable';
+ }
+
+ if (roll < stableWeight + driftWeight) {
+ return 'drift';
+ }
+
+ if (roll < stableWeight + driftWeight + fragileWeight) {
+ return 'fragile';
+ }
+
+ return 'boost';
+ }
+
+ private getPlatformWidth(kind: PlatformKind, difficulty: number): number {
+ switch (kind) {
+ case 'boost':
+ return clamp(1.95 - difficulty * 0.3, 1.5, 1.95);
+ case 'drift':
+ return clamp(2.3 - difficulty * 0.75, 1.35, 2.3);
+ case 'fragile':
+ return clamp(2.05 - difficulty * 0.8, 1.2, 2.05);
+ case 'stable':
+ default:
+ return clamp(2.6 - difficulty * 1.0, 1.35, 2.6);
+ }
+ }
+
+ private choosePlatformStep(previousPlatform: Platform, difficulty: number): number {
+ const maxStep = this.getReachableStepCap(previousPlatform);
+ const minStep = clamp(MIN_PLATFORM_STEP + difficulty * 0.35, 1.45, maxStep - 0.32);
+ const pressure = clamp(0.34 + difficulty * 0.34 + Math.random() * 0.24, 0.2, 0.96);
+
+ return lerp(minStep, maxStep, pressure);
+ }
+
+ private choosePlatformX(previousPlatform: Platform, width: number, step: number, difficulty: number): number {
+ const span = WORLD_WIDTH * 0.5 - width * 0.6;
+ const offsetCap = Math.min(this.getReachableOffsetCap(previousPlatform, step, width), span * 2);
+ const lowChallenge = Math.min(offsetCap * (0.12 + difficulty * 0.3), Math.max(0.24, offsetCap - 0.18));
+
+ let targetX = previousPlatform.x;
+ if (Math.random() < 0.18) {
+ const relaxedOffset = (Math.random() * 2 - 1) * Math.min(0.9, offsetCap);
+ targetX += relaxedOffset;
+ } else {
+ const direction = Math.random() < 0.5 ? -1 : 1;
+ const offset = lerp(lowChallenge, offsetCap, 0.3 + difficulty * 0.28 + Math.random() * 0.42);
+ targetX += direction * offset;
+ }
+
+ if (targetX < -span || targetX > span) {
+ targetX = previousPlatform.x - (targetX - previousPlatform.x) * 0.72;
+ }
+
+ return clamp(targetX, -span, span);
+ }
+
+ private getReachableStepCap(platform: Platform): number {
+ const jumpVelocity = platform.kind === 'boost' ? BOOST_JUMP : BASE_JUMP;
+ const apexRise = (jumpVelocity * jumpVelocity) / (2 * -GRAVITY);
+ const safeStep = apexRise - PLATFORM_HEIGHT - PLATFORM_STEP_PADDING;
+
+ return clamp(safeStep, 1.9, platform.kind === 'boost' ? 5.6 : 2.6);
+ }
+
+ private getReachableOffsetCap(platform: Platform, step: number, nextWidth: number): number {
+ const jumpVelocity = platform.kind === 'boost' ? BOOST_JUMP : BASE_JUMP;
+ const gravity = -GRAVITY;
+ const discriminant = Math.max(0, jumpVelocity * jumpVelocity - 2 * gravity * step);
+ const descentTime = (jumpVelocity + Math.sqrt(discriminant)) / gravity;
+ const travelDistance = MOVE_SPEED * Math.max(0.24, descentTime - 0.08) * HORIZONTAL_REACH_FACTOR;
+ const landingSlack = nextWidth * 0.5 + this.player.width * 0.34;
+ const safeOffset = travelDistance + landingSlack - HORIZONTAL_REACH_BUFFER;
+
+ return clamp(safeOffset, 1.7, platform.kind === 'boost' ? 6.2 : 3.6);
+ }
+
+ private updateThoughts(deltaSeconds: number, cycle: number, layer: LayerState): void {
+ this.thoughtTimer -= deltaSeconds;
+ if (this.thoughtTimer > 0) {
+ return;
+ }
+
+ this.thoughtIndex = (this.thoughtIndex + 1 + cycle) % THOUGHT_LINES.length;
+ this.setThought(THOUGHT_LINES[this.thoughtIndex]);
+ this.hud.subtle.textContent = layer.descriptor.caption;
+ this.thoughtTimer = 8 + Math.random() * 4;
+ }
+
+ private syncHud(height: number, layer: LayerState, fidelity: number): void {
+ const palette = this.getPalette(Math.floor(height / CYCLE_HEIGHT));
+ this.hud.height.textContent = `${Math.floor(height)} m`;
+ this.hud.layer.textContent = layer.descriptor.name;
+ this.hud.fidelity.textContent = `${Math.round(fidelity * 100)}% · ${palette.descriptor}`;
+ this.hud.ghosts.textContent = `${this.networkStatus.peers}`;
+ this.hud.palette.textContent = palette.name;
+ this.hud.status.textContent = `${this.networkStatus.label}: ${this.networkStatus.detail}`;
+ }
+
+ private render(elapsedSeconds: number): void {
+ const ctx = this.context;
+ const { width, height } = this.canvas;
+ const palette = this.getPalette(this.cycle);
+ const layer = this.getLayer(Math.max(0, this.highestY));
+ const fidelity = this.getFidelity(Math.max(0, this.highestY));
+ const visibleHeight = 22;
+ const scale = Math.min(width / (WORLD_WIDTH * 1.15), height / visibleHeight);
+ const viewTop = this.cameraBottom + height / scale;
+
+ ctx.clearRect(0, 0, width, height);
+
+ const background = ctx.createLinearGradient(0, 0, 0, height);
+ background.addColorStop(0, palette.top);
+ background.addColorStop(1, palette.bottom);
+ ctx.fillStyle = background;
+ ctx.fillRect(0, 0, width, height);
+
+ this.drawAtmosphere(ctx, width, height, palette, fidelity, elapsedSeconds);
+ this.drawMotif(ctx, width, height, scale, layer.descriptor, palette, fidelity, elapsedSeconds);
+ this.drawGrid(ctx, width, height, scale, palette, fidelity);
+
+ for (const platform of this.platforms) {
+ if (platform.y < this.cameraBottom - 1 || platform.y > viewTop + 2) {
+ continue;
+ }
+ this.drawPlatform(ctx, platform, scale, palette, fidelity);
+ }
+
+ for (const ghost of this.ghosts) {
+ if (ghost.y < this.cameraBottom - 2 || ghost.y > viewTop + 2) {
+ continue;
+ }
+ this.drawGhost(ctx, ghost, scale, palette);
+ }
+
+ this.drawTrail(ctx, scale, palette);
+ this.drawPlayer(ctx, scale, palette, fidelity);
+ this.drawCompass(ctx, width, height, palette, fidelity);
+ }
+
+ private drawAtmosphere(
+ ctx: CanvasRenderingContext2D,
+ width: number,
+ height: number,
+ palette: Palette,
+ fidelity: number,
+ elapsedSeconds: number
+ ): void {
+ const sparkleCount = Math.floor(14 + fidelity * 34 + this.cycle * 4);
+ ctx.save();
+ for (let index = 0; index < sparkleCount; index += 1) {
+ const seed = hash(index + this.cycle * 131);
+ const x = seed * width;
+ const y = (hash(index * 19 + this.cycle * 17) * height + elapsedSeconds * (8 + hash(index * 23) * 16)) % height;
+ const radius = (0.6 + hash(index * 29) * 2.3) * this.devicePixelRatio;
+ ctx.globalAlpha = 0.18 + hash(index * 31) * 0.34;
+ ctx.fillStyle = index % 5 === 0 ? palette.signal : palette.primary;
+ ctx.beginPath();
+ ctx.arc(x, height - y, radius, 0, Math.PI * 2);
+ ctx.fill();
+ }
+ ctx.restore();
+ }
+
+ private drawGrid(
+ ctx: CanvasRenderingContext2D,
+ width: number,
+ height: number,
+ scale: number,
+ palette: Palette,
+ fidelity: number
+ ): void {
+ ctx.save();
+ ctx.strokeStyle = palette.grid;
+ ctx.globalAlpha = 0.12 + fidelity * 0.1;
+ ctx.lineWidth = this.devicePixelRatio;
+
+ const verticalLines = Math.max(4, Math.floor(6 + fidelity * 7));
+ for (let index = 0; index <= verticalLines; index += 1) {
+ const x = (index / verticalLines) * width;
+ ctx.beginPath();
+ ctx.moveTo(x, 0);
+ ctx.lineTo(x, height);
+ ctx.stroke();
+ }
+
+ const horizontalStep = 2 * scale;
+ const offset = (this.cameraBottom * scale) % horizontalStep;
+ for (let y = -offset; y < height + horizontalStep; y += horizontalStep) {
+ ctx.beginPath();
+ ctx.moveTo(0, y);
+ ctx.lineTo(width, y);
+ ctx.stroke();
+ }
+
+ ctx.restore();
+ }
+
+ private drawMotif(
+ ctx: CanvasRenderingContext2D,
+ width: number,
+ height: number,
+ scale: number,
+ layer: LayerDescriptor,
+ palette: Palette,
+ fidelity: number,
+ elapsedSeconds: number
+ ): void {
+ ctx.save();
+ ctx.globalAlpha = 0.16 + fidelity * 0.22;
+ ctx.strokeStyle = palette.accent;
+ ctx.fillStyle = palette.signal;
+ ctx.lineWidth = Math.max(1, this.devicePixelRatio * 1.4);
+
+ switch (layer.motif) {
+ case 'core': {
+ for (let index = 0; index < 7; index += 1) {
+ const radius = (4 + index * 1.3) * scale;
+ ctx.beginPath();
+ ctx.arc(width * 0.5, height + scale * 4, radius, Math.PI, Math.PI * 2);
+ ctx.stroke();
+ }
+ break;
+ }
+ case 'roots': {
+ const strands = Math.floor(7 + fidelity * 12);
+ for (let index = 0; index < strands; index += 1) {
+ const x = (index / Math.max(1, strands - 1)) * width;
+ ctx.beginPath();
+ ctx.moveTo(x, height);
+ ctx.bezierCurveTo(
+ x + Math.sin(index + elapsedSeconds) * 60,
+ height * 0.75,
+ x + Math.cos(index * 1.3 + elapsedSeconds * 0.7) * 90,
+ height * 0.35,
+ x + Math.sin(index * 2.2 + elapsedSeconds * 0.5) * 40,
+ 0
+ );
+ ctx.stroke();
+ }
+ break;
+ }
+ case 'surface': {
+ const blocks = Math.floor(9 + fidelity * 12);
+ for (let index = 0; index < blocks; index += 1) {
+ const seed = hash(index * 13 + this.cycle * 17);
+ const x = seed * width;
+ const blockWidth = (18 + seed * 70) * this.devicePixelRatio;
+ const blockHeight = (80 + hash(index * 23) * 180) * this.devicePixelRatio;
+ ctx.fillRect(x, height - blockHeight, blockWidth, blockHeight);
+ }
+ break;
+ }
+ case 'aqueduct': {
+ const lanes = 6;
+ for (let index = 0; index < lanes; index += 1) {
+ const y = height * (0.2 + index * 0.13) + Math.sin(elapsedSeconds + index) * 10;
+ ctx.lineWidth = 6 * this.devicePixelRatio;
+ ctx.beginPath();
+ ctx.moveTo(0, y);
+ ctx.lineTo(width, y);
+ ctx.stroke();
+ ctx.lineWidth = Math.max(1, this.devicePixelRatio * 1.2);
+ }
+ break;
+ }
+ case 'towers': {
+ const towers = Math.floor(12 + fidelity * 16);
+ for (let index = 0; index < towers; index += 1) {
+ const seed = hash(index * 7 + this.cycle * 41);
+ const x = seed * width;
+ const towerWidth = (8 + hash(index * 11) * 24) * this.devicePixelRatio;
+ const towerHeight = (120 + hash(index * 17) * 260) * this.devicePixelRatio;
+ ctx.fillRect(x, height - towerHeight, towerWidth, towerHeight);
+ }
+ break;
+ }
+ case 'clouds': {
+ const clouds = Math.floor(5 + fidelity * 8);
+ for (let index = 0; index < clouds; index += 1) {
+ const seed = hash(index * 9 + this.cycle * 53);
+ const x = (seed * width + elapsedSeconds * 20 * (index % 2 === 0 ? 1 : -1)) % width;
+ const y = height * (0.18 + hash(index * 27) * 0.58);
+ ctx.beginPath();
+ ctx.ellipse(x, y, 36 * scale * 0.1, 16 * scale * 0.1, 0, 0, Math.PI * 2);
+ ctx.fill();
+ }
+ break;
+ }
+ case 'orbital': {
+ const bands = Math.floor(6 + fidelity * 10);
+ for (let index = 0; index < bands; index += 1) {
+ const y = height * 0.12 + index * 42 * this.devicePixelRatio;
+ ctx.beginPath();
+ ctx.moveTo(width * 0.1, y + Math.sin(elapsedSeconds + index) * 14);
+ ctx.lineTo(width * 0.9, y + Math.cos(elapsedSeconds * 0.8 + index) * 14);
+ ctx.stroke();
+ }
+ break;
+ }
+ }
+
+ ctx.restore();
+ }
+
+ private drawPlatform(ctx: CanvasRenderingContext2D, platform: Platform, scale: number, palette: Palette, fidelity: number): void {
+ const center = this.worldToScreen(platform.x, platform.y, scale);
+ const width = platform.width * scale;
+ const height = platform.height * scale;
+ const radius = Math.max(4, height * 0.6);
+
+ ctx.save();
+ ctx.globalAlpha = platform.broken ? 0.2 : 0.95;
+ ctx.fillStyle = platform.kind === 'boost' ? palette.warm : platform.kind === 'fragile' ? palette.signal : palette.primary;
+ ctx.shadowBlur = (platform.kind === 'boost' ? 22 : 12) * fidelity * this.devicePixelRatio;
+ ctx.shadowColor = ctx.fillStyle;
+ roundRect(ctx, center.x - width * 0.5, center.y - height * 0.5, width, height, radius);
+ ctx.fill();
+
+ if (platform.kind === 'drift') {
+ ctx.strokeStyle = palette.accent;
+ ctx.lineWidth = Math.max(1, this.devicePixelRatio * 1.2);
+ ctx.stroke();
+ }
+ ctx.restore();
+ }
+
+ private drawGhost(ctx: CanvasRenderingContext2D, ghost: GhostState, scale: number, palette: Palette): void {
+ const ageSeconds = Math.max(0, Date.now() - ghost.updatedAt) / 1000;
+ const alpha = clamp(0.48 - ageSeconds * 0.14, 0.1, 0.48);
+ const center = this.worldToScreen(ghost.x, ghost.y, scale);
+ const width = 0.74 * scale;
+ const height = 1.02 * scale;
+
+ ctx.save();
+ ctx.globalAlpha = alpha;
+ ctx.fillStyle = palette.ghost;
+ ctx.strokeStyle = palette.signal;
+ ctx.lineWidth = Math.max(1, this.devicePixelRatio);
+ roundRect(ctx, center.x - width * 0.5, center.y - height * 0.5, width, height, width * 0.16);
+ ctx.fill();
+ ctx.stroke();
+ ctx.restore();
+ }
+
+ private drawTrail(ctx: CanvasRenderingContext2D, scale: number, palette: Palette): void {
+ ctx.save();
+ for (let index = 0; index < this.trail.length; index += 1) {
+ const point = this.trail[index];
+ const center = this.worldToScreen(point.x, point.y, scale);
+ const alpha = (index + 1) / this.trail.length;
+ ctx.globalAlpha = alpha * 0.14;
+ ctx.fillStyle = palette.signal;
+ ctx.beginPath();
+ ctx.arc(center.x, center.y, Math.max(2, scale * 0.08 * alpha), 0, Math.PI * 2);
+ ctx.fill();
+ }
+ ctx.restore();
+ }
+
+ private drawPlayer(ctx: CanvasRenderingContext2D, scale: number, palette: Palette, fidelity: number): void {
+ const center = this.worldToScreen(this.player.x, this.player.y, scale);
+ const width = this.player.width * scale;
+ const height = this.player.height * scale;
+ const simplify = fidelity < 0.22;
+
+ ctx.save();
+ ctx.shadowBlur = 24 * this.devicePixelRatio;
+ ctx.shadowColor = palette.accent;
+ ctx.fillStyle = simplify ? palette.primary : palette.accent;
+ roundRect(ctx, center.x - width * 0.5, center.y - height * 0.5, width, height, simplify ? 2 : width * 0.24);
+ ctx.fill();
+
+ if (!simplify) {
+ ctx.fillStyle = palette.primary;
+ ctx.fillRect(center.x - width * 0.18, center.y - height * 0.1, width * 0.36, height * 0.16);
+ ctx.fillStyle = palette.warm;
+ ctx.fillRect(center.x - width * 0.08, center.y + height * 0.1, width * 0.16, height * 0.24);
+ }
+ ctx.restore();
+ }
+
+ private drawCompass(
+ ctx: CanvasRenderingContext2D,
+ width: number,
+ height: number,
+ palette: Palette,
+ fidelity: number
+ ): void {
+ const barWidth = Math.min(width * 0.38, 320 * this.devicePixelRatio);
+ const x = width - barWidth - 24 * this.devicePixelRatio;
+ const y = height - 28 * this.devicePixelRatio;
+
+ ctx.save();
+ ctx.globalAlpha = 0.85;
+ ctx.fillStyle = 'rgba(15, 23, 42, 0.55)';
+ roundRect(ctx, x, y, barWidth, 10 * this.devicePixelRatio, 999);
+ ctx.fill();
+
+ ctx.fillStyle = palette.primary;
+ roundRect(ctx, x, y, barWidth * fidelity, 10 * this.devicePixelRatio, 999);
+ ctx.fill();
+ ctx.restore();
+ }
+
+ private pushTrail(): void {
+ this.trail.push({ x: this.player.x, y: this.player.y });
+ if (this.trail.length > PLAYER_TRAIL) {
+ this.trail.shift();
+ }
+ }
+
+ private setThought(text: string): void {
+ this.hud.thought.textContent = text;
+ this.thoughtTimer = 9 + Math.random() * 3;
+ }
+
+ private getLayer(height: number): LayerState {
+ const index = Math.min(LAYERS.length - 1, Math.floor(height / 700));
+ return { index, descriptor: LAYERS[index] };
+ }
+
+ private getPalette(cycle: number): Palette {
+ return PALETTES[cycle % PALETTES.length];
+ }
+
+ private getFidelity(height: number): number {
+ const progress = (height % CYCLE_HEIGHT) / CYCLE_HEIGHT;
+ return clamp(1 - progress, 0.1, 1);
+ }
+
+ private worldToScreen(x: number, y: number, scale: number): { x: number; y: number } {
+ const screenX = this.canvas.width * 0.5 + x * scale;
+ const screenY = this.canvas.height - (y - this.cameraBottom) * scale;
+ return { x: screenX, y: screenY };
+ }
+
+ private buildMarkup(): string {
+ return `
+ <div class="cyberjump-shell">
+ <canvas class="cyberjump-canvas" data-role="canvas"></canvas>
+ <div class="cyberjump-overlay">
+ <section class="cyberjump-panel cyberjump-stats">
+ <p class="cyberjump-label">Run Metrics</p>
+ <div class="cyberjump-grid">
+ <div class="cyberjump-stat">
+ <span class="cyberjump-stat-name">Height</span>
+ <span class="cyberjump-stat-value" data-role="height">0 m</span>
+ </div>
+ <div class="cyberjump-stat">
+ <span class="cyberjump-stat-name">Realm</span>
+ <span class="cyberjump-stat-value" data-role="layer">Planetary Core</span>
+ </div>
+ <div class="cyberjump-stat">
+ <span class="cyberjump-stat-name">Fidelity</span>
+ <span class="cyberjump-stat-value" data-role="fidelity">100%</span>
+ </div>
+ <div class="cyberjump-stat">
+ <span class="cyberjump-stat-name">Ghosts</span>
+ <span class="cyberjump-stat-value" data-role="ghosts">0</span>
+ </div>
+ <div class="cyberjump-stat">
+ <span class="cyberjump-stat-name">Palette</span>
+ <span class="cyberjump-stat-value" data-role="palette">Neon Furnace</span>
+ </div>
+ </div>
+ </section>
+
+ <section class="cyberjump-panel cyberjump-thoughts">
+ <p class="cyberjump-label">Transmission</p>
+ <p class="cyberjump-thought" data-role="thought"></p>
+ <p class="cyberjump-subtle" data-role="subtle"></p>
+ </section>
+
+ <section class="cyberjump-panel cyberjump-status" data-role="status"></section>
+
+ <section class="cyberjump-panel cyberjump-gameover" data-role="game-over">
+ <p class="cyberjump-label">Run Interrupted</p>
+ <h1 class="cyberjump-title">Signal lost</h1>
+ <p class="cyberjump-copy">Press <span class="cyberjump-accent">Space</span> to restart the ascent.</p>
+ </section>
+ </div>
+ </div>
+ `;
+ }
+
+ private query<T extends Element>(selector: string): T {
+ const element = this.root.querySelector<T>(selector);
+ if (!element) {
+ throw new Error(`Missing required element: ${selector}`);
+ }
+ return element;
+ }
+}
+
+interface LayerState {
+ index: number;
+ descriptor: LayerDescriptor;
+}
+
+function lerp(start: number, end: number, amount: number): number {
+ return start + (end - start) * amount;
+}
+
+function clamp(value: number, min: number, max: number): number {
+ return Math.min(max, Math.max(min, value));
+}
+
+function hash(seed: number): number {
+ const value = Math.sin(seed * 127.1 + 311.7) * 43758.5453123;
+ return value - Math.floor(value);
+}
+
+function roundRect(
+ ctx: CanvasRenderingContext2D,
+ x: number,
+ y: number,
+ width: number,
+ height: number,
+ radius: number
+): void {
+ const bounded = Math.min(radius, width * 0.5, height * 0.5);
+ ctx.beginPath();
+ ctx.moveTo(x + bounded, y);
+ ctx.lineTo(x + width - bounded, y);
+ ctx.quadraticCurveTo(x + width, y, x + width, y + bounded);
+ ctx.lineTo(x + width, y + height - bounded);
+ ctx.quadraticCurveTo(x + width, y + height, x + width - bounded, y + height);
+ ctx.lineTo(x + bounded, y + height);
+ ctx.quadraticCurveTo(x, y + height, x, y + height - bounded);
+ ctx.lineTo(x, y + bounded);
+ ctx.quadraticCurveTo(x, y, x + bounded, y);
+ ctx.closePath();
+}
diff --git a/src/game/content.ts b/src/game/content.ts
new file mode 100644
index 0000000..db94df7
--- /dev/null
+++ b/src/game/content.ts
@@ -0,0 +1,123 @@
+export interface Palette {
+ name: string;
+ descriptor: string;
+ top: string;
+ bottom: string;
+ grid: string;
+ primary: string;
+ accent: string;
+ warm: string;
+ ghost: string;
+ signal: string;
+}
+
+export interface LayerDescriptor {
+ name: string;
+ caption: string;
+ motif: 'core' | 'roots' | 'surface' | 'aqueduct' | 'towers' | 'clouds' | 'orbital';
+}
+
+export const CYCLE_HEIGHT = 900;
+export const WORLD_WIDTH = 18;
+
+export const LAYERS: LayerDescriptor[] = [
+ {
+ name: 'Planetary Core',
+ caption: 'Magnetic furnaces hum below the city. Infrastructure begins as pressure.',
+ motif: 'core'
+ },
+ {
+ name: 'Mycelial Switchyard',
+ caption: 'Roots and fungal circuits share memory through living cable bundles.',
+ motif: 'roots'
+ },
+ {
+ name: 'Surface Arteries',
+ caption: 'Aqueduct rails, markets, and dense habitation recycle heat into movement.',
+ motif: 'surface'
+ },
+ {
+ name: 'Analog Canals',
+ caption: 'Fluid logic, wheels, and pressure gates store power in visible machinery.',
+ motif: 'aqueduct'
+ },
+ {
+ name: 'Tower Choir',
+ caption: 'Needle towers process weather, finance, and rumor in stacked districts.',
+ motif: 'towers'
+ },
+ {
+ name: 'Cloud Mesh',
+ caption: 'Platforms thin into luminous rafts and relay gardens above the weather.',
+ motif: 'clouds'
+ },
+ {
+ name: 'Orbital Threshold',
+ caption: 'Geometry strips itself down to signal, velocity, and intention.',
+ motif: 'orbital'
+ }
+];
+
+export const PALETTES: Palette[] = [
+ {
+ name: 'Neon Furnace',
+ descriptor: 'dense, humid, electric',
+ top: '#140b26',
+ bottom: '#04070f',
+ grid: '#1f4d5f',
+ primary: '#71faff',
+ accent: '#fd4fd0',
+ warm: '#ff9f43',
+ ghost: '#d8f8ff',
+ signal: '#a78bfa'
+ },
+ {
+ name: 'Biolume Relay',
+ descriptor: 'organic, damp, networked',
+ top: '#0f1d17',
+ bottom: '#04070d',
+ grid: '#205a4f',
+ primary: '#70ffbf',
+ accent: '#8de95f',
+ warm: '#f4d35e',
+ ghost: '#d4ffe8',
+ signal: '#64d2ff'
+ },
+ {
+ name: 'Ceramic Dawn',
+ descriptor: 'clean, bright, infrastructural',
+ top: '#1c2448',
+ bottom: '#080b13',
+ grid: '#315f8e',
+ primary: '#98c8ff',
+ accent: '#ff6aa2',
+ warm: '#ffd166',
+ ghost: '#f4faff',
+ signal: '#8cf1ff'
+ },
+ {
+ name: 'Voltage Bloom',
+ descriptor: 'vibrant, ceremonial, airborne',
+ top: '#221140',
+ bottom: '#05070c',
+ grid: '#483f9b',
+ primary: '#b8a7ff',
+ accent: '#ff7b72',
+ warm: '#ffe066',
+ ghost: '#ece9ff',
+ signal: '#6ef3ff'
+ }
+];
+
+export const THOUGHT_LINES = [
+ 'If the world gets simpler as you rise, what exactly are you learning to ignore?',
+ 'The city stores memory in pipes, fungus, glass, and habits. Which medium trusts you most?',
+ 'Ghosts are not dead players. They are adjacent decisions still visible from here.',
+ 'Progress can mean refinement, abstraction, or amputation. Which one are you performing?',
+ 'A platform is a temporary agreement between gravity and intention.',
+ 'Every layer calls itself the real city. Each one merely found a different compression.',
+ 'Information wants a body. Bodies want a future. Cities negotiate between the two.',
+ 'Ascending is easy to narrate and hard to define. Are you escaping or integrating?',
+ 'The brighter the signal, the easier it is to mistake compression for truth.',
+ 'Shared ghosts make failure public and persistence communal.'
+] as const;
diff --git a/src/main.ts b/src/main.ts
new file mode 100644
index 0000000..36d156f
--- /dev/null
+++ b/src/main.ts
@@ -0,0 +1,11 @@
+import './styles.css';
+import { CyberJumpApp } from './game/CyberJumpApp';
+
+const root = document.querySelector<HTMLDivElement>('#app');
+
+if (!root) {
+ throw new Error('Missing #app root element.');
+}
+
+const app = new CyberJumpApp(root);
+app.start();
diff --git a/src/network/NetworkClient.ts b/src/network/NetworkClient.ts
new file mode 100644
index 0000000..a2d3ef4
--- /dev/null
+++ b/src/network/NetworkClient.ts
@@ -0,0 +1,180 @@
+export interface GhostState {
+ id: string;
+ x: number;
+ y: number;
+ vx: number;
+ vy: number;
+ height: number;
+ layer: string;
+ cycle: number;
+ updatedAt: number;
+}
+
+interface WelcomeMessage {
+ type: 'welcome';
+ id: string;
+}
+
+interface SnapshotMessage {
+ type: 'snapshot';
+ players: GhostState[];
+}
+
+interface StateMessage {
+ type: 'state';
+ x: number;
+ y: number;
+ vx: number;
+ vy: number;
+ height: number;
+ layer: string;
+ cycle: number;
+}
+
+interface DiscoveryResponse {
+ servers?: Array<{
+ url: string;
+ region?: string;
+ }>;
+}
+
+export interface NetworkStatus {
+ label: string;
+ detail: string;
+ peers: number;
+}
+
+const DEFAULT_DISCOVERY = 'http://localhost:8080/servers';
+const DEFAULT_SOCKET = 'ws://localhost:8080/ws';
+
+export class NetworkClient {
+ private socket?: WebSocket;
+ private discoveryUrl: string;
+ private fallbackUrl: string;
+ private reconnectHandle?: number;
+ private stateBuffer?: StateMessage;
+ private flushHandle?: number;
+ private selfId?: string;
+ private snapshotHandler: (players: GhostState[]) => void;
+ private statusHandler: (status: NetworkStatus) => void;
+
+ constructor(
+ snapshotHandler: (players: GhostState[]) => void,
+ statusHandler: (status: NetworkStatus) => void
+ ) {
+ this.snapshotHandler = snapshotHandler;
+ this.statusHandler = statusHandler;
+ this.discoveryUrl = import.meta.env.VITE_DISCOVERY_URL ?? DEFAULT_DISCOVERY;
+ this.fallbackUrl = import.meta.env.VITE_WS_URL ?? DEFAULT_SOCKET;
+ }
+
+ start(): void {
+ void this.connect();
+ }
+
+ stop(): void {
+ if (this.reconnectHandle) {
+ window.clearTimeout(this.reconnectHandle);
+ this.reconnectHandle = undefined;
+ }
+ if (this.flushHandle) {
+ window.clearInterval(this.flushHandle);
+ this.flushHandle = undefined;
+ }
+ this.socket?.close();
+ this.socket = undefined;
+ }
+
+ updateState(state: Omit<StateMessage, 'type'>): void {
+ this.stateBuffer = { type: 'state', ...state };
+ }
+
+ private async connect(): Promise<void> {
+ this.statusHandler({ label: 'network', detail: 'discovering ghost relay…', peers: 0 });
+
+ const target = await this.resolveSocketUrl();
+ this.socket = new WebSocket(target);
+
+ this.socket.addEventListener('open', () => {
+ this.statusHandler({ label: 'network', detail: `ghost relay online · ${target}`, peers: 0 });
+ this.startFlusher();
+ });
+
+ this.socket.addEventListener('message', (event) => {
+ this.handleMessage(event.data);
+ });
+
+ this.socket.addEventListener('close', () => {
+ this.statusHandler({ label: 'network', detail: 'ghost relay offline · retrying…', peers: 0 });
+ this.snapshotHandler([]);
+ this.socket = undefined;
+ this.selfId = undefined;
+ this.stopFlusher();
+ this.reconnectHandle = window.setTimeout(() => {
+ void this.connect();
+ }, 2000);
+ });
+
+ this.socket.addEventListener('error', () => {
+ this.statusHandler({ label: 'network', detail: 'ghost relay unreachable', peers: 0 });
+ });
+ }
+
+ private async resolveSocketUrl(): Promise<string> {
+ try {
+ const response = await fetch(this.discoveryUrl, { headers: { Accept: 'application/json' } });
+ if (!response.ok) {
+ return this.fallbackUrl;
+ }
+
+ const payload = (await response.json()) as DiscoveryResponse;
+ const first = payload.servers?.[0]?.url;
+ return first ?? this.fallbackUrl;
+ } catch {
+ return this.fallbackUrl;
+ }
+ }
+
+ private startFlusher(): void {
+ this.stopFlusher();
+ this.flushHandle = window.setInterval(() => {
+ if (!this.socket || this.socket.readyState !== WebSocket.OPEN || !this.stateBuffer) {
+ return;
+ }
+ this.socket.send(JSON.stringify(this.stateBuffer));
+ }, 80);
+ }
+
+ private stopFlusher(): void {
+ if (!this.flushHandle) {
+ return;
+ }
+ window.clearInterval(this.flushHandle);
+ this.flushHandle = undefined;
+ }
+
+ private handleMessage(raw: string): void {
+ let parsed: WelcomeMessage | SnapshotMessage | undefined;
+
+ try {
+ parsed = JSON.parse(raw) as WelcomeMessage | SnapshotMessage;
+ } catch {
+ return;
+ }
+
+ if (parsed.type === 'welcome') {
+ this.selfId = parsed.id;
+ return;
+ }
+
+ if (parsed.type === 'snapshot') {
+ const others = parsed.players.filter((player) => player.id !== this.selfId);
+ this.snapshotHandler(others);
+ this.statusHandler({
+ label: 'network',
+ detail: this.socket?.url ? `ghost relay online · ${this.socket.url}` : 'ghost relay online',
+ peers: others.length
+ });
+ }
+ }
+}
diff --git a/src/rendering/shaders/world.frag b/src/rendering/shaders/world.frag
new file mode 100644
index 0000000..06ed16f
--- /dev/null
+++ b/src/rendering/shaders/world.frag
@@ -0,0 +1,57 @@
+uniform float complexity; // 0.0 to 1.0
+uniform float time;
+uniform vec3 color1;
+uniform vec3 color2;
+uniform vec3 color3;
+uniform float layerIndex;
+
+varying vec2 vUv;
+varying vec3 vPosition;
+
+// Reduce color palette based on complexity
+vec3 quantizeColor(vec3 color, float levels) {
+ return floor(color * levels) / levels;
+}
+
+// Simple noise function
+float noise(vec2 p) {
+ return fract(sin(dot(p, vec2(12.9898, 78.233))) * 43758.5453);
+}
+
+void main() {
+ vec2 uv = vUv;
+
+ // Base gradient
+ vec3 color = mix(color1, color2, uv.y);
+ color = mix(color, color3, sin(uv.x * 3.14159) * 0.3);
+
+ // At high complexity, add details
+ if (complexity > 0.3) {
+ // Circuit patterns
+ float circuit = step(0.98, fract(uv.x * 20.0 + time * 0.05));
+ circuit += step(0.98, fract(uv.y * 20.0 + time * 0.03));
+ color += vec3(0.0, 0.5, 1.0) * circuit * complexity * 0.2;
+
+ // Scanlines
+ float scanline = sin(uv.y * 200.0) * 0.5 + 0.5;
+ color *= 0.95 + scanline * 0.05 * complexity;
+ }
+
+ // At medium complexity, add some glow
+ if (complexity > 0.5) {
+ float glow = sin(time * 2.0 + uv.y * 10.0) * 0.5 + 0.5;
+ color += color2 * glow * 0.1 * complexity;
+ }
+
+ // Reduce color palette as complexity decreases
+ float levels = mix(4.0, 256.0, complexity);
+ color = quantizeColor(color, levels);
+
+ // Fade edges at low complexity
+ float edge = 1.0;
+ if (complexity < 0.3) {
+ edge = smoothstep(0.0, 0.1, uv.x) * smoothstep(1.0, 0.9, uv.x);
+ }
+
+ gl_FragColor = vec4(color, edge);
+}
diff --git a/src/rendering/shaders/world.vert b/src/rendering/shaders/world.vert
new file mode 100644
index 0000000..f4410b6
--- /dev/null
+++ b/src/rendering/shaders/world.vert
@@ -0,0 +1,8 @@
+varying vec2 vUv;
+varying vec3 vPosition;
+
+void main() {
+ vUv = uv;
+ vPosition = position;
+ gl_Position = projectionMatrix * modelViewMatrix * vec4(position, 1.0);
+}
diff --git a/src/styles.css b/src/styles.css
new file mode 100644
index 0000000..6ed92e5
--- /dev/null
+++ b/src/styles.css
@@ -0,0 +1,200 @@
+:root {
+ color-scheme: dark;
+ font-family: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
+ background: #050816;
+ color: #eff6ff;
+}
+
+* {
+ box-sizing: border-box;
+}
+
+html,
+body,
+#app {
+ margin: 0;
+ min-height: 100%;
+ width: 100%;
+}
+
+body {
+ overflow: hidden;
+ background:
+ radial-gradient(circle at top, rgba(99, 102, 241, 0.16), transparent 32%),
+ linear-gradient(180deg, #060816 0%, #04050d 100%);
+}
+
+button,
+canvas,
+input,
+textarea,
+select {
+ font: inherit;
+}
+
+.cyberjump-shell {
+ position: relative;
+ width: 100vw;
+ height: 100vh;
+ overflow: hidden;
+}
+
+.cyberjump-canvas {
+ display: block;
+ width: 100%;
+ height: 100%;
+}
+
+.cyberjump-overlay {
+ position: absolute;
+ inset: 0;
+ pointer-events: none;
+}
+
+.cyberjump-panel {
+ position: absolute;
+ backdrop-filter: blur(18px);
+ background: rgba(5, 10, 24, 0.46);
+ border: 1px solid rgba(148, 163, 184, 0.2);
+ border-radius: 18px;
+ box-shadow: 0 18px 64px rgba(2, 8, 23, 0.35);
+}
+
+.cyberjump-stats {
+ top: 20px;
+ left: 20px;
+ min-width: 270px;
+ padding: 16px 18px;
+}
+
+.cyberjump-thoughts {
+ top: 20px;
+ right: 20px;
+ width: min(360px, calc(100vw - 40px));
+ padding: 16px 18px;
+}
+
+.cyberjump-status {
+ position: absolute;
+ left: 20px;
+ bottom: 20px;
+ padding: 12px 16px;
+ max-width: min(420px, calc(100vw - 40px));
+}
+
+.cyberjump-gameover {
+ inset: 50% auto auto 50%;
+ transform: translate(-50%, -50%);
+ width: min(520px, calc(100vw - 32px));
+ padding: 28px;
+ text-align: center;
+ opacity: 0;
+ transition: opacity 180ms ease;
+}
+
+.cyberjump-gameover.is-visible {
+ opacity: 1;
+}
+
+.cyberjump-label {
+ margin: 0 0 8px;
+ color: rgba(125, 211, 252, 0.9);
+ font-size: 0.78rem;
+ letter-spacing: 0.18em;
+ text-transform: uppercase;
+}
+
+.cyberjump-grid {
+ display: grid;
+ grid-template-columns: repeat(2, minmax(0, 1fr));
+ gap: 10px 18px;
+}
+
+.cyberjump-stat {
+ display: flex;
+ flex-direction: column;
+ gap: 3px;
+}
+
+.cyberjump-stat-name {
+ font-size: 0.72rem;
+ text-transform: uppercase;
+ letter-spacing: 0.14em;
+ color: rgba(148, 163, 184, 0.85);
+}
+
+.cyberjump-stat-value {
+ font-size: 1.02rem;
+ font-weight: 600;
+}
+
+.cyberjump-thought {
+ margin: 0;
+ color: rgba(224, 231, 255, 0.96);
+ font-size: 1rem;
+ line-height: 1.55;
+}
+
+.cyberjump-subtle {
+ margin: 8px 0 0;
+ color: rgba(148, 163, 184, 0.95);
+ font-size: 0.88rem;
+ line-height: 1.45;
+}
+
+.cyberjump-title {
+ margin: 0;
+ font-size: clamp(1.8rem, 3vw, 2.8rem);
+ letter-spacing: 0.08em;
+ text-transform: uppercase;
+}
+
+.cyberjump-copy {
+ margin: 12px 0 0;
+ line-height: 1.6;
+ color: rgba(226, 232, 240, 0.92);
+}
+
+.cyberjump-accent {
+ color: rgba(125, 211, 252, 1);
+}
+
+@media (max-width: 900px) {
+ .cyberjump-thoughts {
+ top: auto;
+ right: 20px;
+ bottom: 108px;
+ }
+
+ .cyberjump-stats {
+ min-width: 0;
+ width: min(320px, calc(100vw - 40px));
+ }
+}
+
+@media (max-width: 640px) {
+ .cyberjump-stats,
+ .cyberjump-thoughts,
+ .cyberjump-status {
+ left: 12px;
+ right: 12px;
+ width: auto;
+ }
+
+ .cyberjump-stats {
+ top: 12px;
+ }
+
+ .cyberjump-thoughts {
+ bottom: 106px;
+ top: auto;
+ }
+
+ .cyberjump-status {
+ bottom: 12px;
+ }
+
+ .cyberjump-grid {
+ grid-template-columns: 1fr 1fr;
+ }
+}
diff --git a/src/vite-env.d.ts b/src/vite-env.d.ts
new file mode 100644
index 0000000..11f02fe
--- /dev/null
+++ b/src/vite-env.d.ts
@@ -0,0 +1 @@
+/// <reference types="vite/client" />
diff --git a/tsconfig.json b/tsconfig.json
new file mode 100644
index 0000000..eead15c
--- /dev/null
+++ b/tsconfig.json
@@ -0,0 +1,20 @@
+{
+ "compilerOptions": {
+ "target": "ES2020",
+ "useDefineForClassFields": true,
+ "module": "ESNext",
+ "lib": ["ES2020", "DOM", "DOM.Iterable"],
+ "skipLibCheck": true,
+ "moduleResolution": "bundler",
+ "allowImportingTsExtensions": true,
+ "resolveJsonModule": true,
+ "isolatedModules": true,
+ "noEmit": true,
+ "strict": true,
+ "noUnusedLocals": true,
+ "noUnusedParameters": true,
+ "noFallthroughCasesInSwitch": true,
+ "esModuleInterop": true
+ },
+ "include": ["src"]
+}
diff --git a/vite.config.ts b/vite.config.ts
new file mode 100644
index 0000000..a9e29f6
--- /dev/null
+++ b/vite.config.ts
@@ -0,0 +1,10 @@
+import { defineConfig } from 'vite';
+
+export default defineConfig({
+ server: {
+ port: 3000
+ },
+ build: {
+ target: 'esnext'
+ }
+});