Skip to main content

Command Palette

Search for a command to run...

Three.js is closer to Roblox than you think. It just needed an engine.

Updated
8 min read

When Matt Shumer dropped a Minecraft clone built entirely in Three.js by Claude Fable 5 in twenty minutes, the reaction across the industry was some version of the same question: are we Roblox yet?

It's the right question. Roblox has 380 million monthly active users. Developers make real money there. The platform has physics, multiplayer, input, and a game loop baked in, and the barrier to building something playable on it has always been low. If AI just made building a Three.js game as easy as building a Roblox game, what's the difference anymore?

The answer, which I don't think enough people have said out loud: the rendering was never the gap.

Roblox's value isn't its renderer. The renderer is fine — Luau-scripted, server-authoritative, visually adequate. What Roblox actually gives you is production-quality game infrastructure that arrives for free: a game loop with fixed physics steps, rigid body simulation, collision callbacks, input that just works, an audio system, a distribution platform. You write game logic; the engine handles the rest.

Three.js has always been a spectacular renderer. What it has never shipped is the rest of the engine. You get WebGL with a reasonable API. You don't get a game loop. You don't get physics. You don't get input management. You don't get multiplayer. Those things, you assemble from separate packages with varying degrees of integration, and the assembly is exactly where beginners and prototypers give up.

That assembly problem is what CarverJS solves. And it's what makes "are we Roblox yet?" not quite the right question. The better question is: has anyone built a real game engine on top of Three.js?

We have.


What Roblox actually gives you

To make a fair comparison, you have to be specific about what Roblox developers are actually relying on:

A game loop with execution order guarantees. Roblox's RunService has Stepped (physics, fixed rate), Heartbeat (post-physics), and RenderStepped (pre-render, client only). Game logic that reads physics state runs after physics settles. Cameras follow after positions are final. This ordering is not incidental — without it, you get jitter and stale-state bugs that are maddening to track down.

Physics that works without configuration. You place a Part in the Workspace, set Anchored = false, and gravity applies. Collisions resolve. You don't install anything. The physics engine is just there.

Input with frame boundaries. UserInputService:IsKeyDown is a polling API, but Roblox processes input at frame boundaries so you don't get partial-frame reads. There's no built-in isJustPressed — you track edge transitions yourself — but the input state is consistent within a frame.

Multiplayer without running a server. Roblox's server is always Roblox's server. You don't pay for it, configure it, or maintain it. You write RemoteEvent and RemoteFunction calls; the platform handles the transport.

Somewhere to publish. You upload, you set a title and thumbnail, and the game is discoverable. The distribution is the platform.

Now look at what CarverJS ships over Three.js.


What CarverJS gives you over Three.js

A real game loop. Not requestAnimationFrame. Not R3F's useFrame unmodified. A staged execution pipeline that mirrors Roblox's exactly:

InputFlush     (-50)   flush keyboard/pointer events → per-frame state
earlyUpdate    (-40)   user: pre-physics reads, AI
fixedUpdate    (-30)   user: deterministic physics step (accumulator-based)
CollisionFlush (-25)   engine: collision detection
update         (-20)   user: default game logic
TweenFlush     (-15)   engine: advance active tweens
lateUpdate     (-10)   user: camera follow, post-physics corrections
GameLoopTick    (-5)   engine: increment elapsed, frameCount
R3F render       (0)   Three.js renders the scene

The useGameLoop hook gives you access to this pipeline in one call:

useGameLoop((delta) => {
  // Runs at the "update" stage, after physics, before camera.
  // phase is read via getState() — zero React re-renders at 60fps.
  if (!ref.current) return;
  ref.current.position.x += velocity.current.x * delta;
}, { stage: "update" });

And for camera follow, which Roblox handles via its default camera scripts:

// Runs at lateUpdate — player position is already integrated.
// No jitter because the camera reads a final position, not an in-progress one.
useCamera({
  follow: { target: playerRef, offset: [0, 8, 12], smoothing: 0.08 }
});

Physics. Optional, lazy-loaded Rapier WASM. You don't pay for it if you don't import it. If you do:

<Actor
  type="primitive"
  shape="box"
  color="#facc15"
  position={[0, 1, 0]}
  physics={{
    bodyType: "dynamic",   // gravity, collisions — like Anchored = false in Roblox
    collider: "cuboid",
    linearDamping: 5,
    enabledRotations: [false, false, false],
    ccd: true,             // continuous collision detection — for fast objects
  }}
/>

That's a physics-enabled object. Gravity applies. It collides with other physics bodies. You don't write a physics loop. The engine handles it.

Sensors — the thing Roblox calls CanCollide = false parts with .Touched events — are first-class:

physics={{
  bodyType: "fixed",
  collider: "cuboid",
  sensor: true,  // detect overlaps without physical response
  onCollisionEnter: (e) => {
    if (e.otherName === "player") collectCoin();
  },
}}

Input. useInput gives you per-frame edge detection out of the box:

const { getAxis, isJustPressed } = useInput();

