SpaceInvaders

By Erick Engelke
January 13, 2025

My daughter recently wrote a PONG game in Python, and so I challenged her to write a Space Invaders clone as an exercise.

I decided to make a simple EWB based version from scratch. The sample can be found at: SpaceInvaders and the souce can be downloaded at Zip file.

The rest of this blog post describes the basic code.

Where to start…​ I found a PNG file with a bunch of Space Invader aliens, so I load that into memory and split it up into different alien sprites.

sprites

So, we have image1, which is a TImage with the URL set to that image, and its OnLoad handler cuts it into 9 images .

procedure TForm1.Image1Load(Sender: TObject);
var
   i, row, col : integer;
   x, y : integer;
   image : TImage2;
begin
   image := TImage2.Create( self );
   image.parent = self;
   image.url := image1.URL;
   image.visible := false;
   i := 0;
   setlength( icons, 9 );  // there are 9 icons in the image
   for row := 0 to 2 do begin
      for col := 0 to 2 do begin
         x := col * 400;
         y := row * 400;
         icons[ i] := CopySubImage( image, y, x, 399, 399 );
         inc( i );
      end;
   end;
end;

Okay, TImage2 type is an enhanced TImage type which is a cracker class that exports ImageELement which is normally hidden from other code. ImageElement is needed so we can use DrawImage in the code which separates it into smaller images.

// cracker class
type
   TImage2 = class( TImage )
   public
      property imageelement : TImageElement read fImageElement;
   end;

function TForm1.CopySubImage( img : TImage2 ; row, col, xheight, xwidth : integer ):TImage;
var
  paint : TPaint;
begin
  result := TImage.Create( form1 );
  result.parent := form1;
  result.visible := False;

  result.width := xwidth;
  result.height := xheight;

  paint := TPaint.Create( form1 );
  paint.Width := xwidth;
  paint.Height := xheight;
  paint.visible := False;
  paint.Canvas.DrawImage( img.imageelement, col, row, xwidth, xheight , 0, 0, paint.width, paint.height );

  result.url := paint.canvas.ConvertToDataURL('png', 100 );
end;

Now that we have separated the Aliens into images, we need to draw them as sprites on the screen.

DrawSpriteAt lets you assign or reuse a sprite (number spritenum), using icon number inconnum, at the location (x,y).

The first bit of code sees if we have assigned that sprite, if not, it makes the new sprite.

Then the common code just places it wherever you want on the screen.

// Show the sprites where wanted
procedure TForm1.DrawSpriteAt( spritenum, iconnum, x, y : integer);
var
   spr : TImage;
begin
   if not Assigned( sprites[ spritenum ] ) then begin
      spr: = TImage.Create( form1 );
      spr.width := width div 25;  // resize the sprite
      spr.height := spr.width;
      spritewidth := spr.width;
      spr.parent := form1;
      sprites[ spritenum ] := spr;
      spr.url :=  icons[ iconnum ].url;
      spr.border.left.visible := False;
      spr.border.right.visible := False;
      spr.border.top.visible := False;
      spr.border.bottom.visible := False;
      spr.contentlayout.size := cscontain;
   end;

   spr := sprites[ spritenum ];
   spr.Top := y;
   spr.Left := x;
end;

We also need to make walls above the shooter to protect him from aliens. The walls block bullets, but are eaten away upon being hit by a bullet.

We define a wall element as a TLabel with a single coloured X, and we hide that element once it is struck by a bullet.

procedure TForm1.MakeWalls;
var
  i, j : integer;
  wall : TLabel;
begin
  //
  setlength( walls, 50 );
  for i := 0 to 10 do begin
     for j := 0 to 5 do begin
        wall := TLabel.Create( self );
        wall.parent := self;
        wall.Background.fill.color := clgreen;
        wall.Top := player.top - 20;
        wall.caption := 'X';
        wall.left := i*(width div 10 )  + wall.width*j;
        walls[i*10 + j] := wall;
      end;
   end;
end;

