Templates: plain three.js, React Three Fiber and Rapier

The threejs-game skill starts a new game from one of these three files. Each is a complete small game in the shape the convention describes: replace the rules, keep the shape. They ship inside the plugin at skills/threejs-game/templates/.

plain.html

One page, an import map and no build step. Arrow keys move a box; touching the coin scores and moves it. The state lives in plain variables, step(dt) runs once per frame, keys and act() share one input path, and window.__game is set at the end.

<!doctype html>
<!-- Plain three.js template: one page, no build. Arrow keys move a box; touching the coin scores and moves the coin.
     Needs `bun add three` (the import map points into node_modules). Open it with game_open { url: "index.html" }. Replace the rules, keep the shape:
     state in plain variables, step(dt) once per frame, one input path for keys and act(), window.__game at the end. -->
<html><head><meta charset="utf-8"><title>Game</title>
<style>html,body{margin:0;background:#111;overflow:hidden}canvas{display:block}</style></head>
<body>
<script type="importmap">{"imports":{"three":"./node_modules/three/build/three.module.js"}}</script>
<script type="module">
import * as THREE from 'three';

const SPEED = 4;                       // units per second
const held = new Set();
let score = 0;
let player = { x: 0, y: 0 }, coin = { x: 3, y: 2 };

function step(dt) {
  player.x += ((held.has('ArrowRight') ? 1 : 0) - (held.has('ArrowLeft') ? 1 : 0)) * SPEED * dt;
  player.y += ((held.has('ArrowUp') ? 1 : 0) - (held.has('ArrowDown') ? 1 : 0)) * SPEED * dt;
  if (Math.hypot(player.x - coin.x, player.y - coin.y) < 0.8) {
    score++;
    coin = { x: Math.round(Math.random() * 8 - 4), y: Math.round(Math.random() * 6 - 3) };   // Math.random: the harness seeds it
  }
}

const renderer = new THREE.WebGLRenderer({ antialias: true });
renderer.setSize(innerWidth, innerHeight);
document.body.appendChild(renderer.domElement);
const scene = new THREE.Scene();
const camera = new THREE.PerspectiveCamera(50, innerWidth / innerHeight, 0.1, 100);
camera.position.set(0, 0, 12);
scene.add(new THREE.HemisphereLight(0xffffff, 0x334455, 2));
const box = new THREE.Mesh(new THREE.BoxGeometry(0.8, 0.8, 0.8), new THREE.MeshStandardMaterial({ color: 0xffa040 }));
box.name = 'player'; scene.add(box);   // names show in game_look's on-screen list
const gem = new THREE.Mesh(new THREE.SphereGeometry(0.3), new THREE.MeshStandardMaterial({ color: 0xffe14d }));
gem.name = 'coin'; scene.add(gem);

let last = null;
function loop(now) {
  requestAnimationFrame(loop);
  // elapsed game time, never more than 0.1 s a frame; the first frame counts as one (time before it was the page loading)
  step(last === null ? 1 / 60 : Math.min((now - last) / 1000, 0.1));
  last = now;
  box.position.set(player.x, player.y, 0);
  gem.position.set(coin.x, coin.y, 0);
  renderer.render(scene, camera);
}
requestAnimationFrame(loop);

// One input path for real keys and the harness's act().
const act = ({ key, down = true }) => { if (down) held.add(key); else held.delete(key); };
addEventListener('keydown', e => act({ key: e.key }));
addEventListener('keyup', e => act({ key: e.key, down: false }));

const copy = (p) => ({ x: +p.x.toFixed(3), y: +p.y.toFixed(3) });
window.__game = {
  describe: () => ({ name: 'coin collector', units: 'world units, y up', keys: { ArrowLeft: 'hold to move left', ArrowRight: 'hold to move right', ArrowUp: 'hold to move up', ArrowDown: 'hold to move down' } }),
  observe: () => ({ score, player: copy(player), coin: copy(coin) }),
  act,
  setState: (s) => {   // every schema key, applied at once
    if (s.score !== undefined) score = s.score;
    if (s.player) player = { ...s.player };
    if (s.coin) coin = { ...s.coin };
  },
};
</script>
</body></html>

r3f.tsx

React Three Fiber under vite. The game state lives in a plain module object outside React, so setState and observe() never wait for a render; components only draw it. One useFrame advances the rules by the frame's delta, at most 0.1 s, and window.__game is set in the Canvas's onCreated.

// React Three Fiber template (vite: `bun create vite . --template react-ts`, then `bun add three @react-three/fiber`).
// The game state lives in a plain module object outside React, so setState and observe() never wait for a render;
// components only draw it. One useFrame advances the rules by the frame's delta, at most 0.1 s, and the first frame by
// one frame.
// Replace the rules, keep the shape.
import { useRef } from 'react';
import { createRoot } from 'react-dom/client';
import { Canvas, useFrame } from '@react-three/fiber';
import type { Mesh } from 'three';

type Vec = { x: number; y: number };
const SPEED = 4;   // units per second
const game = { score: 0, player: { x: 0, y: 0 } as Vec, coin: { x: 3, y: 2 } as Vec };
const held = new Set<string>();
let started = false;

function step(dt: number) {
  const p = game.player;
  p.x += ((held.has('ArrowRight') ? 1 : 0) - (held.has('ArrowLeft') ? 1 : 0)) * SPEED * dt;
  p.y += ((held.has('ArrowUp') ? 1 : 0) - (held.has('ArrowDown') ? 1 : 0)) * SPEED * dt;
  if (Math.hypot(p.x - game.coin.x, p.y - game.coin.y) < 0.8) {
    game.score++;
    game.coin = { x: Math.round(Math.random() * 8 - 4), y: Math.round(Math.random() * 6 - 3) };   // the harness seeds Math.random
  }
}

// One input path for real keys and the harness's act().
const act = ({ key, down = true }: { key: string; down?: boolean }) => { if (down) held.add(key); else held.delete(key); };
addEventListener('keydown', e => act({ key: e.key }));
addEventListener('keyup', e => act({ key: e.key, down: false }));

const copy = (v: Vec) => ({ x: +v.x.toFixed(3), y: +v.y.toFixed(3) });
// Installed from the Canvas's onCreated, not here: the harness starts stepping frames once __game exists, and before
// the Canvas has mounted those frames would draw nothing and run no useFrame.
const bridge = {
  describe: () => ({ name: 'coin collector', stack: 'react-three-fiber', keys: { ArrowLeft: 'hold', ArrowRight: 'hold', ArrowUp: 'hold', ArrowDown: 'hold' } }),
  observe: () => ({ score: game.score, player: copy(game.player), coin: copy(game.coin) }),
  act,
  setState: (s: Partial<typeof game>) => {   // every schema key, applied at once
    if (s.score !== undefined) game.score = s.score;
    if (s.player) game.player = { ...s.player };
    if (s.coin) game.coin = { ...s.coin };
  },
};

function Scene() {
  const player = useRef<Mesh>(null), coin = useRef<Mesh>(null);
  useFrame((_, delta) => {
    step(started ? Math.min(delta, 0.1) : 1 / 60);   // the first delta also spans the page's load: count it as one frame
    started = true;
    player.current!.position.set(game.player.x, game.player.y, 0);
    coin.current!.position.set(game.coin.x, game.coin.y, 0);
  });
  return (
    <>
      <hemisphereLight args={['#ffffff', '#334455', 2]} />
      {/* names show in game_look's on-screen list */}
      <mesh ref={player} name="player"><boxGeometry args={[0.8, 0.8, 0.8]} /><meshStandardMaterial color="orange" /></mesh>
      <mesh ref={coin} name="coin"><sphereGeometry args={[0.3]} /><meshStandardMaterial color="gold" /></mesh>
    </>
  );
}

createRoot(document.getElementById('root')!).render(
  <Canvas camera={{ position: [0, 0, 12], fov: 50 }} onCreated={() => { (window as any).__game = bridge; }}><Scene /></Canvas>,
);

rapier.ts

The window.__game bridge for a game whose bodies live in a Rapier world, with @react-three/rapier or plain @dimforge/rapier3d-compat. observe() reads positions from the bodies, rounded so a check's exact values are stable; setState teleports a body and stops it unless the state gives its velocity.

// Rapier template: the window.__game bridge for a game whose bodies live in a Rapier world, with
// @react-three/rapier (`installGame(useRapier().world, ...)` in an effect) or plain @dimforge/rapier3d-compat.
// observe() reads positions from the bodies, rounded so a check's exact values are stable; setState teleports a body
// and stops it unless the state gives its velocity, so a keypoint starts at rest by default. Keep the physics step fixed at 1/60 s (@react-three/rapier's default
// timeStep) so stepped frames are exact. Replace the rules, keep the shape.
import type { RigidBody, World } from '@dimforge/rapier3d-compat';

type Vec = { x: number; y: number; z: number };
const SPEED = 4, JUMP = 7;   // units per second
const held = new Set<string>();
let jump = false, score = 0;
const round = (v: Vec) => ({ x: +v.x.toFixed(2), y: +v.y.toFixed(2), z: +v.z.toFixed(2) });

/** One input path for real keys and the harness's act(); Space jumps once per press. */
export function act({ key, down = true }: { key: string; down?: boolean }) {
  const k = key === ' ' ? 'Space' : key;
  if (k === 'Space' && down && !held.has(k)) jump = true;
  if (down) held.add(k); else held.delete(k);
}

/** Call once per physics step, before the world steps: held keys set the player's velocity. */
export function tick(player: RigidBody) {
  const vx = ((held.has('ArrowRight') ? 1 : 0) - (held.has('ArrowLeft') ? 1 : 0)) * SPEED;
  player.setLinvel({ x: vx, y: jump ? JUMP : player.linvel().y, z: 0 }, true);
  jump = false;
}

/** Installs window.__game and the key listeners for the named bodies; returns the cleanup. */
export function installGame(world: World, bodies: { player: RigidBody }) {
  const down = (e: KeyboardEvent) => act({ key: e.key });
  const up = (e: KeyboardEvent) => act({ key: e.key, down: false });
  addEventListener('keydown', down);
  addEventListener('keyup', up);
  (window as any).__game = {
    describe: () => ({ name: 'platformer', stack: 'rapier', units: 'metres, y up', keys: { ArrowLeft: 'hold', ArrowRight: 'hold', Space: 'jump' } }),
    observe: () => ({ score, bodies: world.bodies.len(), player: round(bodies.player.translation()), velocity: round(bodies.player.linvel()) }),
    act,
    setState: (s: { score?: number; player?: Vec; velocity?: Vec }) => {   // every schema key but bodies (a count), applied at once
      if (s.score !== undefined) score = s.score;
      if (s.player) bodies.player.setTranslation(s.player, true);
      if (s.player || s.velocity) bodies.player.setLinvel(s.velocity ?? { x: 0, y: 0, z: 0 }, true);
    },
  };
  return () => { removeEventListener('keydown', down); removeEventListener('keyup', up); delete (window as any).__game; };
}