Build a table-view paddle game scene

A classic arcade paddle game is a flat vertical screen — both paddles the same distance from the camera, separated left/right. This tutorial builds the other kind: a physical table you stand at one end of, your own paddle close in the foreground and the opponent's paddle small in the distance — then swaps the generic arcade backdrop for a sunset desert without touching the table itself. The whole thing is one Group per section, assembled with plain .add() calls and added in a single undo step. Open the editor, open the Shell tab, and paste each block in order.

A paddle game scene viewed from behind the player's blue paddle, looking down a green table toward a small red paddle at the far end, framed by a backdrop and two pillars.
Standing at your own end of the table — the blue Player Paddle fills the foreground, the red Computer Paddle is small at the far end.
Pick your depth axis before you place anything. The natural mistake is building the arcade layout — paddles on +x/-x, walls on +y/-y, camera parked on +z looking straight in — and then trying to fix the camera afterward to make one paddle look "far away." No camera angle can do that: both paddles are equidistant from the camera on that layout, so any attempt to view it obliquely just makes the thin flat parts (walls, table) foreshorten into confusing diagonal slabs. Decide up front which world axis is "the length of the table" and build paddles onto that axis (here, z) — the perspective camera then does the near/far size difference for free.

1 Lay out the table along z, not x

Keep y as true vertical (matches the camera's default up vector) and put the two paddles at opposite ends of z instead of x. Build every part into one Group with .add(), then add the whole group with a single AddObjectCommand:

const group = new Group();
group.name = 'Paddle Game';

const tableMat = new MeshStandardMaterial({ color: 0x1c5c3a, roughness: 0.7 });
const railMat = new MeshStandardMaterial({ color: 0xffffff, roughness: 0.5 });
const playerMat = new MeshStandardMaterial({ color: 0x2e7dff, roughness: 0.4 });
const compMat = new MeshStandardMaterial({ color: 0xff4444, roughness: 0.4 });
const ballMat = new MeshStandardMaterial({ color: 0xffffff, roughness: 0.3 });

const table = new Mesh(new BoxGeometry(12, 0.4, 24), tableMat);
table.name = 'Table'; table.position.set(0, -0.2, 0);
group.add(table);

const railL = new Mesh(new BoxGeometry(0.3, 0.5, 24.4), railMat);
railL.name = 'Rail Left'; railL.position.set(-6.15, 0.25, 0);
group.add(railL);

const railR = new Mesh(new BoxGeometry(0.3, 0.5, 24.4), railMat);
railR.name = 'Rail Right'; railR.position.set(6.15, 0.25, 0);
group.add(railR);

const centerLine = new Mesh(new BoxGeometry(0.12, 0.02, 24), railMat);
centerLine.name = 'Center Line'; centerLine.position.set(0, 0.01, 0);
group.add(centerLine);

// The two paddles are on opposite ends of z — this is the whole trick.
const playerPaddle = new Mesh(new BoxGeometry(2.2, 0.7, 0.4), playerMat);
playerPaddle.name = 'Player Paddle'; playerPaddle.position.set(0, 0.35, 10.5);
group.add(playerPaddle);

const compPaddle = new Mesh(new BoxGeometry(2.2, 0.7, 0.4), compMat);
compPaddle.name = 'Computer Paddle'; compPaddle.position.set(0, 0.35, -10.5);
group.add(compPaddle);

const ball = new Mesh(new SphereGeometry(0.35, 24, 24), ballMat);
ball.name = 'Ball'; ball.position.set(0, 0.35, 0);
group.add(ball);

editor.execute(new AddObjectCommand(editor, group));

2 Frame the far end

A backdrop, two pillars, and a scoreboard panel behind the computer's end give the camera something to look at beyond the far paddle — an empty background makes distance harder to read. Same pattern: one group, plain .add(), one command:

const scenery = new Group();
scenery.name = 'Background Scenery';

const floor = new Mesh(new PlaneGeometry(60, 50), new MeshStandardMaterial({ color: 0x333844, roughness: 0.9 }));
floor.name = 'Floor'; floor.rotation.x = -Math.PI / 2; floor.position.set(0, -0.45, -2);
scenery.add(floor);

const backdrop = new Mesh(new PlaneGeometry(24, 12), new MeshStandardMaterial({ color: 0x14202c, roughness: 0.85 }));
backdrop.name = 'Backdrop'; backdrop.position.set(0, 5.5, -14);
scenery.add(backdrop);

const pillarMat = new MeshStandardMaterial({ color: 0x777f8c, roughness: 0.6 });
const pillarL = new Mesh(new CylinderGeometry(0.4, 0.4, 12, 16), pillarMat);
pillarL.name = 'Pillar Left'; pillarL.position.set(-8, 5.5, -14);
scenery.add(pillarL);

const pillarR = new Mesh(new CylinderGeometry(0.4, 0.4, 12, 16), pillarMat);
pillarR.name = 'Pillar Right'; pillarR.position.set(8, 5.5, -14);
scenery.add(pillarR);

const board = new Mesh(new BoxGeometry(6, 1.6, 0.3), new MeshStandardMaterial({ color: 0x101418, roughness: 0.5 }));
board.name = 'Scoreboard'; board.position.set(0, 10.5, -14);
scenery.add(board);

editor.execute(new AddObjectCommand(editor, scenery));

editor.execute(new AddObjectCommand(editor, new AmbientLight(0xffffff, 0.5)));
const dir = new DirectionalLight(0xffffff, 1.2);
dir.position.set(4, 12, 10);
editor.execute(new AddObjectCommand(editor, dir));

3 Stand the camera behind your own paddle

Position it a little further back than the player paddle's z, raised in y for eye height, and lookAt a point near the far end — down and slightly beyond the computer paddle, not the paddle itself, so the whole length of the table stays in frame:

const cam = editor.camera;
cam.position.set(0, 3, 21);
cam.up.set(0, 1, 0);
cam.fov = 60;
cam.lookAt(0, 0.2, -8);
cam.updateProjectionMatrix();
editor.controls.center.set(0, 0.2, -8);
editor.signals.cameraChanged.dispatch(cam);
editor.signals.objectChanged.dispatch(cam);
Verify framing with math, not just eyeballing. Perspective cameras are easy to misjudge by feel — a z offset that seems modest can put the camera nearly edge-on to a thin flat object (a wall, a table surface), foreshortening it into a huge confusing diagonal slab that looks like a bug but isn't. Before trusting a screenshot, project the key points you care about through the candidate camera and check their normalized device coordinates (both components should stay roughly within -1..1 to be on screen):

const p = new Vector3(0, 0.35, 10.5).project(editor.camera); // Player Paddle
console.log(p.x, p.y); // outside -1..1 means it's off-frame

Comparing camera.position.distanceTo(...) to the near and far paddle also confirms the perspective ratio you're actually getting (here, roughly 2× — the far paddle is about twice as distant as the near one) before you spend time re-screenshotting.

4 Swap the arcade backdrop for a desert

The table itself never has to change — only what surrounds it. Remove the old Background Scenery group (and any stray lights left over from earlier experiments) with RemoveObjectCommand, the exact undo-tracked inverse of the AddObjectCommand that created them, then build a new Desert Scenery group the same way as step 2: a dune-displaced sand ground, a gradient sky plane, distant mesas, and a scatter of rocks and cacti kept clear of the rails:

const oldScenery = editor.scene.getObjectByProperty('name', 'Background Scenery');
if (oldScenery) editor.execute(new RemoveObjectCommand(editor, oldScenery));

const desert = new Group();
desert.name = 'Desert Scenery';

// Dune-displaced sand, reusing the noise-ground technique from "Build a night
// scene from scratch" — the further a vertex is from the table, the more relief.
const sandMat = new MeshStandardMaterial({ color: 0xd9b579, roughness: 0.95 });
const groundGeo = new PlaneGeometry(90, 90, 48, 48);
const gp = groundGeo.attributes.position;
for (let i = 0; i < gp.count; i++) {
  const x = gp.getX(i), y = gp.getY(i);
  const d = Math.sqrt(x * x + y * y);
  gp.setZ(i, Math.sin(x * 0.08 + 1.7) * Math.cos(y * 0.09) * 1.1 + Math.max(0, (d - 14)) * 0.02);
}
groundGeo.computeVertexNormals();
const ground = new Mesh(groundGeo, sandMat);
ground.name = 'Sand Ground';
ground.rotation.x = -Math.PI / 2;
ground.position.set(0, -0.46, -5);
desert.add(ground);

// A warm gradient painted onto a 4x256 canvas, stretched across a huge far plane —
// cheaper than a sky dome and plenty convincing behind mesas at this distance.
const skyCanvas = document.createElement('canvas');
skyCanvas.width = 4; skyCanvas.height = 256;
const sctx = skyCanvas.getContext('2d');
const grad = sctx.createLinearGradient(0, 0, 0, 256);
grad.addColorStop(0, '#3a3d6b');
grad.addColorStop(0.45, '#c9704f');
grad.addColorStop(0.75, '#f2a45c');
grad.addColorStop(1, '#fbd98b');
sctx.fillStyle = grad;
sctx.fillRect(0, 0, 4, 256);
const sky = new Mesh(new PlaneGeometry(140, 70), new MeshBasicMaterial({ map: new THREE.CanvasTexture(skyCanvas), fog: false }));
sky.name = 'Sky'; sky.position.set(0, 24, -55);
desert.add(sky);

const mesaMat = new MeshStandardMaterial({ color: 0x8a4b34, roughness: 0.9 });
[[-30, 5, -40, 10, 10, 8], [-14, 7, -46, 8, 14, 7], [20, 6, -42, 12, 12, 9], [32, 4.5, -38, 7, 9, 6]]
  .forEach((s, i) => {
    const mesa = new Mesh(new BoxGeometry(s[3], s[4], s[5]), mesaMat);
    mesa.name = 'Mesa ' + (i + 1);
    mesa.position.set(s[0], s[1], s[2]);
    mesa.rotation.y = i * 0.37;
    desert.add(mesa);
  });

editor.execute(new AddObjectCommand(editor, desert));

// Warm sun replaces the neutral arcade lighting.
editor.execute(new AddObjectCommand(editor, new AmbientLight(0xffd9a8, 0.55)));
const sunLight = new DirectionalLight(0xffdca8, 1.4);
sunLight.name = 'Sun Light'; sunLight.position.set(-18, 30, 10);
editor.execute(new AddObjectCommand(editor, sunLight));

setFog('FogExp2', '#e2a565', 0, 0, 0.012);
setBackground('#f2a45c');
The same paddle game table now surrounded by a sunset desert: sand dunes, distant reddish mesas, cacti, scattered rocks, and a warm gradient sky with a pale sun.
Same table, same rails, same paddles — only Background Scenery was swapped for Desert Scenery.
Rocks, cacti, and mesas all stay outside |x| > 6.15 — the rails' own x position — so nothing overlaps the play area or blocks the sightline down the table. Placing scatter props is otherwise the same pattern as step 1/2: a small array of [x, y, z, ...params] tuples and a .forEach that builds one mesh per entry, named with an index so each stays individually addressable (findObject('Rock 3'), $S('#Mesa 1')).

5 Sanity-check the object count

Every editor.execute(...) above is undoable and shows up in editor.history.undos. After any batch of Shell edits, especially ones driven by automation rather than typing, compare the scene's object count against what you expect:

editor.scene.children.length; // top-level count
editor.history.undos.length;  // commands issued this session
Why this matters. A stray keystroke reaching the viewport instead of the Shell input (an unclosed autocomplete dropdown, focus lost between two automated steps) can trigger the app's global keyboard shortcuts — Delete removes whatever's currently selected. It's silent: the Shell shows no error, and the deletion is a normal RemoveObjectCommand sitting in the same undo stack as your real edits. Checking editor.history.undos for command types you didn't intend to run is the fastest way to catch it, and every entry there is individually undoable if something needs reverting.

Credits and licences

People

Assets

None used.

Tools

None used.