This tutorial demonstrates how to smooth the view so that objects move continually even if the model only updates intermittently. This technique is essential for creating smooth user experiences and handling connectivity issues gracefully.

Try it out!

Click or scan the QR code above to launch a new CodePen instance. You’ll see several moving colored dots - one for each device currently connected to the session. Some dots may even belong to other Multisynq developers reading this documentation!

Click or tap the screen to tell your dot where to go.

The unsmoothed position of your dot is shown in gray. Notice how it jumps forward every time the model performs an update. The view uses this information to calculate each dot’s smoothed position.

In this example, the model updates only twice per second, but the dots move smoothly at 60 frames per second because the view interpolates their position between model updates.

What You’ll Learn

Global Constants

Define constants that contribute to session synchronization

Pure Functions

Share utility functions safely between model and view

Frame Optimization

Use "oncePerFrame" to limit view updates efficiently

Animation Interpolation

Handle infrequent model updates with smooth animations

Global Constants

Constants used by the model should be included in the session hash to ensure synchronization. Changing these constants will create a new session, preventing desynchronization issues.

const Q = Multisynq.Constants;
Q.TICK_MS = 500;    // milliseconds per actor tick
Q.SPEED = 0.15;     // dot movement speed in pixels per millisecond
Q.CLOSE = 0.1;      // minimum distance in pixels to a new destination
Q.SMOOTH = 0.05;    // weighting between old and new positions (0 < SMOOTH <= 1)

Multisynq.Constants contributes to the hash used to generate a session ID. Use a short alias like Q to make your code more readable.

Pure Functions

You can safely share utility functions between model and view as long as they are purely functional:

function add(a, b) {
    return { x: (a.x + b.x), y: (a.y + b.y) };
}

function subtract(a, b) {
    return { x: (a.x - b.x), y: (a.y - b.y) };
}

function magnitude(vector) {
    return Math.sqrt(vector.x * vector.x + vector.y * vector.y);
}

function normalize(vector) {
    const mag = magnitude(vector);
    return { x: vector.x / mag, y: vector.y / mag };
}

function scale(vector, factor) {
    return { x: vector.x * factor, y: vector.y * factor };
}

function dotProduct(a, b) {
    return a.x * b.x + a.y * b.y;
}

function lerp(a, b, t) {
    return { x: a.x + (b.x - a.x) * t, y: a.y + (b.y - a.y) * t };
}

Pure Function Requirements

Pure function code isn’t included in the session ID hash. If you change these functions frequently, ensure all users have the same version to maintain synchronization.

Actor-Pawn Architecture

RootModel & RootView

The root classes handle spawning and managing Actor-Pawn pairs:

  • User joins: RootModel spawns an Actor, which tells RootView to spawn a Pawn
  • User exits: RootModel removes the Actor, which tells RootView to remove the Pawn

View Initialization Pattern

// In RootView constructor
model.actors.forEach(actor => this.addPawn(actor));

During initialization, the view should never assume the model’s current state. Always read the model state and build accordingly, as the view might be joining a session in progress or restoring from a snapshot.

Actor Implementation

Movement Planning

goto(goal) {
    this.goal = goal;
    const delta = subtract(goal, this.position);
    if (magnitude(delta) < Q.CLOSE) {
        this.goto(randomPosition());
    } else {
        const unit = normalize(delta);
        this.velocity = scale(unit, Q.SPEED);
    }
}

The goto method calculates movement vectors:

  1. Check if already at destination (within Q.CLOSE distance)
  2. If too close, pick a new random destination
  3. Otherwise, calculate velocity vector toward the goal

Arrival Detection

arrived() {
    const delta = subtract(this.goal, this.position);
    return (dotProduct(this.velocity, delta) <= 0);
}

Since actors step forward fixed distances and usually overshoot goals, arrival is detected by checking if the direction to the goal has reversed (negative dot product).

Animation Loop

tick() {
    this.position = add(this.position, scale(this.velocity, Q.TICK_MS));
    if (this.arrived()) this.goto(this.randomPosition());
    this.publish(this.id, "moved", this.now());
    this.future(Q.TICK_MS).tick();
}

