Dice Duel 3 — rolling, state and randomness
Four remembered values turn a picture into a game. You add the die's face, two scores and whose turn it is, wire a tap straight onto the die, and pick a genuinely random number between one and six.
The chapter where it becomes a game
You have a board and a die. Tap the die and nothing happens, because the screen has no memory — it is a very well-drawn photograph.
Today it gets one. Four values:
die— the number showingp1— player 1's scorep2— player 2's scoreturn— whose go it is
Four Ints. That is the entire game. By the end of this lesson two people can genuinely play Dice Duel, taking turns, watching the scores climb. There is no ending yet and no animation, but the thing works.
Picture two people at a kitchen table with one die and a scrap of paper.
The die shows whatever it last landed on. The paper has two columns of numbers. And there is one more thing nobody writes down but everybody knows: whose turn it is. If you walked away and came back, you would need all three to carry on.
Those three things are the game's . Everything else — the colours, the title, the rounded corners — is scenery. Scenery you can rebuild from nothing; state you cannot.
remember is the scrap of paper. Without it, the numbers would be swept off the table every time anybody blinked.
Two words that change everything
You met these in Part 3. Here they are doing a real job:
var die by remember { mutableStateOf(5) }Three separate ideas are packed into that line.
makes a box holding 5 that Compose watches. Any composable that reads the box gets quietly written down as a reader. Change what is in the box and every one of those readers is re-run. You never call anything to make the screen update; it simply follows.
says: run that block the first time, then hold on to the result. Without it, the box would be built again on every redraw, and the die would snap back to 5 every time anything on the screen changed. A brand new box every frame is the same as no memory at all.
is Kotlin's delegate keyword. It says "when someone reads die, ask the box; when someone writes die, tell the box". Without it you would be typing die.value everywhere. With it, die behaves exactly like a normal Int — which is why p1 += rolled below reads like ordinary arithmetic.
The price of by is two imports that nothing else needs, and forgetting them produces the single most confusing error in all of Compose. It is entry 1 in the Error Doctor. Add all five imports now:
1import androidx.compose.runtime.getValue
2import androidx.compose.runtime.mutableStateOf
3import androidx.compose.runtime.remember
4import androidx.compose.runtime.setValue
5import kotlin.random.RandomThe memory and the rule
Random.nextInt(1, 7) is exclusive at the top end. Random.nextInt(7) — one argument — is different again: it gives 0 to 6, which includes a zero and would let a player roll nothing.
Ranges in Kotlin work the other way round: (1..6).random() includes both ends. Both are correct; just never mix up which style you are using mid-file.
Making the die the button
The die is the only thing worth tapping on this screen, so it should be the button. There is no Button here and there does not need to be one — makes anything tappable.
DieFace.kt gains one import:
import androidx.compose.foundation.clickable...one parameter, and one modifier:
This is doing its job. DieFace holds nothing, decides nothing, and could be dropped into any project. Everything it does is described by what it is given: a number to draw, and someone to tell.
Wiring it up
The body of GameScreen changes from literals to live values:
1 TitleBlock()
2 Spacer(Modifier.height(26.dp))
3 ScoreBoard(p1 = p1, p2 = p2, turn = turn)
4 Spacer(Modifier.weight(1f))
5 DieFace(
6 value = die,
7 size = 172.dp,
8 onRoll = { roll() }
9 )
10 Spacer(Modifier.weight(1f))
11 TurnHint(turn = turn)ScoreBoard(p1 = p1, ...) looks strange the first time. The left p1 is the parameter's name; the right p1 is your state. They are allowed to match, and matching them is the clearest thing to do.
Nothing else in ScoreBoard, PlayerPanel or TitleBlock changes at all. Not one line. That is the payoff for writing them with in chapter 1 — the moment real data existed, they were ready for it.
A hint that tells the truth
"Tap the die to roll" was true but useless. Replace it with something that names the player who is up, in that player's own colour:
What you get
Two people can now play. Tap, the number changes, a score goes up, the ring moves to the other panel, and the bottom line changes colour.
Player 1 has just rolled a 4. The ring and the hint have both moved to player 2.
Three things are worth noticing while you play it:
The number jumps. There is no motion at all — the face is one value, then it is another. It works, and it feels like reading a lookup table rather than throwing something. Chapter 4 is entirely about fixing that.
The scores sail past 30. Nothing is watching for a winner. Play long enough and you will see 84 to 91, which is a fine way to prove that chapter 5 has a job to do.
Rotate the phone and everything resets. remember survives redraws, not a full — Android throws the whole away and builds a new one. rememberSaveable is the fix, and Part 4 covered it. Dice Duel leaves it as remember on purpose: a duel is a two-minute thing, and this way you get to see the difference for yourself rather than read about it.
- Open your Dice Duel project in Pocket Studio, or the chapter 2 ZIP if you need a clean start.
- Open
DieFace.kt. Addimport androidx.compose.foundation.clickablewith the other imports. - Add
onRoll: () -> Unit = {}as the last parameter ofDieFace, afterpipColor. Remember the comma on the line before it. - Add
.clickable { onRoll() }to the modifier chain, after.shadow(14.dp, shape). - Open
GameScreen.kt. Add the five new imports at the top. - Just below
val sky = ..., add the fourvar ... by remember { mutableStateOf(...) }lines. - Below those, add the whole
fun roll() { ... }block. It goes insideGameScreen, before theColumn. - Change
ScoreBoard(p1 = 0, p2 = 0, turn = 1)toScoreBoard(p1 = p1, p2 = p2, turn = turn). - Replace the single-line
DieFace(value = 5, size = 172.dp)with the four-line version that passesvalue = dieandonRoll = { roll() }. - Delete the
Text("Tap the die to roll")block and putTurnHint(turn = turn)in its place. - Add the whole
TurnHintcomposable belowGameScreen. - Tap Run, then tap the die. The number changes, a score climbs, the ring jumps sides.
- Play a full duel against yourself up to about 30. Watch the ripple when you tap — it should stay inside the die's rounded corners.
- As an experiment, change
Random.nextInt(1, 7)toRandom.nextInt(1, 6)and tap twenty times. You will never see a six. Change it back.
var die by remember { ... } needs two extension functions to be in scope, and they are not imported by default. This is the most common Compose error there is, and the message gives no hint that the answer is two import lines.import androidx.compose.runtime.getValue and import androidx.compose.runtime.setValue. If you would rather never see this again, write val die = remember { mutableStateOf(5) } and use die.value everywhere instead — no delegate, no imports.MutableState<Int> with a number — something like if (p1 >= 30) — after declaring it without by. A state box cannot be compared to an Int; only the value inside it can.by, which makes p1 behave as a plain Int, or write p1.value >= 30. Pick one style per file and stick to it.Random lives in kotlin.random and is not imported for you. Worse, the IDE will often offer java.util.Random first — a different class, with a different API, whose nextInt behaves differently.import kotlin.random.Random, then Random.nextInt(1, 7). If your import says java.util, delete it and take the kotlin.random one.val and then tried to change it. Most often it is val die by remember { ... } — with a delegate the compiler cannot fall back on inference, so a state value you write to must be var.var for the four game values and val for rolled, which really is set once per roll. If val rolled is the one complaining, you have assigned to it twice inside roll().if (turn = 1) with one equals sign instead of two. In many languages that quietly compiles and creates a bug you hunt for an hour; Kotlin refuses outright.== compares, = assigns. The condition is if (turn == 1). Assignment inside a condition is never what you want.Dice Duel — end of chapter 3
A complete project. Unzip it, open it in Pocket Studio, and press Run.
- Four
Ints held in —die,p1,p2,turn— are the whole game. var x by remember { mutableStateOf(v) }is three ideas: a box Compose watches (), kept across redraws (), read and written like a plain value ().byneeds thegetValueandsetValueimports, and forgetting them produces a famously unhelpful error..nextInt(1, 7)is inclusive at the bottom and exclusive at the top. Roll once into a and reuse it, or the face and the score will disagree.- turns any composable into a button, with the and the accessibility behaviour included. Put it after
.shadowso the ripple is clipped to the shape. DieFacereports taps through anonRoll: () -> Unitand holds no state of its own, so it can be reused anywhere — including in chapter 5's win banner.- Next: the throw. A counter that only goes up, a random tilt, a nine-frame shuffle inside a
LaunchedEffect, and agraphicsLayerthat spins the die without disturbing a single thing around it.