Tick Loop

Fixed-timestep server updates for deterministic, cheat-proof multiplayer games.

Why tick loops?

Problem: If the server reacts to every input immediately, game speed depends on player hardware and network latency. Fast computers and low-ping players get more updates per second.

Solution: Server processes inputs at fixed intervals (ticks). Common tick rates:

  • 20 ticks/sec (50ms) — Strategy games, turn-based, lobby games
  • 30 ticks/sec (33ms) — Slower action games, mobile games
  • 60 ticks/sec (16.67ms) — Fast action games (FPS, racing)

Every player’s game simulation advances at the same rate, regardless of hardware.


The pattern

  1. Server: Schedule :tick messages at fixed intervals using Process.send_after/3
  2. Server: Each tick, drain the input queue, update state, broadcast
  3. Client: Buffer received states, interpolate between them at 60+ fps for smooth rendering
  4. Client: Use Threlte’s useTask to interpolate positions every frame

Server: Add tick loop to GameServer

Update server/lib/tostada/game/game_server.ex:

defmodule Tostada.Game.GameServer do
  use GenServer
  require Logger

  alias Phoenix.PubSub

  @table_name :game_rooms
  @pubsub Tostada.PubSub
  @tick_rate 50  # 20 ticks per second (50ms)

  ## Client API

  def start_link(opts) do
    GenServer.start_link(__MODULE__, opts, name: __MODULE__)
  end

  def get_room_state(room_id) do
    case :ets.lookup(@table_name, room_id) do
      [{^room_id, state}] -> {:ok, state}
      [] -> {:error, :not_found}
    end
  end

  @doc """
  Queue a player input. It will be processed on the next tick.
  """
  def queue_input(room_id, user_id, input) do
    GenServer.cast(__MODULE__, {:queue_input, room_id, user_id, input})
  end

  def create_room(room_id) do
    GenServer.call(__MODULE__, {:create_room, room_id})
  end

  ## Server Callbacks

  @impl true
  def init(_opts) do
    :ets.new(@table_name, [:set, :public, :named_table, read_concurrency: true])

    # Start the tick loop
    schedule_tick()

    Logger.info("GameServer started with #{@tick_rate}ms tick rate")
    {:ok, %{input_queue: %{}}}
  end

  @impl true
  def handle_call({:create_room, room_id}, _from, state) do
    initial_state = %{
      room_id: room_id,
      players: %{},
      tick_number: 0,
      started_at: System.system_time(:second)
    }

    :ets.insert(@table_name, {room_id, initial_state})

    # Initialize input queue for this room
    input_queue = Map.put(state.input_queue, room_id, [])

    {:reply, {:ok, initial_state}, %{state | input_queue: input_queue}}
  end

  @impl true
  def handle_cast({:queue_input, room_id, user_id, input}, state) do
    # Add input to queue with timestamp
    queued_input = %{
      user_id: user_id,
      input: input,
      queued_at: System.monotonic_time(:millisecond)
    }

    room_queue = Map.get(state.input_queue, room_id, [])
    updated_queue = [queued_input | room_queue]

    new_input_queue = Map.put(state.input_queue, room_id, updated_queue)
    {:noreply, %{state | input_queue: new_input_queue}}
  end

  @impl true
  def handle_info(:tick, state) do
    # Process all rooms
    new_input_queue =
      Enum.reduce(state.input_queue, %{}, fn {room_id, inputs}, acc ->
        process_room_tick(room_id, inputs)
        Map.put(acc, room_id, [])  # Clear queue after processing
      end)

    # Schedule next tick
    schedule_tick()

    {:noreply, %{state | input_queue: new_input_queue}}
  end

  ## Helpers

  defp schedule_tick do
    Process.send_after(self(), :tick, @tick_rate)
  end

  defp process_room_tick(room_id, inputs) do
    case :ets.lookup(@table_name, room_id) do
      [] ->
        :ok

      [{^room_id, room_state}] ->
        # Sort inputs by queue time (oldest first) for determinism
        sorted_inputs = Enum.sort_by(inputs, & &1.queued_at)

        # Process each input in order
        updated_players =
          Enum.reduce(sorted_inputs, room_state.players, fn queued, players ->
            process_input(players, queued.user_id, queued.input)
          end)

        # Increment tick number
        new_tick = room_state.tick_number + 1

        # Update room state
        updated_room = %{room_state | players: updated_players, tick_number: new_tick}
        :ets.insert(@table_name, {room_id, updated_room})

        # Broadcast full state to all clients
        PubSub.broadcast(@pubsub, "game:#{room_id}", {:game_tick, updated_room})
    end
  end

  defp process_input(players, user_id, input) do
    player = Map.get(players, user_id, %{x: 0, y: 0, z: 0, velocity: %{x: 0, z: 0}})

    case input do
      %{"action" => "move", "direction" => direction} ->
        new_velocity = compute_velocity(direction)
        new_position = apply_velocity(player, new_velocity)

        if valid_position?(new_position) do
          updated_player = %{player | x: new_position.x, z: new_position.z, velocity: new_velocity}
          Map.put(players, user_id, updated_player)
        else
          players
        end

      _ ->
        players
    end
  end

  defp compute_velocity(direction) do
    speed = 0.2  # units per tick
    case direction do
      "north" -> %{x: 0, z: -speed}
      "south" -> %{x: 0, z: speed}
      "east" -> %{x: speed, z: 0}
      "west" -> %{x: -speed, z: 0}
      _ -> %{x: 0, z: 0}
    end
  end

  defp apply_velocity(%{x: x, z: z}, %{x: vx, z: vz}) do
    %{x: x + vx, z: z + vz}
  end

  defp valid_position?(%{x: x, z: z}) do
    x >= -10 and x <= 10 and z >= -10 and z <= 10
  end
