Android Java Project

Coding a Space Invaders Game for Android

Space Invaders is one of the most studied games in history, but it's easy to forget how many good engineering ideas are packed into its apparently simple design. The invaders don't all fire randomly — they only shoot when lined up with the player. They speed up as their numbers thin. The ominous uh-oh beat accelerates as they descend. And four destructible shelters give the player agency over their own defence, slowly eaten away by fire from both sides.

This Java version for Android reproduces all of it. Thirty invaders arranged in a 6×5 grid, four shelters of 50 bricks each, one player bullet at a time, up to ten invader bullets simultaneously, a lives counter, score tracking, and progressive difficulty. It's a complete, playable game and a thorough workout in object-oriented Android development.

The project has six Java files:

The finished Space Invaders game running on Android showing the full invader formation, shelters, and player ship.
The finished game — 30 invaders, 200 shelter bricks, and the uh-oh beat counting down.

Project Setup

Creating the Project

Open Android Studio and choose New Project → Empty Views Activity. Language: Java, minimum SDK: API 21. Name the activity SpaceInvadersActivity.

Manifest

<activity
    android:name=".SpaceInvadersActivity"
    android:theme="@android:style/Theme.NoTitleBar.Fullscreen"
    android:screenOrientation="landscape"
    android:exported="true">
    ...
</activity>

Drawable Assets

Save these three PNGs into app/src/main/res/drawable:

Sound Assets

Create an Assets Folder (New → Folder → Assets Folder) and place six .ogg files in app/src/main/assets. You can use the sound files from the Breakout tutorial for the shoot and shelter sounds, and record or source short uh/oh clips for the menace beat.

SpaceInvadersActivity.java

import android.app.Activity;
import android.graphics.Point;
import android.os.Bundle;
import android.view.Display;

public class SpaceInvadersActivity extends Activity {

    SpaceInvadersView spaceInvadersView;

    @Override
    protected void onCreate(Bundle savedInstanceState) {
        super.onCreate(savedInstanceState);
        Display display = getWindowManager().getDefaultDisplay();
        Point size = new Point();
        display.getSize(size);
        spaceInvadersView = new SpaceInvadersView(this, size.x, size.y);
        setContentView(spaceInvadersView);
    }

    @Override
    protected void onResume() { super.onResume(); spaceInvadersView.resume(); }

    @Override
    protected void onPause()  { super.onPause();  spaceInvadersView.pause();  }
}

PlayerShip.java

import android.content.Context;
import android.graphics.Bitmap;
import android.graphics.BitmapFactory;
import android.graphics.RectF;

public class PlayerShip {

    RectF   rect;
    private Bitmap bitmap;
    private float  length;
    private float  height;
    private float  x;
    private float  y;
    private float  shipSpeed = 350;

    public final int STOPPED = 0;
    public final int LEFT    = 1;
    public final int RIGHT   = 2;
    private int shipMoving = STOPPED;

    public PlayerShip(Context context, int screenX, int screenY) {
        rect   = new RectF();
        length = screenX / 10;
        height = screenY / 10;
        x      = screenX / 2;
        y      = screenY - 20;

        bitmap = BitmapFactory.decodeResource(
                context.getResources(), R.drawable.playership);
        bitmap = Bitmap.createScaledBitmap(bitmap, (int) length, (int) height, false);
    }

    public RectF   getRect()   { return rect;   }
    public Bitmap  getBitmap() { return bitmap; }
    public float   getX()      { return x;      }
    public float   getLength() { return length; }

    public void setMovementState(int state) { shipMoving = state; }

    public void update(long fps) {
        if (shipMoving == LEFT)  x -= shipSpeed / fps;
        if (shipMoving == RIGHT) x += shipSpeed / fps;

        rect.top    = y;
        rect.bottom = y + height;
        rect.left   = x;
        rect.right  = x + length;
    }
}

Bullet.java

One class covers both player and invader bullets. The heading flag determines whether the bullet travels up or down, and getImpactPointY() returns the leading edge — top for upward bullets, bottom for downward ones. This is what the out-of-bounds check compares against the screen edge.

import android.graphics.RectF;

public class Bullet {

