Android Kotlin Project

Coding a Space Invaders Game in Kotlin

Space Invaders arrived in arcades in 1978 and changed everything. Before it, arcade games were single-screen diversions. Space Invaders was the first game where the enemies actively fought back, where the tension built as the invaders descended, and where destroying them all only meant a faster, angrier wave replaced them. It was the first game with a high score table that saved between plays. The queues outside arcades in Japan were long enough that the game briefly caused a national shortage of 100-yen coins.

It's also the perfect project for learning Android game development. The rules are clear, the objects are well-defined, and the code is big enough to teach real structure without being so big that it becomes overwhelming. By the time you finish this tutorial you'll have a working Space Invaders clone with sprite animation, destructible shelters, a wave-progression system, sound effects, and a persistent high score — all built on Android's SurfaceView and Canvas API in Kotlin.

The project is split across seven classes:

The finished Kotlin Space Invaders game running on Android, showing the invader formation, player ship, shelters, and score display.
The finished game — invader formation, destructible shelters, player ship, and score bar across the top.

Project Assets

The game needs three PNG images — the player's ship and two animation frames for the invaders. Right-click each image below and save it, then drop them into app/src/main/res/drawable in your project.

Player ship sprite playership.png
Invader frame 1 invader1.png
Invader frame 2 invader2.png

For sound effects you'll need six .ogg files: shoot.ogg, invaderexplode.ogg, playerexplode.ogg, damageshelter.ogg, uh.ogg, and oh.ogg. Short, punchy sounds work best — freesound.org has plenty of royalty-free options. Place them in app/src/main/assets.

Project Setup

Creating the Project

Open Android Studio and choose New Project → Empty Views Activity. Set the language to Kotlin and the minimum SDK to API 21 (Android 5.0). Rename the generated MainActivity to KotlinInvadersActivity via Refactor → Rename — Android Studio updates the manifest entry automatically.

Adding the Drawable Assets

The game uses three bitmap images for the player ship and the two invader animation frames. Place these PNG files into app/src/main/res/drawable:

The three drawable assets: player ship and two invader animation frames.
The three drawable assets used by the game. Any similar pixel-art sprites will work.

Adding the Sound Assets

Right-click the main folder in the Project pane and choose New → Folder → Assets Folder. Add these six .ogg files into app/src/main/assets:

Configuring the Manifest

Open AndroidManifest.xml and add two attributes to the <activity> element:

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

Note: Theme.NoTitleBar.Fullscreen requires the activity to extend android.app.Activity, not AppCompatActivity. Check your class declaration if you get a theme-related crash at startup.

KotlinInvadersActivity

The activity is deliberately thin. It measures the display, creates the game view, and handles the two lifecycle events that matter:

package com.gamecodeschool.kotlininvaders

import android.app.Activity
import android.graphics.Point
import android.os.Bundle

class KotlinInvadersActivity : Activity() {

    private var kotlinInvadersView: KotlinInvadersView? = null

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)

        val display = windowManager.defaultDisplay
        val size = Point()
        display.getSize(size)

        kotlinInvadersView = KotlinInvadersView(this, size)
        setContentView(kotlinInvadersView)
    }

    override fun onResume() {
        super.onResume()
        kotlinInvadersView?.resume()
    }

    override fun onPause() {
        super.onPause()
        kotlinInvadersView?.pause()
    }
}

onCreate reads the display resolution into a Point and hands it to KotlinInvadersView. Because KotlinInvadersView extends SurfaceView, it can be passed directly to setContentView to fill the screen. onResume and onPause delegate straight to the view — they start and stop the game thread in step with the activity lifecycle.

PlayerShip

The player's ship manages its own bitmap, dimensions, position, and movement state:

package com.gamecodeschool.kotlininvaders

import android.content.Context
import android.graphics.Bitmap
import android.graphics.BitmapFactory
import android.graphics.RectF

