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:
- SpaceInvadersActivity.java — entry point, lifecycle management
- SpaceInvadersView.java — game engine: loop, update, draw, input
- PlayerShip.java — the player's ship
- Bullet.java — shared by player and invaders; direction flag distinguishes them
- Invader.java — one enemy with movement, animation, and aiming AI
- DefenceBrick.java — one brick of a shelter
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:
playership.pnginvader1.png— arms-up frameinvader2.png— arms-down frame
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.
shoot.ogg— player firesinvaderexplode.ogg— invader destroyedplayerexplode.ogg— player hitdamageshelter.ogg— brick destroyeduh.ogg— menace beat, first beatoh.ogg— menace beat, second 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
- High score persistence. Save the high score to
SharedPreferenceswhen the player runs out of lives. Load it at startup and display alongside the current score. - Smarter AI. The current
takeAimfires randomly from any row of a column. In the original Arcade game only the bottom-most invader in each column could fire. FiltertakeAimto check whether any visible invader is directly below before allowing a shot. - Multiple waves. After clearing all invaders without losing all lives, call
prepareLevel()but keep the score, subtract a life for a bonus, and start the invaders faster. TheshipSpeedfield inInvaderis the lever — multiply it by 1.3 each wave. - Explosions. When an invader or the player is hit, spawn a short-lived particle effect at the impact position using the same particle approach as the Breakout project's sound cue — a few expanding rectangles that disappear after one second.