Skip to content

See the others

The edge owns the arena. The client shows a window onto it, centered on you, and does two jobs that make a networked game feel good:

  • Prediction. The client simulates your own blob with the edge's exact rule, so it follows your cursor without waiting, and the camera follows it.
  • Interpolation. The client draws everyone else a fraction of a second in the past, smoothly between the snapshots the edge sends, so twenty updates a second look like continuous motion.

The centered blob from part one becomes your predicted self.

Step 0: The helpers prediction and smoothing need

Add these to the root ApplicationWindow. speedFor is a copy of the edge's speed rule, so your prediction moves exactly as the edge will. The snapshot store and interp do the entity interpolation: a short history per remote blob, and a lookup that returns where a blob was at a given moment in the recent past.

// Same rules as the edge, so prediction and drawing match the authority.
function speedFor(mass) { return 260 / Math.pow(mass, 0.22); }
function colorFor(id) {
    let h = 0;
    for (let i = 0; i < id.length; i++) h = (h * 31 + id.charCodeAt(i)) % 360;
    return Qt.hsla(h / 360, 0.6, 0.55, 1);
}

// Where my cursor is aiming, in world coordinates. Starts under my blob.
property real aimX: myX
property real aimY: myY

// Entity interpolation. A buffer of recent positions per remote blob, and a clock a
// little behind real time so there are always two samples to interpolate between.
property var snaps: ({})
property real renderNow: 0
function pushSnap(id, x, y) {
    let a = root.snaps[id]; if (!a) a = root.snaps[id] = [];
    a.push({ t: Date.now(), x: x, y: y });
    if (a.length > 16) a.shift();                     // keep about a second of history
}
function interp(id, t, fx, fy) {
    const a = root.snaps[id];
    if (!a || a.length < 2) return Qt.point(fx, fy);  // not enough history yet
    for (let i = a.length - 1; i > 0; i--) {
        if (a[i - 1].t <= t && t <= a[i].t) {        // the two samples bracketing t
            const s0 = a[i - 1], s1 = a[i];
            const u = (t - s0.t) / Math.max(1, s1.t - s0.t);
            return Qt.point(s0.x + (s1.x - s0.x) * u, s0.y + (s1.y - s0.y) * u);
        }
    }
    return t < a[0].t ? Qt.point(a[0].x, a[0].y) : Qt.point(fx, fy);
}

Step 1: Predict your own motion, and steer

A FrameAnimation runs every frame. It advances the render clock for interpolation, and moves your own blob toward your aim with the edge's speed rule. It moves myX and myY, which the camera is centered on, so your view follows you. Add inside the root ApplicationWindow:

FrameAnimation {
    running: Session.hasScope("player")
    onTriggered: {
        root.renderNow = Date.now() - 100;              // draw others 100 ms in the past
        const dt = Math.min(0.05, frameTime);           // seconds since last frame
        // Predict my own blob with the edge's exact rule, so it tracks my cursor now
        // instead of a round trip from now.
        const dx = root.aimX - root.myX, dy = root.aimY - root.myY;
        const dist = Math.hypot(dx, dy);
        if (dist > 0.5) {
            const step = Math.min(root.speedFor(root.myMass) * dt, dist);
            root.myX += dx / dist * step;
            root.myY += dy / dist * step;
        }
    }
}

// Reconcile the prediction with the edge's authoritative copy (Step 2 feeds this).
// Mass is purely the edge's, so adopt it. Snap on a big jump (you were eaten and
// respawned), and gently correct small drift so normal play never stutters.
function reconcile(ax, ay, amass) {
    root.myMass = amass;
    const err = Math.hypot(ax - root.myX, ay - root.myY);
    if (err > 250) { root.myX = ax; root.myY = ay; }
    else if (err > 1) { root.myX += (ax - root.myX) * 0.15;
                         root.myY += (ay - root.myY) * 0.15; }
}

Now turn the cursor into an aim point in the world and report it. The aim is your position plus the cursor's offset from the view center, scaled back to world units. Add inside the view Rectangle:

