AgentGames

Search titles, descriptions, models and tags. Press Enter to see results.

🕹️ Guide

How to make a 2D game with AI

A model will hand you a working 2D game on the first try. It will also hand you the same five bugs every time, and all five live in about forty lines of code you can fix once and reuse forever.

Updated 8 September 2026 9 min read By the Agent Games team
Jump to a section
  1. Canvas, DOM or a framework?
  2. The game loop, and why speed is wrong on half of all devices
  3. Making the canvas sharp
  4. Input that works on a keyboard and a thumb
  5. Collision, and the bug where bullets pass through walls
  6. Screens, and being able to lose
  7. Art when you have no artist
  8. The forty lines that make it feel good
  9. Keeping the score
  10. Prompts for six 2D genres
  11. Common questions
  12. Where to go next

For a 2D game written by AI, ask for a single HTML file using the Canvas 2D API, not the DOM and not a framework. Then fix the five things that come back wrong every time. Movement tied to frame rate, a canvas that is soft on retina screens, keyboard-only input, collision that lets fast objects pass through walls, and no way to restart after losing.

This guide is the code for all five. It is the same forty lines every time, so it is worth having once and pasting into the first prompt for every game you build after.

Start here if you have not built one yet

This guide assumes you already have a game that runs and want it to be good. If you are at the beginning, how to make a game with AI covers the brief, the first prompt and the working loop.

Canvas, DOM or a framework?#

This is the first decision and it changes everything downstream. For a game written by a model into one file, Canvas 2D wins on every axis that matters.

ApproachWhere it is goodWhy it loses here
Canvas 2DAnything with more than about twenty moving things. One drawing surface, full control, no layout engine in the way.Nothing, for this use. It is the right default.
DOM elementsBoard games, card games, anything grid-shaped that barely animates. Buttons and text are free and accessible.The browser lays out every element every frame. Forty moving divs will stutter on a phone where forty canvas sprites will not.
Phaser, Kaboom, PixiJSLarge projects with a team and real assets.Adds a dependency to load, an API version the model may half-remember, and a failure mode where nothing renders and there is no error.
WebGL by handThousands of sprites, or custom shaders.Enormously more code for a result nobody can distinguish at this scale.

The framework row is the trap. A model that half-remembers an older API version produces a file that loads, throws nothing, and draws a blank screen, which is the hardest kind of bug to describe back to it.

Say it explicitly in the prompt

Write use the Canvas 2D API directly, no game framework, no external libraries. Left unsaid, models reach for Phaser surprisingly often, and a CDN link is one outage away from your game being a white rectangle.

The game loop, and why speed is wrong on half of all devices#

This is the single most common fault in AI-written games and it is invisible on the machine you are testing on. If movement is written as x += 3, the game moves three pixels per frame. Phones commonly run at 90Hz or 120Hz, so your game is up to twice as fast there as on your 60Hz monitor.

Why the same game is twice as fast on a 120Hz screen Frames arrive whenever the browser is ready a slow frame Counting frames x += 3 moves three pixels per frame, so the player is twice as fast on a 120Hz laptop as on a 60Hz one, and crawls whenever a frame runs long. Counting seconds x += 180 * dt moves 180 pixels per second on every machine, because dt is how long the last frame actually took. The part that is still missing Alt-tab for a minute and dt is 60. The player teleports through every wall. dt = Math.min((now - last) / 1000, 0.05) caps the damage at one twentieth of a second.
Movement has to be expressed per second, not per frame. The clamp in the third panel is the part almost everyone leaves out, and it is what stops a backgrounded tab from teleporting the player through a wall.
javascriptThe loop, with the two lines that are usually missing
const CONFIG = { maxStep: 0.05 };   // never simulate more than 50ms at once

let last = performance.now();
let raf = 0;

function frame(now) {
  raf = requestAnimationFrame(frame);

  // Elapsed seconds, clamped. Without the clamp, returning to a backgrounded
  // tab produces a single 60-second step and everything tunnels through
  // everything else.
  const dt = Math.min((now - last) / 1000, CONFIG.maxStep);
  last = now;

  update(dt);
  render();
}
raf = requestAnimationFrame(frame);