end

Key points:

  • schedule_tick/0 — Uses Process.send_after/3 to send :tick message to self
  • handle_info(:tick, state) — Processes all queued inputs, updates all rooms, broadcasts, schedules next tick
  • Inputs are queued via GenServer.cast (non-blocking), processed in batch on tick
  • Inputs sorted by queued_at timestamp for determinism
  • tick_number increments every tick — clients use this for synchronization

Server: Update GameChannel to queue inputs

Update server/lib/tostada_web/channels/game_channel.ex:

@impl true
def handle_in("move", %{"direction" => direction}, socket) do
  user_id = socket.assigns.user_id
  room_id = socket.assigns.room_id

  input = %{"action" => "move", "direction" => direction}
  GameServer.queue_input(room_id, user_id, input)

  # Reply immediately (input queued, will be processed on next tick)
  {:reply, :ok, socket}
end

@impl true
def handle_info({:game_tick, room_state}, socket) do
  # Received from PubSub broadcast
  push(socket, "game_tick", room_state)
  {:noreply, socket}
end

Now inputs are queued, not processed immediately. The client receives state updates at the tick rate.


Client: Interpolation with useTask

Update client/src/lib/game-store.ts:

import { writable, derived } from 'svelte/store';

export interface GameState {
  roomId: string | null;
  players: Map<string, Player>;
  tickNumber: number;
}

export interface Player {
  userId: string;
  x: number;
  y: number;
  z: number;
  velocity?: { x: number; z: number };
}

export const gameState = writable<GameState>({
  roomId: null,
  players: new Map(),
  tickNumber: 0
});

// Store for interpolation targets
export const interpolationState = writable<{
  previous: GameState | null;
  current: GameState | null;
  receivedAt: number;
}>({
  previous: null,
  current: null,
  receivedAt: 0
});

let gameChannel: any = null;

export function joinGameRoom(socket: any, roomId: string): Promise<void> {
  return new Promise((resolve, reject) => {
    gameChannel = socket.channel(`game:${roomId}`, {});

    // Listen for tick updates
    gameChannel.on('game_tick', (state: any) => {
      const now = performance.now();

      interpolationState.update((s) => ({
        previous: s.current,
        current: {
          roomId: state.room_id,
          players: new Map(Object.entries(state.players)),
          tickNumber: state.tick_number
        },
        receivedAt: now
      }));
    });

    gameChannel
      .join()
      .receive('ok', () => resolve())
      .receive('error', (err: any) => reject(err));
  });
}

export function sendMove(direction: 'north' | 'south' | 'east' | 'west'): void {
  if (!gameChannel) return;
  gameChannel.push('move', { direction });
}

Create client/src/lib/interpolation.ts:

import { get } from 'svelte/store';
import { interpolationState, type Player } from './game-store';

/**
 * Get interpolated player position based on elapsed time since last update.
 * Call this from useTask with current frame time.
 */