useGameLoop((delta) => {
  const x = getAxis("KeyA", "KeyD");   // -1 / 0 / +1, flushed per frame
  const jumped = isJustPressed("Space"); // exactly one frame, cleared by InputFlush
});

isJustPressed is the thing Roblox doesn't have natively. You'd track it manually in Luau. Here it's built in.

P2P multiplayer, no server. This is where CarverJS diverges from Roblox most sharply — and in our favor.

Roblox multiplayer is server-client. Always. A Roblox server runs the authoritative simulation. You access the multiplayer through RemoteEvent and RemoteFunction. This is great for security and cheating prevention, but it means you are always dependent on Roblox's servers, always subject to their pricing, always inside their platform.

@carverjs/multiplayer is peer-to-peer WebRTC. Two browsers connect directly. No game server. No monthly bill. The signaling — the handshake that sets up the direct connection — goes through free public MQTT brokers by default. Once the WebRTC channel is open, signaling steps away. Game state flows directly between peers.

<MultiplayerProvider appId="my-game">
  <Game mode="3d">
    <World>
      <GameScene />
    </World>
  </Game>
</MultiplayerProvider>

That's the entire setup for a multiplayer game. If the host disconnects mid-round, another peer takes over automatically via a deterministic election — lowest peer ID wins, zero network round-trips, one tick of transition. The game doesn't pause.


What Claude Fable 5 actually revealed

The Minecraft clone that set off this whole conversation wasn't built with a game engine. It was a raw Three.js implementation — procedural terrain, custom chunk management, BufferGeometry from scratch. The impressive thing about what Fable did is that it wrote all that infrastructure itself.

That's exactly what CarverJS is for. You shouldn't need to write chunk management, a game loop, a physics integration, a collision system, and a network sync layer every time you want to build a game. Those are solved problems. The engine solves them so the game author doesn't have to.

When you give Fable a CarverJS game to write instead of a raw Three.js game, the AI's output changes character. It stops reinventing the loop, the physics, the input. It writes game logic — the part that's actually unique to your game. A complete 3D platformer:

function Player({ ref }: { ref: React.RefObject<Group | null> }) {
  const physics = usePhysics();
  const { getAxis, isJustPressed } = useInput();
  const { getForward, getRight } = useCameraDirection();
  const jumpCooldown = useRef(0);

  useGameLoop((delta) => {
    if (!physics) return;
    const x = getAxis("KeyA", "KeyD");
    const z = getAxis("KeyW", "KeyS");
    if (x !== 0 || z !== 0) {
      const fwd = getForward(), rgt = getRight();
      physics.applyForce([
        -(fwd[0] * z + rgt[0] * x) * 40,
        0,
        -(fwd[2] * z + rgt[2] * x) * 40,
      ]);
    }
    const vel = physics.getLinearVelocity();
    const hSpeed = Math.hypot(vel[0], vel[2]);
    if (hSpeed > 20) {
      const s = 20 / hSpeed;
      physics.setLinearVelocity([vel[0] * s, vel[1], vel[2] * s]);
    }
    if (jumpCooldown.current > 0) jumpCooldown.current -= delta;
    if (isJustPressed("Space") && Math.abs(vel[1]) < 0.5 && jumpCooldown.current <= 0) {
      physics.applyImpulse([0, 7, 0]);
      jumpCooldown.current = 0.35;
    }
  });
  return null;
}

export default function App() {
  return (
    <Game mode="3d">
      <World physics={{ gravity: [0, -20, 0] }}>
        <Camera type="perspective" controls="orbit"
          follow={{ target: playerRef, smoothing: 0.08 }} />
        <Actor ref={playerRef} name="player" type="primitive" shape="box"
          color="#facc15" position={[0, 1, 0]}
          physics={{ bodyType: "dynamic", collider: "cuboid",
            linearDamping: 5, enabledRotations: [false, false, false] }}>
          <Player ref={playerRef} />
        </Actor>
        <Actor type="primitive" shape="box" color="#4a7c59"
          geometryArgs={[40, 0.2, 40]} position={[0, -0.1, 0]}
          physics={{ bodyType: "fixed", collider: "cuboid" }} />
      </World>
    </Game>
  );
}

That's a running 3D character controller with camera follow, speed clamping, jump with cooldown, ground detection, and Rapier physics. Every system is wired. The full example in the CarverJS repo extends this to a three-level coin collection game with per-coin timers, multiple arenas, and a HUD — and it's still one file.

Fable one-shots that. Because the engine is doing the work that isn't unique to the game, Fable only has to write the part that is.


The full engine is MIT at github.com/MoneyTales/carverjs. Docs at docs.carverjs.dev.

npm install @carverjs/core
npm install @carverjs/multiplayer

More from this blog

C

CarverJS

3 posts

CarverJS is a React-native game engine for building browser-based 2D and 3D games without leaving the React ecosystem.

This blog covers tutorials, deep dives, and real-world examples using CarverJS, from game loops and collision detection to spatial audio and multiplayer with WebRTC. Whether you're a React developer curious about game dev or building browser games for production, this is your go-to resource for shipping games the React way.