Android Java Project

2D Scrolling Shooter

This project brings together a handful of ideas that rarely appear in "simple game" tutorials: a virtual world larger than the screen, a viewport that follows the player through it, procedurally generated level geometry, chain-reaction destruction, and a custom on-screen HUD. Each piece is manageable on its own; what makes this project interesting is seeing how they connect.

The world is 500 × 150 metres. The screen shows roughly 90 × 55 metres of it at any moment. The player flies a triangle ship through a randomly generated skyline, shooting buildings that explode in cascades. Everything is drawn with lines, points, and filled rectangles — no bitmaps required.

The 2D scrolling shooter showing the triangle ship flying over a procedurally generated city skyline with explosion effects.
A randomly generated city, five HUD buttons, and a chain-reaction destruction system — all from a handful of classes.

Architecture Overview

The project has seven classes:

The World vs. Screen Coordinate System

Every game object — the ship, every brick, every bullet — lives in world coordinates: floating-point metres with (0, 0) at the top-left of the world and (500, 150) at the bottom-right. The viewport converts those to screen pixels on the fly whenever something needs to be drawn.

The conversion formula centres the visible area on the player. Given the player is at world position (cx, cy) and the screen centre is at pixel (screenCentreX, screenCentreY):

screenX = screenCentreX - (cx - objectX) * pixelsPerMetreX
screenY = screenCentreY - (cy - objectY) * pixelsPerMetreY

Objects to the left of the player produce a positive offset from screen centre; objects to the right produce a negative offset. The same logic applies vertically. The result is a camera that always keeps the player in the middle of the screen.

The other job of the viewport is frustum culling: any object more than ~46 metres to the left/right or ~28 metres above/below the player is outside the visible area and skipped entirely during update and draw. With 20,000 bricks in the world, this is what keeps the frame rate manageable.

Project Setup

Create a new Empty Views Activity project. Language: Java, minimum SDK: API 21. Name the activity MainActivity, package com.gamecodeschool.scrollingshooter2d.

Add fullscreen landscape to AndroidManifest.xml:

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

Sound Assets

This project loads sound from the assets folder rather than res/raw, so the files are accessed by filename string rather than resource ID. Create the folder app/src/main/assets/ and add these two files:

To create the assets folder in Android Studio: right-click app/src/main → New → Directory → type assets.

Viewport.java

The whole viewport system is three methods: worldToScreen converts a world rect to a screen RectF, worldToScreenPoint converts a single point, and clipObjects returns true if an object is outside the visible window.

import android.graphics.PointF;
import android.graphics.RectF;

public class Viewport {
    private PointF currentViewportWorldCentre;
    private RectF convertedRect;
    private PointF convertedPoint;
    private int pixelsPerMetreX;
    private int pixelsPerMetreY;
    private int screenCentreX;
    private int screenCentreY;
    private int metresToShowX;
    private int metresToShowY;

    Viewport(int screenXResolution, int screenYResolution) {
        screenCentreX = screenXResolution / 2;
        screenCentreY = screenYResolution / 2;
        pixelsPerMetreX = screenXResolution / 90;
        pixelsPerMetreY = screenYResolution / 55;
        metresToShowX = 92;
        metresToShowY = 57;
        convertedRect  = new RectF();
        convertedPoint = new PointF();
        currentViewportWorldCentre = new PointF();
    }

    void setWorldCentre(float x, float y) {
        currentViewportWorldCentre.x = x;
        currentViewportWorldCentre.y = y;
    }

    public RectF worldToScreen(float objectX, float objectY,
                               float objectWidth, float objectHeight) {
        int left   = (int)(screenCentreX - ((currentViewportWorldCentre.x - objectX) * pixelsPerMetreX));
        int top    = (int)(screenCentreY - ((currentViewportWorldCentre.y - objectY) * pixelsPerMetreY));
        int right  = (int)(left + (objectWidth  * pixelsPerMetreX));
        int bottom = (int)(top  + (objectHeight * pixelsPerMetreY));
        convertedRect.set(left, top, right, bottom);
        return convertedRect;
    }

    public PointF worldToScreenPoint(float objectX, float objectY) {
        int left = (int)(screenCentreX - ((currentViewportWorldCentre.x - objectX) * pixelsPerMetreX));
        int top  = (int)(screenCentreY - ((currentViewportWorldCentre.y - objectY) * pixelsPerMetreY));
        convertedPoint.x = left;
        convertedPoint.y = top;
        return convertedPoint;
    }

    public boolean clipObjects(float objectX, float objectY,
                               float objectWidth, float objectHeight) {
        boolean clipped = true;
        if (objectX - objectWidth < currentViewportWorldCentre.x + (metresToShowX / 2)) {
            if (objectX + objectWidth > currentViewportWorldCentre.x - (metresToShowX / 2)) {
                if (objectY - objectHeight < currentViewportWorldCentre.y + (metresToShowY / 2)) {
                    if (objectY + objectHeight > currentViewportWorldCentre.y - (metresToShowY / 2)) {
                        clipped = false;
                    }
                }
            }
        }
        return clipped;
    }
}

Note that worldToScreen and worldToScreenPoint return references to the same internal convertedRect and convertedPoint objects — they're reused every call. This avoids allocating garbage on every frame, which is important in a game loop. The caller must copy the values out before making a second call if it needs both results simultaneously (that's why GameView uses convertedPointA, convertedPointB, convertedPointC to cache the three ship vertices separately).

Ship.java

The ship mechanics are built on the same 2D rotation matrix as the Rotation and Heading demo, but with one important addition: momentum. Rather than moving at a constant speed while thrusting and stopping immediately when released, the ship has a speed variable that ramps up toward MAX_SPEED while thrusting and decelerates at BREAK_RATE when not. The horizontal and vertical velocity components, computed from facingAngle, are always applied — which means the ship keeps gliding in its last direction of travel even after you stop thrusting and turn.

import android.graphics.PointF;

public class Ship {
    PointF a, b, c, centre;
    float facingAngle = 270;

    private float speed = 0;
    private float horizontalVelocity;
    private float verticalVelocity;

    public final int STOPPING  = 0;
    public final int LEFT      = 1;
    public final int RIGHT     = 2;
    public final int THRUSTING = 3;

    private int shipMoving = STOPPING;

    public Ship() {
        float length = 2.5f;
        float width  = 1.25f;
        a = new PointF();
        b = new PointF();
        c = new PointF();
        centre = new PointF();

        centre.x = 50;
        centre.y = 50;
        a.x = centre.x;
        a.y = centre.y - length / 2;
        b.x = centre.x - width / 2;
        b.y = centre.y + length / 2;
        c.x = centre.x + width / 2;
        c.y = centre.y + length / 2;
    }

    public PointF getCentre() { return centre; }
    public PointF getA()      { return a; }
    public PointF getB()      { return b; }
    public PointF getC()      { return c; }
    float getFacingAngle()    { return facingAngle; }

    public void bump() {
        speed = 0;
        centre.x -= horizontalVelocity * 2;
        centre.y -= verticalVelocity   * 2;
        a.x -= horizontalVelocity * 2;  a.y -= verticalVelocity * 2;
        b.x -= horizontalVelocity * 2;  b.y -= verticalVelocity * 2;
        c.x -= horizontalVelocity * 2;  c.y -= verticalVelocity * 2;
    }

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

    public void update(long fps) {
        final float ROTATION_SPEED = 200;
        final float BREAK_RATE     = 30;
        float previousFA = facingAngle;

        if (shipMoving == LEFT) {
            facingAngle -= ROTATION_SPEED / fps;
            if (facingAngle < 1) facingAngle = 360;
        }
        if (shipMoving == RIGHT) {
            facingAngle += ROTATION_SPEED / fps;
            if (facingAngle > 360) facingAngle = 1;
        }
        if (shipMoving == THRUSTING) {
            final float MAX_SPEED         = 80;
            final float ACCELERATION_RATE = 40;
            if (speed < MAX_SPEED) speed += ACCELERATION_RATE / fps;
            horizontalVelocity = (float) Math.cos(Math.toRadians(facingAngle));
            verticalVelocity   = (float) Math.sin(Math.toRadians(facingAngle));
        }

        centre.x += horizontalVelocity * speed / fps;
        centre.y += verticalVelocity   * speed / fps;
        a.x += horizontalVelocity * speed / fps;
        a.y += verticalVelocity   * speed / fps;
        b.x += horizontalVelocity * speed / fps;
        b.y += verticalVelocity   * speed / fps;
        c.x += horizontalVelocity * speed / fps;
        c.y += verticalVelocity   * speed / fps;

        if (shipMoving != THRUSTING && speed > 0) {
            speed -= BREAK_RATE / fps;
        }

        // Rotate all three vertices by the angle delta this frame
        float delta = facingAngle - previousFA;
        float cosD  = (float) Math.cos(Math.toRadians(delta));
        float sinD  = (float) Math.sin(Math.toRadians(delta));

        float tempX, tempY;

        a.x -= centre.x; a.y -= centre.y;
        tempX = a.x * cosD - a.y * sinD;
        tempY = a.x * sinD + a.y * cosD;
        a.x = tempX + centre.x; a.y = tempY + centre.y;

        b.x -= centre.x; b.y -= centre.y;
        tempX = b.x * cosD - b.y * sinD;
        tempY = b.x * sinD + b.y * cosD;
        b.x = tempX + centre.x; b.y = tempY + centre.y;

        c.x -= centre.x; c.y -= centre.y;
        tempX = c.x * cosD - c.y * sinD;
        tempY = c.x * sinD + c.y * cosD;
        c.x = tempX + centre.x; c.y = tempY + centre.y;
    }
}