    private float  x, y;
    private RectF  rect;

    public final int UP   = 0;
    public final int DOWN = 1;
    int   heading = -1;
    float speed   = 350;
    private int  width  = 1;
    private int  height;
    private boolean isActive;

    public Bullet(int screenY) {
        height   = screenY / 20;
        isActive = false;
        rect     = new RectF();
    }

    public RectF   getRect()   { return rect;     }
    public boolean getStatus() { return isActive; }
    public void    setInactive() { isActive = false; }

    public float getImpactPointY() {
        return (heading == DOWN) ? y + height : y;
    }

    public boolean shoot(float startX, float startY, int direction) {
        if (!isActive) {
            x        = startX;
            y        = startY;
            heading  = direction;
            isActive = true;
            return true;
        }
        return false;
    }

    public void update(long fps) {
        if (heading == UP)   y -= speed / fps;
        else                 y += speed / fps;

        rect.left   = x;
        rect.right  = x + width;
        rect.top    = y;
        rect.bottom = y + height;
    }
}

Invader.java

Each invader holds two scaled bitmaps — arms up and arms down — that are alternated in SpaceInvadersView.draw() using the shared uhOrOh boolean. The frame switch is tied to the menace beat, so the arms wave in sync with the sound.

import android.content.Context;
import android.graphics.Bitmap;
import android.graphics.BitmapFactory;
import android.graphics.RectF;
import java.util.Random;

public class Invader {

    RectF  rect;
    Random generator = new Random();
    private Bitmap bitmap1, bitmap2;
    private float  length, height;
    private float  x, y;
    private float  shipSpeed = 40;

    public final int LEFT  = 1;
    public final int RIGHT = 2;
    private int shipMoving = RIGHT;
    boolean isVisible = true;

    public Invader(Context context, int row, int column, int screenX, int screenY) {
        rect   = new RectF();
        length = screenX / 20;
        height = screenY / 20;

        int padding = screenX / 25;
        x = column * (length + padding);
        y = row    * (length + padding / 4);

        bitmap1 = BitmapFactory.decodeResource(context.getResources(), R.drawable.invader1);
        bitmap2 = BitmapFactory.decodeResource(context.getResources(), R.drawable.invader2);
        bitmap1 = Bitmap.createScaledBitmap(bitmap1, (int) length, (int) height, false);
        bitmap2 = Bitmap.createScaledBitmap(bitmap2, (int) length, (int) height, false);
    }

    public void    setInvisible()   { isVisible = false; }
    public boolean getVisibility()  { return isVisible;  }
    public RectF   getRect()        { return rect;       }
    public Bitmap  getBitmap()      { return bitmap1;    }
    public Bitmap  getBitmap2()     { return bitmap2;    }
    public float   getX()           { return x;          }
    public float   getY()           { return y;          }
    public float   getLength()      { return length;     }

    public void update(long fps) {
        if (shipMoving == LEFT)  x -= shipSpeed / fps;
        if (shipMoving == RIGHT) x += shipSpeed / fps;

        rect.top    = y;
        rect.bottom = y + height;
        rect.left   = x;
        rect.right  = x + length;
    }

    public void dropDownAndReverse() {
        shipMoving = (shipMoving == LEFT) ? RIGHT : LEFT;
        y         += height;
        shipSpeed *= 1.18f;
    }

    public boolean takeAim(float playerX, float playerLength) {
        // Fire when the player overlaps this invader's column
        if ((playerX + playerLength > x && playerX + playerLength < x + length) ||
            (playerX > x           && playerX < x + length)) {
            if (generator.nextInt(150) == 0) return true;
        }
        // Occasional random shot from any column
        return generator.nextInt(2000) == 0;
    }
}

dropDownAndReverse() increases speed by 18% every time the formation hits a wall. By the time the last few invaders are left they're moving at several times the starting speed — genuine pressure. The same method drops the row by one invader-height so the formation descends steadily toward the player.

DefenceBrick.java

import android.graphics.RectF;

public class DefenceBrick {

    private RectF   rect;
    private boolean isVisible = true;

