Debugging and testing
threeplay ships with a small set of debugging tools, all behind the global debug object (k.debug).
import threeplay from "threeplay";
threeplay({ debug: true, debugKey: "f2" }); // start with the inspector on; F2 toggles it (F1 by default)
add([plane(30), color("#33415c"), area(), body({ isStatic: true })]);const crate = add([box(1), pos(0, 4, 0), color("tomato"), area(), body(), "crate"]);camPos(0, 6, 10);camLookAt(vec3(0, 1, 0));
debug.log("crate spawned at", crate.pos); // console + on-screenonKeyPress("p", () => (debug.paused = !debug.paused)); // freeze the game, keep drawingonKeyPress("t", () => (debug.timeScale = debug.timeScale === 1 ? 0.25 : 1)); // slow motiononKeyPress("i", () => debug.log(debug.hovered()?.inspect())); // what is under the mouse?The inspector
Section titled “The inspector”Press F1 in a running game (or set debug.inspect = true, or start with threeplay({ debug: true })). A small panel appears in the top-left corner with:
- the FPS and the number of live objects,
- the renderer in use (
webgpuorwebgl) and the number of draw calls, - for the object under the mouse: its id, tags and the
inspect()line of each of its components.
While the inspector is on, colliders and contacts are drawn as a wireframe over the scene (Rapier’s debug rendering), which makes a missing or misplaced collider obvious. Without the physics plugin it outlines the bounding box of every object that has a mesh instead.
To move the key use threeplay({ debugKey: "f2" }), and to disable the inspector for good (a released game, say) use threeplay({ debugKey: false }). The panel is cheap while it is off.
Logging
Section titled “Logging”debug.log(...) prints to the browser console and to a list in the bottom-left corner of the game that fades after about four seconds (the last eight messages are kept). That is handy on a phone, or when the console is hidden behind a fullscreen game.
debug.log("score", score); // several values, like console.logdebug.log("hit!", { damage: 3, who: "player" }); // objects are shown as JSONdebug.error("could not spawn enemy"); // red, and console.errordebug.clearLog();Pause, speed and state
Section titled “Pause, speed and state”debug.paused = true; // the same as setPaused(true)debug.timeScale = 0.25; // the same as setTimeScale(0.25)debug.fps(); // smoothed frames per second (honest even while paused)debug.objectCount(); // live game objectsdebug.hovered(); // the object under the mouse, or nullSlowing the game down is the best way to understand a fast bug (a bullet tunneling through a wall, a jump that is too short). Pausing keeps drawing and input alive, so you can still pick things with the mouse and read debug.hovered().
Custom inspect() lines
Section titled “Custom inspect() lines”Components can contribute to the inspector by returning a short string from inspect() (see Game objects). It is also available from code as obj.inspect(), which returns an object of { componentId: line }.
function fuel(amount = 100) { return { id: "fuel", amount, inspect() { return `${this.amount.toFixed(0)} L`; }, };}Common problems
Section titled “Common problems”| Symptom | Likely cause |
|---|---|
| An object falls through the floor | The floor needs area() and body({ isStatic: true }). An area() alone is a sensor. |
| Nothing collides, no collision events | You started with physics: false, or an object is missing area(). Press F1 to see the colliders. |
| An object is black | lighting: "none" without any light of your own. Add dirLight(), pointLight(), or use material({ type: "basic" }). |
| Text does not show, or sits in the 3D scene | A HUD text() needs fixed() to be placed in screen pixels. Without it, it floats over its world position. |
| The camera snaps back to the default view | The scene changed: start camera controllers inside the scene. |
An object disappeared after go() |
go() destroys every object. Create things inside the scene. |
| Keys do nothing | The canvas has no focus (the game is in an iframe, or you clicked outside it). Click the game. |
| No sound | Browsers block audio until the first click or key press. |
Error: ... can't add property "x" |
Two components define the same field. The message names both. |
Headless testing
Section titled “Headless testing”The engine does not need a browser. With renderer: "none" and loop: "manual" you get a deterministic simulation that you drive yourself with k.step(dt), which is ideal for unit tests of game logic. In Node (no document), the renderer is headless automatically, and the HUD and inputs are inert.
import { expect, test } from "vitest";import threeplay from "threeplay";
test("the player lands on the floor", async () => { const k = threeplay({ global: false, loop: "manual", renderer: "none" }); await new Promise((resolve) => k.onLoad(resolve)); // wait for Rapier
k.add([k.box(20, 1, 20), k.pos(0, -0.5, 0), k.area(), k.body({ isStatic: true })]); const player = k.add([k.capsule(0.4, 1.6), k.pos(0, 3, 0), k.area(), k.character()]);
for (let i = 0; i < 100; i++) k.step(1 / 50); // two simulated seconds
expect(player.isGrounded()).toBe(true); expect(player.pos.y).toBeCloseTo(0.8, 1); k.quit();});loop: "manual"stops therequestAnimationFrameloop. Eachk.step(dt)runs one frame: fixed physics steps, updates, timers and rendering (a no-op when headless).k.step()without an argument advances one fixed step (1/50 s).- Use
global: falsein tests so nothing is put onglobalThis, and callk.quit()at the end to free Rapier and cancel listeners. - Wait for loading before you start (
await new Promise((r) => k.onLoad(r))), because physics loads asynchronously. - Simulate input with
k.pressButton("jump")andk.releaseButton("jump"): they take effect at the start of the nextstep. Scene logic, timers, collisions andk.time()all behave as in the browser, only faster.
You can use the same setup to run a game on a server, to precompute a level, or to build bots that play your game.