class PlayerShip(context: Context,
                 private val screenX: Int,
                 screenY: Int) {

    var bitmap: Bitmap = BitmapFactory.decodeResource(
            context.resources, R.drawable.playership)

    val width = screenX / 20f
    private val height = screenY / 20f

    val position = RectF(
            screenX / 2f,
            screenY - height,
            screenX / 2f + width,
            screenY.toFloat())

    private val speed = 450f

    companion object {
        const val stopped = 0
        const val left    = 1
        const val right   = 2
    }

    var moving = stopped

    init {
        bitmap = Bitmap.createScaledBitmap(bitmap,
                width.toInt(), height.toInt(), false)
    }

    fun update(fps: Long) {
        if (moving == left && position.left > 0) {
            position.left -= speed / fps
        } else if (moving == right && position.left < screenX - width) {
            position.left += speed / fps
        }
        position.right = position.left + width
    }
}

The ship starts at the horizontal centre of the screen, one ship-height up from the bottom. Width and height are fractions of the screen resolution so the ship scales to any device.

The companion object defines three direction constants — stopped, left, right — accessible as PlayerShip.left etc. from anywhere. The moving property is set by the touch handler in KotlinInvadersView and read here in update().

update(fps) moves the ship left or right by speed / fps pixels per call. Dividing by the current frame rate gives frame-rate-independent movement — the ship covers the same distance per second regardless of whether the device runs at 30 or 60 FPS. The boundary checks keep it on screen.

Invader

Each invader instance manages its own position and movement. The two animation bitmaps and the live count are shared across all instances via a companion object:

package com.gamecodeschool.kotlininvaders

import android.content.Context
import android.graphics.Bitmap
import android.graphics.BitmapFactory
import android.graphics.RectF
import java.util.Random

class Invader(context: Context, row: Int, column: Int,
              screenX: Int, screenY: Int) {

    var width   = screenX / 35f
    private var height  = screenY / 35f
    private val padding = screenX / 45

    var position = RectF(
            column * (width + padding),
            100 + row * (width + padding / 4),
            column * (width + padding) + width,
            100 + row * (width + padding / 4) + height)

    private var speed = 40f

    private val left  = 1
    private val right = 2
    private var shipMoving = right

    var isVisible = true

    companion object {
        lateinit var bitmap1: Bitmap
        lateinit var bitmap2: Bitmap
        var numberOfInvaders = 0
    }

    init {
        bitmap1 = BitmapFactory.decodeResource(context.resources, R.drawable.invader1)
        bitmap2 = BitmapFactory.decodeResource(context.resources, R.drawable.invader2)

        bitmap1 = Bitmap.createScaledBitmap(bitmap1, width.toInt(), height.toInt(), false)
        bitmap2 = Bitmap.createScaledBitmap(bitmap2, width.toInt(), height.toInt(), false)

        numberOfInvaders++
    }

    fun update(fps: Long) {
        if (shipMoving == left)  position.left -= speed / fps
        if (shipMoving == right) position.left += speed / fps
        position.right = position.left + width
    }

    fun dropDownAndReverse(waveNumber: Int) {
        shipMoving = if (shipMoving == left) right else left
        position.top    += height
        position.bottom += height
        speed *= (1.1f + (waveNumber.toFloat() / 20))
    }

    fun takeAim(playerShipX: Float, playerShipLength: Float, waves: Int): Boolean {
        val generator = Random()

        // Is the player anywhere under this invader?
        if (playerShipX + playerShipLength > position.left &&
                playerShipX + playerShipLength < position.left + width ||
                playerShipX > position.left && playerShipX < position.left + width) {

            // Fewer invaders alive = each one fires more often
            // Higher wave number = each one fires more often
            if (generator.nextInt((100 * numberOfInvaders) / waves) == 0) {
                return true
            }
        }

        // Random fire regardless of player position
        return generator.nextInt(150 * numberOfInvaders) == 0
    }
}

Each invader is placed in a grid by its row and column constructor arguments. All 66 invaders (11 columns × 6 rows) share bitmap1 and bitmap2 — they're stored once in the companion object rather than duplicated per instance.

