Efe Anidi
Tools

Classical 2D mechanics: the design vocabulary AI game designers still need

Coyote time, jump buffering, top-down movement, tile grids, AABB collision, fixed timesteps and cameras: the 2D techniques that still decide how an AI-made game feels, why they anchor generative content, and how to prototype them with an LLM.

17 October 2026 · 13 min read

tools

Generative models are changing what fills a game: the text a character speaks, the music under a scene, and before long the moving image itself. They have not changed what it feels like to press a button and have something respond. That part is still made of a small, well-understood set of techniques from 2D games: how a jump rises and falls, how a character starts and stops, how two rectangles decide they have touched, how a camera keeps up. These form a vocabulary. If you know the words, you can ask a model for exactly what you want, and you can tell when you have not got it.

This piece goes through that vocabulary for AI game designers, explains why it still matters next to generative content and how it anchors that content, and ends with a small canvas platformer that implements the parts people and models most often get wrong, plus how to prototype these mechanics with an LLM.

Why AI game designers still need a mechanics vocabulary

There are three reasons classical 2D mechanics still matter, even for work centred on text-based generative games and generative audio.

Feel is not generated. A model can write a vivid description of a leap across a chasm. It cannot make the leap feel fair. Fairness comes from timing windows, acceleration curves and collision rules, measured in frames and pixels. Those numbers are design decisions, and they are where players decide whether a game respects them.

Mechanics are the stable layer. In any game that mixes generated content with interaction, something has to be deterministic: the rules that decide whether you landed, whether you were hit, whether the door is open. Generated material sits on top of that layer and is anchored by it: a generated room is only a room once collision says where its walls are. The more unpredictable the content, the more the underlying verbs need to be predictable. The same principle applies to interactive film and generative video, where the open question is precisely how input maps to response.

Names make prompts precise. “Make the jump feel better” gets you a guess. “Add 100 ms of coyote time, a 120 ms jump buffer and a heavier gravity multiplier when jump is released while rising” gets you something you can test. Every term in this article is a lever with a name.

Platforming: the jump is a design object

The jump is the most studied verb in 2D games, and most of what makes a good one is invisible. Maddy Thorson’s post Celeste & Forgiveness, originally a 2020 Twitter thread, is the clearest short list available. Thorson is explicit that the studio did not invent these techniques; they are shared craft. The ones worth using in almost every prototype:

  • Coyote time. You can still jump for a short time after walking off a ledge, named after the cartoon coyote who hangs in the air before noticing. Without it, players who press jump a few frames late feel cheated, because from their point of view they were on the ledge.
  • Jump buffering. If you press jump shortly before landing, the game remembers the press and jumps on the frame you touch the ground. Without it, fast players lose inputs.
  • Variable jump height. Holding the button gives a full jump; tapping gives a short hop. There are two common implementations: cut upward velocity when the button is released, or apply heavier gravity while rising without the button held. The second is better because it produces a smooth arc rather than a visible kink.
  • Apex modifiers. Celeste halves gravity near the top of a held jump, giving a little more hang time to line up a landing.
  • Corner correction. If the player’s head clips the corner of a block by a few pixels, nudge them sideways rather than stopping the jump dead.

Thorson sums up the pattern: all of these widen timing or positioning windows slightly in the player’s favour. That is the useful general principle. The physics does not have to be honest; it has to match what the player believes they did.

Two other values define a jump: its height and its duration. If you choose those first and derive gravity and launch speed from them, tuning becomes far easier. For constant gravity, peak height is v² / 2g and time to peak is v / g, so a designer can say “three tiles high, 0.35 seconds to the top” and compute the rest.

Top-down movement and grids

Top-down movement looks simpler than platforming but has its own vocabulary.

Acceleration and friction. Instant full speed feels twitchy; long acceleration feels like ice. Most top-down games use a short ramp up and a shorter ramp down. Turning is often given more acceleration than starting, so changing direction feels responsive.

Normalised diagonals. If you add an x input and a y input without normalising the vector, diagonal movement is about 1.41 times faster. It is the single most common bug in generated movement code.

