๐ ๏ธ 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.
| Requirement | Minimum | Notes |
|---|---|---|
| OS | Windows 10/11, macOS 12+, or Linux | All three fully supported |
| RAM | 8GB | 16GB strongly recommended โ the emulator alone is hungry |
| Disk space | ~15GB free | IDE, SDKs, and emulator images add up fast |
| A phone (optional) | Any Android phone, USB cable | Testing on real hardware is faster and often easier than the emulator |
- Go to developer.android.com/studio and download the installer for your OS.
- Windows: run the .exe, accept defaults, let it install the Android SDK when prompted.
- 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.
- Linux: extract the tar.gz, run
studio.shfrom the extractedbin/folder. - 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.
| Component | What It Does |
|---|---|
| Android Studio | The IDE itself โ code editor, layout designer, debugger, all in one |
| Android SDK | The actual Android platform APIs your code compiles against |
| SDK Platform Tools | Command-line tools like adb (Android Debug Bridge) that talk to devices/emulators |
| Gradle | The build system that compiles your project โ every Android project is a Gradle project |
| AVD Manager | Creates and runs virtual Android devices (emulators) on your computer |
- From the Welcome screen (or Tools menu once a project is open), open Device Manager.
- Click Create Device.
- Pick a phone profile โ Pixel 6 or similar is a safe, modern default.
- 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.
- Finish โ the AVD now appears in Device Manager, ready to launch.
- From the Welcome screen, click New Project.
- Choose the Empty Activity template โ the plainest possible starting point, exactly what you want for learning.
- Name it (e.g. "MyFirstApp"), leave the package name as generated, choose a save location.
- 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).
- Minimum SDK: API 24 (Android 7.0) is a reasonable default โ covers the vast majority of active devices without limiting you unnecessarily.
- 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.
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.
- Select your AVD (or a connected physical device) from the device dropdown in the toolbar.
- Click the green Run โถ button (or Shift+F10).
- Android Studio builds the project, installs it on the emulator/device, and launches it automatically.
- You should see a plain screen with "Hello World!" โ the default Empty Activity template's only content.
- 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.ktandactivity_main.xmlin the project panel
๐ บ 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.
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.
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.
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
fun takeDamage(amount: Int): Int { return health - amount } // single-expression shorthand for simple functions fun isAlive(hp: Int): Boolean = hp > 0
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)
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") }
var nickname: String? = null. Why does the compiler allow this but not var name: String = null?๐ฑ 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.
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.
class MainActivity : AppCompatActivity() { override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) setContentView(R.layout.activity_main) // connects to the XML layout } }
| Method | Typical 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 |
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.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.
<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.
๐งฑ 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.
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 View | Purpose |
|---|---|
TextView | Displays text |
Button | A tappable button |
ImageView | Displays an image |
EditText | Text input field |
| Layout | Arranges Children |
|---|---|
LinearLayout | In a single row or column |
ConstraintLayout | By constraints relative to each other/the parent โ the modern default, flexible for complex screens |
FrameLayout | Stacked 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 |
<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>
Every View with an android:id can be referenced from Kotlin. Modern Android projects use View Binding โ type-safe, no manual findViewById casting required.
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!" } } }
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.๐๏ธ 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.
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.
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."
| Method | Draws |
|---|---|
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 |
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.
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.๐ 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.
At its core, every game โ from Pong to a AAA title โ runs the same cycle, forever, until the game ends:
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.
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.
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.
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 } } } }
try/finally above guarantees the unlock always happens, even if render() throws an exception.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:
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) } }
Directly connecting back to Module 3: surfaceCreated/surfaceDestroyed already handle most of it, but the Activity itself should also explicitly pause/resume:
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 }
๐ผ๏ธ 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.
Place image assets in res/drawable/. A Bitmap is the in-memory representation of that image, ready to be drawn onto a Canvas.
val playerBitmap = BitmapFactory.decodeResource(resources, R.drawable.player) // in render(): canvas.drawBitmap(playerBitmap, playerX, playerY, null)
onCreate() or an init block and keep the reference โ never call decodeResource inside update() or render(), which run dozens of times per second.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.
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) }
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.
๐ 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.
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 }
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.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.
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 }
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.
| Approach | Pro | Con |
|---|---|---|
| Screen-zone touch | Simple, no extra Views, works anywhere on screen | Less visually obvious to new players |
| Overlaid buttons | Clear visual affordance, standard Android input handling | More setup, needs careful layout for different screen sizes |
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:
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
๐ฅ 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.
Real platformer "physics" is almost always just: apply a constant downward acceleration every frame, add it to vertical velocity, add velocity to position.
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 } }
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.
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)
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.
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) } }
๐ฅ 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.
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.
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) } }
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.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.
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) } }
๐บ๏ธ 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 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.
// 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
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) } } }
The payoff for representing a level as data: collision detection can query the same array instead of maintaining a separate list of collision rectangles.
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 }
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.
๐ 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 | MediaPlayer | |
|---|---|---|
| Best for | Short sound effects (jump, coin, hit) | Background music, long audio |
| Latency | Very low โ loaded into memory, plays instantly | Higher โ streams from disk |
| Memory use | Low per clip, but all loaded at once | Low โ streams rather than fully loading |
| Concurrent sounds | Multiple at once (e.g. rapid jumps) | One stream at a time per instance |
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.
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 }
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.
override fun onPause() { super.onPause() gameView.pause() musicPlayer?.pause() } override fun onDestroy() { super.onDestroy() soundPool.release() musicPlayer?.release() }
๐ 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.
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.
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) } } }
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.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.
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 }
๐ Performance & Publishing
Making the game run smoothly on real devices, then getting it out of Android Studio and onto the Google Play Store.
| Rule | Why |
|---|---|
| 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 objects | Covered in Module 11 โ don't draw or update what's nowhere near the camera |
| Reuse Paint objects | Same allocation concern โ one Paint per visual style, created once, not per draw call |
| Keep bitmaps appropriately sized | A 4000ร4000 image scaled down at runtime wastes memory and decode time โ resize source assets to what's actually needed |
| Profile before optimizing blindly | Android 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 |
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.- Build menu โ Generate Signed App Bundle / APK.
- Choose Android App Bundle (AAB) โ the format Google Play requires for new apps, letting Play generate optimized APKs per device automatically.
- 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.
- Choose the release build variant, which strips debug info and can shrink/optimize code via R8 (Android's code shrinker).
- Android Studio outputs a signed
.aabfile, ready to upload.
- Create a Google Play Console developer account (one-time fee).
- Create a new app listing โ title, description, screenshots, feature graphic, icon.
- Fill out the required content rating questionnaire and privacy policy (a URL is required even for a simple game).
- 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.
- Submit for review. Approval typically takes anywhere from a few hours to a few days.
๐ 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.
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.
Starting from Module 1's Empty Activity project, you need three new Kotlin files alongside 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() } }
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 } }
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 }
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) } } }
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 }
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) } } }
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 }
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.
| System | Passes Whenโฆ |
|---|---|
| Menu โ Play โ Game Over โ Restart | All four states are reachable by tapping, with no way to get stuck |
| Jump & gravity | Feels controllable โ not floaty, not instant; tune gravity/jumpVelocity until it feels right |
| Scrolling world | Camera follows the player smoothly; nothing jitters or lags behind |
| Collision | Stomping an enemy from above defeats it; touching it from the side ends the game; falling into the pit ends the game |
| Score | Coins and stomps both increase it; it resets correctly on restart |
| Performance | No new object allocation inside update()/render() (Module 14); runs smoothly on the emulator |
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