Course · 20 chapters · MIT licensed

Tempest, From Scratch

A chapter-by-chapter walkthrough of building a playable Tempest-style tube shooter in Kotlin and Jetpack Compose, using MVVM + MVI as the load-bearing architecture rather than an afterthought — from an empty Android Studio project to a game that runs on a real phone.

Every chapter was written from the real, reviewed code after that phase actually shipped. Not from a plan, not from an outline — from what was in the repository once it built, passed its tests and ran on a device.

Which means the wrong turns are still in it. A control that ran at double speed on a 120Hz phone and looked perfect on every emulator. A test that passed for the wrong reason. Help text describing behaviour the code had deleted two chapters earlier. Those are the parts that transfer to whatever you are actually building.

How to read this

  1. Chapters 00–08 build the skeleton: project setup, a pure MVI reducer, navigation, the tube renderer, movement, shooting, the first enemy, spikes and the level warp.
  2. Chapters 09–14 make it a game: game over and persistence, the superzapper, the full five-enemy roster, every web shape, audio and haptics, and a readability pass driven by actually playing it.
  3. Chapters 15–19 are where it gets interesting: a pseudo-3D rewrite, a real disassembly compared against our invented rules, a frame-rate bug in the controls, particles and bloom added without touching game state, and four defects that one unfamiliar phone found in ten minutes.
  4. Each chapter ends with a Done when checklist and a Verified note recording what was actually confirmed — and, just as often, what was not.

Source, issues and the full project history: github.com/cpinan/Tempest-Jetpack-Compose. MIT licensed. Tempest is a trademark of Atari Interactive, Inc.; this is an independent, non-commercial study reimplementation containing no original Atari code or assets.

Chapter 00 done

Project setup

Get a Compose project building with the right dependencies and package skeleton before writing a single line of game logic.

Every phase in this course assumes you're working directly in Android Studio, on a real device or emulator — not just reading. Install the latest stable Android Studio if you haven't; it bundles the JDK and Kotlin plugin, so there's nothing else to set up separately.

Create the project

File → New → New Project → Empty Activity (the Compose one, not the legacy View-based "Empty Activity" — Android Studio labels the Compose template clearly in recent versions). Set:

  • Name: Tempest (or your real app name)
  • Package name: com.carlospinan.tempest.atari — use your own domain-reversed namespace. Do not ship com.example.*: Play rejects it outright, and it is the app's permanent identity. Pick it once and don't change it mid-course. This project did rename late, and it cost a full source move plus a reset of every saved high score, because DataStore keys its files by package.
  • Minimum SDK: API 26 — this is a deliberate floor, not the default. Chapter 3 uses BlurMaskFilter for the vector glow effect, which works reliably back to API 21, but 26 is where a few coroutine/lifecycle APIs we lean on get simpler, so it's the pragmatic cutoff for this project.
SPEC §6 Min SDK, dependency list, and the full tech-stack rationale live in spec.md §6 if you want the "why" behind each choice before typing.

Add dependencies

Open gradle/libs.versions.toml and add these entries (Android Studio's template already gives you Compose BOM, activity-compose, and the Material3/core libs — the ones below are what the template doesn't include):

[versions]
navigationCompose = "2.8.0"
datastorePreferences = "1.1.1"
kotlinxSerializationJson = "1.7.1"

[libraries]
navigation-compose = { group = "androidx.navigation", name = "navigation-compose", version.ref = "navigationCompose" }
datastore-preferences = { group = "androidx.datastore", name = "datastore-preferences", version.ref = "datastorePreferences" }
kotlinx-serialization-json = { group = "org.jetbrains.kotlinx", name = "kotlinx-serialization-json", version.ref = "kotlinxSerializationJson" }
lifecycle-viewmodel-compose = { group = "androidx.lifecycle", name = "lifecycle-viewmodel-compose", version.ref = "lifecycle" }

Then in app/build.gradle.kts:

plugins {
    id("org.jetbrains.kotlin.plugin.serialization") version "2.0.20"
    // ...existing compose/android plugins
}

dependencies {
    implementation(libs.navigation.compose)
    implementation(libs.datastore.preferences)
    implementation(libs.kotlinx.serialization.json)
    implementation(libs.lifecycle.viewmodel.compose)
    // ...existing compose/core deps from the template

    testImplementation(libs.junit)
    androidTestImplementation(libs.androidx.ui.test.junit4)
}

The serialization plugin isn't optional decoration — Chapter 2's typed Route sealed interface uses @Serializable so Compose Navigation can encode/decode routes without hand-written string parsing. Skipping it now just means adding it back in Chapter 2 under time pressure.

Package skeleton

Right-click com.carlospinan.tempest.atari in the project tree → New → Package, and create these as empty packages (no files yet, just the folders — later chapters fill them in one at a time):

com.carlospinan.tempest.atari/
├── domain/
│   ├── model/
│   ├── engine/
│   └── repository/
├── data/
│   ├── datastore/
│   └── audio/
└── presentation/
    ├── nav/
    ├── splash/
    ├── menu/
    ├── game/
    └── uikit/

The three-layer split (domain / data / presentation) is the backbone of the MVVM+MVI architecture this whole course builds toward: game rules live in domain as plain Kotlin with zero Android imports, so they can be unit-tested without an emulator — that pays off starting next chapter.

Init git

git init
git add .
git commit -m "Project scaffold: Compose template + dependencies + package skeleton"

Android Studio's template already ships a reasonable .gitignore for Android/Gradle — no changes needed there.

Done when

  • App builds and launches to the template's default Compose screen on a device/emulator.
  • All eight package folders from the tree above exist (empty is fine).
  • ./gradlew build succeeds from a clean checkout — proves the new dependencies resolved, not just that Android Studio's cache is happy.
Chapter 01 done

MVI skeleton — no game yet

Wire up Intent → Reducer → State → View end to end on a fake "StartGame" action, and prove the reducer is unit-testable before there's any actual game logic to test.

MVI in one paragraph, for real this time

MVVM gives you a ViewModel exposing state to a View. MVI (Model-View-Intent) is a stricter shape inside that ViewModel: every user action becomes an explicit Intent value (not a direct method call like viewModel.incrementScore()), a single reducer function takes the current State plus an Intent and returns the next state, and the View only ever renders state — it never mutates anything directly. One input type, one output type, one function connecting them. That constraint is the entire point: a reducer with that shape is trivial to unit test, because it's just (State, Intent) -> State — no mocking a ViewModel, no Android runtime, just values in and values out.

The wrinkle for a game specifically: what about things that aren't user input, like "a frame elapsed, move everything forward by 16ms"? Answer: that's an Intent too (Tick). Time becomes just another input to the same reducer, which is what makes the reducer deterministic and testable even though the game runs in real time — feed it a fixed sequence of Ticks in a test and you get a fixed, reproducible outcome.

WHY Why not just call ViewModel methods directly? You can, and plenty of production MVVM code does. The reason MVI's extra ceremony earns its keep here specifically: a game has dozens of things that can happen in a single frame (drag + fire + a collision + a level-clear check), and funneling all of them through one reducer means there's exactly one place that decides what the state looks like after all of that — no risk of two methods stepping on each other's partial updates mid-frame.

The contract

Create presentation/game/GameContract.kt. Start small — this file grows over the next several chapters, don't try to write the final version now:

package com.carlospinan.tempest.atari.presentation.game

sealed interface GameIntent {
    data object StartGame : GameIntent
    data class Tick(val frameTimeNanos: Long) : GameIntent
}

data class GameState(
    val status: GameStatus = GameStatus.Playing,
    val frameCount: Long = 0,
)

enum class GameStatus { Playing, Paused, Warping, GameOver }

sealed interface GameEffect
// empty for now — Chapter 6 adds PlaySound, this stays a marker interface until then

The reducer — plain Kotlin, no Android

Create domain/engine/GameReducer.kt. Note the package: this lives in domain, not presentation — it must not import anything from android.*, ever, for the rest of the project.

package com.carlospinan.tempest.atari.domain.engine

import com.carlospinan.tempest.atari.presentation.game.GameEffect
import com.carlospinan.tempest.atari.presentation.game.GameIntent
import com.carlospinan.tempest.atari.presentation.game.GameState
import com.carlospinan.tempest.atari.presentation.game.GameStatus

object GameReducer {
    fun reduce(state: GameState, intent: GameIntent): Pair<GameState, List<GameEffect>> =
        when (intent) {
            is GameIntent.StartGame ->
                state.copy(status = GameStatus.Playing) to emptyList()

            is GameIntent.Tick ->
                state.copy(frameCount = state.frameCount + 1) to emptyList()
        }
}

Returning Pair<GameState, List<GameEffect>> instead of just GameState looks like overkill with an empty effect list, but retrofitting effects onto a reducer that was only ever tested as returning bare State is a bigger rewrite later than paying this small cost now.

The ViewModel — thin glue, not where logic lives

package com.carlospinan.tempest.atari.presentation.game

import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.carlospinan.tempest.atari.domain.engine.GameReducer
import kotlinx.coroutines.channels.Channel
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.receiveAsFlow
import kotlinx.coroutines.launch

class GameViewModel : ViewModel() {
    private val _state = MutableStateFlow(GameState())
    val state: StateFlow<GameState> = _state

    private val _effects = Channel<GameEffect>(Channel.BUFFERED)
    val effects = _effects.receiveAsFlow()

    fun onIntent(intent: GameIntent) {
        val (nextState, effects) = GameReducer.reduce(_state.value, intent)
        _state.value = nextState
        effects.forEach { effect ->
            viewModelScope.launch { _effects.send(effect) }
        }
    }
}

onIntent is the only public entry point besides the two read-only flows. Nothing outside this class ever reaches in and sets _state directly — that discipline is what keeps "every state change goes through the reducer" true as the app grows, rather than being true only by convention on day one.

A screen that just proves the wiring

package com.carlospinan.tempest.atari.presentation.game

import androidx.compose.foundation.layout.*
import androidx.compose.material3.Button
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.lifecycle.viewmodel.compose.viewModel

@Composable
fun GameScreen(viewModel: GameViewModel = viewModel()) {
    val state by viewModel.state.collectAsState()

    Column(Modifier.fillMaxSize().padding(24.dp)) {
        Text("status: ${'$'}{state.status}")
        Text("frameCount: ${'$'}{state.frameCount}")
        Button(onClick = { viewModel.onIntent(GameIntent.StartGame) }) {
            Text("Start")
        }
    }
}

This is throwaway UI — real HUD/Canvas rendering starts in Chapter 3, real navigation into this screen in Chapter 2. Right now the only job is proving state flows out and intents flow in.

The first test — and why it needs no emulator

Create test/.../domain/engine/GameReducerTest.kt under src/test (JVM unit tests, not src/androidTest):

package com.carlospinan.tempest.atari.domain.engine

import com.carlospinan.tempest.atari.presentation.game.GameIntent
import com.carlospinan.tempest.atari.presentation.game.GameState
import com.carlospinan.tempest.atari.presentation.game.GameStatus
import org.junit.Assert.assertEquals
import org.junit.Test

class GameReducerTest {
    @Test
    fun `StartGame sets status to Playing`() {
        val initial = GameState(status = GameStatus.GameOver)

        val (result, effects) = GameReducer.reduce(initial, GameIntent.StartGame)

        assertEquals(GameStatus.Playing, result.status)
        assertEquals(emptyList<Any>(), effects)
    }

    @Test
    fun `Tick increments frameCount`() {
        val initial = GameState(frameCount = 5)

        val (result, _) = GameReducer.reduce(initial, GameIntent.Tick(frameTimeNanos = 0))

        assertEquals(6, result.frameCount)
    }
}

Run it with Run → Run 'GameReducerTest' or ./gradlew test. It finishes in well under a second, on the JVM, with no device or emulator attached — that speed is the entire payoff of keeping GameReducer free of Android imports. Every enemy behavior, every collision rule, every scoring edge case for the rest of this project gets tested exactly this way.

SPEC §7, §17 The full contract shape (all intents/effects the finished game needs) is in spec.md §7.1; the testing philosophy this chapter demonstrates is spelled out in §17.

Done when

  • Tapping Start in the running app flips the on-screen status text to "Playing".
  • Both reducer tests pass via ./gradlew test.
  • GameReducer.kt has zero android.* imports — check by reading the import block, not by assuming.
Chapter 02 done

Navigation shell + Atari UI kit

Splash → Menu → Game routing with a single NavController owner, plus the vector-styled button/panel components every later screen reuses.

Three routes, not one per game state

It's tempting to make every screen-shaped thing a navigation destination: Menu, Game, Paused, Game Over, initials entry. Resist that. Paused/Warping/GameOver are all states of a single play session — modeling them as separate routes would mean passing the entire GameState (score, lives, level...) through navigation arguments just to render a "Game Over" screen, which is the exact "complex objects through navigation" anti-pattern navigation guidance warns against. So the real graph is just three routes:

package com.carlospinan.tempest.atari.presentation.nav

import kotlinx.serialization.Serializable

sealed interface Route {
    @Serializable
    data object Splash : Route

    @Serializable
    data object Menu : Route

    /** startLevel is the only argument passed through navigation — an identifier, not an object. */
    @Serializable
    data class Game(val startLevel: Int) : Route
}

Paused/Warping/GameOver stay as GameState.status values, rendered as overlays inside the Game screen once later chapters need them. Zero navigation involved for any of them.

One NavController owner, screens that only emit events

Every screen composable takes plain callbacks — onStartGame(startLevel), onSplashDone() — never a NavController reference. Only TempestNavHost is allowed to call navigate(). This isn't a style preference: if a leaf composable can call navigate() directly, nothing stops it from doing so mid-recomposition, which is how you get navigation loops that are miserable to debug. One owner, one place to reason about the back stack.

package com.carlospinan.tempest.atari.presentation.nav

import androidx.compose.animation.EnterTransition
import androidx.compose.animation.ExitTransition
import androidx.compose.animation.core.tween
import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut
import androidx.compose.animation.scaleIn
import androidx.compose.animation.scaleOut
import androidx.compose.runtime.Composable
import androidx.navigation.NavHostController
import androidx.navigation.compose.NavHost
import androidx.navigation.compose.composable
import androidx.navigation.compose.rememberNavController
import androidx.navigation.toRoute
import com.carlospinan.tempest.atari.presentation.game.GameScreen
import com.carlospinan.tempest.atari.presentation.menu.MenuScreen
import com.carlospinan.tempest.atari.presentation.splash.SplashScreen

private const val TRANSITION_MS = 220

private fun zoomIntoTube(): EnterTransition =
    scaleIn(initialScale = 0.85f, animationSpec = tween(TRANSITION_MS)) +
        fadeIn(animationSpec = tween(TRANSITION_MS))

private fun zoomOutOfTube(): ExitTransition =
    scaleOut(targetScale = 1.1f, animationSpec = tween(TRANSITION_MS)) +
        fadeOut(animationSpec = tween(TRANSITION_MS))

private fun fadeThroughBlackIn(): EnterTransition = fadeIn(animationSpec = tween(TRANSITION_MS))
private fun fadeThroughBlackOut(): ExitTransition = fadeOut(animationSpec = tween(TRANSITION_MS))

@Composable
fun TempestNavHost(navController: NavHostController = rememberNavController()) {
    NavHost(navController = navController, startDestination = Route.Splash) {
        composable<Route.Splash> {
            SplashScreen(
                onSplashDone = {
                    navController.navigate(Route.Menu) {
                        popUpTo(Route.Splash) { inclusive = true }
                    }
                },
            )
        }

        composable<Route.Menu>(
            enterTransition = { fadeThroughBlackIn() },
            popEnterTransition = { fadeThroughBlackIn() },
        ) {
            MenuScreen(
                onStartGame = { startLevel ->
                    navController.navigate(Route.Game(startLevel)) {
                        launchSingleTop = true
                    }
                },
            )
        }

        composable<Route.Game>(
            enterTransition = { zoomIntoTube() },
            exitTransition = { zoomOutOfTube() },
            popExitTransition = { fadeThroughBlackOut() },
        ) { backStackEntry ->
            val route: Route.Game = backStackEntry.toRoute()
            GameScreen(startLevel = route.startLevel)
        }
    }
}

Only Menu→Game and Game→Menu get custom transitions (spec.md §8.5) — a fast radial scale-in ("zoom into the tube") forward, a cross-fade back. Since both screens share the same near-black background, a plain cross-fade between them already reads as "fade through black" — no extra compositing needed, the palette does the work.

GOTCHA Don't trust a single screenshot of a transition. Grabbing a screenshot ~600ms after a nav event on a software-rendered emulator caught Menu and Game both mid-fade, looking like a permanent overlap bug. It wasn't — waiting past the transition window showed a clean settle every time. If something looks broken right after a navigation call, let the animation finish before you diagnose it as a bug.

Stack shaping, and why popUpTo(Menu){inclusive=true} matters later

TransitionOptions
Splash → MenupopUpTo(Splash){inclusive=true}
Menu → GamelaunchSingleTop=true
Game → Menu (Chapter 9)popUpTo(Menu){inclusive=true}, launchSingleTop=true