Facing and aim. A top-down character usually needs a facing direction separate from its velocity, so it keeps facing the way it last moved when it stops. Whether that facing snaps to four directions, eight, or follows analogue input is a design choice with consequences for combat and readability.

Grids and tiles are the other branch. In a grid game, position is a pair of integers and movement is a discrete step: Sokoban (1982), roguelikes, many puzzle games and tactics games. Grids trade continuous feel for clarity. Every state can be printed, compared and saved, and every move is either legal or not.

That property is why grid games pair so well with language models. A grid state serialises to a few lines of text, which a model can read, reason about and propose moves for, while a small rules function decides legality. If you are building text-based generative games, a grid is often the right spatial layer underneath: the world is discrete, the descriptions are generated.

Even continuous games use tiles for level data. A tilemap is a 2D array; collision against it becomes a lookup of which tiles a rectangle covers, which is far cheaper than testing against every object.

Collision and physics-lite

Most 2D games do not need a physics engine. They need axis-aligned bounding boxes (AABBs) and a clear resolution rule.

Two AABBs overlap when they overlap on both axes:

const overlaps = (a, b) =>
  a.x < b.x + b.w && a.x + a.w > b.x && a.y < b.y + b.h && a.y + a.h > b.y;

Detecting overlap is easy. Resolving it is where bugs live. The reliable approach for character movement is to move one axis at a time: apply horizontal velocity, push out of anything you now overlap horizontally, then do the same vertically. Because you know which axis you just moved along, you know which way to push. Resolving both axes at once leads to characters snagging on the seams between adjacent floor tiles.

Shape test Cost Good for Watch out for
AABB vs AABB Very cheap Characters, tiles, platforms Rotation is not supported
Circle vs circle Very cheap Top-down characters, bullets Circles slide off corners
AABB vs tilemap Cheap lookup Platformers, grid worlds Tunnelling at high speed
Polygon (SAT) Moderate Slopes, rotated objects More code, more edge cases

“Physics-lite” is a useful name for what sits around that: velocity, gravity, a maximum fall speed, friction, and nothing else. It is enough for nearly every platformer and top-down game, and it is fully under your control, which matters when the goal is feel rather than realism.

The other half is time. If you advance the simulation by however long the last frame took, behaviour changes with frame rate: jumps get slightly higher or lower, and fast objects tunnel through thin walls on slow machines. The standard fix, described in Glenn Fiedler’s article Fix Your Timestep!, is a fixed timestep: accumulate real elapsed time and step the simulation in constant slices. The game then behaves identically at 60Hz and 144Hz, and a replay of the same inputs produces the same result, which is valuable for testing.

Cameras

The camera is a mechanic, not a viewport. Itay Keren’s GDC 2015 talk, written up as Scroll Back: The Theory and Practice of Cameras in Side-Scrollers, is the standard reference. It catalogues techniques such as:

  • Position-locking: the camera follows the player exactly. Simple, and often too twitchy.
  • Camera windows: the player moves freely inside a box, and the camera only moves when they push its edge.
  • Platform snapping: vertical camera movement happens only when the player lands, so jumping does not bob the whole screen.
  • Lookahead: shifting the view in the direction of travel so the player sees what is coming.
  • Smoothing: easing towards the target rather than snapping to it.

For smoothing, avoid camera.x += (target - camera.x) * 0.1 per frame, which tracks more tightly at higher frame rates. Either run it inside the fixed timestep or use a frame-rate-independent factor such as 1 - Math.exp(-k * dt).

Cameras are also where 2D thinking carries most directly into 3D world exploration: windows, lookahead and snapping all have 3D equivalents, and the same complaints (motion sickness, not being able to see what is ahead) apply.

A small platformer with coyote time and jump buffering

Here is a complete, single-file canvas example. It has a fixed 120Hz simulation step, per-axis AABB resolution, coyote time, jump buffering and variable jump height via heavier gravity on release. Save it as index.html and open it in a browser; it uses no libraries. Move with the arrow keys or A/D, and jump with Space, W or the up arrow. The player turns amber while airborne but still inside the coyote window, which is a useful debug view to keep.

