Andi Smith

Technical Leader Product Engineer AI Consultant

Containment DX

- by Andi Smith

One of the earliest games we released at Natomic Studios was a 360 Pong variant called "Containment". It's been lost to time thanks to the advancement of technology, so I thought I'd have a go at rebuilding it on the web using AI.

Play Containment DX in your web browser. Best played in Chrome on desktop.

The original "Containment" game was created by Rich Hodgson and had quite a following on the high scoreboards on Natomic's website. In the game, the player controls both paddles and has to stop the ball from escaping the boundaries of the circle. As time progresses, the ball gets faster adding extra complexity.

You can watch a playthrough on YouTube. As you can see, the gameplay is fairly straightforward - the times were simplier back then!

Rebuilding in JavaScript with Generative AI

I thought it'd be an interesting challenge to see if we could easily replicate this game on the web with help from Claude and Cursor. For this project, my approach was to use Claude's web interface to build an initial version and then move over to Cursor when I needed to start editing the code. I then wanted to use AI to produce extra visual effects, audio and music.

Earlier in the day, I'd been trying to replicate a LucasArts adventure game with AI which had proved too much of a challenge to overcome in a few hours - but with this much simpler game and the assistance of Claude AI, it was very easy to get a simple remake of the core game concept working.

Once the initial gameplay was working, I then used Cursor AI to help me improve the original game with power-ups and particle effects, and other AI tools to assist with music, voice overs and sound effects. This article breaks down my journey building this game with AI.

Initial Prompt

After playing and reviewing the original game, I settled on the following prompt for Claude Sonnet 3.7:

I want to make a special version of Pong in JavaScript and Canvas 2D where the player controls both paddles and they rotate 360 degrees around a circle.

The paddles are controlled with the left and right cursor keys.

The ball needs to be contained within the circle and should bounce when colliding with the paddles. Each bounce should add 100 points to the users score. As the ball gets faster, a multiplier should be added to the score.

As time progresses, the ball should gradually get faster. (every 15 seconds)

The player starts with three lives. If the ball leaves the play area, the player loses a life.

When they have lost all their lives, they are presented with a game over screen and their score.

Before starting the game, there should be a title screen with the word "Containment" and "Press Any Key to Start" on it.

This got me started with an initial version of the game with circular paddles and a ball that bounced around the screen. I then needed to provide AI instructions for some adjustments to the initial gameplay - e.g. slow down the ball as it's unplayable!

I chose to use Canvas 2D as it allows me to manipulate the entire screen like a bitmap, and is perfectly suitable for a 2D game.

Drawing and Animating the 360° Circular Paddles

The game uses a circular arena with paddles that rotate around its perimeter. Let's take a look at how this is implemented:

Paddle Initialization

The arena is positioned at the center of the canvas, with a radius that ensures it fits within the canvas boundaries, and the paddles are initialised to fit in that.

function initPaddles() {
  paddles = [];
  const angleStep = (2 * Math.PI) / paddleCount;

  for (let i = 0; i < paddleCount; i++) {
    paddles.push({
      angle: i * angleStep,
      length: paddleBaseLength,
      width: paddleWidth,
      active: true,
    });
  }
}

This function creates paddles evenly distributed around the circle. Each paddle has:

  • An angle (in radians) representing its position
  • A length (arc length along the circle)
  • A width (thickness)

Rotating Paddles

function updatePaddles() {
  if (rightPressed) {
    paddles.forEach((paddle) => {
      paddle.angle += paddleRotationSpeed;
    });
  } else if (leftPressed) {
    paddles.forEach((paddle) => {
      paddle.angle -= paddleRotationSpeed;
    });
  }
}

When the player presses the left or right arrow keys, all paddles rotate in the corresponding direction at a constant speed.

Drawing Paddles

function renderGame() {
  paddles.forEach((paddle) => {
    const paddleAngle = paddle.angle;
    const paddleLength = paddle.length;

    const halfLength = paddleLength / 2;
    const startAngle = paddleAngle - halfLength / arenaRadius;
    const endAngle = paddleAngle + halfLength / arenaRadius;

    ctx.beginPath();
    ctx.arc(centerX, centerY, arenaRadius, startAngle, endAngle);
    ctx.lineWidth = paddle.width;
    ctx.strokeStyle = "#FFF";

    ctx.stroke();
  });
}

The challenge here is that the paddles are drawn as arc segments on the arena's perimeter rather than the tradional rectangles. To do this:

  1. We calculate the start and end angles for each paddle
  2. The formula paddleAngle ± (halfLength / arenaRadius) converts the paddle's linear length to an angular span
  3. The ctx.arc() method draws the arc segment at the arena's radius
  4. We set lineWidth to control the paddle's thickness
  5. Special effects like the flashing colors I added later for power ups are applied conditionally

