Android Java Project

Coding a Pong Game for Android

Pong is one of those games people underestimate. It looks like a box and two rectangles, but getting the physics to feel satisfying — the ball speed scaling with screen resolution, the bat fast enough to be responsive but not so fast it teleports, the velocity randomised on each bat hit so you can't just park the bat in the middle — all of that takes real thought. It's a better learning project than it looks.

This single-player version keeps the rules simple: bounce the ball off the bat to score points. The ball speeds up slightly with every successful hit. Miss and you lose a life. Three lives and it's game over. The bat is controlled by holding the left or right half of the screen — lift your finger to stop.

The project uses four files:

Pong game running on Android showing the green background, white bat, ball, score and lives.
Score goes up every time the bat connects; lose all three lives and the game resets.

Project Setup

Open Android Studio and create a new Empty Views Activity project. Set the language to Java and minimum SDK to API 21. Name the Activity MainActivity.

Open AndroidManifest.xml, find the <activity> tag, and add:

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

Sound Assets

Right-click the main folder in the Project pane and choose New → Folder → Assets Folder. Right-click each link to save, then drop the files into app/src/main/assets:

Ball.java

The ball is a RectF that moves by velocity scaled to the current FPS. Everything is set relative to screen dimensions in the constructor so the game works identically on phones and tablets:

public class Ball {

    private RectF mRect;
    private float mXVelocity;
    private float mYVelocity;
    private float mBallWidth;
    private float mBallHeight;

    public Ball(int screenX, int screenY) {
        mBallWidth  = screenX / 100;
        mBallHeight = mBallWidth;

        mYVelocity = screenY / 4;   // quarter screen per second
        mXVelocity = mYVelocity;

        mRect = new RectF();
    }

    public RectF getRect() { return mRect; }

    public void update(long fps) {
        mRect.left   += mXVelocity / fps;
        mRect.top    += mYVelocity / fps;
        mRect.right   = mRect.left + mBallWidth;
        mRect.bottom  = mRect.top  - mBallHeight;
    }

    public void reverseYVelocity() { mYVelocity = -mYVelocity; }
    public void reverseXVelocity() { mXVelocity = -mXVelocity; }

    public void setRandomXVelocity() {
        Random generator = new Random();
        if (generator.nextInt(2) == 0) {
            reverseXVelocity();
        }
    }

    public void increaseVelocity() {
        mXVelocity += mXVelocity / 10;
        mYVelocity += mYVelocity / 10;
    }

    public void clearObstacleY(float y) {
        mRect.bottom = y;
        mRect.top    = y - mBallHeight;
    }

    public void clearObstacleX(float x) {
        mRect.left  = x;
        mRect.right = x + mBallWidth;
    }

    public void reset(int x, int y) {
        mRect.left   = x / 2;
        mRect.top    = y - 20;
        mRect.right  = x / 2 + mBallWidth;
        mRect.bottom = y - 20 - mBallHeight;
    }
}

clearObstacleY and clearObstacleX snap the ball back to just outside the surface it collided with before reversing velocity. Without this, if the ball is moving fast and a frame moves it deep inside a wall, reversing velocity alone wouldn't help — it would just oscillate back and forth inside the wall. The clear methods prevent that entirely.

increaseVelocity adds 10% to both axes on every bat hit. The acceleration is compounding, so a long rally gets genuinely frantic near the end.

Bat.java

The bat has three movement states — STOPPED, LEFT, RIGHT — and moves at screen-width pixels per second, meaning it crosses the full screen in exactly one second at full speed:

public class Bat {

    private RectF mRect;
    private float mLength;
    private float mHeight;
    private float mXCoord;
    private float mYCoord;
    private float mBatSpeed;

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

    private int mBatMoving = STOPPED;
    private int mScreenX;
    private int mScreenY;

    public Bat(int x, int y) {
        mScreenX = x;
        mScreenY = y;

        mLength = mScreenX / 8;
        mHeight = mScreenY / 25;

        mXCoord = mScreenX / 2;
        mYCoord = mScreenY - 20;

        mRect = new RectF(mXCoord, mYCoord, mXCoord + mLength, mYCoord + mHeight);

        mBatSpeed = mScreenX;   // full screen per second
    }

