Teqvault.study

Android Studio: Build a 2D Side-Scroller

From an empty install to a fully playable game โ€” Kotlin, the Android app lifecycle, a hand-built game loop, sprites, physics, and a complete side-scroller as your capstone. Zero assumed knowledge.

๐Ÿค– ๐ŸŽฎ ๐Ÿ“ฑ ๐Ÿงฉ ๐Ÿ
Course Progress
0%
MODULE 01

๐Ÿ› ๏ธ Setup & Your First Project

Every module after this one assumes Android Studio is installed, an emulator works, and you've run one app successfully. This module gets you there from a completely blank computer.

๐Ÿ’ป What You Actually Need
RequirementMinimumNotes
OSWindows 10/11, macOS 12+, or LinuxAll three fully supported
RAM8GB16GB strongly recommended โ€” the emulator alone is hungry
Disk space~15GB freeIDE, SDKs, and emulator images add up fast
A phone (optional)Any Android phone, USB cableTesting on real hardware is faster and often easier than the emulator
๐Ÿ“ฅ Step 1 โ€” Download & Install
  1. Go to developer.android.com/studio and download the installer for your OS.
  2. Windows: run the .exe, accept defaults, let it install the Android SDK when prompted.
  3. macOS: open the .dmg, drag Android Studio to Applications, launch it โ€” macOS will ask to confirm opening software from outside the App Store, that's expected.
  4. Linux: extract the tar.gz, run studio.sh from the extracted bin/ folder.
  5. On first launch, the Setup Wizard appears โ€” choose "Standard" install type unless you have a specific reason not to. This downloads the Android SDK, platform tools, and a default emulator image automatically.
๐Ÿ’ก
This first download is largeExpect several GB and 10-20+ minutes depending on your connection. This is normal โ€” Android Studio is downloading the actual Android platform/SDK, not just the IDE itself.
๐Ÿงฉ Step 2 โ€” Understand What You Just Installed
ComponentWhat It Does
Android StudioThe IDE itself โ€” code editor, layout designer, debugger, all in one
Android SDKThe actual Android platform APIs your code compiles against
SDK Platform ToolsCommand-line tools like adb (Android Debug Bridge) that talk to devices/emulators
GradleThe build system that compiles your project โ€” every Android project is a Gradle project
AVD ManagerCreates and runs virtual Android devices (emulators) on your computer
๐Ÿ“ฒ Step 3 โ€” Create a Virtual Device (AVD)
  1. From the Welcome screen (or Tools menu once a project is open), open Device Manager.
  2. Click Create Device.
  3. Pick a phone profile โ€” Pixel 6 or similar is a safe, modern default.
  4. Pick a system image โ€” choose a recent API level (API 34 / Android 14 is a solid current default) with the Google APIs or Google Play label. It'll download if not already present.
  5. Finish โ€” the AVD now appears in Device Manager, ready to launch.
โš ๏ธ
Emulator running slow or won't start?This is almost always a virtualization setting. On Windows, ensure Hyper-V/Windows Hypervisor Platform is enabled (or disabled, depending on your emulator type โ€” HAXM vs. built-in). On Mac/Linux this is rarely an issue. If it stays slow, testing on a real USB-connected phone (Step 5) is a completely valid workaround.
๐Ÿ†• Step 4 โ€” Create Your First Project
  1. From the Welcome screen, click New Project.
  2. Choose the Empty Activity template โ€” the plainest possible starting point, exactly what you want for learning.
  3. Name it (e.g. "MyFirstApp"), leave the package name as generated, choose a save location.
  4. Language: Kotlin. This course uses Kotlin throughout โ€” it's Google's recommended and modern language for Android (Module 2 covers it from scratch, no prior Kotlin needed).
  5. Minimum SDK: API 24 (Android 7.0) is a reasonable default โ€” covers the vast majority of active devices without limiting you unnecessarily.
  6. Click Finish. Android Studio generates the project and runs its first Gradle sync โ€” a progress bar at the bottom that resolves dependencies and prepares the build. This can take a few minutes the first time.
๐Ÿ—‚๏ธ Step 5 โ€” The Project Structure, Explained
app/ โ”œโ”€โ”€ manifests/ โ”‚ โ””โ”€โ”€ AndroidManifest.xml โ† declares your app: activities, permissions, icon โ”œโ”€โ”€ java/ โ”‚ โ””โ”€โ”€ com.example.myfirstapp/ โ”‚ โ””โ”€โ”€ MainActivity.kt โ† your first screen's code โ”œโ”€โ”€ res/ โ”‚ โ”œโ”€โ”€ layout/ โ”‚ โ”‚ โ””โ”€โ”€ activity_main.xml โ† your first screen's UI, defined in XML โ”‚ โ”œโ”€โ”€ values/ โ”‚ โ”‚ โ””โ”€โ”€ strings.xml, colors.xml, themes.xml โ”‚ โ””โ”€โ”€ drawable/ โ† images, icons โ””โ”€โ”€ build.gradle.kts โ† this module's dependencies & build config

Two files matter most while you're starting out: MainActivity.kt (your code) and activity_main.xml (your screen's layout). Module 3 covers exactly how they connect.

โ–ถ๏ธ Step 6 โ€” Run It
  1. Select your AVD (or a connected physical device) from the device dropdown in the toolbar.
  2. Click the green Run โ–ถ button (or Shift+F10).
  3. Android Studio builds the project, installs it on the emulator/device, and launches it automatically.
  4. You should see a plain screen with "Hello World!" โ€” the default Empty Activity template's only content.
๐Ÿ’ก
Testing on a real phone insteadEnable Developer Options (Settings โ†’ About Phone โ†’ tap "Build Number" 7 times), then enable USB Debugging inside the new Developer Options menu. Plug in via USB, accept the "allow USB debugging" prompt on the phone, and it appears in Android Studio's device dropdown exactly like an emulator.
โœ… You're Set Up Whenโ€ฆ
  • Android Studio opens without errors
  • An AVD launches and shows the Android home screen
  • "Hello World!" appears when you run the default project
  • You can locate MainActivity.kt and activity_main.xml in the project panel
๐Ÿง  Quick Check
What does Gradle sync actually do when you open or create a project?
MODULE 02

๐Ÿ…บ Kotlin Crash Course for Android

Just enough Kotlin to read and write everything in the rest of this course โ€” variables, functions, classes, and the null-safety feature that defines the language.

๐Ÿ“ฆ Variables โ€” val vs. var
Kotlin
val playerName = "Hero"   // val = cannot be reassigned (prefer this by default)
var health = 100          // var = can change โ€” use only when it actually will

