Skip to content

3D · TypeScript · Free download

Three.js and Rapier 3D starter

Combine Three.js rendering with Rapier 3D physics in an editable TypeScript browser game.

Build locally with your preferred editor or AI coding agent. You keep the source and choose where to publish.

No account needed. Extract into a new folder to keep your existing projects safe.

Three.js starter illustration

What you can build

3D movement with a dedicated physics engine. The starter includes a minimal movement and collection scene so you can verify the runtime before adding your own rules, artwork and levels.

Included in this kit

  • Three.js for 3D rendering
  • Rapier 3D integration
  • HTML/CSS interface, keyboard input and editable game logic
  • Vite development server, TypeScript, linting and Vitest checks

Browser games are the supported target. Native desktop and mobile packaging are not included.

Run your game

Requires Node.js >=24.0.0 and pnpm@12.10.1.

  1. Download and extract the kit into a new folder.
  2. Open that folder in your editor and run pnpm install.
  3. Run pnpm dev and open the address shown in your terminal.

The local HTTPS setup may ask you to trust its development certificate. See the included README for setup and troubleshooting.

Run pnpm build to prepare a release. Explore hosting when you are ready to share it.

Preview the generated project

These 35 files and commands come from the same configuration used by the download.

Project files
  • dev-domains.config.mjs
  • scripts/local-environment.mjs
  • scripts/local-environment.d.mts
  • .gitignore
  • .npmrc
  • pnpm-workspace.yaml
  • tsconfig.json
  • .oxlintrc.json
  • .oxfmtrc.json
  • src/score.ts
  • src/input.ts
  • vite.config.ts
  • spawnkit/config.ts
  • spawnkit/vite.ts
  • src/style.css
  • src/main.ts
  • src/simulation.ts
  • vitest.config.ts
  • src/score.test.ts
  • src/simulation.test.ts
  • src/ui.ts
  • src/physics.ts
  • src/input.test.ts
  • src/main.test.ts
  • src/main-error.test.ts
  • src/ui.test.ts
  • src/physics.test.ts
  • package.json
  • game.config.json
  • spawnkit.config.ts
  • public/spawnkit-listing.schema.json
  • public/media/cover.svg
  • index.html
  • README.md
  • .spawn/state.json
Available commands
pnpm dev
dev-domains run app
pnpm build
tsc --noEmit && vite build
pnpm preview
vite preview
pnpm lint
oxlint --no-ignore src vite.config.ts
pnpm test
vitest run --coverage
Entry point: src/main.ts
import { createPhysicsDemo } from "./physics"
import { createUI } from "./ui"
import { AmbientLight, BoxGeometry, Color, DirectionalLight, Mesh, MeshStandardMaterial, PerspectiveCamera, PlaneGeometry, Scene, SphereGeometry, WebGLRenderer } from "three"
import "./style.css"
import { advanceScore } from "./score"
import { createSimulation } from "./simulation"
import { createInput } from "./input"

const simulation = createSimulation({ x: 0, y: 0, speed: 5, minX: -7.5, maxX: 7.5, minY: -5.5, maxY: 5.5 })

const host = document.querySelector<HTMLElement>("#game")
if (!host) throw new Error("Game container is missing")
const scene = new Scene()
scene.background = new Color("#101e29")
const camera = new PerspectiveCamera(50, host.clientWidth / host.clientHeight, 0.1, 100)
camera.position.set(0, 12, 14)
camera.lookAt(0, 0, 0)
const renderer = new WebGLRenderer({ antialias: true })
renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2))
renderer.setSize(host.clientWidth, host.clientHeight)
renderer.domElement.tabIndex = 0
renderer.domElement.setAttribute("aria-label", "3D game. Use arrow keys to collect gold.")
host.append(renderer.domElement)
const controls = createInput(renderer.domElement)
scene.add(new AmbientLight(0xffffff, 1.5))
const light = new DirectionalLight(0xffffff, 2)
light.position.set(4, 8, 4)
scene.add(light)
const ground = new Mesh(new PlaneGeometry(16, 12), new MeshStandardMaterial({ color: 0x1a3340 }))
ground.rotation.x = -Math.PI / 2
scene.add(ground)
const player = new Mesh(new BoxGeometry(0.7, 0.7, 0.7), new MeshStandardMaterial({ color: 0xb5f46a }))
player.position.y = 0.35
scene.add(player)
const collectible = new Mesh(new SphereGeometry(0.28), new MeshStandardMaterial({ color: 0xffc77a }))
collectible.position.set(3, 0.5, 0)
scene.add(collectible)
const ui = createUI()
window.addEventListener("pagehide", () => ui.dispose(), { once: true })
const physicsDemo = await createPhysicsDemo()
window.addEventListener("pagehide", () => physicsDemo.dispose(), { once: true })
const physicsBall = new Mesh(new SphereGeometry(0.35), new MeshStandardMaterial({ color: 0xa78bfa }))
scene.add(physicsBall)
let score = 0
let previous = performance.now()
renderer.setAnimationLoop((now) => {
  const delta = (now - previous) / 1000
  if (ui.isPaused()) { previous = now; return }
  const position = simulation.step(controls.read(), delta)
  previous = now
  const bodyPosition = physicsDemo.step(delta)
  physicsBall.position.set(bodyPosition.x, bodyPosition.y, bodyPosition.z)
  player.position.x = position.x
  player.position.z = position.y
  if (player.position.distanceTo(collectible.position) < 0.8) {
    score = advanceScore(score)
    const label = document.querySelector("#score")
    if (label) label.textContent = "Collected: " + score
    collectible.position.set(Math.random() * 14 - 7, 0.5, Math.random() * 10 - 5)
  }
  renderer.render(scene, camera)
})
window.addEventListener("resize", () => {
  camera.aspect = host.clientWidth / host.clientHeight
  camera.updateProjectionMatrix()
  renderer.setSize(host.clientWidth, host.clientHeight)
})

window.addEventListener("pagehide", () => { renderer.setAnimationLoop(null); renderer.dispose() }, { once: true })

Related kits