bump() is called when the ship collides with a building or a world boundary. It zeroes the speed and pushes the ship back by twice its last-frame velocity — a quick way to eject it from the collision zone without a full penetration-depth calculation. It's imprecise, but it feels responsive: the ship bounces rather than sticking.

Bullet.java

A bullet fires from the ship's nose vertex (a) in the direction of facingAngle, using the same cos/sin decomposition as thrust. Up to 10 bullets can be alive simultaneously; they're stored in a fixed-size pool and reused via a ring-buffer index in GameView.

import android.graphics.PointF;

public class Bullet {
    private PointF point;
    private float horizontalVelocity;
    private float verticalVelocity;
    float speed = 60;
    private boolean isActive;

    public Bullet() {
        isActive = false;
        point = new PointF();
    }

    public boolean shoot(float startX, float startY, float direction) {
        if (!isActive) {
            point.x = startX;
            point.y = startY;
            horizontalVelocity = (float) Math.cos(Math.toRadians(direction));
            verticalVelocity   = (float) Math.sin(Math.toRadians(direction));
            isActive = true;
            return true;
        }
        return false;
    }

    public void update(long fps) {
        point.x += horizontalVelocity * speed / fps;
        point.y += verticalVelocity   * speed / fps;
    }

    public PointF getPoint()  { return point; }
    public boolean getStatus() { return isActive; }
    public void setInactive()  { isActive = false; }
}

Star.java

Stars are purely decorative. Each is a random world position. Every frame there's a 1-in-5000 chance the star toggles its visibility — a cheap way to produce the faint twinkle of a distant starfield without any per-star animation state.

import java.util.Random;

public class Star {
    private int x;
    private int y;
    private boolean isVisible = true;
    Random random;

    public Star(int mapWidth, int mapHeight) {
        random = new Random();
        x = random.nextInt(mapWidth);
        y = random.nextInt(mapHeight);
    }

    public int getX() { return x; }
    public int getY() { return y; }

    public void update() {
        if (random.nextInt(5000) == 0) isVisible = !isVisible;
    }

    public boolean getVisibility() { return isVisible; }
}

Brick.java

Each Brick is a 1×1 world-metre tile. Three boolean flags — isLeft, isRight, isTop — mark whether this brick is on the edge of its building column, which the draw code uses to add grey outline lines for a facade effect.

On construction, a 1-in-9 chance makes the brick a yellow window (semi-transparent yellow on black). While alive, there's a 1-in-6000 chance each frame to re-roll the colour — so windows flicker very occasionally. Once destroyed, update() picks a random fire colour (red, orange, or yellow) every single frame, producing an animated flame effect at no extra cost.

import android.graphics.Color;
import android.graphics.RectF;
import java.util.Random;

public class Brick {
    private RectF rect;
    Random random = new Random();
    boolean destroyed;
    private boolean isRight, isLeft, isTop;
    private int color;
    private boolean clipped;

    public Brick(int columnNum, int rowNum,
                 boolean isLeft, boolean isRight, boolean isTop) {
        this.isLeft  = isLeft;
        this.isRight = isRight;
        this.isTop   = isTop;

        rect = new RectF(columnNum, rowNum, columnNum + 1, rowNum + 1);

        color = (random.nextInt(9) == 0)
            ? Color.argb(random.nextInt(256), 255, 255, 0)
            : Color.argb(255, 0, 0, 0);
    }