MouseArea {
    anchors.fill: parent
    hoverEnabled: true
    onPositionChanged: mouse => {
        root.aimX = root.myX + (mouse.x - view.width / 2) / view.zoom;
        root.aimY = root.myY + (mouse.y - view.height / 2) / view.zoom;
    }
}

// Report my aim to the edge a few times a second. The edge does the real moving.
Timer {
    interval: 66; repeat: true
    running: Session.hasScope("player")
    onTriggered: Server.steer(root.aimX, root.aimY)
}

You send a goal, never a position. Your prediction and the edge apply the same rule to the same goal, so they stay together. The edge is still the only authority, and reconcile removes any drift.

Step 2: Draw the other players, smoothly

One Repeater over the blobs model does two jobs. For every row it records snapshots for interpolation, and for your own row it also feeds reconcile. For other rows it draws a circle at the interpolated position, through the camera. Part one already draws your blob at the center, so these circles are for other players only. Add inside the view Rectangle:

Repeater {
    model: Server.blobs
    delegate: Item {
        id: blob

        // The whole row as one property. A delegate is an Item, and Item already declares
        // x and y as FINAL, so a `required property real x` for the role cannot work.
        required property var model

        readonly property bool mine: Session.identity
                                     && blob.model.id === Session.identity.sub
        // On any authoritative change, read the current role values (they are already
        // updated when the change fires). For the player's own row, reconcile the
        // prediction, and for others, append to the interpolation buffer.
        property real ax: blob.model.x
        property real ay: blob.model.y
        onAxChanged: blob.mine ? root.reconcile(blob.model.x, blob.model.y, blob.model.mass)
                               : root.pushSnap(blob.model.id, blob.model.x, blob.model.y)
        onAyChanged: blob.mine ? root.reconcile(blob.model.x, blob.model.y, blob.model.mass)
                               : root.pushSnap(blob.model.id, blob.model.x, blob.model.y)

        // The visible circle for OTHER players, at an interpolated, camera-mapped spot.
        Rectangle {
            readonly property real r: root.radiusFor(blob.model.mass) * view.zoom
            readonly property point ip: root.interp(blob.model.id, root.renderNow,
                                                    blob.model.x, blob.model.y)
            visible: blob.model.online && !blob.mine
            width: 2 * r; height: 2 * r; radius: r
            x: view.sx(ip.x) - r
            y: view.sy(ip.y) - r
            color: root.colorFor(blob.model.id)
            Text {
                anchors.centerIn: parent
                text: blob.model.name
                color: "white"; font.pixelSize: 12
                style: Text.Outline; styleColor: "black"
                visible: parent.r > 10          // hide the label on tiny blobs
            }
        }
    }
}

// The pellets, camera-mapped. They do not move, so they need no interpolation. They
// pop in as you approach and out as you leave (in the last part the edge only
// sends the nearby ones, which is the same effect for free).
Repeater {
    model: Server.pellets
    delegate: Rectangle {
        id: pellet

        required property var model

        width: 8 * view.zoom; height: 8 * view.zoom; radius: width / 2
        color: "#8899bb"
        x: view.sx(pellet.model.x) - width / 2
        y: view.sy(pellet.model.y) - height / 2
    }
}

Note

The client draws each remote blob at renderNow, a fixed 100 ms behind real time, between two snapshots it already has, instead of guessing ahead of the newest one. Snapshots arrive about every 50 ms, so there are always two to interpolate between, and motion stays smooth even when a packet is late. The buffer is keyed by blob id, so it survives the model reordering as sizes change. One technique is left out: replaying your unacknowledged inputs on top of each authoritative update, instead of easing the drift. See the further reading.

Step 3: The live scoreboard

The edge publishes a small board model with the biggest blobs by name and size. It is separate from blobs, so it stays global once the edge sends you only nearby players. Add it inside the root ApplicationWindow as an overlay:

// Live leaderboard, the biggest blobs on the map right now.
Column {
    anchors.top: parent.top
    anchors.right: parent.right
    anchors.margins: 12
    spacing: 2
    Text { text: "On the map"; color: "white"; font.bold: true; font.pixelSize: 14
           style: Text.Outline; styleColor: "black" }
    Repeater {
        model: Server.board
        delegate: Text {
            required property int index
            required property string name
            required property real mass
            text: (index + 1) + ". " + name + "  " + Math.round(mass)
            color: "white"; font.pixelSize: 13
            style: Text.Outline; styleColor: "black"
        }
    }
}

Step 4: Latency and the kill feed

ping returns a value, so it is an asynchronous request. Send the current time, wait for the reply, and the difference is the round trip. Show a banner on the eaten signal through the contract's attached handler, Edge.on<Signal>, with no Connections block (see handling a connect point's signals). Add inside the root ApplicationWindow:

property int latencyMs: -1

Timer {
    interval: 2000; repeat: true
    running: Session.hasScope("player")
    onTriggered: {
        const sent = Date.now();
        Server.ping().then(() => { root.latencyMs = Date.now() - sent; });
    }
}

// A banner for eat events. If it was you, say so. Otherwise it is a kill feed.
Text {
    id: banner
    anchors.bottom: parent.bottom
    anchors.horizontalCenter: parent.horizontalCenter
    anchors.margins: 20
    color: "white"; font.pixelSize: 18; opacity: 0
    style: Text.Outline; styleColor: "black"
    Behavior on opacity { NumberAnimation { duration: 400 } }
    function flash(msg) { text = msg; opacity = 1; hideTimer.restart(); }
    Timer { id: hideTimer; interval: 2500; onTriggered: banner.opacity = 0 }
}

Edge.onEaten: (prey, predator) => {
    const me = Session.identity ? Session.identity.login : null;
    if (prey === me) banner.flash("You were eaten by " + predator + "!");
    else if (predator === me) banner.flash("You ate " + prey);
    else banner.flash(predator + " ate " + prey);
}

When you are eaten, the edge respawns you, small, at a new spot. The next update jumps far enough that reconcile snaps your prediction there, and you reappear tiny and grow again. The respawn is all edge code; the client only reads the model the edge pushes.

Step 5: A tiny HUD and the guest list gate

Finally, show the connection state and latency, and put a gate in front of everything for anyone who is not a player. Add these two overlays inside the root ApplicationWindow:

// Status readout.
Column {
    anchors.top: parent.top
    anchors.left: parent.left
    anchors.margins: 12
    spacing: 2
    Text {
        color: "white"; style: Text.Outline; styleColor: "black"; font.pixelSize: 14
        text: Session.state === "connected" ? "online"
            : Session.state === "reconnecting" ? "reconnecting..."
            : Session.state === "connecting" ? "connecting..." : "offline"
    }
    Text {
        color: "white"; style: Text.Outline; styleColor: "black"; font.pixelSize: 14
        visible: root.latencyMs >= 0
        text: "ping " + root.latencyMs + " ms"
    }
}

// Sign in / guest list gate.
Rectangle {
    anchors.fill: parent
    visible: !Session.hasScope("player")
    color: "#c0000000"
    Column {
        anchors.centerIn: parent
        spacing: 16
        Text {
            anchors.horizontalCenter: parent.horizontalCenter
            color: "white"; font.pixelSize: 22; horizontalAlignment: Text.AlignHCenter
            text: !Session.identity
                  ? "Sign in with GitHub to enter the arena"
                  : "Sorry " + Session.identity.login + ", you are not on the guest list."
        }
        Button {
            anchors.horizontalCenter: parent.horizontalCenter
            visible: !Session.identity
            text: "Sign in with GitHub"
            onClicked: Session.login()
        }
        Button {
            anchors.horizontalCenter: parent.horizontalCenter
            visible: !!Session.identity
            text: "Sign out"
            onClicked: Session.logout()
        }
    }
}

Save. If you are on your own guest list, you can sign in and move around eating pellets, with your view following you and other players moving smoothly nearby. The game still runs forever and forgets who won.