When the user clicks the start button, we call StartGame.

// draw initial sprites
procedure TForm1.Startgame;
var
  spr, row, col : integer;
begin
  MakeWalls;

  // define and place the sprites in their location
  spr := 0;
  setlength( sprites, 50 );

  form1.BeginUpdate;
  for row := 0 to 5 do begin
     for col := 0 to 10 do begin
        DrawSpriteAt( spr, row, col * spritewidth*2, row * spritewidth  );
        inc( spr );
     end;
  end;
  Form1.EndUpdate;

  // make an array of bullets which will travel, they
  setlength( bullets, 10 );

  // set the default direction to +1, or right
  // later we will switch to -1 or left
  spritedirection := 1;

  // update the positions every 10 ms
  timer1.Interval := 10;
  timer1.Enabled := True;

  // speed is the number of pixels every moves ever 10 ms
  speed := 4;
  // at level 1 bullets per interval is 1, high levels
  // add more bullets per interval
  bulletspersecond := 1;

  // note the start time, we will up the level after
  // a period of time, making it harder
  starttime := now;
  points := 0;

end;

Timer1 is the workhorse, it does all the animation.

procedure TForm1.Timer1Timer(Sender: TObject); var i : integer; spr : TImage; nextspritedirection : integer; bullet : TBullet; secondsactive :integer; begin updatelable;

secondsactive := ( Integer(now) - Integer(starttime )) div 1000;
if secondsactive > 30 then begin
   inc( bulletspersecond );
   starttime := now;
   TimerFire.interval := TimerFire.Interval - 250;
   updatelable;
end;
nextspritedirection := spritedirection;
beginupdate;
for i := 0 to length( sprites ) do begin
   if assigned( sprites[i] ) and sprites[i].visible then begin
      spr := sprites[i];
      spr.left := spr.left + 5 * spritedirection;
      if spr.left > width - spritewidth then
         nextspritedirection :=  -1;
      if spr.left < spritewidth then
         nextspritedirection := 1;
   end;
end;
// update bullets
for i := 0 to length( bullets ) do begin
  if assigned( bullets[i] ) then begin
     bullet := bullets[i];
     // only process if the bullet
     // is visible, otherwise it's inactive
     if bullet.visible then begin
        // bullets in either direction can hit walls
        testbullethitwall( bullet );
// did that change it?
if not bullet.visible then continue;
        if bullet.directiondown then begin
            bullet.Top := bullet.Top + speed;
            // did it hit
            testbulletHitPlayer( bullet );
            // did it go off the bottom of screen
            // into oblivion
            if bullet.top > height then
                 bullet.visible := False;
        end else begin
           bullet.Top := bullet.Top - speed;
           // did it hit
           testbulletHitAlien( bullet );
           // did it go off top of screen into oblivion
           if bullet.top < 1 then
                bullet.visible := false;
        end;
     end;
  end;
end;
endupdate;
  spritedirection := nextspritedirection;
end;

Now we need a way to start bullets, either downward from the aliens, or upward from the shooter/player.

procedure TForm1.FireFrom( img : TControl; downward : boolean );
var
   bullet : TBullet;
   i : integer;
begin
   bullet := TBullet.Create( form1 );
   bullet.parent := form1;

   // find a spare bullet in the array, 50 is more
   // than enough
   for i : = 0 to length( bullets ) do begin
      if not assigned( bullets[i] ) or not bullets[i].visible then begin
          bullets[i] := bullet;
          break;
      end;
   end;

   bullet.caption := '|';
   if downward then
      bullet.font.color := clRed
   else
      bullet.font.color := clBlack;

   bullet.visible := True;
   bullet.top := img.Top;
   bullet.left := img.Left + img.Width div 2 ;

   bullet.directiondown := downward;
end;

Our second timer fires a random bullet from a random alien.

procedure TForm1.FireFromRandom;
var
   i, rnd : integer;
   spr : TImage;
   cnt : integer;