    public RectF getRect() { return mRect; }

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

    public void update(long fps) {
        if (mBatMoving == LEFT)  mXCoord -= mBatSpeed / fps;
        if (mBatMoving == RIGHT) mXCoord += mBatSpeed / fps;

        if (mRect.left < 0)               mXCoord = 0;
        if (mRect.right > mScreenX)       mXCoord = mScreenX - (mRect.right - mRect.left);

        mRect.left  = mXCoord;
        mRect.right = mXCoord + mLength;
    }
}

The wall clamp is applied to mRect before it's updated with the new mXCoord, which means on the frame it hits the wall it snaps cleanly without the bat clipping through. Setting bat speed to mScreenX (screen width in pixels) might sound arbitrary, but it means the bat is always fast enough to cover the screen in one second regardless of device resolution — it scales automatically.

MainActivity.java

public class MainActivity extends Activity {

    PongView pongView;

    @Override
    protected void onCreate(Bundle savedInstanceState) {
        super.onCreate(savedInstanceState);

        Display display = getWindowManager().getDefaultDisplay();
        Point size = new Point();
        display.getSize(size);

        pongView = new PongView(this, size.x, size.y);
        setContentView(pongView);
    }

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

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

PongView.java

PongView wires everything together: the game loop thread, collision detection, sound, and drawing. Let's look at the key sections.

Constructor and Audio Setup

public PongView(Context context, int x, int y) {
    super(context);

    mScreenX = x;
    mScreenY = y;

    mOurHolder = getHolder();
    mPaint = new Paint();

    mBat  = new Bat(mScreenX, mScreenY);
    mBall = new Ball(mScreenX, mScreenY);

    if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP) {
        AudioAttributes audioAttributes = new AudioAttributes.Builder()
                .setUsage(AudioAttributes.USAGE_MEDIA)
                .setContentType(AudioAttributes.CONTENT_TYPE_SONIFICATION)
                .build();

        sp = new SoundPool.Builder()
                .setMaxStreams(5)
                .setAudioAttributes(audioAttributes)
                .build();
    } else {
        sp = new SoundPool(5, AudioManager.STREAM_MUSIC, 0);
    }

    try {
        AssetManager assetManager = context.getAssets();
        AssetFileDescriptor descriptor;

        descriptor = assetManager.openFd("beep1.ogg");
        beep1ID = sp.load(descriptor, 0);
        descriptor = assetManager.openFd("beep2.ogg");
        beep2ID = sp.load(descriptor, 0);
        descriptor = assetManager.openFd("beep3.ogg");
        beep3ID = sp.load(descriptor, 0);
        descriptor = assetManager.openFd("loseLife.ogg");
        loseLifeID = sp.load(descriptor, 0);
    } catch (IOException e) {
        Log.e("error", "failed to load sound files");
    }

    setupAndRestart();
}

The Build.VERSION.SDK_INT check handles both old and new Android versions: API 21+ gets the modern SoundPool.Builder with explicit AudioAttributes; older versions use the deprecated single-argument constructor. In practice everything you'll run on today supports the new path, but the guard keeps the code clean if you ever target API 18+.

Update

public void update() {
    mBat.update(mFPS);
    mBall.update(mFPS);

    // Ball hits bat
    if (RectF.intersects(mBat.getRect(), mBall.getRect())) {
        mBall.setRandomXVelocity();
        mBall.reverseYVelocity();
        mBall.clearObstacleY(mBat.getRect().top - 2);
        mScore++;
        mBall.increaseVelocity();
        sp.play(beep1ID, 1, 1, 0, 0, 1);
    }

    // Ball hits bottom — lose a life
    if (mBall.getRect().bottom > mScreenY) {
        mBall.reverseYVelocity();
        mBall.clearObstacleY(mScreenY - 2);
        mLives--;
        sp.play(loseLifeID, 1, 1, 0, 0, 1);

        if (mLives == 0) {
            mPaused = true;
            setupAndRestart();
        }
    }

    // Top wall
    if (mBall.getRect().top < 0) {
        mBall.reverseYVelocity();
        mBall.clearObstacleY(12);
        sp.play(beep2ID, 1, 1, 0, 0, 1);
    }

    // Left wall
    if (mBall.getRect().left < 0) {
        mBall.reverseXVelocity();
        mBall.clearObstacleX(2);
        sp.play(beep3ID, 1, 1, 0, 0, 1);
    }

    // Right wall
    if (mBall.getRect().right > mScreenX) {
        mBall.reverseXVelocity();
        mBall.clearObstacleX(mScreenX - 22);
        sp.play(beep3ID, 1, 1, 0, 0, 1);
    }
}

