| title | View Smoothing |
|---|---|
| description | Learn to create smooth animations by interpolating between model updates for better user experience |
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.
<iframe src="https://codepen.io/multisynq/embed/gbbmdqR?height=512&theme-id=37190&default-tab=result&editable=true" style={{ width: '100%', height: '512px', border: '2px solid #ccc', borderRadius: '8px', marginBottom: '24px' }} title="View Smoothing" allowFullScreen ></iframe>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.
Define constants that contribute to session synchronization Share utility functions safely between model and view Use `"oncePerFrame"` to limit view updates efficiently Handle infrequent model updates with smooth animationsConstants 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)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 };
}The root classes handle spawning and managing Actor-Pawn pairs:
- User joins:
RootModelspawns anActor, which tellsRootViewto spawn aPawn - User exits:
RootModelremoves theActor, which tellsRootViewto remove thePawn
// In RootView constructor
model.actors.forEach(actor => this.addPawn(actor));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:
- Check if already at destination (within
Q.CLOSEdistance) - If too close, pick a new random destination
- Otherwise, calculate velocity vector toward the goal
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).
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:
- Move forward by velocity × tick duration
- Check if arrived and pick new destination if needed
- Notify view that actor has moved
- Schedule next tick
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
actorMoved() {
this.lastMoved = viewTime;
}Simply timestamps when the actor last moved, enabling position extrapolation.
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:
- Extrapolate: Project actor's last known position forward using velocity
- Interpolate: Blend current pawn position with extrapolated position
- Render: Draw the smoothed position
The Q.SMOOTH value (0 < SMOOTH ≤ 1) controls interpolation behavior:
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
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.