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
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.