diff --git a/client/index.html b/client/index.html
index 9d771f0..42a6755 100644
--- a/client/index.html
+++ b/client/index.html
@@ -12,9 +12,11 @@
-
+
+
+
diff --git a/client/js/main.js b/client/js/main.js
index 526a0a7..ca8c0dd 100644
--- a/client/js/main.js
+++ b/client/js/main.js
@@ -22,32 +22,47 @@ var players = {};
var props = {};
var client_id;
var delta = 0.0;
-var last_update = 0.0;
+var lastUpdate = 0.0;
var frames = 0;
var ticks = 0;
-var server_tick = 0;
-var server_ping = 0;
+var serverTick = 0;
+var serverPing = 0;
+var reconciliations = 0;
+var lastServerState = 0;
var ws;
-var predictions = {};
-const interpolationToggle = document.getElementById("interpolation");
-var enable_interpolation = new Stateful(localStorage.getItem("interpolation") || true);
-interpolationToggle.checked = enable_interpolation.get();
-enable_interpolation.onUpdate(val => {
- localStorage.setItem("interpolation", val);
-});
-interpolationToggle.addEventListener("change", () => {
- enable_interpolation.set(interpolationToggle.checked);
-});
+const BUFFER_SIZE = TICK_RATE * 5; // 5 seconds of buffer
+var stateBuffer = new Array(BUFFER_SIZE);
+var inputBuffer = new Array(BUFFER_SIZE);
const fakePingInput = document.getElementById("fakeping");
-var fake_ping = new Stateful(localStorage.getItem("fakeping") || 0);
-fakePingInput.value = fake_ping.get();
-fake_ping.onUpdate(val => {
+var fakePing = new Stateful(localStorage.getItem("fakeping") || 0);
+fakePingInput.value = fakePing.get();
+fakePing.onUpdate(val => {
localStorage.setItem("fakeping", val);
});
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 = {
@@ -72,7 +87,6 @@ function start() {
});
ws.addEventListener("message", packet => {
- setTimeout(() => {
var data = JSON.parse(packet.data);
switch (data.type) {
@@ -107,73 +121,9 @@ function start() {
chatbox.scrollTop = chatbox.scrollHeight;
players[data.id] = new Player(data.name, data.x, data.y, data.col);
break;
- case "update": {
- server_tick = data.tick;
- 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;
- }
- });
+ case "update":
+ processServerTick(data);
break;
- }
case "chat": {
const player = players[data.player];
@@ -214,7 +164,6 @@ function start() {
console.warn("Unknown message received from the server.");
console.warn(msg);
}
- }, fake_ping.get() / 2);
});
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 => {
switch (event.key.toLowerCase()) {
case 'i':
- enable_interpolation.update(val => !val);
- interpolationToggle.checked = enable_interpolation.get();
+ interpolationEnabled.update(val => !val);
+ interpolationToggle.checked = interpolationEnabled.get();
break;
case 'p':
- console.log(predictions);
+ showAuthorityPositions.update(val => !val);
+ authorityPositionToggle.checked = showAuthorityPositions.get();
break;
case 'enter':
composeBox.focus();
@@ -322,45 +392,38 @@ function sendChat(msg) {
type: "chat",
msg: msg,
}));
- }, fake_ping.get() / 2);
+ }, fakePing.get());
}
function update(delta) {
- const prediction = {
- time: new Date(),
- player: {},
- props: {},
- };
-
const clientPlayer = players[client_id];
if (!clientPlayer) return;
clientPlayer.in_x = input.move_right - input.move_left;
clientPlayer.in_y = input.move_down - input.move_up;
- clientPlayer.update(delta);
-
- // insert prediction for the next server tick
- prediction.player = {
- x: clientPlayer.x,
- y: clientPlayer.y,
+ inputBuffer[ticks % BUFFER_SIZE] = {
+ x: clientPlayer.in_x,
+ y: clientPlayer.in_y,
};
- var t = ticks;
- setTimeout(() => {
- if (!ws) return;
- ws.send(JSON.stringify({
- type: "update",
- tick: t,
- x: input.move_right - input.move_left,
- y: input.move_down - input.move_up,
- }));
- }, fake_ping.get() / 2);
+ var playerState = {};
+ Object.keys(players).forEach(id => {
+ const player = players[id];
+ player.update(delta);
+ playerState[id] = {
+ x: player.x,
+ y: player.y,
+ in_x: player.in_x,
+ in_y: player.in_y,
+ };
+ });
+ var propState = {};
Object.keys(props).forEach(id => {
const prop = props[id];
prop.update(delta, Object.values(players));
- prediction.props[id] = {
+ propState[id] = {
x: prop.x,
y: prop.y,
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++;
}
function draw() {
- delta = performance.now() - last_update;
- if (performance.now() - last_update >= 1000 / TICK_RATE) {
- last_update = performance.now();
+ delta = performance.now() - lastUpdate;
+ if (performance.now() - lastUpdate >= 1000 / TICK_RATE) {
+ lastUpdate = performance.now();
update(delta / 1000);
}
@@ -385,34 +463,56 @@ function draw() {
ctx.fillStyle = "#f0f0f0";
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();
drawProps();
- // DEBUG: draw last known authoritative state
- if (Object.values(predictions).length > 10) {
- const server_state = Object.values(predictions)[0];
- ctx.fillStyle = "#208020";
- ctx.beginPath();
- ctx.rect(server_state.player.x - Player.SIZE / 2,
- server_state.player.y - Player.SIZE / 2,
- Player.SIZE, Player.SIZE);
- ctx.stroke();
+ var debug = {
+ "ping": serverPing + "ms",
+ "fake ping": + fakePing.get() + "ms",
+ "ticks behind": ticks - serverTick,
+ "reconciliations": reconciliations,
+ }
+ if (showAuthorityPositions.get()) {
+ debug["authority distance"] = authorityDistance;
}
- 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.font = "16px monospace";
ctx.textAlign = "left";
ctx.textBaseline = "bottom";
- var debug_lines = debug.split('\n');
- var debug_y = WORLD_SIZE - 8 - (debug_lines.length - 1) * 16;
- for (var i = 0; i < debug_lines.length; i++) {
- ctx.fillText(debug_lines[i], 8, debug_y + 16 * i);
- }
+ var debug_y = WORLD_SIZE - 8 - (Object.keys(debug).length - 1) * 16;
+ Object.keys(debug).forEach((key, i) => {
+ ctx.fillText(key + ": " + debug[key], 8, debug_y + 16 * i);
+ });
frames++;
requestAnimationFrame(draw);
@@ -422,7 +522,7 @@ function drawPlayers() {
Object.keys(players).forEach((id, index) => {
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_y = player.draw_y + 0.1 * (player.y - player.draw_y);
} else {
@@ -459,7 +559,7 @@ function drawProps() {
Object.keys(props).forEach(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_y = prop.draw_y + 0.1 * (prop.y - prop.draw_y);
} else {
diff --git a/common/player.js b/common/player.js
index d624fb0..83a89bd 100644
--- a/common/player.js
+++ b/common/player.js
@@ -1,5 +1,7 @@
+import { WORLD_SIZE } from "./world.js";
+
export default class Player {
- static SPEED = 400.0;
+ static SPEED = 200.0;
static SIZE = 50.0;
constructor(name, x, y, colour) {
@@ -16,5 +18,8 @@ export default class Player {
update(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;
+
+ this.x = Math.min(Math.max(this.x, 0.0), WORLD_SIZE);
+ this.y = Math.min(Math.max(this.y, 0.0), WORLD_SIZE);
}
}
diff --git a/server/ws.js b/server/ws.js
index 5439792..f9691e4 100644
--- a/server/ws.js
+++ b/server/ws.js
@@ -77,6 +77,8 @@ export function init(http_server) {
name: client.player.name,
x: client.player.x,
y: client.player.y,
+ xv: client.player.xv,
+ yv: client.player.yv,
col: client.player.colour,
};
});
@@ -168,17 +170,18 @@ export function init(http_server) {
function update() {
var delta = (performance.now() - last_update) / 1000;
+ last_update = performance.now();
// update players
var frame_players = {};
clients.forEach(client => {
if (!client.player) return;
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] = {
x: client.player.x,
y: client.player.y,
+ xv: client.player.xv,
+ yv: client.player.yv,
};
});
@@ -211,7 +214,6 @@ function update() {
}));
})
- last_update = performance.now();
ticks++;
setTimeout(update, 1000 / TICK_RATE);
}