    public void update() {
        if (!destroyed) {
            if (random.nextInt(6000) == 0) {
                color = (random.nextInt(9) == 0)
                    ? Color.argb(random.nextInt(256), 255, 255, 0)
                    : Color.argb(255, 0, 0, 0);
            }
        } else {
            switch (random.nextInt(3)) {
                case 0: color = Color.argb(255, 255,   0,  0); break;
                case 1: color = Color.argb(255, 245, 143, 10); break;
                case 2: color = Color.argb(255, 250, 250, 10); break;
            }
        }
    }

    public void destroy()       { destroyed = true; }
    public boolean isDestroyed(){ return destroyed; }
    public void clip()          { clipped = true; }
    public void unClip()        { clipped = false; }
    public boolean isClipped()  { return clipped; }
    public RectF getRect()      { return rect; }
    public boolean getLeft()    { return isLeft; }
    public boolean getRight()   { return isRight; }
    public boolean getTop()     { return isTop; }
    public int getColor()       { return color; }
}

MainActivity.java

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

public class MainActivity extends Activity {
    GameView view;

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

    @Override protected void onResume() { super.onResume(); view.resume(); }
    @Override protected void onPause()  { super.onPause();  view.pause();  }
}

GameView.java

GameView is the most involved class. It owns the world — the brick array, the star array, the player, the bullet pool — and orchestrates setup, update, draw, and input. The inner HUD class handles button layout and touch routing.

Level Generation

prepareLevel() generates the city procedurally. It walks the world from left to right, choosing a random building width (3–12 m), a random height (1–85 m), and a random gap before the next building. For each building it fills in bricks column by column from ground level upward, flagging which bricks are on the left edge, right edge, or top.

The maximum brick count is 20,000. With buildings averaging ~6 metres wide and ~42 metres tall, a 500-metre world fills roughly 12,500–16,000 bricks in practice — well within the array.

Chain Reaction

When a bullet hits a brick, the game doesn't just destroy that brick. It picks a random chain length between 1 and 5, then destroys that many bricks on either side of the hit brick in the array. This works because bricks are stored in column order: adjacent indices are vertically adjacent in the same building. The result is a small vertical explosion through the column.

The HUD

The HUD inner class lays out five semi-transparent rounded rectangles: left and right rotate buttons on the bottom-left, thrust and shoot buttons stacked on the bottom-right, and a pause button in the top-right corner. Touch coordinates from onTouchEvent are compared against each button's Rect using contains(x, y) to route input to the correct action.

import android.content.Context;
import android.content.res.AssetFileDescriptor;
import android.content.res.AssetManager;
import android.graphics.Canvas;
import android.graphics.Color;
import android.graphics.Paint;
import android.graphics.PointF;
import android.graphics.Rect;
import android.graphics.RectF;
import android.media.AudioManager;
import android.media.SoundPool;
import android.util.Log;
import android.view.MotionEvent;
import android.view.SurfaceHolder;
import android.view.SurfaceView;
import java.io.IOException;
import java.util.ArrayList;
import java.util.Random;

public class GameView extends SurfaceView implements Runnable {

    Random random = new Random();

    private SoundPool soundPool;
    private int shootID          = -1;
    private int damageBuildingID = -1;

    HUD hud;

    int worldWidth        = 0;
    int targetWorldWidth  = 500;
    int targetWorldHeight = 150;
    int groundLevel       = 145;

    RectF  convertedRect   = new RectF();
    PointF convertedPointA = new PointF();
    PointF convertedPointB = new PointF();
    PointF convertedPointC = new PointF();
    PointF tempPointF      = new PointF();

    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 Brick[] bricks = new Brick[20000];
    private int     numBricks;

    private Star[] stars = new Star[5000];
    private int    numStars;

    Ship player;

    private Bullet[] playerBullets    = new Bullet[10];
    private int      nextPlayerBullet = 0;
    private int      maxPlayerBullets = 10;

    Viewport vp;

