Android Java Project

2D Rotation and Heading Demo

Every top-down space shooter — Asteroids, Solar Fox, Geometry Wars — uses the same two ideas: a facing angle that rotates when the player steers, and a movement direction derived from that angle using trigonometry. The ship doesn't move left or right; it moves in the direction it's pointing. Get this mechanic right and a huge category of games opens up.

This project strips the idea down to its bones: a white triangle on a blue screen. Touch the bottom-left to rotate left, bottom-right to rotate right, anywhere above that to thrust. The current facing angle is printed on screen so you can watch the maths working in real time.

The rotation demo showing a white triangle ship on a blue background with the facing angle displayed.
The facing angle updates live — watch it cycle as you rotate, then jump when you thrust and the ship changes direction.

Project Setup

Create a new Empty Views Activity project. Language: Java, minimum SDK: API 21. Name the activity HeadingAndRotationActivity.

In AndroidManifest.xml:

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

No assets needed — everything is drawn with Canvas.drawLine.

Ship.java

The ship is three PointF vertices plus a centre point. All rotation and thrust operations manipulate these four points directly.

import android.content.Context;
import android.graphics.PointF;

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

    private float length;
    private float width;
    private float speed = 100;
    private float horizontalVelocity;
    private float verticalVelocity;
    private float rotationSpeed = 100;

    public final int STOPPED   = 0;
    public final int LEFT      = 1;
    public final int RIGHT     = 2;
    public final int THRUSTING = 3;
    private int shipMoving = STOPPED;

    public Ship(Context context, int screenX, int screenY) {
        length = screenX / 5;
        width  = screenY / 5;
        a = new PointF();
        b = new PointF();
        c = new PointF();
        centre = new PointF();

        centre.x = screenX / 2;
        centre.y = screenY / 2;

        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 setMovementState(int state) { shipMoving = state; }

    public void update(long fps) {
        float previousFA = facingAngle;

        if (shipMoving == LEFT) {
            facingAngle -= rotationSpeed / fps;
            if (facingAngle < 1) facingAngle = 360;
        }

        if (shipMoving == RIGHT) {
            facingAngle += rotationSpeed / fps;
            if (facingAngle > 360) facingAngle = 1;
        }

        if (shipMoving == THRUSTING) {
            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;
        }

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

        rotatePoint(a, cosD, sinD);
        rotatePoint(b, cosD, sinD);
        rotatePoint(c, cosD, sinD);
    }

    private void rotatePoint(PointF p, float cosD, float sinD) {
        p.x -= centre.x;
        p.y -= centre.y;
        float tempX = p.x * cosD - p.y * sinD;
        float tempY = p.x * sinD + p.y * cosD;
        p.x = tempX + centre.x;
        p.y = tempY + centre.y;
    }
}

The rotation transform is the 2D rotation matrix applied manually:

x' = x·cos(θ) − y·sin(θ)
y' = x·sin(θ) + y·cos(θ)

The key step is translating each point so the centre is at the origin before rotating, then translating back. If you skip the translate-to-origin step, the vertices orbit around (0,0) instead of around the ship's own centre.

The initial facingAngle is 270 degrees because in Android's coordinate system (y increases downward), 270° points upward on screen. Math.cos(270°) = 0 and Math.sin(270°) = −1 — pure upward movement in screen coordinates.

HeadingAndRotationView.java

public class HeadingAndRotationView extends SurfaceView implements Runnable {

    Thread gameThread = null;
    SurfaceHolder ourHolder;
    volatile boolean playing;
    boolean paused = true;
    Canvas canvas;
    Paint paint;
    long fps;
    int screenX, screenY;
    Ship ship;

    public HeadingAndRotationView(Context context, int x, int y) {
        super(context);
        ourHolder = getHolder();
        paint     = new Paint();
        screenX   = x;
        screenY   = y;
        ship = new Ship(context, screenX, screenY);
    }

    @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() { ship.update(fps); }

    private void draw() {
        if (ourHolder.getSurface().isValid()) {
            canvas = ourHolder.lockCanvas();
            canvas.drawColor(Color.argb(255, 26, 128, 182));
            paint.setColor(Color.argb(255, 255, 255, 255));

            canvas.drawLine(ship.getA().x, ship.getA().y,
                            ship.getB().x, ship.getB().y, paint);
            canvas.drawLine(ship.getB().x, ship.getB().y,
                            ship.getC().x, ship.getC().y, paint);
            canvas.drawLine(ship.getC().x, ship.getC().y,
                            ship.getA().x, ship.getA().y, paint);
            canvas.drawPoint(ship.getCentre().x, ship.getCentre().y, paint);

            paint.setTextSize(60);
            canvas.drawText("facingAngle = " + (int) ship.getFacingAngle() + " degrees",
                            20, 70, 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:
                paused = false;
                if (motionEvent.getY() > screenY - screenY / 8) {
                    // Bottom strip: steer
                    ship.setMovementState(
                        motionEvent.getX() > screenX / 2 ? ship.RIGHT : ship.LEFT);
                } else {
                    // Anywhere else: thrust
                    ship.setMovementState(ship.THRUSTING);
                }
                break;
            case MotionEvent.ACTION_UP:
                ship.setMovementState(ship.STOPPED);
                break;
        }
        return true;
    }
}

Touch input is split by vertical position: the bottom eighth of the screen is the steering strip; anywhere above it fires the thruster. This keeps the controls intuitive without any on-screen buttons — a natural layout for a demo.

HeadingAndRotationActivity.java

public class HeadingAndRotationActivity extends Activity {
    HeadingAndRotationView view;

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

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

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

Common Issues

Ship spins around a fixed point on screen instead of its own centre. The translate-before-rotate step is missing or wrong. Before applying the rotation matrix, subtract centre.x and centre.y from each vertex. After the rotation, add them back. The rotatePoint helper does this correctly — make sure it's being called with the current centre, not a stale copy.

Thrusting moves the ship in the wrong direction. Android's y-axis points downward, so 0° and 180° move horizontally, 90° moves down, and 270° moves up. If up on screen is moving the ship down in your game, flip the sign of verticalVelocity. The initial facingAngle = 270 is set correctly for upward movement.

Ship drifts after rotating without thrusting. The rotation code also increments horizontalVelocity and verticalVelocity based on the facing angle — but those values are only applied to position in the THRUSTING block. If you've added extra logic that applies velocity outside that block, remove it.

Where to Take It Next