health = 90                  // fine, health is a var
// playerName = "Villain"    // ERROR โ€” playerName is a val

Kotlin infers types automatically (playerName is inferred as String, health as Int) โ€” you rarely write the type explicitly unless it helps readability.

โ“ Null Safety โ€” Kotlin's Signature Feature

A variable can only be null if its type explicitly allows it, marked with ?. This eliminates most null-pointer crashes at compile time instead of runtime.

Kotlin
var name: String = "Hero"       // cannot be null โ€” compiler enforces it
var nickname: String? = null   // CAN be null โ€” the ? says so explicitly

// nickname.length          // ERROR โ€” might be null, compiler won't allow direct access
nickname?.length              // safe call โ€” returns null instead of crashing if nickname is null
nickname ?: "no nickname"       // elvis operator โ€” provide a fallback if null
๐Ÿ’ก
Why this matters for game codeGame objects constantly reference each other (a bullet references its shooter, an enemy references its target). Kotlin forces you to explicitly handle the "what if that reference is gone" case, which prevents an entire category of crashes that plague native game code in other languages.
โš™๏ธ Functions
Kotlin
fun takeDamage(amount: Int): Int {
    return health - amount
}

// single-expression shorthand for simple functions
fun isAlive(hp: Int): Boolean = hp > 0
๐Ÿ—๏ธ Classes & Data Classes
Kotlin
class Player(var x: Float, var y: Float) {
    var health = 100
    fun moveRight(amount: Float) { x += amount }
}

// data class โ€” auto-generates equals(), toString(), copy() โ€” great for simple data holders
data class Vector2(val x: Float, val y: Float)

val player = Player(0f, 0f)
player.moveRight(10f)
๐Ÿ” Control Flow & Collections
Kotlin
val enemies = mutableListOf("Goomba", "Koopa")
enemies.add("Piranha")

for (enemy in enemies) {
    println("Enemy: $enemy")   // string templates with $variable
}

when (health) {                  // Kotlin's switch, but more powerful
    in 0..20  -> println("Critical!")
    in 21..99 -> println("Hurt")
    else       -> println("Full health")
}
๐Ÿง  Quick Check
You declare var nickname: String? = null. Why does the compiler allow this but not var name: String = null?
MODULE 03

๐Ÿ“ฑ App Fundamentals & Lifecycle

What an Activity actually is, and the lifecycle every Android screen goes through โ€” critical for a game, since pausing/resuming correctly is the difference between a polished app and one that crashes when a phone call comes in.

๐Ÿ  What an Activity Is

An Activity represents one screen of your app. MainActivity.kt from Module 1 is a class extending Activity (or more commonly, AppCompatActivity for backward-compatible features). Its onCreate() method runs once when the screen is first created โ€” this is where you set up what the user sees.

Kotlin โ€” MainActivity.kt
class MainActivity : AppCompatActivity() {
    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        setContentView(R.layout.activity_main)  // connects to the XML layout
    }
}
๐Ÿ”„ The Lifecycle โ€” Every Screen Goes Through This
onCreate() โ† screen created, one-time setup โ”‚ onStart() โ† becoming visible โ”‚ onResume() โ† now interactive โ€” user can touch it โ”‚ โ—„โ”€โ”€ app is RUNNING here โ”€โ”€โ–บ onPause() โ† losing focus (a dialog appeared, phone call, etc.) โ”‚ onStop() โ† fully hidden (user hit Home, switched apps) โ”‚ onDestroy() โ† screen being destroyed
MethodTypical Use
onCreate()One-time setup โ€” inflate layout, initialize objects
onResume()Resume anything paused โ€” critically, your game loop (Module 6) restarts here
onPause()Pause anything active โ€” your game loop must stop here or it keeps running invisibly, draining battery and possibly crashing
onDestroy()Release resources โ€” close files, stop threads for good
โš ๏ธ
This is not optional for gamesA game that doesn't properly pause its loop in onPause() keeps its background thread running after the user switches apps โ€” burning battery, and potentially crashing when it tries to draw to a screen that no longer exists. Module 6 builds this correctly from the start.
๐Ÿ“‹ The Manifest โ€” Your App's Table of Contents

AndroidManifest.xml declares everything the system needs to know before running your app: every Activity it contains, required permissions, the app icon, and which Activity launches first.

XML โ€” AndroidManifest.xml (excerpt)
<activity
    android:name=".MainActivity"
    android:exported="true">
    <intent-filter>
        <action android:name="android.intent.action.MAIN" />
        <category android:name="android.intent.category.LAUNCHER" />
    </intent-filter>
</activity>

The MAIN/LAUNCHER intent-filter combination marks this Activity as the one that opens when a user taps your app's icon.

๐Ÿง  Quick Check
Why must a game's loop be explicitly stopped in onPause() rather than left running?
MODULE 04

๐Ÿงฑ Views, Layouts & the UI Toolkit

Every menu, button, and score display in your finished game is built from these same pieces. This module covers standard Android UI before Module 5 breaks from it to build custom game rendering.

๐Ÿงฉ View โ€” The Base of Everything On Screen

A View is the base class for anything drawn on screen โ€” a button, a text label, an image. A ViewGroup is a View that contains other Views (a layout). Every screen is a tree of Views inside ViewGroups.

Common ViewPurpose
TextViewDisplays text
ButtonA tappable button
ImageViewDisplays an image
EditTextText input field
๐Ÿ“ Layouts โ€” Arranging Views
LayoutArranges Children
LinearLayoutIn a single row or column
ConstraintLayoutBy constraints relative to each other/the parent โ€” the modern default, flexible for complex screens
FrameLayoutStacked on top of each other โ€” exactly what you'll use to overlay a game's UI (score, pause button) on top of its rendering surface in the capstone
XML โ€” activity_main.xml
<LinearLayout
    android:layout_width="match_parent"
    android:layout_height="match_parent"
    android:orientation="vertical">

    <TextView
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        android:text="Score: 0"
        android:id="@+id/scoreText" />

    <Button
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        android:text="Start Game"
        android:id="@+id/startButton" />
</LinearLayout>
๐Ÿ”— Connecting XML to Kotlin โ€” View Binding

Every View with an android:id can be referenced from Kotlin. Modern Android projects use View Binding โ€” type-safe, no manual findViewById casting required.

Kotlin โ€” with View Binding
class MainActivity : AppCompatActivity() {
    private lateinit var binding: ActivityMainBinding

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        binding = ActivityMainBinding.inflate(layoutInflater)
        setContentView(binding.root)

