Most game tutorials for web developers start with "install Unity" or "set up a canvas context." Both paths lead somewhere real — but if your game lives inside a React app, you want your game loop to compose with React state the same way everything else does.
CarverJS is a React game engine built for that case. v0.0.1, APIs will move. But the core hooks are stable enough to build with today.
Here is what a complete playable 2D game looks like from scratch.
What you'll build
A player square that moves with WASD. Bounces off the viewport edges. The whole thing is around 40 lines. No canvas setup, no manual requestAnimationFrame, no cleanup in useEffect.
Install
npm install carverjs
The core bundle is under 200 KB. Three.js and react-three-fiber are peer dependencies — CarverJS lazy-loads them so they don't land in your main bundle if you're not already using R3F.
The game loop
useGameLoop is the spine of CarverJS. It runs your callback in the ordered update pipeline — earlyUpdate → fixedUpdate → update → lateUpdate. The delta value (time since last frame, in seconds) is max-delta-capped: if the tab slows down, delta won't compound into a physics spiral.
import { useRef } from 'react';
import { useGameLoop } from 'carverjs';
function Player() {
const pos = useRef({ x: 200, y: 200 });
const vel = useRef({ x: 120, y: 90 });
useGameLoop(({ delta }) => {
pos.current.x += vel.current.x * delta;
pos.current.y += vel.current.y * delta;
});
}
Default slot is update. Pass 'fixedUpdate' as a second argument when you need deterministic timing — physics, multiplayer state sync.
Input
useInput watches keyboard state reactively. No event listeners to register or clean up.
import { useRef } from 'react';
import { useGameLoop, useInput } from 'carverjs';
function Player() {
const pos = useRef({ x: 200, y: 200 });
const vel = useRef({ x: 0, y: 0 });
const SPEED = 200; // px/s
const input = useInput();
useGameLoop(({ delta }) => {
if (input.keys['KeyW']) vel.current.y = -SPEED;
if (input.keys['KeyS']) vel.current.y = SPEED;
if (input.keys['KeyA']) vel.current.x = -SPEED;
if (input.keys['KeyD']) vel.current.x = SPEED;
pos.current.x += vel.current.x * delta;
pos.current.y += vel.current.y * delta;
});
}
Wall bounds
Check position against viewport size in the same update callback. Invert velocity on overshoot.
useGameLoop(({ delta }) => {
// ... input handling above
pos.current.x += vel.current.x * delta;
pos.current.y += vel.current.y * delta;
if (pos.current.x < 0 || pos.current.x > window.innerWidth - 32)
vel.current.x *= -1;
if (pos.current.y < 0 || pos.current.y > window.innerHeight - 32)
vel.current.y *= -1;
});
Mount the scene
<Game> and <Scene> are the wrappers. The mode prop is a discriminated union ("2d" | "3d") — TypeScript narrows the rendering pipeline at compile time.
import { Game, Scene } from 'carverjs';
export default function App() {
return (
<Game>
<Scene mode="2d">
<Player />
</Scene>
</Game>
);
}
Mount the component, the loop starts. Unmount it, the loop stops. No globals to clean up.
The full game (~40 lines)
import { useRef } from 'react';
import { Game, Scene, useGameLoop, useInput } from 'carverjs';
function Player() {
const pos = useRef({ x: 200, y: 200 });
const vel = useRef({ x: 120, y: 90 });
const SPEED = 200;
const input = useInput();
useGameLoop(({ delta }) => {
if (input.keys['KeyW']) vel.current.y = -SPEED;
if (input.keys['KeyS']) vel.current.y = SPEED;
if (input.keys['KeyA']) vel.current.x = -SPEED;
if (input.keys['KeyD']) vel.current.x = SPEED;
pos.current.x += vel.current.x * delta;
pos.current.y += vel.current.y * delta;
if (pos.current.x < 0 || pos.current.x > window.innerWidth - 32)
vel.current.x *= -1;
if (pos.current.y < 0 || pos.current.y > window.innerHeight - 32)
vel.current.y *= -1;
});
return (
<div style={{
position: 'absolute',
left: pos.current.x,
top: pos.current.y,
width: 32,
height: 32,
background: '#4ade80',
}} />
);
}
export default function App() {
return (
<Game>
<Scene mode="2d">
<Player />
</Scene>
</Game>
);
}
WASD movement, velocity, wall bounce. Runs in a browser tab inside your React app. Composes with the rest of your component tree like any other component.
What this doesn't cover
-
Collision between actors: that's
useCollision— AABB only, two registered refs, returns a boolean in reactive state. -
Audio:
useAudiowith 6 independent volume channels. Not needed here. - Multiplayer: P2P for 2–16 players. A topic on its own.
CarverJS is v0.0.1. APIs will move before 1.0. If the hook doesn't fit your use case, open an issue. If something breaks, same place.
Docs: docs.carverjs.dev
Top comments (0)