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.
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.
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:
skyline.png— distant city skyline (scrolls at 50 px/sec)grass.png— foreground grass strip (scrolls at 200 px/sec)
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
- Add a third layer. A midground layer between the skyline and grass — buildings or trees at 120 px/sec — deepens the parallax effect significantly. The
ArrayListand draw-order approach already supports this without any structural changes. - Tie scroll speed to player velocity. Instead of a fixed
speed, pass the player's current horizontal velocity into eachBackground.update()call. When the player accelerates, the world scrolls faster. When they stop, the world stops. This is how most real side-scrollers work. - Vertical parallax. The same mirroring technique works vertically for top-down or vertical-scrolling games — swap the
Matrix.setScale(-1, 1)forsetScale(1, -1)and adjust the clipping rectangles to use Y instead of X.