export function getInterpolatedPlayers(currentTime: number): Map<string, Player> {
  const { previous, current, receivedAt } = get(interpolationState);

  if (!current) return new Map();
  if (!previous) return current.players;

  // Time since we received the current state
  const elapsed = currentTime - receivedAt;
  const tickInterval = 50; // 50ms between ticks

  // Interpolation factor (0 = previous, 1 = current, >1 = extrapolate)
  const alpha = Math.min(elapsed / tickInterval, 1.0);

  const interpolated = new Map<string, Player>();

  for (const [userId, currentPlayer] of current.players.entries()) {
    const prevPlayer = previous.players.get(userId);

    if (!prevPlayer) {
      // New player, just show current position
      interpolated.set(userId, currentPlayer);
      continue;
    }

    // Lerp between previous and current position
    const interpolatedPlayer: Player = {
      userId,
      x: lerp(prevPlayer.x, currentPlayer.x, alpha),
      y: lerp(prevPlayer.y, currentPlayer.y, alpha),
      z: lerp(prevPlayer.z, currentPlayer.z, alpha),
      velocity: currentPlayer.velocity
    };

    interpolated.set(userId, interpolatedPlayer);
  }

  return interpolated;
}

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

Client: Render interpolated positions

Create client/src/lib/scenes/MultiplayerScene.svelte:

<script lang="ts">
  import { T, useTask } from '@threlte/core';
  import { getInterpolatedPlayers } from '$lib/interpolation';
  import { onMount } from 'svelte';

  let players = $state<Map<string, any>>(new Map());

  useTask((delta) => {
    // Update interpolated positions every frame (60+ fps)
    const now = performance.now();
    players = getInterpolatedPlayers(now);
  });
</script>

<T.PerspectiveCamera makeDefault position={[0, 10, 10]} fov={60}>
  <T.OrbitControls />
</T.PerspectiveCamera>

<T.AmbientLight intensity={0.5} />
<T.DirectionalLight position={[5, 10, 5]} intensity={1} />

<!-- Ground plane -->
<T.Mesh rotation.x={-Math.PI / 2} position.y={-0.5}>
  <T.PlaneGeometry args={[20, 20]} />
  <T.MeshStandardMaterial color="#2a2a2a" />
</T.Mesh>

<!-- Render each player -->
{#each Array.from(players.values()) as player (player.userId)}
  <T.Mesh position={[player.x, 0, player.z]}>
    <T.BoxGeometry args={[0.5, 1, 0.5]} />
    <T.MeshStandardMaterial color="#f59e0b" />
  </T.Mesh>
{/each}

What’s happening:

  • Server sends updates at 20 Hz (every 50ms)
  • Client renders at 60+ Hz (every 16ms)
  • getInterpolatedPlayers smoothly blends between the last two received states
  • Result: Smooth motion even though server updates are infrequent

Visualizing the timeline

Server ticks (20 Hz):
  |-------tick 1-------|-------tick 2-------|-------tick 3-------|
  0ms                  50ms                 100ms                150ms
  [broadcast state]    [broadcast state]    [broadcast state]

Client frames (60 Hz):
  |-----|-----|-----|-----|-----|-----|-----|-----|-----|-----|
  0    16    33    50    66    83   100   116   133   150
  [interpolate between tick 1 and tick 2...]  [interpolate between tick 2 and tick 3...]

The client fills in the gaps between server ticks.


Why not just send every input immediately?

Without tick loop:

  • Fast computer sends 100 inputs/sec, slow computer sends 20 inputs/sec
  • Player with 10ms ping gets their moves applied before player with 100ms ping
  • Validation and broadcasting happens chaotically, hard to reason about

With tick loop:

  • All inputs processed in predictable order at predictable times
  • Deterministic simulation (replay systems, rollback netcode possible)
  • Server load is constant (20 ticks/sec regardless of input volume)
  • Physics and collision detection run at fixed timestep (prevents tunneling bugs)

Tuning tick rate

Lower tick rate (10-20 Hz):

  • Less server CPU and bandwidth
  • Good for strategy games, turn-based, lobby games
  • Requires good interpolation on client

Higher tick rate (60-128 Hz):

  • More responsive, less interpolation needed
  • Required for fast action games (FPS, racing)
  • More server resources

Start with 20 Hz. Profile and adjust based on your game’s needs.


Next steps

  • Input buffering: Show client-side input queueing patterns
  • Prediction: Apply inputs locally before server confirms (reduces perceived lag)
  • Lag compensation: Server rewinds time to validate shots (advanced topic)
  • Delta compression: Only send changed fields to save bandwidth

The tick loop is the heartbeat of your multiplayer game.