reworking reconcilation and improved debug

This commit is contained in:
ari melody 2024-08-30 15:18:07 +01:00
parent cdaa7c678d
commit f1c9af7b6b
Signed by: ari
GPG key ID: CF99829C92678188
4 changed files with 251 additions and 142 deletions

View file

@ -12,9 +12,11 @@
<script type="module" src="/js/main.js"></script> <script type="module" src="/js/main.js"></script>
<div id="controls"> <div id="controls">
<label for="fakeping">Fake Latency (ms): </label> <label for="fakeping">Fake Latency (ms): </label>
<input type="number" id="fakeping" value="0" min="0" max="1000" title="This feature is broken!" disabled> <input type="number" id="fakeping" value="0" min="0" max="1000">
<label for="interpolation">Interpolation: </label> <label for="interpolation">Interpolation: </label>
<input type="checkbox" id="interpolation"> <input type="checkbox" id="interpolation">
<label for="show-authority">Show Authority Positions: </label>
<input type="checkbox" id="show-authority">
</div> </div>
<div id="chatbox"></div> <div id="chatbox"></div>
<div id="compose"> <div id="compose">

View file

@ -22,32 +22,47 @@ var players = {};
var props = {}; var props = {};
var client_id; var client_id;
var delta = 0.0; var delta = 0.0;
var last_update = 0.0; var lastUpdate = 0.0;
var frames = 0; var frames = 0;
var ticks = 0; var ticks = 0;
var server_tick = 0; var serverTick = 0;
var server_ping = 0; var serverPing = 0;
var reconciliations = 0;
var lastServerState = 0;
var ws; var ws;
var predictions = {};
const interpolationToggle = document.getElementById("interpolation"); const BUFFER_SIZE = TICK_RATE * 5; // 5 seconds of buffer
var enable_interpolation = new Stateful(localStorage.getItem("interpolation") || true); var stateBuffer = new Array(BUFFER_SIZE);
interpolationToggle.checked = enable_interpolation.get(); var inputBuffer = new Array(BUFFER_SIZE);
enable_interpolation.onUpdate(val => {
localStorage.setItem("interpolation", val);
});
interpolationToggle.addEventListener("change", () => {
enable_interpolation.set(interpolationToggle.checked);
});
const fakePingInput = document.getElementById("fakeping"); const fakePingInput = document.getElementById("fakeping");
var fake_ping = new Stateful(localStorage.getItem("fakeping") || 0); var fakePing = new Stateful(localStorage.getItem("fakeping") || 0);
fakePingInput.value = fake_ping.get(); fakePingInput.value = fakePing.get();
fake_ping.onUpdate(val => { fakePing.onUpdate(val => {
localStorage.setItem("fakeping", val); localStorage.setItem("fakeping", val);
}); });
fakePingInput.addEventListener("change", () => { fakePingInput.addEventListener("change", () => {
fake_ping.set(fakePingInput.value); fakePing.set(fakePingInput.value);
});
const interpolationToggle = document.getElementById("interpolation");
var interpolationEnabled = new Stateful(localStorage.getItem("interpolation") || true);
interpolationToggle.checked = interpolationEnabled.get();
interpolationEnabled.onUpdate(val => {
localStorage.setItem("interpolation", val);
});
interpolationToggle.addEventListener("change", () => {
interpolationEnabled.set(interpolationToggle.checked);
});
const authorityPositionToggle = document.getElementById("show-authority");
var showAuthorityPositions = new Stateful(localStorage.getItem("show-authority") || false);
authorityPositionToggle.checked = showAuthorityPositions.get();
showAuthorityPositions.onUpdate(val => {
localStorage.setItem("show-authority", val);
});
authorityPositionToggle.addEventListener("change", () => {
showAuthorityPositions.set(authorityPositionToggle.checked);
}); });
var input = { var input = {
@ -72,7 +87,6 @@ function start() {
}); });
ws.addEventListener("message", packet => { ws.addEventListener("message", packet => {
setTimeout(() => {
var data = JSON.parse(packet.data); var data = JSON.parse(packet.data);
switch (data.type) { switch (data.type) {
@ -107,73 +121,9 @@ function start() {
chatbox.scrollTop = chatbox.scrollHeight; chatbox.scrollTop = chatbox.scrollHeight;
players[data.id] = new Player(data.name, data.x, data.y, data.col); players[data.id] = new Player(data.name, data.x, data.y, data.col);
break; break;
case "update": { case "update":
server_tick = data.tick; processServerTick(data);
var prediction = predictions[data.tick];
if (prediction) {
Object.keys(predictions).forEach(tick => {
if (tick < data.tick) delete predictions[tick];
});
server_ping = new Date() - prediction.time;
}
Object.keys(data.players).forEach(id => {
const player = players[id];
const update = data.players[id];
if (id == client_id) {
if (!prediction) return;
const predicted = prediction.player;
// clear all predictions prior to this tick
if (predicted != update) {
var diff_x = predicted.x - update.x;
var diff_y = predicted.y - update.y;
// apply difference to all predictions
Object.values(predictions).forEach(p => {
p.x -= diff_x;
p.y -= diff_y;
});
// update client state to reflect
player.x -= diff_x;
player.y -= diff_y;
}
delete predictions[data.tick];
} else {
player.x = update.x;
player.y = update.y;
}
});
Object.keys(data.props).forEach(id => {
const prop = props[id];
const update = data.props[id];
if (!prediction) {
prop.x = update.x;
prop.y = update.y;
prop.xv = update.xv;
prop.yv = update.yv;
return;
}
const predicted = prediction.props[id];
if (predicted != update) {
var diff_x = predicted.x - update.x;
var diff_y = predicted.y - update.y;
var diff_xv = predicted.xv - update.xv;
var diff_yv = predicted.yv - update.yv;
// apply difference to all predictions
Object.values(predictions).forEach(p => {
p.x -= diff_x;
p.y -= diff_y;
p.xv -= diff_xv;
p.yv -= diff_yv;
});
// update client state to reflect
prop.x -= diff_x;
prop.y -= diff_y;
prop.xv -= diff_xv;
prop.yv -= diff_yv;
}
});
break; break;
}
case "chat": { case "chat": {
const player = players[data.player]; const player = players[data.player];
@ -214,7 +164,6 @@ function start() {
console.warn("Unknown message received from the server."); console.warn("Unknown message received from the server.");
console.warn(msg); console.warn(msg);
} }
}, fake_ping.get() / 2);
}); });
ws.addEventListener("error", error => { ws.addEventListener("error", error => {
@ -242,14 +191,135 @@ function start() {
}); });
} }
function processServerTick(data) {
lastServerState = data;
serverTick = data.tick;
var localState = stateBuffer[data.tick % BUFFER_SIZE]
if (localState) serverPing = new Date() - localState.time;
// update players
Object.keys(data.players).forEach(id => {
const player = players[id];
const serverPlayerState = data.players[id];
if (!localState) {
player.x = serverPlayerState.x;
player.y = serverPlayerState.y;
player.in_x = serverPlayerState.in_x;
player.in_y = serverPlayerState.in_y;
return;
}
const localPlayerState = localState.players[id];
// calculate position difference from authority
var dist = Math.sqrt(
Math.pow(localPlayerState.x - serverPlayerState.x, 2) +
Math.pow(localPlayerState.y - serverPlayerState.y, 2));
if (dist > 10.0) {
return;
reconciliations++;
console.warn("Player#" + id + ": Reconciling " + dist + " units of error.");
console.warn("\tclient said (" + localPlayerState.x.toPrecision(4) + "," + localPlayerState.y.toPrecision(4) + ")");
console.warn("\tserver said (" + serverPlayerState.x.toPrecision(4) + "," + serverPlayerState.y.toPrecision(4) + ")");
// resync to authority and roll-forward
localState.players[id] = serverPlayerState;
for (var tick = serverTick + 1; tick < ticks; tick++) {
const state = stateBuffer[(tick - 1) % BUFFER_SIZE].players[id];
const input = id == client_id ? inputBuffer[tick % BUFFER_SIZE] : {
x: state.in_x,
y: state.in_y,
};
player.x = state.x;
player.y = state.y;
player.in_x = input.x;
player.in_y = input.y;
player.update(1000 / TICK_RATE / 1000);
stateBuffer[tick % BUFFER_SIZE].player = {
x: player.x,
y: player.y,
}
}
var lastState = stateBuffer[ticks % BUFFER_SIZE];
if (lastState) {
player.x = lastState.players[id].x;
player.y = lastState.players[id].y;
}
}
});
// update props
Object.keys(data.props).forEach(id => {
const serverPropState = data.props[id];
const prop = props[id];
prop.x = serverPropState.x;
prop.y = serverPropState.y;
prop.xv = serverPropState.xv;
prop.yv = serverPropState.yv;
return; // TODO: reimplement this once player reconciliation is stable
if (!localState) {
prop.x = serverPropState.x;
prop.y = serverPropState.y;
prop.xv = serverPropState.xv;
prop.yv = serverPropState.yv;
return;
}
const localPropState = localState.props[id];
// calculate position difference from authority
var dist = Math.sqrt(
Math.pow(localPropState.x - serverPropState.x, 2) +
Math.pow(localPropState.y - serverPropState.y, 2));
if (dist > 10.0) {
reconciliations++;
console.warn("Prop#" + id + ": Reconciling " + dist + " units of error.");
// resync to authority and roll-forward
localState.props[id] = serverPropState;
for (var tick = serverTick; tick < ticks; tick++) {
const state = stateBuffer[(tick - 1) % BUFFER_SIZE];
const propState = state.props[id];
prop.x = propState.x;
prop.y = propState.y;
prop.xv = propState.xv;
prop.yv = propState.yv;
prop.update(1000 / TICK_RATE / 1000, Object.values(state.players));
stateBuffer[tick % BUFFER_SIZE].props[id] = {
x: prop.x,
y: prop.y,
xv: prop.xv,
yv: prop.yv,
}
}
var lastState = stateBuffer[ticks % BUFFER_SIZE];
if (lastState) {
prop.x = lastState.props[id].x;
prop.y = lastState.props[id].y;
prop.xv = lastState.props[id].xv;
prop.yv = lastState.props[id].yv;
}
}
});
}
canvas.addEventListener("keypress", event => { canvas.addEventListener("keypress", event => {
switch (event.key.toLowerCase()) { switch (event.key.toLowerCase()) {
case 'i': case 'i':
enable_interpolation.update(val => !val); interpolationEnabled.update(val => !val);
interpolationToggle.checked = enable_interpolation.get(); interpolationToggle.checked = interpolationEnabled.get();
break; break;
case 'p': case 'p':
console.log(predictions); showAuthorityPositions.update(val => !val);
authorityPositionToggle.checked = showAuthorityPositions.get();
break; break;
case 'enter': case 'enter':
composeBox.focus(); composeBox.focus();
@ -322,45 +392,38 @@ function sendChat(msg) {
type: "chat", type: "chat",
msg: msg, msg: msg,
})); }));
}, fake_ping.get() / 2); }, fakePing.get());
} }
function update(delta) { function update(delta) {
const prediction = {
time: new Date(),
player: {},
props: {},
};
const clientPlayer = players[client_id]; const clientPlayer = players[client_id];
if (!clientPlayer) return; if (!clientPlayer) return;
clientPlayer.in_x = input.move_right - input.move_left; clientPlayer.in_x = input.move_right - input.move_left;
clientPlayer.in_y = input.move_down - input.move_up; clientPlayer.in_y = input.move_down - input.move_up;
clientPlayer.update(delta); inputBuffer[ticks % BUFFER_SIZE] = {
x: clientPlayer.in_x,
// insert prediction for the next server tick y: clientPlayer.in_y,
prediction.player = {
x: clientPlayer.x,
y: clientPlayer.y,
}; };
var t = ticks; var playerState = {};
setTimeout(() => { Object.keys(players).forEach(id => {
if (!ws) return; const player = players[id];
ws.send(JSON.stringify({ player.update(delta);
type: "update", playerState[id] = {
tick: t, x: player.x,
x: input.move_right - input.move_left, y: player.y,
y: input.move_down - input.move_up, in_x: player.in_x,
})); in_y: player.in_y,
}, fake_ping.get() / 2); };
});
var propState = {};
Object.keys(props).forEach(id => { Object.keys(props).forEach(id => {
const prop = props[id]; const prop = props[id];
prop.update(delta, Object.values(players)); prop.update(delta, Object.values(players));
prediction.props[id] = { propState[id] = {
x: prop.x, x: prop.x,
y: prop.y, y: prop.y,
xv: prop.xv, xv: prop.xv,
@ -368,15 +431,30 @@ function update(delta) {
}; };
}); });
predictions[ticks] = prediction; stateBuffer[ticks % BUFFER_SIZE] = {
tick: ticks,
time: new Date(),
players: playerState,
props: propState,
};
setTimeout(ticks => {
if (!ws) return;
ws.send(JSON.stringify({
type: "update",
tick: ticks,
x: input.move_right - input.move_left,
y: input.move_down - input.move_up,
}));
}, fakePing.get() / 2, ticks);
ticks++; ticks++;
} }
function draw() { function draw() {
delta = performance.now() - last_update; delta = performance.now() - lastUpdate;
if (performance.now() - last_update >= 1000 / TICK_RATE) { if (performance.now() - lastUpdate >= 1000 / TICK_RATE) {
last_update = performance.now(); lastUpdate = performance.now();
update(delta / 1000); update(delta / 1000);
} }
@ -385,34 +463,56 @@ function draw() {
ctx.fillStyle = "#f0f0f0"; ctx.fillStyle = "#f0f0f0";
ctx.fillRect(0, 0, WORLD_SIZE, WORLD_SIZE); ctx.fillRect(0, 0, WORLD_SIZE, WORLD_SIZE);
// DEBUG: show authoritative position for client players and props
var authorityDistance = 0.0;
if (lastServerState && showAuthorityPositions.get()) {
const player = players[client_id];
const lastPlayerState = lastServerState.players[client_id];
authorityDistance = Math.sqrt(
Math.pow(players[client_id].x - lastPlayerState.x, 2) +
Math.pow(players[client_id].y - lastPlayerState.y, 2));
ctx.strokeStyle = player.colour;
ctx.beginPath();
ctx.rect(
lastPlayerState.x - Player.SIZE / 2,
lastPlayerState.y - Player.SIZE / 2,
Player.SIZE, Player.SIZE);
ctx.stroke();
Object.keys(props).forEach(id => {
const prop = props[id];
const lastPropState = lastServerState.props[id];
ctx.strokeStyle = prop.colour;
ctx.beginPath();
ctx.rect(
lastPropState.x - Prop.SIZE / 2,
lastPropState.y - Prop.SIZE / 2,
Prop.SIZE, Prop.SIZE);
ctx.stroke();
});
}
drawPlayers(); drawPlayers();
drawProps(); drawProps();
// DEBUG: draw last known authoritative state var debug = {
if (Object.values(predictions).length > 10) { "ping": serverPing + "ms",
const server_state = Object.values(predictions)[0]; "fake ping": + fakePing.get() + "ms",
ctx.fillStyle = "#208020"; "ticks behind": ticks - serverTick,
ctx.beginPath(); "reconciliations": reconciliations,
ctx.rect(server_state.player.x - Player.SIZE / 2, }
server_state.player.y - Player.SIZE / 2, if (showAuthorityPositions.get()) {
Player.SIZE, Player.SIZE); debug["authority distance"] = authorityDistance;
ctx.stroke();
} }
var debug = "ping: " + server_ping + "ms\n" +
"fake ping: " + fake_ping.get() + "ms\n" +
"buffer length: " + Object.keys(predictions).length + "\n" +
"delta: " + delta + "\n" +
"ticks behind: " + (ticks - server_tick);
ctx.fillStyle = "#101010"; ctx.fillStyle = "#101010";
ctx.font = "16px monospace"; ctx.font = "16px monospace";
ctx.textAlign = "left"; ctx.textAlign = "left";
ctx.textBaseline = "bottom"; ctx.textBaseline = "bottom";
var debug_lines = debug.split('\n'); var debug_y = WORLD_SIZE - 8 - (Object.keys(debug).length - 1) * 16;
var debug_y = WORLD_SIZE - 8 - (debug_lines.length - 1) * 16; Object.keys(debug).forEach((key, i) => {
for (var i = 0; i < debug_lines.length; i++) { ctx.fillText(key + ": " + debug[key], 8, debug_y + 16 * i);
ctx.fillText(debug_lines[i], 8, debug_y + 16 * i); });
}
frames++; frames++;
requestAnimationFrame(draw); requestAnimationFrame(draw);
@ -422,7 +522,7 @@ function drawPlayers() {
Object.keys(players).forEach((id, index) => { Object.keys(players).forEach((id, index) => {
const player = players[id]; const player = players[id];
if (enable_interpolation.get()) { if (interpolationEnabled.get()) {
player.draw_x = player.draw_x + 0.1 * (player.x - player.draw_x); player.draw_x = player.draw_x + 0.1 * (player.x - player.draw_x);
player.draw_y = player.draw_y + 0.1 * (player.y - player.draw_y); player.draw_y = player.draw_y + 0.1 * (player.y - player.draw_y);
} else { } else {
@ -459,7 +559,7 @@ function drawProps() {
Object.keys(props).forEach(id => { Object.keys(props).forEach(id => {
const prop = props[id]; const prop = props[id];
if (enable_interpolation.get()) { if (interpolationEnabled.get()) {
prop.draw_x = prop.draw_x + 0.1 * (prop.x - prop.draw_x); prop.draw_x = prop.draw_x + 0.1 * (prop.x - prop.draw_x);
prop.draw_y = prop.draw_y + 0.1 * (prop.y - prop.draw_y); prop.draw_y = prop.draw_y + 0.1 * (prop.y - prop.draw_y);
} else { } else {

View file

@ -1,5 +1,7 @@
import { WORLD_SIZE } from "./world.js";
export default class Player { export default class Player {
static SPEED = 400.0; static SPEED = 200.0;
static SIZE = 50.0; static SIZE = 50.0;
constructor(name, x, y, colour) { constructor(name, x, y, colour) {
@ -16,5 +18,8 @@ export default class Player {
update(delta) { update(delta) {
if (this.in_x != 0) this.x += this.in_x * Player.SPEED * delta; if (this.in_x != 0) this.x += this.in_x * Player.SPEED * delta;
if (this.in_y != 0) this.y += this.in_y * Player.SPEED * delta; if (this.in_y != 0) this.y += this.in_y * Player.SPEED * delta;
this.x = Math.min(Math.max(this.x, 0.0), WORLD_SIZE);
this.y = Math.min(Math.max(this.y, 0.0), WORLD_SIZE);
} }
} }

View file

@ -77,6 +77,8 @@ export function init(http_server) {
name: client.player.name, name: client.player.name,
x: client.player.x, x: client.player.x,
y: client.player.y, y: client.player.y,
xv: client.player.xv,
yv: client.player.yv,
col: client.player.colour, col: client.player.colour,
}; };
}); });
@ -168,17 +170,18 @@ export function init(http_server) {
function update() { function update() {
var delta = (performance.now() - last_update) / 1000; var delta = (performance.now() - last_update) / 1000;
last_update = performance.now();
// update players // update players
var frame_players = {}; var frame_players = {};
clients.forEach(client => { clients.forEach(client => {
if (!client.player) return; if (!client.player) return;
client.player.update(delta); client.player.update(delta);
client.player.x = Math.max(Math.min(client.player.x, WORLD_SIZE), 0);
client.player.y = Math.max(Math.min(client.player.y, WORLD_SIZE), 0);
frame_players[client.id] = { frame_players[client.id] = {
x: client.player.x, x: client.player.x,
y: client.player.y, y: client.player.y,
xv: client.player.xv,
yv: client.player.yv,
}; };
}); });
@ -211,7 +214,6 @@ function update() {
})); }));
}) })
last_update = performance.now();
ticks++; ticks++;
setTimeout(update, 1000 / TICK_RATE); setTimeout(update, 1000 / TICK_RATE);
} }