    public DefenceBrick(int row, int column, int shelterNumber, int screenX, int screenY) {
        int width         = screenX / 90;
        int height        = screenY / 40;
        int brickPadding  = 1;
        int shelterPadding = screenX / 9;
        int startHeight   = screenY - (screenY / 8 * 2);

        rect = new RectF(
            column * width  + brickPadding + (shelterPadding * shelterNumber)
                + shelterPadding + shelterPadding * shelterNumber,
            row    * height + brickPadding + startHeight,
            column * width  + width - brickPadding + (shelterPadding * shelterNumber)
                + shelterPadding + shelterPadding * shelterNumber,
            row    * height + height - brickPadding + startHeight
        );
    }

    public RectF   getRect()       { return rect;      }
    public boolean getVisibility() { return isVisible; }
    public void    setInvisible()  { isVisible = false; }
}

The positioning arithmetic spaces four shelters evenly across the screen. Each shelter is 10 columns × 5 rows of bricks, with a small pixel gap between each brick. Bricks are never removed from the array — they just become invisible. The collision code skips invisible bricks.

SpaceInvadersView.java

Member Variables and Constructor

public class SpaceInvadersView extends SurfaceView implements Runnable {

    Context context;
    private Thread gameThread = null;
    private SurfaceHolder ourHolder;
    private volatile boolean playing;
    private boolean paused = true;
    private Canvas canvas;
    private Paint  paint;
    private long   fps;
    private int    screenX, screenY;

    private PlayerShip playerShip;
    private Bullet     bullet;
    private Bullet[]   invadersBullets  = new Bullet[200];
    private int        nextBullet;
    private int        maxInvaderBullets = 10;

    Invader[]     invaders  = new Invader[60];
    int           numInvaders = 0;
    DefenceBrick[] bricks   = new DefenceBrick[400];
    int           numBricks = 0;

    private SoundPool soundPool;
    private int playerExplodeID = -1, invaderExplodeID = -1;
    private int shootID = -1, damageShelterID = -1;
    private int uhID = -1, ohID = -1;

    int  score = 0;
    private int  lives = 3;
    private long menaceInterval = 1000;
    private boolean uhOrOh;
    private long lastMenaceTime = System.currentTimeMillis();

    public SpaceInvadersView(Context context, int x, int y) {
        super(context);
        this.context = context;
        ourHolder    = getHolder();
        paint        = new Paint();
        screenX      = x;
        screenY      = y;

        soundPool = new SoundPool(10, AudioManager.STREAM_MUSIC, 0);
        try {
            AssetManager assetManager = context.getAssets();
            AssetFileDescriptor descriptor;
            descriptor = assetManager.openFd("shoot.ogg");
            shootID = soundPool.load(descriptor, 0);
            descriptor = assetManager.openFd("invaderexplode.ogg");
            invaderExplodeID = soundPool.load(descriptor, 0);
            descriptor = assetManager.openFd("damageshelter.ogg");
            damageShelterID = soundPool.load(descriptor, 0);
            descriptor = assetManager.openFd("playerexplode.ogg");
            playerExplodeID = soundPool.load(descriptor, 0);
            descriptor = assetManager.openFd("uh.ogg");
            uhID = soundPool.load(descriptor, 0);
            descriptor = assetManager.openFd("oh.ogg");
            ohID = soundPool.load(descriptor, 0);
        } catch (IOException e) {
            Log.e("error", "failed to load sound files");
        }
        prepareLevel();
    }

prepareLevel()

    private void prepareLevel() {
        playerShip = new PlayerShip(context, screenX, screenY);
        bullet     = new Bullet(screenY);

        for (int i = 0; i < invadersBullets.length; i++)
            invadersBullets[i] = new Bullet(screenY);

        numInvaders = 0;
        for (int column = 0; column < 6; column++) {
            for (int row = 0; row < 5; row++) {
                invaders[numInvaders] = new Invader(context, row, column, screenX, screenY);
                numInvaders++;
            }
        }

        numBricks = 0;
        for (int shelterNumber = 0; shelterNumber < 4; shelterNumber++) {
            for (int column = 0; column < 10; column++) {
                for (int row = 0; row < 5; row++) {
                    bricks[numBricks] = new DefenceBrick(row, column, shelterNumber, screenX, screenY);
                    numBricks++;
                }
            }
        }
        menaceInterval = 1000;
    }

Game Loop, update(), and draw()

