Download Game! Currently 71 players and visitors. Last logged in:GlauriCordobaDefaultCorrel

Blitzer's Blog >> 72181

Back to blogs index
Posted: 07 Sep 2026 14:01 [ permalink ]
That QuakeWorld breakdown is spot on. John Carmacks realization that network
state and rendering state must be completely decoupled is the foundation of
all modern netcode.

For a distributed MUD, you are absolutely right: we don't need the extreme
complexity of server-side lag compensation (rewinding hitboxes). We only need
**Client-Side Prediction (CSP)** for the local player and **Entity
Interpolation** for everyone else.

Here is how we adapt the "Carmack Principles" to our `MeshCRDT` in a
lightweight, web-friendly way.

### 1. Decoupling Network from Render

Right now, if your UI reacts directly to the `MeshCRDT.watch()` event,
characters will snap instantly to their new coordinates. We need to insert a
middleware classan **Interpolator**that listens to the CRDT, buffers the data,
and feeds smooth coordinates to your graphics loop (like Canvas, WebGL, or DOM
updates).

### 2. The Entity Interpolator

Here is a lightweight implementation that buffers your out-of-order LWW
packets and smoothly calculates where a remote player should be drawn on the
screen based on a 100ms delay.

```javascript
class NetworkInterpolator {
    constructor(crdt, objectId, delayMs = 100) {
        this.crdt = crdt;
        this.objectId = objectId;
        this.delayMs = delayMs;
        
        // Timeline buffer: Array of { x, y, timestamp }
        this.snapshots = []; 

        // Listen to network jumps and add them to our timeline
        this.crdt.watch(objectId, (property, value) => {
            const latestNetworkState = this.crdt.state.get(objectId);
            if (!latestNetworkState) return;

            // Push a snapshot of the current CRDT absolute state
            this.snapshots.push({
                x: latestNetworkState.get('x')?.value || 0,
                y: latestNetworkState.get('y')?.value || 0,
                timestamp: latestNetworkState.get('x')?.timestamp ||
Date.now()
            });

            // Keep the buffer clean (discard data older than 1 second)
            const cutoff = Date.now() - 1000;
            this.snapshots = this.snapshots.filter(s => s.timestamp > cutoff);
            
            // Sort to handle out-of-order CRDT packets
            this.snapshots.sort((a, b) => a.timestamp - b.timestamp);
        });
    }

    /** 
     * Called every frame (e.g., requestAnimationFrame)
     * Returns the interpolated X/Y coordinates to draw.
     */
    getRenderCoordinates() {
        const renderTime = Date.now() - this.delayMs;

        // Edge case: Not enough data yet
        if (this.snapshots.length === 0) return { x: 0, y: 0 };
        if (this.snapshots.length === 1) return this.snapshots[0];

        // Find the snapshots just before and just after our renderTime
        let before = this.snapshots[0];
        let after = this.snapshots[this.snapshots.length - 1];

        for (let i = 0; i < this.snapshots.length - 1; i++) {
            if (this.snapshots[i].timestamp <= renderTime && this.snapshots[i+1
].timestamp >= renderTime) {
                before = this.snapshots[i];
                after = this.snapshots[i+1];
                break;
            }
        }

        // If we are extrapolating (lag spike), just return the latest known
position
        if (renderTime > after.timestamp) return after;

        // Calculate the interpolation factor (0.0 to 1.0)
        const timeDiff = after.timestamp - before.timestamp;
        const factor = timeDiff === 0 ? 0 : (renderTime - before.timestamp) /
timeDiff;

        // Linear Interpolation (Lerp)
        return {
            x: before.x + (after.x - before.x) * factor,
            y: before.y + (after.y - before.y) * factor
        };
    }
}

```

### 3. Local Prediction (Optimistic UI)

For the *local* player, you bypass the interpolator entirely. When the user
presses an arrow key, you immediately update the UI graphics and push the
`CRDT_MUTATION`.

* **Remote Players:** `getRenderCoordinates()`  Draws smoothly sliding
characters exactly 100ms in the past.
* **Local Player:** `onKeyPress()`  Instantly draws charater  Sends
`crdt.mutate()`.

Because you are using an absolute LWW architecture, you don't even need
complex Server Reconciliation. If the local player's network packet drops, the
next step they take will simply broadcast their true absolute coordinates, and
the rest of the mesh will immediately smooth them over via their
interpolators.