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
- Server: Schedule
:tickmessages at fixed intervals usingProcess.send_after/3 - Server: Each tick, drain the input queue, update state, broadcast
- Client: Buffer received states, interpolate between them at 60+ fps for smooth rendering
- Client: Use Threlte’s
useTaskto 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— UsesProcess.send_after/3to send:tickmessage to selfhandle_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_attimestamp for determinism tick_numberincrements 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)
getInterpolatedPlayerssmoothly 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.