dropDownAndReverse() is called when any invader touches a screen edge. Every visible invader drops one row and flips direction. The speed multiplier — 1.1 + waveNumber / 20 — ensures each subsequent wave is noticeably faster.

takeAim() implements the invader AI. If the player is directly below an invader, the firing probability increases (the random range narrows). As the number of surviving invaders shrinks, each remaining one fires proportionally more often. Both factors scale with the wave number — later waves are more aggressive even at full strength.

DefenceBrick

Each shelter is composed of a grid of individual bricks. A brick is nothing more than a rectangle and a visibility flag:

package com.gamecodeschool.kotlininvaders

import android.graphics.RectF

class DefenceBrick(row: Int, column: Int, shelterNumber: Int,
                   screenX: Int, screenY: Int) {

    var isVisible = true

    private val width  = screenX / 180
    private val height = screenY / 80
    private val brickPadding   = 1

    private val shelterPadding = screenX / 12f
    private val startHeight    = screenY - screenY / 10f * 2f

    val position = RectF(
            column * width + brickPadding +
                    shelterPadding * shelterNumber +
                    shelterPadding + shelterPadding * shelterNumber,
            row * height + brickPadding + startHeight,
            column * width + width - brickPadding +
                    shelterPadding * shelterNumber +
                    shelterPadding + shelterPadding * shelterNumber,
            row * height + height - brickPadding + startHeight)
}

Five shelters are built, each 19 columns × 9 rows of bricks, giving 855 bricks in total. The position arithmetic spaces them evenly across the lower portion of the screen. When a bullet hits a brick, isVisible is set to false — both the drawing code and collision detection skip invisible bricks, so shelter damage is permanent within a wave.

Bullet

A single Bullet class handles both the player's shot and the invader swarm's shots. The difference is in how they're instantiated and in which direction they travel:

package com.gamecodeschool.kotlininvaders

import android.graphics.RectF

class Bullet(screenY: Int,
             private val speed: Float = 350f,
             heightModifier: Float = 20f) {

    val position = RectF()

    val up   = 0
    val down = 1
    private var heading = -1

    private val width  = 2
    private var height = screenY / heightModifier

    var isActive = false

    fun shoot(startX: Float, startY: Float, direction: Int): Boolean {
        if (!isActive) {
            position.left   = startX
            position.top    = startY
            position.right  = position.left + width
            position.bottom = position.top + height
            heading  = direction
            isActive = true
            return true
        }
        return false   // Already in flight
    }

    fun update(fps: Long) {
        if (heading == up)   position.top -= speed / fps
        else                 position.top += speed / fps
        position.bottom = position.top + height
    }
}

The player's bullet is created with Bullet(size.y, 1200f, 40f) — nearly 3.5× faster and much shorter than an invader bullet, making it snappier and easier to aim. Invader bullets use the defaults: 350f speed and a height of screenY / 20.

shoot() only fires if the bullet isn't already active — the player can only have one shot in the air at a time. The invader pool works the same way: if all ten slots are active, no new shots are spawned until one leaves the screen.

SoundPlayer

SoundPlayer loads all six audio clips once at startup and exposes a single playSound(id) method:

package com.gamecodeschool.kotlininvaders

import android.content.Context
import android.content.res.AssetFileDescriptor
import android.media.AudioManager
import android.media.SoundPool
import android.util.Log
import java.io.IOException

class SoundPlayer(context: Context) {

    private val soundPool: SoundPool = SoundPool(10, AudioManager.STREAM_MUSIC, 0)

    companion object {
        var playerExplodeID  = -1
        var invaderExplodeID = -1
        var shootID          = -1
        var damageShelterID  = -1
        var uhID             = -1
        var ohID             = -1
    }