<!doctype html>
<html lang="en">
<head>
  <meta charset="utf-8" />
  <title>Jump feel</title>
  <style>body { margin: 0; background: #111; } canvas { display: block; margin: 0 auto; background: #1b1f27; }</style>
</head>
<body>
<canvas id="c" width="640" height="360"></canvas>
<script>
const ctx = document.getElementById('c').getContext('2d');

// Tuning: pixels and seconds. Expose these; you will change them constantly.
const STEP = 1 / 120;        // fixed simulation step
const GRAVITY = 2400;
const LOW_JUMP_GRAVITY = 3;  // gravity multiplier while rising with jump released
const MOVE_SPEED = 220;
const JUMP_SPEED = 720;      // peak height about JUMP_SPEED^2 / (2 * GRAVITY) = 108 px
const MAX_FALL = 900;
const COYOTE_TIME = 0.1;     // seconds after leaving a ledge you may still jump
const BUFFER_TIME = 0.12;    // seconds an early jump press is remembered

const solids = [
  { x: 0,   y: 330, w: 640, h: 30 },
  { x: 120, y: 250, w: 140, h: 16 },
  { x: 330, y: 190, w: 120, h: 16 },
  { x: 500, y: 120, w: 100, h: 16 },
];
const p = { x: 40, y: 302, w: 20, h: 28, vx: 0, vy: 0, onGround: false, coyote: 0, buffer: 0 };

const keys = new Set();
const JUMP_KEYS = ['Space', 'ArrowUp', 'KeyW'];
let jumpPressed = false; // set on key down, consumed by the next simulation step
addEventListener('keydown', (e) => {
  if (JUMP_KEYS.includes(e.code)) { e.preventDefault(); if (!e.repeat) jumpPressed = true; }
  keys.add(e.code);
});
addEventListener('keyup', (e) => keys.delete(e.code));
const held = (...codes) => codes.some((c) => keys.has(c));

const overlaps = (a, b) =>
  a.x < b.x + b.w && a.x + a.w > b.x && a.y < b.y + b.h && a.y + a.h > b.y;

function step(dt) {
  p.vx = (held('ArrowRight', 'KeyD') - held('ArrowLeft', 'KeyA')) * MOVE_SPEED;

  // Timers: the buffer remembers an early press, coyote remembers recent ground.
  if (jumpPressed) { p.buffer = BUFFER_TIME; jumpPressed = false; }
  else p.buffer = Math.max(0, p.buffer - dt);
  p.coyote = p.onGround ? COYOTE_TIME : Math.max(0, p.coyote - dt);

  if (p.buffer > 0 && p.coyote > 0) {
    p.vy = -JUMP_SPEED;
    p.buffer = 0;
    p.coyote = 0; // no second jump from the same window
  }

  // Variable height: releasing jump while rising makes gravity heavier.
  const rising = p.vy < 0 && !held(...JUMP_KEYS);
  p.vy = Math.min(p.vy + GRAVITY * (rising ? LOW_JUMP_GRAVITY : 1) * dt, MAX_FALL);

  // Move and resolve one axis at a time (AABB against each solid).
  p.x += p.vx * dt;
  for (const s of solids) {
    if (overlaps(p, s)) p.x = p.vx > 0 ? s.x - p.w : s.x + s.w;
  }
  p.x = Math.max(0, Math.min(640 - p.w, p.x));

  p.y += p.vy * dt;
  p.onGround = false;
  for (const s of solids) {
    if (!overlaps(p, s)) continue;
    if (p.vy > 0) { p.y = s.y - p.h; p.onGround = true; }
    else p.y = s.y + s.h; // head bump
    p.vy = 0;
  }
}

function draw() {
  ctx.clearRect(0, 0, 640, 360);
  ctx.fillStyle = '#4a5568';
  for (const s of solids) ctx.fillRect(s.x, s.y, s.w, s.h);
  // Debug colour: amber while airborne but still inside the coyote window.
  ctx.fillStyle = !p.onGround && p.coyote > 0 ? '#f6ad55' : '#63b3ed';
  ctx.fillRect(p.x, p.y, p.w, p.h);
}

let acc = 0;
let last = performance.now();
function frame(now) {
  acc += Math.min((now - last) / 1000, 0.25); // avoid a spiral after a tab switch
  last = now;
  while (acc >= STEP) { step(STEP); acc -= STEP; }
  draw();
  requestAnimationFrame(frame);
}
requestAnimationFrame(frame);
</script>
</body>
</html>

A few notes on why it is written this way:

  • Jump presses are edge-triggered. jumpPressed is set on the key-down event and consumed by the simulation, rather than polled as “is the key down”. Polling would miss a tap that starts and ends between two steps, and would re-trigger jumps while the key is held.
  • Coyote and buffer are just two timers. The jump fires when both are positive. That one condition covers normal jumps, late jumps and early jumps.
  • Ground is detected by collision, not position. Gravity pushes the player a fraction of a pixel into the floor every step, the vertical pass pushes them back and sets onGround. It is simple and robust.
  • The clamp on accumulated time stops the loop running hundreds of catch-up steps after the tab has been in the background.

The timing behaviour was checked by stubbing the browser APIs and stepping the simulation in Node: a held jump peaks at roughly 105 px (slightly under the continuous-maths figure, because of discrete steps), a tap gives a hop of about a third of that, a jump pressed 50 ms after leaving a ledge still fires while one at 150 ms does not, and a press 80 ms before landing jumps on landing while one 250 ms early is dropped. Those are exactly the checks to ask for in any version of this.

What it leaves out on purpose: render interpolation between steps (worth adding if you see judder on high-refresh screens), acceleration, corner correction and slopes. Each is a good single follow-up request.

Prototyping mechanics with an LLM

Models are good at producing this kind of code, and also good at producing plausible versions that feel wrong in ways a quick test will not reveal. A few habits help.

Name the mechanic and give numbers. Ask for “coyote time of 0.1 seconds” rather than “forgiving jumps”. If you do not know the number, ask the model for a range and the reasoning, then pick.

Ask for tunables at the top. Every magic number in movement code should be a named constant in one block, in pixels and seconds. That turns tuning into editing a list rather than hunting through logic.

Ask for a debug view. Colour the player by state, draw the camera window, print the timers. A model can add these in a minute, and they make invisible mechanics visible, which is the only way to tune them.

Keep the simulation pure and testable. If step(dt) reads only state and input, you can run it headlessly, as above, and write small checks such as “a jump pressed 50 ms after leaving the ledge succeeds”. Ask the model to write those checks alongside the feature.

One mechanic per request. Coyote time, then buffering, then variable height. Bundled requests produce code where the interactions between features are the bugs.

A reusable prompt:

Here is the platformer step(dt) function and its constants (full code below). Add jump corner correction: if the player’s head hits the bottom corner of a solid by 4 px or less while rising, nudge them sideways out of it instead of stopping the jump. Add the threshold as a named constant. Do not change any other behaviour. Then write two headless test cases for it.

Takeaways

  • Classical 2D mechanics are the stable, deterministic layer that anchors AI-generated text, audio or video. Feel lives there, not in the content, and knowing the names is what lets you direct a model.
  • The jump vocabulary to know by name: coyote time, jump buffering, variable jump height, apex gravity, corner correction. They all widen windows in the player’s favour.
  • For top-down movement, normalise diagonals, use short acceleration ramps and keep facing separate from velocity. Grids trade feel for clarity and pair naturally with text and LLMs.
  • Use AABBs and resolve collisions one axis at a time; add a polygon solver only when you need slopes or rotation.
  • Run gameplay on a fixed timestep so behaviour is identical across frame rates and inputs can be replayed.
  • Treat the camera as a mechanic: windows, snapping, lookahead and frame-rate-independent smoothing.
  • When prototyping with an LLM, name the mechanic, give numbers, keep tunables in one block, ask for debug views and headless tests, and change one thing at a time.