begin
   // we see how many bullets per interval, by default
   // it just is one.

   for cnt := 1 to bulletspersecond  do begin
      // we cycle through aliens, looking for one which
      // is still visible and active.  Dead aliens can't
      // fire!!!
      for i := 0 to 50 do begin
         rnd := random( 0, length( sprites ));
         if Assigned( sprites[rnd] ) then begin
            spr := sprites[rnd];
            if spr.visible then begin
               firefrom( spr, True );
               break;
            end;
          end;
      end;
   end;
end;

procedure TForm1.TimerFireTimer(Sender: TObject);
begin
   firefromrandom;
end;

We need to test bullets to see if they hit a wall.

procedure TForm1.TestBulletHitWall( bullet : TBullet );
var
   wall : TLabel;
   i : integer;
begin
   for i := 0 to length( walls ) do begin
       wall := walls[i];
       if assigned( wall ) and wall.visible then begin
          if not ((bullet.top >= wall.top )
             and ( bullet.top <= wall.top+wall.height )) then
                exit;  // they are all at the same Top

          if (bullet.left >= wall.left) and
             ( bullet.left <= wall.left+ wall.width ) then begin
              bullet.visible := false;
              wall.visible := false;
          end;
       end;
    end;
end;

The procedures to test if the bullet hit any Alien or the Player are similar

procedure TForm1.TestBulletHitPlayer( bullet : TBullet );
begin
  //
  if ( bullet.top + bullet.height >= player.Top) and ( bullet.top < player.top + player.height) then begin
     if ( bullet.left >= player.left) and (Bullet.left <= (player.left + player.width )) then begin
        timer1.Enabled := False;
        TimerFire.Enabled := False;

        showmessage( 'You''re hit');
     end;
  end;
end;

procedure TForm1.TestBulletHitAlien( bullet : TBullet );
var
  i : integer;
  spr : TImage;
begin
  //
  for i := 0 to length( sprites ) do begin
      if assigned( sprites[i] ) then begin
         spr := sprites[i];
         if spr.visible then begin
             if ( bullet.top >= spr.top ) and ( bullet.top <= spr.top + spr.height )
              and ( bullet.left >= spr.left ) and ( bullet.left <= spr.left + spr.width ) then begin
                  spr.visible := false;
                  bullet.visible := false;  // cancel the bullet so it doesn't strike more than one
            end;
         end;
      end;
   end;
end;

How do we handle mouse movement?[source,]

procedure TForm1.Form1MouseMove(Sender: TObject; ShiftKey: Boolean;
                                CtrlKey: Boolean; AltKey: Boolean;
                                X: Integer; Y: Integer);
begin
  player.left := x;

end;

That was simple. We also catch the MouseDown and the Keystroke event handlers to fire.

function TForm1.Form1KeyPress(Sender: TObject; Key: Char; ShiftKey: Boolean;
                              CtrlKey: Boolean; AltKey: Boolean): Boolean;
begin
   firefrom( player, False );
end;

procedure TForm1.Button1Click(Sender: TObject);
begin
  button1.Visible := False;

  startgame;
end;

procedure TForm1.Form1MouseDown(Sender: TObject; Button: Integer;
                                ShiftKey: Boolean; CtrlKey: Boolean;
                                AltKey: Boolean; X: Integer; Y: Integer);
begin
   firefrom( player, false );
end;

I’ve skipped a couple of obvious functions, but that, in a nutshell, is the game.

Late Breaking News…​

I’ve just added to more calls to support iPad’s and certain other touch-capable devices.

procedure TForm1.Form1TouchMove(Sender: TObject; ShiftKey: Boolean;
                                CtrlKey: Boolean; AltKey: Boolean;
                                X: Integer; Y: Integer);
begin
  player.left := x;
end;

procedure TForm1.Form1TouchEnd(Sender: TObject; ShiftKey: Boolean;
                               CtrlKey: Boolean; AltKey: Boolean; X: Integer;
                               Y: Integer);
begin
  firefrom( player, false );

end;