Making a game

Smooth gameplay

Drawing between steps, teleports and camera cuts, and measuring a judder.

The rules step at a fixed rate, prefabs.engine.dt, 1/60 of a second in a new game, and the screen draws at its own. This page covers drawing smoothly between the steps, and marking a jump, which the drawing cannot tell from fast travel.

What each frame draws

  • Under render3d, every object and the camera are drawn a step behind the clock, between where the last two steps left them. A look writes its object from its row as the row is, every frame, and never eases, lerps or extrapolates it: a second blend on top lags and wobbles.
  • An InstancedMesh's copies are drawn where they are set, not between.
  • A thing moved on a frame that took no step, such as an animation, a particle or a camera turned on the page's clock, is drawn where it is set for the rest of that step and all of the next. It is drawn between steps again once a whole step has gone by with only the steps moving it.
  • A thing new on the newest step is not drawn until the clock reaches its step, and is then drawn where it was set.

Marking a jump

  1. In the rule, add 1 to a field such as jumps on the step that puts the row somewhere new: a door to elsewhere, a respawn, a car put back on the road. A field set for one step and cleared is missed when two packets land in one frame; a count is not.
  2. In the look, set render3d's userData.teleport to true on the object on the frame the count changes, and back to false on a later frame.
  3. Flag every other object that jumps with it the same way, off the same count: the camera for a first-person jump, the player's body for the other seats, a held thing. A child moved with its parent needs no flag; a child moved by itself needs its own.
  4. For a thing that jumps every step, such as a light moved from lamp to lamp, leave the flag up.
// player.client.ts, for the row that is mine
let jumps = 0
export function setup(row: Row) { jumps = row.data.jumps }
export function tick(row: Row, plugins: Plugins) {
  const camera = plugins.render3d.camera
  if (!camera) return
  camera.userData.teleport = row.data.jumps !== jumps
  jumps = row.data.jumps
  camera.position.set(row.data.pos[0], row.data.pos[1] + eye, row.data.pos[2])
  camera.quaternion.setFromAxisAngle(up, row.data.yaw)
}

render2d has no such flag.

Keeping it smooth

  1. Set a turn whole, as a quaternion: quaternion.setFromAxisAngle or quaternion.fromArray. Never set one Euler angle alone, as rotation.y = yaw: past 90° three.js hands back a mirrored set of angles, and the blend turns the long way round every frame.
  2. Solve each motion exactly over dt, so it moves the same at every step rate (Tuning feel). The manager's prefabs.engine.dt, its prefabs.scenemanager.dt and each scene's prefabs.engine.dt change together.
  3. Put what the seat's press moves on its player row, so client.html draws it from the page's own copy on the frame after the key (Designing a seat). A camera follows that row.
  4. Make a one-step event a ledger entry, not a field set and cleared (Input and seats).
  5. Compute rules with math, never Math.sin and the rest: node and Chrome differ in the last bit, and the page's copy drifts from the server's.
  6. Give predict's world.predict.snap in the rows' units, well under the shortest jump. A correction no bigger than snap is eased out over halflife milliseconds; a bigger one, such as a correction across a jump, is drawn at once.

What goes wrong

SeenCauseFix
The camera slides across the level for a frame on a jump or a respawnNot flaggeduserData.teleport on the frame it is put there
It still slides for part of the stepThe flag raised and lowered in the same tickLower it on a later frame
A thing that jumps every step streaksIt is blendedLeave its flag up
A turn spins the long wayOne Euler angle set aloneSet the whole quaternion
A look's own smoothing lags and wobblesA second blend over render3d'sWrite the object from the row as it is
The flag is on a group, and a child still slidesThe child moved by itselfFlag the child
A jump is missed on some framesA one-step true or false on the rowA count

Checking it

  1. Record what is drawn every frame with render3d's drew(f): where the camera and each mesh stand, between the steps. The frame where a drawn value leaves the row's is the bug.
  2. Read the simulator's standoff: how much the camera's distance to the seat's row changes from frame to frame.
  3. Play the game in Chrome at the screen's own rate, walk through the door, and watch for any frame between the two places.