When every phone receives every animation frame, network traffic starts driving the experience. We wanted the network to agree on what happened, while each device handled drawing locally.

A bottle spin looks simple: apply force, watch it rotate, wait for it to stop. Put that spin on fifteen phones at once, though, and it becomes a distributed-systems problem disguised as a party game.

Our first instinct was to synchronize the visible motion. If the bottle’s angle changes every frame, why not send the new angle over a WebSocket 60 times per second?

Because “send every frame” makes the network part of the animation loop.

The Cost of Synchronizing Pixels

At 60 frames per second, one spinning bottle can produce 60 state updates each second. Each update needs to be encoded, sent, received, decoded, and applied. A server that fans the updates out to fourteen other phones has to deliver those updates repeatedly, too.

The raw number of bytes is only part of the cost. Every message also competes with network jitter, mobile radio power saving, background scheduling, and other room events. If several updates arrive late or in a burst, the receiving phone has to decide whether to render stale positions, skip ahead, or wait.

None of those choices is ideal:

  • Rendering every received coordinate can make the bottle trail behind the host.
  • Dropping old coordinates creates visible, jarring jumps.
  • Waiting for updates makes the animation feel uneven and choppy.

The deeper issue is that the network is carrying a description of every frame, when the game really needs agreement on the spin event itself.

Synchronize the Event; Draw the Motion Locally

A spin can be represented by a small set of inputs: the initial angle, the angular impulse, and a shared start time. The host publishes that event once over the WebSocket connection. Each client then runs the same motion model locally.

{ "type": "spin_started", "roundId": "round-42", "spinId": "spin-08", "initialAngle": 1.2, "impulse": { "angularVelocity": 18.7 }, "startAt": 1730000000000, "simulationVersion": 1 } 

This works reliably when every client interprets those inputs consistently:

  1. The starting angle is shared.
  2. The spin begins at a common room time reference.
  3. All clients use the same motion rules and fixed simulation step.

What angularVelocity *= 0.985 Does

The damping rule is compact:

angularVelocity *= 0.985; 

At each simulation step, angular velocity shrinks to 98.5% of its previous value. The bottle keeps rotating while gradually losing speed.

If 0.985 is applied once per fixed 60 Hz simulation tick, the velocity after $n$ ticks is:
$$\omega_n = \omega_0 \times 0.985^n$$

Given the same initial angle, initial angular velocity, tick count, and update order, every client following the same recurrence computes the exact same sequence of angles.

To avoid frame rate divergence (e.g. 120 Hz ProMotion vs 60 Hz standard screens), we decouple simulation time from rendering time:

const fixedStep = 1 / 60; const dampingPerStep = 0.985; void simulateSpin() { angularVelocity *= dampingPerStep; angle += angularVelocity * fixedStep; } 

The application renders with Flutter’s CustomPainter at the display’s available rate while advancing the spin in fixed simulation steps.

Reconnecting with a Snapshot

A 6-digit PIN makes it easy to enter or return to a room. When a phone drops Wi-Fi for 5 seconds and reconnects, replaying every historical event since disconnection is wasteful and slow.

Instead, the client requests a compact current-state snapshot:

  • The room and current round identifiers.
  • The current phase (waiting, spinning, showing a result).
  • The participant list and current turn.
  • The resolved angle or selected player.
  • The current prompt card state.

The player returns to the current moment immediately, without watching historical animations fast-forward.

15 Screens, One Shared Result

Local animation changes what the network has to guarantee. It no longer has to deliver sixty positions per second to every phone. It has to deliver the spin event reliably, provide a shared timing reference, and resolve any meaningful disagreement.

In a party room with up to 15 phones, that keeps the experience responsive and silky-smooth without making every animation frame wait for the network.