    init {
        try {
            val assetManager = context.assets
            var descriptor: AssetFileDescriptor

            descriptor       = assetManager.openFd("shoot.ogg")
            shootID          = soundPool.load(descriptor, 0)

            descriptor       = assetManager.openFd("invaderexplode.ogg")
            invaderExplodeID = soundPool.load(descriptor, 0)

            descriptor       = assetManager.openFd("damageshelter.ogg")
            damageShelterID  = soundPool.load(descriptor, 0)

            descriptor       = assetManager.openFd("playerexplode.ogg")
            playerExplodeID  = soundPool.load(descriptor, 0)

            descriptor       = assetManager.openFd("uh.ogg")
            uhID             = soundPool.load(descriptor, 0)

            descriptor       = assetManager.openFd("oh.ogg")
            ohID             = soundPool.load(descriptor, 0)

        } catch (e: IOException) {
            Log.e("SoundPlayer", "Failed to load sound files")
        }
    }

    fun playSound(id: Int) {
        soundPool.play(id, 1f, 1f, 0, 0, 1f)
    }
}

The sound IDs are stored as companion object properties so any class can reference them as SoundPlayer.shootID without needing a reference to the SoundPlayer instance. playSound() triggers immediate one-shot playback at full volume.

KotlinInvadersView