    @Override
    public void run() {
        while (playing) {
            long startFrameTime = System.currentTimeMillis();
            if (!paused) update();
            draw();
            long timeThisFrame = System.currentTimeMillis() - startFrameTime;
            if (timeThisFrame >= 1) fps = 1000 / timeThisFrame;

            if (!paused && (startFrameTime - lastMenaceTime) > menaceInterval) {
                soundPool.play(uhOrOh ? uhID : ohID, 1, 1, 0, 0, 1);
                lastMenaceTime = System.currentTimeMillis();
                uhOrOh = !uhOrOh;
            }
        }
    }

    private void update() {
        boolean bumped = false;
        boolean lost   = false;

        playerShip.update(fps);

        for (int i = 0; i < numInvaders; i++) {
            if (invaders[i].getVisibility()) {
                invaders[i].update(fps);
                if (invaders[i].takeAim(playerShip.getX(), playerShip.getLength())) {
                    if (invadersBullets[nextBullet].shoot(
                            invaders[i].getX() + invaders[i].getLength() / 2,
                            invaders[i].getY(), bullet.DOWN)) {
                        nextBullet++;
                        if (nextBullet == maxInvaderBullets) nextBullet = 0;
                    }
                }
                if (invaders[i].getX() > screenX - invaders[i].getLength() ||
                    invaders[i].getX() < 0) bumped = true;
            }
        }

        if (bumped) {
            for (int i = 0; i < numInvaders; i++) {
                invaders[i].dropDownAndReverse();
                if (invaders[i].getY() > screenY - screenY / 10) lost = true;
            }
            menaceInterval -= 80;
        }

        if (lost) { prepareLevel(); return; }

        if (bullet.getStatus()) bullet.update(fps);

        for (int i = 0; i < invadersBullets.length; i++)
            if (invadersBullets[i].getStatus()) invadersBullets[i].update(fps);

        // Bullet out of bounds
        if (bullet.getImpactPointY() < 0) bullet.setInactive();
        for (int i = 0; i < invadersBullets.length; i++)
            if (invadersBullets[i].getImpactPointY() > screenY)
                invadersBullets[i].setInactive();

        // Player bullet vs invaders
        if (bullet.getStatus()) {
            for (int i = 0; i < numInvaders; i++) {
                if (invaders[i].getVisibility() &&
                    RectF.intersects(bullet.getRect(), invaders[i].getRect())) {
                    invaders[i].setInvisible();
                    soundPool.play(invaderExplodeID, 1, 1, 0, 0, 1);
                    bullet.setInactive();
                    score += 10;
                    if (score == numInvaders * 10) {
                        paused = true;
                        score  = 0;
                        lives  = 3;
                        prepareLevel();
                    }
                }
            }
        }

        // Invader bullets vs shelter bricks
        for (int i = 0; i < invadersBullets.length; i++) {
            if (invadersBullets[i].getStatus()) {
                for (int j = 0; j < numBricks; j++) {
                    if (bricks[j].getVisibility() &&
                        RectF.intersects(invadersBullets[i].getRect(), bricks[j].getRect())) {
                        invadersBullets[i].setInactive();
                        bricks[j].setInvisible();
                        soundPool.play(damageShelterID, 1, 1, 0, 0, 1);
                    }
                }
            }
        }

        // Player bullet vs shelter bricks
        if (bullet.getStatus()) {
            for (int i = 0; i < numBricks; i++) {
                if (bricks[i].getVisibility() &&
                    RectF.intersects(bullet.getRect(), bricks[i].getRect())) {
                    bullet.setInactive();
                    bricks[i].setInvisible();
                    soundPool.play(damageShelterID, 1, 1, 0, 0, 1);
                }
            }
        }

        // Invader bullets vs player
        for (int i = 0; i < invadersBullets.length; i++) {
            if (invadersBullets[i].getStatus() &&
                RectF.intersects(playerShip.getRect(), invadersBullets[i].getRect())) {
                invadersBullets[i].setInactive();
                lives--;
                soundPool.play(playerExplodeID, 1, 1, 0, 0, 1);
                if (lives == 0) {
                    paused = true;
                    lives  = 3;
                    score  = 0;
                    prepareLevel();
                }
            }
        }
    }