        binding.startButton.setOnClickListener {
            binding.scoreText.text = "Game Started!"
        }
    }
}
๐Ÿ’ก
Enable it once per projectAdd buildFeatures { viewBinding = true } inside the android {} block of your module's build.gradle.kts. Android Studio then auto-generates a Binding class matching each XML layout's filename.
๐Ÿง  Quick Check
You need to overlay a score display on top of your game's rendering surface, not beside it. Which layout fits?
MODULE 05

๐Ÿ–Œ๏ธ Custom Views & Canvas Drawing

Standard Views (Module 4) are for menus and UI. A game world needs to draw arbitrary shapes and images every frame โ€” that's what Canvas is for, and this module is the bridge into everything the rest of the course builds on.

๐Ÿ–ผ๏ธ What a Canvas Actually Is

A Canvas is a drawing surface you paint onto directly with code โ€” lines, shapes, text, images โ€” pixel by pixel, frame by frame. Instead of declaring UI in XML, you override a method and issue drawing commands yourself.

๐Ÿ—๏ธ Building a Custom View
Kotlin โ€” GameView.kt
class GameView(context: Context) : View(context) {

    private val playerPaint = Paint().apply {
        color = Color.RED
        style = Paint.Style.FILL
    }

    private var playerX = 100f
    private var playerY = 300f

    override fun onDraw(canvas: Canvas) {
        super.onDraw(canvas)
        canvas.drawColor(Color.CYAN)  // clear/fill background each frame
        canvas.drawRect(playerX, playerY, playerX + 80f, playerY + 80f, playerPaint)
    }
}

onDraw() is where every drawing command goes. Paint objects define color, style, stroke width โ€” think of them as the "brush," while Canvas drawing calls are the "strokes."

๐ŸŽจ The Core Drawing Commands
MethodDraws
drawColor()Fills the entire canvas with one color โ€” typically used to clear the frame
drawRect()A rectangle
drawCircle()A circle
drawBitmap()An image โ€” this is how real sprites get drawn (Module 7)
drawText()Text โ€” score displays, debug info
โš ๏ธ Why onDraw() Alone Isn't a Game

A plain custom View only calls onDraw() when the system decides something changed โ€” it doesn't run continuously. A game needs to redraw 30-60 times per second regardless, updating positions and checking collisions every single frame. That continuous loop is not something a plain View gives you automatically โ€” it's the entire subject of Module 6, which introduces SurfaceView specifically to solve this.

๐Ÿ’ก
Why learn plain Canvas first, thenEvery drawing command here โ€” drawRect, drawBitmap, Paint โ€” is identical whether it's called from a plain View's onDraw() or from the game loop's render step in Module 6. This module teaches the drawing vocabulary; Module 6 teaches when and how often to call it.
๐Ÿง  Quick Check
Why can't a plain custom View's onDraw() alone power a real-time game?
MODULE 06

๐Ÿ” The Game Loop

The technical backbone of every game in this course. SurfaceView, a dedicated thread, and the update-then-render cycle that runs dozens of times per second for as long as the game is alive.

๐Ÿ” What a Game Loop Actually Is

At its core, every game โ€” from Pong to a AAA title โ€” runs the same cycle, forever, until the game ends:

while (running) { update() โ† move things, check input, check collisions render() โ† draw the current state to the screen }

update() changes the game's data. render() draws that data. Keeping these separate โ€” never drawing inside update logic, never changing positions inside drawing code โ€” is what keeps a growing game's code manageable.

๐Ÿ–ผ๏ธ SurfaceView โ€” A Canvas With Its Own Thread

Module 5's plain View only draws when the system asks. SurfaceView gives you a dedicated drawing surface plus the ability to run your own thread that redraws continuously and independently of the main UI thread โ€” exactly what a real-time game needs.

Kotlin โ€” GameView.kt
class GameView(context: Context) : SurfaceView(context), SurfaceHolder.Callback {

    private var gameThread: GameThread? = null

    init {
        holder.addCallback(this)
    }

    override fun surfaceCreated(holder: SurfaceHolder) {
        gameThread = GameThread(holder, this)
        gameThread?.running = true
        gameThread?.start()
    }

    override fun surfaceDestroyed(holder: SurfaceHolder) {
        gameThread?.running = false
        gameThread?.join()  // wait for the thread to actually finish before continuing
    }

    override fun surfaceChanged(holder: SurfaceHolder, format: Int, width: Int, height: Int) {}