Patterned Paddles

There was another challenge with the paddles. In the original game, they were represented with yellow and black diagonal lines as traditionally seen in construction signs. I really wanted to implement this by my canvas was drawing an arc.

function createHazardPattern() {
  const patternCanvas = document.createElement("canvas");
  const patternContext = patternCanvas.getContext("2d");

  patternCanvas.width = 20;
  patternCanvas.height = 20;

  patternContext.fillStyle = "#FFD700";
  patternContext.fillRect(0, 0, 20, 20);

  patternContext.fillStyle = "#000000";
  patternContext.beginPath();
  for (let i = -20; i < 40; i += 20) {
    patternContext.moveTo(i, 0);
    patternContext.lineTo(i + 10, 0);
    patternContext.lineTo(i + 30, 20);
    patternContext.lineTo(i + 20, 20);
    patternContext.closePath();
  }
  patternContext.fill();

  return ctx.createPattern(patternCanvas, "repeat");
}

This code draws a hazard pattern which can be applied to the paddles. However, we couldn't apply it directly as the pattern needed to rotate as the paddles rotated! So instead, we clip the paddle shape and apply a pattern to a rectangle drawn around the paddle.

paddles.forEach((paddle) => {
  // as above
  const paddleAngle = paddle.angle;
  const paddleLength = paddle.length;

  const halfLength = paddleLength / 2;
  const startAngle = paddleAngle - halfLength / arenaRadius;
  const endAngle = paddleAngle + halfLength / arenaRadius;

  ctx.save();

  const outerRadius = arenaRadius + paddle.width / 2;
  const innerRadius = arenaRadius - paddle.width / 2;

  ctx.beginPath();
  ctx.arc(centerX, centerY, outerRadius, startAngle, endAngle);
  ctx.lineTo(
    centerX + innerRadius * Math.cos(endAngle),
    centerY + innerRadius * Math.sin(endAngle)
  );
  ctx.arc(centerX, centerY, innerRadius, endAngle, startAngle, true);
  ctx.closePath();

  ctx.clip();

  ctx.translate(centerX, centerY);
  ctx.rotate(paddleAngle);
  ctx.fillStyle = hazardPattern;
  ctx.fillRect(
    -arenaRadius * 1.5,
    -arenaRadius * 1.5,
    arenaRadius * 3,
    arenaRadius * 3
  );

  ctx.restore();
});

Handling Collisions

The collision system determines what happens when a ball hits (or misses) a paddle.

Ball Movement

  ball.x += Math.cos(ball.angle) * ball.speed * speedMultiplier;
  ball.y += Math.sin(ball.angle) * ball.speed * speedMultiplier;

  const dx = ball.x - centerX;
  const dy = ball.y - centerY;
  const distance = Math.sqrt(dx * dx + dy * dy);

  if (distance + ball.radius > arenaRadius) {
    if (!checkPaddleCollision(ball)) {
      // Ball escaped, handle life loss...
    }
  }
}

For each ball:

  1. We update its position based on angle and speed
  2. We calculate its distance from the center
  3. If the ball reaches the arena boundary, we check for paddle collisions

For the movement, the game uses polar coordinates converted to Cartesian (x,y) movement. The ball.angle represents the direction of travel in radians (0 to 2π) with Math.cos(ball.angle) calculating the x-component of movement and Math.sin(ball.angle) calculates the y-component.

To help visualise the movement, consider:

  • When ball.angle = 0: Ball moves right (cos(0)=1, sin(0)=0)
  • When ball.angle = π/2 (90°): Ball moves down (cos(π/2)=0, sin(π/2)=1)
  • When ball.angle = π (180°): Ball moves left (cos(π)=-1, sin(π)=0)
  • When ball.angle = 3π/2 (270°): Ball moves up (cos(3π/2)=0, sin(3π/2)=-1)

Paddle Collision Detection

function checkPaddleCollision(ball) {
  const dx = ball.x - centerX;
  const dy = ball.y - centerY;
  const ballAngle = Math.atan2(dy, dx);

  for (const paddle of paddles) {
    let angleDiff = (ballAngle - paddle.angle) % (2 * Math.PI);
    if (angleDiff < 0) angleDiff += 2 * Math.PI;
    if (angleDiff > Math.PI) angleDiff = 2 * Math.PI - angleDiff;

    const arcLength = angleDiff * arenaRadius;

    if (arcLength <= paddle.length / 2) {
      return true;
    }
  }

  return false;
}

