Skip to content

Game server load testing

A game server load test tells you how many concurrent players the server handles before latency climbs, connections are refused or game logic starts dropping events. Every studio needs that number before launch. This page shows you how to build a concurrent-session load test in MaxoPerf with k6’s WebSocket API. It covers configuration, the MaxoPerf console steps and how to read the results.

  • Read Real-time protocols. It explains why game server tests use WebSocket and what k6 measures.
  • Read k6 scripts on Maxoperf for the upload and run workflow.
  • Know your target: the WebSocket endpoint URL, the authentication scheme (token query param, first message, cookie), and the expected server-side message format.

Each virtual user (VU) in MaxoPerf represents one player session. The test:

  1. Opens a WebSocket connection (simulating login + matchmaking handshake).
  2. Exchanges messages at a realistic rate (game ticks, position updates, state sync).
  3. Holds the connection open for the duration of a realistic session (2–5 minutes for most game types).
  4. Closes and, optionally, reconnects to model churn.

The key measurements are connection establishment time (ws_connecting), session hold success rate, message latency (custom Trend metric), and error rate.

import ws from 'k6/ws';
import { check, sleep } from 'k6';
import { Trend, Counter } from 'k6/metrics';
const msgLatency = new Trend('game_msg_latency_ms');
const disconnects = new Counter('game_unexpected_disconnects');
export const options = {
stages: [
{ duration: '2m', target: 500 }, // ramp to 500 concurrent players
{ duration: '5m', target: 500 }, // hold — steady-state load
{ duration: '3m', target: 1000 }, // scale to 1 000 players
{ duration: '5m', target: 1000 }, // hold — observe at peak
{ duration: '2m', target: 0 }, // ramp down
],
thresholds: {
ws_connecting: ['p(95)<300'], // handshake under 300 ms
game_msg_latency_ms: ['p(95)<100'], // message round-trip under 100 ms
game_unexpected_disconnects: ['count<50'], // tolerate < 50 drops
},
};
export default function () {
const token = __ENV.GAME_TOKEN;
const url = `wss://gameserver.staging.example.com/play?token=${token}`;
const res = ws.connect(url, {}, function (socket) {
socket.on('open', () => {
// Send player-ready handshake
socket.send(JSON.stringify({ type: 'join', zone: 'world-1' }));
});
socket.on('message', (data) => {
const msg = JSON.parse(data);
// Measure echo-based round-trip latency
if (msg.type === 'tick_ack' && msg.client_ts) {
msgLatency.add(Date.now() - msg.client_ts);
}
check(msg, {
'no server error': (m) => m.type !== 'error',
'valid game state': (m) => m.type !== undefined,
});
// Send a position update every tick
if (msg.type === 'tick') {
socket.send(JSON.stringify({
type: 'position',
x: Math.random() * 1000,
y: Math.random() * 1000,
client_ts: Date.now(),
}));
}
});
socket.on('error', () => {
disconnects.add(1);
});
socket.on('close', (code) => {
if (code !== 1000) disconnects.add(1); // unexpected close
});
// Hold session for 3 minutes — typical play session
socket.setTimeout(() => socket.close(), 180000);
});
check(res, { 'connected (101)': (r) => r && r.status === 101 });
sleep(1);
}

The stages array creates the ramp profile. The thresholds block is the pass/fail gate: MaxoPerf reports a threshold violation as a failure in the run detail.

  1. Save the script as game-server-load.js. Open MaxoPerf console → Tests → New test.
  2. Name the test game-server-concurrent-players and select the project.
  3. In the Files tab, upload game-server-load.js as the Entrypoint. MaxoPerf auto-detects k6.
  4. In Configuration, set the runner location to the regions closest to your game server (e.g. us-east-1 for a North American server). For a global title, add eu-west-1 and ap-southeast-1 as well.
  5. Open Secrets and add GAME_TOKEN, the bearer token your test players use for authentication.
  6. Optionally add a Failure criteria in Configuration that matches the thresholds in your script (p95 ws_connecting < 300 ms, etc.). MaxoPerf then fails the run automatically if the game server is not ready.

Click Run. The live Overview tab shows:

  • VU count, climbing through the stages ramp.
  • Throughput in messages per second (MaxoPerf maps k6’s ws_msgs_sent rate).
  • Latency, with ws_connecting as the primary latency signal. game_msg_latency_ms appears as a custom metric in the metrics panel.
  • Error rate, from game_unexpected_disconnects and ws errors.

After the run completes, open the Overview tab and look for:

  • Connection ramp shape. The VU count should climb smoothly through the stages. A flat VU plateau below the target means MaxoPerf couldn’t open enough connections. Check the Runners tab for errors.
  • p95 ws_connecting latency. Under 300 ms is typical for a well-provisioned game server. If it climbs across the hold phase, the server’s connection-accept loop is probably saturated.
  • Custom metric game_msg_latency_ms. This is the game-specific signal. Values above 100 ms p95 mean the server is falling behind on game logic under load.
  • Disconnect count. A handful of unexpected disconnects is normal (network blips). A disconnect count that grows linearly with VUs points to a server-side connection limit.

For games with instanced worlds or zones, parameterize the zone field in the join message with MaxoPerf’s data entities (CSV):

// index.js — reads zone names from a CSV data entity
import { SharedArray } from 'k6/data';
const zones = new SharedArray('zones', function () {
return JSON.parse(open('./zones.json'));
});
export default function () {
const zone = zones[__VU % zones.length]; // distribute VUs across zones
// ... connect with zone
}

Upload zones.json as a Test asset next to the entrypoint. The script then spreads virtual players across zones the same way real matchmaking does.

Do:

  • Hold connections open for a realistic session duration (2–5 min, not 10 s).
  • Use custom Trend metrics to measure application-level message latency as well as connection establishment.
  • Ramp gradually before the hold phase so autoscaling has time to respond.

Don’t:

  • Use the default k6 options.duration for session hold. Use socket.setTimeout() inside the WebSocket handler so the connection stays open for the full session window.
  • Ignore game_unexpected_disconnects. Even a 0.1 % disconnect rate at 10 000 players is 10 dropped sessions every second.