The bat collision uses RectF.intersects() — the same static call used throughout these Android projects. After a bat hit the X direction is randomised with setRandomXVelocity(): this 50/50 coin flip means you can't predict exactly where the ball will go, which is the entire reason Pong is interesting to play rather than a solved problem.

Touch and Draw

@Override
public boolean onTouchEvent(MotionEvent motionEvent) {
    switch (motionEvent.getAction() & MotionEvent.ACTION_MASK) {
        case MotionEvent.ACTION_DOWN:
            mPaused = false;
            if (motionEvent.getX() > mScreenX / 2) {
                mBat.setMovementState(mBat.RIGHT);
            } else {
                mBat.setMovementState(mBat.LEFT);
            }
            break;
        case MotionEvent.ACTION_UP:
            mBat.setMovementState(mBat.STOPPED);
            break;
    }
    return true;
}
public void draw() {
    if (mOurHolder.getSurface().isValid()) {
        mCanvas = mOurHolder.lockCanvas();
        mCanvas.drawColor(Color.argb(255, 120, 197, 87));
        mPaint.setColor(Color.argb(255, 255, 255, 255));
        mCanvas.drawRect(mBat.getRect(),  mPaint);
        mCanvas.drawRect(mBall.getRect(), mPaint);
        mPaint.setTextSize(40);
        mCanvas.drawText("Score: " + mScore + "   Lives: " + mLives, 10, 50, mPaint);
        mOurHolder.unlockCanvasAndPost(mCanvas);
    }
}

Complete Listing — PongView.java

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.media.AudioAttributes;
import android.media.AudioManager;
import android.media.SoundPool;
import android.os.Build;
import android.util.Log;
import android.view.MotionEvent;
import android.view.SurfaceHolder;
import android.view.SurfaceView;
import java.io.IOException;

class PongView extends SurfaceView implements Runnable {

    Thread mGameThread = null;
    SurfaceHolder mOurHolder;
    volatile boolean mPlaying;
    boolean mPaused = true;
    Canvas mCanvas;
    Paint mPaint;
    long mFPS;
    int mScreenX;
    int mScreenY;
    Bat mBat;
    Ball mBall;
    SoundPool sp;
    int beep1ID    = -1;
    int beep2ID    = -1;
    int beep3ID    = -1;
    int loseLifeID = -1;
    int mScore = 0;
    int mLives = 3;

    public PongView(Context context, int x, int y) {
        super(context);
        mScreenX = x;
        mScreenY = y;
        mOurHolder = getHolder();
        mPaint = new Paint();
        mBat  = new Bat(mScreenX, mScreenY);
        mBall = new Ball(mScreenX, mScreenY);

        if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP) {
            AudioAttributes audioAttributes = new AudioAttributes.Builder()
                    .setUsage(AudioAttributes.USAGE_MEDIA)
                    .setContentType(AudioAttributes.CONTENT_TYPE_SONIFICATION)
                    .build();
            sp = new SoundPool.Builder()
                    .setMaxStreams(5)
                    .setAudioAttributes(audioAttributes)
                    .build();
        } else {
            sp = new SoundPool(5, AudioManager.STREAM_MUSIC, 0);
        }

