| name | qa-multiplayer-testing |
| description | E2E multiplayer testing using Playwright API with multi-client browser contexts. Validates server-authoritative patterns, state synchronization, and anti-cheat measures. Use when testing multiplayer features. |
| category | validation |
Multiplayer Testing with E2E Tests
"Server-authoritative code must be validated with actual server connections using E2E tests."
When to Use This Skill
Use for EVERY task marked with serverAuthoritative: true or multiplayerTested: true.
Core Principle: Write Multi-Client E2E Tests
✅ CORRECT APPROACH:
test('server-authoritative movement sync', async ({ browser }) => {
const context1 = await browser.newContext();
const context2 = await browser.newContext();
const page1 = await context1.newPage();
const page2 = await context2.newPage();
await page1.goto('http://localhost:3000');
await page2.goto('http://localhost:3000');
});
❌ DO NOT USE:
mcp__playwright__browser_navigate('http://localhost:3000');
mcp__playwright__browser_tabs({ action: 'new' });
Critical Architecture Principle
Single-browser testing is INSUFFICIENT for multiplayer validation.
You must verify in E2E tests:
- Server receives input from clients
- Server validates and processes input
- Server broadcasts state to all clients
- All clients see synchronized state
Quick Start: Multi-Client Test Pattern
import { test, expect } from '@playwright/test';
test('server-authoritative movement sync', async ({ browser }) => {
const context1 = await browser.newContext();
const context2 = await browser.newContext();
const page1 = await context1.newPage();
const page2 = await context2.newPage();
try {
await page1.goto('http://localhost:3000?room=test_room');
await page2.goto('http://localhost:3000?room=test_room');
await page1.waitForFunction(() => (window as any).isConnected?.() === true);
await page2.waitForFunction(() => (window as any).isConnected?.() === true);
await page1.();
page1..();
page1.();
page1..();
page1.();
player1PosOnPage2 = page2.( {
( ).?.();
});
(player1PosOnPage2.).();
} {
context1.();
context2.();
}
});
Test Categories
| Category | What to Validate |
|---|
| Connection | Multiple clients connect to same room |
| State Sync | All clients see same server state |
| Movement | Client input → Server validate → All clients see result |
| Shooting | Client fires → Server validates → All clients see paint |
| Spawning | Server assigns spawn → All clients see same location |
| Tamper Detection | Server rejects invalid inputs |
| Latency | Client prediction + server reconciliation |
Server Management
⚠️ CRITICAL: Use shared-lifecycle skill for server management.
Server Detection (Before Multiplayer E2E Tests)
⚠️ IMPORTANT: Playwright's webServer config manages servers for E2E tests automatically.
Multiplayer tests require both frontend (port 3000) and backend (Colyseus port 2567) servers.
When running npm run test:e2e, Playwright automatically starts:
npm run dev (port 3000) with reuseExistingServer: !process.env.CI
npm run server (port 2567) with reuseExistingServer: false
DO NOT manually start servers for E2E tests.
Server Check Pattern
netstat -an | grep :3000 || lsof -i :3000
netstat -an | grep :2567 || lsof -i :2567
curl -s http://localhost:3000 | grep -q "vite" && echo "DEV_RUNNING" || echo "DEV_NOT_RUNNING"
curl -s http://localhost:2567 || echo "COLYSEUS_NOT_RUNNING"
E2E Test Path (Standard Multiplayer Validation)
npm run test:e2e -- tests/e2e/multiplayer-suite.spec.ts
Manual MCP Validation Path (Only when explicitly needed)
if ! netstat -an | grep :3000; then
Bash(command="npm run dev", run_in_background=true)
fi
if ! netstat -an | grep :2567; then
Bash(command="npm run server", run_in_background=true)
fi
TaskStop(task_id="dev_server_shell_id")
TaskStop(task_id="server_shell_id")
Before running multiplayer E2E tests, always check/start the dev server using the patterns from shared-lifecycle skill.
MANDATORY CLEANUP after all tests complete (pass OR fail):
Use the cleanup patterns from shared-lifecycle skill to ensure:
- Dev server is stopped
- Backend server is stopped
- Ports 3000 and 2567 are released
- No orphaned processes remain
Server Validation Checklist
Before running multiplayer E2E tests, verify server is running:
npm run dev:all:sh
If server is NOT running, FAIL the validation immediately.
Progressive Guide
Level 1: Multi-Client Connection
test('two clients connect to same room', async ({ browser }) => {
const context1 = await browser.newContext();
const context2 = await browser.newContext();
const page1 = await context1.newPage();
const page2 = await context2.newPage();
try {
await page1.goto('http://localhost:3000');
await page2.goto('http://localhost:3000');
const connected1 = await page1.evaluate(() => (window as any).gameState?.connected);
const connected2 = await page2.evaluate(() => (window as any).gameState?.connected);
expect(connected1).toBe(true);
expect(connected2).toBe(true);
room1 = page1.( ( ).?.);
room2 = page2.( ( ).?.);
(room1).(room2);
} {
context1.();
context2.();
}
});
Level 2: State Synchronization
test('movement syncs between clients', async ({ browser }) => {
const context1 = await browser.newContext();
const context2 = await browser.newContext();
const page1 = await context1.newPage();
const page2 = await context2.newPage();
try {
await page1.goto('http://localhost:3000');
await page2.goto('http://localhost:3000');
await page1.waitForFunction(() => (window as any).gameState?.players?.size >= 2);
await page2.waitForFunction(() => (window as any).gameState?.players?.size >= 2);
const initialPos = await page1.evaluate(() => {
const localId = (window ).?.;
( ).?.?.(localId)?.;
});
page1.();
page1..();
page1.();
page1..();
page1.();
localPos = page1.( {
localId = ( ).?.;
( ).?.?.(localId)?.;
});
remotePos = page2.( {
players = ( ).?.;
( [id, player] players?.()) {
(id !== ( ).?.) {
player.;
}
}
});
(localPos.)..(initialPos.);
(.(remotePos. - localPos.)).();
} {
context1.();
context2.();
}
});
Level 3: Server Authority Validation
test('server validates input (anti-cheat)', async ({ browser }) => {
const page = await browser.newPage();
await page.goto('http://localhost:3000');
const networkManager = await page.evaluate(() => (window as any).networkManager);
await page.evaluate(() => {
(window as any).networkManager?.send({
type: 'player_input',
input: {
forward: true,
speed: 999999,
},
});
});
const posBefore = await page.evaluate(() => (window as any).gameState?.localPlayer?.position);
await page.waitForTimeout();
posAfter = page.( ( ).?.?.);
(.(posAfter. - posBefore.)).();
});
Level 4: Paint Shooting Validation
test('shooting syncs between clients', async ({ browser }) => {
const context1 = await browser.newContext();
const context2 = await browser.newContext();
const page1 = await context1.newPage();
const page2 = await context2.newPage();
try {
await page1.goto('http://localhost:3000');
await page2.goto('http://localhost:3000');
await page1.waitForFunction(() => (window as any).gameState?.players?.size >= 2);
await page2.waitForFunction(() => (window as any).gameState?.players?.size >= 2);
await page1.click('canvas');
await page1.mouse.click(400, 300);
page1.();
paintCount1 = page1.(
( ).?.?. ||
);
paintCount2 = page2.(
( ).?.?. ||
);
(paintCount1).();
(paintCount1).(paintCount2);
} {
context1.();
context2.();
}
});
Level 5: Network Latency Simulation
test('client prediction works with latency', async ({ browser, context }) => {
await context.route('**/*', async (route) => {
await new Promise((resolve) => setTimeout(resolve, 200));
route.continue();
});
const page = await browser.newPage();
await page.goto('http://localhost:3000');
await page.click('canvas');
const posBefore = await page.evaluate(() => (window as any).gameState?.localPlayer?.position);
await page.keyboard.down('KeyW');
await page.waitForTimeout(100);
await page.keyboard.up('KeyW');
predictedPos = page.( ( ).?.?.);
(predictedPos.).(posBefore.);
});
Using Page Objects for Multiplayer Tests
For cleaner tests, use the MultiplayerPage object:
import { test, expect } from '@playwright/test';
import { MultiplayerPage } from '@/pages/multiplayer.page';
test('multiplayer state sync with page objects', async ({ browser }) => {
const multiplayerPage = new MultiplayerPage(null);
const players = await multiplayerPage.setupMultiPlayerTest(browser, 2);
try {
await multiplayerPage.connectPlayersToGame(players);
expect(await multiplayerPage.verifyAllConnected(players)).toBe(true);
await players[0].page.click('canvas');
await players[0].page.keyboard.down('KeyW');
await players[0].page.waitForTimeout(500);
await players[0].page.keyboard.up('KeyW');
synced = multiplayerPage.(players);
(synced).();
} {
multiplayerPage.(players);
}
});
Server-Side Integration Tests
Create server tests alongside client tests:
import { describe, it, expect, beforeEach } from 'vitest';
import { GameRoom } from '../rooms/GameRoom';
import { Client, Room } from 'colyseus';
describe('GameRoom Server Authority', () => {
let room: GameRoom;
beforeEach(() => {
room = new GameRoom();
room.onCreate({});
});
it('validates player input speed', () => {
const mockClient = { sessionId: 'test-player' } as Client;
room.onJoin(mockClient);
const player = room.state.players.get('test-player');
room.onMessage(mockClient, {
type: 'player_input',
input: { speed: 9999 },
});
expect(player.x).toBeCloseTo(, );
});
(, {
mockClient = { : } ;
room.(mockClient);
player = room...();
player. = .();
room.(mockClient, {
: ,
: { : , : , : },
});
(room.?. || ).();
});
});
Tamper Detection Tests
Verify server rejects client manipulation attempts:
test('server rejects position hacks', async ({ browser }) => {
const page = await browser.newPage();
await page.goto('http://localhost:3000');
const posBefore = await page.evaluate(() => {
return (window as any).gameState?.localPlayer?.position;
});
await page.evaluate(() => {
const localId = (window as any).gameState?.localPlayerId;
(window as any).gameState.players.get(localId).position = { x: 9999, y: 0, z: 9999 };
});
await page.waitForTimeout(500);
const posAfter = await page.( {
( ).?.?.;
});
(posAfter.)..();
(.(posAfter. - posBefore.)).();
});
Testing Checklist
For each multiplayer validation:
Common Mistakes
| ❌ Wrong | ✅ Right |
|---|
| Test with 1 browser context | Test with 2+ contexts (multi-client) |
| Don't check server logs | Verify server receives and processes input |
| Assume state syncs | Assert state values match across clients |
| Test local state only | Test REMOTE player state from other client |
| Ignore server validation | Test that invalid inputs are rejected |
| Don't cleanup contexts | Always close contexts in finally block |
Anti-Patterns
❌ DON'T:
- Test multiplayer features with only 1 browser
- Skip checking server logs
- Assume state sync without assertions
- Test only local player state
- Skip tamper detection tests
- Use Playwright MCP for multiplayer testing
✅ DO:
- Always test with 2+ browser contexts
- Monitor server logs for input processing
- Assert state synchronization explicitly
- Test remote player state from other client's perspective
- Include tamper detection tests
- Write E2E tests as persistent artifacts
- Always cleanup contexts in finally blocks
Validation Failure Criteria
FAIL the validation if:
- Server is not running
- Clients cannot connect to same room
- State does not sync between clients within 500ms
- Server logs show no input processing
- Invalid inputs are not rejected
- Console errors on any client
- Server crashes or throws errors
Running Multiplayer Tests
npm run test:e2e -- tests/e2e/multiplayer-suite.spec.ts
npm run test:e2e -- -g "server-authoritative movement sync"
npm run test:e2e -- --headed
npm run test:e2e -- --debug
References