P5js is used for rendering.
It's a bit long, so if anything's unclear, just ask me.
This is the best result I've achieved, but the response is terrible and hasn't eliminated the jerking (it just became smoother). It's exactly this stroking and this line; I just don't see any other way to go besides interpolation.
localPlayer.renderPos.lerp(p5.Vector.lerp(localPlayer.lastPos, localPlayer.pos, alpha), 0.1);
The problem can be clearly seen by replacing this formula translate(localPlayer.renderPos); with this translate(localPlayer.pos);
As a result of what I'm trying to achieve, you can click the "Stop Server" button, after which the "server" will stop sending updates and pure client-side physics will begin.
Controls: W/S - UP/DOWN
const physicsTickRate = 1 / 60; // Physics step duration (seconds)
let physicsAccumulator = 0; // Accumulator for sub-frame physics timing
let localPlayer; // Local player data object
let lastInputId = 0; // Incremental ID for tracking inputs
let pendingInputs = []; // Queue of inputs not yet acknowledged by server
const inputTickRate = 1 / 60; // Input polling frequency (seconds)
function getInput() { // Capture user controls
let x = 0; // Horizontal axis
let y = 0; // Vertical axis
if (keyIsDown(87)) y -= 1; // Move up if W is pressed
if (keyIsDown(83)) y += 1; // Move down if S is pressed
return {id: lastInputId++, x, y}; // Return input object with unique ID
}
function physicsTick(player, input) { // Calculate one physics step
const playerInput = input || player.input; // Use provided input or current player state
if (player.pos.y <= 100) { // Check if player is above "ground" level
player.pos.x += player.speed * playerInput.x * physicsTickRate; // Update X position
player.pos.y += player.speed * playerInput.y * physicsTickRate; // Update Y position
} else { // Handle collision with ground
player.pos.y = 100; // Snap to ground level
if (player.vel.y > 0) player.vel.y = 0; // Stop downward velocity
}
}
function mockServer(ping, updateTickRate, physicsTickRate, onUpdateTick) { // Server emulation
let isRunning = false; // Server execution state
let physicsTimeout; // Reference for physics loop timer
let updateTimeout; // Reference for broadcast loop timer
const speed = 300; // Constant movement speed
const createInitialRemotePlayer = () => ({ // Factory for clean state
id: 1, // Player identifier
pos: {x: 0, y: 0}, // Starting position
vel: {x: 0, y: 0}, // Starting velocity
speed, // Apply speed constant
input: {id: 0, x: 0, y: 0}, // Initial empty input
});
let remotePlayer = createInitialRemotePlayer(); // Initialize server-side player
return { // Public server API
async start() { // Begin server loops
if(isRunning) return; // Prevent duplicate execution
isRunning = true; // Set active flag
const runPhysics = async () => { // Server physics loop
if(!isRunning) return; // Exit if server stopped
physicsTick(remotePlayer); // Process movement
physicsTimeout = setTimeout(runPhysics, physicsTickRate * 1000); // Schedule next tick
};
const runUpdate = async () => { // Data broadcast loop
if(!isRunning) return; // Exit if server stopped
onUpdateTick(remotePlayer); // Send state to client
updateTimeout = setTimeout(runUpdate, updateTickRate * 1000 + ping); // Schedule next update
};
runPhysics(); // Trigger physics loop
runUpdate(); // Trigger update loop
},
stop(){ // Terminate server
isRunning = false; // Reset active flag
clearTimeout(physicsTimeout); // Kill physics timer
clearTimeout(updateTimeout); // Kill update timer
remotePlayer = createInitialRemotePlayer(); // Deep reset of player state
},
restart() { // Reset server state
this.stop(); // Clean up current instance
this.start(); // Re-initialize
},
sendInput: input => remotePlayer.input = input, // Receive input from client
};
}
function onUpdateTick(remotePlayer) { // Client-side handling of server data
if(!localPlayer) { // Initialize local player if missing
localPlayer = { // Build object with p5.Vector methods
id: remotePlayer.id, // Copy ID
pos: createVector(remotePlayer.pos.x, remotePlayer.pos.y), // Current position vector
lastPos: createVector(remotePlayer.pos.x, remotePlayer.pos.y), // Previous position vector
renderPos: createVector(remotePlayer.pos.x, remotePlayer.pos.y), // Visual position vector
vel: createVector(remotePlayer.vel.x, remotePlayer.vel.y), // Velocity vector
speed: remotePlayer.speed, // Movement speed
input: remotePlayer.input, // Last server-processed input
};
} else { // Perform Client-side Reconciliation
localPlayer.speed = remotePlayer.speed; // Sync speed
localPlayer.vel.set(remotePlayer.vel.x, remotePlayer.vel.y); // Sync velocity
localPlayer.pos.set(remotePlayer.pos.x, remotePlayer.pos.y); // Snap to server position
pendingInputs = pendingInputs.filter( // Remove inputs
input => input.id > remotePlayer.input.id // already processed by server
);
for (const input of pendingInputs) { // Re-apply unacknowledged inputs
physicsTick(localPlayer, input); // Predict current position
}
}
}
const server = mockServer(300, 1 / 60, physicsTickRate, onUpdateTick); // Instantiate server
function setup() { // P5.js initialization
frameRate(240); // High FPS for smooth rendering
createCanvas(windowWidth, windowHeight); // Fullscreen canvas
const buttons = [ // UI button array
createButton('Start Server'), // Start btn
createButton('Restart Server'), // Restart btn
createButton('Stop Server') // Stop btn
];
for(const [i, btn] of buttons.entries()) { // Style and position buttons
btn.style('width', '100px'); // Button width
btn.style('height', '50px'); // Button height
btn.position(width - 125, 75 * (i + 1)); // Stack buttons in corner
}
buttons[0].mousePressed(() => server.start()); // Bind start action
buttons[1].mousePressed(server.restart.bind(server)); // Bind restart with context
buttons[2].mousePressed(() => server.stop()); // Bind stop action
server.start(); // Auto-start on load
setInterval(() => { // Input sampling loop (16.6ms)
if (localPlayer) { // Verify player exists
localPlayer.input = getInput(); // Read keys
server.sendInput(localPlayer.input); // Send to server
pendingInputs.push(localPlayer.input); // Queue for reconciliation
}
}, inputTickRate * 1000); // Milliseconds interval
}
function draw() { // P5.js render loop
background(220); // Clear screen with gray color
if (!localPlayer) return; // Wait for player initialization
physicsAccumulator += deltaTime / 1000; // Add frame time to accumulator
while (physicsAccumulator >= physicsTickRate) { // Catch up on physics steps
localPlayer.lastPos = localPlayer.pos.copy(); // Store state for interpolation
physicsTick(localPlayer); // Advance local prediction
physicsAccumulator -= physicsTickRate; // Spend accumulated time
}
const alpha = physicsAccumulator / physicsTickRate; // Interpolation factor (0 to 1)
// Smooth render position by lerping between physics states
localPlayer.renderPos.lerp(p5.Vector.lerp(localPlayer.lastPos, localPlayer.pos, alpha), 0.1);
translate(width / 2, height / 2); // Center camera on screen
line(-width / 2, 100, width / 2, 100); // Draw floor line
push(); // Isolate coordinate transformations
translate(localPlayer.renderPos); // Move to player's visual position
rectMode(CENTER); // Draw rectangle from its center
line(-width / 2, 0, width / 2, 0); // Draw local horizontal axis
rect(0, 0, 100, 50); // Draw player body
pop(); // Restore coordinate state
}
window.setup = setup; // Global export for p5.js
window.draw = draw; // Global export for p5.js
window.onresize = () => resizeCanvas(windowWidth, windowHeight); // Responsive canvas resizing
body, html {
padding: 0;
margin: 0;
}
<script src="https://cdnjs.cloudflare.com/ajax/libs/p5.js/1.11.0/p5.min.js"></script>
Another try, this time we smooth out the difference between positions (correction):
const physicsTickRate = 1 / 60;
let physicsAccumulator = 0;
let localPlayer;
let lastInputId = 0;
let pendingInputs = [];
const inputTickRate = 1 / 60;
function getInput() {
let x = 0;
let y = 0;
if (keyIsDown(87)) y -= 1; // W
if (keyIsDown(83)) y += 1; // S
return {id: lastInputId++, x, y};
}
function physicsTick(player, input) {
const playerInput = input || player.input;
if (player.pos.y <= 100) {
player.pos.x += player.speed * playerInput.x * physicsTickRate;
player.pos.y += player.speed * playerInput.y * physicsTickRate;
} else {
player.pos.y = 100;
if (player.vel.y > 0) player.vel.y = 0;
}
}
function mockServer(ping, updateTickRate, physicsTickRate, onUpdateTick) {
let isRunning = false;
let physicsTimeout;
let updateTimeout;
const speed = 700;
const createInitialRemotePlayer = () => ({
id: 1,
pos: {x: 0, y: 0},
vel: {x: 0, y: 0},
speed,
input: {id: 0, x: 0, y: 0},
});
let remotePlayer = createInitialRemotePlayer();
return {
async start() {
if(isRunning) return;
isRunning = true;
const runPhysics = async () => {
if(!isRunning) return;
physicsTick(remotePlayer);
physicsTimeout = setTimeout(runPhysics, physicsTickRate * 1000);
};
const runUpdate = async () => {
if(!isRunning) return;
onUpdateTick(remotePlayer);
updateTimeout = setTimeout(runUpdate, updateTickRate * 1000 + ping);
};
runPhysics();
runUpdate();
},
stop(){
isRunning = false;
clearTimeout(physicsTimeout);
clearTimeout(updateTimeout);
remotePlayer = createInitialRemotePlayer();
},
restart() {
this.stop();
this.start();
},
sendInput: input => remotePlayer.input = input,
};
}
function onUpdateTick(remotePlayer) {
if(!localPlayer) {
localPlayer = {
id: remotePlayer.id,
pos: createVector(remotePlayer.pos.x, remotePlayer.pos.y),
lastPos: createVector(remotePlayer.pos.x, remotePlayer.pos.y),
renderPos: createVector(remotePlayer.pos.x, remotePlayer.pos.y),
offset: createVector(0, 0),
vel: createVector(remotePlayer.vel.x, remotePlayer.vel.y),
speed: remotePlayer.speed,
input: remotePlayer.input,
};
} else {
localPlayer.lastPos = localPlayer.pos.copy();
localPlayer.speed = remotePlayer.speed;
localPlayer.vel.set(remotePlayer.vel.x, remotePlayer.vel.y);
localPlayer.pos.set(remotePlayer.pos.x, remotePlayer.pos.y);
pendingInputs = pendingInputs.filter(
input => input.id > remotePlayer.input.id
);
for (const input of pendingInputs) {
physicsTick(localPlayer, input);
}
localPlayer.offset.add(p5.Vector.sub(localPlayer.lastPos, localPlayer.pos));
}
}
const server = mockServer(300, 1 / 60, physicsTickRate, onUpdateTick);
function setup() {
frameRate(144);
createCanvas(windowWidth, windowHeight);
const buttons = [
createButton('Start Server'),
createButton('Restart Server'),
createButton('Stop Server')
];
for(const [i, btn] of buttons.entries()) {
btn.style('width', '100px');
btn.style('height', '50px');
btn.position(width - 125, 75 * (i + 1));
}
buttons[0].mousePressed(server.start);
buttons[1].mousePressed(server.restart.bind(server));
buttons[2].mousePressed(server.stop);
server.start();
setInterval(() => {
if (localPlayer) {
localPlayer.input = getInput();
server.sendInput(localPlayer.input);
pendingInputs.push(localPlayer.input);
}
}, inputTickRate);
}
function draw() {
background(220);
if (!localPlayer) return;
physicsAccumulator += deltaTime / 1000;
while (physicsAccumulator >= physicsTickRate) {
localPlayer.lastPos = localPlayer.pos.copy();
physicsTick(localPlayer);
physicsAccumulator -= physicsTickRate;
}
const alpha = physicsAccumulator / physicsTickRate;
localPlayer.renderPos.lerp(p5.Vector.add(localPlayer.pos, localPlayer.offset), alpha);
localPlayer.offset.mult(pow(0.001, deltaTime/1000));
translate(width / 2, height / 2);
line(-width / 2, 100, width / 2, 100);
push();
translate(localPlayer.renderPos);
rectMode(CENTER);
line(-width / 2, 0, width / 2, 0);
rect(0, 0, 100, 50);
pop();
}
window.setup = setup;
window.draw = draw;
window.onresize = () => resizeCanvas(windowWidth, windowHeight);
body, html {
padding: 0;
margin: 0;
}
<script src="https://cdnjs.cloudflare.com/ajax/libs/p5.js/1.11.0/p5.min.js"></script>