    fun update() { // game logic goes here โ€” Module 8+ builds this out }

    fun render(canvas: Canvas) {
        canvas.drawColor(Color.CYAN)
        // draw game objects here โ€” Module 7 covers real sprites
    }
}

surfaceCreated/surfaceDestroyed are called automatically as the surface becomes available or goes away โ€” this is where the thread actually starts and stops, tied directly to whether there's a valid surface to draw on.

๐Ÿงต The Thread Itself
Kotlin โ€” GameThread.kt
class GameThread(
    private val surfaceHolder: SurfaceHolder,
    private val gameView: GameView
) : Thread() {

    var running = false

    override fun run() {
        while (running) {
            val canvas = surfaceHolder.lockCanvas() ?: continue
            try {
                gameView.update()
                gameView.render(canvas)
            } finally {
                surfaceHolder.unlockCanvasAndPost(canvas)  // ALWAYS release the canvas, even if render() throws
            }
        }
    }
}
โš ๏ธ
lockCanvas() / unlockCanvasAndPost() must always pair upLocking the canvas gives your thread exclusive access to draw; forgetting to unlock it (or crashing between lock and unlock) freezes rendering entirely. The try/finally above guarantees the unlock always happens, even if render() throws an exception.
โฑ๏ธ Frame Rate โ€” Why Unthrottled Loops Are a Problem

The loop above runs as fast as the CPU allows โ€” potentially hundreds of times per second, burning battery for no visual benefit (the screen can't refresh that fast anyway) and making game speed dependent on device performance. A simple fixed-rate throttle solves this:

Kotlin โ€” Throttled to ~60 FPS
override fun run() {
    val targetFrameTimeMs = 1000L / 60  // ~16.6ms per frame for 60 FPS
    while (running) {
        val startTime = System.currentTimeMillis()
        val canvas = surfaceHolder.lockCanvas() ?: continue
        try {
            gameView.update()
            gameView.render(canvas)
        } finally {
            surfaceHolder.unlockCanvasAndPost(canvas)
        }
        val frameTime = System.currentTimeMillis() - startTime
        val sleepTime = targetFrameTimeMs - frameTime
        if (sleepTime > 0) sleep(sleepTime)
    }
}
๐Ÿ”— Wiring It Into the Activity's Lifecycle

Directly connecting back to Module 3: surfaceCreated/surfaceDestroyed already handle most of it, but the Activity itself should also explicitly pause/resume:

Kotlin โ€” MainActivity.kt
override fun onPause() {
    super.onPause()
    gameView.pause()  // stop the thread โ€” from Module 3, don't let it run invisibly
}

override fun onResume() {
    super.onResume()
    gameView.resume()  // restart it
}
๐Ÿง  Quick Check
Why wrap the render call in try/finally with unlockCanvasAndPost() in the finally block?
MODULE 07

๐Ÿ–ผ๏ธ Sprites, Bitmaps & Animation

Colored rectangles were fine for learning Canvas โ€” real games draw images. Here's how to load them efficiently and animate a walk cycle from a sprite sheet.

๐Ÿ–ผ๏ธ Loading a Bitmap

Place image assets in res/drawable/. A Bitmap is the in-memory representation of that image, ready to be drawn onto a Canvas.

Kotlin
val playerBitmap = BitmapFactory.decodeResource(resources, R.drawable.player)

// in render():
canvas.drawBitmap(playerBitmap, playerX, playerY, null)
โš ๏ธ
Load bitmaps once, not every frameDecoding an image from disk is expensive. Load every bitmap once in onCreate() or an init block and keep the reference โ€” never call decodeResource inside update() or render(), which run dozens of times per second.
๐ŸŽž๏ธ Sprite Sheets โ€” One Image, Many Frames

A sprite sheet packs multiple animation frames into a single image, arranged in a grid โ€” far more efficient than loading dozens of separate files. You draw one rectangular slice of it per frame.

player_walk.png โ€” 4 frames, 64ร—64 each, laid out horizontally โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ฌโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ฌโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ฌโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ” โ”‚Frame 0 โ”‚Frame 1 โ”‚Frame 2 โ”‚Frame 3 โ”‚ โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ดโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ดโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ดโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜ 0,0 64,0 128,0 192,0 โ† source rectangle X offsets
Kotlin โ€” Drawing One Frame From a Sheet
val frameWidth = 64
val frameHeight = 64
var currentFrame = 0

fun drawPlayer(canvas: Canvas) {
    val srcRect = Rect(
        currentFrame * frameWidth, 0,
        (currentFrame + 1) * frameWidth, frameHeight
    )
    val dstRect = Rect(
        playerX.toInt(), playerY.toInt(),
        playerX.toInt() + frameWidth, playerY.toInt() + frameHeight
    )
    canvas.drawBitmap(playerSheet, srcRect, dstRect, null)
}
โฑ๏ธ Animating โ€” Advancing Frames Over Time
Kotlin โ€” Frame Timer in update()
private var frameTimer = 0L
private val frameDurationMs = 120L  // ~8 frames per second walk cycle
private val totalFrames = 4

fun update(deltaTime: Long) {
    frameTimer += deltaTime
    if (frameTimer >= frameDurationMs) {
        frameTimer = 0
        currentFrame = (currentFrame + 1) % totalFrames  // loop back to 0 after the last frame
    }
}

Note the pattern: animation speed is driven by elapsed time, not by frame count โ€” this keeps the walk cycle at a consistent real-world speed regardless of the device's actual frame rate.

๐Ÿง  Quick Check
Why load bitmaps once in onCreate() rather than inside update() or render()?
MODULE 08

๐Ÿ‘† Touch Input & Controls

Reading raw touch events and turning them into game actions โ€” tap-to-jump, an on-screen d-pad, and the debouncing considerations that mirror what you'd see with a physical button.

๐Ÿ‘† Overriding onTouchEvent
Kotlin โ€” inside GameView
override fun onTouchEvent(event: MotionEvent): Boolean {
    when (event.action) {
        MotionEvent.ACTION_DOWN -> {
            // finger just touched the screen
            handleJump()
        }
        MotionEvent.ACTION_MOVE -> {
            // finger is dragging โ€” event.x, event.y give current position
        }
        MotionEvent.ACTION_UP -> {
            // finger lifted
        }
    }
    return true  // tells Android you consumed the event
}
โš ๏ธ
Always return true for events you handleReturning false tells the system you didn't consume the touch, and it may stop delivering subsequent events (like ACTION_MOVE and ACTION_UP) for that gesture โ€” a common source of "my controls stopped working after one tap" bugs.
๐Ÿ•น๏ธ Tap Zones โ€” A Simple, Reliable Control Scheme

For a side-scroller, the simplest reliable control scheme splits the screen into zones rather than drawing a virtual joystick โ€” easier to implement correctly and easier for players to use without looking down.

Kotlin โ€” Left/Right Screen Halves
override fun onTouchEvent(event: MotionEvent): Boolean {
    when (event.action) {
        MotionEvent.ACTION_DOWN, MotionEvent.ACTION_MOVE -> {
            if (event.x < width / 2) {
                moveDirection = -1  // left half โ€” move left
            } else {
                moveDirection = 1   // right half โ€” move right
            }
        }
        MotionEvent.ACTION_UP -> moveDirection = 0
    }
    return true
}
๐Ÿ”˜ Overlaying Real Buttons Instead

An alternative, often more precise for a platformer: overlay actual ImageButton Views (Module 4) on top of the GameView using a FrameLayout, with jump/left/right buttons using standard setOnClickListener / setOnTouchListener handlers instead of manually computing screen zones.

ApproachProCon
Screen-zone touchSimple, no extra Views, works anywhere on screenLess visually obvious to new players
Overlaid buttonsClear visual affordance, standard Android input handlingMore setup, needs careful layout for different screen sizes
โฑ๏ธ Debouncing a Jump Button

Same underlying idea as a physical button's bounce โ€” without a guard, a single tap can register a jump multiple times per frame if held even briefly across frame boundaries. Track jump state explicitly:

Kotlin
private var isJumping = false

fun handleJump() {
    if (!isJumping) {
        velocityY = jumpVelocity  // only apply jump force once per press
        isJumping = true
    }
}
// isJumping is reset to false only once the player lands โ€” Module 9 covers that check
๐Ÿง  Quick Check
Your onTouchEvent handles ACTION_DOWN but the player's finger movements (ACTION_MOVE) are never received afterward. Most likely cause?
MODULE 09

๐Ÿ’ฅ Physics & Collision Detection

No physics engine here โ€” just enough hand-rolled gravity, velocity, and rectangle-overlap math to make a platformer feel right. This is the module the whole side-scroller capstone leans on hardest.

โฌ‡๏ธ Gravity & Velocity โ€” The Core Loop

Real platformer "physics" is almost always just: apply a constant downward acceleration every frame, add it to vertical velocity, add velocity to position.

Kotlin โ€” Player physics in update()
private var velocityY = 0f
private val gravity = 1.5f       // tune this โ€” higher feels "heavier"
private val jumpVelocity = -28f  // negative = upward in screen coordinates

fun update() {
    velocityY += gravity
    playerY += velocityY

    if (playerY >= groundY) {   // hit the ground
        playerY = groundY
        velocityY = 0f
        isJumping = false       // from Module 8 โ€” allows jumping again
    }
}
๐Ÿ’ก
Screen coordinates are flipped from math classOn a Canvas, Y increases downward โ€” (0,0) is the top-left corner. That's why gravity is a positive value added to velocity, and a jump is negative. This trips up nearly every beginner exactly once.
๐Ÿ“ฆ AABB Collision โ€” Axis-Aligned Bounding Box

The standard, cheap collision check for rectangular sprites: two rectangles overlap if and only if they overlap on both the X axis and the Y axis simultaneously.

Two rectangles overlap when ALL four conditions hold: A.left < B.right A.right > B.left A.top < B.bottom A.bottom > B.top
Kotlin โ€” AABB Check
fun isColliding(a: RectF, b: RectF): Boolean {
    return a.left < b.right &&
           a.right > b.left &&
           a.top < b.bottom &&
           a.bottom > b.top
}

// Android's RectF has this built in:
val colliding = playerRect.intersects(enemyRect.left, enemyRect.top, enemyRect.right, enemyRect.bottom)
๐Ÿงฑ Platform Collision โ€” Landing on Top vs. Hitting the Side

A platformer needs more than "are they touching" โ€” it needs to know which side the collision happened on, so the player lands on top of a platform instead of getting stuck inside it. A reliable approach: check the player's previous frame position to infer direction of approach.

Kotlin โ€” Landing on Top of a Platform
fun resolvePlatformCollision(player: RectF, prevPlayerBottom: Float, platform: RectF) {
    if (player.intersects(platform.left, platform.top, platform.right, platform.bottom)) {
        val wasAbove = prevPlayerBottom <= platform.top
        if (wasAbove && velocityY >= 0f) {
            // falling AND was above the platform last frame โ†’ land on top
            playerY = platform.top - playerHeight
            velocityY = 0f
            isJumping = false
        }
        // side/bottom collisions handled separately if the game needs them (e.g. head-bump blocks)
    }
}
โš ๏ธ
Why "previous frame position" mattersWithout it, a player moving sideways into a platform's edge can appear to "land" on top of it incorrectly, since a simple overlap check alone can't distinguish "falling onto it" from "walking into its side." Tracking the previous frame's bottom edge is the cheap, reliable fix used throughout the capstone.
๐Ÿง  Quick Check
On an Android Canvas, why does a jump use a negative velocity value?
MODULE 10

๐ŸŽฅ Scrolling Camera & Parallax

A side-scroller's defining feature: the world moves as the player advances. Here's the trick โ€” nothing about the world actually moves. The camera does.

๐Ÿ“ท The Core Trick โ€” There Is No Camera

Android's Canvas has no concept of a "camera." The illusion of scrolling is created entirely by tracking one number โ€” cameraX โ€” and subtracting it from every world object's position before drawing. The world's actual coordinates never change; only what you subtract when rendering does.

Kotlin โ€” The Camera Offset Pattern
private var cameraX = 0f

fun update() {
    // keep the camera centered on the player, but never let it go negative
    cameraX = (player.x - screenWidth / 2).coerceAtLeast(0f)
}

fun render(canvas: Canvas) {
    val playerScreenX = player.x - cameraX  // world position minus camera = screen position
    canvas.drawBitmap(playerBitmap, playerScreenX, player.y, null)

    for (enemy in enemies) {
        val enemyScreenX = enemy.x - cameraX
        canvas.drawBitmap(enemyBitmap, enemyScreenX, enemy.y, null)
    }
}
๐Ÿ’ก
Everything in the world stays in "world space"Collision checks, physics, and game logic all operate on the true world coordinates (player.x, enemy.x). Only the very last step โ€” drawing โ€” subtracts cameraX. Mixing world and screen coordinates anywhere else is the single most common source of bugs in a scrolling game.
๐Ÿ”๏ธ Parallax โ€” Depth Through Different Scroll Speeds

Real depth perception comes from distant objects appearing to move slower than close ones โ€” exactly what you notice looking out a car window. A parallax background fakes this with 2-4 background layers, each scrolling at a different fraction of the camera's speed.

Layer Scroll Speed Feel Sky/clouds ร— 0.1 barely moves โ€” far away Distant hills ร— 0.3 drifts slowly Mid trees ร— 0.6 moderate Foreground ร— 1.0 moves exactly with the camera
Kotlin โ€” Parallax Layer Rendering
data class ParallaxLayer(val bitmap: Bitmap, val scrollSpeed: Float)

fun renderBackground(canvas: Canvas, layers: List<ParallaxLayer>) {
    for (layer in layers) {
        val offsetX = -(cameraX * layer.scrollSpeed) % layer.bitmap.width
        canvas.drawBitmap(layer.bitmap, offsetX, 0f, null)
        // draw a second copy right after the first, so there's no gap when it wraps
        canvas.drawBitmap(layer.bitmap, offsetX + layer.bitmap.width, 0f, null)
    }
}
โš ๏ธ
The two-copy trick prevents gapsAs a background scrolls and wraps back around, drawing only one copy leaves a visible blank gap right as it resets. Drawing a second copy immediately after the first means one of the two always fills the screen, seamlessly.
๐Ÿง  Quick Check
In the camera offset pattern, where should cameraX actually be subtracted from object positions?
MODULE 11

๐Ÿ—บ๏ธ Tilemaps & Level Design

Hand-placing every platform in code doesn't scale past one tiny test level. A tilemap turns level design into editing a grid of numbers โ€” the same approach real games use.

๐Ÿ”ข A Level as a Grid of Numbers

A tilemap represents a level as a 2D array, where each number identifies which tile type goes in that grid cell. This makes a level editable as plain data instead of hardcoded drawing calls.

Kotlin โ€” A Level Defined as Data
// 0 = empty, 1 = ground, 2 = platform
val level = arrayOf(
    intArrayOf(0,0,0,0,2,2,0,0,0,0),
    intArrayOf(0,0,0,0,0,0,0,2,2,0),
    intArrayOf(1,1,1,1,1,1,1,1,1,1)
)
const val TILE_SIZE = 64
๐Ÿ–Œ๏ธ Rendering the Tilemap
Kotlin โ€” Draw Only Visible Tiles
fun renderLevel(canvas: Canvas) {
    for (row in level.indices) {
        for (col in level[row].indices) {
            val tile = level[row][col]
            if (tile == 0) continue  // skip empty cells

            val worldX = col * TILE_SIZE
            val screenX = worldX - cameraX  // Module 9's camera pattern

            // skip tiles fully off-screen โ€” cheap and meaningfully faster on big levels
            if (screenX < -TILE_SIZE || screenX > screenWidth) continue

            val bitmap = if (tile == 1) groundBitmap else platformBitmap
            canvas.drawBitmap(bitmap, screenX, (row * TILE_SIZE).toFloat(), null)
        }
    }
}
๐Ÿ’ก
Off-screen culling matters more than it looksA long level might have thousands of tiles total โ€” drawing every single one every frame regardless of visibility wastes enormous time. The bounds check above is a simple, meaningful performance win covered further in Module 14.
๐Ÿงฑ Collision From the Same Grid

The payoff for representing a level as data: collision detection can query the same array instead of maintaining a separate list of collision rectangles.

Kotlin โ€” World Position to Grid Cell
fun isSolidAt(worldX: Float, worldY: Float): Boolean {
    val col = (worldX / TILE_SIZE).toInt()
    val row = (worldY / TILE_SIZE).toInt()
    if (row !in level.indices || col !in level[0].indices) return false
    return level[row][col] != 0
}
๐ŸŽจ Beyond Hand-Typed Arrays

For anything beyond a short test level, dedicated tools like Tiled (free, open-source) let you visually paint a level and export it as data your game reads at runtime โ€” the same grid concept, just with an actual editor instead of counting commas in a Kotlin array by hand.

๐Ÿง  Quick Check
Why check whether a tile is off-screen before drawing it, on a large level?
MODULE 12

๐Ÿ”Š Sound Effects & Music

SoundPool for snappy sound effects, MediaPlayer for background music โ€” and why using the wrong one for the wrong job causes either lag or wasted memory.

๐Ÿ†š SoundPool vs. MediaPlayer
SoundPoolMediaPlayer
Best forShort sound effects (jump, coin, hit)Background music, long audio
LatencyVery low โ€” loaded into memory, plays instantlyHigher โ€” streams from disk
Memory useLow per clip, but all loaded at onceLow โ€” streams rather than fully loading
Concurrent soundsMultiple at once (e.g. rapid jumps)One stream at a time per instance
โš ๏ธ
Using MediaPlayer for jump sounds is the classic mistakeMediaPlayer's setup overhead per play call causes a noticeable delay โ€” completely wrong for something that needs to fire the instant a player taps the screen. SoundPool exists specifically to solve this.
๐Ÿ”Š SoundPool โ€” Sound Effects
Kotlin
val soundPool = SoundPool.Builder().setMaxStreams(5).build()
val jumpSoundId = soundPool.load(context, R.raw.jump, 1)  // load once, keep the ID

// later, inside handleJump():
soundPool.play(jumpSoundId, 1f, 1f, 0, 0, 1f)
// params: soundId, leftVolume, rightVolume, priority, loop, playbackRate

setMaxStreams(5) means up to 5 sounds can overlap simultaneously โ€” important for something like rapid jumps or multiple simultaneous hits.

๐ŸŽต MediaPlayer โ€” Background Music
Kotlin
var musicPlayer: MediaPlayer? = null

fun startMusic() {
    musicPlayer = MediaPlayer.create(context, R.raw.background_music)
    musicPlayer?.isLooping = true
    musicPlayer?.start()
}

fun stopMusic() {
    musicPlayer?.stop()
    musicPlayer?.release()  // always release โ€” it holds real system resources
    musicPlayer = null
}
๐Ÿ”— Tying Audio to the Lifecycle

Directly extending Module 3/6's pause pattern: music should stop in onPause() just like the game thread, and resume in onResume() โ€” otherwise background music keeps playing after the user switches away from the app entirely.

Kotlin โ€” MainActivity.kt
override fun onPause() {
    super.onPause()
    gameView.pause()
    musicPlayer?.pause()
}

override fun onDestroy() {
    super.onDestroy()
    soundPool.release()
    musicPlayer?.release()
}
๐Ÿง  Quick Check
A jump sound effect has a noticeable, annoying delay every time it plays. What's the likely cause?
MODULE 13

๐Ÿ† Game State โ€” Menus, Score & Game Over

A real game is more than the play loop โ€” it's a menu, a playing state, a pause, and a game-over screen, all sharing one codebase without turning into a tangle of booleans.

๐ŸŽ›๏ธ A State Machine, Not a Pile of Booleans

The tempting-but-wrong approach is a growing set of flags: isPlaying, isPaused, isGameOver, isMenuShowing โ€” which quickly allows contradictory combinations. An enum-based state machine makes "what state is the game in" a single, unambiguous value.

Kotlin โ€” GameState.kt
enum class GameState { MENU, PLAYING, PAUSED, GAME_OVER }

class GameView(context: Context) : SurfaceView(context), SurfaceHolder.Callback {
    private var state = GameState.MENU

    fun update() {
        when (state) {
            GameState.MENU      -> { /* waiting for tap to start */ }
            GameState.PLAYING   -> updateGameplay()
            GameState.PAUSED    -> { /* frozen โ€” no updates */ }
            GameState.GAME_OVER -> { /* waiting for tap to restart */ }
        }
    }

    fun render(canvas: Canvas) {
        when (state) {
            GameState.MENU      -> renderMenu(canvas)
            GameState.PLAYING,
            GameState.PAUSED    -> renderGameplay(canvas)  // paused still shows the frozen world
            GameState.GAME_OVER -> renderGameOver(canvas)
        }
    }
}
๐Ÿ’ก
The compiler enforces completenessKotlin's when over an enum won't compile unless every case is handled (or there's an else) โ€” add a new state later and the compiler immediately flags every place that now needs to handle it. This is worth far more than it sounds once a project grows.
๐Ÿ† Tracking Score
Kotlin
private var score = 0

fun onCoinCollected() {
    score += 10
}

// in render(), drawn every frame regardless of camera position โ€” this is UI, not world space
canvas.drawText("Score: $score", 20f, 60f, scoreTextPaint)

Note score text is drawn at a fixed screen position, never adjusted by cameraX โ€” it's UI, living in screen space, unlike the world objects from Module 9.

๐Ÿ’€ Game Over & Restart
Kotlin
fun onPlayerDied() {
    state = GameState.GAME_OVER
}

fun restartGame() {
    player = Player(startX, startY)   // fresh player state
    cameraX = 0f
    score = 0
    enemies.clear()
    enemies.addAll(loadLevelEnemies())
    state = GameState.PLAYING
}
โš ๏ธ
Reset everything, not just the playerA common bug: restarting resets the player's position but forgets the camera, score, or enemy list โ€” leaving stale state from the previous run bleeding into the new one. Treat restart as building a clean game world from scratch.
๐Ÿง  Quick Check
Why is an enum-based GameState generally better than several separate boolean flags?
MODULE 14

๐Ÿš€ Performance & Publishing

Making the game run smoothly on real devices, then getting it out of Android Studio and onto the Google Play Store.

โšก Performance Checklist
RuleWhy
Never allocate inside update()/render()Creating new objects every frame (e.g. Rect(), Paint()) triggers garbage collection pauses โ€” allocate once, reuse the reference
Cull off-screen objectsCovered in Module 11 โ€” don't draw or update what's nowhere near the camera
Reuse Paint objectsSame allocation concern โ€” one Paint per visual style, created once, not per draw call
Keep bitmaps appropriately sizedA 4000ร—4000 image scaled down at runtime wastes memory and decode time โ€” resize source assets to what's actually needed
Profile before optimizing blindlyAndroid Studio's built-in Profiler (View โ†’ Tool Windows โ†’ Profiler) shows real CPU/memory data โ€” fix what's actually slow, not what you assume is slow
โš ๏ธ
The single most common beginner performance bugCreating a new Rect() or new Paint() inside update() or render(). At 60 FPS that's 60+ new objects every second doing nothing but generating garbage collection work. Declare them once as class fields and mutate their values instead.
๐Ÿ“ฆ Building a Release APK/AAB
  1. Build menu โ†’ Generate Signed App Bundle / APK.
  2. Choose Android App Bundle (AAB) โ€” the format Google Play requires for new apps, letting Play generate optimized APKs per device automatically.
  3. Create a new keystore (or use an existing one) โ€” this is your app's signing identity. Back this up somewhere safe โ€” losing it means you can never update the app under the same listing again.
  4. Choose the release build variant, which strips debug info and can shrink/optimize code via R8 (Android's code shrinker).
  5. Android Studio outputs a signed .aab file, ready to upload.
๐Ÿฌ Publishing to Google Play
  1. Create a Google Play Console developer account (one-time fee).
  2. Create a new app listing โ€” title, description, screenshots, feature graphic, icon.
  3. Fill out the required content rating questionnaire and privacy policy (a URL is required even for a simple game).
  4. Upload the signed AAB to a testing track first (Internal or Closed testing) before going to Production โ€” this catches issues with real reviewers before the public sees them.
  5. Submit for review. Approval typically takes anywhere from a few hours to a few days.
๐Ÿ’ก
Test the actual release build before submittingDebug and release builds can behave differently (R8 shrinking can occasionally strip something your code actually needed via reflection). Always install and play through the signed release build once before uploading, not just the debug build you've been testing throughout development.
๐Ÿง  Quick Check
Your game stutters periodically even though gameplay logic seems simple. A likely culprit isโ€ฆ
MODULE 15 ๐Ÿ

๐Ÿ Capstone โ€” Build a Complete 2D Side-Scroller

Every module in this course, assembled into one playable game: a running/jumping player, a scrolling parallax level, platforms, enemies, coins, a score HUD, sound, and a proper menu โ†’ play โ†’ game over โ†’ restart cycle.

๐ŸŽฎ What You're Building

A single-level runner/platformer: the player automatically faces right, taps to jump over gaps and enemies, collects coins for score, and dies on enemy contact or falling into a pit โ€” triggering Game Over with a tap-to-restart. Small in scope on purpose: this is the shape of game you can actually finish, then expand.

๐Ÿ—‚๏ธ Step 1 โ€” Project Skeleton

Starting from Module 1's Empty Activity project, you need three new Kotlin files alongside MainActivity.kt:

app/java/com.example.sidescroller/ โ”œโ”€โ”€ MainActivity.kt โ† hosts GameView, wires lifecycle (Module 3, 6) โ”œโ”€โ”€ GameView.kt โ† SurfaceView, thread, state machine, update/render โ”œโ”€โ”€ GameThread.kt โ† the loop itself (Module 6) โ””โ”€โ”€ Player.kt โ† player physics + animation (Modules 7-9)
Kotlin โ€” MainActivity.kt
class MainActivity : AppCompatActivity() {
    private lateinit var gameView: GameView

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        gameView = GameView(this)
        setContentView(gameView)   // the whole screen IS the game โ€” no XML layout needed here
    }

    override fun onPause() { super.onPause(); gameView.pause() }
    override fun onResume() { super.onResume(); gameView.resume() }
}
๐ŸŽ›๏ธ Step 2 โ€” GameState & the GameView Skeleton
Kotlin โ€” GameView.kt (skeleton)
enum class GameState { MENU, PLAYING, GAME_OVER }

class GameView(context: Context) : SurfaceView(context), SurfaceHolder.Callback {

    private var thread: GameThread? = null
    private var state = GameState.MENU

    private val player = Player(x = 100f, groundY = 600f)
    private var cameraX = 0f
    private var score = 0

    // level as a tile grid โ€” Module 11. 0=empty, 1=ground, 2=platform
    private val level = arrayOf(
        intArrayOf(0,0,0,2,2,0,0,2,2,2,0,0,0,2,2),
        intArrayOf(1,1,1,1,1,1,0,0,1,1,1,1,1,1,1)  // note the 2-tile gap = a pit
    )
    private val tileSize = 64

    private data class Enemy(var x: Float, val y: Float, var alive: Boolean = true)
    private data class Coin(val x: Float, val y: Float, var collected: Boolean = false)
    private val enemies = mutableListOf(Enemy(700f, 536f), Enemy(1100f, 536f))
    private val coins = mutableListOf(Coin(300f, 450f), Coin(500f, 450f), Coin(900f, 450f))

    init { holder.addCallback(this) }

    override fun surfaceCreated(holder: SurfaceHolder) {
        thread = GameThread(holder, this).apply { running = true; start() }
    }
    override fun surfaceDestroyed(holder: SurfaceHolder) {
        thread?.running = false
        thread?.join()
    }
    override fun surfaceChanged(h: SurfaceHolder, f: Int, w: Int, ht: Int) {}

    fun pause() { thread?.running = false }
    fun resume() { thread?.running = true }
}
๐Ÿƒ Step 3 โ€” The Player Class
Kotlin โ€” Player.kt
class Player(var x: Float, val groundY: Float) {
    var y = groundY
    var velocityY = 0f
    var isJumping = false
    val width = 48f
    val height = 64f
    private val gravity = 1.6f
    private val jumpVelocity = -28f
    val runSpeed = 4f  // auto-runner โ€” always moving right

    fun jump() {
        if (!isJumping) { velocityY = jumpVelocity; isJumping = true }
    }

    fun update() {
        x += runSpeed
        velocityY += gravity
        y += velocityY
        if (y >= groundY) { y = groundY; velocityY = 0f; isJumping = false }
    }

    fun bounds() = RectF(x, y, x + width, y + height)
    fun isFalling() = velocityY > 15f  // used for "fell into a pit" death check
}
๐Ÿ’ก
This is an auto-runner, not free movementKeeping the player always moving right (Module 8's control scope reduced to just "jump") is a deliberate scope decision โ€” it removes an entire axis of physics/collision complexity while still producing a genuinely playable side-scroller. Expanding to free left/right movement afterward is a great first modification once this version works.
๐Ÿงต Step 4 โ€” GameThread (unchanged from Module 6)
Kotlin โ€” GameThread.kt
class GameThread(private val holder: SurfaceHolder, private val view: GameView) : Thread() {
    var running = false
    override fun run() {
        val targetMs = 1000L / 60
        while (running) {
            val start = System.currentTimeMillis()
            val canvas = holder.lockCanvas() ?: continue
            try { view.update(); view.render(canvas) }
            finally { holder.unlockCanvasAndPost(canvas) }
            val sleepTime = targetMs - (System.currentTimeMillis() - start)
            if (sleepTime > 0) sleep(sleepTime)
        }
    }
}
๐Ÿ’ฅ Step 5 โ€” Wiring update(): Camera, Collision, Score, Death
Kotlin โ€” inside GameView
fun update() {
    if (state != GameState.PLAYING) return

    player.update()
    cameraX = player.x - 200f

    // Module 9 AABB checks against enemies
    val pBounds = player.bounds()
    for (enemy in enemies) {
        if (!enemy.alive) continue
        val eBounds = RectF(enemy.x, enemy.y, enemy.x + 48f, enemy.y + 48f)
        if (pBounds.intersects(eBounds.left, eBounds.top, eBounds.right, eBounds.bottom)) {
            if (player.isFalling() && pBounds.bottom < eBounds.top + 16f) {
                enemy.alive = false  // stomped from above โ€” classic platformer rule
                score += 50
            } else {
                state = GameState.GAME_OVER
            }
        }
    }

    // coin collection
    for (coin in coins) {
        if (coin.collected) continue
        val cBounds = RectF(coin.x, coin.y, coin.x + 24f, coin.y + 24f)
        if (pBounds.intersects(cBounds.left, cBounds.top, cBounds.right, cBounds.bottom)) {
            coin.collected = true
            score += 10
        }
    }

    // fell into a pit โ€” Module 11's grid lookup at the player's feet
    val col = (player.x / tileSize).toInt()
    if (player.y > 700f) state = GameState.GAME_OVER
}
๐Ÿ–ผ๏ธ Step 6 โ€” Wiring render(): World, HUD, Menu, Game Over
Kotlin โ€” inside GameView
fun render(canvas: Canvas) {
    canvas.drawColor(Color.rgb(135,206,235))  // sky

    when (state) {
        GameState.MENU -> {
            canvas.drawText("TAP TO START", width / 2f - 150f, height / 2f, hudPaint)
        }
        GameState.PLAYING -> {
            renderLevel(canvas)   // Module 11, offset by cameraX (Module 9)
            for (coin in coins) if (!coin.collected)
                canvas.drawCircle(coin.x - cameraX, coin.y, 12f, coinPaint)
            for (enemy in enemies) if (enemy.alive)
                canvas.drawRect(enemy.x - cameraX, enemy.y, enemy.x - cameraX + 48f, enemy.y + 48f, enemyPaint)
            canvas.drawRect(player.x - cameraX, player.y, player.x - cameraX + player.width, player.y + player.height, playerPaint)
            canvas.drawText("Score: $score", 20f, 60f, hudPaint)  // screen space โ€” no camera offset
        }
        GameState.GAME_OVER -> {
            canvas.drawText("GAME OVER โ€” Score: $score", width / 2f - 220f, height / 2f, hudPaint)
            canvas.drawText("Tap to restart", width / 2f - 140f, height / 2f + 60f, hudPaint)
        }
    }
}
๐Ÿ‘† Step 7 โ€” Touch Input Ties the State Machine Together
Kotlin โ€” inside GameView
override fun onTouchEvent(event: MotionEvent): Boolean {
    if (event.action != MotionEvent.ACTION_DOWN) return true
    when (state) {
        GameState.MENU      -> state = GameState.PLAYING
        GameState.PLAYING   -> player.jump()
        GameState.GAME_OVER -> restartGame()
    }
    return true
}

private fun restartGame() {
    player.x = 100f; player.y = player.groundY; player.velocityY = 0f
    cameraX = 0f; score = 0
    enemies.forEach { it.alive = true }
    coins.forEach { it.collected = false }
    state = GameState.PLAYING
}
๐Ÿ”Š Step 8 โ€” Adding Sound (Module 12)

Load a SoundPool in an init block, hold sound IDs for jump/coin/stomp, and call soundPool.play() at each corresponding point above โ€” jump inside Player.jump()'s caller, coin inside the coin-collection loop, stomp inside the enemy-defeated branch. Release the SoundPool in surfaceDestroyed.

โœ… Definition of Done
SystemPasses Whenโ€ฆ
Menu โ†’ Play โ†’ Game Over โ†’ RestartAll four states are reachable by tapping, with no way to get stuck
Jump & gravityFeels controllable โ€” not floaty, not instant; tune gravity/jumpVelocity until it feels right
Scrolling worldCamera follows the player smoothly; nothing jitters or lags behind
CollisionStomping an enemy from above defeats it; touching it from the side ends the game; falling into the pit ends the game
ScoreCoins and stomps both increase it; it resets correctly on restart
PerformanceNo new object allocation inside update()/render() (Module 14); runs smoothly on the emulator
๐Ÿ Final Challenge

Ship a Playable Build

Get every system above running together in one project, and play through it start to death to restart at least ten times without a crash. Then pick ONE expansion and add it: real sprite art replacing the colored rectangles (Module 7), a second level, a parallax background (Module 9), or background music (Module 12). Finishing something small is worth more than an unfinished ambitious version โ€” this is the same discipline every shipped game, indie or AAA, was actually built with.

  • Full MENU โ†’ PLAYING โ†’ GAME_OVER โ†’ restart cycle with no dead ends
  • Player runs automatically, jumps on tap, with tuned gravity/jump feel
  • At least one pit, one platform gap, and two enemies in the level
  • Coins that increase score, enemies stompable from above
  • Camera scrolls correctly with zero world/screen coordinate mixing bugs
  • One chosen expansion (art, level, parallax, or music) beyond the base build
Roadmap