This is where everything comes together. KotlinInvadersView extends SurfaceView (giving us a thread-safe drawable surface) and implements Runnable (making it the game loop's task). It orchestrates all the other classes.

Member Variables

package com.gamecodeschool.kotlininvaders

import android.content.Context
import android.content.SharedPreferences
import android.graphics.*
import android.util.Log
import android.view.MotionEvent
import android.view.SurfaceView

class KotlinInvadersView(context: Context, private val size: Point)
    : SurfaceView(context), Runnable {

    private val soundPlayer = SoundPlayer(context)

    private var gameThread: Thread = Thread(this)
    private var playing = false
    private var paused  = true

    private var canvas: Canvas = Canvas()
    private val paint: Paint   = Paint()

    private var playerShip = PlayerShip(context, size.x, size.y)

    private val invaders       = ArrayList<Invader>()
    private var numInvaders    = 0

    private val bricks         = ArrayList<DefenceBrick>()
    private var numBricks      = 0

    // Player fires one fast, short bullet
    private var playerBullet   = Bullet(size.y, 1200f, 40f)

    // Invaders share a pool of up to 10 bullets
    private val invadersBullets  = ArrayList<Bullet>()
    private var nextBullet       = 0
    private val maxInvaderBullets = 10

    private var score     = 0
    private var waves     = 1
    private var lives     = 3

    private val prefs: SharedPreferences = context.getSharedPreferences(
            "Kotlin Invaders", Context.MODE_PRIVATE)
    private var highScore = prefs.getInt("highScore", 0)

    // Menace system — alternating uh/oh sounds
    private var menaceInterval  = 1000L
    private var uhOrOh          = false
    private var lastMenaceTime  = System.currentTimeMillis()
}

A few design points worth noting:

The invader bullet pool is an ArrayList of ten Bullet objects. Rather than creating and destroying bullets on demand, we cycle through the list with nextBullet. If a slot is still active (mid-flight), shoot() returns false and that round is skipped — natural rate-limiting at no extra cost.

The game starts paused = true. The first touch event sets it to false, so the game doesn't start moving until the player deliberately interacts with the screen.

menaceInterval controls how often the uh/oh beat plays. It starts at 1000 ms (once per second) and could be shortened as the invaders descend to recreate the original game's rising tension.

prepareLevel()

private fun prepareLevel() {
    Invader.numberOfInvaders = 0
    numInvaders = 0

    for (column in 0..10) {
        for (row in 0..5) {
            invaders.add(Invader(context, row, column, size.x, size.y))
            numInvaders++
        }
    }

    numBricks = 0
    for (shelterNumber in 0..4) {
        for (column in 0..18) {
            for (row in 0..8) {
                bricks.add(DefenceBrick(row, column, shelterNumber, size.x, size.y))
                numBricks++
            }
        }
    }

    for (i in 0 until maxInvaderBullets) {
        invadersBullets.add(Bullet(size.y))
    }
}

prepareLevel() populates the three collections from scratch each time it's called. 66 invaders fill an 11 × 6 formation. Five shelters of 19 × 9 bricks (855 total) are spaced across the lower screen. The ten-bullet invader pool is initialised last. This same method is called both on first load (from resume()) and after each wave clears or the player dies, which keeps the reset logic in one place.

The Game Loop

override fun run() {
    var fps: Long = 0

    while (playing) {
        val startFrameTime = System.currentTimeMillis()

        if (!paused) {
            update(fps)
        }

        draw()

        val timeThisFrame = System.currentTimeMillis() - startFrameTime
        if (timeThisFrame >= 1) {
            fps = 1000 / timeThisFrame
        }

        if (!paused && (startFrameTime - lastMenaceTime) > menaceInterval) {
            menacePlayer()
        }
    }
}

Each pass through the loop timestamps the start of the frame, calls update() if unpaused, always calls draw(), then calculates the frame time and derives the current FPS. That FPS value is passed into the next call to update() so all movement calculations stay frame-rate-independent.

Drawing always runs regardless of pause state — this lets the screen render on the first frame before the player has touched anything, showing the initial setup rather than a blank screen.

The Menace System

private fun menacePlayer() {
    if (uhOrOh) {
        soundPlayer.playSound(SoundPlayer.uhID)
    } else {
        soundPlayer.playSound(SoundPlayer.ohID)
    }
    lastMenaceTime = System.currentTimeMillis()
    uhOrOh = !uhOrOh
}

The alternating uh/oh rhythm was one of Space Invaders' signature touches. The original arcade cabinet slowed down as more invaders filled the screen, and the sound slowed with it — when the formation was decimated to a few survivors the beat became frantic. Here menaceInterval is fixed, but reducing it as Invader.numberOfInvaders falls would be a faithful recreation. See the "Where to Take It Next" section at the end.

update()

update() advances every game object and handles all collision detection. It's the largest method in the project:

private fun update(fps: Long) {
    playerShip.update(fps)

    var bumped = false
    var lost   = false

    // Move every living invader and check for wall collision
    for (invader in invaders) {
        if (invader.isVisible) {
            invader.update(fps)

            // Ask each invader if it wants to take a shot
            if (invader.takeAim(playerShip.position.left, playerShip.width, waves)) {
                if (invadersBullets[nextBullet].shoot(
                            invader.position.left + invader.width / 2,
                            invader.position.top, playerBullet.down)) {
                    nextBullet++
                    if (nextBullet == maxInvaderBullets) nextBullet = 0
                }
            }

            if (invader.position.left > size.x - invader.width ||
                    invader.position.left < 0) {
                bumped = true
            }
        }
    }

    // Move active bullets
    if (playerBullet.isActive) playerBullet.update(fps)
    for (bullet in invadersBullets) {
        if (bullet.isActive) bullet.update(fps)
    }

    // Drop the whole formation if any invader hit a wall
    if (bumped) {
        for (invader in invaders) {
            invader.dropDownAndReverse(waves)
            if (invader.position.bottom >= size.y && invader.isVisible) {
                lost = true
            }
        }
    }

    // Deactivate bullets that have left the screen
    if (playerBullet.position.bottom < 0) playerBullet.isActive = false
    for (bullet in invadersBullets) {
        if (bullet.position.top > size.y) bullet.isActive = false
    }

    // Player bullet hits an invader
    if (playerBullet.isActive) {
        for (invader in invaders) {
            if (invader.isVisible) {
                if (RectF.intersects(playerBullet.position, invader.position)) {
                    invader.isVisible    = false
                    playerBullet.isActive = false
                    soundPlayer.playSound(SoundPlayer.invaderExplodeID)
                    Invader.numberOfInvaders--
                    score += 10
                    if (score > highScore) highScore = score

                    // Wave cleared?
                    if (Invader.numberOfInvaders == 0) {
                        paused = true
                        lives++
                        invaders.clear()
                        bricks.clear()
                        invadersBullets.clear()
                        prepareLevel()
                        waves++
                    }
                    break
                }
            }
        }
    }

    // Invader bullet hits a shelter brick
    for (bullet in invadersBullets) {
        if (bullet.isActive) {
            for (brick in bricks) {
                if (brick.isVisible) {
                    if (RectF.intersects(bullet.position, brick.position)) {
                        bullet.isActive  = false
                        brick.isVisible  = false
                        soundPlayer.playSound(SoundPlayer.damageShelterID)
                    }
                }
            }
        }
    }

    // Player bullet hits a shelter brick
    if (playerBullet.isActive) {
        for (brick in bricks) {
            if (brick.isVisible) {
                if (RectF.intersects(playerBullet.position, brick.position)) {
                    playerBullet.isActive = false
                    brick.isVisible       = false
                    soundPlayer.playSound(SoundPlayer.damageShelterID)
                }
            }
        }
    }

    // Invader bullet hits the player
    for (bullet in invadersBullets) {
        if (bullet.isActive) {
            if (RectF.intersects(playerShip.position, bullet.position)) {
                bullet.isActive = false
                lives--
                soundPlayer.playSound(SoundPlayer.playerExplodeID)
                if (lives == 0) {
                    lost = true
                    break
                }
            }
        }
    }

    // Handle game over
    if (lost) {
        paused = true
        lives  = 3
        score  = 0
        waves  = 1
        invaders.clear()
        bricks.clear()
        invadersBullets.clear()
        prepareLevel()
    }
}

All collision detection uses RectF.intersects() — Android's built-in axis-aligned rectangle overlap test. Both bullets and game objects are already RectF instances, so no conversion is needed.

Notice that clearing a wave gives the player an extra life and increments the wave counter before calling prepareLevel() again. The game stays paused until the next touch, giving the player a moment to read the screen before the new formation appears.

Space Invaders game showing the invader formation mid-wave with some invaders destroyed.
Mid-wave — some invaders destroyed, shelters taking damage, bullets in flight.

draw()

private fun draw() {
    if (holder.surface.isValid) {
        canvas = holder.lockCanvas()

        // Black background
        canvas.drawColor(Color.argb(255, 0, 0, 0))

        paint.color = Color.argb(255, 0, 255, 0)

        // Player ship
        canvas.drawBitmap(playerShip.bitmap,
                playerShip.position.left, playerShip.position.top, paint)

        // Invaders — alternate between bitmap1 and bitmap2 each frame
        for (invader in invaders) {
            if (invader.isVisible) {
                val frame = if (uhOrOh) Invader.bitmap1 else Invader.bitmap2
                canvas.drawBitmap(frame,
                        invader.position.left, invader.position.top, paint)
            }
        }

        // Shelter bricks
        for (brick in bricks) {
            if (brick.isVisible) canvas.drawRect(brick.position, paint)
        }

        // Player bullet
        if (playerBullet.isActive) canvas.drawRect(playerBullet.position, paint)

        // Invader bullets
        for (bullet in invadersBullets) {
            if (bullet.isActive) canvas.drawRect(bullet.position, paint)
        }

        // HUD
        paint.color    = Color.argb(255, 255, 255, 255)
        paint.textSize = 70f
        canvas.drawText("Score: $score   Lives: $lives   Wave: $waves   HI: $highScore",
                20f, 75f, paint)

        holder.unlockCanvasAndPost(canvas)
    }
}

Everything draws in green on black — the classic monochrome arcade palette. The invader animation is free: the two bitmaps alternate each frame driven by the same uhOrOh boolean that triggers the menace sound. When the sound beats, the sprite frame flips. No separate animation timer needed.

pause() and resume()

fun pause() {
    playing = false
    try {
        gameThread.join()
    } catch (e: InterruptedException) {
        Log.e("Error:", "joining thread")
    }

    // Save the high score if it improved this session
    val editor = prefs.edit()
    val savedBest = prefs.getInt("highScore", 0)
    if (highScore > savedBest) {
        editor.putInt("highScore", highScore)
        editor.apply()
    }
}

fun resume() {
    playing = true
    prepareLevel()
    gameThread = Thread(this)
    gameThread.start()
}

pause() stops the loop, waits for the thread to finish its current frame with join(), then persists the high score to SharedPreferences if it improved. resume() builds the level and starts the thread.

SharedPreferences is Android's lightweight key-value store, appropriate for a single integer like a high score. The value survives the app being closed or the device being rebooted — the player's best score is there the next time they open the game.

onTouchEvent()

override fun onTouchEvent(motionEvent: MotionEvent): Boolean {
    // Bottom eighth of screen = movement zone
    val motionArea = size.y - (size.y / 8)

    when (motionEvent.action and MotionEvent.ACTION_MASK) {

        MotionEvent.ACTION_POINTER_DOWN,
        MotionEvent.ACTION_DOWN,
        MotionEvent.ACTION_MOVE -> {
            paused = false   // First touch starts the game

            if (motionEvent.y > motionArea) {
                // Movement zone — steer left or right
                playerShip.moving = if (motionEvent.x > size.x / 2)
                    PlayerShip.right else PlayerShip.left
            }

            if (motionEvent.y < motionArea) {
                // Everything else fires
                if (playerBullet.shoot(
                            playerShip.position.left + playerShip.width / 2f,
                            playerShip.position.top,
                            playerBullet.up)) {
                    soundPlayer.playSound(SoundPlayer.shootID)
                }
            }
        }

        MotionEvent.ACTION_POINTER_UP,
        MotionEvent.ACTION_UP -> {
            if (motionEvent.y > motionArea) {
                playerShip.moving = PlayerShip.stopped
            }
        }
    }
    return true
}

The screen is divided horizontally: the bottom eighth is the movement zone, everything above it fires. Holding a finger in the movement zone steers continuously; lifting it stops the ship. Tapping anywhere above the movement zone fires — if the player's bullet is already in the air, shoot() returns false and the tap is silently ignored.

Handling ACTION_MOVE alongside ACTION_DOWN means the player can slide their finger left-to-right in the movement zone to change direction without lifting, which feels more natural on a touchscreen.

Space Invaders game showing the shelters being damaged by invader fire.
Shelter damage is permanent — each brick is destroyed individually when hit by either player or invader fire.

Running the Game

Build and deploy to a device or emulator. On first launch the game renders the full formation but stays frozen — a deliberate pause until the first touch. Tap anywhere above the bottom strip to fire and unfreeze the game simultaneously. Hold the bottom-left or bottom-right to steer.

Clear all 66 invaders to advance to wave 2. Each successive wave is faster, the invaders fire more aggressively, and you earn a bonus life for clearing the screen. Die with no lives remaining and the score resets, but the high score persists across sessions.

Common Issues

Crash on launch — theme error. The activity must extend android.app.Activity, not AppCompatActivity. Change the class declaration and make sure the manifest points to the correct class name.

Black screen, game doesn't appear. Check that onResume() in the activity calls kotlinInvadersView?.resume(). Without it the thread never starts. Also check that prepareLevel() is being called from resume().

Bitmaps not found / resource not found crash. The three PNG files must be in app/src/main/res/drawable and named exactly playership.png, invader1.png, invader2.png — lowercase, matching the R.drawable references in the code.

Sound doesn't play. Confirm all six .ogg files are in app/src/main/assets (not res/raw) and that filenames match the strings in SoundPlayer exactly, including case.

Game crashes after pausing and resuming the app. A Thread in Java/Kotlin can only be started once. If you see IllegalThreadStateException, check that resume() assigns a fresh Thread(this) to gameThread before calling start() — as shown in the code above. A common mistake is declaring gameThread as val, which prevents reassignment.

Where to Take It Next

The codebase as written is intentionally straightforward. A few improvements worth considering: