| 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.).(initialPos.);
});
(, ({ page }) => {
page.();
page.();
initialPos = page.(
( ). || { : , : , : }
);
page..();
page.();
page..();
afterPos = page.(
( ). || { : , : , : }
);
(afterPos.).(initialPos.);
});
(, ({ page }) => {
page.();
page.();
initialPos = page.(
( ). || { : , : , : }
);
page..();
page.();
page..();
afterPos = page.(
( ). || { : , : , : }
);
(afterPos.).(initialPos.);
});
(, ({ page }) => {
page.();
page.();
initialPos = page.(
( ). || { : , : , : }
);
page..();
page.();
page..();
afterPos = page.(
( ). || { : , : , : }
);
(afterPos.).(initialPos.);
});
});
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 ). || { : , : , : }
);
(afterPos.).(initialPos.);
(afterPos.).(initialPos.);
});
(, ({ page }) => {
page.();
page.();
initialPos = page.(
( ). || { : , : , : }
);
page..();
page..();
page.();
page..();
page..();
afterPos = page.(
( ). || { : , : , : }
);
(afterPos.).(initialPos.);
(afterPos.).(initialPos.);
});
});
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 ). || { : , : , : }
);
(.(afterPos. - initialPos.)).();
});
(, ({ page }) => {
page.();
page.();
page..();
page..();
page..();
page.();
page..();
page..();
page..();
position = page.( ( ).);
(position.).();
(position.).();
});
});
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 || );
(afterHeight).(initialHeight);
});
(, ({ page }) => {
page.();
page.();
initialHeight = page.( ( ).?. || );
page..();
page.();
page..();
afterHeight = page.( ( ).?. || );
(afterHeight).(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', ({ page }) => {
page.();
page.();
isLocked = page.( {
. === .;
});
(isLocked).();
initialRotation = page.( ( ).?. || );
page..(, );
page.();
afterRotation = page.( ( ).?. || );
(afterRotation)..(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();
page.();
page..(, , { : });
actionState = page.(
( ).?. ||
);
(actionState).();
});
(, ({ page }) => {
page.();
page.();
page..({ : });
page.();
page..({ : });
attackType = page.( ( ). || );
(attackType).();
});
});
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();
page..();
page.();
shortJumpY = page.( ( ).?. || );
page..();
page.();
page..();
page..();
page.();
page..();
page.();
longJumpY = page.( ( ).?. || );
(longJumpY).(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?. ||
);
(afterInteract).();
});
(, ({ page }) => {
page.();
page.();
page..();
page.();
page..();
interactionComplete = page.(
( ). ||
);
(interactionComplete).();
});
});
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');
page..();
page.();
scoreboardVisible = page.().();
(scoreboardVisible).();
page..();
page.();
scoreboardHidden = page.().();
(scoreboardHidden).();
});
(, ({ page }) => {
page.();
page..();
inventoryVisible = page.().();
(inventoryVisible).();
page..();
inventoryClosed = page.().();
(inventoryClosed).();
});
});
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 ).?. || );
(comboCount).();
});
(, ({ page }) => {
page.();
page.();
page..();
page.();
page..();
page.();
page..();
page.();
page..();
page.();
page..();
page.();
page..();
lastAttack = page.( ( ).?. || );
(lastAttack).();
});
});
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.( ( ).?. || );
(spellActive)..();
});
});
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);
});
(, ({ page }) => {
page.();
page.();
initialY = page.( ( ).?. || );
page..();
page..();
page..();
page.();
afterY = page.( ( ).?. || );
(afterY).(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();
page.();
page..();
page..();
page.();
page..();
page..();
page.();
finalPos = page.(
( ). || { : , : , : }
);
(finalPos.)..(initialPos.);
(finalPos.)..(initialPos.);
(finalPos.)..(initialPos.);
});
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({ : });
finalAmmo = page.( ( ).?. || );
(finalAmmo).(initialAmmo);
hits = page.( ( ).?. || );
(hits).();
});
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 >= lastTime + ) {
fps.(frames);
frames = ;
lastTime = now;
(fps. >= ) {
(fps);
;
}
}
(measureFPS);
}
();
});
}, []);
avgFPS = fpsData.( a + b, ) / fpsData.;
(avgFPS).();
});
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);
page..();
}
() {
page..();
page.(durationMs);
page..();
}
(, ({ page }) => {
page.();
page.();
(page, );
(page, );
position = page.( ( ).);
(position.).();
(position.).();
});
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