Claude-skill-registry game-loop
Fixed timestep game loop with interpolation for frame-rate independent physics. Separates physics updates from rendering, prevents spiral of death, and supports hitstop/slow-mo effects.
install
source · Clone the upstream repo
git clone https://github.com/majiayu000/claude-skill-registry
Claude Code · Install into ~/.claude/skills/
T=$(mktemp -d) && git clone --depth=1 https://github.com/majiayu000/claude-skill-registry "$T" && mkdir -p ~/.claude/skills && cp -r "$T/skills/data/game-loop" ~/.claude/skills/majiayu000-claude-skill-registry-game-loop && rm -rf "$T"
manifest:
skills/data/game-loop/SKILL.mdsource content
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)