Android Java Project

Parallax Scrolling Background

Parallax scrolling is the effect where background layers closer to the viewer appear to move faster than layers further away. Your eyes are fooled into perceiving depth even on a completely flat screen. It's been a staple of 2D games since the early 1980s — Moon Patrol on the Atari used it in 1982 — and it's still one of the cheapest visual improvements you can make to any side-scrolling game.

The implementation here is elegant: instead of tiling a background image across the screen, you take a single image and place its horizontally-mirrored copy directly beside it. When you scroll the pair left, the seam between the original and its mirror is invisible because the pixel at the join is the same pixel viewed from either direction. When the pair scrolls fully off-screen you reset the position and repeat — a perfectly seamless infinite loop with no visible tile boundary.

Parallax scrolling demo showing a city skyline and grass layer scrolling at different speeds.
Two layers — a distant skyline at 50 px/sec and a close grass strip at 200 px/sec — create the illusion of depth.

How the Loop Works

Each layer is one image wide on screen. A xClip value tracks how many pixels have scrolled. The draw method uses two source rectangles to slice the right-hand portion of the bitmap and the left-hand portion of its mirror, compositing them side-by-side to fill the screen seamlessly.

Diagram showing a background image and its mirror placed side by side.
The image and its horizontal mirror placed side by side — the join is invisible because both edges are identical.
Diagram showing the clipping rectangle shifting as the image scrolls left.
As xClip grows, the visible slice moves right on the image — which means the image appears to scroll left on screen.
Diagram showing the two source rectangles used to fill the screen.
Two source rects fill the screen: the tail of the regular image on the right, the head of the reversed image on the left.
Diagram showing the xClip reset and images swapping when the scroll completes.
When xClip reaches the image width the roles swap — reversed becomes regular and vice versa — and the clip resets to zero.

Project Setup

Create a new Empty Views Activity project in Android Studio. Language: Java, minimum SDK: API 21. Name the activity ParallaxActivity.

In AndroidManifest.xml add fullscreen landscape to the activity:

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

Background Assets

Place these two PNG files in app/src/main/res/drawable. Right-click each to save:

Background.java

This class holds one scrolling layer: the original bitmap, its mirror, the current clip position, and the scroll speed.

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

public class Background {
    Bitmap bitmap;
    Bitmap bitmapReversed;
    int    width;
    int    height;
    boolean reversedFirst;
    float  speed;
    int    xClip;
    int    startY;
    int    endY;

    Background(Context context, int screenWidth, int screenHeight,
               String bitmapName, int sY, int eY, float s) {

        int resID = context.getResources().getIdentifier(
                bitmapName, "drawable", context.getPackageName());
        bitmap = BitmapFactory.decodeResource(context.getResources(), resID);

        reversedFirst = false;
        xClip  = 0;
        startY = sY * (screenHeight / 100);
        endY   = eY * (screenHeight / 100);
        speed  = s;

        bitmap = Bitmap.createScaledBitmap(bitmap, screenWidth, (endY - startY), true);
        width  = bitmap.getWidth();
        height = bitmap.getHeight();

        Matrix matrix = new Matrix();
        matrix.setScale(-1, 1);
        bitmapReversed = Bitmap.createBitmap(bitmap, 0, 0, width, height, matrix, true);
    }

    public void update(long fps) {
        xClip -= speed / fps;
        if (xClip >= width) {
            xClip = 0;
            reversedFirst = !reversedFirst;
        } else if (xClip <= 0) {
            xClip = width;
            reversedFirst = !reversedFirst;
        }
    }
}

The mirror is created once in the constructor using Android's Matrix.setScale(-1, 1) — a horizontal flip. Bitmap.createBitmap accepts a Matrix parameter and applies it during the copy. After this the original and mirror sit in memory and are never regenerated.

startY and endY use percentage values (0–100) of the screen height. Passing 0, 80 for the skyline places it from the top down to 80% of the screen height. Passing 70, 110 for the grass (which intentionally overruns 100%) places it overlapping the bottom quarter of the skyline. This layering is part of what creates the depth.

ParallaxView.java

import android.content.Context;
import android.graphics.Canvas;
import android.graphics.Color;
import android.graphics.Paint;
import android.graphics.Rect;
import android.view.SurfaceHolder;
import android.view.SurfaceView;
import java.util.ArrayList;