    private void draw() {
        if (ourHolder.getSurface().isValid()) {
            canvas = ourHolder.lockCanvas();
            canvas.drawColor(Color.argb(255, 26, 128, 182));
            paint.setColor(Color.argb(255, 255, 255, 255));

            canvas.drawBitmap(playerShip.getBitmap(),
                    playerShip.getX(), screenY - 50, paint);

            for (int i = 0; i < numInvaders; i++) {
                if (invaders[i].getVisibility()) {
                    canvas.drawBitmap(
                        uhOrOh ? invaders[i].getBitmap() : invaders[i].getBitmap2(),
                        invaders[i].getX(), invaders[i].getY(), paint);
                }
            }

            for (int i = 0; i < numBricks; i++)
                if (bricks[i].getVisibility())
                    canvas.drawRect(bricks[i].getRect(), paint);

            if (bullet.getStatus())
                canvas.drawRect(bullet.getRect(), paint);

            for (int i = 0; i < invadersBullets.length; i++)
                if (invadersBullets[i].getStatus())
                    canvas.drawRect(invadersBullets[i].getRect(), paint);

            paint.setColor(Color.argb(255, 249, 129, 0));
            paint.setTextSize(40);
            canvas.drawText("Score: " + score + "   Lives: " + lives, 10, 50, paint);

            ourHolder.unlockCanvasAndPost(canvas);
        }
    }

    public void pause() {
        playing = false;
        try { gameThread.join(); }
        catch (InterruptedException e) { Log.e("Error:", "joining thread"); }
    }

    public void resume() {
        playing = true;
        gameThread = new Thread(this);
        gameThread.start();
    }

    @Override
    public boolean onTouchEvent(MotionEvent motionEvent) {
        switch (motionEvent.getAction() & MotionEvent.ACTION_MASK) {
            case MotionEvent.ACTION_DOWN:
                paused = false;
                if (motionEvent.getY() > screenY - screenY / 8) {
                    playerShip.setMovementState(
                        motionEvent.getX() > screenX / 2
                            ? playerShip.RIGHT : playerShip.LEFT);
                } else {
                    if (bullet.shoot(playerShip.getX() + playerShip.getLength() / 2,
                                     screenY, bullet.UP))
                        soundPool.play(shootID, 1, 1, 0, 0, 1);
                }
                break;
            case MotionEvent.ACTION_UP:
                if (motionEvent.getY() > screenY - screenY / 10)
                    playerShip.setMovementState(playerShip.STOPPED);
                break;
        }
        return true;
    }
}

The Menace System

The uh-oh beat is driven by a simple timer check at the bottom of run(). Every time menaceInterval milliseconds elapse, one of the two sounds plays and the boolean flips. That same boolean — uhOrOh — controls which invader bitmap is drawn, so the arms wave in perfect sync with the beat. When invaders drop a row, menaceInterval shrinks by 80 ms. The tension builds mechanically.

Common Issues

Invaders don't appear. Check that invader1.png and invader2.png are in res/drawable and that the resource IDs R.drawable.invader1 and R.drawable.invader2 match the filenames exactly (no capital letters, no spaces).

Game resets immediately when it starts. prepareLevel() is called without zeroing score — if the win condition score == numInvaders * 10 is met before any invaders are shot (e.g. score is non-zero from a previous run), the game loops instantly. Confirm score is reset to 0 at the start of prepareLevel() or in the win handler.

Player bullet goes through invaders without deregistering. RectF.intersects is static — it must be called as RectF.intersects(a, b). Calling it as an instance method (a.intersects(b)) uses a different code path and won't work correctly.

Invaders reach the bottom and the game freezes instead of resetting. The lost flag is set correctly but prepareLevel() is not called immediately — there's a missing return after the call to prevent update() continuing to run collision checks on the freshly-reset objects. Make sure the reset path exits update() early.

Where to Take It Next