Dice Duel 2 — drawing a die face with Canvas
You draw a real die with no picture files at all — a rounded square and up to six pips placed on a 3×3 grid, computed from whatever size the composable is given, so it stays razor sharp from 88dp to 400dp.
Where we got to, and what is missing
Last time you ended with a violet title, two player panels reading zero, and a large empty space under them. The app installs, it follows dark mode, it has your own icon. It also does nothing.
Today you fill that empty space with a die.
Not a photograph of a die. Not a picture file that somebody drew in an art program. An actual drawing, made by your code, out of one rounded square and up to six circles.
That decision has consequences you will feel all the way to chapter 5:
- It is sharp at any size. The same composable draws the 172dp die in the middle of the board and the 88dp die in the win banner. No blurring, no second file.
- It costs zero kilobytes. A set of six die-face PNGs at four screen densities is twenty-four files. This is one function.
- You can change it. Want square pips? Change one line. With a picture you would be back in an art program.
Imagine describing a die to somebody over the phone, so they can draw it.
You would not read out the colour of every dot on a grid of graph paper. You would say: draw a square with rounded corners, about as wide as your hand. Now imagine a noughts-and-crosses grid on it. Put a dot in the top-left box, the top-right box, the middle, the bottom-left and the bottom-right.
That is a five. It works whether they are drawing on a postcard or a whiteboard, because you described the die in proportions, not in measurements.
A is somebody on the other end of that phone. It hands you a blank rectangle and a set of instructions you can shout at it. Every number you will write in this lesson is a fraction of the rectangle's own size, which is exactly why the die never blurs.
The shape of the file
DieFace.kt is new, and it holds three things:
| What | Job | |
|---|---|---|
DieFace | A [[composable | composable]] — the thing you put on screen |
pipsFor | Answers "where do the dots go for a 4?" | |
DrawScope.drawDie | Does the actual painting |
Only the first is public. The other two are private helpers, because nothing outside this file should ever need them.
Here are the imports. They are worth reading rather than skipping, because half the errors in this lesson are a missing one:
1package com.nativeworks.diceduel
2
3import androidx.compose.foundation.Canvas
4import androidx.compose.foundation.layout.size
5import androidx.compose.foundation.shape.RoundedCornerShape
6import androidx.compose.runtime.Composable
7import androidx.compose.ui.Modifier
8import androidx.compose.ui.draw.shadow
9import androidx.compose.ui.geometry.CornerRadius
10import androidx.compose.ui.geometry.Offset
11import androidx.compose.ui.geometry.Size
12import androidx.compose.ui.graphics.Color
13import androidx.compose.ui.graphics.drawscope.DrawScope
14import androidx.compose.ui.graphics.drawscope.Stroke
15import androidx.compose.ui.tooling.preview.Preview
16import androidx.compose.ui.unit.Dp
17import androidx.compose.ui.unit.dp
18import com.nativeworks.diceduel.ui.theme.DiceDuelTheme
19import com.nativeworks.diceduel.ui.theme.DieFaceLight
20import com.nativeworks.diceduel.ui.theme.DiePipDarkNotice Offset and Size come from ui.geometry, while Color comes from ui.graphics. Two different packages, and Pocket Studio will happily offer you the wrong one if you are not reading. Size in particular has several namesakes.
The composable
Where do the dots go?
A standard die is not six arbitrary patterns. Every face is dots placed on a three-by-three grid, and once you see that, the whole thing collapses into a small lookup:
1 col 0 col 1 col 2
2 +-------+-------+-------+
3row0| • | | • |
4 +-------+-------+-------+
5row1| | • | |
6 +-------+-------+-------+
7row2| • | | • |
8 +-------+-------+-------+
9 a five: 0,0 2,0 1,1 0,2 2,2The painting
Now the part that actually puts colour on the screen.
Add a preview so you can look at it without building the whole app:
1@Preview(showBackground = true)
2@Composable
3private fun DieFacePreview() {
4 DiceDuelTheme {
5 DieFace(value = 5)
6 }
7}Change value = 5 to each number from 1 to 6 in turn and look at the preview. This is the fastest possible way to check a lookup table, and it takes about fifteen seconds. If a face looks mirrored, you have written row to col somewhere instead of col to row.
Putting it on the board
GameScreen.kt needs four new lines inside its Column, and no other change:
1 TitleBlock()
2 Spacer(Modifier.height(26.dp))
3 ScoreBoard(p1 = 0, p2 = 0, turn = 1)
4 Spacer(Modifier.weight(1f))
5 DieFace(value = 5, size = 172.dp)
6 Spacer(Modifier.weight(1f))
7 Text(
8 text = "Tap the die to roll",
9 color = scheme.onSurfaceVariant,
10 fontSize = 15.sp,
11 fontWeight = FontWeight.Medium
12 )The two Spacer(Modifier.weight(1f)) calls are doing something clever and completely invisible. A with a claims a share of whatever space is left after everything with a fixed size has been measured. Two of them, both 1f, split the leftover space exactly in half — so the die floats in the middle of the gap and the hint is pinned to the bottom, on a tall phone and a short one alike.
The alternative — Spacer(Modifier.height(120.dp)) — works perfectly on the phone you are holding and looks wrong on every other one.
"Tap the die to roll" is a lie until chapter 3. Nothing happens when you tap it.
Writing the label before the behaviour is a real technique, not a mistake: the screen now tells you what it is missing every time you look at it. Chapter 3 makes it true, then replaces it with something better.
What you get
End of chapter 2. The die is drawn, not photographed — and it is stuck on five.
In dark mode the board goes from #121022 to #2A2545 and the die stays bright white, which makes it the obvious thing on the screen to touch. That is not an accident — it is why DieFaceLight and DiePipDark were kept outside the in chapter 1.
- Open your Dice Duel project in Pocket Studio. If you skipped chapter 1, download the ZIP at the bottom of that lesson and open it instead.
- In the file tree, tap the folder
app/src/main/java/com/nativeworks/diceduel. - Tap New → Kotlin File and name it
DieFace. Pocket Studio adds thepackageline for you. - Type the imports, then
DieFace,pipsForanddrawDie. Take the imports seriously — if Pocket Studio offers youandroidx.compose.ui.geometry.Size, accept it; if it offersandroid.util.Size, do not. - Add the
@Previewfunction at the bottom and look at it. You should see a five. - Change
DieFace(value = 5)in the preview tovalue = 3, thenvalue = 6. Check each face. Set it back to 5. - Open
GameScreen.kt. Inside theColumn, after theScoreBoard(...)line, add the two weightedSpacers, theDieFacecall and theTexthint exactly as shown. - Tap Run. The die should appear, centred, in the middle of the empty space.
- Change
size = 172.dptosize = 260.dpand Run again. Look closely at the corners and the pips: they are still perfectly proportioned, and not a single pixel is blurred. - Put it back to
172.dp.
private fun drawDie(...) instead of private fun DrawScope.drawDie(...). size, drawRoundRect and drawCircle all belong to DrawScope; outside it, none of them exists.DrawScope. back in front of the function name, and keep import androidx.compose.ui.graphics.drawscope.DrawScope. When one missing word produces a cluster of unresolved references, always look at the line the cluster is inside, not at the errors themselves.Modifier.height(24). Layout sizes are Dp values, never plain Ints, so Kotlin lists both overloads to show that neither takes a number.Modifier.height(24.dp), with import androidx.compose.ui.unit.dp. Same story for size(172) — it wants 172.dp.Text is an ordinary function living in a package. If the import is missing, Kotlin has genuinely never heard of it. The identical message appears for Canvas, Offset and even your own DieFace if the file is in a different package.import androidx.compose.material3.Text. In Pocket Studio, tap the red name and take the import it offers — but read which package it picked before accepting.DieFace(...) or Text(...) from a plain fun. Composables can only be called from other composables — and drawDie is a plain function, on purpose, because drawing commands are not composables.@Composable above the calling function. If you were trying to call DieFace from inside drawDie, that is the wrong idea entirely: drawDie paints pixels, DieFace describes a piece of screen. Keep them separate.pipsFor a pair is written row to col instead of col to row, so the face is mirrored along the diagonal. It is invisible on 1, 4 and 5, and obvious on 2 and 3.2 to 0 is the top-right box. If your 2 runs bottom-left to top-right instead of top-left to bottom-right, the pairs are swapped.Dice Duel — end of chapter 2
A complete project. Unzip it, open it in Pocket Studio, and press Run.
- A is a composable that occupies space and hands you drawing commands. It has no appearance of its own — only what you paint.
- Inside it you are in a , which knows your exact size in pixels and provides
drawRoundRect,drawCircleand the rest. Write your drawing helper asfun DrawScope.drawDie(...)so it can reach them. - Express every measurement as a fraction of the size you were given. That is what makes one
DieFacecorrect at 88dp and at 260dp. - A die face is six patterns on a 3×3 grid, so
pipsForis awhenreturning a of . Awhenused as an expression needselse. style = Stroke(...)outlines a shape instead of filling it — used here for a 10% hairline so a near-white die does not dissolve into a pale background.- Two
Spacers with equal centre the die in whatever room is left, on any phone. - Next: memory. Four pieces of — the die, two scores, whose turn it is — plus
Random.nextInt(1, 7)and a tap listener, and this becomes a game you can actually play.