// Stop simulating when nobody is looking, and reset the clock on the way back
// in. Forgetting the reset is what produces the one enormous step.
document.addEventListener('visibilitychange', () => {
  if (document.hidden) {
    cancelAnimationFrame(raf);
    raf = 0;
  } else if (!raf) {
    last = performance.now();
    raf = requestAnimationFrame(frame);
  }
});

Every speed in the game is then expressed in units per second, which also makes them readable. speed: 180 means 180 pixels per second, and you can reason about whether that is fast.

javascriptMovement, before and after
// Frame-dependent: twice as fast on a 120Hz screen.
player.x += 3;
enemy.y += 1.5;
if (--cooldown <= 0) fire();

// Time-based: identical everywhere.
player.x += 180 * dt;        // 180 pixels per second
enemy.y += 90 * dt;          // 90 pixels per second
cooldown -= dt;
if (cooldown <= 0) { fire(); cooldown = 0.25; }   // every 250ms
The half fix is worse than no fix

Models often convert the obvious movement to delta time and leave the timers counting frames. You then get a game where the player moves correctly and enemies spawn twice as fast. When you ask for this fix, say every speed, timer, cooldown and spawn interval.

Making the canvas sharp#

A canvas has two sizes: how big it is on the page, and how many pixels are in its drawing buffer. Set only the first and the browser stretches a low-resolution image to fit, which on any phone or retina laptop means everything is slightly soft.

Why the canvas looks soft on a phone and a MacBook What a first pass writes canvas.width = 400 400 real pixels stretched over 800 What the screen actually has canvas.width = 400 * dpr 800 real pixels, drawn at 800 Set the buffer to CSS size times devicePixelRatio, then ctx.setTransform(dpr, 0, 0, dpr, 0, 0) so your code keeps using CSS units.
Left: a 400-pixel buffer stretched across 800 physical pixels. Right: an 800-pixel buffer drawn at 800. Same code, one extra line, and the second one does not look cheap.
javascriptResolution-correct sizing that survives being called twice
const canvas = document.getElementById('game');
const ctx = canvas.getContext('2d');
let W = 0, H = 0;   // logical size, in CSS pixels: use these everywhere

function resize() {
  // Capped at 2. A 3x phone costs nine times the fill rate for a difference
  // nobody can see, and it is the first thing to drop frames.
  const dpr = Math.min(window.devicePixelRatio || 1, 2);
  const rect = canvas.getBoundingClientRect();

  W = rect.width;
  H = rect.height;
  canvas.width = Math.round(W * dpr);
  canvas.height = Math.round(H * dpr);

  // setTransform, not scale. scale() multiplies onto whatever is already
  // there, so calling resize() twice makes everything four times too big.
  ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
}

window.addEventListener('resize', resize);
resize();
ctx.scale on every resize is a real bug

It is the version models write most often, and it looks correct because the first call is correct. Rotate a phone once and the whole game doubles in size. Ask for setTransform specifically.

For a deliberately pixelated look, add ctx.imageSmoothingEnabled = false and image-rendering: pixelated in the CSS. Without both, upscaled pixel art comes out blurry rather than chunky.

Input that works on a keyboard and a thumb#

Ask for touch support in the first prompt and you will still often get keyboard only. The fix is one small layer that both input methods write into, so the game logic never asks where a movement came from.

javascriptOne input layer for keyboard, mouse and touch
const keys = new Set();

addEventListener('keydown', (e) => {
  // Stop arrow keys and space scrolling the page under the game.
  if (['ArrowLeft','ArrowRight','ArrowUp','ArrowDown',' '].includes(e.key)) e.preventDefault();
  keys.add(e.key.toLowerCase());
});
addEventListener('keyup', (e) => keys.delete(e.key.toLowerCase()));