        try {
            AssetManager assetManager = context.getAssets();
            AssetFileDescriptor descriptor;
            descriptor = assetManager.openFd("beep1.ogg");
            beep1ID = sp.load(descriptor, 0);
            descriptor = assetManager.openFd("beep2.ogg");
            beep2ID = sp.load(descriptor, 0);
            descriptor = assetManager.openFd("beep3.ogg");
            beep3ID = sp.load(descriptor, 0);
            descriptor = assetManager.openFd("loseLife.ogg");
            loseLifeID = sp.load(descriptor, 0);
        } catch (IOException e) {
            Log.e("error", "failed to load sound files");
        }
        setupAndRestart();
    }

    public void setupAndRestart() {
        mBall.reset(mScreenX, mScreenY);
        if (mLives == 0) {
            mScore = 0;
            mLives = 3;
        }
    }

    @Override
    public void run() {
        while (mPlaying) {
            long startFrameTime = System.currentTimeMillis();
            if (!mPaused) update();
            draw();
            long timeThisFrame = System.currentTimeMillis() - startFrameTime;
            if (timeThisFrame >= 1) mFPS = 1000 / timeThisFrame;
        }
    }

    public void update() {
        mBat.update(mFPS);
        mBall.update(mFPS);

        if (RectF.intersects(mBat.getRect(), mBall.getRect())) {
            mBall.setRandomXVelocity();
            mBall.reverseYVelocity();
            mBall.clearObstacleY(mBat.getRect().top - 2);
            mScore++;
            mBall.increaseVelocity();
            sp.play(beep1ID, 1, 1, 0, 0, 1);
        }

        if (mBall.getRect().bottom > mScreenY) {
            mBall.reverseYVelocity();
            mBall.clearObstacleY(mScreenY - 2);
            mLives--;
            sp.play(loseLifeID, 1, 1, 0, 0, 1);
            if (mLives == 0) { mPaused = true; setupAndRestart(); }
        }

        if (mBall.getRect().top < 0) {
            mBall.reverseYVelocity();
            mBall.clearObstacleY(12);
            sp.play(beep2ID, 1, 1, 0, 0, 1);
        }

        if (mBall.getRect().left < 0) {
            mBall.reverseXVelocity();
            mBall.clearObstacleX(2);
            sp.play(beep3ID, 1, 1, 0, 0, 1);
        }

        if (mBall.getRect().right > mScreenX) {
            mBall.reverseXVelocity();
            mBall.clearObstacleX(mScreenX - 22);
            sp.play(beep3ID, 1, 1, 0, 0, 1);
        }
    }

    public void draw() {
        if (mOurHolder.getSurface().isValid()) {
            mCanvas = mOurHolder.lockCanvas();
            mCanvas.drawColor(Color.argb(255, 120, 197, 87));
            mPaint.setColor(Color.argb(255, 255, 255, 255));
            mCanvas.drawRect(mBat.getRect(),  mPaint);
            mCanvas.drawRect(mBall.getRect(), mPaint);
            mPaint.setTextSize(40);
            mCanvas.drawText("Score: " + mScore + "   Lives: " + mLives, 10, 50, mPaint);
            mOurHolder.unlockCanvasAndPost(mCanvas);
        }
    }

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

    public void resume() {
        mPlaying = true;
        mGameThread = new Thread(this);
        mGameThread.start();
    }

    @Override
    public boolean onTouchEvent(MotionEvent motionEvent) {
        switch (motionEvent.getAction() & MotionEvent.ACTION_MASK) {
            case MotionEvent.ACTION_DOWN:
                mPaused = false;
                mBat.setMovementState(motionEvent.getX() > mScreenX / 2 ? mBat.RIGHT : mBat.LEFT);
                break;
            case MotionEvent.ACTION_UP:
                mBat.setMovementState(mBat.STOPPED);
                break;
        }
        return true;
    }
}

Common Issues

Ball disappears off one edge and never comes back. This usually means the clearObstacleX call for that wall is clamping the ball to a position that is still inside the wall. Check that the left wall clamps to 2 (not 0) and the right wall clamps to mScreenX - 22 to leave room for the ball width.

Ball passes straight through the bat. RectF.intersects is a static method — it must be called as RectF.intersects(a, b), not a.intersects(b). The instance method has different behaviour and won't work correctly here.

No sound. Confirm the four .ogg files are in app/src/main/assets and that filenames match exactly (case-sensitive). The SoundPool load returns -1 on failure; add a log after each load call to verify the IDs are positive.

Game doesn't start on first touch. mPaused starts as true and is set to false in onTouchEvent — if the manifest doesn't set the activity to fullscreen landscape, the touch zone may be offset. Confirm the manifest attributes are in place.

Where to Take It Next