| name | qa-gameplay-testing |
| description | E2E gameplay testing patterns using Playwright API. Tests continuous movement, mouse control, and complete gameplay loops. Use when validating game controls, combat mechanics, and player interactions. |
| category | validation |
Gameplay Testing with E2E Tests
"Game controls must be tested with continuous input patterns, not single keypresses."
When to Use This Skill
Use for every game feature validation to create E2E tests for:
- Character movement (WASD, arrow keys)
- Mouse aiming and interaction
- Combat mechanics and combos
- Special actions (jump, crouch, interact)
- UI navigation (menus, inventory, map)
- Complete gameplay loops
MANDATORY: Port Detection Before Browser Testing
⚠️ CRITICAL: Vite dev server may run on different ports (3000, 3001, 5174, 8080, etc.)
Before ANY browser interaction, ALWAYS detect the correct port:
netstat -an | grep LISTEN | grep -E ":(3000|3001|5174|8080)"
curl -s http://localhost:3000 | grep -q "vite" && echo "PORT=3000" || \
curl -s http://localhost:3001 | grep -q "vite" && echo "PORT=3001" || \
curl -s http://localhost:5174 | grep -q "vite" && echo "PORT=5174"
NOTE: E2E tests configured in playwright.config.ts use baseURL: 'http://localhost:3000' which works for most cases. The webServer configuration automatically starts the dev server on the correct port.
For manual testing or MCP validation, detect the port first and use http://localhost:{detectedPort}.
Core Principle: Write Test Code, Don't Use MCP
❌ OLD APPROACH (Do NOT do this):
mcp__playwright__browser_navigate('http://localhost:3000');
mcp__playwright__browser_press_key({ key: 'KeyW' });
✅ NEW APPROACH (Do this):
test('player can move forward', async ({ page }) => {
await page.goto('http://localhost:3000');
await page.click('canvas');
await page.keyboard.down('KeyW');
await page.waitForTimeout(1000);
await page.keyboard.up('KeyW');
const position = await page.evaluate(() => (window as any).playerPosition);
expect(position.z).not.toBe(0);
});
E2E Test Structure
import { test, expect } from '@playwright/test';
test.describe('Gameplay - Movement', () => {
test.beforeEach(async ({ page }) => {
await page.goto('http://localhost:3000');
await page.waitForSelector('canvas');
await page.click('canvas');
});
test('should move forward with W key', async ({ page }) => {
});
test('should strafe left with A key', async ({ page }) => {
});
});
Continuous Movement Patterns
Critical Pattern: Key Down/Up
Single press() only simulates a quick tap. Use down() + waitForTimeout() + up() for continuous movement.
Basic WASD Movement
test.describe('WASD Movement', () => {
test('should move forward', async ({ page }) => {
await page.goto('http://localhost:3000');
await page.click('canvas');
const initialPos = await page.evaluate(
() => (window as any).playerPosition || { x: 0, y: 0, z: 0 }
);
await page.keyboard.down('KeyW');
await page.waitForTimeout(1000);
await page.keyboard.up('KeyW');
const afterPos = await page.evaluate(
() => (window as any).playerPosition || { x: 0, y: 0, z: 0 }
);
expect(afterPos.z).toBeLessThan(initialPos.z);
});
test('should move backward', async ({ page }) => {
await page.goto('http://localhost:3000');
await page.click('canvas');
const initialPos = await page.evaluate(
() => (window as any).playerPosition || { x: 0, y: 0, z: 0 }
);
await page.keyboard.down('KeyS');
await page.waitForTimeout(1000);
await page.keyboard.up('KeyS');
const afterPos = await page.evaluate(
() => (window as any).playerPosition || { x: 0, y: 0, z: 0 }
);
expect(afterPos.z).toBeGreaterThan(initialPos.z);
});
test('should strafe left', async ({ page }) => {
await page.goto('http://localhost:3000');
await page.click('canvas');
const initialPos = await page.evaluate(
() => (window as any).playerPosition || { x: 0, y: 0, z: 0 }
);
await page.keyboard.down('KeyA');
await page.waitForTimeout(1000);
await page.keyboard.up('KeyA');
const afterPos = await page.evaluate(
() => (window as any).playerPosition || { x: 0, y: 0, z: 0 }
);
expect(afterPos.x).toBeLessThan(initialPos.x);
});
test('should strafe right', async ({ page }) => {
await page.goto('http://localhost:3000');
await page.click('canvas');
const initialPos = await page.evaluate(
() => (window as any).playerPosition || { x: 0, y: 0, z: 0 }
);
await page.keyboard.down('KeyD');
await page.waitForTimeout(1000);
await page.keyboard.up('KeyD');
const afterPos = await page.evaluate(
() => (window as any).playerPosition || { x: 0, y: 0, z: 0 }
);
expect(afterPos.x).toBeGreaterThan(initialPos.x);
});
});
Diagonal Movement
test.describe('Diagonal Movement', () => {
test('should move forward-left diagonally', async ({ page }) => {
await page.goto('http://localhost:3000');
await page.click('canvas');
const initialPos = await page.evaluate(
() => (window as any).playerPosition || { x: 0, y: 0, z: 0 }
);
await page.keyboard.down('KeyW');
await page.keyboard.down('KeyA');
await page.waitForTimeout(1000);
await page.keyboard.up('KeyA');
await page.keyboard.up('KeyW');
const afterPos = await page.evaluate(
() => (window as any).playerPosition || { x: 0, y: 0, z: 0 }
);
expect(afterPos.x).toBeLessThan(initialPos.x);
expect(afterPos.z).toBeLessThan(initialPos.z);
});
test('should move forward-right diagonally', async ({ page }) => {
await page.goto('http://localhost:3000');
await page.click('canvas');
const initialPos = await page.evaluate(
() => (window as any).playerPosition || { x: 0, y: 0, z: 0 }
);
await page.keyboard.down('KeyW');
await page.keyboard.down('KeyD');
await page.waitForTimeout(1000);
await page.keyboard.up('KeyD');
await page.keyboard.up('KeyW');
const afterPos = await page.evaluate(
() => (window as any).playerPosition || { x: 0, y: 0, z: 0 }
);
expect(afterPos.x).toBeGreaterThan(initialPos.x);
expect(afterPos.z).toBeLessThan(initialPos.z);
});
});
Sprint/Run Combinations
test.describe('Sprint Movement', () => {
test('should sprint forward', async ({ page }) => {
await page.goto('http://localhost:3000');
await page.click('canvas');
const initialPos = await page.evaluate(
() => (window as any).playerPosition || { x: 0, y: 0, z: 0 }
);
await page.keyboard.down('ShiftLeft');
await page.keyboard.down('KeyW');
await page.waitForTimeout(1000);
await page.keyboard.up('KeyW');
await page.keyboard.up('ShiftLeft');
const afterPos = await page.evaluate(
() => (window as any).playerPosition || { x: 0, y: 0, z: 0 }
);
expect(Math.abs(afterPos.z - initialPos.z)).toBeGreaterThan(5);
});
test('should sprint diagonally', async ({ page }) => {
await page.goto('http://localhost:3000');
await page.click('canvas');
await page.keyboard.down('ShiftLeft');
await page.keyboard.down('KeyW');
await page.keyboard.down('KeyD');
await page.waitForTimeout(1000);
await page.keyboard.up('KeyD');
await page.keyboard.up('KeyW');
await page.keyboard.up('ShiftLeft');
const position = await page.evaluate(() => (window as any).playerPosition);
expect(position.x).toBeGreaterThan(0);
expect(position.z).toBeLessThan(0);
});
});
Crouch Movement
test.describe('Crouch Movement', () => {
test('should crouch forward', async ({ page }) => {
await page.goto('http://localhost:3000');
await page.click('canvas');
const initialHeight = await page.evaluate(() => (window as any).playerPosition?.y || 0);
await page.keyboard.down('ControlLeft');
await page.keyboard.down('KeyW');
await page.waitForTimeout(1000);
await page.keyboard.up('KeyW');
await page.keyboard.up('ControlLeft');
const afterHeight = await page.evaluate(() => (window as any).playerPosition?.y || 0);
expect(afterHeight).toBeLessThanOrEqual(initialHeight);
});
test('should crouch in place', async ({ page }) => {
await page.goto('http://localhost:3000');
await page.click('canvas');
const initialHeight = await page.evaluate(() => (window as any).playerPosition?.y || 0);
await page.keyboard.down('ControlLeft');
await page.waitForTimeout(500);
await page.keyboard.up('ControlLeft');
const afterHeight = await page.evaluate(() => (window as any).playerPosition?.y || 0);
expect(afterHeight).toBeLessThan(initialHeight);
});
});
Mouse Control Patterns
Aiming
test.describe('Mouse Aiming', () => {
test('should aim with mouse movement', async ({ page }) => {
await page.goto('http://localhost:3000');
await page.click('canvas');
const initialRotation = await page.evaluate(() => (window as any).cameraRotation || { y: 0 });
await page.mouse.move(500, 300);
await page.waitForTimeout(100);
const afterRotation = await page.evaluate(() => (window as any).cameraRotation || { y: 0 });
expect(afterRotation.y).not.toBe(initialRotation.y);
});
test('should use pointer lock for camera control', async ({ page }) => {
await page.goto('http://localhost:3000');
await page.waitForSelector('canvas');
const isLocked = await page.evaluate(() => {
return document.pointerLockElement === document.body;
});
expect(isLocked).toBe(true);
const initialRotation = await page.evaluate(() => (window as any).cameraRotation?.y || 0);
await page.mouse.move(500, 300);
await page.waitForTimeout(100);
const afterRotation = await page.evaluate(() => (window as any).cameraRotation?.y || 0);
expect(afterRotation).not.toBe(initialRotation);
});
});
Clicking
test.describe('Mouse Click Actions', () => {
test('should shoot on left click', async ({ page }) => {
await page.goto('http://localhost:3000');
await page.click('canvas');
const initialAmmo = await page.evaluate(() => (window as any).playerState?.ammo || 0);
await page.mouse.click(400, 300, { button: 'left' });
await page.waitForTimeout(100);
const afterAmmo = await page.evaluate(() => (window as any).playerState?.ammo || 0);
expect(afterAmmo).toBeLessThan(initialAmmo);
});
test('should perform alt action on right click', async ({ page }) => {
await page.goto('http://localhost:3000');
await page.click('canvas');
await page.mouse.click(400, 300, { button: 'right' });
const actionState = await page.evaluate(
() => (window as any).playerState?.secondaryActionActive || false
);
expect(actionState).toBe(true);
});
test('should handle charged attack', async ({ page }) => {
await page.goto('http://localhost:3000');
await page.click('canvas');
await page.mouse.down({ button: 'left' });
await page.waitForTimeout(1000);
await page.mouse.up({ button: 'left' });
const attackType = await page.evaluate(() => (window as any).lastAttackType || 'none');
expect(attackType).toBe('charged');
});
});
Special Keys
Jump
test.describe('Jump Actions', () => {
test('should jump on space', async ({ page }) => {
await page.goto('http://localhost:3000');
await page.click('canvas');
const initialY = await page.evaluate(() => (window as any).playerPosition?.y || 0);
await page.keyboard.press('Space');
await page.waitForTimeout(500);
const peakY = await page.evaluate(() => (window as any).playerPosition?.y || 0);
expect(peakY).toBeGreaterThan(initialY);
});
test('should vary jump height with hold duration', async ({ page }) => {
await page.goto('http://localhost:3000');
await page.click('canvas');
await page.keyboard.press('Space');
await page.waitForTimeout(300);
const shortJumpY = await page.evaluate(() => (window as any).playerPosition?.y || 0);
await page.keyboard.press('KeyS');
await page.waitForTimeout(500);
await page.keyboard.up('KeyS');
await page.keyboard.down('Space');
await page.waitForTimeout(300);
await page.keyboard.up('Space');
await page.waitForTimeout(200);
const longJumpY = await page.evaluate(() => (window as any).playerPosition?.y || 0);
expect(longJumpY).toBeGreaterThan(shortJumpY);
});
});
Interact Keys
test.describe('Interaction Keys', () => {
test('should interact with E key', async ({ page }) => {
await page.goto('http://localhost:3000');
await page.click('canvas');
await page.keyboard.down('KeyW');
await page.waitForTimeout(1000);
await page.keyboard.up('KeyW');
const beforeInteract = await page.evaluate(
() => (window as any).nearbyInteractable?.activated || false
);
await page.keyboard.press('KeyE');
await page.waitForTimeout(100);
const afterInteract = await page.evaluate(
() => (window as any).nearbyInteractable?.activated || false
);
expect(afterInteract).toBe(true);
});
test('should hold interact for long actions', async ({ page }) => {
await page.goto('http://localhost:3000');
await page.click('canvas');
await page.keyboard.down('KeyE');
await page.waitForTimeout(2000);
await page.keyboard.up('KeyE');
const interactionComplete = await page.evaluate(
() => (window as any).longInteractionComplete || false
);
expect(interactionComplete).toBe(true);
});
});
Menu/UI Keys
test.describe('Menu Keys', () => {
test('should pause game on Escape', async ({ page }) => {
await page.goto('http://localhost:3000');
await page.click('canvas');
await page.keyboard.press('Escape');
const isPaused = await page.evaluate(() => (window as any).gameState?.paused || false);
expect(isPaused).toBe(true);
const pausedText = await page.locator('text=PAUSED').isVisible();
expect(pausedText).toBe(true);
});
test('should show scoreboard on Tab', async ({ page }) => {
await page.goto('http://localhost:3000');
await page.click('canvas');
await page.keyboard.down('Tab');
await page.waitForTimeout(100);
const scoreboardVisible = await page.getByTestId('scoreboard').isVisible();
expect(scoreboardVisible).toBe(true);
await page.keyboard.up('Tab');
await page.waitForTimeout(100);
const scoreboardHidden = await page.getByTestId('scoreboard').isHidden();
expect(scoreboardHidden).toBe(true);
});
test('should toggle inventory on I', async ({ page }) => {
await page.goto('http://localhost:3000');
await page.keyboard.press('KeyI');
const inventoryVisible = await page.getByTestId('inventory').isVisible();
expect(inventoryVisible).toBe(true);
await page.keyboard.press('KeyI');
const inventoryClosed = await page.getByTestId('inventory').isHidden();
expect(inventoryClosed).toBe(true);
});
});
Combo Sequences
Melee Combo Pattern
test.describe('Combat Combos', () => {
test('should execute three-hit light combo', async ({ page }) => {
await page.goto('http://localhost:3000');
await page.click('canvas');
const comboSequence = [
{ key: 'KeyJ', hold: 100 },
{ key: 'KeyJ', hold: 100 },
{ key: 'KeyJ', hold: 100 },
];
for (const action of comboSequence) {
await page.keyboard.down(action.key);
await page.waitForTimeout(action.hold);
await page.keyboard.up(action.key);
await page.waitForTimeout(50);
}
const comboCount = await page.evaluate(() => (window as any).playerState?.comboCount || 0);
expect(comboCount).toBe(3);
});
test('should execute light-light-heavy finisher', async ({ page }) => {
await page.goto('http://localhost:3000');
await page.click('canvas');
await page.keyboard.down('KeyJ');
await page.waitForTimeout(100);
await page.keyboard.up('KeyJ');
await page.waitForTimeout(50);
await page.keyboard.down('KeyJ');
await page.waitForTimeout(100);
await page.keyboard.up('KeyJ');
await page.waitForTimeout(50);
await page.keyboard.down('KeyK');
await page.waitForTimeout(200);
await page.keyboard.up('KeyK');
const lastAttack = await page.evaluate(() => (window as any).playerState?.lastAttack || 'none');
expect(lastAttack).toBe('heavy_finisher');
});
});
Spell Cast Sequence
test.describe('Spell Casting', () => {
test('should cast spell with modifier key', async ({ page }) => {
await page.goto('http://localhost:3000');
await page.click('canvas');
const initialMana = await page.evaluate(() => (window as any).playerState?.mana || 0);
await page.keyboard.down('ControlLeft');
await page.keyboard.press('KeyQ');
await page.keyboard.up('ControlLeft');
const afterMana = await page.evaluate(() => (window as any).playerState?.mana || 0);
expect(afterMana).toBeLessThan(initialMana);
const spellActive = await page.evaluate(() => (window as any).activeSpell?.type || 'none');
expect(spellActive).not.toBe('none');
});
});
Modifier Combinations
test.describe('Modifier Combinations', () => {
test('should sprint jump', async ({ page }) => {
await page.goto('http://localhost:3000');
await page.click('canvas');
const initialY = await page.evaluate(() => (window as any).playerPosition?.y || 0);
await page.keyboard.down('ShiftLeft');
await page.keyboard.press('Space');
await page.keyboard.up('ShiftLeft');
await page.waitForTimeout(500);
const peakY = await page.evaluate(() => (window as any).playerPosition?.y || 0);
expect(peakY).toBeGreaterThan(initialY);
});
test('should crouch jump', async ({ page }) => {
await page.goto('http://localhost:3000');
await page.click('canvas');
const initialY = await page.evaluate(() => (window as any).playerPosition?.y || 0);
await page.keyboard.down('ControlLeft');
await page.keyboard.press('Space');
await page.keyboard.up('ControlLeft');
await page.waitForTimeout(500);
const afterY = await page.evaluate(() => (window as any).playerPosition?.y || 0);
expect(afterY).toBeGreaterThan(initialY);
});
});
Complete Gameplay Loop Tests
Full Character Movement Test
test('complete character movement test', async ({ page }) => {
await page.goto('http://localhost:3000');
await page.waitForSelector('canvas');
await page.click('canvas');
const initialPos = await page.evaluate(
() => (window as any).playerPosition || { x: 0, y: 0, z: 0 }
);
await page.keyboard.down('KeyW');
await page.waitForTimeout(500);
await page.keyboard.up('KeyW');
await page.keyboard.down('KeyA');
await page.waitForTimeout(500);
await page.keyboard.up('KeyA');
await page.keyboard.down('KeyS');
await page.waitForTimeout(500);
await page.keyboard.up('KeyS');
await page.keyboard.down('KeyD');
await page.waitForTimeout(500);
await page.keyboard.up('KeyD');
await page.keyboard.press('Space');
await page.waitForTimeout(500);
const finalPos = await page.evaluate(
() => (window as any).playerPosition || { x: 0, y: 0, z: 0 }
);
expect(finalPos.x).not.toBe(initialPos.x);
expect(finalPos.y).not.toBe(initialPos.y);
expect(finalPos.z).not.toBe(initialPos.z);
});
Complete Combat Test
test('complete combat test', async ({ page }) => {
await page.goto('http://localhost:3000');
await page.click('canvas');
const initialAmmo = await page.evaluate(() => (window as any).playerState?.ammo || 30);
await page.keyboard.down('KeyW');
await page.waitForTimeout(500);
await page.keyboard.up('KeyW');
await page.mouse.move(500, 300);
await page.waitForTimeout(100);
await page.mouse.down({ button: 'left' });
await page.waitForTimeout(500);
await page.mouse.up({ button: 'left' });
const finalAmmo = await page.evaluate(() => (window as any).playerState?.ammo || 30);
expect(finalAmmo).toBeLessThan(initialAmmo);
const hits = await page.evaluate(() => (window as any).playerState?.hits || 0);
expect(hits).toBeGreaterThan(0);
});
Performance During Gameplay
test('maintains 60 FPS during gameplay', async ({ page }) => {
await page.goto('http://localhost:3000');
await page.waitForSelector('canvas');
await page.click('canvas');
const actions = [
() => page.keyboard.down('KeyW'),
() => page.waitForTimeout(200),
() => page.mouse.move(500, 300),
() => page.mouse.down({ button: 'left' }),
];
const fpsData = await page.evaluate(async (actions) => {
return new Promise((resolve) => {
const fps = [];
let lastTime = performance.now();
let frames = 0;
function measureFPS() {
frames++;
const now = performance.now();
if (now >= lastTime + 1000) {
fps.push(frames);
frames = 0;
lastTime = now;
if (fps.length >= 10) {
resolve(fps);
return;
}
}
requestAnimationFrame(measureFPS);
}
measureFPS();
});
}, []);
const avgFPS = fpsData.reduce((a, b) => a + b, 0) / fpsData.length;
expect(avgFPS).toBeGreaterThanOrEqual(55);
});
Helper Functions for Tests
Movement Helper
export async function moveForward(page: Page, durationMs: number) {
await page.keyboard.down('KeyW');
await page.waitForTimeout(durationMs);
await page.keyboard.up('KeyW');
}
export async function moveBackward(page: Page, durationMs: number) {
await page.keyboard.down('KeyS');
await page.waitForTimeout(durationMs);
await page.keyboard.up('KeyS');
}
export async function strafeLeft(page: Page, durationMs: number) {
await page.keyboard.down('KeyA');
await page.waitForTimeout(durationMs);
await page.keyboard.up('KeyA');
}
export async function strafeRight(page: Page, durationMs: number) {
await page.keyboard.down('KeyD');
await page.waitForTimeout(durationMs);
await page.keyboard.up('KeyD');
}
test('movement helpers work', async ({ page }) => {
await page.goto('http://localhost:3000');
await page.click('canvas');
await moveForward(page, 1000);
await strafeLeft(page, 500);
const position = await page.evaluate(() => (window as any).playerPosition);
expect(position.z).toBeLessThan(0);
expect(position.x).toBeLessThan(0);
});
Testing Checklist
For each gameplay feature:
Running Gameplay Tests
npm run test:e2e -- tests/e2e/gameplay-suite.spec.ts
npm run test:e2e -- -g "should move forward"
npm run test:e2e -- --headed
npm run test:e2e -- --debug
Server Management
⚠️ CRITICAL: Use shared-lifecycle skill for server management.
Server Detection (Before Gameplay Tests)
⚠️ IMPORTANT: Playwright's webServer config manages servers for E2E tests automatically.
When running npm run test:e2e, Playwright automatically starts:
npm run dev (port 3000) with reuseExistingServer: !process.env.CI
DO NOT manually start servers for E2E tests.
Server Check Pattern
netstat -an | grep :3000 || lsof -i :3000
curl -s http://localhost:3000 | grep -q "vite" && echo "RUNNING" || echo "NOT_RUNNING"
E2E Test Path (Standard Gameplay Validation)
npm run test:e2e -- tests/e2e/gameplay-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
TaskStop(task_id="abc123")
Before running gameplay 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
- Ports are released
- No orphaned processes remain
See also: shared-lifecycle skill for complete process management patterns.
References