// Pointer events cover mouse, touch and stylus in one API. There is no reason
// to write separate touchstart and mousedown handlers any more.
let pointer = null;
canvas.addEventListener('pointerdown', (e) => {
  canvas.setPointerCapture(e.pointerId);
  pointer = { x: e.offsetX, y: e.offsetY, down: true };
});
canvas.addEventListener('pointermove', (e) => {
  if (pointer) { pointer.x = e.offsetX; pointer.y = e.offsetY; }
});
canvas.addEventListener('pointerup',     () => { pointer = null; });
canvas.addEventListener('pointercancel', () => { pointer = null; });

// The game asks these, and never asks which device answered.
const wantsLeft  = () => keys.has('arrowleft')  || keys.has('a');
const wantsRight = () => keys.has('arrowright') || keys.has('d');
const wantsFire  = () => keys.has(' ') || (pointer && pointer.down);
The one CSS line without which touch does not work

canvas { touch-action: none; }. Leave it out and every drag scrolls the page instead of moving the player, and the game feels broken on exactly the devices most people will open it on. Models omit it very often, because on a desktop it changes nothing.

Two more small things worth asking for. user-select: none on the canvas stops a long press selecting text and popping the copy menu mid-game. And an explicit tap target for a fire button beats a drag gesture for anything the player does more than twice a second.

🕹️2D arcade games made with AI

Published here, playable without an account. Each page lists the prompt behind the game.
View all ›

Collision, and the bug where bullets pass through walls#

Almost every 2D game needs exactly two tests. Models write both correctly. What they miss is that both are tested only at the position the object landed on this frame, which fails as soon as anything moves faster than its own size.

