Browser games run in a rendering loop at 60 frames per second. A bug that fires once every 200 frames — a physics glitch, a memory leak, a dropped input — won’t show up in a unit test. Traditional web debugging tools help, but you need game-specific techniques layered on top.
This guide covers the practical toolkit: Chrome DevTools for performance, Spector.js for WebGL, structured logging for game state, and automated testing strategies that actually work for real-time games.
The Performance tab records a timeline of everything the browser does — JavaScript execution, rendering, compositing, and garbage collection. For games, the key metric is frame time: anything over 16.67ms means you dropped below 60fps.
Record 5–10 seconds of gameplay, then look for:
Take heap snapshots before and after a gameplay session. Compare them to find objects that should have been garbage collected but weren’t. Common culprits in games:
performance.mark() and performance.measure() to instrument specific game systems. You can then see custom markers in the DevTools timeline.
// Instrument your update loop
performance.mark('physics-start');
this.physics.step(dt);
performance.mark('physics-end');
performance.measure('Physics', 'physics-start', 'physics-end');
performance.mark('render-start');
this.renderer.draw();
performance.mark('render-end');
performance.measure('Render', 'render-start', 'render-end');
Raw console.log is noisy in a game loop. Instead, build a structured logger that categorizes messages and can be filtered:
const GameLog = {
_filters: new Set(['physics', 'input', 'ai', 'render']),
_enabled: true,
log(system, msg, data) {
if (!this._enabled || !this._filters.has(system)) return;
console.log(`[${system.toUpperCase()}] ${msg}`, data || '');
},
// Toggle in console: GameLog.toggle('physics')
toggle(system) {
this._filters.has(system)
? this._filters.delete(system)
: this._filters.add(system);
}
};
// Usage
GameLog.log('physics', 'Collision detected', { a: entity1.id, b: entity2.id });
GameLog.log('ai', 'NPC state change', { npc: 'farmer', from: 'idle', to: 'harvest' });
Expose GameLog on window during development so you can toggle systems from the browser console without reloading.
When something renders wrong — missing textures, incorrect colors, z-fighting — you need to inspect the actual WebGL draw calls. Spector.js is a browser extension that captures a single frame and shows you every WebGL command, shader, texture, and buffer.
What to look for in a Spector.js capture:
The most insidious game bug. Your game runs fine for 5 minutes, then starts stuttering. The usual pattern:
Fix: Use a proper entity lifecycle. When an entity is destroyed, explicitly remove it from every system — physics world, render list, event listeners, particle emitters. Consider an object pool for frequently created/destroyed objects like bullets or particles.
// BAD: dt will spike to seconds after tab switch
function update(timestamp) {
const dt = (timestamp - lastTime) / 1000;
lastTime = timestamp;
physics.step(dt); // dt = 30 seconds after returning from another tab
requestAnimationFrame(update);
}
// GOOD: clamp dt to prevent physics explosion
function update(timestamp) {
const raw = (timestamp - lastTime) / 1000;
const dt = Math.min(raw, 0.1); // cap at 100ms
lastTime = timestamp;
physics.step(dt);
requestAnimationFrame(update);
}
When users switch tabs and come back, requestAnimationFrame resumes with a massive delta time. Without clamping, physics objects teleport through walls and timers skip ahead.
Mobile browsers and some desktop GPUs will kill your WebGL context under memory pressure. If you don’t handle it, the game shows a black screen with no error message.
canvas.addEventListener('webglcontextlost', (e) => {
e.preventDefault(); // allows context restore
cancelAnimationFrame(rafId);
showMessage('Restoring graphics...');
});
canvas.addEventListener('webglcontextrestored', () => {
// Recreate all GL resources
initShaders();
reuploadTextures();
rebuildBuffers();
rafId = requestAnimationFrame(update);
});
You can’t easily unit-test rendering, but you can test game logic — and you should. The key is separating logic from rendering:
// game-logic.js — pure functions, no DOM/canvas
export function calculateDamage(attacker, defender) {
const base = attacker.strength * attacker.weapon.multiplier;
const reduced = Math.max(0, base - defender.armor);
const crit = attacker.critChance > Math.random() ? 2 : 1;
return Math.round(reduced * crit);
}
export function canPlantCrop(tile, crop, season) {
if (tile.type !== 'soil') return false;
if (tile.occupied) return false;
if (!crop.seasons.includes(season)) return false;
return true;
}
// game-logic.test.js
test('damage reduced by armor', () => {
const atk = { strength: 10, weapon: { multiplier: 1.5 }, critChance: 0 };
const def = { armor: 5 };
expect(calculateDamage(atk, def)).toBe(10); // (10*1.5)-5 = 10
});
Run these with any test runner (Vitest, Jest). They execute in milliseconds and catch regression bugs before they reach the canvas.
Mobile browser games break in unique ways — touch event ordering, viewport resizing, GPU differences. Use Chrome’s remote debugging:
chrome://inspect on your desktop ChromeFor iOS, use Safari’s Web Inspector (Develop menu → your device). Test on real hardware — iOS Safari’s WebGL implementation has quirks that no emulator reproduces.
| Symptom | First Check | Tool |
|---|---|---|
| Stuttering / frame drops | GC pauses or long JS execution | DevTools Performance tab |
| Black screen | WebGL context loss or shader error | Spector.js + context event listeners |
| Wrong colors / missing objects | Texture binding or draw order | Spector.js frame capture |
| Increasing memory usage | Entity lifecycle / disposed resources | DevTools Memory snapshots |
| Physics explosion after tab switch | Unclamped delta time | Structured logging on dt values |
| Touch not registering | preventDefault / passive listeners | Chrome remote debugging |
See these techniques in action in a real browser game.
Play FarmHeart