Dice Duel 1 — project setup and theme
The first sitting of your first real app. You build the project from its foundations, choose a colour palette that will carry the whole game, draw a launcher icon out of shapes, and get a real APK with your own icon onto your phone.
Five sittings, one game
Everything up to now has been one idea at a time. A lesson on state. A lesson on . A lesson on animation. All of it real, none of it joined up.
Dice Duel joins it up. Two players share one phone. They take turns tapping a die. It tumbles, lands on a number, and that number is added to their score. First to 30 wins.
It takes five sittings:
| Chapter | What you add |
|---|---|
| 1 (this one) | The project, the palette, the icon, a screen you can see |
| 2 | A die drawn by hand with Canvas |
| 3 | Rolling, scoring, taking turns |
| 4 | The throw animation |
| 5 | Winning, a banner, and the last 10% of polish |
Each chapter ends at a working you can install and show someone. That matters more than it sounds. An app that builds is a place you can stand.
This chapter builds nothing interactive at all. That is deliberate. Getting a real, signed, installable app with your own icon onto a real phone is the genuinely hard part of day one — harder than any game logic. Do it first, while you have the patience for it.
Think about setting up a board game you have never played.
Before anyone rolls anything, somebody unfolds the board, puts the box lid where people can see the picture, and lays out the two player mats. Nothing has happened yet. Nobody has scored. But now the game exists on the table, and everyone can see where their bit goes.
That is chapter 1. The board, the lid, and two player mats reading zero. The dice come out of the box next time.
The bones of a project
A is a folder with a particular shape. reads that shape and turns it into an app. Here is Dice Duel's, with the files you will actually open marked:
1dice-duel/
2 settings.gradle.kts <- which modules exist
3 build.gradle.kts <- top-level plugin list
4 gradle.properties <- build settings
5 gradle/
6 libs.versions.toml <- every library and version
7 wrapper/ <- pins the Gradle version
8 app/
9 build.gradle.kts <- how the app is built
10 src/main/
11 AndroidManifest.xml <- the app's ID card
12 res/ <- icon, strings, window theme
13 java/com/nativeworks/diceduel/
14 MainActivity.kt
15 GameScreen.kt
16 ui/theme/Color.kt
17 ui/theme/Theme.ktFour Kotlin files. That is the whole app by the end of chapter 5 — five, once the die gets its own file. Everything else is scaffolding you set up once and stop thinking about.
settings.gradle.kts
1// Which repositories Gradle may download from, and which
2// modules make up the build. Dice Duel has exactly one.
3pluginManagement {
4 repositories {
5 google()
6 mavenCentral()
7 gradlePluginPortal()
8 }
9}
10
11dependencyResolutionManagement {
12 repositories {
13 google()
14 mavenCentral()
15 }
16}
17
18rootProject.name = "Dice Duel"
19include(":app")google() and mavenCentral() are — public warehouses of libraries. include(":app") says this build contains one module. Big apps have many; Dice Duel has one, forever.
The root build file
1// Top-level build file. The plugins are named here but
2// switched off with "apply false"; the app module is the
3// one that actually turns them on.
4plugins {
5 alias(libs.plugins.android.application)
6 alias(libs.plugins.kotlin.android)
7 alias(libs.plugins.kotlin.compose)
8}Three , all declared apply false. This file's only job is to say "these exist, at these versions". The module that needs them switches them on.
gradle.properties
1org.gradle.jvmargs=-Xmx2048m -Dfile.encoding=UTF-8
2android.useAndroidX=true
3android.nonTransitiveRClass=true
4kotlin.code.style=officialThe first line is the one that matters on a phone: it caps the build at 2 GB of memory. Leave it alone unless a build actually runs out.
The version catalog
Every library and version lives in one file, and nowhere else:
1[versions]
2agp = "8.7.3"
3kotlin = "2.1.0"
4composeBom = "2024.12.01"
5coreKtx = "1.15.0"
6lifecycle = "2.8.7"
7activityCompose = "1.9.3"
8
9[plugins]
10android-application = { id = "com.android.application", version.ref = "agp" }
11kotlin-android = { id = "org.jetbrains.kotlin.android", version.ref = "kotlin" }
12kotlin-compose = { id = "org.jetbrains.kotlin.plugin.compose", version.ref = "kotlin" }This is the one file in the whole course whose lines run past 60 characters. TOML will not let a { ... } entry be split across lines, so it wraps in the editor and there is nothing to be done about it. It is also the file you are least likely to retype — the template generates most of it, and the download at the bottom of this lesson has the rest.
The turns dashes into dots when you use it. The library entry androidx-ui-tooling-preview is written libs.androidx.ui.tooling.preview in a build file. Get that wrong and Gradle tells you Unresolved reference — it is in the Error Doctor below.
The app's build file
This is the one you will read most often.
The manifest
1<?xml version="1.0" encoding="utf-8"?>
2<manifest
3xmlns:android="http://schemas.android.com/apk/res/android">
4
5 <application
6 android:allowBackup="true"
7 android:icon="@mipmap/ic_launcher"
8 android:label="@string/app_name"
9 android:roundIcon="@mipmap/ic_launcher_round"
10 android:supportsRtl="true"
11 android:theme="@style/Theme.DiceDuel">
12
13 <activity
14 android:name=".MainActivity"
15 android:exported="true"
16 android:label="@string/app_name"
17 android:theme="@style/Theme.DiceDuel">
18
19 <intent-filter>
20 <action
21 android:name="android.intent.action.MAIN" />
22 <category
23 android:name="android.intent.category.LAUNCHER" />
24 </intent-filter>
25 </activity>
26 </application>
27
28</manifest>Every @mipmap/..., @string/... and @style/... in the is a promise that a file or entry with that exact name exists. Spell one wrong and the build fails at resource linking, not at compile time — a different error, from a different tool, with a different vocabulary. Error Doctor, entry 4.
The is what puts the icon on the home screen. MAIN plus LAUNCHER means "this is a thing a person starts". Without it, the app installs and is completely unreachable.
The two res/values/themes.xml files (and their -night twin) set the colour Android paints behind your app in the fraction of a second before Compose takes over. Get it wrong and every cold start flashes white:
1<?xml version="1.0" encoding="utf-8"?>
2<resources>
3
4 <!-- The window theme. Compose paints everything the
5 user sees, so this only needs to remove the old
6 action bar and set the colour behind the app
7 while it starts. -->
8 <style
9 name="Theme.DiceDuel"
10 parent="android:Theme.Material.Light.NoActionBar">
11 <item name="android:windowBackground">#FFF7F4FF</item>
12 </style>
13
14</resources>The values-night copy is identical except for android:Theme.Material.NoActionBar and #FF121022. Android picks the folder; you never write an if.
The palette
Now the fun part. Every colour in the game is decided in one file, once.
1package com.nativeworks.diceduel.ui.theme
2
3import androidx.compose.ui.graphics.Color
4
5// The Dice Duel palette.
6//
7// Violet belongs to the game itself: the title, the
8// buttons, the pips. Rose belongs to player one and
9// teal belongs to player two, so the two sides never
10// get confused at a glance.
11//
12// Every colour is written as 0xAARRGGBB: alpha first,
13// then red, green and blue.
14
15val Violet = Color(0xFF5B3FD6)
16val VioletLight = Color(0xFFC3B2FF)
17val VioletPale = Color(0xFFE4DDFF)
18val VioletDeep = Color(0xFF422F9E)
19val VioletInk = Color(0xFF231152)
20
21val Rose = Color(0xFFD6355F)
22val RosePop = Color(0xFFFF87A6)
23val RosePale = Color(0xFFFFD9E2)
24val RoseDeep = Color(0xFF5C1730)
25val RoseInk = Color(0xFF5F0F27)
26
27val Teal = Color(0xFF0E7C6B)
28val TealPop = Color(0xFF5EE3CB)
29val TealPale = Color(0xFFB7F1E6)
30val TealDeep = Color(0xFF0B4A41)
31val TealInk = Color(0xFF00352C)
32
33val DayBg = Color(0xFFF7F4FF)
34val DayCard = Color(0xFFFFFFFF)
35val DayTint = Color(0xFFEBE5FA)
36val DayText = Color(0xFF1B1730)
37val DayMuted = Color(0xFF4A4463)
38val DayLine = Color(0xFFCFC6E8)
39
40val NightBg = Color(0xFF121022)
41val NightCard = Color(0xFF1C1930)
42val NightTint = Color(0xFF2A2545)
43val NightText = Color(0xFFEDE9FF)
44val NightMuted = Color(0xFFC6BFE0)
45val NightLine = Color(0xFF4A4368)
46
47// A real die is white with dark pips, in any light.
48val DieFaceLight = Color(0xFFFDFBFF)
49val DiePipDark = Color(0xFF241A4D)Each colour is one number: 0xFF5B3FD6 is FF alpha (fully solid), 5B red, 3F green, D6 blue. The 0x prefix means "read the rest as hexadecimal".
The naming is doing real work. Rose is player one. Teal is player two. Violet is the game's own furniture — the title, the buttons, the on the die. Once a player has a colour, you never have to write the word "player 1" on a progress bar or a win banner; the colour says it.
The last two are outside the scheme on purpose. A real die is white with dark dots in any light, so DieFaceLight and DiePipDark do not flip in dark mode.
Wiring the palette into Material
does not want a bag of colours. It wants two with named slots, and then every component helps itself.
DarkColors is the same twenty lines with the night half of the palette:
1private val DarkColors = darkColorScheme(
2 primary = VioletLight,
3 onPrimary = VioletInk,
4 primaryContainer = VioletDeep,
5 onPrimaryContainer = VioletPale,
6 secondary = RosePop,
7 onSecondary = RoseInk,
8 secondaryContainer = RoseDeep,
9 onSecondaryContainer = RosePale,
10 tertiary = TealPop,
11 onTertiary = TealInk,
12 tertiaryContainer = TealDeep,
13 onTertiaryContainer = TealPale,
14 background = NightBg,
15 onBackground = NightText,
16 surface = NightCard,
17 onSurface = NightText,
18 surfaceVariant = NightTint,
19 onSurfaceVariant = NightMuted,
20 outline = NightLine
21)Notice it is not an inversion. Violet does not become anti-violet; it becomes a lighter violet, because a dark screen needs bright accents and a light screen needs deep ones. That is the whole trick of a good .
Then one composable picks between them:
1@Composable
2fun DiceDuelTheme(
3 darkTheme: Boolean = isSystemInDarkTheme(),
4 content: @Composable () -> Unit
5) {
6 val colors = if (darkTheme) DarkColors else LightColors
7 MaterialTheme(
8 colorScheme = colors,
9 content = content
10 )
11}isSystemInDarkTheme() reads the phone's setting, and because it is a default you can still force either mode when you want one — handy for a @Preview. content is everything wrapped inside; that is what makes DiceDuelTheme { ... } work.
The Activity
1package com.nativeworks.diceduel
2
3import android.os.Bundle
4import androidx.activity.ComponentActivity
5import androidx.activity.compose.setContent
6import com.nativeworks.diceduel.ui.theme.DiceDuelTheme
7
8// The single Activity. Its only job is to hand the
9// screen over to Compose.
10class MainActivity : ComponentActivity() {
11 override fun onCreate(state: Bundle?) {
12 super.onCreate(state)
13 setContent {
14 DiceDuelTheme {
15 GameScreen()
16 }
17 }
18 }
19}Nineteen lines, and this file will not change again for the rest of Dice Duel. One , one theme, one screen.
The screen
TitleBlock is two Text calls and a :
1// The name of the game, plus the one rule you need.
2@Composable
3private fun TitleBlock() {
4 val scheme = MaterialTheme.colorScheme
5 Text(
6 text = "DICE DUEL",
7 color = scheme.primary,
8 fontSize = 40.sp,
9 fontWeight = FontWeight.Black,
10 letterSpacing = 6.sp
11 )
12 Spacer(Modifier.height(2.dp))
13 Text(
14 text = "First to 30 wins the duel",
15 color = scheme.onSurfaceVariant,
16 fontSize = 15.sp,
17 fontWeight = FontWeight.Medium,
18 letterSpacing = 1.sp
19 )
20}letterSpacing = 6.sp is most of what makes that title look designed rather than typed. Heavy weight plus wide spacing reads as a logo. Try it at 0.sp once, just to see.
ScoreBoard puts two panels in a and shares the width equally:
1// Two panels side by side, one per player.
2@Composable
3private fun ScoreBoard(p1: Int, p2: Int, turn: Int) {
4 val scheme = MaterialTheme.colorScheme
5 Row(
6 modifier = Modifier.fillMaxWidth(),
7 horizontalArrangement = Arrangement.spacedBy(14.dp)
8 ) {
9 PlayerPanel(
10 name = "PLAYER 1",
11 score = p1,
12 accent = scheme.secondary,
13 panel = scheme.secondaryContainer,
14 label = scheme.onSecondaryContainer,
15 active = turn == 1,
16 modifier = Modifier.weight(1f)
17 )
18 PlayerPanel(
19 name = "PLAYER 2",
20 score = p2,
21 accent = scheme.tertiary,
22 panel = scheme.tertiaryContainer,
23 label = scheme.onTertiaryContainer,
24 active = turn == 2,
25 modifier = Modifier.weight(1f)
26 )
27 }
28}Both panels get Modifier.weight(1f), so they split whatever is left after the 14dp gutter. Equal mean equal widths, no matter how wide the phone is.
And the panel itself — one composable, drawn twice, in two different colours:
An icon made of shapes
No PNG files anywhere in this project — including the launcher icon. It is an : a background layer and a foreground layer, which the launcher masks into whatever shape the phone uses.
The background is one colour:
1<?xml version="1.0" encoding="utf-8"?>
2<resources>
3 <color name="ic_launcher_background">#FF5B3FD6</color>
4</resources>The foreground is a — a white die, tilted 14 degrees, showing a five, with a soft shadow behind it. Three paths, all written as :
1<?xml version="1.0" encoding="utf-8"?>
2<vector
3xmlns:android="http://schemas.android.com/apk/res/android"
4 android:width="108dp"
5 android:height="108dp"
6 android:viewportWidth="108"
7 android:viewportHeight="108">
8
9 <!-- One white die, tilted, showing a five. Drawn with
10 vector paths so it stays sharp on every screen and
11 adds no PNG files to the app. -->
12 <group
13 android:pivotX="54"
14 android:pivotY="54"
15 android:rotation="-14">
16
17 <!-- Soft shadow, the same shape nudged down. -->
18 <group
19 android:translateX="2"
20 android:translateY="4">
21 <path
22 android:fillColor="#33150C3F"
23 android:pathData="M40,30 H68 A10,10 0 0 1 78,40
24 V68 A10,10 0 0 1 68,78 H40 A10,10 0 0 1 30,68
25 V40 A10,10 0 0 1 40,30 Z" />
26 </group>
27
28 <!-- The die face. -->
29 <path
30 android:fillColor="#FFFDFBFF"
31 android:pathData="M40,30 H68 A10,10 0 0 1 78,40
32 V68 A10,10 0 0 1 68,78 H40 A10,10 0 0 1 30,68
33 V40 A10,10 0 0 1 40,30 Z" />
34
35 <!-- Five pips, all in one path. -->
36 <path
37 android:fillColor="#FF241A4D"
38 android:pathData="M36.5,42 a5.5,5.5 0 1,0 11,0
39 a5.5,5.5 0 1,0 -11,0 Z
40 M60.5,42 a5.5,5.5 0 1,0 11,0
41 a5.5,5.5 0 1,0 -11,0 Z
42 M48.5,54 a5.5,5.5 0 1,0 11,0
43 a5.5,5.5 0 1,0 -11,0 Z
44 M36.5,66 a5.5,5.5 0 1,0 11,0
45 a5.5,5.5 0 1,0 -11,0 Z
46 M60.5,66 a5.5,5.5 0 1,0 11,0
47 a5.5,5.5 0 1,0 -11,0 Z" />
48 </group>
49</vector>Read the die-face path once and it stops being frightening. M40,30 moves the pen to x 40, y 30. H68 draws a horizontal line to x 68. A10,10 0 0 1 78,40 draws an arc of radius 10 — that is a rounded corner. Four lines and four arcs, then Z closes the loop. The shadow is the identical path in translucent dark violet, nudged 2 right and 4 down.
The <group> wrapping everything rotates the lot by -14 degrees around the centre point 54,54. Rotating a group is far easier than working out five rotated pip positions by hand.
Both mipmap-anydpi-v26/ic_launcher.xml and ic_launcher_round.xml are the same three lines:
1<?xml version="1.0" encoding="utf-8"?>
2<adaptive-icon
3xmlns:android="http://schemas.android.com/apk/res/android">
4 <background
5 android:drawable="@color/ic_launcher_background" />
6 <foreground
7 android:drawable="@drawable/ic_launcher_foreground" />
8 <monochrome
9 android:drawable="@drawable/ic_launcher_foreground" />
10</adaptive-icon>The third layer is the one, which Android 13 and newer uses for themed icons — your die, tinted to match the user's wallpaper. Reusing the foreground for it is free and takes one line.
What you get
End of chapter 1. Nothing is interactive — and that is the point.
Player 1's panel has the rose ring because turn is 1. Player 2's ring is there too — it is just transparent. Everything below the panels is empty, and that gap is exactly where the die goes next time.
- Open Pocket Studio and tap Projects, then New Project.
- Choose the Empty Compose Activity template.
- Set the name to
Dice Dueland the package tocom.nativeworks.diceduel. Tap Create. - Wait for the first . It downloads Gradle and the Compose libraries, so it is the slow one — several minutes on a first run.
- Open
app/build.gradle.ktsand make it match the file in this lesson. CheckminSdkis 26 and that all threealias(...)plugin lines are present. - Open
gradle/libs.versions.tomland check every version matches the catalog above. - In the file tree, long-press the
uifolder — orjava/com/nativeworks/diceduelif there is nouifolder yet — and tap New → Package. Name ittheme. - In
ui/theme, tap New → Kotlin File twice. Name themColorandTheme. Type the two files from this lesson into them. - Open
MainActivity.kt. Delete whatever the template generated belowonCreateand replace the body with the version above. - Tap New → Kotlin File beside
MainActivity.kt, name itGameScreen, and typeGameScreen.kt. Accept every import Pocket Studio offers. - Open
app/src/main/resand add the four resource files:values/strings.xml,values/themes.xml,values-night/themes.xml,values/ic_launcher_background.xml. - Add
drawable/ic_launcher_foreground.xmland the twomipmap-anydpi-v26files. Delete anyic_launcherPNGs the template created, in everymipmap-*folder. - Open
AndroidManifest.xmland make it match. - Tap Run. When the build finishes, accept the install prompt.
- Press Home and find Dice Duel on your home screen. Your violet die icon should be there.
- Switch your phone to dark mode and open the app again. The whole screen should change.
:app:compileDebugJavaWithJavac depends on.ANDROID_HOME for you, so this usually means the project was copied from a computer. Do not create a local.properties file to fix it — that hard-codes one machine's folder path and breaks the project everywhere else. Delete local.properties if it exists and rebuild.buildFeatures { compose = true } but did not add the plugin that actually compiles @Composable functions. Turning the feature on is only half of it.alias(libs.plugins.kotlin.compose) to app/build.gradle.kts, and the matching line with apply false in the root build.gradle.kts. Both files, or it will not resolve.libs. names are generated from gradle/libs.versions.toml. There is no androidx-material entry in the catalog, so there is no libs.androidx.material to use.libs.androidx.material3. Remember that dashes in the catalog become dots in Kotlin — androidx-ui-tooling-preview is written libs.androidx.ui.tooling.preview.@mipmap/..., @string/... and @style/... in the manifest must match a real file or entry, spelled exactly. This error comes from AAPT, the resource compiler, not from Kotlin — which is why it looks so different from the errors you are used to.ic_launchr is missing an e; the file is res/mipmap-anydpi-v26/ic_launcher.xml. The same error for string/app_name means res/values/strings.xml is missing or does not contain that entry.fontSize = 40. Compose measures text in TextUnit and layout in Dp, never in bare numbers, so Kotlin dumps every Text overload to show that none of them accepts an Int there.fontSize = 40.sp and add import androidx.compose.ui.unit.sp. The same rule catches height(26) — that one wants 26.dp.Dice Duel — end of chapter 1
A complete project. Unzip it, open it in Pocket Studio, and press Run.
- A Dice Duel is four Kotlin files plus scaffolding you set up once:
settings.gradle.kts, the two build files, the , the and a handful of resources. - Eight , one of them a that pins all of Compose together. Fewer dependencies means faster builds, and on a phone you notice.
- The palette lives in one file as numbers, and is wired into two Material 3 . Player 1 is the
secondaryslot, player 2 istertiary, and the game's own furniture isprimary— so no game code ever names a colour. - Modifier order is not decoration.
fillMaxSizebeforebackground;clipbeforebackgroundbeforeborder; insets before your own padding. - The launcher icon is an built from a — three paths and a rotated group, no image files at all.
- Next: the die. You will draw a real one on a — a rounded square and up to six placed on a 3×3 grid, sharp at any size, costing the app zero kilobytes.