├── .gitignore ├── README.md ├── apps ├── game-front │ ├── .gitignore │ ├── README.md │ ├── components.json │ ├── eslint.config.mjs │ ├── next.config.ts │ ├── package.json │ ├── postcss.config.mjs │ ├── public │ │ ├── auto1.glb │ │ ├── file.svg │ │ ├── game-track.glb │ │ ├── globe.svg │ │ ├── next.svg │ │ ├── vercel.svg │ │ └── window.svg │ ├── src │ │ ├── app │ │ │ ├── components │ │ │ │ ├── assets.tsx │ │ │ │ ├── cube.tsx │ │ │ │ ├── game.tsx │ │ │ │ ├── gradient.tsx │ │ │ │ ├── ground.tsx │ │ │ │ ├── other-players.tsx │ │ │ │ ├── player.tsx │ │ │ │ ├── track.tsx │ │ │ │ ├── ui │ │ │ │ │ ├── button.tsx │ │ │ │ │ └── dialog.tsx │ │ │ │ ├── use-party.tsx │ │ │ │ ├── utils │ │ │ │ │ ├── orientation-controls.tsx │ │ │ │ │ └── overscroll-prevent.tsx │ │ │ │ ├── vehicle │ │ │ │ │ ├── body.tsx │ │ │ │ │ ├── constants.ts │ │ │ │ │ ├── controller.tsx │ │ │ │ │ └── instances.tsx │ │ │ │ └── wasd-controls.tsx │ │ │ ├── controls │ │ │ │ └── page.tsx │ │ │ ├── favicon.ico │ │ │ ├── globals.css │ │ │ ├── layout.tsx │ │ │ ├── page.tsx │ │ │ └── room │ │ │ │ └── [room-id] │ │ │ │ ├── controls-mobile-overlay.tsx │ │ │ │ ├── controls-qr-overlay.tsx │ │ │ │ ├── github-overlay.tsx │ │ │ │ ├── page.tsx │ │ │ │ ├── room.tsx │ │ │ │ └── server-status-overlay.tsx │ │ ├── hooks │ │ │ ├── use-device-orientation.ts │ │ │ ├── use-is-mobile.ts │ │ │ ├── use-media.ts │ │ │ └── use-peer-controls.ts │ │ └── lib │ │ │ ├── basehub.ts │ │ │ ├── math.ts │ │ │ ├── pack.ts │ │ │ └── utils.ts │ └── tsconfig.json └── game-server │ ├── README.md │ ├── package.json │ ├── partykit.json │ ├── src │ ├── index.ts │ └── utils.ts │ ├── tsconfig.build.json │ └── tsconfig.json ├── package.json ├── packages ├── game-schemas │ ├── README.md │ ├── package.json │ ├── src │ │ ├── actions.ts │ │ ├── index.ts │ │ ├── messages.ts │ │ ├── presence.ts │ │ ├── user.ts │ │ └── utils.ts │ └── tsconfig.json └── peerjs-react │ ├── README.md │ ├── package.json │ ├── src │ ├── index.ts │ └── peer-party.ts │ ├── tsconfig.build.json │ ├── tsconfig.json │ └── vite.config.ts ├── pnpm-lock.yaml ├── pnpm-workspace.yaml └── turbo.json /.gitignore: -------------------------------------------------------------------------------- 1 | # Dependencies 2 | node_modules 3 | .pnpm-store 4 | 5 | # Build outputs 6 | dist 7 | build 8 | .next 9 | out 10 | .turbo 11 | .partykit 12 | .basehub 13 | 14 | # Environment variables 15 | .env 16 | .env.local 17 | .env.development.local 18 | .env.test.local 19 | .env.production.local 20 | 21 | # Logs 22 | logs 23 | *.log 24 | npm-debug.log* 25 | yarn-debug.log* 26 | yarn-error.log* 27 | pnpm-debug.log* 28 | 29 | localhost-key.pem 30 | localhost.pem 31 | 32 | # Editor directories and files 33 | .idea 34 | .vscode 35 | *.suo 36 | *.ntvs* 37 | *.njsproj 38 | *.sln 39 | *.sw? 40 | 41 | # OS generated files 42 | .DS_Store 43 | .DS_Store? 44 | ._* 45 | .Spotlight-V100 46 | .Trashes 47 | ehthumbs.db 48 | Thumbs.db 49 | 50 | # Testing 51 | coverage 52 | 53 | # Misc 54 | .cache 55 | .temp 56 | .tmp 57 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Partykit + peerjs demo 2 | 3 | Real-time multiplayer game implementation using PartyKit and PeerJS. 4 | 5 | ## Structure 6 | 7 | - `apps/game-front`: Game UI implementation 8 | - `apps/game-server`: Game server implementation 9 | - `packages/game-shared`: Shared game schemas 10 | - `packages/peerjs-react`: PeerJS React hooks 11 | 12 | 3D models by [@_Nico_brc_](https://x.com/_Nico_brc_) -------------------------------------------------------------------------------- /apps/game-front/.gitignore: -------------------------------------------------------------------------------- 1 | # See https://help.github.com/articles/ignoring-files/ for more about ignoring files. 2 | 3 | # dependencies 4 | /node_modules 5 | /.pnp 6 | .pnp.* 7 | .yarn/* 8 | !.yarn/patches 9 | !.yarn/plugins 10 | !.yarn/releases 11 | !.yarn/versions 12 | 13 | # testing 14 | /coverage 15 | 16 | # next.js 17 | /.next/ 18 | /out/ 19 | 20 | # production 21 | /build 22 | 23 | # misc 24 | .DS_Store 25 | *.pem 26 | 27 | # debug 28 | npm-debug.log* 29 | yarn-debug.log* 30 | yarn-error.log* 31 | .pnpm-debug.log* 32 | 33 | # env files (can opt-in for committing if needed) 34 | .env* 35 | 36 | # vercel 37 | .vercel 38 | 39 | # typescript 40 | *.tsbuildinfo 41 | next-env.d.ts 42 | .env*.local 43 | 44 | # BaseHub 45 | .basehub -------------------------------------------------------------------------------- /apps/game-front/README.md: -------------------------------------------------------------------------------- 1 | This is a [Next.js](https://nextjs.org) project bootstrapped with [`create-next-app`](https://nextjs.org/docs/app/api-reference/cli/create-next-app). 2 | 3 | ## Getting Started 4 | 5 | First, run the development server: 6 | 7 | ```bash 8 | npm run dev 9 | # or 10 | yarn dev 11 | # or 12 | pnpm dev 13 | # or 14 | bun dev 15 | ``` 16 | 17 | Open [http://localhost:3000](http://localhost:3000) with your browser to see the result. 18 | 19 | You can start editing the page by modifying `app/page.tsx`. The page auto-updates as you edit the file. 20 | 21 | This project uses [`next/font`](https://nextjs.org/docs/app/building-your-application/optimizing/fonts) to automatically optimize and load [Geist](https://vercel.com/font), a new font family for Vercel. 22 | 23 | ## Learn More 24 | 25 | To learn more about Next.js, take a look at the following resources: 26 | 27 | - [Next.js Documentation](https://nextjs.org/docs) - learn about Next.js features and API. 28 | - [Learn Next.js](https://nextjs.org/learn) - an interactive Next.js tutorial. 29 | 30 | You can check out [the Next.js GitHub repository](https://github.com/vercel/next.js) - your feedback and contributions are welcome! 31 | 32 | ## Deploy on Vercel 33 | 34 | The easiest way to deploy your Next.js app is to use the [Vercel Platform](https://vercel.com/new?utm_medium=default-template&filter=next.js&utm_source=create-next-app&utm_campaign=create-next-app-readme) from the creators of Next.js. 35 | 36 | Check out our [Next.js deployment documentation](https://nextjs.org/docs/app/building-your-application/deploying) for more details. 37 | -------------------------------------------------------------------------------- /apps/game-front/components.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://ui.shadcn.com/schema.json", 3 | "style": "new-york", 4 | "rsc": true, 5 | "tsx": true, 6 | "tailwind": { 7 | "config": "", 8 | "css": "src/app/globals.css", 9 | "baseColor": "gray", 10 | "cssVariables": true, 11 | "prefix": "" 12 | }, 13 | "aliases": { 14 | "components": "@/components", 15 | "utils": "@/lib/utils", 16 | "ui": "@/components/ui", 17 | "lib": "@/lib", 18 | "hooks": "@/hooks" 19 | }, 20 | "iconLibrary": "lucide" 21 | } -------------------------------------------------------------------------------- /apps/game-front/eslint.config.mjs: -------------------------------------------------------------------------------- 1 | import { dirname } from "path"; 2 | import { fileURLToPath } from "url"; 3 | import { FlatCompat } from "@eslint/eslintrc"; 4 | 5 | const __filename = fileURLToPath(import.meta.url); 6 | const __dirname = dirname(__filename); 7 | 8 | const compat = new FlatCompat({ 9 | baseDirectory: __dirname, 10 | }); 11 | 12 | const eslintConfig = [ 13 | ...compat.extends("next/core-web-vitals", "next/typescript"), 14 | { 15 | rules: { 16 | "@typescript-eslint/no-unused-vars": ["error", { 17 | "argsIgnorePattern": "^_", 18 | "varsIgnorePattern": "^_" 19 | }] 20 | } 21 | } 22 | ]; 23 | 24 | export default eslintConfig; 25 | -------------------------------------------------------------------------------- /apps/game-front/next.config.ts: -------------------------------------------------------------------------------- 1 | import type { NextConfig } from "next"; 2 | 3 | const nextConfig: NextConfig = { 4 | /* config options here */ 5 | devIndicators: false 6 | }; 7 | 8 | export default nextConfig; 9 | -------------------------------------------------------------------------------- /apps/game-front/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "game-front", 3 | "version": "0.1.0", 4 | "private": true, 5 | "scripts": { 6 | "dev": "basehub dev & NODE_OPTIONS='--inspect' next dev", 7 | "build": "basehub && next build", 8 | "start": "next start", 9 | "lint": "next lint" 10 | }, 11 | "dependencies": { 12 | "@react-three/drei": "^10.0.6", 13 | "@react-three/fiber": "^9.1.2", 14 | "@react-three/rapier": "^2.1.0", 15 | "@shadcn/ui": "^0.0.4", 16 | "@types/three": "^0.175.0", 17 | "@vercel/analytics": "^1.5.0", 18 | "basehub": "^8.1.32", 19 | "class-variance-authority": "^0.7.1", 20 | "clsx": "^2.1.1", 21 | "eventemitter3": "^5.0.1", 22 | "game-schemas": "workspace:*", 23 | "lodash.throttle": "^4.1.1", 24 | "lucide-react": "^0.487.0", 25 | "merge-refs": "^2.0.0", 26 | "next": "15.2.4", 27 | "partysocket": "^1.1.3", 28 | "peerjs": "^1.5.4", 29 | "peerjs-react": "workspace:*", 30 | "qrcode.react": "^4.2.0", 31 | "react": "^19.0.0", 32 | "react-dom": "^19.0.0", 33 | "tailwind-merge": "^3.2.0", 34 | "three": "^0.175.0", 35 | "tw-animate-css": "^1.2.5", 36 | "zod": "^3.24.2", 37 | "zustand": "^5.0.3" 38 | }, 39 | "devDependencies": { 40 | "@eslint/eslintrc": "^3", 41 | "@tailwindcss/postcss": "^4", 42 | "@types/lodash.throttle": "^4.1.9", 43 | "@types/node": "^20", 44 | "@types/react": "^19", 45 | "@types/react-dom": "^19", 46 | "eslint": "^9", 47 | "eslint-config-next": "15.2.4", 48 | "tailwindcss": "^4", 49 | "typescript": "^5" 50 | } 51 | } 52 | -------------------------------------------------------------------------------- /apps/game-front/postcss.config.mjs: -------------------------------------------------------------------------------- 1 | const config = { 2 | plugins: ["@tailwindcss/postcss"], 3 | }; 4 | 5 | export default config; 6 | -------------------------------------------------------------------------------- /apps/game-front/public/auto1.glb: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/basementstudio/react-miami-game/124af5343009a55f40819f187ce156d30003f800/apps/game-front/public/auto1.glb -------------------------------------------------------------------------------- /apps/game-front/public/file.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /apps/game-front/public/game-track.glb: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/basementstudio/react-miami-game/124af5343009a55f40819f187ce156d30003f800/apps/game-front/public/game-track.glb -------------------------------------------------------------------------------- /apps/game-front/public/globe.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /apps/game-front/public/next.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /apps/game-front/public/vercel.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /apps/game-front/public/window.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /apps/game-front/src/app/components/assets.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import { QueryType } from "@/lib/basehub"; 4 | import { createContext, useContext, useRef } from "react"; 5 | 6 | const AssetContext = createContext(null); 7 | 8 | export function useAssets() { 9 | const assets = useContext(AssetContext); 10 | const assetsRef = useRef(assets); 11 | if (!assets) 12 | throw new Error("useAssets must be used within an AssetsProvider"); 13 | 14 | // avoid re-renders if this changes 15 | return assetsRef.current!; 16 | } 17 | 18 | interface AssetsProviderProps { 19 | children: React.ReactNode; 20 | assets: QueryType; 21 | } 22 | 23 | export const AssetsProvider = ({ children, assets }: AssetsProviderProps) => ( 24 | {children} 25 | ); 26 | -------------------------------------------------------------------------------- /apps/game-front/src/app/components/cube.tsx: -------------------------------------------------------------------------------- 1 | import { forwardRef } from "react"; 2 | import { type Mesh } from "three"; 3 | 4 | export const Cube = forwardRef((_, ref) => { 5 | return ( 6 | 7 | 8 | 9 | 10 | ); 11 | }); 12 | 13 | Cube.displayName = "Cube"; 14 | -------------------------------------------------------------------------------- /apps/game-front/src/app/components/game.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import { Canvas } from "@react-three/fiber"; 4 | import { 5 | Environment, 6 | KeyboardControls, 7 | KeyboardControlsEntry, 8 | PerspectiveCamera, 9 | } from "@react-three/drei"; 10 | import { memo, Suspense, useEffect } from "react"; 11 | import usePartySocket from "partysocket/react"; 12 | import { Player } from "./player"; 13 | import { PartyProvider } from "./use-party"; 14 | import { OtherPlayers } from "./other-players"; 15 | import { Physics } from "@react-three/rapier"; 16 | import { Ground } from "./ground"; 17 | import { InitUserActionType } from "game-schemas"; 18 | import { packMessage } from "@/lib/pack"; 19 | import { Track } from "./track"; 20 | import { CarBodyInstancer } from "./vehicle/body"; 21 | import { GradientBackground } from "./gradient"; 22 | import { create } from "zustand"; 23 | import { WasdControls } from "./wasd-controls"; 24 | 25 | export enum GameControls { 26 | forward = "forward", 27 | back = "back", 28 | left = "left", 29 | right = "right", 30 | drift = "drift", 31 | } 32 | 33 | const controlMap = [ 34 | { name: GameControls.forward, keys: ["ArrowUp", "KeyW"] }, 35 | { name: GameControls.back, keys: ["ArrowDown", "KeyS"] }, 36 | { name: GameControls.left, keys: ["ArrowLeft", "KeyA"] }, 37 | { name: GameControls.right, keys: ["ArrowRight", "KeyD"] }, 38 | { name: GameControls.drift, keys: ["Space"] }, 39 | ] satisfies KeyboardControlsEntry[]; 40 | 41 | interface GameStore { 42 | debug: boolean; 43 | } 44 | 45 | export const useGame = create(() => ({ 46 | debug: false, 47 | })); 48 | 49 | function Game({ roomId }: { roomId: string }) { 50 | const debug = useGame((s) => s.debug); 51 | 52 | const socket = usePartySocket({ 53 | host: process.env.NEXT_PUBLIC_PARTY_SOCKET_HOST, 54 | room: roomId, 55 | }); 56 | 57 | useEffect(() => { 58 | if (typeof window === "undefined") return; 59 | const urlParams = new URLSearchParams(window.location.search); 60 | 61 | const debug = urlParams.has("debug"); 62 | if (debug) useGame.setState({ debug: true }); 63 | }, []); 64 | 65 | useEffect(() => { 66 | const initPlayer: InitUserActionType = { 67 | type: "init-user", 68 | payload: { 69 | name: "John Doe", 70 | pos: { 71 | x: 0, 72 | y: 0, 73 | z: 0, 74 | }, 75 | rot: { 76 | x: 0, 77 | y: 0, 78 | z: 0, 79 | w: 1, 80 | }, 81 | wheel: { 82 | x: 0, 83 | y: 0, 84 | }, 85 | vel: { 86 | x: 0, 87 | y: 0, 88 | z: 0, 89 | }, 90 | timestamp: performance.now(), 91 | }, 92 | }; 93 | 94 | socket.send(packMessage(initPlayer)); 95 | }, [socket]); 96 | 97 | return ( 98 | 99 | 100 | 101 | 102 | 103 | 104 | 105 | 106 | 107 | 108 | 109 | 110 | 111 | 112 | 117 | 118 | 119 | 120 | 121 | 122 | {debug && } 123 | 124 | 125 | ); 126 | } 127 | 128 | function GameCanvasInner({ roomId }: { roomId: string }) { 129 | return ( 130 | 131 | 132 | 133 | ); 134 | } 135 | 136 | export const GameCanvas = memo(GameCanvasInner); 137 | -------------------------------------------------------------------------------- /apps/game-front/src/app/components/gradient.tsx: -------------------------------------------------------------------------------- 1 | import * as THREE from "three"; 2 | 3 | export function GradientBackground({ colorA = "#ff5f6d", colorB = "#ffc371" }) { 4 | // Create shader material for gradient 5 | const uniforms = { 6 | colorA: { value: new THREE.Color(colorA) }, 7 | colorB: { value: new THREE.Color(colorB) }, 8 | }; 9 | 10 | return ( 11 | 12 | 13 | 33 | 34 | ); 35 | } 36 | -------------------------------------------------------------------------------- /apps/game-front/src/app/components/ground.tsx: -------------------------------------------------------------------------------- 1 | import { MeshDiscardMaterial } from "@react-three/drei"; 2 | import { RigidBody } from "@react-three/rapier"; 3 | 4 | export function Ground() { 5 | return ( 6 | <> 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | ); 15 | } 16 | -------------------------------------------------------------------------------- /apps/game-front/src/app/components/other-players.tsx: -------------------------------------------------------------------------------- 1 | /** 2 | * This component handles rendering of other players 3 | * Is in charge of syncing presence and player ids 4 | */ 5 | 6 | import { useEffect, useMemo, useRef } from "react"; 7 | import { useParty } from "./use-party"; 8 | import * as THREE from "three"; 9 | import { useFrame } from "@react-three/fiber"; 10 | import { CarBody, MAX_VEHICLE_INSTANCES } from "./vehicle/body"; 11 | import { ServerMessage, type PresenceType } from "game-schemas"; 12 | import { unpackMessage } from "@/lib/pack"; 13 | import { create } from "zustand"; 14 | 15 | const presenceRef = { 16 | current: {} as Record, 17 | }; 18 | 19 | export interface ServerStatusStore { 20 | playerIds: string[]; 21 | } 22 | 23 | export const useServerStatus = create(() => ({ 24 | playerIds: [], 25 | })); 26 | 27 | export function OtherPlayers() { 28 | const { playerIds } = useServerStatus(); 29 | 30 | const playerIdsRef = useRef([]); 31 | playerIdsRef.current = playerIds; 32 | 33 | const party = useParty(); 34 | const selfId = party.id; 35 | 36 | useEffect(() => { 37 | const controller = new AbortController(); 38 | const signal = controller.signal; 39 | 40 | const messageHandler = (m: MessageEvent) => { 41 | const message = unpackMessage(m.data) as ServerMessage; 42 | 43 | switch (message.type) { 44 | case "pull-server-presence": 45 | const allUsers = message.payload.users; 46 | // remove self from presence update 47 | delete allUsers[selfId]; 48 | 49 | const playerKeys = Object.keys(allUsers); 50 | useServerStatus.setState({ 51 | playerIds: playerKeys, 52 | }); 53 | Object.entries(allUsers).forEach(([id, presence]) => { 54 | presenceRef.current[id] = presence; 55 | }); 56 | break; 57 | case "sync-presence": 58 | const usersToUpdate = message.payload.users; 59 | // remove self from presence update 60 | delete usersToUpdate[selfId]; 61 | 62 | Object.entries(usersToUpdate).forEach(([id, presence]) => { 63 | const currentP = presenceRef.current[id] || {}; 64 | presenceRef.current[id] = { 65 | ...currentP, 66 | ...presence, 67 | }; 68 | }); 69 | break; 70 | case "player-added": 71 | if (message.payload.id === party.id) return; 72 | 73 | useServerStatus.setState((prev) => { 74 | if (!prev.playerIds.includes(message.payload.id)) { 75 | return { 76 | playerIds: [...prev.playerIds, message.payload.id], 77 | }; 78 | } 79 | return prev; 80 | }); 81 | 82 | presenceRef.current[message.payload.id] = message.payload.presence; 83 | 84 | break; 85 | case "player-removed": 86 | useServerStatus.setState((prev) => { 87 | return { 88 | playerIds: prev.playerIds.filter( 89 | (id) => id !== message.payload.id 90 | ), 91 | }; 92 | }); 93 | delete presenceRef.current[message.payload.id]; 94 | break; 95 | } 96 | }; 97 | party.addEventListener("message", messageHandler, { 98 | signal, 99 | }); 100 | 101 | return () => { 102 | controller.abort(); 103 | party.removeEventListener("message", messageHandler); 104 | }; 105 | }, [party, selfId]); 106 | 107 | return ( 108 | <> 109 | {playerIds.map((id, i) => 110 | i < MAX_VEHICLE_INSTANCES - 3 ? : null 111 | )} 112 | 113 | ); 114 | } 115 | 116 | function OtherPlayer({ id }: { id: string }) { 117 | const playerRef = useRef(null); 118 | 119 | const carVectors = useMemo( 120 | () => ({ 121 | originTimestamp: 0, 122 | positionCurrent: new THREE.Vector3(), 123 | velocity: new THREE.Vector3(), 124 | deltaVelocity: new THREE.Vector3(), 125 | wheelRotation: { current: 0 }, 126 | visibleSteering: { current: 0 }, 127 | }), 128 | [] 129 | ); 130 | 131 | useFrame((_, delta) => { 132 | const presence = presenceRef.current[id]; 133 | 134 | if (!presence) return; 135 | if (!playerRef.current) return; 136 | 137 | if (carVectors.originTimestamp !== presence.timestamp) { 138 | carVectors.velocity.copy(presence.vel); 139 | carVectors.positionCurrent.copy(presence.pos); 140 | carVectors.originTimestamp = presence.timestamp; 141 | } 142 | 143 | carVectors.deltaVelocity.copy(carVectors.velocity).multiplyScalar(delta); 144 | 145 | carVectors.positionCurrent.add(carVectors.deltaVelocity); 146 | 147 | playerRef.current.position.lerp( 148 | carVectors.positionCurrent, 149 | Math.min(delta * 10, 1) 150 | ); 151 | playerRef.current.quaternion.set( 152 | presence.rot.x, 153 | presence.rot.y, 154 | presence.rot.z, 155 | presence.rot.w 156 | ); 157 | 158 | carVectors.wheelRotation.current = presence.wheel.x; 159 | carVectors.visibleSteering.current = presence.wheel.y; 160 | }); 161 | 162 | return ; 163 | } 164 | -------------------------------------------------------------------------------- /apps/game-front/src/app/components/player.tsx: -------------------------------------------------------------------------------- 1 | import { Group } from "three"; 2 | import { useRef } from "react"; 3 | import { CarController } from "./vehicle/controller"; 4 | 5 | export function Player() { 6 | const playerObjectRef = useRef(null); 7 | 8 | return ( 9 | <> 10 | 11 | 12 | ); 13 | } 14 | -------------------------------------------------------------------------------- /apps/game-front/src/app/components/track.tsx: -------------------------------------------------------------------------------- 1 | import { MeshDiscardMaterial, useGLTF } from "@react-three/drei"; 2 | import { RigidBody } from "@react-three/rapier"; 3 | import { useEffect, useState } from "react"; 4 | import * as THREE from "three"; 5 | import { GLTF } from "three/examples/jsm/Addons.js"; 6 | import { useAssets } from "./assets"; 7 | 8 | interface TrackGTLF extends GLTF { 9 | nodes: { 10 | track: THREE.Mesh; 11 | collider: THREE.Mesh; 12 | "ground-collider": THREE.Mesh; 13 | }; 14 | } 15 | 16 | export function Track() { 17 | const { models } = useAssets(); 18 | const result = useGLTF(models.track.url) as unknown as TrackGTLF; 19 | 20 | const [trackScene, _] = useState(result.scene); 21 | const [collider, setCollider] = useState(null); 22 | 23 | useEffect(() => { 24 | Object.values(result.nodes).forEach((node) => { 25 | if (node.material) { 26 | const mat = node.material as THREE.MeshStandardMaterial; 27 | if (mat.map && mat.map.anisotropy) { 28 | mat.map.anisotropy = 16; 29 | } 30 | } 31 | }); 32 | 33 | const colliderMesh = result.scene.getObjectByName("collider"); 34 | if (colliderMesh) { 35 | colliderMesh.removeFromParent(); 36 | setCollider(colliderMesh as THREE.Mesh); 37 | } 38 | }, [result]); 39 | 40 | if (!trackScene || !collider) return null; 41 | 42 | return ( 43 | <> 44 | 45 | 46 | 47 | 48 | 49 | 50 | 51 | ); 52 | } 53 | -------------------------------------------------------------------------------- /apps/game-front/src/app/components/ui/button.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import { clsx } from "clsx"; 3 | import { twMerge } from "tailwind-merge"; 4 | 5 | export function cn(...inputs: (string | undefined | null | boolean)[]) { 6 | return twMerge(clsx(inputs)); 7 | } 8 | 9 | interface ButtonProps extends React.ButtonHTMLAttributes { 10 | variant?: "default" | "outline" | "ghost" | "link"; 11 | size?: "default" | "sm" | "lg" | "icon"; 12 | } 13 | 14 | export function Button({ 15 | className, 16 | variant = "default", 17 | size = "default", 18 | ...props 19 | }: ButtonProps) { 20 | const baseStyle = 21 | "inline-flex items-center justify-center rounded-md font-medium transition-colors focus-visible:outline-none disabled:opacity-50"; 22 | 23 | const variants = { 24 | default: "bg-zinc-800 text-zinc-100 hover:bg-zinc-700", 25 | outline: 26 | "border border-zinc-700 bg-transparent hover:bg-zinc-800 text-zinc-300", 27 | ghost: "bg-transparent hover:bg-zinc-800 text-zinc-300", 28 | link: "text-zinc-300 underline-offset-4 hover:underline bg-transparent", 29 | }; 30 | 31 | const sizes = { 32 | default: "h-9 px-4 py-2 text-sm", 33 | sm: "h-8 px-3 py-1 text-xs", 34 | lg: "h-10 px-8 py-2 text-base", 35 | icon: "h-9 w-9 p-0", 36 | }; 37 | 38 | return ( 39 | 43 | {children} 44 | 45 | 46 | ); 47 | } 48 | -------------------------------------------------------------------------------- /apps/game-front/src/app/components/use-party.tsx: -------------------------------------------------------------------------------- 1 | import { createContext, useContext } from "react"; 2 | import type { PartySocket } from "partysocket"; 3 | 4 | interface PartyContextType { 5 | socket: PartySocket; 6 | } 7 | 8 | const PartyContext = createContext(null); 9 | 10 | export function PartyProvider({ 11 | children, 12 | socket, 13 | }: { 14 | children: React.ReactNode; 15 | socket: PartySocket; 16 | }) { 17 | return ( 18 | {children} 19 | ); 20 | } 21 | 22 | export function useParty() { 23 | const context = useContext(PartyContext); 24 | if (!context) throw new Error("useParty must be used within a PartyProvider"); 25 | return context.socket; 26 | } 27 | -------------------------------------------------------------------------------- /apps/game-front/src/app/components/utils/orientation-controls.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import { ArrowBigUp } from "lucide-react"; 4 | import { useDeviceOrientation } from "@/hooks/use-device-orientation"; 5 | import { ArrowBigDown } from "lucide-react"; 6 | import { useCallback, useEffect } from "react"; 7 | import { useRef, useState } from "react"; 8 | import { cn } from "@/lib/utils"; 9 | import { Button } from "../ui/button"; 10 | 11 | interface OrientationControlsProps { 12 | onAccelerationChange?: (enabled: boolean) => void; 13 | onBreakChange?: (enabled: boolean) => void; 14 | onSteeringChange?: (angle: number) => void; 15 | rotationLimit?: number; 16 | showInclination?: boolean; 17 | } 18 | 19 | export function OrientationControls({ 20 | onAccelerationChange, 21 | onBreakChange, 22 | onSteeringChange, 23 | rotationLimit = 90, 24 | showInclination = false, 25 | }: OrientationControlsProps) { 26 | const [acceleration, setAcceleration] = useState(false); 27 | const [brake, setBrake] = useState(false); 28 | 29 | const onAccelerationChangeRef = useRef(onAccelerationChange); 30 | onAccelerationChangeRef.current = onAccelerationChange; 31 | const onBreakChangeRef = useRef(onBreakChange); 32 | onBreakChangeRef.current = onBreakChange; 33 | const onSteeringChangeRef = useRef(onSteeringChange); 34 | onSteeringChangeRef.current = onSteeringChange; 35 | 36 | useEffect(() => { 37 | onAccelerationChangeRef.current?.(acceleration); 38 | }, [acceleration]); 39 | 40 | useEffect(() => { 41 | onBreakChangeRef.current?.(brake); 42 | }, [brake]); 43 | 44 | const squareRef = useRef(null); 45 | const [orientationType, setOrientationType] = 46 | useState(null); 47 | 48 | const handleOrientationUpdate = useCallback( 49 | (event: DeviceOrientationEvent) => { 50 | const currentOrientation = 51 | typeof screen !== "undefined" 52 | ? screen.orientation.type 53 | : "portrait-primary"; // Default or read 54 | setOrientationType(currentOrientation); // Update orientation type state 55 | 56 | const rotationValue = event.beta ?? 0; // Use beta for landscape 57 | const clampedRotationValue = Math.max( 58 | -rotationLimit, 59 | Math.min(rotationLimit, rotationValue) 60 | ); 61 | 62 | onSteeringChangeRef.current?.(clampedRotationValue); 63 | 64 | if (squareRef.current) { 65 | squareRef.current.style.transform = `rotate(${-clampedRotationValue}deg)`; 66 | } 67 | }, 68 | [rotationLimit] 69 | ); // No dependencies needed here as it reads screen orientation directly 70 | 71 | const { 72 | requestDeviceOrientation, 73 | deviceOrientationStarted, 74 | deviceOrientationError, 75 | } = useDeviceOrientation({ 76 | onUpdate: handleOrientationUpdate, 77 | onError: (err) => console.error("Device Orientation Hook Error:", err), 78 | }); 79 | 80 | // Effect to update orientation type on change 81 | useEffect(() => { 82 | const updateOrientation = () => { 83 | if (typeof screen !== "undefined") { 84 | setOrientationType(screen.orientation.type); 85 | } 86 | }; 87 | if (typeof screen !== "undefined" && screen.orientation) { 88 | screen.orientation.addEventListener("change", updateOrientation); 89 | updateOrientation(); // Initial check 90 | return () => 91 | screen.orientation.removeEventListener("change", updateOrientation); 92 | } 93 | }, []); 94 | 95 | if (!deviceOrientationStarted || deviceOrientationError) { 96 | return ( 97 |
98 | {!deviceOrientationStarted && ( 99 | 102 | )} 103 | {deviceOrientationError && ( 104 |

105 | {deviceOrientationError} 106 |

107 | )} 108 |
109 | ); 110 | } 111 | 112 | const okOrientation = 113 | orientationType && orientationType.startsWith("landscape"); 114 | 115 | if (!okOrientation) { 116 | return ( 117 |
118 |

119 | Rotate your device to landscape mode. 120 |

121 |
122 | ); 123 | } 124 | 125 | return ( 126 |
127 | {!deviceOrientationStarted && ( 128 | 134 | )} 135 | {showInclination && ( 136 |
140 | )} 141 |
142 |
setBrake(true)} 144 | onPointerUp={() => setBrake(false)} 145 | className="w-1/2 h-full flex items-center justify-center" 146 | > 147 | 148 |
149 |
setAcceleration(true)} 151 | onPointerUp={() => setAcceleration(false)} 152 | className="w-1/2 h-full flex items-center justify-center" 153 | > 154 | 157 |
158 |
159 |
160 | ); 161 | } 162 | -------------------------------------------------------------------------------- /apps/game-front/src/app/components/utils/overscroll-prevent.tsx: -------------------------------------------------------------------------------- 1 | // Hack made by https://gsap.com/docs/v3/HelperFunctions/helpers/stopOverscroll/ 2 | 3 | /* eslint-disable @typescript-eslint/no-unused-expressions */ 4 | /* eslint-disable @typescript-eslint/no-explicit-any */ 5 | "use client"; 6 | 7 | import { useEffect, useRef } from "react"; 8 | 9 | export function OverscrollPrevent() { 10 | const overscrollPreventedRef = useRef(false); 11 | useEffect(() => { 12 | if (typeof document !== "undefined" && !overscrollPreventedRef.current) { 13 | stopOverscroll(document.body); 14 | overscrollPreventedRef.current = true; 15 | } 16 | }, []); 17 | 18 | return null; 19 | } 20 | 21 | function stopOverscroll(e: HTMLElement | Window) { 22 | let element = e; 23 | 24 | if (e === document.body || e === document.documentElement) { 25 | element = window; 26 | } 27 | 28 | let lastScroll = 0; 29 | let lastTouch: number = 0; 30 | let forcing: boolean = false; 31 | let forward: boolean = true; 32 | const isRoot = element === window; 33 | const scroller = ( 34 | isRoot ? document.scrollingElement : element 35 | ) as HTMLElement; 36 | const ua = window.navigator.userAgent + ""; 37 | const getMax = isRoot 38 | ? () => scroller.scrollHeight - window.innerHeight 39 | : () => scroller.scrollHeight - scroller.clientHeight; 40 | const addListener = (type: string, func: any) => 41 | element.addEventListener(type, func, { passive: false }); 42 | const revert = () => { 43 | scroller.style.overflowY = "auto"; 44 | forcing = false; 45 | }; 46 | const kill = () => { 47 | forcing = true; 48 | scroller.style.overflowY = "hidden"; 49 | !forward && scroller.scrollTop < 1 50 | ? (scroller.scrollTop = 1) 51 | : (scroller.scrollTop = getMax() - 1); 52 | setTimeout(revert, 1); 53 | }; 54 | const handleTouch = (e: any) => { 55 | const evt = e.changedTouches ? e.changedTouches[0] : e, 56 | forward = evt.pageY <= lastTouch; 57 | if ( 58 | ((!forward && scroller.scrollTop <= 1) || 59 | (forward && scroller.scrollTop >= getMax() - 1)) && 60 | e.type === "touchmove" 61 | ) { 62 | e.preventDefault(); 63 | } else { 64 | lastTouch = evt.pageY; 65 | } 66 | }; 67 | const handleScroll = (e: any) => { 68 | if (!forcing) { 69 | const scrollTop = scroller.scrollTop; 70 | forward = scrollTop > lastScroll; 71 | if ( 72 | (!forward && scrollTop < 1) || 73 | (forward && scrollTop >= getMax() - 1) 74 | ) { 75 | e.preventDefault(); 76 | kill(); 77 | } 78 | lastScroll = scrollTop; 79 | } 80 | }; 81 | if ("ontouchend" in document && !!ua.match(/Version\/[\d\.]+.*Safari/)) { 82 | addListener("scroll", handleScroll); 83 | addListener("touchstart", handleTouch); 84 | addListener("touchmove", handleTouch); 85 | } 86 | if (scroller) { 87 | scroller.style.overscrollBehavior = "none"; 88 | } 89 | } 90 | -------------------------------------------------------------------------------- /apps/game-front/src/app/components/vehicle/body.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | /** Inspired by https://github.com/isaac-mason/sketches/blob/main/sketches/rapier/arcade-vehicle-controller/src/sketch.tsx */ 4 | 5 | import { useFrame } from "@react-three/fiber"; 6 | import { forwardRef, useMemo, useRef } from "react"; 7 | import * as THREE from "three"; 8 | import { WHEEL } from "./constants"; 9 | import { useGLTF, useTexture } from "@react-three/drei"; 10 | import { GLTF } from "three/examples/jsm/Addons.js"; 11 | import { createInstance } from "./instances"; 12 | import { useAssets } from "../assets"; 13 | import { useIsMobile } from "@/hooks/use-is-mobile"; 14 | 15 | const [CarInstancer, CarInstance] = createInstance(); 16 | const [WheelsInstancer, WheelsInstance] = createInstance(); 17 | 18 | export const MAX_VEHICLE_INSTANCES = 800; 19 | 20 | interface CarGLTF extends GLTF { 21 | nodes: { 22 | Body: THREE.Mesh; 23 | antena: THREE.Mesh; 24 | wheel: THREE.Mesh; 25 | }; 26 | } 27 | 28 | export function CarBodyInstancer({ children }: { children: React.ReactNode }) { 29 | const { 30 | models: { vehicle, bodyMobile }, 31 | } = useAssets(); 32 | 33 | const mobileTexture = useTexture(bodyMobile.url); 34 | const { nodes } = useGLTF(vehicle.url) as unknown as CarGLTF; 35 | 36 | const { 37 | bodyMaterialHigh, 38 | bodyMaterialLow, 39 | wheelMaterialHigh, 40 | wheelMaterialLow, 41 | } = useMemo(() => { 42 | mobileTexture.colorSpace = THREE.SRGBColorSpace; 43 | mobileTexture.flipY = false; 44 | mobileTexture.anisotropy = 8; 45 | 46 | const bodyMaterialHigh = ( 47 | nodes.Body.material as THREE.MeshStandardMaterial 48 | ).clone(); 49 | 50 | const bodyMaterialLow = new THREE.MeshBasicMaterial({ 51 | map: mobileTexture, 52 | }); 53 | 54 | const wheelMaterialHigh = ( 55 | nodes.wheel.material as THREE.MeshStandardMaterial 56 | ).clone(); 57 | 58 | const wheelMaterialLow = new THREE.MeshBasicMaterial({ 59 | map: mobileTexture, 60 | }); 61 | 62 | return { 63 | bodyMaterialHigh, 64 | bodyMaterialLow, 65 | wheelMaterialHigh, 66 | wheelMaterialLow, 67 | }; 68 | }, [nodes, mobileTexture]); 69 | 70 | const isMobile = useIsMobile(); 71 | if (typeof isMobile === "undefined") return null; 72 | 73 | return ( 74 | 75 | 81 | 87 | {children} 88 | 89 | 90 | 91 | ); 92 | } 93 | 94 | const wheels = [ 95 | // front 96 | { 97 | position: new THREE.Vector3( 98 | -WHEEL.SIDE_OFFSET, 99 | WHEEL.HEIGHT_OFFSET, 100 | WHEEL.FRONT_OFFSET 101 | ), 102 | }, 103 | { 104 | position: new THREE.Vector3( 105 | WHEEL.SIDE_OFFSET, 106 | WHEEL.HEIGHT_OFFSET, 107 | WHEEL.FRONT_OFFSET 108 | ), 109 | }, 110 | // rear 111 | { 112 | position: new THREE.Vector3( 113 | -WHEEL.SIDE_OFFSET, 114 | WHEEL.HEIGHT_OFFSET, 115 | WHEEL.REAR_OFFSET 116 | ), 117 | }, 118 | { 119 | position: new THREE.Vector3( 120 | WHEEL.SIDE_OFFSET, 121 | WHEEL.HEIGHT_OFFSET, 122 | WHEEL.REAR_OFFSET 123 | ), 124 | }, 125 | ]; 126 | 127 | export interface VehicleVectors { 128 | wheelRotation: { current: number }; 129 | visibleSteering: { current: number }; 130 | } 131 | 132 | export const CarBody = forwardRef( 133 | ({ v }, ref) => { 134 | const wheelsRef = useRef<(THREE.Object3D | null)[]>([]); 135 | 136 | useFrame(() => { 137 | wheelsRef.current.forEach((wheel) => { 138 | if (!wheel) return; 139 | 140 | wheel.rotation.order = "YXZ"; 141 | wheel.rotation.x = v.wheelRotation.current * 0.2; 142 | }); 143 | 144 | wheelsRef.current[1]!.rotation.y = v.visibleSteering.current * 0.5; 145 | wheelsRef.current[0]!.rotation.y = v.visibleSteering.current * 0.5; 146 | }); 147 | 148 | return ( 149 | 150 | 155 | 156 | {wheels.map((wheel, index) => ( 157 | (wheelsRef.current[index] = ref)} 160 | position={wheel.position} 161 | > 162 | 166 | 167 | ))} 168 | 169 | ); 170 | } 171 | ); 172 | 173 | CarBody.displayName = "CarBody"; 174 | -------------------------------------------------------------------------------- /apps/game-front/src/app/components/vehicle/constants.ts: -------------------------------------------------------------------------------- 1 | // Car dimensions 2 | export const CAR_DIMENSIONS = { 3 | WIDTH: 0.1, 4 | HEIGHT: 0.08, 5 | LENGTH: 0.2, 6 | MASS: 2, 7 | COLLIDER_RADIUS: 0.1, 8 | } as const; 9 | 10 | // Wheel properties 11 | export const WHEEL = { 12 | RADIUS: 0.052, 13 | WIDTH: 0.03, 14 | HEIGHT_OFFSET: -0.0, 15 | FRONT_OFFSET: -0.1, 16 | REAR_OFFSET: 0.1, 17 | SIDE_OFFSET: 0.07, 18 | } as const; -------------------------------------------------------------------------------- /apps/game-front/src/app/components/vehicle/controller.tsx: -------------------------------------------------------------------------------- 1 | /** 2 | * Logic to control the vehicle by the player 3 | * It will handle both keyboard and joystick controls 4 | * Physics inspired by https://github.com/isaac-mason/sketches/blob/main/sketches/rapier/arcade-vehicle-controller/src/sketch.tsx 5 | * */ 6 | 7 | import { useKeyboardControls } from "@react-three/drei"; 8 | import { useFrame, useThree } from "@react-three/fiber"; 9 | import throttle from "lodash.throttle"; 10 | import { 11 | BallCollider, 12 | RapierRigidBody, 13 | RigidBody, 14 | RigidBodyProps, 15 | useAfterPhysicsStep, 16 | useBeforePhysicsStep, 17 | useRapier, 18 | } from "@react-three/rapier"; 19 | import { forwardRef, useEffect, useMemo, useRef } from "react"; 20 | import mergeRefs from "merge-refs"; 21 | import * as THREE from "three"; 22 | import { GameControls, useGame } from "../game"; 23 | import { valueRemap } from "@/lib/math"; 24 | import { clamp, degToRad } from "three/src/math/MathUtils.js"; 25 | import { 26 | controlsInstance, 27 | useControlsPeerEvent, 28 | useOnControlsMessage, 29 | } from "@/hooks/use-peer-controls"; 30 | import { CarBody } from "./body"; 31 | import { CAR_DIMENSIONS, WHEEL } from "./constants"; 32 | import { UpdatePresenceActionType } from "game-schemas"; 33 | import { useParty } from "../use-party"; 34 | import { packMessage } from "@/lib/pack"; 35 | 36 | const PLAYER_UPDATE_FPS = 15; 37 | 38 | const initialPosition = new THREE.Vector3(0, 0.01, 0); 39 | 40 | const up = new THREE.Vector3(0, 1, 0); 41 | const maxForwardSpeed = 8; 42 | const maxReverseSpeed = -4; 43 | 44 | const joysticRemapFrom = 30; 45 | const joysticRemapTo = 0.04; 46 | 47 | const CAMERA = { 48 | positionOffset: new THREE.Vector3(0, 0.3, 0.8), 49 | lookAtOffset: new THREE.Vector3(0, 0, -2), 50 | // positionOffset: new THREE.Vector3(1, 0.1, -0.2), 51 | // lookAtOffset: new THREE.Vector3(0, 0.05, 0), 52 | cameraTargetPosition: new THREE.Vector3(0, 0, 0), 53 | cameraTargetLookat: new THREE.Vector3(), 54 | cameraPosition: new THREE.Vector3(), 55 | cameraLookat: new THREE.Vector3(), 56 | }; 57 | 58 | const bodyPosition = new THREE.Vector3(); 59 | const _bodyEuler = new THREE.Euler(); 60 | const _cameraPosition = new THREE.Vector3(); 61 | const _impulse = new THREE.Vector3(); 62 | 63 | // TODO: replace this with _bodyPosition 64 | const playerPos = new THREE.Vector3(0, 0, 0).copy(initialPosition); 65 | const playerPosBefore = new THREE.Vector3(0, 0, 0).copy(initialPosition); 66 | const playerVel = new THREE.Vector3(0, 0, 0); 67 | const playerRot = new THREE.Quaternion(); 68 | 69 | export interface CarControllerVectors { 70 | activeJoystick: { current: boolean }; 71 | joystickRotation: { current: number }; 72 | joystickAcceleration: { current: boolean }; 73 | joystickBrake: { current: boolean }; 74 | wheelRotation: { current: number }; 75 | steeringInput: { current: number }; 76 | visibleSteering: { current: number }; 77 | } 78 | 79 | export const controllerVectors: CarControllerVectors = { 80 | activeJoystick: { current: false }, 81 | joystickRotation: { current: 0 }, 82 | joystickAcceleration: { current: false }, 83 | joystickBrake: { current: false }, 84 | wheelRotation: { current: 0 }, 85 | steeringInput: { current: 0 }, 86 | visibleSteering: { current: 0 }, 87 | }; 88 | 89 | export const CarController = forwardRef( 90 | function CarControllerInner(_props, ref) { 91 | const groupRef = useRef(null!); 92 | 93 | // update multiplayer 94 | const party = useParty(); 95 | 96 | const updatePosition = useMemo(() => { 97 | const newPresence = { 98 | type: "update-presence", 99 | payload: { 100 | pos: { 101 | x: playerPos.x, 102 | y: playerPos.y, 103 | z: playerPos.z, 104 | }, 105 | vel: { 106 | x: playerVel.x, 107 | y: playerVel.y, 108 | z: playerVel.z, 109 | }, 110 | rot: { 111 | x: playerRot.x, 112 | y: playerRot.y, 113 | z: playerRot.z, 114 | w: playerRot.w, 115 | }, 116 | wheel: { 117 | x: controllerVectors.wheelRotation.current, 118 | y: controllerVectors.visibleSteering.current, 119 | }, 120 | timestamp: performance.now(), 121 | }, 122 | } satisfies UpdatePresenceActionType; 123 | 124 | return throttle(() => { 125 | newPresence.payload.pos.x = playerPos.x; 126 | newPresence.payload.pos.y = playerPos.y; 127 | newPresence.payload.pos.z = playerPos.z; 128 | newPresence.payload.vel.x = playerVel.x; 129 | newPresence.payload.vel.y = playerVel.y; 130 | newPresence.payload.vel.z = playerVel.z; 131 | newPresence.payload.rot.x = playerRot.x; 132 | newPresence.payload.rot.y = playerRot.y; 133 | newPresence.payload.rot.z = playerRot.z; 134 | newPresence.payload.rot.w = playerRot.w; 135 | newPresence.payload.wheel.x = controllerVectors.wheelRotation.current; 136 | newPresence.payload.wheel.y = controllerVectors.visibleSteering.current; 137 | newPresence.payload.timestamp = performance.now(); 138 | 139 | party.send(packMessage(newPresence)); 140 | }, 1000 / PLAYER_UPDATE_FPS); 141 | }, [party]); 142 | 143 | useFrame((_, delta) => { 144 | if (!groupRef.current) return; 145 | 146 | groupRef.current.getWorldPosition(playerPos); 147 | groupRef.current.getWorldQuaternion(playerRot); 148 | 149 | // calculate translation in one second 150 | playerVel.copy(playerPos).sub(playerPosBefore).divideScalar(delta); 151 | playerPosBefore.copy(playerPos); 152 | 153 | updatePosition(); 154 | }); 155 | 156 | // joystick controls 157 | useControlsPeerEvent("connection", () => { 158 | controllerVectors.activeJoystick.current = true; 159 | }); 160 | 161 | useControlsPeerEvent("disconnected", () => { 162 | if (Object.keys(controlsInstance.connections).length === 0) { 163 | controllerVectors.activeJoystick.current = false; 164 | } 165 | }); 166 | 167 | useOnControlsMessage("steeringAngle", (message) => { 168 | controllerVectors.joystickRotation.current = message.data; 169 | }); 170 | 171 | useOnControlsMessage("acceleration", (message) => { 172 | controllerVectors.joystickAcceleration.current = message.data; 173 | }); 174 | 175 | useOnControlsMessage("brake", (message) => { 176 | controllerVectors.joystickBrake.current = message.data; 177 | }); 178 | 179 | return ( 180 | 181 | ); 182 | } 183 | ); 184 | 185 | CarController.displayName = "CarController"; 186 | 187 | interface CarPhysicsProps extends RigidBodyProps { 188 | vectors: CarControllerVectors; 189 | } 190 | 191 | export const CarPhysics = forwardRef( 192 | ({ vectors, ...props }, ref) => { 193 | const { rapier, world } = useRapier(); 194 | 195 | // physics 196 | const bodyRef = useRef(null!); 197 | const groupRef = useRef(null!); 198 | 199 | const steeringAngle = useRef(0); 200 | const steeringAngleQuat = useRef(new THREE.Quaternion()); 201 | 202 | const driftSteeringAngle = useRef(0); 203 | 204 | const driftingLeft = useRef(false); 205 | const driftingRight = useRef(false); 206 | const driftSteeringVisualAngle = useRef(0); 207 | 208 | const speed = useRef(0); 209 | const grounded = useRef(false); 210 | 211 | const [, getKeyboardControls] = useKeyboardControls(); 212 | 213 | useBeforePhysicsStep(() => { 214 | const controls = getKeyboardControls(); 215 | const { forward, back, left, right, drift } = controls; 216 | 217 | const impulse = _impulse.set(0, 0, -speed.current).multiplyScalar(5); 218 | 219 | // check if grounded 220 | const groundRayResult = world.castRay( 221 | new rapier.Ray(bodyRef.current.translation(), { x: 0, y: -1, z: 0 }), 222 | 1, 223 | false, 224 | undefined, 225 | undefined, 226 | undefined, 227 | bodyRef.current 228 | ); 229 | grounded.current = groundRayResult !== null; 230 | 231 | // steering angle 232 | vectors.steeringInput.current = Number(left) - Number(right); 233 | vectors.visibleSteering.current = vectors.steeringInput.current; 234 | // udpate angle based on direction 235 | if (impulse.z > 0) { 236 | vectors.steeringInput.current *= -1; 237 | } 238 | 239 | // drifting controls 240 | if (!drift) { 241 | driftingLeft.current = false; 242 | driftingRight.current = false; 243 | } 244 | 245 | if (drift && grounded.current && 1 < speed.current) { 246 | if (left) { 247 | driftingLeft.current = true; 248 | } 249 | 250 | if (right) { 251 | driftingRight.current = true; 252 | } 253 | 254 | if ( 255 | (driftingLeft.current && driftingRight.current) || 256 | (!left && !right) 257 | ) { 258 | driftingLeft.current = false; 259 | driftingRight.current = false; 260 | } 261 | } else { 262 | driftingLeft.current = false; 263 | driftingRight.current = false; 264 | } 265 | 266 | // drift steering 267 | let driftSteeringTarget = 0; 268 | 269 | if (driftingLeft.current) { 270 | driftSteeringTarget = 1; 271 | } else if (driftingRight.current) { 272 | driftSteeringTarget = -1; 273 | } 274 | 275 | driftSteeringAngle.current = THREE.MathUtils.lerp( 276 | driftSteeringAngle.current, 277 | driftSteeringTarget, 278 | 0.1 279 | ); 280 | 281 | if (Math.abs(speed.current) > 0.1) { 282 | let steeringMultiply = valueRemap( 283 | Math.abs(speed.current), 284 | 0.1, 285 | 0.9, 286 | 0, 287 | 1 288 | ); 289 | steeringMultiply = clamp(steeringMultiply, 0, 1); 290 | // update vehicle angle 291 | steeringAngle.current += 292 | vectors.steeringInput.current * 0.02 * steeringMultiply; 293 | steeringAngle.current += 294 | driftSteeringAngle.current * 0.01 * steeringMultiply; 295 | 296 | if (vectors.activeJoystick.current) { 297 | steeringAngle.current += 298 | valueRemap( 299 | vectors.joystickRotation.current, 300 | -joysticRemapFrom, 301 | joysticRemapFrom, 302 | joysticRemapTo, 303 | -joysticRemapTo 304 | ) * steeringMultiply; 305 | } 306 | steeringAngleQuat.current.setFromAxisAngle(up, steeringAngle.current); 307 | impulse.applyQuaternion(steeringAngleQuat.current); 308 | } 309 | 310 | if (vectors.activeJoystick.current) { 311 | vectors.visibleSteering.current = 312 | -degToRad(vectors.joystickRotation.current) * 2; 313 | } 314 | 315 | // acceleration and deceleration 316 | let speedTarget = 0; 317 | 318 | if (forward || vectors.joystickAcceleration.current) { 319 | speedTarget = maxForwardSpeed; 320 | } else if (back || vectors.joystickBrake.current) { 321 | speedTarget = maxReverseSpeed; 322 | } 323 | 324 | speed.current = THREE.MathUtils.lerp(speed.current, speedTarget, 0.03); 325 | 326 | // apply impulse 327 | if (impulse.length() > 0) { 328 | bodyRef.current.applyImpulse(impulse, true); 329 | } 330 | 331 | // damping 332 | bodyRef.current.applyImpulse( 333 | { 334 | x: -bodyRef.current.linvel().x * 1.5, 335 | y: -Math.abs(speed.current) * 0.45, 336 | z: -bodyRef.current.linvel().z * 1.5, 337 | }, 338 | true 339 | ); 340 | }); 341 | 342 | useEffect(() => { 343 | bodyRef.current.setTranslation(initialPosition, true); 344 | }, []); 345 | 346 | const camera = useThree((state) => state.camera); 347 | const prevTimestamp = useRef(0); 348 | 349 | const springNormal = new THREE.Vector3(); 350 | const springVector = ( 351 | vectorA: THREE.Vector3, 352 | vectorB: THREE.Vector3, 353 | maxDistance: number, 354 | force: number, 355 | delta: number 356 | ) => { 357 | vectorA.lerp(vectorB, force * delta); 358 | 359 | const len = vectorA.distanceTo(vectorB); 360 | 361 | if (len > maxDistance) { 362 | springNormal.copy(vectorB).sub(vectorA).normalize(); 363 | vectorA.copy(vectorB).sub(springNormal.multiplyScalar(maxDistance)); 364 | } 365 | 366 | return vectorA; 367 | }; 368 | 369 | const debug = useGame((s) => s.debug); 370 | 371 | useAfterPhysicsStep(() => { 372 | const now = performance.now(); 373 | const d = now - prevTimestamp.current; 374 | prevTimestamp.current = now; 375 | const delta = Math.min(d, 0.1); 376 | // body position 377 | if (!bodyRef.current) return; 378 | 379 | bodyPosition.copy(bodyRef.current.translation()); 380 | bodyPosition.y = WHEEL.RADIUS - WHEEL.HEIGHT_OFFSET; 381 | groupRef.current.position.copy(bodyPosition); 382 | 383 | // update mesh rotation 384 | groupRef.current.quaternion.copy(steeringAngleQuat.current); 385 | groupRef.current.updateMatrix(); 386 | 387 | // drift visual angle 388 | driftSteeringVisualAngle.current = THREE.MathUtils.lerp( 389 | driftSteeringVisualAngle.current, 390 | driftSteeringAngle.current, 391 | delta * 10 392 | ); 393 | 394 | // body rotation 395 | const bodyEuler = _bodyEuler.setFromQuaternion( 396 | groupRef.current.quaternion, 397 | "YXZ" 398 | ); 399 | bodyEuler.y = bodyEuler.y + driftSteeringVisualAngle.current * 0.35; 400 | groupRef.current.rotation.copy(bodyEuler); 401 | 402 | // wheel rotation 403 | vectors.wheelRotation.current -= (speed.current / 10) * delta * 100; 404 | 405 | // camera 406 | if (!debug) { 407 | // update lookat 408 | CAMERA.cameraTargetLookat 409 | .copy(CAMERA.lookAtOffset) 410 | .applyQuaternion(groupRef.current.quaternion); 411 | CAMERA.cameraTargetLookat.add(bodyPosition); 412 | springVector( 413 | CAMERA.cameraLookat, 414 | CAMERA.cameraTargetLookat, 415 | 0.1, 416 | 2, 417 | delta 418 | ); 419 | camera.lookAt(CAMERA.cameraLookat); 420 | 421 | // update position 422 | CAMERA.cameraTargetPosition 423 | .copy(CAMERA.positionOffset) 424 | .applyQuaternion(groupRef.current.quaternion); 425 | CAMERA.cameraTargetPosition.add(bodyPosition); 426 | CAMERA.cameraPosition.copy(CAMERA.cameraTargetPosition); 427 | 428 | springVector(camera.position, CAMERA.cameraPosition, 0.3, 2, delta); 429 | } 430 | }); 431 | 432 | return ( 433 | <> 434 | {/* body */} 435 | 445 | 449 | 450 | 451 | {/* vehicle */} 452 | 453 | 454 | 455 | 456 | 457 | 458 | ); 459 | } 460 | ); 461 | 462 | CarPhysics.displayName = "CarPhysics"; 463 | -------------------------------------------------------------------------------- /apps/game-front/src/app/components/vehicle/instances.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import type { ComponentProps, JSX, PropsWithChildren } from "react"; 4 | import type { StoreApi, UseBoundStore } from "zustand"; 5 | import { create } from "zustand"; 6 | import { Instances, type Instance } from "@react-three/drei"; 7 | 8 | export type InstanceProps = ComponentProps; 9 | 10 | export const createInstance = () => { 11 | const useStore = create<{ 12 | instance: typeof Instance; 13 | }>(() => ({ 14 | instance: null as unknown as typeof Instance, 15 | })); 16 | 17 | function Provider(props: ComponentProps) { 18 | return ; 19 | } 20 | 21 | function Client(props: PropsWithChildren): JSX.Element { 22 | const InstanceResult = useStore((s) => s.instance); 23 | return ; 24 | } 25 | 26 | return [Provider, Client] as const; 27 | }; 28 | 29 | type InstanceStore = UseBoundStore< 30 | StoreApi<{ 31 | instance: typeof Instance; 32 | }> 33 | >; 34 | 35 | interface InstancesProviderProps extends ComponentProps { 36 | store: InstanceStore; 37 | } 38 | 39 | function InstancesProvider({ 40 | store, 41 | children, 42 | ...props 43 | }: PropsWithChildren): JSX.Element { 44 | return ( 45 | 46 | { 47 | ((InstanceResult: unknown) => { 48 | store.setState({ instance: InstanceResult as typeof Instance }); 49 | return children as JSX.Element; 50 | }) as unknown as JSX.Element 51 | } 52 | 53 | ); 54 | } 55 | -------------------------------------------------------------------------------- /apps/game-front/src/app/components/wasd-controls.tsx: -------------------------------------------------------------------------------- 1 | import { KeyboardControls, PerspectiveCamera } from "@react-three/drei"; 2 | import { useKeyboardControls } from "@react-three/drei"; 3 | import { type ComponentRef, useEffect, useMemo, useRef, useState } from "react"; 4 | import { Euler, Vector3 } from "three"; 5 | 6 | import { useFrame } from "@react-three/fiber"; 7 | 8 | enum Controls { 9 | forward = "forward", 10 | backward = "backward", 11 | left = "left", 12 | right = "right", 13 | up = "up", 14 | down = "down", 15 | fast = "fast", 16 | fov = "fov", 17 | } 18 | 19 | export function WasdControls() { 20 | return ( 21 | 33 | 34 | 35 | ); 36 | } 37 | 38 | function ControlsInner() { 39 | const [, get] = useKeyboardControls(); 40 | 41 | const fov = useKeyboardControls((s) => s.fov); 42 | 43 | const cameraRef = useRef>(null); 44 | const vectors = useMemo( 45 | () => ({ 46 | moveTo: new Vector3(), 47 | cameraEuler: new Euler(), 48 | }), 49 | [] 50 | ); 51 | 52 | vectors.cameraEuler.order = "YXZ"; 53 | 54 | useFrame(() => { 55 | if (!cameraRef.current) return; 56 | const controls = get(); 57 | if (!controls) return; 58 | 59 | const { forward, backward, left, right, up, down, fast } = controls; 60 | 61 | const speed = fast ? 0.1 : 0.05; 62 | 63 | vectors.moveTo.set( 64 | left ? -speed : right ? speed : 0, 65 | 0, 66 | forward ? -speed : backward ? speed : 0 67 | ); 68 | 69 | vectors.cameraEuler.copy(cameraRef.current.rotation); 70 | vectors.moveTo.applyEuler(vectors.cameraEuler); 71 | vectors.moveTo.y += up ? speed : down ? -speed : 0; 72 | 73 | cameraRef.current.position.add(vectors.moveTo); 74 | }); 75 | 76 | useEffect(() => { 77 | if (typeof window === "undefined") return; 78 | const controller = new AbortController(); 79 | const signal = controller.signal; 80 | 81 | let isPointerDown = false; 82 | let isContextMenu = false; 83 | 84 | // Capture clicks 85 | const onPointerDown = (event: PointerEvent) => { 86 | const target = event.target as HTMLElement; 87 | const isLevaElement = target.closest('[class^="leva-c-"]'); 88 | if (isLevaElement) return; 89 | isPointerDown = true; 90 | document.body.requestPointerLock(); 91 | }; 92 | const onContextMenu = (event: MouseEvent) => { 93 | const target = event.target as HTMLElement; 94 | const isLevaElement = target.closest('[class^="leva-c-"]'); 95 | if (isLevaElement) return; 96 | event.preventDefault(); 97 | document.body.requestPointerLock(); 98 | isContextMenu = true; 99 | }; 100 | 101 | // Cleanup 102 | const onPointerUp = () => { 103 | isPointerDown = false; 104 | isContextMenu = false; 105 | try { 106 | document.exitPointerLock(); 107 | // eslint-disable-next-line @typescript-eslint/no-unused-vars 108 | } catch (_) {} 109 | }; 110 | 111 | // Capture pointer movement 112 | const onPointerMove = (event: PointerEvent) => { 113 | if (!isPointerDown) return; 114 | 115 | const camera = cameraRef.current; 116 | if (!camera) return; 117 | 118 | if (isContextMenu) { 119 | // pan 120 | 121 | vectors.moveTo.set( 122 | event.movementX * 0.004, 123 | event.movementY * -0.004, 124 | 0 125 | ); 126 | vectors.cameraEuler.copy(camera.rotation); 127 | vectors.moveTo.applyEuler(vectors.cameraEuler); 128 | camera.position.add(vectors.moveTo); 129 | } else { 130 | // rotate 131 | camera.rotation.reorder("YXZ"); 132 | camera.rotation.x -= event.movementY * 0.003; 133 | camera.rotation.y -= event.movementX * 0.003; 134 | camera.rotation.z = 0; 135 | } 136 | }; 137 | 138 | window.addEventListener("pointerdown", onPointerDown, { 139 | signal, 140 | passive: true, 141 | }); 142 | window.addEventListener("pointermove", onPointerMove, { 143 | signal, 144 | passive: true, 145 | }); 146 | window.addEventListener("pointerup", onPointerUp, { 147 | signal, 148 | passive: true, 149 | }); 150 | window.addEventListener("contextmenu", onContextMenu, { signal }); 151 | 152 | return () => { 153 | controller.abort(); 154 | try { 155 | document.exitPointerLock(); 156 | // eslint-disable-next-line @typescript-eslint/no-unused-vars 157 | } catch (_) {} 158 | }; 159 | // eslint-disable-next-line react-hooks/exhaustive-deps 160 | }, []); 161 | 162 | const [bigFov, setBigFov] = useState(false); 163 | 164 | useEffect(() => { 165 | if (!fov) return; 166 | setBigFov((current) => !current); 167 | }, [fov]); 168 | 169 | return ( 170 | <> 171 | 177 | 178 | ); 179 | } 180 | -------------------------------------------------------------------------------- /apps/game-front/src/app/controls/page.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import { 4 | controlsInstance, 5 | useControlsPeerEvent, 6 | } from "@/hooks/use-peer-controls"; 7 | import { useState } from "react"; 8 | import { OrientationControls } from "../components/utils/orientation-controls"; 9 | 10 | // Component using the hook 11 | export default function ControlsPage() { 12 | useControlsPeerEvent("open", () => { 13 | const idQueryParam = new URLSearchParams(window.location.search).get("id"); 14 | if (idQueryParam) { 15 | controlsInstance.connectToPeer(idQueryParam); 16 | } 17 | }); 18 | 19 | const [error, setError] = useState(null); 20 | 21 | useControlsPeerEvent("error", (error) => { 22 | setError(error); 23 | }); 24 | 25 | if (error) { 26 | return ( 27 |
28 |

