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
- 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.
- 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.
- 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.
- 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.
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 shipcom.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
BlurMaskFilterfor 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.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 buildsucceeds from a clean checkout — proves the new dependencies resolved, not just that Android Studio's cache is happy.
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.
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.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.kthas zeroandroid.*imports — check by reading the import block, not by assuming.
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.
Stack shaping, and why popUpTo(Menu){inclusive=true} matters later
| Transition | Options |
|---|---|
| Splash → Menu | popUpTo(Splash){inclusive=true} |
| Menu → Game | launchSingleTop=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()
}
}
}
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.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 withstartLevelpassed through — confirmed on-device viaGameScreen'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 activitiesshowing the task govisible=false, not a blank screen). grep -rl "NavController" presentation/outsidepresentation/nav/returns nothing.
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.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") }
}
}
./gradlew build.
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 buildpasses, confirmed with a real on-device screenshot, not just a green terminal.
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,
)
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 lane0minus one step wraps to lane15instead of lane-1.- Open-web boundaries reset the accumulator, they don't just stop.
Without the
accumulator = 0fon 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 aWebShapecomes 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.
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.
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.
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)
}
}
}
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?
./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.
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 buildpasses clean — including lint, with the one tool-crash workaround explained inline inbuild.gradle.kts.
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:
- Move existing projectiles (unchanged from Chapter 5).
- Move existing enemies: climb by
FLIPPER_SPEED * dt, clamped at1f; flip toward the player if near the rim and off-cooldown. - Spawn a new projectile if firing and off-cooldown (Chapter 5's logic, now reading from the already-moved projectile list).
- Spawn a new enemy if the spawn timer expired — spawn lane cycles deterministically
(
0, 1, 2, ..., wrapping), no RNG here either. - Resolve collisions: same lane, depths within
0.08f→ both removed,+150score. Each projectile can only destroy one enemy in a given tick (a consumed-id set prevents double-kills). - Resolve captures: any surviving enemy at
depth >= 1fon the player's exact lane costs a life and is removed;liveshitting0setsstatus = 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.
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.
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.
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).
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
}
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.
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).
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.
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.
./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
Warpingwith 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
GameOverpre-emptsWarpingif 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.
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.
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.
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.
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.
RequestExitToMenutriggerspopUpTo(Menu){inclusive=true}; back-stack verified clean via a real system-back press exiting the app, not un-dying the last run.Tickis gated onstatus == Playingfor 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).GameScreenstill never referencesNavController— only a plainonExitToMenucallback crosses the boundary, wired exclusively byTempestNavHost.- 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.
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.
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
randomSeedfor an exact-match assertion. - Third+ press is a no-op — unit-tested.
- Charges reset to 2 on both
StartGameand 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).
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
(InkDim → Amber → Cyan) 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.
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.
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 capturewhenare 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).
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.
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.
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.ktat all this phase. - All 106 tests pass across `GameReducerTest` (100) and the new `LevelFactoryTest` (6).
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" />
Done when
- Every
GameEffectvariant produces a real sound/haptic call, not a no-op — confirmed by code review of the 1:1 mapping inSoundEngine/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.kthas 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).
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 · maxRoutward whileangle = 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.
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).
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.
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.
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
GameStateinstead 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.
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.
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:
| Display | Rotation | On 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.
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.
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.
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.
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.
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.