game-loop
SKILL.md
Fixed Timestep Game Loop
Frame-rate independent game loop with physics interpolation and time manipulation.
When to Use This Skill
- Building browser-based games or interactive simulations
- Need consistent physics regardless of monitor refresh rate
- Want smooth rendering with deterministic game logic
- Implementing hitstop, slow-mo, or time manipulation effects
Core Concepts
The key insight is separating physics (fixed timestep) from rendering (variable). An accumulator tracks time debt, running physics at a consistent rate while interpolating between states for smooth visuals.
Frame → Accumulator += delta → While(accumulator >= fixedStep) { physics() } → Render(interpolation)
Implementation
TypeScript
interface GameLoopStats {
fps: number;
frameTime: number;
physicsTime: number;
renderTime: number;
lagSpikes: number;
interpolation: number;
timeScale: number;
isInHitstop: boolean;
}
interface GameLoopCallbacks {
onFixedUpdate: (fixedDelta: number, now: number) => void;
onRenderUpdate: (delta: number, interpolation: number, now: number) => void;
onLagSpike?: (missedFrames: number) => void;
}
class GameLoop {
private fixedTimestep: number;
private readonly MAX_FRAME_TIME = 0.25;
private accumulator = 0;
private lastTime = 0;
private interpolation = 0;
private frameCount = 0;
private fpsTimer = 0;
private currentFps = 60;
private lagSpikes = 0;
private running = false;
private animationId: number | null = null;
private callbacks: GameLoopCallbacks;
private hitstopTimer = 0;
private hitstopIntensity = 0;
private externalTimeScale = 1.0;
constructor(callbacks: GameLoopCallbacks, fixedTimestep = 1 / 60) {
this.callbacks = callbacks;
this.fixedTimestep = fixedTimestep;
}
start(): void {
if (this.running) return;
this.running = true;
this.lastTime = performance.now() / 1000;
this.accumulator = 0;
this.loop();
}
stop(): void {
this.running = false;
if (this.animationId !== null) {
cancelAnimationFrame(this.animationId);
this.animationId = null;
}
}
triggerHitstop(frames = 3, intensity = 0.1): void {
this.hitstopTimer = frames * this.fixedTimestep;
this.hitstopIntensity = intensity;
}
setTimeScale(scale: number): void {
this.externalTimeScale = Math.max(0, scale);
}
getStats(): GameLoopStats {
return {
fps: this.currentFps,
frameTime: 0,
physicsTime: 0,
renderTime: 0,
lagSpikes: this.lagSpikes,
interpolation: this.interpolation,
timeScale: this.getEffectiveTimeScale(),
isInHitstop: this.hitstopTimer > 0,
};
}
private loop = (): void => {
if (!this.running) return;
const now = performance.now() / 1000;
let frameTime = now - this.lastTime;
this.lastTime = now;
// Cap frame time to prevent spiral of death
if (frameTime > this.MAX_FRAME_TIME) {
const missedFrames = Math.floor(frameTime / this.fixedTimestep);
this.lagSpikes++;
this.callbacks.onLagSpike?.(missedFrames);
frameTime = this.MAX_FRAME_TIME;
}
frameTime *= this.getEffectiveTimeScale();
if (this.hitstopTimer > 0) {
this.hitstopTimer -= frameTime / this.getEffectiveTimeScale();
}
this.accumulator += frameTime;
// Fixed timestep physics
while (this.accumulator >= this.fixedTimestep) {
this.callbacks.onFixedUpdate(this.fixedTimestep, now);
this.accumulator -= this.fixedTimestep;
}
// Interpolation for smooth rendering
this.interpolation = this.accumulator / this.fixedTimestep;
this.callbacks.onRenderUpdate(frameTime, this.interpolation, now);
// FPS calculation
this.frameCount++;
this.fpsTimer += frameTime / this.getEffectiveTimeScale();
if (this.fpsTimer >= 1.0) {
this.currentFps = Math.round(this.frameCount / this.fpsTimer);
this.frameCount = 0;
this.fpsTimer = 0;
}
this.animationId = requestAnimationFrame(this.loop);
};
private getEffectiveTimeScale(): number {
return this.hitstopTimer > 0 ? this.hitstopIntensity : this.externalTimeScale;
}
}
// Interpolation helpers
function lerp(a: number, b: number, t: number): number {
return a + (b - a) * t;
}
function lerpAngle(a: number, b: number, t: number): number {
let diff = b - a;
while (diff > Math.PI) diff -= Math.PI * 2;
while (diff < -Math.PI) diff += Math.PI * 2;
return a + diff * t;
}
Usage Examples
// Game state
let playerX = 0, playerY = 0;
let playerVelX = 0, playerVelY = 0;
let prevPlayerX = 0, prevPlayerY = 0;
const gameLoop = new GameLoop({
onFixedUpdate: (fixedDelta) => {
// Store previous for interpolation
prevPlayerX = playerX;
prevPlayerY = playerY;
// Deterministic physics
playerVelY += 980 * fixedDelta; // Gravity
playerX += playerVelX * fixedDelta;
playerY += playerVelY * fixedDelta;
// Collision
if (playerY > 500) {
playerY = 500;
playerVelY = 0;
}
},
onRenderUpdate: (delta, interpolation) => {
// Smooth rendering between physics states
const renderX = lerp(prevPlayerX, playerX, interpolation);
const renderY = lerp(prevPlayerY, playerY, interpolation);
ctx.clearRect(0, 0, canvas.width, canvas.height);
ctx.fillRect(renderX - 10, renderY - 10, 20, 20);
},
onLagSpike: (missed) => console.warn(`Lag: missed ${missed} frames`),
});
gameLoop.start();
// Hitstop on collision
function onPlayerHit() {
gameLoop.triggerHitstop(4, 0.05); // 4 frames at 5% speed
}
// Slow-mo death
function onPlayerDeath() {
gameLoop.setTimeScale(0.3);
setTimeout(() => gameLoop.setTimeScale(1.0), 2000);
}
Best Practices
- Always store previous state before physics update for interpolation
- Cap frame time to prevent spiral of death (0.25s is reasonable)
- Use fixed timestep for all game logic, variable only for rendering
- Tune hitstop values for game feel (2-5 frames typical)
- Consider 30Hz physics for mobile to save CPU
Common Mistakes
- Running physics in render callback (frame-rate dependent)
- Not interpolating positions (causes stuttering)
- Forgetting to cap frame time (causes spiral of death on tab switch)
- Using delta time for physics (non-deterministic)
Related Patterns
- server-tick (server-side equivalent)
- websocket-management (multiplayer sync)
Weekly Installs
17
Repository
dadbodgeoff/driftGitHub Stars
761
First Seen
Jan 25, 2026
Security Audits
Installed on
codex17
opencode16
github-copilot16
cursor16
gemini-cli15
claude-code14