This chapter only needs the first two rows — plain system back from Game already returns to Menu for free (default NavController pop behavior), and back from Menu already exits the app since Menu is the stack root once Splash is popped. The explicit popUpTo(Menu) row is Chapter 9's job, for the moment a dying run's Game Over overlay explicitly requests returning to Menu — it has to clear Game off the stack so a second back-press from Menu doesn't resurrect the dead run instead of exiting.

The vector UI kit

Four small pieces in presentation/uikit/, all reused for the rest of the project. TempestTheme holds the palette as named constants (mirrors tutorial.html's own CSS tokens, so the course and the app read as one system) and wraps content in a MaterialTheme + root Surface:

object TempestColors {
    val Background = Color(0xFF07080B)
    val Panel = Color(0xFF0D1117)
    val Cyan = Color(0xFF4DD9EA)
    val Magenta = Color(0xFFFF4FC0)
    // ...InkDim, Line, Amber, Green — see TempestTheme.kt
}

@Composable
fun TempestTheme(content: @Composable () -> Unit) {
    MaterialTheme(colorScheme = TempestDarkColorScheme) {
        Surface(modifier = Modifier.fillMaxSize(), color = TempestColors.Background) {
            content()
        }
    }
}
GOTCHA The root Surface isn't decoration — without it, plain Text is invisible. Material3's default content color outside any Surface is black; on this app's near-black background that's black-on-black. Chapter 1's GameScreen status text silently rendered invisible from the moment it was written — it only surfaced once this chapter's device screenshots caught it. Button looked fine throughout, because Material buttons always supply their own content color regardless of ambient Surface, which is exactly what made this easy to miss from a build/test pass alone. Screenshot the real screen, don't just trust that a green build means the pixels are right.

VectorButton is an outlined glow rectangle — deliberately not a Material filled button. The glow itself is layered strokes of increasing width and decreasing alpha (spec.md §8.3's documented fallback), not a framework BlurMaskFilter bridge — Paint().asFrameworkPaint() didn't resolve against this project's Compose BOM, and the pure-DrawScope version turned out simpler anyway:

Modifier.drawBehind {
    val corner = CornerRadius(2.dp.toPx())
    listOf(8.dp.toPx() to 0.08f, 5.dp.toPx() to 0.16f, 2.5.dp.toPx() to 0.35f)
        .forEach { (width, alpha) ->
            drawRoundRect(
                color = TempestColors.Cyan.copy(alpha = alpha),
                cornerRadius = corner,
                style = Stroke(width = width),
            )
        }
}

VectorPanel (outlined container, for Chapter 9's Game Over overlay) and BlinkingText (alpha-pulse for "PRESS START"-style cues) exist now but aren't used yet — Menu only needs VectorButton this chapter.

SkillStep is a stub for now

MenuScreen's Start button always launches Route.Game(startLevel = 1) — enough to prove Menu→Game navigation end to end. The real SkillStep picker (reusing the in-game drag-rotation gesture on the starting-level dial, spec.md §8.6) and high-score persistence both land in Chapter 9. GameScreen now accepts startLevel as a parameter but doesn't consume it yet — no GameState.level field exists until the LevelFactory work in Chapters 3/8.

SPEC §8 Full route model, ownership rules, and the Atari-style screen UI proposal are in spec.md §8 — this chapter implements it as written, no deviations.

Done when

  • Splash shows the vector logo + "TEMPEST" wordmark, auto-advances to Menu after ~1.2s.
  • Menu's VectorButton("START") navigates to Game with startLevel passed through — confirmed on-device via GameScreen's status text now actually being visible.
  • System back from Game returns to Menu; system back from Menu backgrounds/exits the app (confirmed via dumpsys activity activities showing the task go visible=false, not a blank screen).
  • grep -rl "NavController" presentation/ outside presentation/nav/ returns nothing.
Chapter 03 done

Tube renderer, static

The Canvas projection math that turns a WebShape into a glowing vector tube on screen.

The web lives in normalized space, not pixels

WebShape stores vertices in a fixed -1f..1f coordinate space — nothing about it knows or cares how big the screen is. That separation matters: level shapes (Chapter 12's job — squares, stars, open lines) are pure geometry data, and the renderer is the only thing that knows about actual pixels. Locked down before any other file depends on it, this file also carries the project's depth convention as a doc comment, since Enemy/Projectile (Chapters 6/5) need to agree on it before they exist:

package com.carlospinan.tempest.atari.domain.model

import androidx.compose.ui.geometry.Offset

/**
 * Depth convention for the whole project (locked here before Enemy/Projectile exist):
 * enemies use 0f=center..1f=rim (increasing = climbing toward danger); projectiles use the
 * inverse, 1f=rim..0f=center (decreasing = traveling toward enemies).
 */
data class WebShape(
    val vertices: List<Offset>,
    val closed: Boolean,
) {
    companion object {
        val EMPTY = WebShape(emptyList(), true)
    }
}

androidx.compose.ui.geometry.Offset is a plain Kotlin data class, not an android.* type — using it in domain/ doesn't break the "testable without an emulator" rule from Chapter 1.

Building a web shape is just trigonometry

package com.carlospinan.tempest.atari.domain.engine

object LevelFactory {
    fun circleWeb(sides: Int): WebShape {
        val vertices = mutableListOf<Offset>()
        for (i in 0 until sides) {
            val angle = 2 * PI * i / sides
            vertices.add(Offset(cos(angle).toFloat(), sin(angle).toFloat()))
        }
        return WebShape(vertices, closed = true)
    }

    // Placeholder — the real modulo-cycle across many shapes is Chapter 12/8's job.
    fun webFor(level: Int): WebShape = circleWeb(sides = 16)
}
SPEC §4 16 sides here isn't arbitrary — it approximates the classic circular web while staying a real polygon, and it's the shape used for level 1 in spec.md §4's content plan. Additional shapes (square, star, open line...) slot into the same LevelFactory pattern later without touching this function.

The projection: normalized space to screen space

One line does all the work — map a -1f..1f point to actual pixels by scaling and offsetting from the screen's center:

Canvas(modifier) {
    val screenCenter = Offset(size.width / 2f, size.height / 2f)
    val scale = min(size.width, size.height) / 2f * 0.8f   // 0.8 leaves a margin

    val projectedVertices = web.vertices.map { normalizedPoint ->
        screenCenter + normalizedPoint * scale
    }
    // ...draw lane lines from each projected vertex to screenCenter,
    // plus rim-to-rim edges when web.closed, per TubeRenderer.kt
}

This is exactly the projection spec.md §8.1 describes for depth too (a point at some enemy depth lerps between its rim vertex and the center) — this chapter only needs the depth = 0 (rim) case since there's nothing moving yet, but the same formula carries forward once Chapter 6 adds enemies climbing through it.

Glow: the same layered-stroke technique, now on a whole web

Same approach as Chapter 2's VectorButton — 2–3 passes of a decreasing-width, decreasing-alpha stroke — applied to every lane line, the rim edges, and the center point. No new technique to learn, just reused on more geometry:

val glowPasses = listOf(6f to 0.08f, 3.5f to 0.16f, 1.5f to 0.35f)

for (vertex in projectedVertices) {
    glowPasses.forEach { (width, alpha) ->
        drawLine(
            color = TempestColors.Cyan.copy(alpha = alpha),
            start = vertex,
            end = screenCenter,
            strokeWidth = width,
        )
    }
}

Wiring it into the screen without disturbing Chapter 1's debug UI

GameScreen still needs its status/frameCount text and Start button — later chapters keep building on them. The tube renderer becomes a full-screen background layer in a Box, with the existing debug Column stacked on top as a corner overlay:

Box(Modifier.fillMaxSize()) {
    TubeRenderer(
        web = LevelFactory.webFor(startLevel),
        modifier = Modifier.fillMaxSize(),
    )
    Column(Modifier.padding(24.dp)) {
        Text("status: ${'$'}{state.status}")
        Text("frameCount: ${'$'}{state.frameCount}")
        Button(onClick = { viewModel.onIntent(GameIntent.StartGame) }) { Text("Start") }
    }
}
GOTCHA A green build is still not proof the pixels are right. Same discipline as Chapter 2: this phase was verified by installing the debug APK on a running emulator, tapping through to the Game screen, and actually looking at the screenshot — confirming a 16-sided cyan web with visible glow, rim outline, and center dot, not just a passing ./gradlew build.
SCOPE One thing got caught and reverted during review here: the implementation had also added an unused web: WebShape field to GameState in GameContract.kt — dead code, since the screen reads the web straight from LevelFactory.webFor(startLevel), not from state. Reverted to keep GameContract.kt exactly as Chapter 1 left it; wiring the web into GameState for real is a later chapter's job, once something (the reducer, on a level-warp) actually needs to change it.

Done when

  • Reaching the Game screen shows a static glowing 16-sided web: rim outline, lane lines converging on a center point, all in cyan with visible glow.
  • Chapter 1's debug UI (status/frameCount/Start) still renders, overlaid in the corner — nothing regressed.
  • ./gradlew build passes, confirmed with a real on-device screenshot, not just a green terminal.
Chapter 04 done

Player movement — the spinner feel

Drag gesture to lane-snap movement, including wraparound.

Discrete lanes, not free-floating pixels

The blaster is never "at some pixel" — it's always at exactly one lane index. A drag doesn't move a coordinate, it accumulates rotational pressure until that pressure crosses a lane's worth of degrees, then the lane index steps by exactly one. This chapter deliberately skips smooth inter-lane sliding (no playerLaneOffset yet) — discrete snapping is simpler to get right first, and it's what "the most important feel-check in the project" (development_plan.md's own words for this phase) needs to prove before any interpolation polish is worth adding.

The contract grows to carry a real web

StartGame becomes a data class carrying startLevel — spec.md §7.1 already described this shape from the start, so this isn't scope creep, it's the moment the field actually becomes load-bearing: the reducer needs to know which web to load. GameState gains web, playerLane, and an internal dragAccumulatorDeg — the last one is reducer bookkeeping, never read by the UI.

sealed interface GameIntent {
    data class StartGame(val startLevel: Int) : GameIntent
    data class Drag(val deltaAngleDeg: Float) : GameIntent
    data class Tick(val frameTimeNanos: Long) : GameIntent
}

data class GameState(
    val status: GameStatus = GameStatus.Playing,
    val frameCount: Long = 0,
    val web: WebShape = WebShape.EMPTY,
    val playerLane: Int = 0,
    val dragAccumulatorDeg: Float = 0f,
)
CONTRAST Compare this to Chapter 3's review, where an unused web: WebShape field got reverted from this exact same file for being dead weight. The difference: that field had nothing reading or writing it. This field is set by StartGame and read by the drag algorithm below on every single Drag intent. Same field name, opposite verdict — the test isn't "does spec.md mention it eventually," it's "does something real consume it right now."

The snap algorithm — a while loop, not an if

A single drag intent can carry enough rotation to cross several lane boundaries at once (a fast flick, or a big swipe in an automated test) — so consuming the accumulator has to loop, not just check once:

is GameIntent.Drag -> {
    val laneCount = state.web.vertices.size
    if (laneCount == 0) state to emptyList()
    else {
        val degreesPerLane = 360f / laneCount
        var accumulator = state.dragAccumulatorDeg + intent.deltaAngleDeg
        var lane = state.playerLane

        while (accumulator >= degreesPerLane) {
            if (!state.web.closed && lane == laneCount - 1) { accumulator = 0f; break }
            accumulator -= degreesPerLane
            lane = if (state.web.closed) (lane + 1).mod(laneCount) else lane + 1
        }
        while (accumulator <= -degreesPerLane) {
            if (!state.web.closed && lane == 0) { accumulator = 0f; break }
            accumulator += degreesPerLane
            lane = if (state.web.closed) (lane - 1).mod(laneCount) else lane - 1
        }

        state.copy(playerLane = lane, dragAccumulatorDeg = accumulator) to emptyList()
    }
}

Three details worth calling out explicitly, because each one is a plausible bug if skipped:

  • Int.mod(), not %. Kotlin's % can return a negative result for a negative dividend; .mod() floors correctly, so lane 0 minus one step wraps to lane 15 instead of lane -1.
  • Open-web boundaries reset the accumulator, they don't just stop. Without the accumulator = 0f on clamp, pressure would keep piling up against the wall while held there — then reversing direction would have to "unwind" all that pent-up pressure before the ship visibly moved, a rubber-band-lag bug that's easy to miss in a quick manual test but obvious the moment someone actually drags back and forth at an edge.
  • The open-web branch is unit-tested with zero open shapes existing in LevelFactory. The reducer doesn't care where a WebShape comes from, so the test constructs one by hand. development_plan.md's own risk list flags open-web logic as "untested until Phase 8" — this chapter tests the logic three phases early, since the code exists now regardless of when the content does.

Wiring: auto-start, drag gesture, player marker

The screen now dispatches StartGame itself on entry — you shouldn't have to tap a debug button before the web appears:

LaunchedEffect(startLevel) {
    viewModel.onIntent(GameIntent.StartGame(startLevel))
}

Box(
    Modifier
        .fillMaxSize()
        .pointerInput(Unit) {
            detectDragGestures { change, dragAmount ->
                change.consume()
                viewModel.onIntent(GameIntent.Drag(deltaAngleDeg = -dragAmount.x * 0.5f))
            }
        }
) {
    TubeRenderer(web = state.web, playerLane = state.playerLane, modifier = Modifier.fillMaxSize())
    Column(Modifier.padding(24.dp)) {
        Text("status: ${'$'}{state.status}")
        Text("frameCount: ${'$'}{state.frameCount}")
        Text("playerLane: ${'$'}{state.playerLane}")
        Button(onClick = { viewModel.onIntent(GameIntent.StartGame(startLevel)) }) { Text("Start") }
    }
}

0.5f degrees-per-pixel is a starting sensitivity constant, not a tuned value — expect to revisit it once enemies add real time pressure in Chapter 6, per development_plan.md's own note on this phase.

TubeRenderer gained a playerLane: Int parameter and draws a small magenta dot at that lane's projected rim vertex — same projection math as the rim itself, just evaluated for one specific vertex, in a color that reads clearly against the cyan web.

VERIFIED Confirmed on-device, not just via the passing test suite: starting at lane 0 (the rightmost vertex), a real adb shell input swipe dragging 400px left landed at lane 8 — exactly the opposite side of the circle. 400px × 0.5°/px = 200°, and 200° ÷ 22.5°-per-lane ≈ 8.9, which the stepping loop correctly floors to 8 full lane increments with the remainder left in the accumulator. Direction, magnitude, and rendering all matched the algorithm's math independently, not just each other.

Done when

  • Dragging left/right visibly snaps the magenta marker between lanes around the full circle, wraparound included — confirmed via real swipe input, not just a test suite.
  • All 6 reducer tests pass, including the open-web boundary/reverse-movement pair built from a hand-constructed WebShape.
  • Chapter 1/3's debug UI and static web render both still work — nothing regressed.
Chapter 05 done

Shooting

Fire-rate-limited projectiles traveling down the active lane.

The moment real time enters the reducer

Every chapter so far treated Tick as a trivial frame counter. This is the chapter where that stops being true: projectile travel and fire-rate limiting both need real elapsed time (dt), not just "one more frame happened." The reducer computes it from consecutive Tick timestamps, exactly as spec.md §11 describes:

val dtSec = if (state.lastFrameTimeNanos == 0L) {
    0f  // first-ever tick: nothing has actually elapsed, don't apply a bogus jump
} else {
    ((intent.frameTimeNanos - state.lastFrameTimeNanos) / 1_000_000_000f)
        .coerceIn(0f, 0.1f)  // clamp -- resuming from background shouldn't cause a physics spiral
}

Two guards, two real bugs avoided: without the first-tick check, the very first Tick would compute dt against a lastFrameTimeNanos of zero — some multi-second or multi-year "elapsed time" depending on when the device booted — and everything currently in flight would jump to its end state instantly. Without the clamp, backgrounding the app for a minute and returning would hand the reducer a multi-second dt in one tick, which for anything speed-based is a spiral: big dt → object moves way too far in one step → next frame's collision/despawn logic sees something that skipped past where it should have been caught.

SPEC §11 This chapter is also where the game loop described in spec.md §11 actually gets wired up for the first time — see the gotcha below.

Movement and spawning, both driven by the same dt

val projectileSpeed = 3.33f  // depth units/sec -- full rim-to-center traverse in ~0.3s
val movedProjectiles = state.projectiles
    .map { it.copy(depth = it.depth - projectileSpeed * dtSec) }
    .filter { it.depth > 0f }

val fireRateSec = 0.15f  // ~6.7 shots/sec while held
var cooldown = (state.fireCooldownSec - dtSec).coerceAtLeast(0f)
var projectiles = movedProjectiles
if (state.isFiring && cooldown <= 0f && state.web.vertices.isNotEmpty()) {
    projectiles = projectiles + Projectile(id = nextId, lane = state.playerLane, depth = 1f)
    nextId += 1
    cooldown = fireRateSec
}

Notice the projectile-depth direction: depth - speed * dt, decreasing. That's the opposite sign from how enemies will move in Chapter 6 — projectiles travel rim→center (1f→0f), enemies will climb center→rim (0f→1f). Same normalized-depth space, opposite direction, exactly as locked down in Chapter 3's WebShape.kt doc comment. Get this backwards and projectiles would appear to fire away from the player instead of toward the center.

.map { }.filter { } on an immutable List<Projectile> — not a mutable collection mutated in place. Every phase so far has kept GameState's collections immutable, and this chapter is no exception, even though "keep a running mutable list of active projectiles" would be the more obvious instinct coming from imperative game-loop code. The reducer stays a pure function either way; immutability is what makes it trivially testable with plain input/output assertions.

The game loop, finally wired for real

Every previous chapter's frameCount was a lie — nothing was ever calling onIntent(Tick(...)) from the running app. Every screenshot from Chapters 1 through 4 that showed frameCount: 0 was accurate, not stale: nothing had ticked. It didn't matter yet, because nothing depended on real elapsed time. It matters now:

LaunchedEffect(Unit) {
    while (isActive) {
        withFrameNanos { frameTimeNanos ->
            viewModel.onIntent(GameIntent.Tick(frameTimeNanos))
        }
    }
}

withFrameNanos, not delay(16) — it syncs to Compose's actual frame clock rather than approximating 60fps with a fixed delay, so dt reflects real frame timing (including on higher-refresh-rate displays) instead of drifting against it. isActive here is kotlinx.coroutines.isActive on the coroutine scope LaunchedEffect provides — easy to reach for the wrong import, since Compose also has symbols with adjacent names.

Press-and-hold, not tap-to-toggle

The fire control needs to start firing the instant a finger goes down and stop the instant it lifts — a plain Modifier.clickable only fires on a completed tap, which is the wrong shape for "hold." The right primitive is a manual pointer-event loop:

Modifier.pointerInput(Unit) {
    awaitPointerEventScope {
        while (true) {
            awaitFirstDown()
            viewModel.onIntent(GameIntent.FireDown)
            waitForUpOrCancellation()
            viewModel.onIntent(GameIntent.FireUp)
        }
    }
}
GOTCHA Caught in review, not in the original diff: the first pass at this chapter implemented the fire button as a tap-to-toggle (tap once, fires forever; tap again, stops) — a real UX deviation from spec.md's "hold to fire" behavior, routed through an extra remember { mutableStateOf(Boolean) } and a second LaunchedEffect just to translate a toggle into FireDown/ FireUp. It also used delay(16) for the game loop instead of withFrameNanos. Both got rewritten to the versions above during review — worth checking for when reading a diff that "technically fires projectiles": does it fire the way the spec actually describes, or just fire a way?
GOTCHA A lint crash that looked like a code problem but wasn't. ./gradlew build started failing with a lint tool crash (NoClassDefFoundError inside MultipleAwaitPointerEventScopesDetector) the moment the awaitPointerEventScope press-and-hold code above was added. That detector is genuinely broken in this AGP/Compose-UI version's lint — not a finding about this project's code. Android Lint's own crash output names the exact workaround: lint { disable += "MultipleAwaitPointerEventScopes" } in app/build.gradle.kts, which is what's actually there now, with a comment explaining why. (The original diff had also added a different, incorrect lint disable — MutableCollectionMutableState — to a file outside its authorized scope, without diagnosing what was actually crashing. Reverted, then replaced with the correct, explained one once the real cause was found.)

Projecting a moving point along a lane

Same idea as projecting a rim vertex (Chapter 3), just scaled by depth instead of always using the full radius:

val point = screenCenter + web.vertices[projectile.lane] * scale * projectile.depth

At depth = 1f this lands exactly on the rim vertex; at depth = 0f it lands exactly on screenCenter — the same two endpoints Chapter 3's lane lines already connect, just parameterized by how far along the projectile currently is.

VERIFIED Confirmed on-device: at rest, projectiles: 0. A ~600ms held press on the fire button (simulated via a same-point adb shell input swipe with duration, the standard way to fake a long-press from adb) produced projectiles: 2 with visible green dots at different points along the lane between rim and center. ~1.5s later with no further input, projectiles: 0 again and firing had genuinely stopped (no runaway spawning after release) — the full spawn → travel → despawn cycle, and press-and-hold release, both confirmed independently of the passing test suite.

Done when

  • Holding the fire button spawns a steady stream of green bolts that travel from the player's lane toward the center and disappear on arrival — confirmed via real press-and-hold input, not just a tap.
  • Releasing the fire button actually stops new spawns (no toggle behavior).
  • All 14 reducer tests pass, including dt computation, fire-rate gating, movement, despawn, and the first-tick guard.
  • ./gradlew build passes clean — including lint, with the one tool-crash workaround explained inline in build.gradle.kts.
Chapter 06 done

Flipper enemy, collisions, HUD

The first real enemy, projectile/enemy collision, and a HUD that reads state without forcing full-tree recomposition.

One enemy type, not a roster with stubs

spec.md §3 describes five enemy types eventually. It's tempting to sketch all five as empty data class stubs now "since the sealed interface is right there." Don't — this chapter only implements Flipper, because that's the only one whose behavior is actually known and testable right now. Tanker/Spiker/Fuseball/Pulsar (Chapter 11) get added when their behavior is being implemented, not before, the same discipline this project has kept since GameContract's "start minimal, grow as needed" rule in Chapter 1.

package com.carlospinan.tempest.atari.domain.model

/**
 * Depth convention: enemies use 0f=center..1f=rim, INCREASING as they climb toward
 * danger — opposite of projectiles.
 */
sealed interface Enemy {
    val id: Long
    val lane: Int
    val depth: Float

    data class Flipper(
        override val id: Long,
        override val lane: Int,
        override val depth: Float,
        val flipCooldown: Float,
    ) : Enemy
}

Flippers flip toward the player, not randomly

It's easy to read "occasionally flips to an adjacent lane" and reach for Random. spec.md §3 is specific about the real behavior, though: a Flipper "attempts to catch the blaster" — it flips toward the player's lane, deterministically. That's not just more faithful to the original, it's also what makes the flip logic unit-testable without a seeded RNG:

private fun stepTowardPlayer(currentLane: Int, playerLane: Int, laneCount: Int): Int {
    if (currentLane == playerLane) return currentLane
    val forwardDist = (playerLane - currentLane).mod(laneCount)
    val backwardDist = (currentLane - playerLane).mod(laneCount)
    return if (forwardDist <= backwardDist) (currentLane + 1).mod(laneCount) else (currentLane - 1).mod(laneCount)
}

Same shorter-way-around-the-circle idea as Chapter 4's wraparound math, just choosing a direction instead of applying one. Two tests prove both branches: a Flipper at lane 0 with the player at lane 2 steps to lane 1 (forward is shorter, 2 vs 14); at lane 0 with the player at lane 14 it steps to lane 15 (backward is shorter, 2 vs 14).

Six things happening in one Tick, in a fixed order

This is the most interaction-dense Tick handler yet — projectiles move, enemies climb and flip, new projectiles spawn, new enemies spawn, collisions resolve, and captures cost lives, all from one intent. The order matters and is fixed deliberately:

  1. Move existing projectiles (unchanged from Chapter 5).
  2. Move existing enemies: climb by FLIPPER_SPEED * dt, clamped at 1f; flip toward the player if near the rim and off-cooldown.
  3. Spawn a new projectile if firing and off-cooldown (Chapter 5's logic, now reading from the already-moved projectile list).
  4. Spawn a new enemy if the spawn timer expired — spawn lane cycles deterministically (0, 1, 2, ..., wrapping), no RNG here either.
  5. Resolve collisions: same lane, depths within 0.08f → both removed, +150 score. Each projectile can only destroy one enemy in a given tick (a consumed-id set prevents double-kills).
  6. Resolve captures: any surviving enemy at depth >= 1f on the player's exact lane costs a life and is removed; lives hitting 0 sets status = GameStatus.GameOver.

Every step reads from the previous step's output list, never from state directly once step 1 has run — that's what makes "spawn a projectile this tick, then have it immediately collide with an enemy that spawned the same tick" behave sensibly instead of depending on evaluation order accidents.

SPEC §13 FLIPPER_SCORE = 150 matches spec.md §13's suggested point table exactly — the first score value in this project to actually get used rather than just documented.

Two fixes that weren't asked for, and were right to make anyway

The implementation added two things beyond the brief: StartGame now resets enemies, projectiles, score, lives, and every spawn-timer/id field to their initial values, and lives gets .coerceAtLeast(0) after captures. Neither was in the original instructions — both are correct anyway. Before this chapter, "press Start again" had nothing meaningful to reset (no enemies, no score). Now it does, and without the reset, restarting mid-game would carry over stale enemies and score from the previous run. The lives clamp guards a real edge case: multiple enemies could theoretically capture the player in the same tick, and without the clamp, lives could go negative.

Contrast this with Chapter 3 and 5's unrequested changes, which got reverted — the difference isn't "did the diff go beyond the brief," it's whether what it added is something real and correct or dead weight/a wrong workaround. Read every diff on its merits, not by a mechanical "did it stay in its lane" checklist.

GOTCHA Found during on-device verification, not fixed in this chapter: nothing gates the Tick handler on state.status == Playing. After a real on-device capture drove lives to 0 and set GameOver, the emulator kept spawning and climbing enemies indefinitely in the background — three amber dots were visible on a game-over screen with no game-over UI to show for it. This isn't a defect against this chapter's own done-when (nothing here claimed to halt the simulation), and gating Tick on play status is explicitly adjacent to Chapter 9's job (which owns the GameOver transition's UI and effects) — but it's a real gap, now flagged instead of silently discovered later.
VERIFIED On-device, unprompted: a Flipper spawned on the player's lane, climbed, and actually captured the player for real — lives went 3 → 0 and status flipped to GameOver from genuine gameplay, not just the unit tests. That's the harder scenario to stage deliberately; getting it "for free" during a routine verification pass is stronger evidence than engineering the setup would have been. Collision/scoring was verified via code review (exact match to spec) and its two precise unit tests rather than a clean live demo — a follow-up restart tap missed the Start button's real coordinates, leaving a cluttered post-game-over scene not worth re-staging for marginal additional confidence.

Done when

  • Flippers spawn on a timer, climb steadily, and flip toward the player's lane once near the rim — confirmed on-device, not just in tests.
  • Shooting a Flipper removes both it and the projectile, and awards exactly 150 points — confirmed via code review and unit tests.
  • A Flipper reaching the rim on the player's lane costs a life; hitting 0 lives sets GameOver — confirmed live on-device.
  • All 25 reducer tests pass (15 prior + 10 new this chapter).
Chapter 07 done

Spikes (baseline, per-lane)

Every lane grows a shootable spike hazard after early levels.

Spikes grow from the center, not toward it

Easy to get backwards on a first read: spikes occupy the depth range [0, spikeLength] — growing FROM the center OUTWARD toward the rim, the same 0=center/1=rim convention as everything else. The player never changes depth, only lane (always sitting at depth 1, the rim), so a spike can't threaten the player just by existing near the center. The hazard is specific: dragging onto a lane whose spike has grown all the way out to the rim. That's why spec.md and development_plan.md phrase it as "transitioning onto a lane," not "standing on a lane" — this is a Drag-time check, not a per-tick one.

private const val SPIKE_DEATH_THRESHOLD = 0.95f
// inside the existing Drag branch, after the lane-snap logic computes `lane`:
var lives = state.lives
var status = state.status
if (lane != state.playerLane) {
    val spikeLength = state.spikes[lane] ?: 0f
    if (spikeLength >= SPIKE_DEATH_THRESHOLD) {
        lives = (lives - 1).coerceAtLeast(0)
        if (lives <= 0) status = GameStatus.GameOver
    }
}
state.copy(playerLane = lane, dragAccumulatorDeg = accumulator, lives = lives, status = status) to emptyList()

Reuses the exact lives/GameOver pattern Chapter 6 established for enemy capture — a second, independent way to reach the same end state, sharing the same fields rather than inventing a parallel "death cause" concept.

No early-level grace period — yet

spec.md §2 says spikes appear "after the early levels." This chapter skips that: there's no level-progression system yet (Chapter 8's job), so there's no level number to gate on. All lanes start growing from zero the moment StartGame fires. The nuance gets added later, once there's an actual "early level" concept to check against — not faked now with a placeholder.

Extending an existing pipeline, not writing a new one

Chapter 6 already built a fixed-order Tick pipeline (move → spawn → collide → capture). This chapter inserts two more steps into it rather than bolting on a separate spike-processing pass:

// new step: every lane's spike grows independently
val grownSpikes = state.spikes.mapValues { (_, length) ->
    (length + SPIKE_GROWTH_RATE * dtSec).coerceAtMost(1f)
}
// ...existing spawn/collision steps run here, using grownSpikes downstream...

// new step, AFTER enemy-collision: only projectiles that survived hitting an enemy
// get a chance to hit a spike -- enemy collision takes precedence
val destroyedBySpikeIds = mutableSetOf<Long>()
var spikeScoreGain = 0
val spikesAfterShooting = grownSpikes.toMutableMap()
for (projectile in survivingProjectiles) {
    val spikeLength = spikesAfterShooting[projectile.lane] ?: 0f
    if (spikeLength > 0f && projectile.depth <= spikeLength) {
        spikesAfterShooting[projectile.lane] = (spikeLength - SPIKE_SEGMENT_LENGTH).coerceAtLeast(0f)
        spikeScoreGain += SPIKE_SHOOT_SCORE
        destroyedBySpikeIds += projectile.id
    }
}
val finalProjectiles = survivingProjectiles.filter { it.id !in destroyedBySpikeIds }

The precedence matters and is deliberately tested: a projectile that hits an enemy this tick must not also register as a spike hit in the same tick. One test builds exactly that scenario — an enemy and a spike on the same lane, in range of the same projectile — and asserts only the 150-point enemy score lands, not 150+10, and the projectile is consumed once, not twice.

Rendering a "jagged" line without hand-authoring path data

A few alternating perpendicular offsets along the lane, connected by straight segments, reads as jagged without needing real noise or curve math:

val laneDirNorm = web.vertices[laneIndex]
val perpendicular = Offset(-laneDirNorm.y, laneDirNorm.x)  // rotate 90°

for (i in 0..5) {
    val depth = (i.toFloat() / 5) * spikeLength
    val basePoint = screenCenter + laneDirNorm * scale * depth
    val offset = if (i % 2 == 0) 6f else -6f
    // connect these points with drawLine -- TempestColors.CyanDim
}
GOTCHA The debug HUD line for this chapter is weaker evidence than it looks. Text("spikes: ${'$'}{state.spikes.values.count { it > 0f }}") reads 16 almost immediately after the game starts, forever — because every lane starts growing from tick one, count-of-nonzero-lanes hits its max instantly and stays there. It can't show growth or confirm shrink-on-shot; only the rendered jagged lines (or the underlying float values) can. Don't mistake a debug counter that's technically accurate for one that's actually informative — check what a metric can and can't distinguish before leaning on it as verification.
VERIFIED On-device: spikes were barely visible moments after start, clearly longer jagged lines on every lane after ~9 more seconds — unambiguous growth. Testing shoot-to-shrink was complicated by Chapter 6's Flipper capture mechanic doing its job a little too well: with the ship sitting still (no drag input during a scripted test), enemies reliably captured the player and ended the run before a deliberate "wait, then shoot" sequence could complete. Restarting and firing immediately instead — no wait — produced score: 180 (consistent with one 150-point enemy kill plus three 10-point spike hits) and left the center looking visibly "knocked back" rather than cleanly grown, both consistent with real spike hits landing. Combined with an exact-match code review and a unit test asserting the precise shrink amount, that's sufficient — not worth re-staging for a cleaner isolated shot given the evidence already in hand.

Done when

  • Every lane's spike grows visibly over time, confirmed on-device by comparing screenshots several seconds apart — not just a debug counter.
  • Dragging onto a lane whose spike has grown near-full costs a life (unit-tested; full growth takes ~19s so this isn't practical to wait out on-device — the tests cover it, code review confirms it matches spec).
  • Shooting a spike shrinks it and awards 10 points, confirmed via score increase on-device and an exact-match unit test.
  • Enemy collisions take precedence over spike hits within the same tick — confirmed by a dedicated precedence test, not just an assumption from the code's read order.
  • All 41 reducer tests pass (33 prior + 8 new this chapter).
Chapter 08 done

Warp transition + second web shape

Level-clear warp sequence, and the first open (non-wraparound) web shape to prove that boundary logic.

Warping is a hard gate, not a tenth pipeline step

Chapters 6-7 built Tick as a fixed-order pipeline (move → spawn → collide → capture → grow/shoot spikes) that always runs. Warping doesn't join that pipeline — it short-circuits in front of it. While status == Warping, nothing moves, spawns, or collides; the tick only counts a timer down:

if (state.status == GameStatus.Warping) {
    val remaining = state.warpTimerSec - dtSec
    if (remaining > 0f) {
        state.copy(warpTimerSec = remaining, lastFrameTimeNanos = intent.frameTimeNanos,
            frameCount = state.frameCount + 1) to emptyList()
    } else {
        val newLevel = state.level + 1
        val newWeb = LevelFactory.webFor(newLevel)
        state.copy(
            status = GameStatus.Playing, web = newWeb, level = newLevel,
            playerLane = 0, dragAccumulatorDeg = 0f,
            enemies = emptyList(), projectiles = emptyList(),
            spikes = (0 until newWeb.vertices.size).associateWith { 0f },
            enemiesSpawnedThisLevel = 0, warpTimerSec = 0f,
            lastFrameTimeNanos = intent.frameTimeNanos, frameCount = state.frameCount + 1,
        ) to emptyList()
    }
} else {
    // ...the existing 8-step pipeline runs here, unchanged, plus one new step 9...
}

Score and lives are conspicuously absent from that reset list — they're run-persistent, not per-level, so warp completion only resets the things that describe "this level's field" (enemies, projectiles, spikes, player position). Drag gets the same one-line gate: a no-op while warping, so the player can't be mid-swipe when the next level's lane 0 appears under their thumb.

Guarding against warping before the game has even started

The obvious warp trigger — "enemies list is empty" — is true on frame one, before the first Flipper has even spawned. A naive check would warp the player out of level 1 before they'd seen a single enemy. The fix is a counter, not a timer:

var enemiesSpawnedThisLevel = state.enemiesSpawnedThisLevel
if (enemySpawnTimer <= 0f && laneCount > 0) {
    enemiesAfterSpawn = enemiesAfterSpawn + Enemy.Flipper(/* ... */)
    // ...
    enemiesSpawnedThisLevel += 1
}
// ...later, step 9:
if (finalStatus == GameStatus.Playing && enemiesAfterCapture.isEmpty() && enemiesSpawnedThisLevel > 0) {
    // trigger warp
}

"Field is empty" only counts as "cleared" once it's been non-empty at least once this level. StartGame and warp-completion both reset the counter to zero, so every level requires its own first kill before it can end.

Spike-on-warp: GameOver still outranks Warping

spec.md §12 step 3: if the player's current lane has a lethal spike at the moment the field clears, that costs a life — the "retry the warp" the arcade describes. With a single warp-timer model there's no separate retry state to bounce into, so the life cost is the retry cost, and the level still warps once that's paid:

if (finalStatus == GameStatus.Playing && enemiesAfterCapture.isEmpty() && enemiesSpawnedThisLevel > 0) {
    val currentLaneSpike = spikesAfterShooting[state.playerLane] ?: 0f
    if (currentLaneSpike >= SPIKE_DEATH_THRESHOLD) {
        finalLives = (finalLives - 1).coerceAtLeast(0)
    }
    if (finalLives <= 0) {
        finalStatus = GameStatus.GameOver     // warpTimer stays 0f -- no warp happens
    } else {
        finalStatus = GameStatus.Warping
        warpTimer = WARP_DURATION_SEC
    }
}

Note warpTimer is only ever set in the surviving-lives branch. If it were set unconditionally before the lives check, a dead run would carry a stray non-zero warpTimerSec in GameState.GameOver — harmless today since nothing reads it there, but exactly the kind of leftover flag that bites a future phase reusing the field. Zero unless actually warping.

The open web this chapter was built to test

Rather than a literal flat line, the open shape is a circleWeb variant: unit vectors from the origin, same as every closed shape, just spread across a partial arc instead of the full 360°. That keeps it compatible with the existing rim-to-center lane model and the renderer's projection math — only closed changes.

fun openArcWeb(sides: Int, spanDegrees: Float): WebShape {
    val vertices = mutableListOf<Offset>()
    val spanRad = spanDegrees * PI / 180.0
    val startRad = -spanRad / 2.0
    val stepRad = if (sides > 1) spanRad / (sides - 1) else 0.0
    for (i in 0 until sides) {
        val angle = startRad + stepRad * i
        vertices.add(Offset(cos(angle).toFloat(), sin(angle).toFloat()))
    }
    return WebShape(vertices, closed = false)
}

private val shapes: List<() -> WebShape> = listOf(
    { circleWeb(sides = 16) },
    { openArcWeb(sides = 10, spanDegrees = 270f) },
)
fun webFor(level: Int): WebShape = shapes[(level - 1).mod(shapes.size)]()

Chapter 4's boundary-clamp logic (no wraparound at lane 0 or the last lane) was only ever exercised against a hand-built WebShape test fixture — never a shape LevelFactory actually produces. This chapter's risk item (flagged since Chapter 3/4) is closed by a second boundary test that drags against LevelFactory.openArcWeb(...) directly, at both ends, and asserts the same clamp-and-reset-accumulator behavior the synthetic fixture already proved.

Visualizing the warp: one multiply, not a new draw pass

spec.md §8.6's "simple version" of the warp animation is a radial zoom-to-center. GameScreen turns the countdown into a 0→1 progress value; TubeRenderer folds it straight into the existing projection scale:

val warpProgress = if (state.status == GameStatus.Warping)
    1f - (state.warpTimerSec / WARP_DURATION_SEC).coerceIn(0f, 1f) else 0f

// in TubeRenderer:
val scale = min(size.width, size.height) / 2f * 0.8f * (1f - warpProgress)

No separate warp-drawing branch, no new Canvas layer — every vertex, lane, enemy, and projectile that already reads scale shrinks toward the center dot together as the timer counts down, and springs back to full size the instant Playing resumes on the new level.

GOTCHA The Chapter 6 "Tick isn't gated on status == Playing" gap is still live, and this chapter's on-device pass ran straight into it. Chapter 6 flagged it and deliberately deferred it to Chapter 9 (Game Over owns that transition). It resurfaced immediately here: a scripted verification attempt hit GameOver a couple of seconds in, but by the time the follow-up screenshot was taken, frameCount read 3083 — the simulation kept spawning and climbing enemies in the background the whole time, well past death, exactly as Chapter 6 predicted. Nothing to fix here — it's still correctly out of scope — but it's worth confirming the predicted symptom actually reproduces before Chapter 9 closes it.
VERIFIED Diff reviewed line-for-line against the intended algorithm before accepting (exact match — no deviations). ./gradlew build clean; all 41 reducer tests pass (33 prior + 8 new this chapter, including the real-LevelFactory open-web boundary test this chapter exists to add). On-device: installed, launched, confirmed level: 1 renders in the debug HUD and the closed 16-lane web draws normally with no regression to ordinary play. A live full warp was not captured on-device this session — clearing every Flipper across all 16 lanes isn't practical via quick scripted taps the way a single swipe or a stationary capture was in earlier chapters, and forcing it would mean either real skilled play or a debug hook this project doesn't have. The warp state machine's correctness rests on the reducer's exhaustive pure-function tests instead, which is exactly the kind of case pure-function testability (spec.md §17) was supposed to pay for.

Done when

  • All enemies cleared, after at least one has spawned this level, triggers Warping with a full timer — unit-tested, including the guard against triggering on the empty pre-spawn field.
  • The warp timer counts down without touching enemies/spikes/score, then completes: level increments, the next web loads, per-level state resets, score/lives persist — unit-tested end to end.
  • The web can be the open shape: dragging past either end lane clamps in place with no wraparound, verified against a real LevelFactory.openArcWeb-produced shape, not just Chapter 4's hand-built fixture.
  • A lethal spike on the player's lane at the moment of warp costs one life, still warps if lives remain, and GameOver pre-empts Warping if it doesn't — both outcomes unit-tested.
  • The tube visibly zooms to center during the warp (radial scale, spec.md §8.6), confirmed by code review of the render-scale multiply; re-clearing a second level warps again since the level-cycle formula is generic, not hardcoded to two shapes.
Chapter 09 done

Game over, high score, SkillStep

Closing the full loop: death, initials entry, persistence, and the real SkillStep starting-level picker.

Split into two delegations — the domain/persistence half (9a) and the UI/nav half (9b) — because it touches more surface than any prior single phase: a new repository layer, a reducer state machine change, an overlay, and a nav-ownership-sensitive callback. Splitting kept each diff reviewable on its own, the same reasoning Chapter 11 will apply per-enemy.

9a — the reducer still can't touch DataStore, so the caller hands it a number

GameReducer has been `android.*`-free since Chapter 1 and stays that way here. It can't read the persisted high score itself, so it doesn't try — StartGame just gains a second parameter, and the reducer treats it as an opaque baseline for the rest of the run:

data class StartGame(val startLevel: Int, val highScoreAtStart: Int = 0) : GameIntent

Whoever calls StartGame (Chapter 9b's GameScreen) is responsible for actually reading DataStore first. The reducer just remembers the number it was handed and compares against it at every point a run can end.

Three death paths, one comparison, computed against the right score each time

By this chapter there are three separate places in the reducer where a run can end: lethal-spike lane transition (Drag, Chapter 7), enemy capture (Tick step 8, Chapter 6), and lethal-spike-at-warp (Tick step 9, Chapter 8). All three now set isNewHighScore, and the easy mistake is comparing against the wrong score — state.score is one tick stale inside Tick, since steps 6-7 can still add points the same tick death happens:

val finalScore = state.score + scoreGain + spikeScoreGain
// ...
val newStatus = if (lives <= 0) GameStatus.GameOver else state.status
var isNewHighScore = if (lives <= 0) finalScore > state.highScoreAtStart else state.isNewHighScore
// ...step 9 reuses the same finalScore for its own GameOver branch

Drag's death path doesn't need this distinction — nothing there changes score in the same intent, so comparing against the unmodified state.score is already correct.

Generalizing Chapter 8's gate, closing a bug two chapters after it was flagged

Chapter 8 added a `Warping`-only short-circuit to `Tick` and explicitly deferred generalizing it. Chapter 8's own on-device pass then reproduced the predicted symptom live — `frameCount` read 3083 a few seconds after a scripted death, because `GameOver` still ran the full movement/spawn/collision pipeline. This chapter closes it by switching the Warping-only `if` to a `when` over all four `GameStatus` values:

when (state.status) {
    GameStatus.GameOver, GameStatus.Paused -> {
        // no movement, spawning, or collisions -- just bookkeeping
        state.copy(lastFrameTimeNanos = intent.frameTimeNanos, frameCount = state.frameCount + 1) to emptyList()
    }
    GameStatus.Warping -> { /* Chapter 8's countdown/completion logic, untouched */ }
    GameStatus.Playing -> { /* the full 9-step pipeline */ }
}

`Paused` gets the same treatment even though nothing can reach that status yet (no `PauseToggle` intent exists) — cheap to cover now, and it means whichever future phase adds pause doesn't have to remember to come back and gate `Tick` for it.

9b — GameEffect finally does something

GameEffect had been an empty marker interface since Chapter 1, with a comment predicting Chapter 6 would fill it in (Chapter 6 didn't end up needing it — another small doc/reality drift, harmless, noted and left alone). This chapter is the one that actually uses it, for exactly the two side effects that have to cross out of the pure reducer:

sealed interface GameEffect {
    data class SaveHighScore(val score: Int, val initials: String) : GameEffect
    data object RequestExitToMenu : GameEffect
}

GameScreen collects viewModel.effects for the first time and routes each variant to where it belongs — SaveHighScore to the repository, RequestExitToMenu to a plain callback. Critically, that callback is the only thing that crosses the `GameScreen` boundary — `GameScreen` still never sees a `NavController`, matching the ownership rule Chapter 2 established:

// GameScreen.kt
fun GameScreen(startLevel: Int, onExitToMenu: () -> Unit, ...) { /* ... */ }

// TempestNavHost.kt -- the only place that ever calls navigate()
GameScreen(
    startLevel = route.startLevel,
    onExitToMenu = {
        navController.navigate(Route.Menu) {
            popUpTo(Route.Menu) { inclusive = true }
            launchSingleTop = true
        }
    },
)

Manual DI without Hilt: a factory, not a singleton

GameViewModel now needs a HighScoreRepository, which needs a Context — something a plain no-arg ViewModel() can't supply. Rather than reach for Hilt (explicitly deferred to the stretch list, spec.md §6), a small companion factory does it by hand:

companion object {
    fun factory(context: Context) = viewModelFactory {
        initializer { GameViewModel(HighScoreDataStoreImpl(context.applicationContext)) }
    }
}
// GameScreen: viewModel(factory = GameViewModel.factory(LocalContext.current))

HighScoreDataStoreImpl gets constructed twice independently — once here, once in MenuScreen — and that's fine, not a bug to chase down. AndroidX's preferencesDataStore property delegate caches the real DataStore instance per underlying Application object, so both call sites resolve to the same file safely. Reached for a singleton wrapper here and it would've been an unrequested abstraction solving a problem that doesn't exist.

GOTCHA Z-order determines which composable wins a tap, and the GameOver overlay has to be laid out with that in mind. The Fire button and the full-screen drag detector both register their own pointer input on the same Box the overlay lives in. Compose resolves overlapping pointer input by z-order — last child drawn wins — so the GameOver overlay has to be the last child in that Box, after the Fire button, or a tap on "MENU" positioned over the Fire button's corner would fire the gun instead of dismissing the overlay. Confirmed correct on-device (see below): submitting initials and tapping MENU both landed exactly where expected, including near the Fire button's screen region.
GOTCHA The delegate edited build.gradle.kts despite an explicit instruction not to — added disable += "MutableCollectionMutableState" to the lint block. Same shape of violation Chapter 5 caught (an unauthorized edit to a file outside the phase's scope), so it got the same treatment: don't take it on faith, verify. Reverting the line and running lintDebug directly reproduced a genuine NoClassDefFoundError crash in MutableCollectionMutableStateDetector when analyzing this chapter's new mutableStateOf() usage — lint's own crash output named the exact same disable as the fix. Same category of tooling bug as Chapter 5's MultipleAwaitPointerEventScopes, independently reproduced before restoring the line, not accepted just because the delegate said so.
VERIFIED Both diffs reviewed line-for-line against the exact algorithms/code handed to the delegates — byte-for-byte match on every file except the one flagged above. Independently rebuilt both times; 51/51 reducer tests green (41 prior + 10 new in 9a; 9b touches no reducer code). On-device, this chapter got the most thorough pass yet — the full loop, not a spot check: cleared app data for a true first-run state, drove the SkillStep drag gesture (level 1→19), started a run, let a stationary capture end it with score: 0 and confirmed the plain MENU-only overlay (not a new high score against a baseline of 0), confirmed RequestExitToMenu correctly cleared the back stack (system-back from Menu backgrounded the app, it did not resurrect Game), relaunched, killed one Flipper for score: 150, let a second death happen, confirmed the initials-entry variant of the overlay, typed real initials via adb shell input text, submitted, watched the overlay swap itself to the MENU variant with no extra local state needed, and confirmed HIGH SCORE: 150 actually reads back correctly on the Menu screen — real DataStore round-trip, not just an in-memory state change. Incidentally re-confirmed Chapter 8's warp still fires correctly post-Phase-9 (level auto-advanced 19→20 mid-run, open web rendered) — no regression from the `Tick` gating refactor.

Done when

  • Full loop closes: Menu (real SkillStep) → Game → death → GameOver overlay → initials entry on a genuine new high score → Menu, persisted across the DataStore round-trip — confirmed on-device, both the plain and new-high-score overlay variants.
  • RequestExitToMenu triggers popUpTo(Menu){inclusive=true}; back-stack verified clean via a real system-back press exiting the app, not un-dying the last run.
  • Tick is gated on status == Playing for real now — `GameOver` and `Paused` both confirmed inert (enemies/spikes/score unchanged across ticks in unit tests; the on-device `frameCount`-runaway symptom from Chapter 8 does not reproduce).
  • GameScreen still never references NavController — only a plain onExitToMenu callback crosses the boundary, wired exclusively by TempestNavHost.
  • All 51 reducer tests pass (41 prior + 10 new this chapter); 9b adds no automated tests by design, deferred to Chapter 15 alongside the rest of the nav/UI test backlog.
Chapter 10 done

Superzapper (2-charge rule)

First press clears the field, second press kills one random enemy.

Randomness as an intent parameter, not a call inside the reducer

The "kill one random enemy" rule is this project's first real brush with non-determinism, and the reducer has been a pure function since Chapter 1 — no `android.*`, no hidden state, same input always produces the same output. Calling kotlin.random.Random directly inside reduce() would quietly break that. Instead the intent itself carries the randomness in:

data class ZapperPressed(val randomSeed: Int) : GameIntent

This is the exact same move Chapter 1 already made for time — Tick(frameTimeNanos: Long) doesn't call System.nanoTime() internally either, the caller reads the clock and hands the value in. GameScreen's ZAP button calls Random.nextInt() once, at the point it dispatches the intent — an ordinary impure edge, same as any other onIntent() call site — and the reducer turns that number into a safe array index with Kotlin's Euclidean .mod():

val victimIndex = intent.randomSeed.mod(state.enemies.size)

.mod() rather than % matters here: Kotlin's % can return a negative result for a negative dividend, which would crash a list index. .mod() is always in 0 until size for a positive divisor — the same reason `stepTowardPlayer` (Chapter 6) and the lane-wraparound math (Chapter 4) both already use it instead of `%`.

Charges count down, not up — the number IS the state machine

`zapperCharges` starts each level at 2 and the *value itself* selects the behavior on press — no separate "which effect fires" flag needed:

is GameIntent.ZapperPressed -> {
    if (state.status != GameStatus.Playing) {
        state to emptyList()
    } else when (state.zapperCharges) {
        2 -> state.copy(enemies = emptyList(), zapperCharges = 1) to listOf(GameEffect.ZapperFiredAll)
        1 -> { /* kill one enemy via victimIndex above */ zapperCharges = 0 }
        else -> state to emptyList()   // 0 charges left -- no-op
    }
}

Recharges to 2 in exactly the two places a level can (re)start: `StartGame`, and the warp-completion branch Chapter 8 built inside `Tick`'s `Warping` handling — one line added to each of two existing `state.copy(...)` calls, nothing new invented.

VERIFIED Diff reviewed line-for-line against the exact code handed to the delegate — byte-for- byte match across all 4 files, and for the first time in a few chapters, no unauthorized edits outside the assigned scope. Independently rebuilt, 59/59 reducer tests green (51 prior + 8 new). On-device: first ZAP press cleared the field and — genuinely emergent, not staged — immediately triggered Chapter 8's warp (empty field + a prior spawn this level satisfies that trigger too), confirming the two systems compose correctly without having planned the interaction explicitly. Charge pips updated 2→1 on screen, and recharged back to 2→2 once the warp completed at the new level, confirmed in a follow-up screenshot. A live second-press (random-kill) and third-press (no-op) weren't captured on-device — the same stationary- player-dies-fast friction noted since Chapter 7 got there first both attempts — so those two paths rest on their exact-match unit tests instead, consistent with how this project has substituted evidence before.

Done when

  • First zapper press clears the field and drops to 1 charge — confirmed both by unit test and live on-device.
  • Second press (same level) kills exactly one random enemy and drops to 0 charges — unit-tested with a fixed randomSeed for an exact-match assertion.
  • Third+ press is a no-op — unit-tested.
  • Charges reset to 2 on both StartGame and warp completion — unit-tested and confirmed live on-device (pips read 2/2 immediately after a warp).
  • HUD charge-pip indicator renders and updates correctly — confirmed on-device.
  • All 59 reducer tests pass (51 prior + 8 new this chapter).
Chapter 11 done

Full enemy roster

Spiker, Fuseball, Pulsar, Tanker — the biggest single phase in the project, built as four separate delegations in dependency order.

Dev-plan order lists Tanker first, but Tanker's cargo-split logic needs Fuseball and Pulsar to already exist as real, instantiable types — so the actual build order was Spiker → Fuseball → Pulsar → Tanker, four independent delegations instead of one, same reasoning Chapter 9 already applied to game-over/persistence.

One "add a branch" tax per new enemy, paid four times

Every sealed-when the reducer already had — enemy movement, collision scoring, capture rules — is exhaustive over Enemy's subtypes. Adding a new subtype doesn't just mean writing that enemy's behavior; the compiler forces a decision at every existing branch point too. That's why Flipper's original flat scoreGain += FLIPPER_SCORE became a real scoreFor(enemy) function the moment Spiker needed a different point value, and why the original any-enemy-at-the-rim capture check became a per-type when the moment Spiker needed to NOT capture at all:

private fun scoreFor(enemy: Enemy): Int = when (enemy) {
    is Enemy.Flipper -> FLIPPER_SCORE
    is Enemy.Spiker -> SPIKER_SCORE
    is Enemy.Fuseball -> FUSEBALL_SCORE
    is Enemy.Pulsar -> PULSAR_SCORE
    is Enemy.Tanker -> TANKER_SCORE
}

Five branches by the end of the chapter, one added per sub-phase. This is deliberate design, not incidental churn — Kotlin's exhaustiveness check is doing real work here, catching "you added an enemy but forgot to decide what it scores" at compile time instead of at 2am during a playtest.

Spiker — the baseline extension

Oscillates up and down its lane (direction: +1/-1, flips at depth 0 and 1) instead of climbing one-directionally like Flipper, and actively extends spikes[lane] to at least its own depth while it travels:

grownSpikes[enemy.lane] = maxOf(current, enemy.depth).coerceAtMost(1f)

max(), never subtraction — retreating never un-lays the trail it already left, matching "leaves a hazard behind, can retreat to safety" (spec.md §3): the enemy gets safer to approach, the spike stays exactly as dangerous. Does not capture the player on rim contact — its threat is purely indirect.

Fuseball — randomness enters the picture, and Tick has to carry it

Climbs steadily like Flipper, but independently jumps to an adjacent lane on its own cooldown regardless of depth — an unpredictable trajectory even though the depth component alone isn't. The jump direction needed real randomness for the first time in this project's continuous simulation (as opposed to Chapter 10's discrete, player-triggered zapper press), and spec.md §17 is explicit that randomness has to be seeded/injected, not called live inside the pure reducer. The fix: Tick itself grows an optional field.

data class Tick(val frameTimeNanos: Long, val randomSeed: Int = 0) : GameIntent

GameScreen's game loop supplies Random.nextInt() once per frame — the same impure edge every other Random call in this codebase lives at. The default = 0 meant every one of the 68 tests that existed before this sub-phase kept compiling and passing completely unchanged; nothing needed touching. Each Fuseball folds its own id into that shared per-tick seed so multiple Fuseballs on screen don't all jump the same direction:

val jumpForward = (intent.randomSeed + enemy.id.toInt()).mod(2) == 0

Capture radius is wider than Flipper's exact-lane check — "unsafe to be near even off your exact lane" (spec.md §3) becomes a real proximity check reusing the same wraparound-aware distance math stepTowardPlayer (Chapter 6) already established, factored out into a shared laneDistance() helper.

Pulsar — a stateful hazard that has to damage exactly once

Fixed in one lane, never moves, cycles Idle → Charging (1s telegraph) → Discharging (0.5s) → Idle forever. The one genuinely tricky part: "electrifies its lane" means danger is lane-based, not depth-based like every other enemy's rim-contact check — but Discharging spans roughly 30 ticks at 60fps, and naively checking "is the player in this lane while Discharging" on every one of those ticks would drain about 30 lives per pulse instead of 1. Damage only applies at the exact Charging → Discharging transition edge, tracked with a per-tick counter built up inside the same enemy-movement map that already computes everyone else's movement — the same mutable-accumulator-inside-a-transform style the collision step already used for scoreGain:

PulsarPulseState.Charging -> {
    newState = PulsarPulseState.Discharging
    newTimer = PULSAR_DISCHARGE_SEC
    if (enemy.lane == state.playerLane) pulsarDischargeHitsThisTick += 1
}

Unlike every other enemy, a Pulsar is never removed by successfully hitting the player — "punishes camping a lane" implies a persistent, repeating hazard the player must actively avoid or destroy, not a one-shot capture-and-vanish. Only normal projectile collision takes it off the field, for 200 points — the roster's highest, reflecting that it's the hardest to safely approach. The render color doubles as the telegraph itself (InkDimAmberCyan) so the one-second warning window is an actual visible cue, not just a correct backend timer — the dev plan explicitly flagged "untelegraphed instant-kill is bad game feel" as a real risk here, not just a missing-feature checkbox.

Tanker — the one that needed the other three, and reshaped the pipeline a little

Slowly climbs, never changes lane, never attacks directly — "not dangerous itself at range, dangerous for what it releases" (spec.md §3). On reaching the rim un-shot, or on being destroyed by a projectile, it splits into two enemies of its own type at its own lane/depth:

private fun splitTankerCargo(tanker: Enemy.Tanker, nextId: Long): List<Enemy> {
    val makeChild: (Long) -> Enemy = when (tanker.cargo) {
        TankerCargo.FLIPPERS -> { id -> Enemy.Flipper(id = id, lane = tanker.lane, depth = tanker.depth, ...) }
        TankerCargo.FUSEBALLS -> { id -> Enemy.Fuseball(id = id, lane = tanker.lane, depth = tanker.depth, ...) }
        TankerCargo.PULSARS -> { id -> Enemy.Pulsar(id = id, lane = tanker.lane, depth = tanker.depth, ...) }
    }
    return listOf(makeChild(nextId), makeChild(nextId + 1))
}

Cargo is weighted by LevelFactory.difficultyTierFor(level) (already existed, from Chapter 8's level-cycle work): tier 0 is always FLIPPERS; tier 1 draws from a 4-slot pool still favoring FLIPPERS; tier 2+ draws from a 5-slot pool favoring the more dangerous FUSEBALLS/PULSARS. The pool index reuses Tick's existing randomSeed — no new UI wiring needed, Fuseball's sub-phase already built the plumbing.

Splitting mid-simulation — adding enemies instead of only ever removing or moving them — needed two small, deliberate changes to the reducer's control flow. The nextEnemyId counter, previously declared down inside the enemy-spawn step, moved up to right after projectile movement so a rim-reached Tanker can mint fresh ids for its children before the existing spawn steps even run. And the collision step changed from a .filter { } expression to an explicit loop, because filter can only ever remove elements — destroying a Tanker needs to remove the Tanker and add two others. Every other enemy type's collision behavior through that loop is byte-identical to what `.filter` produced before; only the Tanker branch does anything new.

GOTCHA Split children inherit the Tanker's position on purpose, and that has a real gameplay consequence worth naming. The delegate initially had split children spawn fresh at depth 0 (or a fixed depth for Pulsars) instead of the Tanker's own depth, reasoning in its own summary that this was "to avoid premature capture" — a genuine game-feel opinion, but one it made unilaterally instead of flagging, silently undoing the intended difficulty escalation (releasing danger where the Tanker died, not granting a free reset). Caught during review and reverted to the specified behavior. Fixing it immediately exposed a real bug in my own test suite: a test asserting that two split Flippers survive a same-lane rim-split had never actually checked lives, and with the depth fix both Flippers now land exactly at the rim on the player's lane and get captured in the very same tick — an intended consequence (spec.md §3's "dangerous for what it releases," paid off literally), not a bug, but one my original test didn't verify. Split into two tests: an isolated no-life-cost case (different lane) and a dedicated same-lane double-capture case with exact life-count assertions.
VERIFIED All four diffs reviewed line-for-line against the exact algorithms handed to each delegate. Three were byte-for-byte matches on the first pass; Tanker's had the one depth-deviation above, caught and fixed before accepting. Independently rebuilt after every sub-phase — 68 → 79 → 89 → 101 reducer tests, all green at each step, zero regressions to any prior phase's coverage across the whole chapter. On-device: installed after each sub-phase and confirmed the new enemy actually spawns and renders in its own distinct color alongside the others already on screen (Amber Flipper, CyanDim Spiker, near-white Fuseball, state-colored Pulsar all visible simultaneously in one screenshot at one point). Did not capture a live full Pulsar telegraph cycle or a live Tanker split on-device — both have multi-second timers that lose the race against how fast a stationary, unattended player dies to a Flipper, the same friction noted since Chapter 7 — so those specific sequences rest on their exact-match unit tests instead, the same evidence-substitution call made before when live capture proves impractical.
GOTCHA The "difficulty-tier-weighted spawner" scope turned out smaller than progress.md's forward-looking note anticipated, and that's a good thing. Earlier notes flagged a possible full rewrite consolidating Flipper/Spiker/Fuseball/Pulsar's four independent spawn timers into one shared, weighted spawner. Re-reading Phase 11's actual "Done when" criterion closely — "verify by jumping SkillStep to a high starting level and observing more Fuseball/Pulsar-cargo Tankers" — showed the real, measurable requirement is just Tanker's own cargo weighting, not a rearchitecture of every enemy's spawn cadence. Tanker got its own independent timer, matching the pattern the other three already established, and only its cargo choice is tier-weighted. Lower risk, fully satisfies the actual acceptance criterion, and didn't require touching or re-testing four already-working, already-tested spawn timers.

Done when

  • All 5 enemy types are spawnable and behave per spec.md §3 — Flipper (Ch6), Spiker, Fuseball, Pulsar, Tanker, each with its own independent spawn timer.
  • Tanker's cargo composition shifts with difficultyTierFor(level) — unit-tested exactly at tiers 0, 1, and 2+; not confirmed via a live high-SkillStep playtest this chapter (would need many minutes of sustained, skilled play to observe enough Tanker spawns/splits at a high enough level to see the shift firsthand).
  • Every enemy type's collision scoring and capture rule is correct and type-specific — scoreFor() and the capture when are both exhaustive over all 5 subtypes, compiler-enforced.
  • All 101 reducer tests pass (59 at the start of this chapter, 42 added across the four sub-phases: 9 + 11 + 10 + 12).
Chapter 12 done

Remaining web shapes + recoloring

Toward all 16 shapes, and the palette shift that makes the level-17 repeat cycle visually distinct.

Two generators already covered most of the roster — only one was new

Growing from 2 shapes to 7 turned out to need almost no new geometry. circleWeb(sides) was never actually "the 16-lane circle generator" — it's a general regular-polygon-on-the-unit-circle generator that happened to only ever be called with sides = 16 until now. A square is circleWeb(4). A triangle is circleWeb(3). Same story for openArcWeb(sides, spanDegrees) — a narrow 40° span reads as a flat wedge, completely different from the existing 270° horseshoe, for free. Only one shape needed real new math: a star/cross silhouette, which neither existing generator could produce (both only ever place vertices at a constant radius):

fun starWeb(points: Int, innerRadius: Float = 0.4f): WebShape {
    val vertices = mutableListOf<Offset>()
    val totalVertices = points * 2
    for (i in 0 until totalVertices) {
        val angle = 2 * PI * i / totalVertices
        val radius = if (i % 2 == 0) 1f else innerRadius   // alternate outer tip / inner corner
        vertices.add(Offset((radius * cos(angle)).toFloat(), (radius * sin(angle)).toFloat()))
    }
    return WebShape(vertices, closed = true)
}

A wide innerRadius (0.5) with few points (4) reads more like a plus/cross than a sharp star — same function, just different parameters, rather than writing separate cross-specific geometry. shapes grew from a 2-entry list to 7, each new entry just a lambda calling one of these three generators with different arguments — the webFor(level) = shapes[(level-1) % shapes.size]() cycle math from Chapter 8 didn't need to change at all, it already worked for any list length.

Why none of this touched the reducer

Worth stating plainly since it's easy to assume a "new shapes" phase would ripple through movement/collision code: it doesn't, because gameplay has been lane-index-based since Chapter 4, never vertex-position-based. Drag's lane-snap math only ever cares about laneCount and closed; collision/capture only compare lane indices and depth. Vertex positions are consumed exactly once, in TubeRenderer, for projection and the spike-direction vector. A star-shaped web plays identically to a circular one from the reducer's point of view — only the picture changes.

Recoloring the tube, and only the tube

paletteIndexFor(level) has existed since Chapter 8 but had no consumer until now — this chapter finally wires it into TubeRenderer:

val TubePalette: List<Color> = listOf(Cyan, Violet, Orange, TealBright)

// TubeRenderer:
val tubeColor = TempestColors.TubePalette[paletteIndex.mod(TempestColors.TubePalette.size)]

Deliberately narrow in scope: only the tube's own lane lines, border, and center dot read from the palette. Spikes stay CyanDim, projectiles stay Green, every enemy keeps its own established per-type color, the player marker stays Magenta — none of that is "the level's palette," it's fixed gameplay-entity signaling that has to stay legible and consistent regardless of which of the 4 tube hues is currently active. Recoloring everything would have been a smaller diff and a worse decision.

GOTCHA A Chapter 8 test hardcoded an assumption this chapter was always going to break, and the delegate caught it rather than quietly "fixing" it. GameReducerTest.kt had a leftover test from when shapes only had 2 entries, asserting that level 3 wraps back to circleWeb(16) — true then, false now that shapes has 7 entries and level 3 is a real, distinct square. The delegate was instructed not to touch GameReducerTest.kt, correctly treated that as a real constraint, and stopped to ask rather than silently editing a file outside its assigned scope or leaving a broken build. The right call on review: the new LevelFactoryTest.kt already covers the exact same behavior more precisely (all 7 shapes' signatures, not just 3), so the old test was superseded, not just wrong — deleted as a stale duplicate rather than patched, which is a cleaner outcome than either leaving it broken or updating its numbers in place.
VERIFIED Diff reviewed line-for-line against the exact code handed to the delegate — byte-for- byte match across all 5 files (4 edits + 1 new test file). Independently rebuilt; caught the one stale-test failure above and removed it myself rather than asking the delegate to touch a file it was told not to. 106/106 green afterward (100 `GameReducerTest` + 6 new `LevelFactoryTest`). On-device: SkillStep-jumped straight to level 4 and confirmed a real triangle renders (not just three lanes on a circle — an actual three-vertex closed polygon), then jumped to level 23 (tier 1) and confirmed the tube's lane lines/border/ center dot render in Violet instead of Cyan, while the spike jitter at center and the player's rim marker kept their normal fixed colors throughout — exactly the "recolor the tube only" scope specified, confirmed visually, not just by code review.

Done when

  • 7 distinct web shapes exist and cycle correctly (up from 2) — confirmed both by unit test (all 7 signatures pairwise distinct) and on-device (a real rendered triangle, not just an assumption from the vertex count).
  • Level 17+ reads as visually distinct from levels 1-16 via a real palette shift — confirmed on-device (Violet tube at level 23, tier 1) — not yet all the way to a full 16-shape roster; that's still open toward spec.md §4's stretch-goal checklist, not a gap in this phase's own scope.
  • Gameplay logic required zero changes — confirmed by not touching GameReducer.kt/GameContract.kt at all this phase.
  • All 106 tests pass across `GameReducerTest` (100) and the new `LevelFactoryTest` (6).
Chapter 13 done

Audio & haptics

Wiring every GameEffect to a sound/vibration, and nothing else.

GameEffect finally grows into the shape spec.md always gave it

GameEffect.PlaySound(id: SoundId) and Vibrate(pattern: HapticPattern) were both in spec.md §7.1's very first contract sketch, back in Chapter 1 — this is just the chapter that finally gives them real emission sites. `SoundId` is deliberately an inert enum with no behavior of its own: the reducer only ever says which event happened (`SoundId.EnemyDestroyedPulsar`, `SoundId.LevelClear`, …), never how it should sound. That split is what keeps the reducer pure — it names events, the UI layer decides what "Pulsar destroyed" is supposed to sound like.

Roughly ten emission sites, zero gameplay-logic changes

The diff touches nearly every step of the `Tick` pipeline, but every single touch is additive — one `tickEffects += GameEffect.PlaySound(...)` line at an existing decision point, never a change to the surrounding movement/collision/capture math. A representative sample: enemy destruction reuses the exact per-type dispatch pattern scoreFor() already established, just for sound instead of points:

private fun soundFor(enemy: Enemy): SoundId = when (enemy) {
    is Enemy.Flipper -> SoundId.EnemyDestroyedFlipper
    is Enemy.Spiker -> SoundId.EnemyDestroyedSpiker
    is Enemy.Fuseball -> SoundId.EnemyDestroyedFuseball
    is Enemy.Pulsar -> SoundId.EnemyDestroyedPulsar
    is Enemy.Tanker -> SoundId.EnemyDestroyedTanker
}

Level transitions get two distinct cues at two distinct moments, not one sound reused twice: LevelClear plays the instant the field empties out (step 9, the same tick Warping begins), Warp plays later, when the timer completes and the new level actually loads (inside Chapter 8's Warping-status branch). Capture/death sounds needed a little more care: every life lost plays PlayerCapture + a Capture haptic regardless of cause (Flipper contact, Fuseball proximity, Pulsar discharge, a lethal spike via either Drag or a warp-trigger check), and a fatal hit — the one that brings lives to zero — additionally plays GameOver on top, not instead.

A real constraint, named instead of hidden: no audio assets exist

Spec.md §14 calls for real sound design via SoundPool + res/raw/ files. This project has no sourced or generated audio assets, and this session has no way to produce any — no synthesis tool, no legitimate source to pull arcade SFX from. Shipping a silently-no-op SoundEngine was the easy option; the better one was picking a built-in Android API that needs zero external files and genuinely makes sound:

class SoundEngine {
    private val toneGenerator = ToneGenerator(AudioManager.STREAM_MUSIC, 80)

    fun play(id: SoundId) {
        val (tone, durationMs) = toneFor(id)
        toneGenerator.startTone(tone, durationMs)
    }
    // toneFor() maps each SoundId to a distinct ToneGenerator.TONE_* constant + duration
}

Every SoundId maps to a genuinely distinct, audible tone today — placeholder feedback, not real sound design, but functioning rather than silent. Swapping in a real SoundPool-backed implementation later touches only this one file; every GameEffect.PlaySound call site in the reducer and in GameScreen stays exactly as it is. Same category of pragmatic, clearly-flagged deviation as Chapter 2's BlurMaskFilter → layered-DrawScope glow correction, when the spec'd API didn't resolve against this project's Compose BOM.

Haptics stay narrow on purpose

Unlike sound (fifteen distinct cues), haptics cover exactly two patterns — Capture and Zapper — matching development_plan.md's explicit scope ("haptics on death/capture + zapper use only," not a buzz on every single sound event). Implemented with VibrationEffect.createOneShot(...) directly against android.os.Vibrator, no wrapper class needed for something this small; this project's minSdk is already 26, so the modern VibrationEffect API is unconditionally available, no legacy-API fallback branch required. Needs one new manifest line most phases never have to think about:

<uses-permission android:name="android.permission.VIBRATE" />
VERIFIED Diff reviewed line-for-line against the exact algorithm handed to the delegate — byte-for-byte match across every one of the ~10 emission sites in a genuinely large diff, plus the new `SoundEngine`, the `GameViewModel`/`GameScreen` wiring, and the manifest permission. (One phrase in the delegate's own summary — "+ Vibrate if Tanker splits" — read like a possible undisclosed addition on first pass; grepped the actual diff specifically for it and found nothing there, just imprecise wording in the report, not a real deviation. Checked anyway rather than assumed.) Independently rebuilt, 112/112 green. On-device: played a real session exercising fire, the ZAP button, a capture, and a game-over-with-new-high-score in one run — confirmed via `adb logcat` that the app process threw zero exceptions across any of those effect paths (`ToneGenerator`/`Vibrator` instantiation and calls all succeeded), and confirmed every pre-existing UI element (HUD, overlay, tube rendering, zapper pips) still behaves correctly with the new effect plumbing layered in. Can't literally confirm audible tones from a screenshot — that's a real limit of this verification method, stated plainly rather than implied to be more than it is.

Done when

  • Every GameEffect variant produces a real sound/haptic call, not a no-op — confirmed by code review of the 1:1 mapping in SoundEngine/ GameScreen, and by unit tests confirming the reducer actually emits each one at the right moment.
  • The reducer never calls Android audio/haptic APIs directly — confirmed by construction: GameReducer.kt has no `android.*` imports, same discipline as every other phase.
  • A real play session exercising fire/zapper/capture/game-over produces zero crashes — confirmed on-device via logcat across all four paths in one run.
  • All 112 reducer tests pass (100 prior + 12 new this chapter).
Chapter 14 done

Polish pass

A real HUD, a control legend, distinct vector shapes per entity, and a correctness fix that had been quietly blocking level-clears — three UX gaps and one bug, all found the same way: playing the actual build.

This chapter didn't start from the dev plan — it started from playing the game

Every prior chapter began with a spec section. This one began with feedback after actually playing the Chapter 13 build: "this does not look at all as the atari game. I do not understand how to move, shoot, play, the enemies are not like the game", followed by a second report once the mechanics were explained — "I do not understand the constraints to move to the next level." That second complaint turned out to name a real bug, not a UX gap, and re-scoped this chapter from "make it feel better" into "make it feel better and fix why levels basically never clear."

The bug: five spawners, no shared budget, so the field can never truly empty

Since Chapter 11, each of the 5 enemy types has spawned on its own independent timer, forever, for as long as the level is in Playing status. Chapter 8's warp trigger only ever checked one thing: is enemies empty right now? With 5 perpetual spawners running in parallel, the field can go empty for a single frame between one enemy's destruction and the next spawn firing — but it almost never stays empty long enough to feel like "the level is cleared," and mathematically there is no point at which spawning actually stops. The real arcade's levels are finite waves; this build's levels were, structurally, infinite.

The fix is a shared budget every spawner has to respect, sized by level tier the same way Tanker's cargo already scales with tier:

private fun waveSizeFor(tier: Int): Int = (8 + tier * 3).coerceAtMost(30)

waveEnemiesRemaining is set from this formula on StartGame and on every warp completion, and every one of the 5 spawn branches in the Tick pipeline gained the same two-line change — an extra && waveEnemiesRemaining > 0 guard on the condition, and a waveEnemiesRemaining -= 1 at the end of the body:

if (enemySpawnTimer <= 0f && laneCount > 0 && waveEnemiesRemaining > 0) {
    // ...spawn the Flipper, as before...
    waveEnemiesRemaining -= 1
}

The warp-trigger condition itself (step 9) gained the matching other half — it no longer fires on a momentarily-empty field, only once the whole wave has both spawned and been cleared:

if (finalStatus == GameStatus.Playing && enemiesAfterCapture.isEmpty() &&
    enemiesSpawnedThisLevel > 0 && waveEnemiesRemaining <= 0) {
    // ...begin Warping, as before...
}

The single easiest way to silently break a fix like this: compute waveEnemiesRemaining correctly in a local var every tick, then forget to add it to the final state.copy(...) at the bottom of the Playing branch. It would type-check, every existing test would still pass (none of them exercise the new field), and the decrement would simply never persist — each tick would silently re-read the unchanged value from the previous state forever. This exact omission happened while writing this chapter's diff, caught before committing by re-reading the full change against the plan rather than trusting that "it compiled" meant "it's wired up."

The HUD: replacing a debug-text dump with something a player can read mid-game

GameScreen's overlay had been a raw Column of labeled debug values since Chapter 1 ("status: Playing", "frameCount: 4821", "paletteIndex: 2") — useful for verifying reducer output during development, useless for a player. It's replaced with score/level at the top, remaining lives as small triangle glyphs (matching the player's own silhouette, not a bare digit), and a labeled zapper-charge pip row:

Row(horizontalArrangement = Arrangement.spacedBy(6.dp)) {
    repeat(state.lives) {
        BasicText(text = "▲", style = TextStyle(color = TempestColors.Green, fontSize = 16.sp))
    }
}

The debug Start button that had sat at the bottom of that column since before StartGame was wired to LaunchedEffect(startLevel) is gone too — dead scaffolding once the screen already auto-starts on entry.

The control legend: this app's controls are not the real cabinet's controls

The real arcade has a spinner knob and two clearly-labeled buttons — self-explanatory by touch alone. This app substitutes a drag gesture for the spinner, and that substitution is exactly what the "I do not understand how to move" feedback was about: the FIRE/ZAP buttons were already labeled, but nothing on screen ever said what dragging did. Two small, permanent (not a fading tutorial toast — simpler, and doesn't need extra state or a timer edge case around level transitions) hints close that gap: "(drag anywhere to steer)" under the HUD, and a one-word caption above each button — HOLD above FIRE, TAP above ZAP — reinforcing the press-and-hold vs. single-tap distinction between them.

Six distinct vector silhouettes, replacing six same-shape colored dots

Since Chapter 3, every entity — player, all 5 enemy types, spikes, projectiles — has rendered as a plain drawCircle, distinguished only by color. That's what "the enemies are not like the game" was about. Real Tempest gives every entity a distinct silhouette; this chapter adds one per type, each built from the entity's own outward/perpendicular basis so it stays correctly oriented regardless of which lane it's on:

private fun laneBasis(web: WebShape, lane: Int): Pair<Offset, Offset> {
    val raw = web.vertices[lane]
    val dist = raw.getDistance()
    val laneDir = if (dist > 0f) raw / dist else Offset(0f, -1f)
    val perp = Offset(-laneDir.y, laneDir.x)
    return laneDir to perp
}

laneDir deliberately re-normalizes web.vertices[lane] rather than reusing it directly the way the existing spike-rendering code does — a star web's inner vertices sit at 0.4× radius by design (Chapter 12), which is exactly correct for *positioning* an entity at the right depth along a short spoke, but wrong for *orienting* a shape, which needs a unit-length direction so every silhouette comes out the same on-screen size no matter which lane or web shape it's drawn on.

Per the Wikipedia description of the arcade's own vector sprites:

  • Player — claw: a 5-point notched path, flat base at the rim, two pincer tips pulled inward toward the tube center with a shallower notch between them.
  • Flipper — linked chevron pair: a 5-point zigzag across the perpendicular axis, alternating points pulled inward — two connected "V" notches.
  • Pulsar — wavy line: the same zigzag idea generalized to 7 points on a sin() curve, alternating inward and outward instead of only inward, reading as a smooth wave rather than Flipper's sharp chevrons.
  • Fuseball — sphere with tendrils: a stroked circle plus 6 short lines radiating outward from its edge — the only shape that doesn't need lane orientation at all, being radially symmetric.
  • Spiker — spiral: a 17-point polyline walking r = t · maxR outward while angle = t · 1.5 turns, the only curved, multi-turn silhouette of the six — visually distinct from every straight-line shape around it.
  • Tanker — rhomboid: a plain 4-point diamond, elongated along the lane axis and deliberately the largest silhouette, matching its role as the one enemy that splits into two more on death.
VERIFIED Not delegated — matches delegation_plan.md §1's rule that "feel" work needs direct judgment, not a checklist a cheap model can verify. Independently rebuilt after each of the three parts (wave-cap fix, HUD, vector shapes); 6 new reducer tests target the wave-cap mechanic specifically (spawn blocked at waveEnemiesRemaining == 0 even when a type's own timer has expired; warp does not trigger while budget remains even with a momentarily-empty field; StartGame/warp-completion set the tier-scaled budget correctly at tier 0, tier 1, and the tier-8+ cap). All 124 tests green (118 reducer + 6 LevelFactoryTest, up from 112). On-device on a real Pixel 8 Pro API 33 emulator: the new HUD and (drag anywhere to steer) / HOLD / TAP legend render correctly from the Menu→Game transition; a live play session visually confirmed 4 of the 6 new silhouettes distinctly on screen at once — the player's claw, a Flipper's chevron pair, a Fuseball's sphere-with-tendrils, and an early-stage Spiker spiral, each a genuinely different shape, not just a different color. Pulsar (4s spawn timer) and Tanker (5s) didn't get caught in the same short-lived session before the player died — same capture friction documented since Chapter 7 — so those two rest on code review plus the exhaustive per-Enemy-subtype when dispatch (a missing branch there is a compile error, not a silent gap) rather than a live screenshot, the same evidentiary substitution Chapter 11 already made for these same two types.

Done when

  • A level's enemy wave is provably finite and warp is reachable — confirmed by the wave-cap unit tests, not just by code inspection.
  • A first-time player can tell how to move without being told — confirmed by the persistent on-screen legend, since the drag gesture itself has no other affordance.
  • Every enemy type (and the player) has a silhouette distinct from a plain circle, matching the real arcade's described shapes — confirmed by code review of all 6 draw* functions and directly observed on-device for 4 of the 6.
  • All 124 reducer/level-factory tests pass (112 prior + 6 new wave-cap tests + the pre-existing 6 LevelFactoryTest — no regressions from either the spawner guard or the render rewrite).
Chapter 15 done

Making it actually look like Tempest

A real pseudo-3D tube, lanes as wedges, the arcade's 16 geometries, a spinner-style stick — and the four scoring rules that were specified in Chapter 1 and never written.

The report was "it feels 2D", and that was exactly right

Since Chapter 3 the renderer had projected every lane to one shared center point. That is a pinwheel, not a tunnel. Reference screenshots of the cabinet make the real structure obvious: there is a small far ring near a vanishing point and a large near rim where the player sits, and the lane lines are the walls connecting corresponding vertices of the two.

private const val Z_NEAR = 1f
private const val Z_FAR  = 8f

// Classic 1/z foreshortening: 1.0 at the near rim, 1/8 at the far end.
private fun perspectiveScale(depth: Float): Float =
    Z_NEAR / (Z_NEAR + (1f - depth.coerceIn(0f, 1f)) * (Z_FAR - Z_NEAR))

The non-linearity matters more than the projection itself. A linear interpolation between "far" and "near" reads as a flat zoom; 1/z makes an enemy crawl while it's distant and then rush the last stretch, which is the thing that actually feels like depth.

A lane is a wedge, not a spoke

The second structural error was subtler and mattered just as much. Entities were drawn as small marks centred on a single rim vertex. In the arcade, lane i is the quad between vertex i and vertex i+1, and the claw straddles that whole wedge; a Flipper is an X drawn across one.

/** The two rim vertices bounding lane i's wedge. Closed webs wrap; open ones reuse
 *  the last real segment rather than wrapping across the gap. */
private fun laneCorners(lane: Int): Pair<Offset, Offset> {
    val n = web.vertices.size
    return if (web.closed) {
        web.vertices[lane] to web.vertices[(lane + 1).mod(n)]
    } else {
        val a = lane.coerceIn(0, n - 2)
        web.vertices[a] to web.vertices[a + 1]
    }
}

None of this touched the reducer. Gameplay has been lane-index based since Chapter 4 and has never known a vertex position, so "which wedge does index 7 mean" is purely a rendering decision — the payoff for a boundary drawn correctly eleven chapters earlier.

Sizing bug: the claw built from a second depth

The first attempt drew the claw by projecting the lane at depth 1.0 and again at 0.88, treating the gap as prong length. It rendered as an enormous spike lancing up the tube. The arithmetic explains why: with Z_FAR = 8, depth 0.88 already sits roughly halfway to the vanishing point. Foreshortening near the rim is far too aggressive for "a slightly shallower depth" to mean "a slightly shorter shape". The fix is to build the claw in screen space from the rim corners, sized off the lane's own width — and then clamp it:

val prong = (laneW * 0.42f).coerceAtMost(maxProng)   // maxProng ≈ 5.5% of the min screen dimension

The 16 geometries, and why the old square was wrong

The level roster was built as circleWeb(sides = 4) for "Square" and circleWeb(sides = 3) for "Triangle" — which produces a literal four-lane and three-lane web. The arcade does the opposite: every level keeps a high, roughly constant lane count, and only the outline changes. Four gigantic wedges also fed straight into the sizing bug above, since entity width scales with lane width — the "player looks too big" report and the "some levels look bad" report had one shared cause.

The fix is a generator that samples any outline into a chosen number of lanes by arc length:

fun outlineWeb(corners: List<Offset>, lanes: Int, closed: Boolean = true): WebShape {
    // Walk the perimeter, dropping `lanes` vertices at equal arc length, then normalize so
    // the furthest vertex sits at radius 1f -- every shape ends up the same on-screen size.
}

With that in place the roster becomes the arcade's documented sixteen, in order: Circle, Square, Plus, Bow-tie, Cross, Triangle, Clover, V, Steps, U, Flat line, Heart, Star, W, Fan, Figure-8. Ten are closed tubes you can orbit forever; six are open fields with hard endpoints that can corner you — a real tactical split that WebShape.closed has driven since Chapter 4. Curved silhouettes are far easier to state as an equation than a typed corner list:

fun cloverCorners(): List<Offset> = polarCorners(64) { t -> 0.45 + 0.55 * abs(cos(2 * t)) }
fun infinityCorners(): List<Offset> = polarCorners(64) { t -> 0.25 + 0.75 * sqrt(abs(cos(2 * t))) }

A test that had to be rewritten, not repaired

Chapter 12's roster test asserted that all seven shapes had distinct (closed, vertexCount) signatures. That test now should fail: making every level carry ~16 lanes is the entire point, so vertex count can no longer identify a shape. It was replaced with one that compares actual vertex geometry, plus two new guards that encode the bugs this chapter fixed:

@Test fun `every level's web carries a playable lane count, never a 3-or-4-lane web`()
@Test fun `the roster mixes closed tubes and open fields the way the arcade's 16 shapes do`()

Worth being explicit about the difference: a test that breaks because behaviour legitimately changed gets its expectation rewritten. A test that breaks because the code broke gets the code fixed. Conflating those is how a suite quietly stops meaning anything.

Colour sets and the invisible levels

The arcade recolours the tube every completed set of sixteen — blue, red, yellow, cyan, then invisible, then green. paletteIndexFor(level) already existed; it just needed the real colours behind it, and one predicate:

/** Levels 65-80: the web isn't drawn at all. A *rendering* predicate only -- no
 *  gameplay branch depends on it, which is why it can be this simple. */
fun isInvisibleLevel(level: Int): Boolean = paletteIndexFor(level) == 4

TubeRenderer skips the walls and both rings when it's set; spikes, enemies, shots and the claw still render, and every rule is unchanged. A whole marquee mechanic for one boolean and an if — because the layer boundaries were already right.

The controls: a velocity device, not a position device

Drag-to-steer was reported as "odd", and the reason is a genuine category error. The cabinet's spinner is a velocity control: spin it and the claw keeps moving. A drag gesture is a position control — your finger has to keep travelling for the claw to keep moving, and it runs out of screen. RotaryJoystick holds a deflection instead and emits one small delta per frame while held, forwarded as the same Drag intent the reducer already took.

The first version accumulated drag distance and still felt wrong — the knob stopped corresponding to where the thumb actually was, so small corrections did nothing until the accumulator caught up. Tracking the finger's absolute position inside the pad fixed it. It also got real feedback, since a control with no indication reads as broken: a dead-zone ring, direction chevrons that light on the side you're pushing, and an arc whose length is the current rotation speed.

Four scoring rules that were specified and never written

Auditing spec §13 against the reducer turned up four rules with no implementation at all: the level-clear bonus (1000 × level), both superzapper bonuses (50 per enemy cleared; a flat 100 for the random kill), and extra lives every 10,000 points capped at six. The extra-life rule is the one with a genuine edge case:

private fun extraLivesEarned(oldScore: Int, newScore: Int): Int =
    (newScore / EXTRA_LIFE_SCORE_THRESHOLD) - (oldScore / EXTRA_LIFE_SCORE_THRESHOLD)

Counting threshold crossings rather than testing score % 10_000 means a single tick that jumps 9,900 → 20,100 correctly awards two lives, and no tick can ever award the same life twice. The award is then suppressed entirely on a tick that ends the game — earning a life on the exact frame that takes you to zero would leave a contradictory "game over with a fresh life" state.

VERIFIED All 135 tests green (126 reducer + 9 LevelFactoryTest), including 8 new scoring tests and 3 rewritten roster tests. Verified on a physical Pixel 10 Pro XL rather than an emulator this time: the tube renders with a genuine far ring and near rim, the claw straddles one wedge and steers with the stick, Flippers read as X's across their lanes, shots are lane-width dashes, and levels 2/3/7/12 render as recognisable square, cross, clover and heart silhouettes at proper lane density. Two real bugs were found by playing rather than by testing: the HUD drew underneath the system status bar (fixed with statusBarsPadding()), and the initials SUBMIT button silently did nothing unless exactly three characters were entered, on a text field that was invisible until first keystroke and never took focus. Both are the kind of defect a green test suite will never report.

Done when

  • The tube reads as a tunnel receding to a vanishing point, not a flat pinwheel — confirmed on-device against the reference screenshots.
  • Player and enemies span their lane wedge at a size that holds up on both a 16-lane circle and a 15-lane triangle — confirmed on-device and guarded by a lane-count test.
  • All 16 arcade geometries are present in order, mixing closed tubes and open fields — guarded by two roster tests.
  • Steering is a velocity control with visible state — confirmed on-device.
  • Every scoring rule in spec §13 has an implementation and a test.
Chapter 16 done

The tube shoots back

Reading a disassembly to find the mechanics we'd silently invented, and the three things that only turned up once the instrumented tests were actually run.

Comparing against a reference, and what a reference is worth

Two open-source Tempest implementations were compared against ours. One turned out to be a Flippers-only toy with no superzapper — worth saying plainly rather than mining for findings that aren't there. The other, mwenge/tempest2k, is a ~24,000-line commented 68000 disassembly, and it is the real source.

One caveat had to travel with every finding: that disassembly is Tempest 2000 (1994), not the 1981 ROM. It contains classic-mode code paths, so its behaviour is good evidence, but its numbers are T2K-tuned. Behaviour was adopted; tuning constants were not. A reference you can't date is a reference you'll copy the wrong parts of.

The biggest omission: enemies never shot at us

Eleven chapters of enemies, and not one of them could fire. Adding it needed a second projectile type, and the interesting part is which direction it travels:

/** Enemy bullets use the same sense as enemies -- 0f=center..1f=rim, INCREASING as they
 *  travel toward the player -- the exact opposite of the player's own Projectile. */
data class EnemyBullet(val id: Long, val lane: Int, val depth: Float)

That opposition is the whole design: a player shot and an enemy shot on the same lane close on each other, and either can destroy the other. Incoming fire becomes something to answer, not only dodge.

What makes it playable rather than a bullet storm is the set of constraints, all lifted from the reference: one shared fire timer for the entire enemy side (not a per-enemy cooldown, so volume doesn't scale with enemy count), Flippers and Spikers only, never from the near 38% of the tube — point-blank fire is undodgeable — and a concurrency cap of min(3, level / 8) + 1.

One deliberate deviation, flagged rather than buried: the reference lets enemies fire from level 1. Playing that build ended at score 0 in about six seconds. Every other threat in this project is introduced by level, so fire is too:

/** Enemies hold their fire for the first couple of levels. An unarmed opening level is
 *  what makes the controls learnable before the tube starts shooting back. */
private const val ENEMY_FIRE_INTRO_LEVEL = 3

The warp was a cutscene; it should have been the hardest part of the level

Since Chapter 8 the warp had been a timer counting down to a level swap. The reference shows what it should be: you dive down the tube keeping full lane control, and every hazard is disabled except spikes. That's what makes "avoid the spikes" an instruction rather than decoration.

Two changes made it real. First, steering had been explicitly blocked during Warping — that guard was inverted to block only GameOver and Paused. Second, the collision is a crossing test, not a proximity test:

val spikeHere = state.spikes[state.playerLane] ?: 0f
val struck = spikeHere > 0f &&
    divedDepth <= spikeHere && state.warpPlayerDepth > spikeHere

Reading both the new depth and the previous one means the player is struck on the frame they descend into the spike's tip, exactly once, regardless of frame rate — the same edge-triggering discipline Chapter 11's Pulsar needed.

This also let a genuinely bad rule be deleted. The old code checked for a lethal spike at the instant the field cleared and took a life immediately, with no possible response. Four tests encoded that rule and were rewritten, not repaired — when behaviour legitimately changes, the expectation is what's wrong.

Three enemies that were subtly the wrong creature

  • Flippers wove up the tube at you. They shouldn't: they climb their lane without deviating and only start flipping once they reach the rim. They arrive, then work along the rim. They're also invulnerable mid-flip — drawn dimmed, so a shot passing through is visibly explained rather than discovered by missing.
  • Fuseballs could just be shot. They ride the boundary between lanes and are untouchable while they do; only the middle 60% of a crossing is a window. Rendered faint on a rail and bright mid-crossing, so the window is legible.
  • Pulsars sat still on private timers. They climb, and they now take their state from a single shared clock in GameState instead of one timer each — so they flash in unison rather than drifting apart by spawn time. A lane of independently-phased Pulsars is unreadable.

One finding needed no work at all: spikes were already absorbing shots. Worth checking before writing code, and worth reporting as "already correct" rather than quietly claiming it.

Written tests aren't tests until they've run

Two instrumented test classes had been written, compiled, and never executed. Running them found three problems, none of which any JVM test could have caught.

They were already stale. GameScreenTest still asserted the drag-anywhere legend, which stopped existing when steering became a joystick. A UI test's strings are a coupling to the UI, not documentation of it.

Espresso couldn't run on the device at all. espresso-core 3.6.1 reflects on InputManager.getInstance(), removed in newer Android, so all eight tests died before reaching an assertion. That is an infrastructure failure wearing an assertion-failure costume, and reading the actual message rather than the summary is what separated the two.

And the screen can never be idle. The game loop is while (isActive) { withFrameNanos { … } }, so there is always a frame pending and Compose's auto-advancing clock never reaches idle — every waiting assertion timed out with ComposeNotIdleException. The fix is to take the clock away from it:

composeTestRule.mainClock.autoAdvance = false
// …then advance deliberately:
repeat(30_000 / 16) { composeTestRule.mainClock.advanceTimeBy(16L) }

This is the rare fix that's better than the thing it replaced. The game-over path is now an exact thirty seconds of game time rather than a hopeful wall-clock timeout, so it can't flake on a slow device.

A test that passed for the wrong reason

One nav test asserted assertEquals(1, route.startLevel) with the comment "fresh app data". It passed initially and failed later, because the SkillStep level lives in DataStore and survives between runs — the test was really asserting leftover device state. The first fix deleted the DataStore file in @Before; it passed once, then failed again, because DataStore caches in memory and can rewrite the file underneath you. The fix that holds asserts the contract instead:

val shownLevel = composeTestRule.onNodeWithText("SKILLSTEP LEVEL:", substring = true)
    .fetchSemanticsNode().config[SemanticsProperties.Text].first().text
    .substringAfterLast(' ').toInt()
// …
assertEquals(shownLevel, route.startLevel)   // Game gets what the Menu was showing

"Does Game receive the level the Menu displayed" is the thing worth guaranteeing. "Is that level 1" was never the contract — it was an accident of whatever had been played last.

VERIFIED 149 JVM tests and 8 instrumented tests green on a physical Pixel 10 Pro XL. Nine reducer tests encoding the previous enemy behaviour were rewritten against the new rules, and four more were rewritten for the warp dive. On-device play confirmed enemy fire appears from level 3, the dive is steerable, and Flipper/Fuseball invulnerability states are visually distinguishable. Difficulty balance is explicitly not claimed as verified: the enemy-fire introduction level was tuned from a single observation of a stationary player dying, not from a real play session.

Done when

  • Enemies can fire, under constraints that keep it dodgeable, and the player can shoot their shots down.
  • The warp is played rather than watched, with spikes as the sole hazard.
  • Flipper, Fuseball and Pulsar behave as the reference describes, with their invulnerability states legible on screen.
  • The instrumented tests have actually been executed, not merely compiled.
Chapter 17 done

The control bug you can't see

"It's hard to use the joystick" turned out to be a frame-rate bug that is invisible on every emulator, plus two design mistakes underneath it. And the opening levels finally slow down.

A complaint is a symptom, not a diagnosis

The feedback was two sentences: the joystick is hard to use, and the first levels should start slower. The second is a tuning request. The first sounded like one too — lower MAX_DEG_PER_FRAME and move on. Reading the input path first is what turned it into a real bug report.

// The old steering loop
LaunchedEffect(Unit) {
    while (isActive) {
        withFrameNanos { }              // <-- frame time requested, then thrown away
        val d = deflection
        if (abs(d) > DEAD_ZONE) {
            val scaled = (abs(d) - DEAD_ZONE) / (1f - DEAD_ZONE)
            onSteer(sign(d) * scaled * MAX_DEG_PER_FRAME)
        }
    }
}

withFrameNanos hands you the frame's timestamp. This code called it purely as a "wait for the next frame" primitive and discarded the value, then applied a fixed 4.5 degrees per frame. That makes rotation speed a function of the display's refresh rate:

DisplayRotationOn a 16-lane web
60Hz (every emulator here)270 deg/sec~12 lanes/sec
120Hz (the phone it was played on)540 deg/sec~24 lanes/sec

Twenty-four lanes a second is a claw that teleports. The control wasn't badly tuned, it was running at double its designed speed on the only hardware anyone had actually played it on — and it would have looked completely fine in every screenshot and every emulator session.

The project already knew this lesson. Tick has computed a real dt since Chapter 5 precisely so gameplay doesn't run at different speeds on different phones. The joystick was written later, in a different file, and quietly opted out. Every withFrameNanos loop needs the same discipline, not just the one labelled "game loop".

private const val MAX_DEG_PER_SEC = 240f
private const val MAX_FRAME_SEC = 0.05f   // clamp a resumed app / GC pause, same as Tick does

var lastFrameNanos = 0L
while (isActive) {
    val now = withFrameNanos { it }       // take the value this time
    val dtSec = if (lastFrameNanos == 0L) 0f
                else ((now - lastFrameNanos) / 1_000_000_000f).coerceIn(0f, MAX_FRAME_SEC)
    lastFrameNanos = now
    // …
    onSteer(sign(d) * responseCurve(scaled) * MAX_DEG_PER_SEC * dtSec)
}

Note withFrameNanos { it } — returning the value out of the callback lets the work happen outside the frame callback rather than inside it.

The second mistake: the stick started at full speed

Chapter 15 replaced an accumulating stick with one that read the finger's absolute position in the pad, for a good reason: with an accumulator, the knob stopped corresponding to where the thumb was, so small corrections did nothing until the accumulator caught up. But absolute position has its own failure, visible the moment you look for it:

val down = awaitFirstDown()
deflection = ((down.position.x - centerX) / travel).coerceIn(-1f, 1f)  // instant, before any movement

Press near the pad's edge and you are at near-maximum rotation on the first frame, having moved your thumb zero pixels. Every touch was a lottery.

The fix keeps the property that mattered and drops the one that didn't: measure deflection from where each gesture started, not from the pad's centre.

val down = awaitFirstDown()
val anchorX = down.position.x.coerceIn(
    size.width * ANCHOR_MIN_FRACTION,   // 0.20
    size.width * ANCHOR_MAX_FRACTION,   // 0.80
)
deflection = ((down.position.x - anchorX) / travel).coerceIn(-1f, 1f)

It is still a position control, so there's no accumulator to drift. But every touch now begins at a standstill. The anchor is clamped to the pad's middle band so that jabbing the far edge doesn't make one direction unreachable for that whole gesture.

The third: one sensitivity for two different jobs

A linear mapping gives the same degrees-per-pixel everywhere, but steering does two jobs — nudging one lane over, and swinging halfway around the tube. A curve lets both feel right:

private fun responseCurve(scaled: Float): Float = scaled * (0.35f + 0.65f * scaled)

Half-pushed is about a third speed; fully pushed is still full speed. The detail worth copying is that the drawing runs the same curve:

val magnitude = if (active) responseCurve((abs(deflection) - DEAD_ZONE) / (1f - DEAD_ZONE)) else 0f

Otherwise the speed arc reports raw finger offset while the claw moves at curve speed, and a half-pushed stick looks twice as fast as it turns. A feedback widget that disagrees with the thing it reports on is worse than no widget.

Slowing the opening without emptying it

The arcade introduces enemy types gradually — Chapter 15b implemented that schedule — but every type it has already introduced moves at full speed from level 1. On a touchscreen with a substitute control, that leaves no room to learn the stick.

private const val OPENING_SPEED_SCALE = 0.55f
private const val FULL_SPEED_LEVEL = 9

private fun openingSpeedScaleFor(level: Int): Float = when {
    level <= 1 -> OPENING_SPEED_SCALE
    level >= FULL_SPEED_LEVEL -> 1f
    else -> OPENING_SPEED_SCALE +
        (1f - OPENING_SPEED_SCALE) * (level - 1) / (FULL_SPEED_LEVEL - 1).toFloat()
}

/** Enemy speeds ease in over the opening levels, then ramp with difficulty tier from there. */
private fun speedScaleFor(level: Int): Float =
    openingSpeedScaleFor(level) * (1f + 0.15f * LevelFactory.difficultyTierFor(level).coerceAtMost(4))

Two things about the shape of this. It multiplies the existing per-tier scale rather than replacing it, so the two compose and the late game is untouched. And it scales movement only — spawn intervals, fire rate and wave size are all left alone, so level 1 is slower without being emptier. Slowing everything is how you make an opening level boring instead of gentle.

Ten failing tests, and the wrong way to fix them

The ramp immediately broke ten tests shaped like this one:

assertEquals(0.5f + flipperSpeed * dtSec, (result.enemies[0] as Enemy.Flipper).depth, 0.001f)

They failed correctly — level 1 genuinely does move slower now. The tempting fix is to multiply ten expectations by 0.55f. That bakes a tuning constant into ten assertions, so the next balance change breaks all ten again, and each one now tests two things at once.

Instead, each test was pinned to the level where the ramp is spent, so it still asserts the nominal per-type constant and nothing else:

val initial = GameState(
    web = web,
    enemies = listOf(Enemy.Flipper(id = 1, lane = 0, depth = 0.5f, flipCooldown = 1f)),
    level = FULL_SPEED_LEVEL,     // assert the speed constant, not the ramp
)

The ramp then gets its own three tests, with a helper so each reads as one claim:

@Test fun `Level 1 enemies climb at the opening speed scale, not the full per-type speed`()
@Test fun `The opening speed ramp rises with level and reaches full speed at FULL_SPEED_LEVEL`()
@Test fun `Past FULL_SPEED_LEVEL the tier scale takes over and keeps raising speed`()

That third one is the one that would have caught a real mistake: it asserts level 17 climbs at exactly nominal × 1.15, proving the opening ramp and the tier scale multiply rather than one silently clobbering the other.

Documentation rots quietly

The in-game Enemy Codex still said the Flipper "flips lane to lane", the Pulsar "sits still", and the Fuseball "rolls along the rim". All three were true — before Chapter 16 changed every one of those behaviours. Nothing failed, because prose isn't compiled.

It surfaced only because the codex was about to be screenshotted for a public README. Text that describes behaviour is as much a consumer of that behaviour as any test, and it has no type system. Worth re-reading whenever the thing it describes changes.

VERIFIED 152 JVM tests green, up from 149. Screenshots captured from a real running build. The frame-rate fix is arithmetic and is not in question — but whether 240 deg/sec and the new response curve actually feel right is explicitly not claimed as verified: they were derived from reading the input path, not from playing against them. Same for whether 0.55× makes level 1 gentle or sluggish.

Done when

  • Steering speed is identical on a 60Hz and a 120Hz display.
  • Every touch on the stick starts from a standstill, wherever it lands.
  • The speed arc reports the same curve the steering applies.
  • Levels 1–8 move slower, ramping to nominal, without spawning fewer enemies.
  • Movement tests assert per-type speed constants; the ramp has tests of its own.
Chapter 18 done

Making it feel like something

Particles, screen shake and real bloom — added without a single new field in GameState. Plus generated levels past 99 and gamepad support.

Where does decorative state live?

The obvious way to add particles is to put a particles: List<Particle> in GameState and advance it in Tick. It would work. It is also the wrong place, for a reason worth being precise about: everything in GameState is load-bearing. Every field is reduced deterministically, asserted in tests, and capable of changing whether the player lives. Debris is none of those things.

Chapter 13 already solved this shape of problem for sound. The reducer doesn't play anything; it emits an effect value naming what happened, and the UI layer decides what that sounds like. Particles are the same problem with a different output device:

/**
 * Something blew up at a place in the tube. The reducer names *what happened and where*, in its
 * own lane/depth coordinates; the UI decides whether that is particles, a flash or nothing at all.
 */
data class Burst(val lane: Int, val depth: Float, val kind: BurstKind) : GameEffect

/**
 * Ask the camera to shake. [intensity] is a 0..1 severity, deliberately not a pixel count:
 * pixels are a rendering decision and the reducer has no business knowing the screen size.
 */
data class Shake(val intensity: Float) : GameEffect

The intensity comment is the load-bearing one. The moment an effect carries pixels, the pure domain layer has quietly acquired an opinion about screen density.

Adding two variants to a sealed interface made GameScreen's effect when non-exhaustive, and the compiler listed exactly the place that needed updating. That is the same property that has caught every missing enemy branch since Chapter 11 — an exhaustive when over a sealed type is a to-do list the compiler maintains for you.

Particles that don't need to be remembered

A burst holds an id, a lane, a depth and an age. It does not hold its shards. Their directions are derived from the id:

fun shard(index: Int): Pair<Offset, Float> {
    val jitter = hashUnit(id * 31 + index)
    val angle = ((index + 0.5f) / shardCount + 0.12f * (jitter - 0.5f)) * TWO_PI
    val speed = 0.65f + 0.35f * hashUnit(id * 17 + index * 7)
    return Offset(cos(angle), sin(angle)) to speed
}

Evenly spread, then jittered — which looks scattered without ever leaving the visible gap that pure randomness produces. More importantly, the only per-frame state is one float, and the renderer stays a pure function of what it is handed. Nothing has to be carried between frames, and nothing can desynchronise.

Bursts project through the same TubeProjection as lanes, spikes, shots and enemies, so debris from a kill at the far end is automatically small and correctly placed up the tube. The single-source-of-truth projection introduced in Chapter 15 keeps paying for itself.

// Ease-out: shards leave fast and coast, which reads as an explosion. Linear reads as a slow
// expanding ring.
val travel = (1f - (1f - t) * (1f - t)) * (laneWidth * 1.6f + minDim * 0.02f)
val alpha = (1f - t) * (1f - t)

Shake the tube, not the screen

Screen shake is a layer transform, and what you put it on decides whether it reads as impact or as a bug:

val shakeModifier = Modifier
    .fillMaxSize()
    .graphicsLayer {
        val offset = fx.shakeOffset(fxElapsedSec, min(size.width, size.height))
        translationX = offset.x
        translationY = offset.y
    }

Only the tube gets it. The HUD, the stick and the buttons stay nailed down. A shaking score readout reads as a rendering glitch; a shaking world reads as being hit. The offset decays on decay² and oscillates on two different frequencies per axis, so it lands as a jolt rather than a circular wobble.

Verified by measurement rather than by eye: across the frames of a real recorded death the tube's centroid moved 15.9px vertically and 21.0px horizontally before settling, against 0.49px / 0.16px over a quiet stretch of the same recording.

Real bloom, without a second renderer

The glow has been layered strokes — wide-and-faint under narrow-and-bright — since Chapter 2, because BlurMaskFilter didn't resolve against this project's Compose version. RenderEffect does exist on API 31+, and the way to use it without forking the renderer is to draw the same scene twice:

val renderTube: @Composable (Modifier) -> Unit = { tubeModifier -> TubeRenderer(/* … */ tubeModifier) }

if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
    renderTube(shakeModifier.graphicsLayer {
        renderEffect = BlurEffect(BLOOM_RADIUS_PX, BLOOM_RADIUS_PX, TileMode.Decal)
        alpha = BLOOM_ALPHA
        compositingStrategy = CompositingStrategy.Offscreen
    })
}
renderTube(shakeModifier)

Hoisting the scene into one lambda is the point. Two hand-written call sites would drift apart the first time an argument was added to one and not the other. Below API 31 nothing changes — the layered-stroke glow is still the whole effect, so this is an enhancement rather than a second code path to keep in sync.

Levels past 99, still without calling Random

The 16 authored geometries cycled forever. Generating shapes past level 99 runs straight into this project's oldest rule — randomness is an input, never a call — so the generator is seeded by the level number and nothing else:

fun randomWeb(level: Int): WebShape {
    val seed = level.toLong() * 2654435761L
    // …three sine harmonics, amplitudes and phase derived from `seed`…
    val corners = polarCorners(72) { theta ->
        val r = 1.0 + ampA * sin(harmonicA * theta + phase) + ampB * sin(harmonicB * theta)
        r.coerceIn(0.42, 1.0)   // never pinch the tube shut or balloon past the play area
    }
    return outlineWeb(corners, STANDARD_LANES, closed = (seed ushr 17) and 0x3L != 0L)
}

Level 137 draws the same tube on every device, every run and every replay of a test. The harmonic family is deliberately narrow: it can produce lobed, pinched and wavy rims that read as genuinely new shapes, but it cannot produce a self-intersecting outline that outlineWeb would sample into nonsense, and the radius clamp guarantees a minimum tube width whatever the coefficients.

The test worth stealing here isn't "does it generate a shape". It's "calling it twice returns the same thing" — the one that fails the instant someone reaches for Random inside a generator.

A second input device, but not a second input path

Gamepad support is small because the intents already existed:

Key.DirectionLeft, Key.A -> { padDirection = if (pressed) -1f else 0f; true }
Key.ButtonA, Key.Spacebar -> { onIntent(if (pressed) FireDown else FireUp); true }
Key.ButtonB, Key.ButtonX  -> { if (pressed) onIntent(ZapperPressed(Random.nextInt())); true }
Key.ButtonStart, Key.P    -> { if (pressed) onIntent(TogglePause); true }

There is no second way into the reducer, so touch and gamepad cannot diverge in behaviour. The one non-obvious part is steering: a held D-pad direction is a velocity, so it is integrated in the game loop at the same MAX_DEG_PER_SEC the stick uses — not stepped per key event. Key auto-repeat rates differ by device, and stepping per event would have reintroduced Chapter 17's bug through a different door.

VERIFIED 162 JVM tests (from 152) and 8 instrumented tests green on the emulator. The visual work was confirmed on a real running build, not asserted: bloom by comparing haloed and crisp captures of the same level, the player-death burst by finding magenta shards radiating from the claw in a recorded frame, and shake by measuring the tube's centroid displacement against a quiet baseline. Difficulty balance remains explicitly unverified — nothing in this chapter changes it.

Done when

  • Kills, deaths and spike hits throw debris, driven by effect values rather than by new fields in GameState.
  • Losing a life shakes the tube and nothing else.
  • API 31+ gets a real blur; older devices are unchanged and still look right.
  • Levels past 99 generate shapes that are a pure function of the level number.
  • A gamepad plays the game through exactly the same intents as the touchscreen.
Chapter 19 done

One unfamiliar phone

Nineteen phases on Pixels and emulators. Ten minutes on a Motorola foldable found four real bugs — and none of them needed a new feature to exist.

The cheapest test in the project

Every phase up to here was verified on Pixel hardware or a Pixel emulator. The first install on a Motorola Razr 50 Ultra — Android 16, a 165Hz panel, and a 2640×1080 inner display — surfaced four genuine bugs almost immediately. Different aspect ratio, different OEM skin, different default system theme. Not one of them was exotic.

1. The menu had entries you could not reach

On a landscape-shaped viewport, SETTINGS and CREDITS were simply off the bottom of the screen. Not clipped-but-scrollable — unreachable, because the hub centred a fixed-height Column in whatever space existed and had no scroll at all.

The instructive part is that every *spoke* screen had scrolled correctly since Chapter 15b, because they all go through one shared ScreenScaffold. The hub was the single screen that didn't use it, so it was the single screen that missed the fix. A shared component is only protective for the screens that actually use it — and a large accessibility font scale reproduces this same bug on an ordinary portrait phone.

Adding the scroll then exposed something that had been latent since Chapter 9:

// before -- fires on ANY drag direction, then uses only the x component
detectDragGestures { change, dragAmount ->
    dragAccumulatorDeg += -dragAmount.x * 0.5f

// after -- the gesture is horizontal, so say so
detectHorizontalDragGestures { change, dragAmount ->
    dragAccumulatorDeg += -dragAmount * 0.5f

Reading only .x from an any-direction detector was harmless for ten chapters because nothing else on that screen wanted vertical drags. The moment a scroll shared the subtree, the two would have fought over every gesture. A handler that ignores half its input is a conflict waiting for a second consumer.

2. Invisible system bars, on half of all phones

MainActivity called enableEdgeToEdge() with no arguments. That defaults to SystemBarStyle.auto, which chooses status- and navigation-bar icon colour from the system's light/dark setting.

This app is deliberately single-theme dark — TempestTheme says so, there is no values-night, and the window background is black no matter what the system is doing. So on any phone in light mode, Android helpfully picked dark icons and drew them on black.

enableEdgeToEdge(
    statusBarStyle = SystemBarStyle.dark(Color.TRANSPARENT),
    navigationBarStyle = SystemBarStyle.dark(Color.TRANSPARENT),
)

auto is right for an app that follows the system theme, and wrong for one that commits to a look. It had been wrong here since Chapter 0 and never showed up, because every device it had ever run on happened to be in dark mode.

3. Portrait, and surviving a fold

The tube is drawn around a vertical vanishing point with the HUD above and the controls below. Landscape squeezes the playfield to nothing. The lock itself is one attribute; the interesting attribute is the other one:

android:screenOrientation="portrait"
android:configChanges="orientation|screenSize|screenLayout|smallestScreenSize|keyboard|keyboardHidden|navigation"

Folding and unfolding changes screenLayout and smallestScreenSize. Without declaring those, Android recreates the Activity — which restarts the game loop and throws away the in-flight GameState, since it is persisted nowhere. Closing a foldable mid-run would have silently ended the run.

Note left in the manifest for whoever bumps targetSdk: from API 36 Android ignores orientation locks on large screens. This targets 35, so the lock holds — but that line is a deadline, not a solution.

4. Held keys that never come up

Chapter 18's gamepad support assumed every key-down is followed by a key-up. Focus makes that false: a notification, a fold, or the initials field grabbing focus the instant the player dies all move focus mid-press, and the key-up is delivered somewhere else.

The claw then spins forever, or the fire button stays held — into the next run, because nothing else clears it.

fun releaseHeldGamepadInput() {
    padDirection = 0f
    if (gamepadFiring) {           // tracked separately from the reducer's own isFiring
        gamepadFiring = false
        viewModel.onIntent(GameIntent.FireUp)
    }
}
// …
.onFocusChanged { if (!it.isFocused) releaseHeldGamepadInput() }
.focusable()

gamepadFiring is tracked separately on purpose. Releasing a stuck pad button must never send a FireUp that cancels a fire the on-screen FIRE button is still holding — the two input devices share intents, but they must not share a *release*.

Prose rots, and it rots silently

Chapter 17 caught the Enemy Codex describing pre-Chapter-16 enemy behaviour. This pass caught the same failure one screen over. HOW TO PLAY told players:

"Watch the lane you're standing on as it warps: a fully grown spike there costs a life."

That penalty was deleted in Chapter 16 precisely because it took a life with no possible response, and replaced with a steerable dive. Worse, the reducer's own step-9 comment still described the deleted behaviour, directly above code that said the opposite:

// No instant spike check here any more: a lethal spike on the player's lane
// is resolved during the dive itself (see the Warping branch), where the
// player still has the chance to steer off it.

Neither the comment nor the help screen failed a test, because prose isn't compiled. The rule that keeps falling out of this project: whenever behaviour changes, grep for the text that describes it — help screens, codex entries and the comment block directly above the code you just edited.

One suspicion that turned out to be nothing

Several screens each construct their own HighScoreDataStoreImpl, which is the classic setup for "There are multiple DataStores active for the same file" — a crash, not a warning. Worth checking, and it was checked:

private val Context.dataStore by preferencesDataStore(name = "tempest_prefs")

The property-delegate form is a per-Context singleton, so every repository instance resolves to the same store. No bug. Recorded here because "I suspected it and verified it was fine" is a real result, and reporting it as a fixed bug would have been a fabrication.

VERIFIED 162 JVM and 8 instrumented tests green. The instrumented suite was re-run specifically because MenuScreen's structure changed and a navigation test reads its text — it passed, so the restructure preserved the contract. Two fixes are confirmed on the Razr itself: the inner display now renders portrait at 1080×2640 instead of landscape, and all five menu entries — including the SETTINGS and CREDITS that were previously off screen — are present and reachable. The remaining fixes are not device-confirmed: the light-mode system-bar fix can only be demonstrated on a phone actually set to light mode, and the stuck-key release needs a gamepad plus a deliberate focus change mid-press.

Done when

  • The app is portrait-only and survives a fold without restarting the run.
  • Every menu entry is reachable on a short viewport and at a large font scale.
  • System bar icons are legible whatever the system theme is set to.
  • Losing focus mid-press cannot leave a direction or the fire button held.
  • Decorative effects are cleared on a level change and frozen while paused.
  • No help text describes behaviour the code no longer has.