Making a game

Tuning feel

Movement, physics and cameras, and the numbers that make them feel right.

This covers how fast a thing speeds up, stops, turns and falls, where those numbers live, and how to turn and measure them while the game runs.

Where the numbers go

NumberHome
What changes while it runs, such as velA field of the row, its starting value in the kind's prefab
What a kind is tuned by, such as accelThe kind's definition in assets/<game>.json
A plugin's fixed numbersworld.<plugin>, such as world.walker.kinds.<kind> or world.box2d.kinds.<kind>
The step rateprefabs.engine.dt and prefabs.scenemanager.dt in the manager, and prefabs.engine.dt in each child scene, changed together

The movement plugins are box2d, box3d, box3dvehicle, walker, flight3d and ragdoll3d. Each page lists the numbers it reads.

1. Write the motion as a law

  1. Give the thing a velocity field, "vel": [0, 0], on its prefab.
  2. Put accel and friction on its definition, and in its rule step vel by dv/dt = accel·move − friction·v, solved exactly over each step's dt.
  3. Read def(w, me.kind) every step, so a changed number takes effect on the next step.
const keep = math.exp(-friction * dt)
const gain = friction &gt; 0 ? (1 - keep) / friction : dt
vel = [vel[0] * keep + accel * move[0] * gain, vel[1] * keep + accel * move[1] * gain]

Held, the speed closes on accel / friction and reaches nine tenths of it in ln 10 / friction seconds. Let go, it glides a further top / friction. With accel 1800 and friction 7.5 that is 240 px/s, 0.31 s and 32 px. Because the law is solved over the step, the same numbers give the same speeds at 30, 60 or 120 steps a second.

walker eases vel toward want the same way: its accel is a rate a second, this law's friction, not a push, and its top speed is the want the rule asks for. Its airAccel is that rate while the body is in the air, accel when left out, so 0 keeps the speed a leap or a fling left the ground with.

2. Test the law and the press

  1. In <kind>.sim.test.ts, check the step that first reaches nine tenths of top speed, the top speed after three seconds held, and the glide, all worked out from the definition.
  2. Check the law gives the same speed at the same moment at 1/30, 1/60 and 1/120.
  3. In <game>.predict.test.ts, check that the seat's player row is drawn moved on the frame after each press.

3. Turn the numbers in dev.html

In the testbed, dev.html, write overrides into the definition table with jsonAt, and any scene, prefab or world override before load. Add keys that turn a number and a readout of top speed, time to nine tenths and glide. Write what feels right into the definition file by hand.

4. Measure

A glide too long for its top speed reads as floaty; too many seconds to nine tenths reads as sluggish. Put both in the readout and the test. The simulator plays pages against the server and records every frame; its standoff figure shows a camera that judders.

Cameras

chasecamera3d is a rigid orbit on one row and adds no easing: whatever should turn or rise gradually is a field of the row that its rule moves at a rate from the definition. pancamera pans at world.pancamera.speed inside bounds. roomcamera is handed a point and a room each frame. The seat's camera follows its player row, so it moves on the frame after the key.

Checking it

  • yarn vitest run games/<game>/player.sim.test.ts and yarn vitest run games/<game>/<game>.predict.test.ts pass.
  • In dev.html the readout's top speed matches the drawn speed with a key held.
  • On client.html against the server, the thing answers the key on the next frame.