How to implement smooth synchronization of the predicted position with the authoritative one while maintaining instant response?

21 hours ago 1
ARTICLE AD BOX

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>
Read Entire Article