benjivers/val Public Public: anyone can read this; the crew can change it.

Predict through the buy phase, and hold rather than snap at a wall

Two stutters left, reported from play: 'when i hit a wall i stutter and when
im in the buy phase i stutter'. Both were corrections that jumped.

The buy phase turned prediction off entirely, so your own body was drawn from
the newest snapshot and nothing else — a position that moves thirty times a
second under a sixty-frame render, which is a stutter by construction. The
reason it was off was that the pen (nowhere further than 120 from your side's
spawn) is a match rule the browser did not know. It does now: the map it is
already handed carries the spawn tiles, so the client reads the same rule and
predicts through the phase like any other.

At a wall, a lean across a corner could put the drawn body inside one, and the
answer was to take the server's position outright. Pressed against a wall and
strafing that fires most frames, and every one of them is a jump. It now holds
the last good position instead — invisible, just as honest, and the next
snapshot corrects it anyway. Anchoring is kept for the case where holding
still leaves the body in a wall.

258 tests, four clean runs.
benjivers committed 03302de Browse files
2 files changed +53 −4
src/client.test.ts +17
@@ -469,3 +469,20 @@ test("your own camera is never drawn as a stranger arriving", async () => {
469469 expect(ownReturn).toBeGreaterThan(-1);
470470 expect(slideCall).toBeGreaterThan(ownReturn);
471471 });
472
473 test("the buy phase is predicted, and its pen is the server's own rule", async () => {
474 const bundle = await Bun.file(new URL("../static/val.js", import.meta.url)).text();
475 // Prediction is no longer switched off for the whole buy phase: it is on
476 // whenever the browser knows the pen, which it learns from the map it is
477 // already given. Drawing your own body straight from a 30Hz snapshot while
478 // rendering at 60 is a stutter, and that is what the phase used to do.
479 expect(bundle).toContain('snap.phase !== "buy" || pens !== null');
480 // And the pen is the server's number, not a guess: 120 from a spawn tile.
481 expect(bundle).toContain("best > 120");
482 // A refused step holds where it was rather than snapping to the server.
483 const predict = bundle.slice(bundle.indexOf("function predict("));
484 const holds = predict.indexOf("local.x = was.x");
485 const anchors = predict.indexOf("anchor(local, snap.me)");
486 expect(holds).toBeGreaterThan(-1);
487 expect(holds).toBeLessThan(anchors); // hold first; anchor only if still stuck
488 });
static/val.js +36 −4
@@ -44,6 +44,7 @@ const HELP_SEEN = "val.help.seen";
4444 let map = null;
4545 let solid = () => true;
4646 let world = null; // what predict.js slides against: the grid, from map.json
47 let pens = null; // the spawn tiles each side is penned to during the buy phase
4748 let snap = null;
4849 let socket = null;
4950 let agents = null;
@@ -370,7 +371,17 @@ const shownAt = (p) => shown(presence, p.id, nowSec);
370371 * phase being pulled back through the door.
371372 */
372373 const predicting = () =>
373 snap !== null && !watching(snap) && world !== null && snap.phase !== "buy" && snap.me.alive;
374 snap !== null && !watching(snap) && world !== null && snap.me.alive && (snap.phase !== "buy" || pens !== null);
375
376 /** The server's own rule: in the buy phase, nowhere further than 120 from a spawn. */
377 const outsidePen = (x, y) => {
378 if (snap.phase !== "buy" || pens === null) return false;
379 const cells = pens[snap.me.side] ?? [];
380 if (cells.length === 0) return false;
381 let best = Infinity;
382 for (const c of cells) best = Math.min(best, Math.hypot(c.x - x, c.y - y));
383 return best > 120;
384 };
374385
375386 /** The server's answer, caught up to now, then this frame of your own keys. */
376387 function predict(dt, now) {
@@ -389,12 +400,20 @@ function predict(dt, now) {
389400 // The snapshot is a moment old and the keys held since have not reached
390401 // it; carry it that far forward and lean the drawn body onto it.
391402 const target = project(snap.me, held.mx, held.my, speed, (now - snapAt) / 1000, world);
403 const was = { x: local.x, y: local.y };
392404 reconcile(local, target, dt);
393405 advance(local, held.mx, held.my, speed, dt, world);
394406 holdBack(local, target, held.mx, held.my);
395 // A lean across a corner could put the drawn body in a wall; the server's
396 // answer is never wrong, so take it rather than draw from inside one.
397 if (insideWall(local, world)) anchor(local, snap.me);
407 // A lean across a corner can put the drawn body inside a wall, and walking
408 // out of the spawn pen is refused by the server outright. Both used to take
409 // the server's answer, which is a jump: pressed against a wall and strafing,
410 // that fired most frames and read as a stutter. Holding the last good place
411 // is invisible and just as honest — the next snapshot corrects it anyway.
412 if (insideWall(local, world) || outsidePen(local.x, local.y)) {
413 local.x = was.x;
414 local.y = was.y;
415 if (insideWall(local, world)) anchor(local, snap.me);
416 }
398417 drawnMe.id = snap.me.id;
399418 drawnMe.x = local.x;
400419 drawnMe.y = local.y;
@@ -856,6 +875,19 @@ Promise.all([fetch("/map.json").then((r) => r.json()), fetch("/agents.json").the
856875 // The same grid the server collides against, so a guess about your own
857876 // movement lands where the server's answer is about to.
858877 world = { solid, tile: map.tile, r: BODY_R };
878 // The buy phase pens everybody in their own spawn, and the map the client
879 // is handed carries the spawn tiles ('a' attackers, 'd' defenders), so the
880 // browser can know the rule rather than turning prediction off through the
881 // whole phase and drawing your own body thirty times a second.
882 const cellsOf = (letter) => {
883 const out = [];
884 for (let row = 0; row < rows.length; row++)
885 for (let col = 0; col < cols; col++)
886 if (rows[row][col] === letter)
887 out.push({ x: (col + 0.5) * map.tile, y: (row + 0.5) * map.tile });
888 return out;
889 };
890 pens = { atk: cellsOf("a"), def: cellsOf("d") };
859891 $("stage").dataset.mode = mode;
860892 fillHelp();
861893 resize();
↑↓ navigate ↵ open esc close

Keyboard shortcuts