The only two collision tests most 2D games need Boxes (AABB) a.x < b.x+b.w && a.x+a.w > b.x && … Four comparisons. Use it for platforms and walls. Circles dist dx*dx + dy*dy < (r1+r2) * (r1+r2) No square root. Use it for bullets and pickups. Ask for boxes when things stack and rest on each other, circles when things fly past each other.
Boxes for things that stack and rest on each other, circles for things that fly past each other. The circle test avoids a square root by comparing squared distances, which is worth doing when you run it a thousand times a frame.
javascriptThe two tests
// Boxes: {x, y, w, h}, measured from the top-left corner.
const hitBox = (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;

// Circles: {x, y, r}, measured from the centre. No Math.sqrt: comparing
// squared distances gives the same answer for a fraction of the cost.
const hitCircle = (a, b) => {
  const dx = a.x - b.x;
  const dy = a.y - b.y;
  const r  = a.r + b.r;
  return dx * dx + dy * dy < r * r;
};

Tunnelling: the bug that only appears when things get fast#

A bullet travelling 1200 pixels per second moves 20 pixels in a single frame. A wall 8 pixels thick is simply not there on either the frame before or the frame after, so the bullet passes straight through. This is why it always shows up late, after you tuned the speed up.

javascriptSubstepping: move in slices no larger than the object
function moveAndCollide(obj, dt, walls) {
  const dist  = Math.hypot(obj.vx, obj.vy) * dt;
  // Never take a step bigger than the object itself.
  const steps = Math.max(1, Math.ceil(dist / (obj.r || obj.w || 4)));
  const sub   = dt / steps;

  for (let i = 0; i < steps; i++) {
    obj.x += obj.vx * sub;
    obj.y += obj.vy * sub;
    for (const w of walls) {
      if (hitBox(obj, w)) return w;   // stop at the first thing hit
    }
  }
  return null;
}
How to ask for this

Bullets pass through thin walls at high speed. Substep the movement so no single step is larger than the object's own radius, and test collision at each substep. Naming the technique gets a correct fix in one pass; describing the symptom alone usually gets the wall made thicker.

Separating axes so the player does not stick to walls#

In a platformer, resolving both axes at once produces a character that catches on the seams between floor tiles. Move on X, resolve X, then move on Y, resolve Y. It is two lines of restructuring and it removes a class of bug that is very hard to describe.

Screens, and being able to lose#

A first build often drops the player straight into play with no title screen and no way back after losing. The whole fix is one variable, and asking for it up front costs nothing.

javascriptA state machine small enough to not be worth a library
let mode = 'title';   // 'title' | 'playing' | 'paused' | 'dead'

function update(dt) {
  if (mode !== 'playing') return;
  // ... all game logic lives behind this one guard
}

function render() {
  drawWorld();
  if (mode === 'title')  drawOverlay('Press space to start');
  if (mode === 'paused') drawOverlay('Paused');
  if (mode === 'dead')   drawOverlay('Score ' + score + ' · best ' + best, 'Press space to play again');
}

// reset() must put back every single thing start() changed. The classic bug
// is a second run that begins at the difficulty the first run ended on.
function reset() {
  score = 0;
  lives = CONFIG.startLives;
  elapsed = 0;
  spawnRate = CONFIG.baseSpawnRate;
  entities.length = 0;
  spawnPlayer();
  mode = 'playing';
}
Check the second run, not the first

The most common bug after adding a restart is that difficulty, timers or the spawn rate carry over. Play, lose deliberately, play again, and see whether run two starts where run one started or where it ended.

Art when you have no artist#

You do not need image files, and asking for them is actively harmful. A model given permission to use images will reference files that do not exist. You get a blank screen and no error.

Shapes, which is what most arcade games are anyway#

A triangle ship, rough polygons for rocks, glowing circles for pickups. Say no external images, draw everything with canvas paths and fills and you get something coherent instead of something missing.

Emoji, pre-rendered once#

Emoji are a free sprite sheet that ships with every device. Drawing them with fillText every frame is slow, so render each one to a small offscreen canvas at load and blit that instead.

javascriptAn emoji sprite, drawn once and reused
function emojiSprite(char, size) {
  const c = document.createElement('canvas');
  c.width = c.height = size;
  const x = c.getContext('2d');
  x.font = Math.floor(size * 0.82) + 'px serif';
  x.textAlign = 'center';
  x.textBaseline = 'middle';
  x.fillText(char, size / 2, size / 2);
  return c;   // drawImage() this, it is just a canvas
}

const SPRITES = {
  ship:  emojiSprite('\u{1F680}', 48),
  rock:  emojiSprite('\u{1FAA8}', 40),
  fuel:  emojiSprite('\u26FD',    32),
};
Emoji do not look the same everywhere

The same character is a different drawing on Windows, Android, iOS and Linux, and a few render as a blank box on older devices. Fine for a jam game or a prototype. If the look matters, use shapes.

The forty lines that make it feel good#

Two 2D games with identical rules can feel completely different, and the difference is almost entirely in how the game responds to the player in the fifty milliseconds after an input. None of this is hard. It is just never in a first build.

javascriptAcceleration, friction, screen shake and hit stop
// Acceleration and friction. The single biggest change to how a game feels
// under the hand, and it is four lines.
const ACC = 1800, FRICTION = 0.86, MAX = 320;
if (wantsLeft())  player.vx -= ACC * dt;
if (wantsRight()) player.vx += ACC * dt;
player.vx *= Math.pow(FRICTION, dt * 60);          // frame-rate independent
player.vx  = Math.max(-MAX, Math.min(MAX, player.vx));
player.x  += player.vx * dt;

// Screen shake. Decays on its own, so callers just add to it.
let shake = 0;
const addShake = (n) => { shake = Math.min(shake + n, 18); };

function render() {
  ctx.save();
  if (shake > 0.4) {
    ctx.translate((Math.random() - 0.5) * shake, (Math.random() - 0.5) * shake);
    shake *= Math.pow(0.02, dt);                   // gone in about 200ms
  }
  drawWorld();
  ctx.restore();
  drawHud();                                       // never shake the HUD
}

// Hit stop: freeze the simulation briefly on a big impact. Reads as weight.
let freeze = 0;
function update(dt) {
  if (freeze > 0) { freeze -= dt; return; }
  // ...
}
// on a heavy hit: freeze = 0.06; addShake(12);

If you only add one of these, add acceleration and friction. Instant velocity is the thing that makes a game feel like a demo rather than a game.

Never shake the HUD

Apply the shake translate before drawing the world and restore before drawing the score. A shaking score is nauseating and it is the default outcome if the shake wraps the whole render.

Keeping the score#

One key, wrapped in a try. Storage throws in private windows and when a browser is set to block site data, and an unguarded read is a game that fails to start for a small slice of players.

javascriptPersistence that cannot break the game
const KEY = 'dodge.best';

function loadBest() {
  try { return Number(localStorage.getItem(KEY)) || 0; }
  catch { return 0; }          // private window, or site data blocked
}

function saveBest(n) {
  try { localStorage.setItem(KEY, String(n)); }
  catch { /* nothing to do, and nothing worth interrupting play for */ }
}

let best = loadBest();
function onGameOver() {
  if (score > best) { best = score; saveBest(best); }
  mode = 'dead';
}
Namespace the key

Use yourgame.best, not best. On any site that hosts multiple games on one origin, a bare key is shared with every other game there, and two games will overwrite each other's scores.

Prompts for six 2D genres#

Each of these is a complete first message. They assume the technical requirements block from the prompt guide, which is worth appending to all of them.

PromptEndless runner
One self-contained .html file, Canvas 2D, no libraries.

Endless side-scrolling runner. The player auto-runs right and jumps obstacles.
Space or tap to jump, hold for a higher jump, and a second tap in the air for
a double jump. Ground obstacles of two heights plus a flying one that must be
ducked (down arrow or swipe down).

Distance is the score. Speed increases 6% every 10 seconds up to double the
starting speed. Three parallax background layers drawn with shapes only.
Death is a single collision; show distance, best distance, and a one-key
restart. Best distance in localStorage.

Coyote time of 100ms after leaving a ledge, and a jump buffer of 120ms, so
the jump feels forgiving. Everything drawn with canvas shapes, no images.

Coyote time and jump buffering are why a platformer feels responsive rather than strict. Ask for them by name; almost no first build includes them.

PromptMatch-3 puzzle
One self-contained .html file, Canvas 2D, no libraries.

An 8x8 match-3 grid of six coloured gem types. Click or tap two adjacent gems
to swap. A swap that creates no line of three or more reverts with a small
animation. Matches clear, gems above fall in, new gems drop from the top, and
cascades chain and score more.

Scoring: 3 gems = 30, 4 = 60, 5+ = 120, and each cascade step multiplies by
1.5. Sixty-second timer, +2 seconds per match. Show score, timer and best.

The board must never generate with matches already present, and must always
have at least one legal move; reshuffle if it does not. Animate falls and
swaps over about 180ms with an ease-out curve, not instantly.

The last paragraph prevents the two bugs every match-3 first build has: a board that starts mid-cascade, and a deadlocked board with no legal move.

PromptTower defence
One self-contained .html file, Canvas 2D, no libraries.

A fixed path across the screen drawn from waypoints. Enemies walk it in waves.
Click a buildable tile to place one of three towers: rapid short range, slow
heavy, and a slowing tower. Towers cost money, kills pay money.

Ten waves with rising enemy health and count, plus a boss on wave 10. Twenty
lives, one lost per enemy that reaches the end. Show wave number, money,
lives and a Start Wave button; do not auto-start waves.

Towers target the enemy furthest along the path, not the nearest. Draw a
range circle on hover before placing. Enemies show a health bar only when
damaged. Everything drawn with shapes.

Targeting the furthest-along enemy rather than the nearest is the standard behaviour and it changes the whole difficulty balance. Models default to nearest unless told.

PromptTop-down twin-stick shooter
One self-contained .html file, Canvas 2D, no libraries.

Top-down arena shooter. WASD moves, mouse aims, click or hold to fire. On
touch, a left thumbstick moves and a right thumbstick aims and fires; draw
both sticks only when a touch is active.

Enemies spawn at the arena edges and move toward the player. Three types:
fast and weak, slow and tough, and one that fires back. Waves get larger
every 20 seconds. One health bar, five hits.

Bullets must substep their movement so they cannot pass through enemies at
high speed. Add screen shake on hits, particle bursts on kills, and 60ms of
hit stop when the player is damaged. Score, best score in localStorage.
PromptSnake, with the modern conveniences
One self-contained .html file, Canvas 2D, no libraries.

Classic snake on a 24x24 grid. Arrow keys or WASD; on touch, swipe. The snake
moves on a fixed tick, starting at 8 ticks per second and speeding up by 4%
per food eaten, capped at 20.

Buffer the next direction input rather than applying it immediately, so a fast
double turn is never dropped and a 180-degree reversal is rejected. Food never
spawns inside the snake. Growing by one segment per food.

Death on hitting a wall or yourself, with a half-second pause before the
game-over screen so the player sees what happened. Score, best in
localStorage, one-key restart. Smoothly interpolate segment positions between
ticks so movement does not look like it is stuttering.

Input buffering and interpolation between ticks are the two things that separate a snake that feels good from one that feels like 1997. Both are commonly missing.

PromptPhysics sandbox
One self-contained .html file, Canvas 2D, no libraries and no physics
engine. Write the physics directly.

Circles fall under gravity into a bowl-shaped container. Click or tap to drop
a new circle of random size and colour at that position. Circles collide with
each other and the container with elastic response and a restitution of 0.6.

Use Verlet integration with 4 solver iterations per frame for stability, and
substep the simulation so fast circles cannot pass through the container wall.
Cap the count at 400 circles, removing the oldest.

Show the circle count and the frame rate. Everything drawn with shapes.

Naming Verlet integration and a solver iteration count is the difference between a stable pile and circles that jitter and explode. This is the case where naming the algorithm matters most.

🎈More 2D games to take apart

Open one, then open its page to read the prompt that produced it.
View all ›

Common questions#

Should I use a game engine like Phaser for an AI-built 2D game?

Not for a single-file game. A framework adds a dependency that can fail to load. Worse, a model that half-remembers an older API version produces a file that throws no error and renders nothing. That is the hardest kind of fault to debug through a chat window. Canvas 2D has no version and no CDN.

Why is my game faster on my phone than my laptop?

Movement is written per frame instead of per second. Your phone is probably 90Hz or 120Hz against your laptop's 60Hz. Multiply every speed, timer, cooldown and spawn interval by elapsed seconds, and clamp that value to about 0.05 so a backgrounded tab does not produce one huge step.

Why does my canvas look blurry?

The drawing buffer is at CSS size while the screen has two or three physical pixels per CSS pixel. Set canvas.width and canvas.height to the CSS size multiplied by devicePixelRatio, then use ctx.setTransform(dpr, 0, 0, dpr, 0, 0). Use setTransform rather than scale, because scale compounds if resize runs more than once.

Why do my bullets pass through walls?

Collision is only tested at each frame's end position, and a fast bullet jumps further in one frame than the wall is thick. Break the movement into substeps no larger than the object itself and test at each one. This appears late because it only starts happening once you tune the speed up.

Touch controls do not work even though I asked for them. Why?

Usually one missing CSS line: canvas { touch-action: none; }. Without it the browser treats a drag as a page scroll and never gives the events to your game. It is easy to miss because it changes nothing on a desktop.

How many objects can a canvas game handle?

A few thousand simple shapes at 60fps on a mid-range phone, if you are not creating garbage each frame. The usual cause of slowdown is not the object count but allocating new objects and arrays inside the loop. Reuse a pool of objects and reuse arrays instead of rebuilding them.

How do I add sound without any audio files?

Use the Web Audio API to generate tones with an oscillator. A blip, a thud and a sweep cover most arcade needs in about thirty lines. Create or resume the AudioContext inside the first click or keypress, because browsers block audio until the player has interacted with the page.

Where to go next#

Ready-to-send prompts for ten more games, plus a repair prompt for each of the faults above, are in the prompt guide. For a third dimension, the 3D guide covers three.js and the settings that stop a scene looking like a student demo.

When something is worth playing, put it on Agent Games. It gets its own page, a leaderboard, and a record of the model and prompt behind it.