Game Loop Architecture skill for Codex
Fixed ticks, smooth frames, no spiral of death. Codex separates simulation from rendering with a fixed-timestep accumulator, interpolates visuals between ticks, clamps long frames so hitches cannot snowball, and latches input so no press is lost or doubled. Game flow becomes a push-down state stack with queued transitions, and it proves frame-rate independence by running the same scenario at 30, 60 and 144 Hz.
curl -fsSL codexskills.dev/install.sh \ | sh -s -- game-loop-architecture Installs to ~/.agents/skills/game-loop-architecture/ for every project on this machine.
curl -fsSL codexskills.dev/cli.mjs -o codex-skills.mjsnode codex-skills.mjs install game-loop-architecture The CLI also searches, updates and removes skills. Run it with no arguments to pick from a list. Needs Node 20.12+.
curl -fsSL codexskills.dev/install.sh \ | sh -s -- --project game-loop-architecture Installs to .agents/skills/game-loop-architecture/ in the current repository. Commit it and everyone who runs Codex there gets it.
curl -fsSLO codexskills.dev/downloads/game-loop-architecture.zipunzip -o game-loop-architecture.zip -d ~/.agents/skills/ The archive holds the skill folder with its generated agents/openai.yaml. Unzip into .agents/skills/ to scope it to a repo.
& ([scriptblock]::Create((irm codexskills.dev/install.ps1))) game-loop-architecture PowerShell 5.1 or 7. Installs to %USERPROFILE%\.agents\skills\. Add -Project for the current repo.
104-line POSIX script. No sudo, no telemetry. Read install.sh
Use it in Codex
Start a prompt with $game-loop-architecture, or pick it from /skills.
You can also just describe the task: Codex reads the description below and loads the playbook when your request matches.
What Codex reads before loading it
Designs and fixes the core loop of a game - fixed-timestep simulation with an accumulator, render interpolation, spiral-of-death clamps, pause and time scale, input sampling per tick, finite state machines and pushdown scene stacks for game flow, and update ordering between systems. Use when movement speed depends on frame rate, physics explodes after a hitch, a custom engine or framework needs a main loop, game states (menu, play, pause, game over) are tangled in if-statements, or replays and netcode need deterministic ticks.
Use it when
- Gameplay speed or physics changes with frame rate
- Writing the main loop for a browser, Pygame, SDL or custom engine game
- Game states and screens are tangled in flags and nested ifs
- Preparing a simulation for replays or deterministic networking
Use something else when
- You are designing enemy decision making: use game-ai
- You need rollback or client prediction: use multiplayer-netcode
How to check the result
The same scenario takes the same ticks and wall time at 30, 60 and 144 Hz, a forced 2-second stall recovers cleanly, and paused time advances no ticks.
The mistake it prevents
Using lerp(a, b, 0.1) per frame for smoothing, which silently makes camera and motion behave differently on every refresh rate.
Done well means: simulation advances in fixed ticks independent of display rate, rendering interpolates between the last two simulation states, a long hitch cannot snowball into a freeze, input is sampled into each tick exactly once, and game flow is an explicit state machine where every transition has one place it happens.
First, find out what owns the loop
| Environment | Loop owner | Your job |
|---|---|---|
| Unity | Engine: Update (variable) + FixedUpdate (fixed, 0.02 s default) | Put simulation in fixed callbacks; interpolate visuals (Rigidbody interpolation) |
| Godot | Engine: _process + _physics_process (60 Hz default) | Same split; enable physics interpolation in project settings (2D in 4.3+, 3D in 4.4+) |
| Unreal | Engine: variable Tick; physics substepping optional | Use DeltaSeconds; enable substepping for physics stability |
| Bevy | Update + FixedUpdate schedules | Put simulation in FixedUpdate; read Time<Fixed> overstep for interpolation |
| Browser (Canvas, Pixi, custom) | You, via requestAnimationFrame | Write the accumulator loop below |
| Pygame, SDL, raylib, custom C++ | You | Same loop; add sleep or VSync to avoid spinning a core |
Read AGENTS.md and the existing main loop before changing anything. In engines, do not
write a second loop; fix where code runs.
The fixed-timestep loop
const TICK_HZ = 60;
const DT = 1 / TICK_HZ; // seconds of simulation per tick
const MAX_FRAME = 0.25; // clamp: never simulate more than 250 ms after a hitch
let prev = performance.now() / 1000;
let acc = 0;
let previous = cloneState(state); // for interpolation
function frame(nowMs: number) {
const now = nowMs / 1000;
let frameTime = now - prev;
prev = now;
if (frameTime > MAX_FRAME) frameTime = MAX_FRAME; // spiral-of-death guard
acc += frameTime * timeScale; // timeScale 0 = paused, 0.5 = slow-mo
while (acc >= DT) {
const input = inputSampler.sample(); // one snapshot per tick
copyState(previous, state);
simulate(state, input, DT); // deterministic given state+input
tickCount++;
acc -= DT;
}
const alpha = acc / DT; // 0..1 between previous and current
render(previous, state, alpha);
requestAnimationFrame(frame);
}
requestAnimationFrame(frame);
- Simulation never reads wall-clock time; it only knows
DTandtickCount. renderlerps positions (and slerps rotations) betweenpreviousandstatebyalpha. Without it, a 60 Hz sim on a 144 Hz display stutters.- Choose
TICK_HZby need: 30 for turn-like or slow games, 60 for action, 120+ only for physics that needs it. Higher tick rates cost CPU linearly. - Browsers throttle
requestAnimationFramein background tabs; the clamp keeps the game from trying to catch up minutes of time on return. Pause onvisibilitychangeinstead.
Variable vs fixed: decision rules
| Code | Where |
|---|---|
| Movement, physics, AI decisions, cooldowns, anything affecting gameplay outcome | Fixed tick |
| Camera smoothing, particles, UI tweens, animation blending | Per frame with real dt |
| Input polling | Per frame into a buffer; consumed per tick |
| Networking send/receive | Fixed tick (send rate may be lower, e.g. every 2nd tick) |
Input sampling without lost presses
A press and release can both happen between two ticks, or several ticks can run in one frame. Latch edges:
class InputSampler {
private held = new Set<string>();
private pressedSinceTick = new Set<string>();
onKeyDown(k: string) { if (!this.held.has(k)) this.pressedSinceTick.add(k); this.held.add(k); }
onKeyUp(k: string) { this.held.delete(k); }
sample() {
const snap = { held: new Set(this.held), pressed: new Set(this.pressedSinceTick) };
this.pressedSinceTick.clear(); // consumed by exactly one tick
return snap;
}
}
Add a jump buffer (accept jump pressed up to ~6 ticks before landing) and coyote time (accept jump up to ~6 ticks after leaving a ledge) in simulation, counted in ticks.
Game flow as a state machine
Replace if (gameState === "playing" && !paused && !inCutscene) chains with explicit states:
interface GameState {
enter(prev?: GameState): void;
exit(): void;
tick(input: InputSnapshot, dt: number): void;
render(alpha: number): void;
/** true = states below also render (pause menu over gameplay) */
readonly transparent?: boolean;
}
class StateStack {
private stack: GameState[] = [];
push(s: GameState) { this.top()?.exit(); this.stack.push(s); s.enter(); }
pop() { this.stack.pop()?.exit(); this.top()?.enter(); }
replace(s: GameState) { this.stack.pop()?.exit(); this.stack.push(s); s.enter(); }
top() { return this.stack.at(-1); }
tick(i: InputSnapshot, dt: number) { this.top()?.tick(i, dt); } // only the top simulates
render(alpha: number) {
let from = this.stack.length - 1;
while (from > 0 && this.stack[from].transparent) from--;
for (let i = from; i < this.stack.length; i++) this.stack[i].render(alpha);
}
}
Menu -> replace(Play),Play -> push(Pause),Pause -> pop(),Play -> replace(GameOver).- Transitions requested during
tickshould be queued and applied after the tick, so a state never exits halfway through its own update. - For entity behaviour (enemy patrol/chase/attack) use the same pattern per entity, or a behaviour tree when states multiply (see game-ai).
Update order within a tick
Write the order down and make it explicit in code, not implied by registration order:
1. Apply input / AI intents
2. Movement and physics integration
3. Collision detection and response
4. Gameplay reactions (damage, pickups, triggers) from collision events
5. Spawn and despawn queued entities
6. Advance timers and cooldowns
Spawning or destroying during iteration is the classic bug: queue structural changes and apply them at step 5.
Gotchas
- Multiplying by
dtinside a fixed tick is still correct (and keeps tuning in per-second units); forgetting it in variable-rate code is the frame-rate-dependence bug. - Exponential smoothing with
lerp(a, b, 0.1)per frame is frame-rate dependent. Uselerp(a, b, 1 - Math.exp(-rate * dt)). setIntervalloops drift and ignore VSync; userequestAnimationFramein browsers.- Accumulating float time (
time += dt) for hours loses precision; count integer ticks and derive time astick * DT. - Pausing by skipping
simulatebut still advancing timers in render code desyncs cooldowns. - Determinism (replays, lockstep) also needs a seeded RNG owned by the simulation and no iteration over unordered hash maps; see multiplayer-netcode.
Verification
- Run at 30, 60 and 144 Hz display rates (or throttle with an artificial frame delay): a character crossing the screen takes the same number of ticks and the same wall time.
- Inject a 2-second stall (
while (performance.now() < t + 2000) {}in a debug key): the game resumes without a burst of hundreds of ticks or physics explosions. - Record inputs for 60 seconds and replay them: final state hash matches if the game claims determinism.
- Push Pause, pop it, and check no ticks ran while paused (tick counter unchanged).
Hand-off summary
**Loop:** 60 Hz fixed tick, 250 ms clamp, render interpolation (alpha) for transforms
**Input:** edge-latched sampler, jump buffer 6 ticks, coyote 6 ticks
**Flow:** StateStack with Menu, Play, Pause (transparent), GameOver; transitions queued
**Order:** intents -> integrate -> collide -> react -> spawn/despawn -> timers
**Checked:** 30/60/144 Hz parity, 2 s stall recovery, 60 s replay hash match What the zip contains
SKILL.md plus the interface file below. Nothing runs on install.
agents/openai.yaml
Gives the Codex app a display name, a one-line description and a starter prompt for this skill.
interface:
display_name: "Game Loop Architecture"
short_description: "Fixed ticks, smooth frames, no spiral of death"
default_prompt: "$game-loop-architecture My character moves faster on my 144 Hz monitor, fix the game loop" About this skill
Written and maintained by the Codex Skills editors to the same editorial standard as every skill here: a selective description, a real workflow, known pitfalls and a way to verify the work. First published , last updated . MIT licensed. Something wrong or out of date? Tell us.