The collision detection has several key steps:

  1. Calculate the angle between the center and the ball's position
  2. For each paddle, find the angular difference between the ball and paddle
  3. Convert this angular difference to an arc length along the arena boundary
  4. If this arc length is less than half the paddle's length, a collision occurred

Power-up System

The original Containment game didn't include power-ups. When I played around with the original, it felt like it would benefit from such a feature. Originally power-ups spawned from the middle circle in the same way the ball did. But it wasn't satisifying and it felt too difficult to obtain a power up. So instead, I moved the power ups to appear in an empty space around the arena boundary, and so can be collected by paddles as they rotate. Power ups include gaining an extra life or getting a bigger paddle, but can also include negative effects like a faster ball or smaller paddle. As there are dual paddles, it becomes a challenge for the user to avoid certain power-ups. However, successfully using these negative power ups provides a larger score increase.

Spawning Power-ups

Power-ups spawn with these characteristics:

  1. A random type is selected
  2. The system tries to position them away from paddles (to prevent immediate collection)
  3. They appear exactly on the arena boundary
  4. They have visual effects (fade in/out) and a limited lifetime
function spawnPowerUp() {
  const randomIndex = Math.floor(Math.random() * powerUpTypes.length);
  const powerUpType = powerUpTypes[randomIndex];

  let angle;
  let validPosition = false;
  let maxAttempts = 10;
  let attempts = 0;

  const posX = centerX + Math.cos(angle) * arenaRadius;
  const posY = centerY + Math.sin(angle) * arenaRadius;

  // Create the power-up
  powerUps.push({
    x: posX,
    y: posY,
    type: powerUpType,
    expiresAt: Date.now() + 2000,
    opacity: 0,
    fadeDirection: "in",
    fadeSpeed: 0.05,
  });
}

Power-up Collision Detection

For collision detection, we calculate the angular distance between the power-up and the paddle. There's a lot of Math involved here, and Claude AI was great in doing the heavy lifting.

for (const paddle of paddles) {
  let angleDiff = Math.abs(angle - paddle.angle) % (Math.PI * 2);
  if (angleDiff > Math.PI) angleDiff = Math.PI * 2 - angleDiff;

  const arcLength = angleDiff * arenaRadius;

  if (arcLength <= paddle.length / 2 + powerUpRadius) {
    activatePowerUp(powerUp.type);

    showPowerUpMessage(powerUp.type);

    powerUps.splice(i, 1);
    break;
  }
}

The collision detection is similar to ball collisions, but with a more generous collision area.

Each power-up:

  1. Applies an immediate effect (changing paddle size, adding balls, etc.)
  2. May have visual feedback (flashing)
  3. For timed effects, schedules a deactivation

Particles

Although I've written a fair number of particle systems in my time, I got AI to help me write all of the particle systems for this project. It wrote it far quicker than I would and created more impressive effects.

After playing around with space starfield background for a while, I asked AI to produce a Bokeh effect for the background instead which looked really cool. When implementing changing the colours of the Bokeh effect when the score changed, I accidentally stumbled across a way to make the background interact with the game. I had to tone down the intensity as it started to make me feel sick, but I think the tamer version helps tie everything together.

Music and Sound Effects

For the music, I wanted to create something quite basey. I took inspiration from Disclosure - King Steps. I asked Claude AI to summarise the beat and sounds of the song.

Please describe the beat and sounds of King Steps by Disclosure. Ignore the vocals, imagine you had to ask someone to replicate the music. Describe the sound in 200 characters.

It output the following:

Four-on-the-floor house beat with crisp hi-hats & punchy kicks. Deep, wobbling bass groove creates momentum. Atmospheric synth pads float overhead while melodic synth hooks provide catchy motifs.

I then took that prompt in to AI Music.so and generated two tracks which are used in the game (chosen at random).

Sound effects came from Mixkit.co which is an awesome resource that doesn't require a sign in for a lot of sounds.

I decided the power ups needed to be more prominently announced, so I used ElevenLabs text-to-speech to produce spoken words for each. The voice is Brian on a very slow speed.

Conclusion

In the days gone past, building a indie game like Containment would either mean skipping extra visual or sound effects, or spending a unreasonably large amount of time on getting it right.

AI has helped me recreate this game which had been lost to old versions of Windows, and I'm thankful for that. However, if you start to dig around the source code you'll see it has become quite a mess. I decided to stay relatively hands-off in this project, only intervening when it was easier to make a manual edit (e.g. update a power up icon) or one bizarre time where Cursor told me I needed to go and manually remove a bracket as it couldn't do it.

So there's still a way to go with AI, but given this project wouldn't exist at all without AI I can live with some messy code.

Play Containment DX here!

--

By Andi Smith