{error.message}

29 |
30 | ); 31 | } 32 | 33 | return ( 34 |
35 | 38 | controlsInstance.sendMessage("acceleration", acceleration) 39 | } 40 | onBreakChange={(brake) => controlsInstance.sendMessage("brake", brake)} 41 | onSteeringChange={(angle) => 42 | controlsInstance.sendMessage("steeringAngle", angle) 43 | } 44 | rotationLimit={30} 45 | /> 46 |
47 | ); 48 | } 49 | -------------------------------------------------------------------------------- /apps/game-front/src/app/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/basementstudio/react-miami-game/124af5343009a55f40819f187ce156d30003f800/apps/game-front/src/app/favicon.ico -------------------------------------------------------------------------------- /apps/game-front/src/app/globals.css: -------------------------------------------------------------------------------- 1 | @import "tailwindcss"; 2 | @import "tw-animate-css"; 3 | 4 | @custom-variant dark (&:is(.dark *)); 5 | 6 | @theme inline { 7 | --color-background: var(--background); 8 | --color-foreground: var(--foreground); 9 | --font-sans: var(--font-geist-sans); 10 | --font-mono: var(--font-geist-mono); 11 | --color-sidebar-ring: var(--sidebar-ring); 12 | --color-sidebar-border: var(--sidebar-border); 13 | --color-sidebar-accent-foreground: var(--sidebar-accent-foreground); 14 | --color-sidebar-accent: var(--sidebar-accent); 15 | --color-sidebar-primary-foreground: var(--sidebar-primary-foreground); 16 | --color-sidebar-primary: var(--sidebar-primary); 17 | --color-sidebar-foreground: var(--sidebar-foreground); 18 | --color-sidebar: var(--sidebar); 19 | --color-chart-5: var(--chart-5); 20 | --color-chart-4: var(--chart-4); 21 | --color-chart-3: var(--chart-3); 22 | --color-chart-2: var(--chart-2); 23 | --color-chart-1: var(--chart-1); 24 | --color-ring: var(--ring); 25 | --color-input: var(--input); 26 | --color-border: var(--border); 27 | --color-destructive: var(--destructive); 28 | --color-accent-foreground: var(--accent-foreground); 29 | --color-accent: var(--accent); 30 | --color-muted-foreground: var(--muted-foreground); 31 | --color-muted: var(--muted); 32 | --color-secondary-foreground: var(--secondary-foreground); 33 | --color-secondary: var(--secondary); 34 | --color-primary-foreground: var(--primary-foreground); 35 | --color-primary: var(--primary); 36 | --color-popover-foreground: var(--popover-foreground); 37 | --color-popover: var(--popover); 38 | --color-card-foreground: var(--card-foreground); 39 | --color-card: var(--card); 40 | --radius-sm: calc(var(--radius) - 4px); 41 | --radius-md: calc(var(--radius) - 2px); 42 | --radius-lg: var(--radius); 43 | --radius-xl: calc(var(--radius) + 4px); 44 | } 45 | 46 | :root { 47 | --radius: 0.625rem; 48 | --background: oklch(1 0 0); 49 | --foreground: oklch(0.13 0.028 261.692); 50 | --card: oklch(1 0 0); 51 | --card-foreground: oklch(0.13 0.028 261.692); 52 | --popover: oklch(1 0 0); 53 | --popover-foreground: oklch(0.13 0.028 261.692); 54 | --primary: oklch(0.21 0.034 264.665); 55 | --primary-foreground: oklch(0.985 0.002 247.839); 56 | --secondary: oklch(0.967 0.003 264.542); 57 | --secondary-foreground: oklch(0.21 0.034 264.665); 58 | --muted: oklch(0.967 0.003 264.542); 59 | --muted-foreground: oklch(0.551 0.027 264.364); 60 | --accent: oklch(0.967 0.003 264.542); 61 | --accent-foreground: oklch(0.21 0.034 264.665); 62 | --destructive: oklch(0.577 0.245 27.325); 63 | --border: oklch(0.928 0.006 264.531); 64 | --input: oklch(0.928 0.006 264.531); 65 | --ring: oklch(0.707 0.022 261.325); 66 | --chart-1: oklch(0.646 0.222 41.116); 67 | --chart-2: oklch(0.6 0.118 184.704); 68 | --chart-3: oklch(0.398 0.07 227.392); 69 | --chart-4: oklch(0.828 0.189 84.429); 70 | --chart-5: oklch(0.769 0.188 70.08); 71 | --sidebar: oklch(0.985 0.002 247.839); 72 | --sidebar-foreground: oklch(0.13 0.028 261.692); 73 | --sidebar-primary: oklch(0.21 0.034 264.665); 74 | --sidebar-primary-foreground: oklch(0.985 0.002 247.839); 75 | --sidebar-accent: oklch(0.967 0.003 264.542); 76 | --sidebar-accent-foreground: oklch(0.21 0.034 264.665); 77 | --sidebar-border: oklch(0.928 0.006 264.531); 78 | --sidebar-ring: oklch(0.707 0.022 261.325); 79 | } 80 | 81 | .dark { 82 | --background: oklch(0.13 0.028 261.692); 83 | --foreground: oklch(0.985 0.002 247.839); 84 | --card: oklch(0.21 0.034 264.665); 85 | --card-foreground: oklch(0.985 0.002 247.839); 86 | --popover: oklch(0.21 0.034 264.665); 87 | --popover-foreground: oklch(0.985 0.002 247.839); 88 | --primary: oklch(0.928 0.006 264.531); 89 | --primary-foreground: oklch(0.21 0.034 264.665); 90 | --secondary: oklch(0.278 0.033 256.848); 91 | --secondary-foreground: oklch(0.985 0.002 247.839); 92 | --muted: oklch(0.278 0.033 256.848); 93 | --muted-foreground: oklch(0.707 0.022 261.325); 94 | --accent: oklch(0.278 0.033 256.848); 95 | --accent-foreground: oklch(0.985 0.002 247.839); 96 | --destructive: oklch(0.704 0.191 22.216); 97 | --border: oklch(1 0 0 / 10%); 98 | --input: oklch(1 0 0 / 15%); 99 | --ring: oklch(0.551 0.027 264.364); 100 | --chart-1: oklch(0.488 0.243 264.376); 101 | --chart-2: oklch(0.696 0.17 162.48); 102 | --chart-3: oklch(0.769 0.188 70.08); 103 | --chart-4: oklch(0.627 0.265 303.9); 104 | --chart-5: oklch(0.645 0.246 16.439); 105 | --sidebar: oklch(0.21 0.034 264.665); 106 | --sidebar-foreground: oklch(0.985 0.002 247.839); 107 | --sidebar-primary: oklch(0.488 0.243 264.376); 108 | --sidebar-primary-foreground: oklch(0.985 0.002 247.839); 109 | --sidebar-accent: oklch(0.278 0.033 256.848); 110 | --sidebar-accent-foreground: oklch(0.985 0.002 247.839); 111 | --sidebar-border: oklch(1 0 0 / 10%); 112 | --sidebar-ring: oklch(0.551 0.027 264.364); 113 | } 114 | 115 | @layer base { 116 | * { 117 | @apply border-border outline-ring/50; 118 | } 119 | body { 120 | @apply bg-background text-foreground; 121 | } 122 | } 123 | -------------------------------------------------------------------------------- /apps/game-front/src/app/layout.tsx: -------------------------------------------------------------------------------- 1 | import type { Metadata } from "next"; 2 | import { Geist, Geist_Mono } from "next/font/google"; 3 | import "./globals.css"; 4 | import { AssetsProvider } from "./components/assets"; 5 | import { client } from "@/lib/basehub"; 6 | import { assetsQuery } from "@/lib/basehub"; 7 | import { cn } from "@/lib/utils"; 8 | import { OverscrollPrevent } from "./components/utils/overscroll-prevent"; 9 | import { Analytics } from "@vercel/analytics/react"; 10 | 11 | const geistSans = Geist({ 12 | variable: "--font-geist-sans", 13 | subsets: ["latin"], 14 | }); 15 | 16 | const geistMono = Geist_Mono({ 17 | variable: "--font-geist-mono", 18 | subsets: ["latin"], 19 | }); 20 | 21 | export const metadata: Metadata = { 22 | title: "Basement - React miami", 23 | description: "Partykit + peerjs demo", 24 | }; 25 | 26 | export default async function RootLayout({ 27 | children, 28 | }: Readonly<{ 29 | children: React.ReactNode; 30 | }>) { 31 | const assetsResult = await client().query(assetsQuery); 32 | 33 | return ( 34 | 35 | 43 | 44 | {children} 45 | 46 | 47 | 48 | ); 49 | } 50 | -------------------------------------------------------------------------------- /apps/game-front/src/app/page.tsx: -------------------------------------------------------------------------------- 1 | /* eslint-disable @next/next/no-img-element */ 2 | "use client"; 3 | 4 | import Link from "next/link"; 5 | import { useAssets } from "./components/assets"; 6 | import { Button } from "./components/ui/button"; 7 | 8 | export default function Home() { 9 | const { models } = useAssets(); 10 | 11 | return ( 12 |
13 |
14 | 19 |
20 |
21 | Basement React Miami 26 | 27 | 28 | 29 |
30 | 36 | 42 | 50 |
51 | ); 52 | } 53 | -------------------------------------------------------------------------------- /apps/game-front/src/app/room/[room-id]/controls-mobile-overlay.tsx: -------------------------------------------------------------------------------- 1 | import { OrientationControls } from "@/app/components/utils/orientation-controls"; 2 | import { controllerVectors } from "@/app/components/vehicle/controller"; 3 | import { useEffect } from "react"; 4 | 5 | export function ControlsMobileOverlay() { 6 | useEffect(() => { 7 | controllerVectors.activeJoystick.current = true; 8 | 9 | return () => { 10 | controllerVectors.activeJoystick.current = false; 11 | }; 12 | }, []); 13 | 14 | return ( 15 | 17 | (controllerVectors.joystickAcceleration.current = acceleration) 18 | } 19 | onBreakChange={(brake) => 20 | (controllerVectors.joystickBrake.current = brake) 21 | } 22 | onSteeringChange={(angle) => 23 | (controllerVectors.joystickRotation.current = angle) 24 | } 25 | rotationLimit={30} 26 | /> 27 | ); 28 | } 29 | -------------------------------------------------------------------------------- /apps/game-front/src/app/room/[room-id]/controls-qr-overlay.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import React, { useState } from "react"; 4 | import { QRCodeSVG } from "qrcode.react"; 5 | import { Gamepad2, Loader2, Smartphone } from "lucide-react"; 6 | import { Button } from "@/app/components/ui/button"; 7 | import { Dialog } from "@/app/components/ui/dialog"; 8 | import { useControlsPeerEvent } from "@/hooks/use-peer-controls"; 9 | 10 | export function ControlsQrOverlay() { 11 | const [isOpen, setIsOpen] = useState(false); 12 | const [qr, setQr] = useState(null); 13 | 14 | function handleOpen() { 15 | setIsOpen(true); 16 | } 17 | 18 | function handleClose() { 19 | setIsOpen(false); 20 | } 21 | 22 | useControlsPeerEvent("open", (id) => { 23 | const windowUrl = new URL(window.location.href); 24 | windowUrl.pathname = "/controls"; 25 | windowUrl.searchParams.set("id", id); 26 | setQr(windowUrl.toString()); 27 | }); 28 | 29 | useControlsPeerEvent("connection", () => { 30 | setIsOpen(false); 31 | }); 32 | 33 | useControlsPeerEvent("error", (error) => { 34 | console.error(error); 35 | }); 36 | 37 | return ( 38 | <> 39 | 47 | 48 | 49 |
50 |
51 | {qr ? ( 52 | 59 | ) : ( 60 |
61 | 62 |
63 | )} 64 |
65 |
66 | 67 | 68 |
69 |

70 | Scan to use your phone as controller. 71 |

72 |
73 |
74 | 75 | ); 76 | } 77 | -------------------------------------------------------------------------------- /apps/game-front/src/app/room/[room-id]/github-overlay.tsx: -------------------------------------------------------------------------------- 1 | import Link from "next/link"; 2 | 3 | export function GithubOverlay() { 4 | return ( 5 | 9 |
10 | 17 | GitHub 18 | 22 | 23 |
24 | 25 | ); 26 | } 27 | -------------------------------------------------------------------------------- /apps/game-front/src/app/room/[room-id]/page.tsx: -------------------------------------------------------------------------------- 1 | import { Room } from "./room"; 2 | 3 | export interface RoomPageProps { 4 | params: Promise<{ "room-id": string }>; 5 | } 6 | 7 | export default async function RoomPage({ params }: RoomPageProps) { 8 | const { "room-id": roomId } = await params; 9 | 10 | return ; 11 | } 12 | -------------------------------------------------------------------------------- /apps/game-front/src/app/room/[room-id]/room.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import { ControlsQrOverlay } from "@/app/room/[room-id]/controls-qr-overlay"; 4 | import { GameCanvas } from "@/app/components/game"; 5 | import { useMedia } from "@/hooks/use-media"; 6 | import { ControlsMobileOverlay } from "./controls-mobile-overlay"; 7 | import { ServerStatusOverlay } from "./server-status-overlay"; 8 | import { useIsMobile } from "@/hooks/use-is-mobile"; 9 | import { GithubOverlay } from "./github-overlay"; 10 | 11 | export function Room({ roomId }: { roomId: string }) { 12 | const isMobile = useIsMobile(); 13 | const bigScreen = useMedia("(min-width: 1024px)", false); 14 | 15 | if (isMobile === undefined) return null; 16 | 17 | const mobileControls = isMobile && !bigScreen; 18 | 19 | return ( 20 |
21 | 22 | 23 | {mobileControls && ( 24 |
25 | 26 |
27 | )} 28 | 29 | 30 | {!isMobile && } 31 |
32 | ); 33 | } 34 | -------------------------------------------------------------------------------- /apps/game-front/src/app/room/[room-id]/server-status-overlay.tsx: -------------------------------------------------------------------------------- 1 | import { useServerStatus } from "@/app/components/other-players"; 2 | import { User } from "lucide-react"; 3 | 4 | export function ServerStatusOverlay() { 5 | const { playerIds } = useServerStatus(); 6 | 7 | return ( 8 |
9 | 10 | {playerIds.length + 1} 11 |
12 | ); 13 | } 14 | -------------------------------------------------------------------------------- /apps/game-front/src/hooks/use-device-orientation.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable @typescript-eslint/no-explicit-any */ 2 | import { useRef, useEffect, useState, useCallback } from "react"; 3 | 4 | // Define the hook options type 5 | export interface UseDeviceOrientationParams { 6 | onUpdate: (event: DeviceOrientationEvent) => void; 7 | onError?: (error: string) => void; 8 | onStarted?: () => void; 9 | } 10 | 11 | // Custom Hook: useDeviceOrientation 12 | export const useDeviceOrientation = ({ 13 | onUpdate, 14 | onError, 15 | onStarted, 16 | }: UseDeviceOrientationParams) => { 17 | const [permissionGranted, setPermissionGranted] = useState(false); 18 | const [error, setError] = useState(null); 19 | 20 | const handleInternalError = useCallback( 21 | (errorMessage: string) => { 22 | setError(errorMessage); 23 | setPermissionGranted(false); 24 | onError?.(errorMessage); // Call external error handler if provided 25 | }, 26 | [onError] 27 | ); 28 | 29 | const requestDeviceOrientation = useCallback(async () => { 30 | // Check if the specific permission API exists (mainly for iOS) 31 | if ( 32 | typeof (DeviceOrientationEvent as any).requestPermission === "function" 33 | ) { 34 | try { 35 | const permissionState = await ( 36 | DeviceOrientationEvent as any 37 | ).requestPermission(); 38 | if (permissionState === "granted") { 39 | setPermissionGranted(true); 40 | setError(null); // Clear previous errors 41 | onStarted?.(); // Call external started handler 42 | } else { 43 | handleInternalError("Permission denied."); 44 | } 45 | } catch (err) { 46 | console.error("Error requesting permission:", err); 47 | handleInternalError( 48 | `Error requesting permission: ${err instanceof Error ? err.message : String(err)}` 49 | ); 50 | } 51 | } else { 52 | // For browsers/devices that don't require explicit permission 53 | if (window.DeviceOrientationEvent) { 54 | // We can't be *sure* it will work without an event, but we can assume 55 | // it might if the API exists. The effect will add the listener. 56 | // We'll set permissionGranted optimistically, actual events confirm it. 57 | setPermissionGranted(true); 58 | setError(null); 59 | onStarted?.(); // Assume started if API exists 60 | } else { 61 | handleInternalError("Device Orientation API not supported."); 62 | } 63 | } 64 | }, [handleInternalError, onStarted]); 65 | 66 | const onUpdateRef = useRef(onUpdate); 67 | onUpdateRef.current = onUpdate; 68 | 69 | useEffect(() => { 70 | if (!permissionGranted) return; 71 | 72 | const handleOrientation = (event: DeviceOrientationEvent) => { 73 | // Check if essential properties exist to ensure it's a valid event 74 | if (event.alpha === null && event.beta === null && event.gamma === null) { 75 | console.warn("Received DeviceOrientationEvent with null values."); 76 | // Optionally handle this case, maybe show a different error or retry permission 77 | // For now, we just ignore the event. 78 | return; 79 | } 80 | onUpdateRef.current(event); 81 | }; 82 | 83 | window.addEventListener("deviceorientation", handleOrientation); 84 | 85 | return () => { 86 | window.removeEventListener("deviceorientation", handleOrientation); 87 | }; 88 | }, [permissionGranted]); 89 | 90 | return { 91 | requestDeviceOrientation, 92 | deviceOrientationStarted: permissionGranted, 93 | deviceOrientationError: error, 94 | }; 95 | }; -------------------------------------------------------------------------------- /apps/game-front/src/hooks/use-is-mobile.ts: -------------------------------------------------------------------------------- 1 | import { useEffect, useState } from "react"; 2 | 3 | export const useIsMobile = () => { 4 | const [isMobile, setIsMobile] = useState(undefined); 5 | 6 | useEffect(() => { 7 | if (typeof window === "undefined") return; 8 | 9 | const isTouchDevice = 10 | "ontouchstart" in window || 11 | navigator.maxTouchPoints > 0 || 12 | // @ts-expect-error - IE11 13 | navigator.msMaxTouchPoints > 0; 14 | 15 | setIsMobile(isTouchDevice); 16 | }, []); 17 | 18 | return isMobile; 19 | }; -------------------------------------------------------------------------------- /apps/game-front/src/hooks/use-media.ts: -------------------------------------------------------------------------------- 1 | import * as React from "react" 2 | 3 | const isClient = typeof document !== "undefined" 4 | 5 | const isApiSupported = (api: string) => isClient && api in window 6 | 7 | export const useMedia = (mediaQuery: string, initialValue?: boolean) => { 8 | const [isVerified, setIsVerified] = React.useState( 9 | initialValue 10 | ) 11 | 12 | React.useEffect(() => { 13 | if (!isApiSupported("matchMedia")) { 14 | console.warn("matchMedia is not supported by your current browser") 15 | return 16 | } 17 | const mediaQueryList = window.matchMedia(mediaQuery) 18 | const changeHandler = () => setIsVerified(!!mediaQueryList.matches) 19 | 20 | changeHandler() 21 | if (typeof mediaQueryList.addEventListener === "function") { 22 | mediaQueryList.addEventListener("change", changeHandler, { 23 | passive: true 24 | }) 25 | return () => { 26 | mediaQueryList.removeEventListener("change", changeHandler) 27 | } 28 | } else if (typeof mediaQueryList.addListener === "function") { 29 | mediaQueryList.addListener(changeHandler) 30 | return () => { 31 | mediaQueryList.removeListener(changeHandler) 32 | } 33 | } 34 | }, [mediaQuery]) 35 | 36 | return isVerified 37 | } 38 | -------------------------------------------------------------------------------- /apps/game-front/src/hooks/use-peer-controls.ts: -------------------------------------------------------------------------------- 1 | import { createPeerParty } from "peerjs-react"; 2 | 3 | type ControlsMessage = { 4 | "steeringAngle": number; 5 | "acceleration": boolean; 6 | "brake": boolean; 7 | } 8 | 9 | export const { 10 | instance: controlsInstance, 11 | // send messages 12 | useSendMessage: useSendControlsMessage, 13 | // receive messages 14 | useOnMessage: useOnControlsMessage, 15 | // peer events 16 | usePeerEvent: useControlsPeerEvent, 17 | } = createPeerParty() -------------------------------------------------------------------------------- /apps/game-front/src/lib/basehub.ts: -------------------------------------------------------------------------------- 1 | import { basehub, Client, fragmentOn } from "basehub" 2 | 3 | export class BaseHubService { 4 | private static instance: BaseHubService | null = null 5 | client: Client 6 | 7 | private constructor() { 8 | this.client = basehub() 9 | } 10 | 11 | static getInstance() { 12 | if (!BaseHubService.instance) { 13 | BaseHubService.instance = new BaseHubService() 14 | } 15 | return BaseHubService.instance 16 | } 17 | } 18 | 19 | export const client = () => { 20 | return BaseHubService.getInstance().client 21 | } 22 | 23 | // query 24 | 25 | export const assetsQuery = fragmentOn("Query", { 26 | models: { 27 | track: { 28 | url: true, 29 | }, 30 | vehicle: { 31 | url: true, 32 | }, 33 | logo: { 34 | url: true, 35 | }, 36 | heroBackground: { 37 | url: true, 38 | }, 39 | bodyMobile: { 40 | url: true, 41 | }, 42 | }, 43 | }); 44 | export type QueryType = fragmentOn.infer; -------------------------------------------------------------------------------- /apps/game-front/src/lib/math.ts: -------------------------------------------------------------------------------- 1 | export function valueRemap(value: number, min: number, max: number, newMin: number, newMax: number) { 2 | return newMin + (newMax - newMin) * (value - min) / (max - min); 3 | } 4 | -------------------------------------------------------------------------------- /apps/game-front/src/lib/pack.ts: -------------------------------------------------------------------------------- 1 | export function packMessage(object: unknown, _type: 'string' | 'binary' = 'binary'): string { 2 | return JSON.stringify(object) 3 | } 4 | 5 | export function unpackMessage(message: string): T { 6 | return JSON.parse(message) 7 | } -------------------------------------------------------------------------------- /apps/game-front/src/lib/utils.ts: -------------------------------------------------------------------------------- 1 | import { clsx, type ClassValue } from "clsx" 2 | import { twMerge } from "tailwind-merge" 3 | 4 | export function cn(...inputs: ClassValue[]) { 5 | return twMerge(clsx(inputs)) 6 | } 7 | -------------------------------------------------------------------------------- /apps/game-front/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "ES2017", 4 | "lib": ["dom", "dom.iterable", "esnext"], 5 | "allowJs": true, 6 | "skipLibCheck": true, 7 | "strict": true, 8 | "noEmit": true, 9 | "esModuleInterop": true, 10 | "module": "esnext", 11 | "moduleResolution": "bundler", 12 | "resolveJsonModule": true, 13 | "isolatedModules": true, 14 | "jsx": "preserve", 15 | "incremental": true, 16 | "plugins": [ 17 | { 18 | "name": "next" 19 | } 20 | ], 21 | "paths": { 22 | "@/*": ["./src/*"] 23 | } 24 | }, 25 | "include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"], 26 | "exclude": ["node_modules"] 27 | } 28 | -------------------------------------------------------------------------------- /apps/game-server/README.md: -------------------------------------------------------------------------------- 1 | # Game Server 2 | 3 | A simple TypeScript game server built with Vite.js. 4 | 5 | ## Setup 6 | 7 | Install dependencies: 8 | 9 | ```bash 10 | pnpm install 11 | ``` 12 | 13 | ## Development 14 | 15 | Run the development server: 16 | 17 | ```bash 18 | pnpm run dev 19 | ``` 20 | 21 | ## Build 22 | 23 | Build for production: 24 | 25 | ```bash 26 | pnpm run build 27 | ``` 28 | 29 | This will create a `dist` folder with the compiled JavaScript. 30 | 31 | ## Preview 32 | 33 | Preview the production build: 34 | 35 | ```bash 36 | pnpm run preview 37 | ``` -------------------------------------------------------------------------------- /apps/game-server/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "game-server", 3 | "private": true, 4 | "version": "0.2.0", 5 | "type": "module", 6 | "scripts": { 7 | "dev": "concurrently \"pnpm run build-watch\" \"partykit dev\"", 8 | "build-watch": "esbuild src/index.ts --bundle --platform=node --format=esm --outfile=dist/index.js --watch", 9 | "build": "esbuild src/index.ts --bundle --platform=node --format=esm --outfile=dist/index.js", 10 | "deploy-server": "pnpm run build && npx partykit deploy" 11 | }, 12 | "devDependencies": { 13 | "@types/lodash.throttle": "^4.1.9", 14 | "@types/node": "^20.9.0", 15 | "concurrently": "^9.1.2", 16 | "esbuild": "^0.20.0", 17 | "typescript": "^5.2.2" 18 | }, 19 | "dependencies": { 20 | "cbor-x": "^1.6.0", 21 | "game-schemas": "workspace:*", 22 | "lodash.throttle": "^4.1.1", 23 | "msgpackr": "^1.11.2", 24 | "partykit": "^0.0.114", 25 | "zod": "^3.24.2" 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /apps/game-server/partykit.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "game-server", 3 | "main": "dist/index.js", 4 | "compatibilityDate": "2024-04-09" 5 | } 6 | -------------------------------------------------------------------------------- /apps/game-server/src/index.ts: -------------------------------------------------------------------------------- 1 | import type * as Party from "partykit/server"; 2 | import { type UserType, type SyncPresenceType, PresenceType, InitUserAction, UpdatePresenceAction, UpdatePresenceActionType, InitUserActionType, PlayerAddedMessageType, PlayerRemovedMessageType, PullServerPresenceMessageType } from "game-schemas"; 3 | import { z } from "zod"; 4 | import { createThrottle } from "./utils"; 5 | 6 | const objectValidation = z.object({ 7 | type: z.string(), 8 | payload: z.any(), 9 | }) 10 | 11 | const SERVER_UPDATE_FPS = 30 12 | 13 | // import { decode, encode } from 'cbor-x'; 14 | 15 | // export function packMessage(object: unknown, type: 'string' | 'binary' = 'binary'): string | Buffer { 16 | // if (type === 'string') { 17 | // return JSON.stringify(object) 18 | // } 19 | 20 | // return encode(object) 21 | // } 22 | 23 | // export function unpackMessage(message: string | Buffer): T { 24 | // if (typeof message === 'string') { 25 | // return JSON.parse(message) 26 | // } else { 27 | // try { 28 | // return decode(message) 29 | // } catch (e) { 30 | // console.log(message); 31 | // console.log(e); 32 | // throw e; 33 | // } 34 | // } 35 | // } 36 | 37 | function packMessage(object: unknown, _type: 'string' | 'binary' = 'binary'): string { 38 | return JSON.stringify(object) 39 | } 40 | 41 | function unpackMessage(message: string): T { 42 | return JSON.parse(message) 43 | } 44 | 45 | export default class GameServer implements Party.Server { 46 | 47 | constructor(readonly room: Party.Room) { 48 | } 49 | 50 | 51 | static options = { 52 | hibernate: true 53 | } 54 | 55 | sendToAll = (message: string | ArrayBufferLike) => { 56 | for (const connection of this.room.getConnections()) { 57 | connection.send(message); 58 | } 59 | } 60 | 61 | updateUsers = createThrottle( 62 | 63 | () => { 64 | 65 | const presenceMessage = JSON.stringify(this.getPresenceMessage()); 66 | this.sendToAll(presenceMessage); 67 | }, 68 | 1000 / SERVER_UPDATE_FPS 69 | ); 70 | 71 | 72 | public onConnect(connection: Party.Connection, _ctx: Party.ConnectionContext): void | Promise { 73 | // send current state to this new user 74 | const message = this.getAllServerPresence(); 75 | connection.send(packMessage(message, 'string')); 76 | } 77 | 78 | private markSynced(connection: Party.Connection) { 79 | connection.setState((prevState) => { 80 | if (!prevState) throw new Error("No previous state"); 81 | return { 82 | ...prevState, 83 | shouldSyncPresence: false, 84 | shouldSyncMovement: false, 85 | } 86 | }) 87 | } 88 | 89 | getPresenceMessage(): SyncPresenceType { 90 | const users: SyncPresenceType['payload']['users'] = {}; 91 | for (const connection of this.room.getConnections()) { 92 | const userState = connection.state; 93 | if (!userState || !userState.presence) continue; 94 | if (userState.shouldSyncPresence) { 95 | users[connection.id] = userState.presence; 96 | this.markSynced(connection); 97 | continue; 98 | } 99 | if (userState.shouldSyncMovement) { 100 | // filter unwanted data 101 | const { name, ...rest } = userState.presence; 102 | users[connection.id] = rest; 103 | this.markSynced(connection); 104 | continue; 105 | } 106 | } 107 | return { 108 | type: "sync-presence", 109 | payload: { users }, 110 | } 111 | } 112 | 113 | getAllServerPresence(): PullServerPresenceMessageType { 114 | const users: Record = {}; 115 | for (const connection of this.room.getConnections()) { 116 | const userState = connection.state; 117 | if (!userState || !userState.presence) continue; 118 | users[connection.id] = userState.presence; 119 | } 120 | return { type: "pull-server-presence", payload: { users } }; 121 | } 122 | 123 | public onMessage(message: string | ArrayBufferLike, sender: Party.Connection): void | Promise { 124 | const messageJson = unpackMessage(message as string); 125 | 126 | const parsed = objectValidation.safeParse(messageJson); 127 | if (!parsed.success) return; 128 | 129 | switch (parsed.data.type) { 130 | case "init-user": 131 | const initUser = InitUserAction.safeParse(parsed.data); 132 | if (initUser.success) { 133 | return this.initPlayerAction(initUser.data, sender); 134 | } 135 | break; 136 | case "update-presence": 137 | const updatePresence = UpdatePresenceAction.safeParse(parsed.data); 138 | if (updatePresence.success) { 139 | return this.updatePresenceAction(updatePresence.data, sender); 140 | } 141 | break; 142 | } 143 | } 144 | 145 | private initPlayerAction(action: InitUserActionType, sender: Party.Connection) { 146 | sender.setState({ 147 | id: sender.id, 148 | shouldSyncPresence: false, 149 | shouldSyncMovement: false, 150 | presence: action.payload, 151 | }) 152 | // Update all clients with new player data 153 | this.sendPlayerAdded(sender.id, action.payload); 154 | } 155 | 156 | sendPlayerAdded(id: string, presence: PresenceType) { 157 | const totalPlayers = [...this.room.getConnections()].length 158 | const message = { 159 | type: "player-added", 160 | payload: { 161 | id, 162 | presence, 163 | totalPlayers, 164 | }, 165 | } satisfies PlayerAddedMessageType 166 | this.sendToAll(packMessage(message, 'string')); 167 | } 168 | 169 | private updatePresenceAction(action: UpdatePresenceActionType, sender: Party.Connection) { 170 | if (!sender.state || !sender.state.presence) return; // no current presence, ignore update 171 | 172 | sender.setState((prevState) => { 173 | if (!prevState) throw new Error("No previous state"); 174 | 175 | const shouldSyncPresence = 'name' in action.payload 176 | 177 | return { 178 | ...prevState, 179 | shouldSyncPresence, 180 | shouldSyncMovement: !shouldSyncPresence, 181 | presence: { 182 | ...prevState.presence, 183 | ...action.payload, 184 | }, 185 | } 186 | }) 187 | this.updateUsers(); 188 | } 189 | 190 | onClose(connection: Party.Connection) { 191 | 192 | const totalPlayers = [...this.room.getConnections()].length 193 | 194 | const message = { 195 | type: "player-removed", 196 | payload: { 197 | id: connection.id, 198 | totalPlayers, 199 | }, 200 | } satisfies PlayerRemovedMessageType 201 | this.sendToAll(packMessage(message, 'string')); 202 | } 203 | 204 | onError() { 205 | this.updateUsers(); 206 | } 207 | } 208 | 209 | -------------------------------------------------------------------------------- /apps/game-server/src/utils.ts: -------------------------------------------------------------------------------- 1 | export function createThrottle(fn: (...args: any[]) => void, wait: number) { 2 | 3 | const lastUsed = { current: 0 } 4 | 5 | return (...args: any[]) => { 6 | const now = Date.now() 7 | if (now - lastUsed.current > wait) { 8 | lastUsed.current = now 9 | fn(...args) 10 | } 11 | } 12 | 13 | } -------------------------------------------------------------------------------- /apps/game-server/tsconfig.build.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "./tsconfig.json", 3 | "compilerOptions": { 4 | "noEmit": false, 5 | "outDir": "dist", 6 | "allowImportingTsExtensions": false 7 | } 8 | } -------------------------------------------------------------------------------- /apps/game-server/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "ES2020", 4 | "useDefineForClassFields": true, 5 | "module": "ESNext", 6 | "lib": [ 7 | "ES2020", 8 | "DOM", 9 | "DOM.Iterable" 10 | ], 11 | "skipLibCheck": true, 12 | /* Bundler mode */ 13 | "moduleResolution": "bundler", 14 | "allowImportingTsExtensions": true, 15 | "resolveJsonModule": true, 16 | "isolatedModules": true, 17 | "noEmit": true, 18 | /* Linting */ 19 | "strict": true, 20 | "noUnusedLocals": true, 21 | "noUnusedParameters": true, 22 | "noFallthroughCasesInSwitch": true 23 | }, 24 | "include": [ 25 | "src" 26 | ] 27 | } -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "partykit-test", 3 | "version": "1.0.0", 4 | "description": "", 5 | "main": "index.js", 6 | "scripts": { 7 | "build": "turbo build", 8 | "dev": "turbo dev" 9 | }, 10 | "devDependencies": { 11 | "prettier": "^3.5.3", 12 | "turbo": "^2.5.0", 13 | "typescript": "5.8.2" 14 | }, 15 | "keywords": [], 16 | "author": "", 17 | "license": "ISC", 18 | "packageManager": "pnpm@10.6.5" 19 | } 20 | -------------------------------------------------------------------------------- /packages/game-schemas/README.md: -------------------------------------------------------------------------------- 1 | # Game Schemas 2 | 3 | This package contains shared validation schemas, types, and event handling utilities for the multiplayer game. 4 | 5 | ## Features 6 | 7 | - **Zod Schemas**: Type-safe validation schemas for all game data structures 8 | - **TypeScript Types**: Generated types from Zod schemas 9 | - **Event System**: Type-safe event emitter for game events 10 | 11 | ## Usage 12 | 13 | ### Importing Schemas and Types 14 | 15 | ```typescript 16 | import { 17 | // Schemas 18 | Vector3Schema, 19 | PlayerDataSchema, 20 | 21 | // Types 22 | PlayerData, 23 | PresenceMessage, 24 | 25 | // Event System 26 | GameClientEventEmitter 27 | } from 'game-schemas'; 28 | ``` 29 | 30 | ### Validation Example 31 | 32 | ```typescript 33 | import { PlayerDataSchema } from 'game-schemas'; 34 | 35 | const rawData = { 36 | name: "Player1", 37 | position: { x: 0, y: 0, z: 0 }, 38 | rotation: { x: 0, y: 0, z: 0, w: 1 } 39 | }; 40 | 41 | const result = PlayerDataSchema.safeParse(rawData); 42 | if (result.success) { 43 | const playerData = result.data; 44 | // Use validated data 45 | } else { 46 | console.error("Invalid player data:", result.error); 47 | } 48 | ``` 49 | 50 | ### Events Example 51 | 52 | ```typescript 53 | import { GameClientEventEmitter } from 'game-schemas'; 54 | 55 | const events = new GameClientEventEmitter(); 56 | 57 | // Type-safe event subscription 58 | events.on('playerJoined', ({ id, data }) => { 59 | console.log(`Player ${id} joined at position:`, data.position); 60 | }); 61 | 62 | // Type-safe event emission 63 | events.emit('playerJoined', { 64 | id: 'player-123', 65 | data: { 66 | name: 'Player1', 67 | position: { x: 0, y: 0, z: 0 }, 68 | rotation: { x: 0, y: 0, z: 0, w: 1 } 69 | } 70 | }); 71 | ``` -------------------------------------------------------------------------------- /packages/game-schemas/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "game-schemas", 3 | "version": "0.1.0", 4 | "private": true, 5 | "main": "dist/index.js", 6 | "module": "dist/index.mjs", 7 | "types": "dist/index.d.ts", 8 | "files": [ 9 | "dist" 10 | ], 11 | "scripts": { 12 | "build": "tsup src/index.ts --format cjs,esm --dts --external msgpackr", 13 | "dev": "tsup src/index.ts --format cjs,esm --watch --dts --external msgpackr", 14 | "lint": "eslint src", 15 | "clean": "rm -rf .turbo node_modules dist" 16 | }, 17 | "dependencies": { 18 | "zod": "^3.24.2" 19 | }, 20 | "devDependencies": { 21 | "eslint": "^9", 22 | "tsup": "^8.4.0", 23 | "typescript": "^5" 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /packages/game-schemas/src/actions.ts: -------------------------------------------------------------------------------- 1 | // Client -> Server actions 2 | 3 | import { z } from "zod"; 4 | import { PresenceSchema } from "./presence"; 5 | 6 | export const InitUserAction = z.object({ 7 | type: z.literal("init-user"), 8 | payload: PresenceSchema, 9 | }); 10 | 11 | export type InitUserActionType = z.infer; 12 | 13 | export const UpdatePresenceAction = z.object({ 14 | type: z.literal("update-presence"), 15 | payload: PresenceSchema.partial(), 16 | }); 17 | 18 | export type UpdatePresenceActionType = z.infer; 19 | 20 | // Union of all possible client actions 21 | export const ClientActionSchema = z.discriminatedUnion("type", [ 22 | InitUserAction, 23 | UpdatePresenceAction, 24 | ]); 25 | 26 | export type ClientAction = z.infer; 27 | -------------------------------------------------------------------------------- /packages/game-schemas/src/index.ts: -------------------------------------------------------------------------------- 1 | export * from "./user"; 2 | export * from "./presence"; 3 | export * from "./utils"; 4 | export * from "./messages"; 5 | export * from "./actions"; -------------------------------------------------------------------------------- /packages/game-schemas/src/messages.ts: -------------------------------------------------------------------------------- 1 | // Messages are server -> client messages 2 | 3 | import { z } from "zod"; 4 | import { PresenceSchema } from "./presence"; 5 | 6 | export const PlayerAddedMessage = z.object({ 7 | type: z.literal("player-added"), 8 | payload: z.object({ 9 | id: z.string(), 10 | presence: PresenceSchema, 11 | totalPlayers: z.number(), 12 | }), 13 | }); 14 | 15 | export type PlayerAddedMessageType = z.infer; 16 | 17 | export const PlayerRemovedMessage = z.object({ 18 | type: z.literal("player-removed"), 19 | payload: z.object({ 20 | id: z.string(), 21 | totalPlayers: z.number(), 22 | }), 23 | }); 24 | 25 | export type PlayerRemovedMessageType = z.infer; 26 | 27 | export const SyncPresenceMessage = z.object({ 28 | type: z.literal("sync-presence"), 29 | payload: z.object({ 30 | users: z.record(z.string(), PresenceSchema.partial()), 31 | }), 32 | }); 33 | 34 | export type SyncPresenceType = z.infer; 35 | 36 | export const PullServerPresenceMessage = z.object({ 37 | type: z.literal("pull-server-presence"), 38 | payload: z.object({ 39 | users: z.record(z.string(), PresenceSchema), 40 | }), 41 | }); 42 | 43 | export type PullServerPresenceMessageType = z.infer< 44 | typeof PullServerPresenceMessage 45 | >; 46 | 47 | // Union of all possible server messages 48 | export const ServerMessageSchema = z.discriminatedUnion("type", [ 49 | SyncPresenceMessage, 50 | PlayerAddedMessage, 51 | PlayerRemovedMessage, 52 | PullServerPresenceMessage, 53 | ]); 54 | 55 | export type ServerMessage = z.infer; -------------------------------------------------------------------------------- /packages/game-schemas/src/presence.ts: -------------------------------------------------------------------------------- 1 | import { QuaternionSchema, Vector2Schema } from "./utils"; 2 | 3 | import { z } from "zod"; 4 | import { Vector3Schema } from "./utils"; 5 | 6 | // Player data schemas 7 | export const PresenceSchema = z.object({ 8 | name: z.string(), 9 | /** Player position */ 10 | pos: Vector3Schema, 11 | /** Movement on eachframe */ 12 | vel: Vector3Schema, 13 | /** Player rotation */ 14 | rot: QuaternionSchema, 15 | /** Wheel rotation */ 16 | wheel: Vector2Schema, 17 | /** Timestamp of the update frame */ 18 | timestamp: z.number() 19 | }); 20 | 21 | export type PresenceType = z.infer; -------------------------------------------------------------------------------- /packages/game-schemas/src/user.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * User data is visible to the server 3 | * Presence is visible to all clients 4 | */ 5 | 6 | import { z } from "zod"; 7 | import { PresenceSchema } from "./presence"; 8 | 9 | 10 | export const UserSchema = z.object({ 11 | id: z.string(), 12 | shouldSyncPresence: z.boolean(), 13 | shouldSyncMovement: z.boolean(), 14 | presence: PresenceSchema 15 | }) 16 | 17 | export type UserType = z.infer; 18 | -------------------------------------------------------------------------------- /packages/game-schemas/src/utils.ts: -------------------------------------------------------------------------------- 1 | import { z } from "zod"; 2 | 3 | // Base geometry schemas 4 | export const Vector3Schema = z.object({ 5 | x: z.number(), 6 | y: z.number(), 7 | z: z.number(), 8 | }); 9 | 10 | export const Vector2Schema = z.object({ 11 | x: z.number(), 12 | y: z.number(), 13 | }); 14 | 15 | export const QuaternionSchema = z.object({ 16 | x: z.number(), 17 | y: z.number(), 18 | z: z.number(), 19 | w: z.number(), 20 | }); -------------------------------------------------------------------------------- /packages/game-schemas/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "ES2020", 4 | "module": "ESNext", 5 | "moduleResolution": "node", 6 | "esModuleInterop": true, 7 | "strict": true, 8 | "declaration": true, 9 | "declarationMap": true, 10 | "sourceMap": true, 11 | "outDir": "dist", 12 | "skipLibCheck": true, 13 | "forceConsistentCasingInFileNames": true 14 | }, 15 | "include": [ 16 | "src/**/*" 17 | ], 18 | "exclude": [ 19 | "node_modules", 20 | "dist" 21 | ] 22 | } -------------------------------------------------------------------------------- /packages/peerjs-react/README.md: -------------------------------------------------------------------------------- 1 | # PeerJS React 2 | 3 | ```bash 4 | pnpm add peerjs peerjs-react 5 | ``` 6 | 7 | ```tsx 8 | import { createPeerParty } from "peerjs-react"; 9 | 10 | 11 | // define the messages types 12 | type VehicleControlMessages = { 13 | "steeringAngle": number; 14 | "accelerationPressed": boolean; 15 | "brakePressed": boolean; 16 | } 17 | 18 | // create your typed hooks 19 | const { 20 | instance, 21 | usePeer, 22 | useOnMessage, 23 | useSendMessage, 24 | usePeerEvent 25 | } = createPeerParty(); 26 | ``` 27 | 28 | ## instance 29 | 30 | Get the peer instance. 31 | 32 | ```tsx 33 | // connect to a peer 34 | instance.connectToPeer("other-peer-id"); 35 | 36 | // send message to all peers 37 | instance.sendMessage("steeringAngle", 0); 38 | 39 | // send message to a specific peer 40 | instance.sendMessageTo("peer-id", "steeringAngle", acceleration) 41 | 42 | // listener for connection events 43 | instance.on("connection", (connection) => {}); 44 | ``` 45 | 46 | ## useOnMessage 47 | 48 | Listen for messages from other peers. 49 | 50 | ```tsx 51 | useOnMessage("steeringAngle", (message) => { 52 | message.data // number 53 | }); 54 | ``` 55 | 56 | ## useSendMessage 57 | 58 | Send react state to other peers. 59 | 60 | ```tsx 61 | const [accelerationPressed, setAccelerationPressed] = useState(false); 62 | 63 | useSendMessage("accelerationPressed", accelerationPressed); 64 | ``` 65 | 66 | ## usePeerEvent 67 | 68 | Listen for events from the peer instance. 69 | 70 | ```tsx 71 | usePeerEvent("connection", () => { 72 | // handle connection 73 | }); 74 | 75 | usePeerEvent("disconnected", () => { 76 | // handle disconnection 77 | }); 78 | ``` 79 | 80 | ## usePeer 81 | 82 | Get the peer instance. You can also access the peer instance from the `instance` object. 83 | 84 | ```tsx 85 | const peer = usePeer(); 86 | 87 | peer.connectToPeer("other-peer-id"); 88 | 89 | peer.on("connection", (connection) => { 90 | // handle connection 91 | }); 92 | 93 | peer.sendMessage("steeringAngle", 0); 94 | ``` 95 | -------------------------------------------------------------------------------- /packages/peerjs-react/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "peerjs-react", 3 | "version": "0.0.1", 4 | "type": "module", 5 | "main": "dist/index.cjs", 6 | "module": "dist/index.js", 7 | "types": "dist/index.d.ts", 8 | "files": [ 9 | "dist" 10 | ], 11 | "scripts": { 12 | "dev": "vite build --watch", 13 | "build": "vite build && tsc --emitDeclarationOnly" 14 | }, 15 | "devDependencies": { 16 | "@types/node": "^20.9.0", 17 | "@types/react": "^18.3.20", 18 | "@vitejs/plugin-react": "^4.4.0", 19 | "peerjs": "^1.5.4", 20 | "react": "^18.2.0", 21 | "typescript": "^5.2.2", 22 | "vite": "^6.3.1", 23 | "vite-plugin-dts": "^4.5.3" 24 | }, 25 | "dependencies": { 26 | "eventemitter3": "^5.0.1" 27 | }, 28 | "peerDependencies": { 29 | "peerjs": "^1.5.4", 30 | "react": "^18.0.0" 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /packages/peerjs-react/src/index.ts: -------------------------------------------------------------------------------- 1 | import { PeerOptions } from "peerjs"; 2 | import { MessagePayload, PeerParty, PeerPartyEvents } from "./peer-party"; 3 | import { useEffect, useRef } from "react"; 4 | import EventEmitter from "eventemitter3"; 5 | 6 | export function createPeerParty>(options?: PeerOptions) { 7 | const instance = new PeerParty(options) 8 | 9 | function usePeer() { 10 | return instance 11 | } 12 | 13 | function useOnMessage(type: T, callback: (message: MessagePayload) => void) { 14 | const callbackRef = useRef(callback) 15 | callbackRef.current = callback 16 | 17 | const peerInstance = usePeer() 18 | 19 | useEffect(() => { 20 | peerInstance.onMessage(type, (message) => { 21 | callbackRef.current(message) 22 | }) 23 | 24 | return () => { 25 | peerInstance.removeMessageListener(callbackRef.current) 26 | } 27 | }, [type, peerInstance]) 28 | } 29 | 30 | function useSendMessage(type: T, data: PartyEvents[T]) { 31 | const peerInstance = usePeer() 32 | 33 | useEffect(() => { 34 | peerInstance.sendMessage(type, data) 35 | }, [type, data, peerInstance]) 36 | } 37 | 38 | function usePeerEvent(type: T, callback: (...args: EventEmitter.ArgumentMap[Extract]) => void) { 39 | const peerInstance = usePeer() 40 | 41 | const callbackRef = useRef(callback) 42 | callbackRef.current = callback 43 | 44 | useEffect(() => { 45 | const cn: typeof callback = (...args) => { 46 | callbackRef.current(...args) 47 | } 48 | 49 | peerInstance.on(type, cn) 50 | 51 | return () => { 52 | peerInstance.off(type, cn) 53 | } 54 | }, [type, callback, peerInstance]) 55 | } 56 | 57 | return { instance, useOnMessage, useSendMessage, usePeer, usePeerEvent } 58 | } -------------------------------------------------------------------------------- /packages/peerjs-react/src/peer-party.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable @typescript-eslint/no-explicit-any */ 2 | import Peer, { DataConnection, PeerOptions } from "peerjs"; 3 | import { EventEmitter } from "eventemitter3"; 4 | 5 | export type PeerPartyEvents = { 6 | 'open': (id: string) => void 7 | 'disconnected': () => void 8 | 'close': () => void 9 | 'connection': (connection: DataConnection) => void 10 | 'message': (payload: MessagePayload) => void 11 | 'error': (error: Error) => void 12 | } 13 | 14 | export type MessageType = { 15 | type: T 16 | data: D 17 | } 18 | 19 | export type MessagePayload = MessageType & { 20 | fromId: string 21 | } 22 | 23 | export class PeerParty> { 24 | instance: Peer 25 | id?: string 26 | isConnected?: boolean 27 | EE: EventEmitter 28 | connections: Record = {} 29 | 30 | constructor(options?: PeerOptions) { 31 | this.EE = new EventEmitter() 32 | 33 | this.instance = new Peer(options || {}) 34 | this.instance.on('open', (id) => { 35 | this.id = id 36 | this.isConnected = true 37 | this.EE.emit('open', id) 38 | }) 39 | this.instance.on('disconnected', () => { 40 | this.isConnected = false 41 | this.EE.emit('disconnected') 42 | }) 43 | this.instance.on('close', () => { 44 | this.isConnected = false 45 | this.EE.emit('close') 46 | }) 47 | this.instance.on('connection', (connection) => { 48 | this.EE.emit('connection', connection) 49 | this.connections[connection.peer] = connection 50 | 51 | const onMessageCallback = (payload: unknown) => { 52 | this.EE.emit('message', payload as any) 53 | } 54 | connection.on('data', onMessageCallback) 55 | // handle connection close 56 | connection.on('close', () => { 57 | delete this.connections[connection.peer] 58 | }) 59 | }) 60 | 61 | this.instance.on('error', (error) => { 62 | this.EE.emit('error', error) 63 | }) 64 | } 65 | 66 | connectToPeer(peerId: string) { 67 | const conn = this.instance.connect(peerId) 68 | conn.on('open', () => { 69 | this.connections[peerId] = conn 70 | }) 71 | conn.on('error', (error) => { 72 | this.EE.emit('error', error) 73 | }) 74 | conn.on('close', () => { 75 | delete this.connections[peerId] 76 | }) 77 | } 78 | 79 | onMessage(type: T, callback: (payload: MessagePayload) => void) { 80 | this.EE.on('message', (payload) => { 81 | const valid = payload && typeof payload === 'object' && 'type' in payload && 'data' in payload 82 | if (!valid) return 83 | 84 | const message = payload as unknown as MessagePayload 85 | if (message.type === type) { 86 | callback(message) 87 | } 88 | }) 89 | } 90 | 91 | // eslint-disable-next-line @typescript-eslint/no-unsafe-function-type 92 | removeMessageListener(callback: Function) { 93 | this.EE.off('message', callback as any) 94 | } 95 | 96 | sendMessageTo(peerId: string, type: T, data: D) { 97 | this.connections[peerId].send({ type, data }) 98 | } 99 | 100 | sendMessage(type: T, data: D) { 101 | if (!this.isConnected) { 102 | return 103 | } 104 | 105 | Object.values(this.connections).forEach((connection) => { 106 | connection.send({ type, data }) 107 | }) 108 | } 109 | 110 | addListener: EventEmitter['addListener'] = (type, callback, context) => { 111 | return this.EE.addListener(type, callback, context) 112 | } 113 | 114 | removeListener: EventEmitter['removeListener'] = (type, callback, context) => { 115 | return this.EE.removeListener(type, callback, context) 116 | } 117 | 118 | on: EventEmitter['on'] = (type, callback, context) => { 119 | return this.EE.on(type, callback, context) 120 | } 121 | 122 | off: EventEmitter['off'] = (type, callback, context) => { 123 | return this.EE.off(type, callback, context) 124 | } 125 | 126 | 127 | 128 | destroy() { 129 | this.instance.destroy() 130 | this.EE.removeAllListeners() 131 | } 132 | } 133 | -------------------------------------------------------------------------------- /packages/peerjs-react/tsconfig.build.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "./tsconfig.json", 3 | "compilerOptions": { 4 | "noEmit": false, 5 | "outDir": "dist", 6 | "allowImportingTsExtensions": false, 7 | "emitDeclarationOnly": true 8 | } 9 | } -------------------------------------------------------------------------------- /packages/peerjs-react/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "ES2020", 4 | "useDefineForClassFields": true, 5 | "lib": [ 6 | "ES2020", 7 | "DOM", 8 | "DOM.Iterable" 9 | ], 10 | "module": "ESNext", 11 | "skipLibCheck": true, 12 | "moduleResolution": "bundler", 13 | "allowImportingTsExtensions": true, 14 | "resolveJsonModule": true, 15 | "isolatedModules": true, 16 | "noEmit": true, 17 | "jsx": "react-jsx", 18 | "strict": true, 19 | "noUnusedLocals": true, 20 | "noUnusedParameters": true, 21 | "noFallthroughCasesInSwitch": true, 22 | "declaration": true, 23 | "declarationDir": "./dist", 24 | "outDir": "./dist" 25 | }, 26 | "include": [ 27 | "src" 28 | ] 29 | } -------------------------------------------------------------------------------- /packages/peerjs-react/vite.config.ts: -------------------------------------------------------------------------------- 1 | import { defineConfig } from 'vite'; 2 | import { resolve } from 'path'; 3 | import dts from 'vite-plugin-dts' 4 | 5 | export default defineConfig({ 6 | build: { 7 | lib: { 8 | entry: resolve(__dirname, 'src/index.ts'), 9 | name: 'PeerjsReact', 10 | formats: ['es', 'cjs'], 11 | fileName: (format) => `index.${format === 'es' ? 'js' : 'cjs'}` 12 | }, 13 | rollupOptions: { 14 | external: ['react', 'peerjs'], 15 | output: { 16 | globals: { 17 | react: 'React', 18 | peerjs: 'Peer' 19 | } 20 | } 21 | }, 22 | sourcemap: true, 23 | minify: true 24 | }, 25 | plugins: [ 26 | dts() 27 | ] 28 | }); -------------------------------------------------------------------------------- /pnpm-workspace.yaml: -------------------------------------------------------------------------------- 1 | packages: 2 | - "apps/*" 3 | - "packages/*" -------------------------------------------------------------------------------- /turbo.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://turbo.build/schema.json", 3 | "globalDependencies": [".env", "**/.env.*local", "**/*.tsconfig.json"], 4 | "tasks": { 5 | "build": { 6 | "dependsOn": ["^build"], 7 | "outputs": [".next/**", "!.next/cache/**", "dist/**"] 8 | }, 9 | "game-front#build": { 10 | "env": ["BASEHUB_TOKEN", "NEXT_PUBLIC_PARTY_SOCKET_HOST"], 11 | "dependsOn": ["game-schemas#build", "peerjs-react#build"], 12 | "inputs": ["$TURBO_DEFAULT$", ".env*"], 13 | "outputs": [".next/**", "!.next/cache/**", "dist/**"] 14 | }, 15 | "lint": { 16 | "dependsOn": ["^lint"] 17 | }, 18 | "check-types": { 19 | "dependsOn": ["^build"] 20 | }, 21 | "dev": { 22 | "cache": false, 23 | "persistent": true 24 | } 25 | } 26 | } 27 | --------------------------------------------------------------------------------