Each tick:

  1. Move forward by velocity × tick duration
  2. Check if arrived and pick new destination if needed
  3. Notify view that actor has moved
  4. Schedule next tick

Pawn Implementation

Constructor with Frame Limiting

constructor(actor) {
    super(actor);
    this.actor = actor;
    this.position = {...actor.position};
    this.actorMoved();
    this.subscribe(actor.id, {event: "moved", handling: "oncePerFrame"}, this.actorMoved);
}

Key features:

  • Copy initial position from actor
  • Subscribe to actor’s movement events
  • Use "oncePerFrame" to optimize event handling

"oncePerFrame" discards all but the last event of this type during each frame. This is crucial for high-frequency updates where only the latest position matters.

Event Handling

actorMoved() {
    this.lastMoved = viewTime;
}

Simply timestamps when the actor last moved, enabling position extrapolation.

Smooth Animation Update

update() {
    // Special case for own pawn - show debug info
    if (this.actor.viewId === this.viewId) {
        this.draw(this.actor.goal, null, this.actor.color);
        this.draw(this.actor.position, "lightgrey");
    }

    // Calculate extrapolated position
    const delta = scale(this.actor.velocity, viewTime - this.lastMoved);
    const extrapolation = add(this.actor.position, delta);
    
    // Interpolate between current and extrapolated position
    this.position = lerp(this.position, extrapolation, Q.SMOOTH);
    this.draw(this.position, this.actor.color);
}

The smoothing algorithm:

  1. Extrapolate: Project actor’s last known position forward using velocity
  2. Interpolate: Blend current pawn position with extrapolated position
  3. Render: Draw the smoothed position

Understanding the Smoothing Parameter

The Q.SMOOTH value (0 < SMOOTH ≤ 1) controls interpolation behavior:

SMOOTH = 1.0

No interpolation - instant position updates (jerky)

SMOOTH = 0.5

Balanced smoothing - good responsiveness

SMOOTH = 0.1

Heavy smoothing - very smooth but less responsive

Rule of thumb: Tune Q.SMOOTH so the pawn spends about half its time behind the actor’s position and half ahead. This provides optimal balance between smoothness and responsiveness.

Reflector Heartbeat Configuration

Setting Tick Rate

Multisynq.Session.join({
  apiKey: "your_api_key",
  appId: "io.codepen.multisynq.smooth",
  name: "public",
  password: "none",
  model: RootModel,
  view: RootView,
  tps: 1000/Q.TICK_MS,  // or simply: tps: 2
});

The tps (ticks per second) option controls reflector heartbeat frequency:

  • Purpose: Keeps model running when no user input is received
  • Default: 20 ticks per second
  • Range: 1-60 ticks per second
  • Best Practice: Match your model’s internal tick rate

In this tutorial, Q.TICK_MS = 500 means the reflector sends heartbeat ticks twice per second maximum. Set heartbeat rate to match your model’s update frequency.

Heartbeat vs Responsiveness

Increasing heartbeat tick rate will NOT make your app more responsive. User input events are sent immediately and processed as soon as received. Heartbeat ticks only affect model updates when no other events are received.

Performance Optimization Techniques

Frame-Rate Optimization

oncePerFrame

Discards redundant events within single frame

Selective Updates

Only update pawns that have actually moved

Extrapolation

Predict position between model updates

Interpolation

Smooth transitions prevent visual “popping”

Tuning Guidelines

Advanced Concepts

Actor-Pawn Pattern Benefits

Separation of Concerns

Models handle logic, views handle presentation

Performance

Smooth 60fps animations from low-frequency model updates

Resilience

Gracefully handles network hiccups and connectivity issues

Scalability

Efficient event handling with frame-rate limiting

Common Pitfalls

Avoid These Mistakes:

  • Setting Q.SMOOTH to 0 (causes no movement)
  • Making functions impure (breaks synchronization)
  • Ignoring existing model state during view initialization
  • Using wall-clock time instead of simulation time

Next Steps

This tutorial demonstrates essential techniques for creating smooth, responsive user experiences in Multisynq applications. The Actor-Pawn pattern with interpolation is fundamental for professional-quality real-time applications.