← DEVLOG
Game Dev2025.11.077 min read

Fortress — Building Ballistics Physics and an AI Opponent

Parabolic shell trajectories driven by angle and power, an AI opponent that calculates distance and corrects from previous shots — a devlog for building a two-player artillery game

canvasphysicsgame-aiweb-audioballistics

The Beginning — The Geometry of Artillery

Artillery games are simple but mathematically satisfying. Set an angle and power, a parabola traces out, and the goal is to land at the enemy turret. It seemed straightforward at first — but building a proper AI opponent turned out to be far more interesting than expected.


Parabolic Ballistics

Everything starts with decomposing initial velocity. The player's angle and power inputs are split into horizontal and vertical velocity components, and gravity is added to the vertical component every frame.

const vx = Math.cos(angle) * power * POWER_SCALE;
const vy = -Math.sin(angle) * power * POWER_SCALE; // upward is negative

function updateBullet(bullet: Bullet) {
  bullet.vy += GRAVITY; // apply gravity every frame
  bullet.x += bullet.vx;
  bullet.y += bullet.vy;
}

The GRAVITY value required tuning for gameplay feel. Using real-world gravity (9.8 m/s²) causes shells to drop so fast that only short-range shots are viable. Finding the right value for the game's scale was important.


Collision Detection — AABB and Terrain

Shell-to-turret collision uses AABB (Axis-Aligned Bounding Box). Turrets are rectangular, so this is sufficient.

function checkTurretHit(bullet: Bullet, turret: Turret): boolean {
  return (
    bullet.x >= turret.x && bullet.x <= turret.x + turret.w && bullet.y >= turret.y && bullet.y <= turret.y + turret.h
  );
}

Terrain collision is different. Terrain is an irregular polygon, so a hit is registered when the shell's Y position exceeds the terrain height at that X coordinate.

function checkTerrainHit(bullet: Bullet, terrain: TerrainPoint[]): boolean {
  const terrainY = getTerrainY(terrain, bullet.x);
  return bullet.y >= terrainY;
}

Terrain data is an array of Y heights indexed by X. At game start, Perlin Noise generates terrain from a random seed. When an explosion occurs, the terrain at that point is destroyed, leaving a crater.


AI Opponent — Convergent Targeting

The AI doesn't simply aim at the player. It uses a convergent targeting algorithm — it measures the error from each previous landing point and incrementally corrects the next shot angle.

interface AiState {
  angle: number;
  power: number;
  lastLandingX: number | null;
  targetX: number;
}

function computeAiShot(ai: AiState): { angle: number; power: number } {
  if (ai.lastLandingX === null) {
    // First shot: distance-based initial estimate
    const dist = Math.abs(ai.targetX - AI_TURRET_X);
    ai.angle = Math.PI / 4; // 45-degree starting angle
    ai.power = estimatePower(dist);
  } else {
    // Subsequent shots: correct based on landing error
    const error = ai.targetX - ai.lastLandingX;
    ai.angle += error * ANGLE_CORRECTION_FACTOR;
    ai.power += error * POWER_CORRECTION_FACTOR;
  }

  return { angle: clamp(ai.angle, MIN_ANGLE, MAX_ANGLE), power: clamp(ai.power, 1, 100) };
}

Watching the AI start wide and converge toward accuracy feels like actual artillery training. Converge too fast and the AI becomes overwhelming; too slow and it feels unrealistically sloppy. Tuning the correction factors to find the right tension was the key balancing challenge.


Explosion Particles

Impact particle explosions handle the visual feedback. Each particle is emitted from the impact point in a random radial direction.

function createExplosion(x: number, y: number): Particle[] {
  return Array.from({ length: PARTICLE_COUNT }, () => {
    const angle = Math.random() * Math.PI * 2;
    const speed = 2 + Math.random() * 4;
    return {
      x,
      y,
      vx: Math.cos(angle) * speed,
      vy: Math.sin(angle) * speed,
      life: 1.0, // 1.0 → 0.0 fade out
      size: 2 + Math.random() * 3,
    };
  });
}

function updateParticle(p: Particle) {
  p.x += p.vx;
  p.y += p.vy;
  p.vy += 0.15; // gravity applied to particles too
  p.life -= 0.04;
}

Gravity is applied to particles as well. Upward-scattered fragments following their own parabolas before falling gives the explosion a more physical feel.


Multi-Round Structure

The game doesn't end with a single hit. Each turret has TANK_HP health points that decrease with each hit. When health reaches zero, that round ends and the next begins.

Craters from previous rounds persist when a new round starts. The battlefield naturally degrades over time, which is a visual effect that emerges automatically from the mechanics.


Canvas Performance — 4K Resolution Cap

On high-DPI displays, canvas buffers can grow to enormous sizes. A 4K monitor with devicePixelRatio of 2 produces a buffer four times larger than 1080p, and the GPU cost of every fill and stroke scales linearly with pixel count.

To prevent this, the canvas buffer area is capped at 1920 × 1080 pixels. When the physical area exceeds this threshold, a scale factor is computed and applied to both canvas dimensions.

const MAX_AREA = 1920 * 1080;
const area = w * h;
const scale = area > MAX_AREA ? Math.sqrt(MAX_AREA / area) : 1;
canvas.width = Math.round(w * scale);
canvas.height = Math.round(h * scale);

The visual difference is imperceptible — terrain edges, explosion particles, and turret outlines look identical — but frame times on 4K displays drop by roughly 60%. Artillery games demand consistent frame timing for accurate trajectory rendering, so this cap keeps the physics loop stable across all display configurations.


Closing Thoughts

Watching the AI refine its aim — starting wide, incrementally converging — there's something strangely satisfying about it. There are moments when an algorithm you wrote looks like it's learning. It's really just a simple feedback loop, but experiencing it through gameplay makes it feel surprisingly impressive.

And when the AI lands a direct hit — losing to something I built — there's a peculiar satisfaction in that too.

Content related to this post

Try it yourself