diff --git a/backend/Dockerfile b/backend/Dockerfile
index 6c6f999..172e513 100644
--- a/backend/Dockerfile
+++ b/backend/Dockerfile
@@ -12,6 +12,13 @@ RUN bun install
COPY mobile/ .
RUN bun run build:web
+FROM oven/bun:1.2.18 AS games-builder
+WORKDIR /games
+COPY games/package.json ./
+RUN bun install
+COPY games/ .
+RUN bun run build
+
FROM oven/bun:1.2.18
WORKDIR /app
COPY backend/package.json backend/bun.lock backend/tsconfig.json ./
@@ -19,6 +26,7 @@ RUN bun install --frozen-lockfile --production
COPY backend/src ./src
COPY --from=website-builder /website/dist ./public
COPY --from=mobile-builder /mobile/dist ./public/mobile
+COPY --from=games-builder /games/dist ./public/games
ENV NODE_ENV=production
ENV PORT=8080
diff --git a/backend/src/index.ts b/backend/src/index.ts
index b485ae9..f131ba2 100644
--- a/backend/src/index.ts
+++ b/backend/src/index.ts
@@ -64,6 +64,13 @@ app.get('/{*path}', (req, res, next) => {
return;
}
+ if (req.path.startsWith('/games')) {
+ res.sendFile(path.join(publicDir, 'games/index.html'), (err) => {
+ if (err) next();
+ });
+ return;
+ }
+
res.sendFile(path.join(publicDir, 'index.html'), (err) => {
if (err) next();
});
diff --git a/games/.gitignore b/games/.gitignore
new file mode 100644
index 0000000..8990dad
--- /dev/null
+++ b/games/.gitignore
@@ -0,0 +1,17 @@
+# Dependencies
+node_modules
+
+# Lockfiles
+bun.lock
+
+# Vite
+.vite
+
+# Build output
+dist
+
+# Cache
+cache
+
+# OS files
+.DS_Store
diff --git a/games/eslint.config.js b/games/eslint.config.js
new file mode 100644
index 0000000..4fa125d
--- /dev/null
+++ b/games/eslint.config.js
@@ -0,0 +1,29 @@
+import js from '@eslint/js'
+import globals from 'globals'
+import reactHooks from 'eslint-plugin-react-hooks'
+import reactRefresh from 'eslint-plugin-react-refresh'
+import { defineConfig, globalIgnores } from 'eslint/config'
+
+export default defineConfig([
+ globalIgnores(['dist']),
+ {
+ files: ['**/*.{js,jsx}'],
+ extends: [
+ js.configs.recommended,
+ reactHooks.configs.flat.recommended,
+ reactRefresh.configs.vite,
+ ],
+ languageOptions: {
+ ecmaVersion: 2020,
+ globals: globals.browser,
+ parserOptions: {
+ ecmaVersion: 'latest',
+ ecmaFeatures: { jsx: true },
+ sourceType: 'module',
+ },
+ },
+ rules: {
+ 'no-unused-vars': ['error', { varsIgnorePattern: '^[A-Z_]' }],
+ },
+ },
+])
diff --git a/games/index.html b/games/index.html
new file mode 100644
index 0000000..cf1c48a
--- /dev/null
+++ b/games/index.html
@@ -0,0 +1,14 @@
+
+
+
+
+
+
+ Three.js Game
+
+
+
+
+
+
+
diff --git a/games/package.json b/games/package.json
new file mode 100644
index 0000000..3f8d532
--- /dev/null
+++ b/games/package.json
@@ -0,0 +1,30 @@
+{
+ "name": "noah-threejs-template",
+ "private": true,
+ "version": "0.0.0",
+ "type": "module",
+ "scripts": {
+ "dev": "vite --host",
+ "build": "vite build",
+ "lint": "eslint .",
+ "preview": "vite preview",
+ "watch": "vite build --watch"
+ },
+ "dependencies": {
+ "@supabase/supabase-js": "2.57.4",
+ "react": "19.1.0",
+ "react-dom": "19.1.0",
+ "three": "0.160.0"
+ },
+ "devDependencies": {
+ "@eslint/js": "9.9.1",
+ "@types/react": "19.0.0",
+ "@types/react-dom": "19.0.0",
+ "@vitejs/plugin-react-swc": "4.3.0",
+ "eslint": "9.9.1",
+ "eslint-plugin-react-hooks": "5.1.0-rc.0",
+ "eslint-plugin-react-refresh": "0.4.11",
+ "globals": "15.9.0",
+ "vite": "8.0.8"
+ }
+}
diff --git a/games/src/App.jsx b/games/src/App.jsx
new file mode 100644
index 0000000..cf7ed4a
--- /dev/null
+++ b/games/src/App.jsx
@@ -0,0 +1,18 @@
+import { useEffect, useRef } from "react";
+import { ThreeScene } from "./game/ThreeScene";
+
+function App() {
+ const containerRef = useRef(null);
+ const sceneRef = useRef(null);
+
+ useEffect(() => {
+ if (containerRef.current && !sceneRef.current) {
+ sceneRef.current = new ThreeScene(containerRef.current);
+ sceneRef.current.init();
+ }
+ }, []);
+
+ return ;
+}
+
+export default App;
diff --git a/games/src/game/ThreeScene.js b/games/src/game/ThreeScene.js
new file mode 100644
index 0000000..4d00a44
--- /dev/null
+++ b/games/src/game/ThreeScene.js
@@ -0,0 +1,171 @@
+import * as THREE from "three";
+import { OrbitControls } from "three/examples/jsm/controls/OrbitControls";
+
+export class ThreeScene {
+ constructor(container) {
+ this.container = container;
+ this.scene = new THREE.Scene();
+ this.camera = new THREE.PerspectiveCamera(
+ 75,
+ this.container.clientWidth / this.container.clientHeight,
+ 0.1,
+ 1000
+ );
+ this.renderer = new THREE.WebGLRenderer({ antialias: true });
+ this.controls = null;
+ this.clock = new THREE.Clock();
+
+ this.playerObject = null;
+ this.planetObject = null;
+ }
+
+ init() {
+ this.renderer.setSize(
+ this.container.clientWidth,
+ this.container.clientHeight
+ );
+ this.renderer.setPixelRatio(window.devicePixelRatio);
+ this.renderer.setClearColor(0x101020);
+ this.container.appendChild(this.renderer.domElement);
+
+ this.camera.position.set(0, 10, 20);
+
+ const ambientLight = new THREE.AmbientLight(0x404040);
+ this.scene.add(ambientLight);
+
+ const directionalLight = new THREE.DirectionalLight(0xffffff, 0.8);
+ directionalLight.position.set(10, 15, 10);
+ this.scene.add(directionalLight);
+
+ this.addStars();
+
+ this.createPlanet();
+ this.createPlayer();
+
+ this.controls = new OrbitControls(this.camera, this.renderer.domElement);
+ this.controls.enableDamping = true;
+ this.controls.dampingFactor = 0.05;
+ this.controls.screenSpacePanning = false;
+ this.controls.minDistance = 5;
+ this.controls.maxDistance = 100;
+ this.controls.target.set(0, 0, 0);
+
+ window.addEventListener("resize", () => this.onWindowResize());
+
+ this.animate();
+ }
+
+ addStars() {
+ const starsGeometry = new THREE.BufferGeometry();
+ const starsMaterial = new THREE.PointsMaterial({
+ color: 0xffffff,
+ size: 0.05,
+ sizeAttenuation: true,
+ });
+
+ const starsVertices = [];
+ for (let i = 0; i < 10000; i++) {
+ const x = THREE.MathUtils.randFloatSpread(200);
+ const y = THREE.MathUtils.randFloatSpread(200);
+ const z = THREE.MathUtils.randFloatSpread(200);
+ if (Math.sqrt(x * x + y * y + z * z) > 20) {
+ starsVertices.push(x, y, z);
+ } else {
+ i--;
+ }
+ }
+
+ starsGeometry.setAttribute(
+ "position",
+ new THREE.Float32BufferAttribute(starsVertices, 3)
+ );
+ const stars = new THREE.Points(starsGeometry, starsMaterial);
+ this.scene.add(stars);
+ }
+
+ createPlanet() {
+ const planetGeometry = new THREE.SphereGeometry(4, 32, 32);
+ const planetMaterial = new THREE.MeshPhongMaterial({
+ color: 0x4a6f8a,
+ shininess: 10,
+ });
+ this.planetObject = new THREE.Mesh(planetGeometry, planetMaterial);
+ this.scene.add(this.planetObject);
+ }
+
+ createPlayer() {
+ const playerGroup = new THREE.Group();
+
+ const bodyGeometry = new THREE.ConeGeometry(0.8, 2, 8);
+ const bodyMaterial = new THREE.MeshPhongMaterial({
+ color: 0xe88a5b,
+ shininess: 30,
+ });
+ const playerBody = new THREE.Mesh(bodyGeometry, bodyMaterial);
+ playerBody.rotation.x = Math.PI / 2;
+ playerGroup.add(playerBody);
+
+ const cockpitGeometry = new THREE.SphereGeometry(
+ 0.4,
+ 16,
+ 16,
+ 0,
+ Math.PI * 2,
+ 0,
+ Math.PI / 2
+ );
+ const cockpitMaterial = new THREE.MeshPhongMaterial({
+ color: 0xadd8e6,
+ transparent: true,
+ opacity: 0.6,
+ shininess: 50,
+ });
+ const cockpit = new THREE.Mesh(cockpitGeometry, cockpitMaterial);
+ cockpit.rotation.x = Math.PI;
+ cockpit.position.set(0, 0, -0.6);
+ playerBody.add(cockpit);
+
+ this.playerObject = playerGroup;
+ this.playerObject.position.set(0, 0, 10);
+ this.scene.add(this.playerObject);
+ }
+
+ onWindowResize() {
+ this.camera.aspect =
+ this.container.clientWidth / this.container.clientHeight;
+ this.camera.updateProjectionMatrix();
+ this.renderer.setSize(
+ this.container.clientWidth,
+ this.container.clientHeight
+ );
+ this.renderer.setPixelRatio(window.devicePixelRatio);
+ }
+
+ animate() {
+ requestAnimationFrame(() => this.animate());
+
+ const delta = this.clock.getDelta();
+ const elapsedTime = this.clock.getElapsedTime();
+
+ if (this.planetObject) {
+ this.planetObject.rotation.y += 0.2 * delta;
+ }
+
+ if (this.playerObject) {
+ const orbitRadius = 10;
+ const orbitSpeed = 0.5;
+ this.playerObject.position.x =
+ Math.cos(elapsedTime * orbitSpeed) * orbitRadius;
+ this.playerObject.position.z =
+ Math.sin(elapsedTime * orbitSpeed) * orbitRadius;
+ this.playerObject.lookAt(this.planetObject.position);
+ this.playerObject.rotateY(Math.PI);
+ }
+
+ if (this.controls) {
+ this.controls.update();
+ }
+
+ this.renderer.render(this.scene, this.camera);
+ }
+}
diff --git a/games/src/index.css b/games/src/index.css
new file mode 100644
index 0000000..ec00aa9
--- /dev/null
+++ b/games/src/index.css
@@ -0,0 +1,13 @@
+body {
+ margin: 0;
+ overflow: hidden;
+ background-color: #000;
+}
+
+#game-container {
+ position: fixed;
+ top: 0;
+ left: 0;
+ width: 100%;
+ height: 100%;
+}
diff --git a/games/src/main.jsx b/games/src/main.jsx
new file mode 100644
index 0000000..b9a1a6d
--- /dev/null
+++ b/games/src/main.jsx
@@ -0,0 +1,10 @@
+import { StrictMode } from 'react'
+import { createRoot } from 'react-dom/client'
+import './index.css'
+import App from './App.jsx'
+
+createRoot(document.getElementById('root')).render(
+
+
+ ,
+)
diff --git a/games/vite.config.js b/games/vite.config.js
new file mode 100644
index 0000000..38677b3
--- /dev/null
+++ b/games/vite.config.js
@@ -0,0 +1,21 @@
+import { defineConfig } from "vite";
+import react from "@vitejs/plugin-react-swc";
+
+export default defineConfig(({ mode }) => ({
+ base: "/games/",
+ server: {
+ host: "0.0.0.0",
+ port: 3000,
+ allowedHosts: true,
+ hmr: {
+ overlay: false,
+ timeout: 15000,
+ },
+ watch: {
+ usePolling: true,
+ interval: 500,
+ binaryInterval: 500,
+ },
+ },
+ plugins: [react()],
+}));
diff --git a/website/vite.config.ts b/website/vite.config.ts
index 49936fd..7f9e412 100644
--- a/website/vite.config.ts
+++ b/website/vite.config.ts
@@ -26,6 +26,10 @@ export default defineConfig(({ mode }) => ({
target: "http://localhost:8081",
changeOrigin: true,
},
+ "/games": {
+ target: "http://localhost:3000",
+ changeOrigin: true,
+ },
},
},
plugins: [react(), mode === "development" && componentTagger()],