    public GameView(Context context, int screenX, int screenY) {
        super(context);
        ourHolder = getHolder();
        paint     = new Paint();

        vp  = new Viewport(screenX, screenY);
        hud = new HUD(screenX, screenY);

        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("damagebuilding.ogg");
            damageBuildingID = soundPool.load(descriptor, 0);
        } catch (IOException e) {
            Log.e("error", "failed to load sound files");
        }

        prepareLevel();
    }

    private void prepareLevel() {
        player = new Ship();

        for (int i = 0; i < playerBullets.length; i++) {
            playerBullets[i] = new Bullet();
        }

        vp.setWorldCentre(player.getCentre().x, player.getCentre().y);

        int maxGap           = 25;
        int maxBuildingWidth = 10;
        int maxBuildingHeight = 85;

        for (worldWidth = 0; worldWidth < targetWorldWidth; ) {
            int buildingWidth  = random.nextInt(maxBuildingWidth) + 3;
            int buildingHeight = random.nextInt(maxBuildingHeight) + 1;
            int gapFromLast    = random.nextInt(maxGap) + 1;

            for (int x = 0; x < buildingWidth; x++) {
                for (int y = groundLevel; y > groundLevel - buildingHeight; y--) {
                    boolean isLeft  = (x == 0);
                    boolean isRight = (x == buildingWidth - 1);
                    boolean isTop   = (y == (groundLevel - buildingHeight) + 1);
                    bricks[numBricks++] = new Brick(x + worldWidth, y, isLeft, isRight, isTop);
                }
            }
            worldWidth += buildingWidth + gapFromLast;
        }

        for (int i = 0; i < 500; i++) {
            stars[numStars++] = new Star(targetWorldWidth, targetWorldHeight);
        }
    }

    @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;
        }
    }

    private void update() {
        vp.setWorldCentre(player.getCentre().x, player.getCentre().y);

        // Frustum cull: mark bricks outside viewport
        for (int i = 0; i < numBricks; i++) {
            if (vp.clipObjects(bricks[i].getRect().left, bricks[i].getRect().top, 1, 1)) {
                bricks[i].clip();
            } else {
                bricks[i].unClip();
            }
        }

        player.update(fps);

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

        // Bullet vs brick collision
        for (int i = 0; i < maxPlayerBullets; i++) {
            if (playerBullets[i].getStatus()) {
                for (int j = 0; j < numBricks; j++) {
                    if (!bricks[j].isClipped() && !bricks[j].isDestroyed()) {
                        if (bricks[j].getRect().contains(
                                playerBullets[i].getPoint().x,
                                playerBullets[i].getPoint().y)) {
                            playerBullets[i].setInactive();
                            soundPool.play(damageBuildingID, 1, 1, 0, 0, 1);
                            bricks[j].destroy();
                            int chainSize = random.nextInt(6);
                            for (int k = 1; k < chainSize; k++) {
                                bricks[j + k].destroy();
                                bricks[j - k].destroy();
                            }
                        }
                    }
                }
            }
        }

        // Deactivate bullets that leave the world
        for (int i = 0; i < maxPlayerBullets; i++) {
            if (playerBullets[i].getStatus()) {
                PointF p = playerBullets[i].getPoint();
                if (p.x < 0 || p.x > targetWorldWidth ||
                    p.y < 0 || p.y > targetWorldHeight) {
                    playerBullets[i].setInactive();
                }
            }
        }

        for (int i = 0; i < numStars;  i++) stars[i].update();
        for (int i = 0; i < numBricks; i++) { if (!bricks[i].isClipped()) bricks[i].update(); }

        // Ship vs brick collision
        for (int i = 0; i < numBricks; i++) {
            if (!bricks[i].isClipped() && !bricks[i].isDestroyed()) {
                RectF r = bricks[i].getRect();
                if (r.contains(player.getA().x, player.getA().y) ||
                    r.contains(player.getB().x, player.getB().y) ||
                    r.contains(player.getC().x, player.getC().y)) {
                    player.bump();
                }
            }
        }

        // World boundary collisions
        if (player.getA().y > groundLevel || player.getB().y > groundLevel || player.getC().y > groundLevel) player.bump();
        if (player.getA().y < 0           || player.getB().y < 0           || player.getC().y < 0          ) player.bump();
        if (player.getA().x < 0           || player.getB().x < 0           || player.getC().x < 0          ) player.bump();
        if (player.getA().x > worldWidth  || player.getB().x > worldWidth  || player.getC().x > worldWidth ) player.bump();
    }

    private void draw() {
        if (ourHolder.getSurface().isValid()) {
            canvas = ourHolder.lockCanvas();
            canvas.drawColor(Color.argb(255, 0, 0, 0));

            // World boundary walls
            paint.setColor(Color.argb(255, 255, 255, 255));
            convertedRect = vp.worldToScreen(0, 0, targetWorldWidth, 1);
            canvas.drawRect(convertedRect, paint);
            convertedRect = vp.worldToScreen(0, 0, 1, targetWorldHeight);
            canvas.drawRect(convertedRect, paint);
            convertedRect = vp.worldToScreen(targetWorldWidth, 0, 1, targetWorldHeight);
            canvas.drawRect(convertedRect, paint);

            // Stars
            for (int i = 0; i < numStars; i++) {
                if (stars[i].getVisibility()) {
                    tempPointF = vp.worldToScreenPoint(stars[i].getX(), stars[i].getY());
                    canvas.drawPoint(tempPointF.x, tempPointF.y, paint);
                }
            }

            // Buildings
            for (int i = 0; i < numBricks; i++) {
                if (!bricks[i].isClipped()) {
                    paint.setColor(bricks[i].getColor());
                    RectF r = bricks[i].getRect();
                    convertedRect = vp.worldToScreen(r.left, r.top,
                                                     r.right - r.left, r.bottom - r.top);
                    canvas.drawRect(convertedRect, paint);

                    paint.setColor(Color.argb(255, 190, 190, 190));
                    if (bricks[i].getLeft())
                        canvas.drawLine(convertedRect.left,  convertedRect.top,
                                        convertedRect.left,  convertedRect.bottom, paint);
                    if (bricks[i].getRight())
                        canvas.drawLine(convertedRect.right, convertedRect.top,
                                        convertedRect.right, convertedRect.bottom, paint);
                    if (bricks[i].getTop())
                        canvas.drawLine(convertedRect.left,  convertedRect.top,
                                        convertedRect.right, convertedRect.top,    paint);
                }
            }

            // Ship
            tempPointF = vp.worldToScreenPoint(player.getA().x, player.getA().y);
            convertedPointA.set(tempPointF.x, tempPointF.y);
            tempPointF = vp.worldToScreenPoint(player.getB().x, player.getB().y);
            convertedPointB.set(tempPointF.x, tempPointF.y);
            tempPointF = vp.worldToScreenPoint(player.getC().x, player.getC().y);
            convertedPointC.set(tempPointF.x, tempPointF.y);

            paint.setColor(Color.argb(255, 255, 255, 255));
            canvas.drawLine(convertedPointA.x, convertedPointA.y,
                            convertedPointB.x, convertedPointB.y, paint);
            canvas.drawLine(convertedPointB.x, convertedPointB.y,
                            convertedPointC.x, convertedPointC.y, paint);
            canvas.drawLine(convertedPointC.x, convertedPointC.y,
                            convertedPointA.x, convertedPointA.y, paint);
            canvas.drawPoint(convertedPointA.x, convertedPointA.y, paint);

            // Bullets
            for (int i = 0; i < playerBullets.length; i++) {
                if (playerBullets[i].getStatus()) {
                    tempPointF = vp.worldToScreenPoint(
                        playerBullets[i].getPoint().x,
                        playerBullets[i].getPoint().y);
                    canvas.drawRect(tempPointF.x, tempPointF.y,
                                    tempPointF.x + 4, tempPointF.y + 4, paint);
                }
            }

            // FPS counter
            paint.setTextSize(20);
            canvas.drawText("FPS = " + fps, 20, 70, paint);

            // Ground fill
            convertedRect = vp.worldToScreen(-10, groundLevel,
                                             targetWorldWidth + 10,
                                             targetWorldHeight - groundLevel);
            paint.setColor(Color.argb(255, 5, 66, 9));
            canvas.drawRect(convertedRect, paint);

            // HUD buttons
            paint.setColor(Color.argb(80, 255, 255, 255));
            for (Rect rect : hud.currentButtonList) {
                RectF rf = new RectF(rect.left, rect.top, rect.right, rect.bottom);
                canvas.drawRoundRect(rf, 15f, 15f, 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) {
        hud.handleInput(motionEvent);
        return true;
    }

    class HUD {
        Rect left, right, thrust, shoot, pause;
        public ArrayList<Rect> currentButtonList = new ArrayList<>();

        HUD(int screenWidth, int screenHeight) {
            int buttonWidth   = screenWidth  / 8;
            int buttonHeight  = screenHeight / 7;
            int buttonPadding = screenWidth  / 80;

            left = new Rect(
                buttonPadding,
                screenHeight - buttonHeight - buttonPadding,
                buttonWidth,
                screenHeight - buttonPadding);

            right = new Rect(
                buttonWidth + buttonPadding,
                screenHeight - buttonHeight - buttonPadding,
                buttonWidth + buttonPadding + buttonWidth,
                screenHeight - buttonPadding);

            thrust = new Rect(
                screenWidth - buttonWidth - buttonPadding,
                screenHeight - buttonHeight - buttonPadding - buttonHeight - buttonPadding,
                screenWidth - buttonPadding,
                screenHeight - buttonPadding - buttonHeight - buttonPadding);

            shoot = new Rect(
                screenWidth - buttonWidth - buttonPadding,
                screenHeight - buttonHeight - buttonPadding,
                screenWidth - buttonPadding,
                screenHeight - buttonPadding);

            pause = new Rect(
                screenWidth - buttonPadding - buttonWidth,
                buttonPadding,
                screenWidth - buttonPadding,
                buttonPadding + buttonHeight);

            currentButtonList.add(left);
            currentButtonList.add(right);
            currentButtonList.add(thrust);
            currentButtonList.add(shoot);
            currentButtonList.add(pause);
        }

        public void handleInput(MotionEvent motionEvent) {
            int x = (int) motionEvent.getX(0);
            int y = (int) motionEvent.getY(0);

            switch (motionEvent.getAction() & MotionEvent.ACTION_MASK) {
                case MotionEvent.ACTION_DOWN:
                    if      (right.contains(x, y))  player.setMovementState(player.RIGHT);
                    else if (left.contains(x, y))   player.setMovementState(player.LEFT);
                    else if (thrust.contains(x, y)) player.setMovementState(player.THRUSTING);
                    else if (shoot.contains(x, y)) {
                        playerBullets[nextPlayerBullet].shoot(
                            player.getA().x, player.getA().y, player.getFacingAngle());
                        nextPlayerBullet = (nextPlayerBullet + 1) % maxPlayerBullets;
                        soundPool.play(shootID, 1, 1, 0, 0, 1);
                    } else if (pause.contains(x, y)) {
                        paused = !paused;
                    }
                    break;
                case MotionEvent.ACTION_UP:
                    player.setMovementState(player.STOPPING);
                    break;
            }
        }
    }
}

Common Issues

ArrayIndexOutOfBoundsException in the chain reaction loop. The chain destruction uses bricks[j+k] and bricks[j-k] without bounds checking. If the hit brick is near the start or end of the array, j-k can go negative or j+k can exceed numBricks. Guard the loop: if (j+k < numBricks && j-k >= 0) before each destroy call.

Sound files not found — IOException on launch. The sounds are loaded from assets, not res/raw. Make sure the files are in app/src/main/assets/ (create the folder if it doesn't exist), not in res/. The filenames must match exactly, including the extension.

Ship teleports to a fixed point instead of bouncing. bump() reverses the ship's last-frame displacement using the current horizontalVelocity and verticalVelocity. If those values are zero — because the ship hasn't moved yet, or because the velocity fields got reset — bump() won't move the ship at all, and a second collision check the same frame can re-fire bump() and pull it somewhere unexpected. Check that the ship isn't spawning inside a building.

Viewport conversion returns wrong screen positions. The worldToScreen and worldToScreenPoint methods return references to internal RectF/PointF objects that are overwritten on every call. If you call worldToScreenPoint for vertex A, then again for vertex B, and then try to draw a line using the result of the first call, you'll be using B's coordinates twice. Always copy the returned point into a separate variable before the next call, as the ship draw code does.

Frame rate drops severely on older devices. The inner loop — bullets × bricks — is O(n²) and can hit thousands of iterations per frame if nothing is being culled. Make sure clipObjects is being called in update() before the collision loop, and that clipped bricks are skipped. If performance is still poor, narrow metresToShowX / metresToShowY in Viewport to reduce the clip window.

Where to Take It Next