A sprite sheet packs multiple animation frames into a single image, side by side. Instead of loading five separate bitmaps for a five-frame walk cycle, you load one and slide a window across it — showing frame 0, then frame 1, then frame 2, and so on. One texture load, one draw call, clean animation.
The trickier part is timing. Your game loop runs at whatever FPS the device can manage — 60, 45, 30. You almost certainly don't want animation frames changing at that rate. A walking character advancing a frame 60 times per second is a blur. This project shows how to decouple animation timing from the loop: a wall-clock check advances the frame only when a fixed millisecond interval has elapsed.
The Sprite Sheet
Rect selects which frame to show.
canvas.drawBitmap(bitmap, frameToDraw, whereToDraw, paint) — source rect picks the frame, destination rect places it on screen.Project Setup
Create a new Empty Views Activity project. Language: Java, minimum SDK: API 21. Name the activity SpriteSheetAnimation.
Add fullscreen landscape to AndroidManifest.xml:
<activity
android:name=".SpriteSheetAnimation"
android:theme="@android:style/Theme.NoTitleBar.Fullscreen"
android:screenOrientation="landscape"
android:exported="true">
...
</activity>
The Bob Sprite Sheet
Save this file into app/src/main/res/drawable and name it bob.png:
bob.png— five-frame walking sprite sheet
SpriteSheetAnimation.java
The whole project lives in one file: the SpriteSheetAnimation activity containing a GameView inner class.
import android.app.Activity;
import android.content.Context;
import android.graphics.Bitmap;
import android.graphics.BitmapFactory;
import android.graphics.Canvas;
import android.graphics.Color;
import android.graphics.Paint;
import android.graphics.Rect;
import android.graphics.RectF;
import android.os.Bundle;
import android.util.Log;
import android.view.MotionEvent;
import android.view.SurfaceHolder;
import android.view.SurfaceView;
public class SpriteSheetAnimation extends Activity {
GameView gameView;
@Override
protected void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
gameView = new GameView(this);
setContentView(gameView);
}
@Override protected void onResume() { super.onResume(); gameView.resume(); }
@Override protected void onPause() { super.onPause(); gameView.pause(); }
class GameView extends SurfaceView implements Runnable {
Thread gameThread = null;
SurfaceHolder ourHolder;
volatile boolean playing;
Canvas canvas;
Paint paint;
long fps;
private long timeThisFrame;
Bitmap bitmapBob;
boolean isMoving = false;
float walkSpeedPerSecond = 250;
float bobXPosition = 10;
private int frameWidth = 100;
private int frameHeight = 50;
private int frameCount = 5;
private int currentFrame = 0;
private long lastFrameChangeTime = 0;
private int frameLengthInMilliseconds = 100;
private Rect frameToDraw = new Rect(0, 0, frameWidth, frameHeight);
private RectF whereToDraw = new RectF(bobXPosition, 0,
bobXPosition + frameWidth, frameHeight);
public GameView(Context context) {
super(context);
ourHolder = getHolder();
paint = new Paint();
bitmapBob = BitmapFactory.decodeResource(
this.getResources(), R.drawable.bob);
bitmapBob = Bitmap.createScaledBitmap(
bitmapBob, frameWidth * frameCount, frameHeight, false);
}
@Override
public void run() {
while (playing) {
long startFrameTime = System.currentTimeMillis();
update();
draw();
timeThisFrame = System.currentTimeMillis() - startFrameTime;
if (timeThisFrame >= 1) fps = 1000 / timeThisFrame;
}
}
public void update() {
if (isMoving) {
bobXPosition += walkSpeedPerSecond / fps;
}
}
public void getCurrentFrame() {
long time = System.currentTimeMillis();
if (isMoving) {
if (time > lastFrameChangeTime + frameLengthInMilliseconds) {
lastFrameChangeTime = time;
currentFrame++;
if (currentFrame >= frameCount) currentFrame = 0;
}
}
frameToDraw.left = currentFrame * frameWidth;
frameToDraw.right = frameToDraw.left + frameWidth;
}
public void draw() {
if (ourHolder.getSurface().isValid()) {
canvas = ourHolder.lockCanvas();
canvas.drawColor(Color.argb(255, 26, 128, 182));
paint.setColor(Color.argb(255, 249, 129, 0));
paint.setTextSize(45);
canvas.drawText("FPS:" + fps, 20, 40, paint);
whereToDraw.set((int) bobXPosition, 0,
(int) bobXPosition + frameWidth, frameHeight);
getCurrentFrame();
canvas.drawBitmap(bitmapBob, frameToDraw, whereToDraw, 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: isMoving = true; break;
case MotionEvent.ACTION_UP: isMoving = false; break;
}
return true;
}
}
}
The critical separation is between update() and getCurrentFrame(). update() advances the X position using FPS-scaled delta — so Bob moves at exactly 250 pixels per second regardless of frame rate. getCurrentFrame() uses a wall-clock check: it only advances the animation frame when at least 100 ms has passed since the last change. The two counters are completely independent.
The frameToDraw source Rect is updated each frame by setting its left to currentFrame × frameWidth and right to left + frameWidth. This slides the selection window across the sprite sheet. Android's drawBitmap(bitmap, src, dst, paint) overload handles the crop and scale from source to destination in a single call.
The Reusable Animation Class
Once your game has more than one animated character, it's worth extracting the frame logic into its own class. Here's a version designed to plug into a larger project:
import android.content.Context;
import android.graphics.Bitmap;
import android.graphics.Rect;
public class Animation {
Bitmap bitmapSheet;
String bitmapName;
private Rect sourceRect;
private int frameCount;
private int currentFrame;
private long frameTicker;
private int framePeriod;
private int frameWidth;
private int frameHeight;
int pixelsPerMetre;
Animation(Context context, String bitmapName, float frameHeight, float frameWidth,
int animFps, int frameCount, int pixelsPerMetre) {
this.currentFrame = 0;
this.frameCount = frameCount;
this.frameWidth = (int) frameWidth * pixelsPerMetre;
this.frameHeight = (int) frameHeight * pixelsPerMetre;
sourceRect = new Rect(0, 0, this.frameWidth, this.frameHeight);
framePeriod = 1000 / animFps;
frameTicker = 0L;
this.bitmapName = bitmapName;
this.pixelsPerMetre = pixelsPerMetre;
}
public Rect getCurrentFrame(long time, float xVelocity, boolean moves) {
if (xVelocity != 0 || !moves) {
if (time > frameTicker + framePeriod) {
frameTicker = time;
currentFrame++;
if (currentFrame >= frameCount) currentFrame = 0;
}
}
sourceRect.left = currentFrame * frameWidth;
sourceRect.right = sourceRect.left + frameWidth;
return sourceRect;
}
}
This version takes a pixelsPerMetre scale factor, making it compatible with viewport-based projects where the world uses logical units rather than raw pixels. It also accepts an xVelocity parameter — if the character isn't moving horizontally (xVelocity == 0) and moves is true, the animation pauses. This gives you idle/walking states for free without extra logic in the caller.
Common Issues
Animation plays but all frames look the same — only the first frame is visible. frameToDraw.left isn't being updated. Make sure getCurrentFrame() is called every draw cycle and that it sets frameToDraw.left = currentFrame * frameWidth and frameToDraw.right = frameToDraw.left + frameWidth.
Bob is a stretched blur — frame dimensions are wrong. The sprite sheet was scaled to frameWidth * frameCount wide. If frameWidth or frameCount don't match the actual image, the scaling will be wrong and each frame slice will show parts of adjacent frames. Measure the original image first.
Animation runs at different speeds on different devices. You're probably incrementing currentFrame in update() (which is FPS-dependent) instead of in getCurrentFrame() (which uses wall-clock time). The wall-clock check is what keeps animation speed consistent.
Bob walks off screen and never returns. bobXPosition is never reset. Add a check after the position update: if (bobXPosition > screenWidth) bobXPosition = -frameWidth; to wrap Bob back to the left edge.
Where to Take It Next
- Idle animation. Add a second, shorter sprite sheet for standing still. Switch between sheets based on the
isMovingflag — when Bob stops, swapbitmapBobfor the idle sheet and resetcurrentFrameto 0. - Directional flip. When Bob moves left, flip the bitmap horizontally using a
Matrix.setScale(-1, 1)transform so he always faces the direction of travel. One sprite sheet, both directions. - Multiple characters. Extract the character into its own class holding a position, velocity, and an
Animationinstance. Put them in anArrayListand loop over them in update and draw. You instantly have a crowd scene.