public class ParallaxView extends SurfaceView implements Runnable {

    ArrayList<Background> backgrounds;
    private volatile boolean running;
    private Thread gameThread = null;
    private Paint paint;
    private Canvas canvas;
    private SurfaceHolder ourHolder;
    Context context;
    long fps = 60;
    int screenWidth;
    int screenHeight;

    ParallaxView(Context context, int screenWidth, int screenHeight) {
        super(context);
        this.context     = context;
        this.screenWidth  = screenWidth;
        this.screenHeight = screenHeight;
        ourHolder = getHolder();
        paint = new Paint();
        backgrounds = new ArrayList<>();

        backgrounds.add(new Background(this.context, screenWidth, screenHeight,
                "skyline", 0, 80, 50));
        backgrounds.add(new Background(this.context, screenWidth, screenHeight,
                "grass", 70, 110, 200));
    }

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

    private void update() {
        for (Background bg : backgrounds) bg.update(fps);
    }

    private void drawBackground(int position) {
        Background bg = backgrounds.get(position);

        Rect fromRect1 = new Rect(0, 0, bg.width - bg.xClip, bg.height);
        Rect toRect1   = new Rect(bg.xClip, bg.startY, bg.width, bg.endY);

        Rect fromRect2 = new Rect(bg.width - bg.xClip, 0, bg.width, bg.height);
        Rect toRect2   = new Rect(0, bg.startY, bg.xClip, bg.endY);

        if (!bg.reversedFirst) {
            canvas.drawBitmap(bg.bitmap,         fromRect1, toRect1, paint);
            canvas.drawBitmap(bg.bitmapReversed, fromRect2, toRect2, paint);
        } else {
            canvas.drawBitmap(bg.bitmap,         fromRect2, toRect2, paint);
            canvas.drawBitmap(bg.bitmapReversed, fromRect1, toRect1, paint);
        }
    }

    private void draw() {
        if (ourHolder.getSurface().isValid()) {
            canvas = ourHolder.lockCanvas();
            canvas.drawColor(Color.argb(255, 0, 3, 70));
            drawBackground(0);     // skyline behind everything
            paint.setTextSize(60);
            paint.setColor(Color.argb(255, 255, 255, 255));
            canvas.drawText("I am a plane", 350, screenHeight / 100 * 5, paint);
            paint.setTextSize(220);
            canvas.drawText("I'm a train", 50, screenHeight / 100 * 80, paint);
            drawBackground(1);     // grass in front
            ourHolder.unlockCanvasAndPost(canvas);
        }
    }

    public void pause() {
        running = false;
        try { gameThread.join(); }
        catch (InterruptedException e) { /* ignore */ }
    }

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

The draw order matters: the dark sky colour goes first, then the distant skyline layer, then your game content (the placeholder text), and finally the grass strip on top. This is the correct painter's order — things nearer the camera are drawn later and appear in front of things further away.

Notice that this view always runs — there's no paused flag. For a background layer there's nothing to pause: even when gameplay is frozen the scenery should keep scrolling. If you need it to stop, set speed on each Background to 0.

ParallaxActivity.java

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

public class ParallaxActivity extends Activity {
    private ParallaxView parallaxView;

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

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

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

Common Issues

Visible seam or flicker at the join point. This almost always means reversedFirst is being toggled at the wrong moment. The toggle should happen when xClip exceeds width going right, or falls below 0 going left — check the boundary condition in Background.update() carefully.

Bitmap resource not found — NullPointerException on launch. getIdentifier looks up resources by name as a string. The filename must match exactly (without the extension) and the resource must be in res/drawable, not res/drawable-hdpi or similar density-specific folders.

Layers overlap incorrectly — grass is behind the skyline. Draw order in draw() is the fix: call drawBackground(0) before any game content and drawBackground(1) after, so the foreground layer composites on top.

Images look squashed or stretched. Bitmap.createScaledBitmap ignores the original aspect ratio and scales to exactly screenWidth × (endY - startY). Design your background images with a wide aspect ratio (4:1 or wider) to minimise distortion on standard landscape screens.

Where to Take It Next