23 KiB
Play Games Services Android — Plan d'implémentation
For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (
- [ ]) syntax for tracking.
Goal: Sur Android uniquement, authentification Play Jeux Services v2 + 10 leaderboards (un par nombre de questions) avec un score pondéré, le tout silencieux et optionnel (l'app reste jouable hors connexion).
Architecture: Interface GamesServices dans commonMain injectée dans App() (même pattern que filesDirPath), implémentée par PlayGamesServices dans androidApp avec play-services-games-v2:22.0.0, no-op sur desktop/iOS. Formule du score = round((Σ produits réussis) × 30 ÷ secondsPerQuestion).
Tech Stack: Kotlin Multiplatform 2.4.10, Compose Multiplatform 1.12, Google Play Services Games v2 22.0.0, kotlinx-coroutines-play-services 1.11.0.
Spec : docs/superpowers/specs/2026-08-28-play-games-services-design.md
Global Constraints
- La dépendance GMS reste confinée à
androidApp— sharedUI ne dépend d'aucune API Android/Google. - Version exacte :
com.google.android.gms:play-services-games-v2:22.0.0;org.jetbrains.kotlinx:kotlinx-coroutines-play-services:1.11.0. - Formule du score :
round((Σ produits des questions réussies) × 30 ÷ secondsPerQuestion); 0 bonne réponse → pas de soumission. - Produit d'une question :
Direct(a, b)→a × b;MissingFactor(a, b)→a × b(⚠️correctAnswerd'unMissingFactorest le facteur manquantb, pas le produit). - IDs de boards Play Console (custom) :
questions_5,questions_10, …questions_50. - Tous les échecs GMS sont silencieux : pas d'UI d'erreur, pas de crash, retour
false. - Pas de déconnexion in-app (standard PGS v2, gérée par l'app Play Jeux).
- JVM 17, minSdk 24, compileSdk 37, Kotlin 2.4.10 (cataloge existant).
- Tests :
./gradlew :sharedUI:jvmTest; vérif Android :./gradlew :androidApp:assembleDebug; régression finale :./gradlew build. - Style des commits du dépôt :
feat:/docs:en français.
Task 1: Score leaderboard et ID de board (logique pure, TDD)
Files:
- Modify:
sharedUI/src/commonMain/kotlin/com/livrets/game/Question.kt(ajout extensionproduct) - Create:
sharedUI/src/commonMain/kotlin/com/livrets/games/LeaderboardScore.kt - Create:
sharedUI/src/commonMain/kotlin/com/livrets/games/Leaderboards.kt - Test:
sharedUI/src/commonTest/kotlin/com/livrets/games/LeaderboardScoreTest.kt
Interfaces:
-
Consumes:
GameResult(com.livrets.game.GameResult, champsconfig,records,correctCount),AnswerRecord(question,isCorrect),Question(sealed :Direct(a, b),MissingFactor(a, b)),GameConfig(questionCount). -
Produces:
Question.product: Int,GameResult.leaderboardScore(): Long,GameConfig.leaderboardId(): String— utilisés par les Tasks 3 et 4. -
Step 1: Write the failing test
Créer sharedUI/src/commonTest/kotlin/com/livrets/games/LeaderboardScoreTest.kt :
package com.livrets.games
import com.livrets.game.AnswerRecord
import com.livrets.game.GameConfig
import com.livrets.game.GameResult
import com.livrets.game.Question
import com.livrets.game.product
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.time.Instant
class LeaderboardScoreTest {
private fun result(config: GameConfig, records: List<AnswerRecord>) =
GameResult(date = Instant.parse("2026-08-28T10:00:00Z"), config = config, records = records)
private fun direct(a: Int, b: Int, correct: Boolean) = AnswerRecord(
question = Question.Direct(a, b),
givenAnswer = if (correct) a * b else a * b + 1,
correctAnswer = a * b,
isCorrect = correct,
durationMs = 1000,
)
private fun missing(a: Int, b: Int, correct: Boolean) = AnswerRecord(
question = Question.MissingFactor(a, b),
givenAnswer = if (correct) b else b + 1,
correctAnswer = b,
isCorrect = correct,
durationMs = 1000,
)
@Test
fun `produit d'une question directe et a facteur manquant`() {
assertEquals(56, Question.Direct(7, 8).product)
assertEquals(10, Question.MissingFactor(2, 5).product)
}
@Test
fun `score nul sans bonne reponse`() {
val r = result(GameConfig(5, 5, 12), listOf(direct(7, 8, false), missing(2, 5, false)))
assertEquals(0L, r.leaderboardScore())
}
@Test
fun `score = somme des produits reussis fois le facteur vitesse`() {
val r = result(
GameConfig(10, 5, 12),
listOf(direct(7, 8, true), direct(3, 4, false), missing(2, 5, true)),
)
// (56 + 10) × 30 / 5 = 396
assertEquals(396L, r.leaderboardScore())
}
@Test
fun `toutes les questions reussies`() {
val r = result(
GameConfig(5, 3, 12),
listOf(
direct(4, 6, true),
direct(7, 8, true),
direct(9, 9, true),
direct(2, 12, true),
direct(5, 5, true),
),
)
// (24 + 56 + 81 + 24 + 25) × 30 / 3 = 210 × 10 = 2100
assertEquals(2100L, r.leaderboardScore())
}
@Test
fun `arrondi au point le plus proche`() {
// 8 × 30 / 4 = 60 (exact)
assertEquals(
60L,
result(GameConfig(10, 4, 12), listOf(direct(2, 4, true))).leaderboardScore(),
)
// 7 × 30 / 4 = 52,5 → 53
assertEquals(
53L,
result(GameConfig(10, 4, 12), listOf(direct(7, 1, true))).leaderboardScore(),
)
}
@Test
fun `id de board par nombre de questions`() {
assertEquals("questions_10", GameConfig(10, 5, 12).leaderboardId())
assertEquals("questions_5", GameConfig(5, 3, 1).leaderboardId())
}
}
- Step 2: Run test to verify it fails
Run: ./gradlew :sharedUI:jvmTest --tests "com.livrets.games.LeaderboardScoreTest"
Expected: FAIL — compilation error, product et leaderboardScore/leaderboardId introuvables.
- Step 3: Write minimal implementation
Dans sharedUI/src/commonMain/kotlin/com/livrets/game/Question.kt, ajouter après l'extension correctAnswer :
val Question.product: Int
get() = when (this) {
is Question.Direct -> a * b
is Question.MissingFactor -> a * b
}
Créer sharedUI/src/commonMain/kotlin/com/livrets/games/LeaderboardScore.kt :
package com.livrets.games
import com.livrets.game.GameResult
import kotlin.math.roundToLong
fun GameResult.leaderboardScore(): Long {
if (correctCount == 0) return 0L
val products = records.filter { it.isCorrect }.sumOf { it.question.product }
return (products * 30.0 / config.secondsPerQuestion).roundToLong()
}
Créer sharedUI/src/commonMain/kotlin/com/livrets/games/Leaderboards.kt :
package com.livrets.games
import com.livrets.game.GameConfig
fun GameConfig.leaderboardId(): String = "questions_$questionCount"
- Step 4: Run test to verify it passes
Run: ./gradlew :sharedUI:jvmTest --tests "com.livrets.games.LeaderboardScoreTest"
Expected: PASS (6 tests). Puis régression complète : ./gradlew :sharedUI:jvmTest — PASS.
- Step 5: Commit
git add sharedUI/src/commonMain/kotlin/com/livrets/game/Question.kt \
sharedUI/src/commonMain/kotlin/com/livrets/games/LeaderboardScore.kt \
sharedUI/src/commonMain/kotlin/com/livrets/games/Leaderboards.kt \
sharedUI/src/commonTest/kotlin/com/livrets/games/LeaderboardScoreTest.kt
git commit -m "feat: score leaderboard pondéré et id de board"
Task 2: Interface GamesServices et no-op
Files:
- Create:
sharedUI/src/commonMain/kotlin/com/livrets/games/GamesServices.kt
Interfaces:
-
Consumes: rien.
-
Produces:
interface GamesServices { suspend fun signIn(): Boolean ; suspend fun isSignedIn(): Boolean ; suspend fun submitScore(leaderboardId: String, score: Long): Boolean ; suspend fun showLeaderboard(leaderboardId: String) }etobject NoopGamesServices : GamesServices— utilisés par les Tasks 3 et 4. -
Step 1: Write the interface and the no-op implementation
Créer sharedUI/src/commonMain/kotlin/com/livrets/games/GamesServices.kt :
package com.livrets.games
interface GamesServices {
suspend fun signIn(): Boolean
suspend fun isSignedIn(): Boolean
suspend fun submitScore(leaderboardId: String, score: Long): Boolean
suspend fun showLeaderboard(leaderboardId: String)
}
object NoopGamesServices : GamesServices {
override suspend fun signIn(): Boolean = false
override suspend fun isSignedIn(): Boolean = false
override suspend fun submitScore(leaderboardId: String, score: Long): Boolean = false
override suspend fun showLeaderboard(leaderboardId: String) {}
}
- Step 2: Verify compilation (les 3 cibles consomment commonMain)
Run: ./gradlew :sharedUI:compileKotlinJvm
Expected: BUILD SUCCESSFUL
- Step 3: Commit
git add sharedUI/src/commonMain/kotlin/com/livrets/games/GamesServices.kt
git commit -m "feat: interface GamesServices injectable"
Task 3: Câblage UI partagée (App, Home, Results)
Files:
- Modify:
sharedUI/src/commonMain/kotlin/com/livrets/App.kt - Modify:
sharedUI/src/commonMain/kotlin/com/livrets/ui/HomeScreen.kt - Modify:
sharedUI/src/commonMain/kotlin/com/livrets/ui/ResultsScreen.kt
Interfaces:
-
Consumes:
GamesServices,NoopGamesServices,leaderboardId(),leaderboardScore()(Tasks 1-2). -
Produces:
App(filesDirPath: String, gamesServices: GamesServices = NoopGamesServices, onThemeChanged: …);HomeScreen(onPlay: (GameConfig) -> Unit, onHistory: () -> Unit, onLeaderboard: (GameConfig) -> Unit);ResultsScreeninchangé en signature (afficheresult.leaderboardScore()). -
Step 1: Modifier HomeScreen (bouton Classement)
Dans sharedUI/src/commonMain/kotlin/com/livrets/ui/HomeScreen.kt, remplacer la signature :
@Composable
fun HomeScreen(
onPlay: (GameConfig) -> Unit,
onHistory: () -> Unit,
onLeaderboard: (GameConfig) -> Unit,
) {
et remplacer la fin du Column (bouton Jouer + Historique) par :
Spacer(Modifier.height(32.dp))
Button(
onClick = { onPlay(GameConfig(questionCount, secondsPerQuestion, maxTable)) },
modifier = Modifier.fillMaxWidth().height(56.dp),
) {
Text("Jouer", fontSize = 20.sp)
}
TextButton(onClick = onHistory) { Text("Historique") }
TextButton(
onClick = { onLeaderboard(GameConfig(questionCount, secondsPerQuestion, maxTable)) },
) { Text("Classement") }
- Step 2: Modifier ResultsScreen (ligne points classement)
Dans sharedUI/src/commonMain/kotlin/com/livrets/ui/ResultsScreen.kt, ajouter l'import :
import com.livrets.games.leaderboardScore
et insérer sous le Text « Temps moyen de réponse » (ligne 55) :
Text(
text = "Points classement : ${result.leaderboardScore()}",
style = MaterialTheme.typography.titleMedium,
)
- Step 3: Modifier App.kt (injection, sign-in au lancement, envoi, onLeaderboard)
Remplacer tout le contenu de sharedUI/src/commonMain/kotlin/com/livrets/App.kt par :
package com.livrets
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import com.livrets.data.HistoryRepository
import com.livrets.game.GameConfig
import com.livrets.game.GameResult
import com.livrets.games.GamesServices
import com.livrets.games.NoopGamesServices
import com.livrets.games.leaderboardId
import com.livrets.games.leaderboardScore
import com.livrets.theme.AppTheme
import com.livrets.ui.GameScreen
import com.livrets.ui.HistoryScreen
import com.livrets.ui.HomeScreen
import com.livrets.ui.ResultsScreen
import com.livrets.ui.SwipeBack
import kotlinx.coroutines.launch
import okio.Path.Companion.toPath
sealed interface Screen {
data object Home : Screen
data class Game(val config: GameConfig) : Screen
data class Results(val result: GameResult) : Screen
data object History : Screen
}
@Composable
fun App(
filesDirPath: String,
gamesServices: GamesServices = NoopGamesServices,
onThemeChanged: @Composable (isDark: Boolean) -> Unit = {},
) = AppTheme(onThemeChanged) {
val scope = rememberCoroutineScope()
val history = remember { HistoryRepository("$filesDirPath/history.json".toPath()) }
var screen by remember { mutableStateOf<Screen>(Screen.Home) }
LaunchedEffect(gamesServices) { gamesServices.signIn() }
val homePreview: @Composable () -> Unit = {
HomeScreen(onPlay = {}, onHistory = {}, onLeaderboard = {})
}
when (val current = screen) {
Screen.Home -> HomeScreen(
onPlay = { config -> screen = Screen.Game(config) },
onHistory = { screen = Screen.History },
onLeaderboard = { config ->
scope.launch {
if (gamesServices.isSignedIn() || gamesServices.signIn()) {
gamesServices.showLeaderboard(config.leaderboardId())
}
}
},
)
is Screen.Game -> GameScreen(
config = current.config,
onFinish = { result ->
screen = Screen.Results(result)
scope.launch { history.save(result) }
scope.launch {
val score = result.leaderboardScore()
if (score > 0 && gamesServices.isSignedIn()) {
gamesServices.submitScore(result.config.leaderboardId(), score)
}
}
},
onQuit = { screen = Screen.Home },
)
is Screen.Results -> SwipeBack(
onBack = { screen = Screen.Home },
background = homePreview,
) {
ResultsScreen(
result = current.result,
onReplay = { screen = Screen.Game(current.result.config) },
onHome = { screen = Screen.Home },
)
}
Screen.History -> SwipeBack(
onBack = { screen = Screen.Home },
background = homePreview,
) {
HistoryScreen(
history = history,
onBack = { screen = Screen.Home },
)
}
}
}
- Step 4: Run régression tests + build desktop
Run: ./gradlew :sharedUI:jvmTest
Expected: PASS (aucune régression).
Run: ./gradlew :desktopApp:run
Expected: l'app s'ouvre (no-op : pas d'erreur), bouton « Classement » visible sur l'accueil, ligne « Points classement : X » sur l'écran de résultats. Quitter la fenêtre.
- Step 5: Commit
git add sharedUI/src/commonMain/kotlin/com/livrets/App.kt \
sharedUI/src/commonMain/kotlin/com/livrets/ui/HomeScreen.kt \
sharedUI/src/commonMain/kotlin/com/livrets/ui/ResultsScreen.kt
git commit -m "feat: connexion et envoi du score Play Jeux dans l'UI partagée"
Task 4: Implémentation Android PlayGamesServices
Files:
- Modify:
gradle/libs.versions.toml - Modify:
androidApp/build.gradle.kts - Create:
androidApp/src/main/kotlin/com/livrets/androidApp/PlayGamesServices.kt - Modify:
androidApp/src/main/kotlin/com/livrets/androidApp/AppActivity.kt
Interfaces:
-
Consumes:
GamesServices(Task 2 — mêmes signatures exactes). -
Produces:
PlayGamesServices(activity: Activity, launchIntent: (Intent) -> Unit) : GamesServices— instancié dansAppActivity. -
Step 1: Ajouter les dépendances au catalogue
Dans gradle/libs.versions.toml :
- Dans la section
[versions], ajouter :
gms-games-v2 = "22.0.0"
- Dans la section
[libraries], ajouter :
gms-games-v2 = { module = "com.google.android.gms:play-services-games-v2", version.ref = "gms-games-v2" }
kotlinx-coroutines-play-services = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-play-services", version.ref = "kotlinx-coroutines" }
- Step 2: Déclarer les dépendances dans androidApp
Dans androidApp/build.gradle.kts, remplacer le bloc dependencies par :
dependencies {
implementation(project(":sharedUI"))
implementation(libs.androidx.activityCompose)
implementation(libs.gms.games.v2)
implementation(libs.kotlinx.coroutines.play.services)
}
- Step 3: Écrire PlayGamesServices
Créer androidApp/src/main/kotlin/com/livrets/androidApp/PlayGamesServices.kt :
package com.livrets.androidApp
import android.app.Activity
import android.content.Intent
import com.google.android.gms.games.PlayGames
import com.livrets.games.GamesServices
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.tasks.await
import kotlinx.coroutines.withContext
class PlayGamesServices(
private val activity: Activity,
private val launchIntent: (Intent) -> Unit,
) : GamesServices {
override suspend fun signIn(): Boolean = withContext(Dispatchers.IO) {
runCatching {
PlayGames.getGamesSignInClient(activity).signIn().await().isAuthenticated
}.getOrDefault(false)
}
override suspend fun isSignedIn(): Boolean = withContext(Dispatchers.IO) {
runCatching {
PlayGames.getGamesSignInClient(activity).isAuthenticated().await().isAuthenticated
}.getOrDefault(false)
}
override suspend fun submitScore(leaderboardId: String, score: Long): Boolean =
withContext(Dispatchers.IO) {
runCatching {
PlayGames.getLeaderboardsClient(activity)
.submitScoreImmediate(leaderboardId, score)
.await()
true
}.getOrDefault(false)
}
override suspend fun showLeaderboard(leaderboardId: String) {
val intent = withContext(Dispatchers.IO) {
runCatching {
PlayGames.getLeaderboardsClient(activity)
.getLeaderboardIntent(leaderboardId)
.await()
}.getOrNull()
} ?: return
launchIntent(intent)
}
}
- Step 4: Câbler dans AppActivity
Remplacer tout le contenu de androidApp/src/main/kotlin/com/livrets/androidApp/AppActivity.kt par :
package com.livrets.androidApp
import android.app.Activity
import android.os.Bundle
import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
import androidx.activity.enableEdgeToEdge
import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.ui.platform.LocalView
import androidx.core.view.WindowInsetsControllerCompat
import com.livrets.App
class AppActivity : ComponentActivity() {
private val leaderboardLauncher =
registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { }
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
enableEdgeToEdge()
val gamesServices = PlayGamesServices(this) { intent -> leaderboardLauncher.launch(intent) }
setContent {
App(
filesDirPath = filesDir.absolutePath,
gamesServices = gamesServices,
onThemeChanged = { ThemeChanged(it) },
)
}
}
}
@Composable
private fun ThemeChanged(isDark: Boolean) {
val view = LocalView.current
LaunchedEffect(isDark) {
val window = (view.context as Activity).window
WindowInsetsControllerCompat(window, window.decorView).apply {
isAppearanceLightStatusBars = isDark
isAppearanceLightNavigationBars = isDark
}
}
}
- Step 5: Verify compilation APK debug
Run: ./gradlew :androidApp:assembleDebug
Expected: BUILD SUCCESSFUL (la connexion échouera à l'exécution tant que la console n'est pas configurée — comportement silencieux attendu).
- Step 6: Commit
git add gradle/libs.versions.toml androidApp/build.gradle.kts \
androidApp/src/main/kotlin/com/livrets/androidApp/PlayGamesServices.kt \
androidApp/src/main/kotlin/com/livrets/androidApp/AppActivity.kt
git commit -m "feat: implémentation Play Games Services v2 sur Android"
Task 5: README (prérequis console) + build complet
Files:
- Modify:
README.MD
Interfaces:
-
Consumes: rien.
-
Produces: documentation des prérequis Play Console (spec § 7) et de la checklist de vérification manuelle.
-
Step 1: Documenter les prérequis dans le README
Dans README.MD, ajouter cette section après la section « ### macOS desktop » (avant « ## Tests ») :
## Play Jeux Services (Android)
Connexion silencieuse au lancement, envoi automatique du score en fin de partie et
bouton « Classement » sur l'accueil (UI native Play Jeux). Hors connexion ou sans
configuration console, tout échoue silencieusement : le jeu reste pleinement jouable.
Score soumis : `round((somme des produits réussis) × 30 ÷ secondes par question)`.
Prérequis manuels dans [Google Play Console](https://play.google.com/console) :
1. Créer l'app (package `com.livrets.androidApp`).
2. Jouer → Play Jeux Services : créer le jeu, lier l'app Android, ajouter les
empreintes SHA-1 debug et release.
3. Créer les 10 leaderboards avec les IDs `questions_5` … `questions_50`
(« 5 questions » … « 50 questions »).
4. Ajouter des testeurs Play Jeux Services pour valider la connexion en dev.
- Step 2: Build complet (régression 3 cibles)
Run: ./gradlew build
Expected: BUILD SUCCESSFUL.
- Step 3: Vérification manuelle Android (si appareil + console configurés)
Checklist sur appareil Android avec compte Play (testeur PGS requis) :
./gradlew :androidApp:installDebug
- Lancement : aucune UI de connexion bloquante.
- Jouer une partie → écran résultats : ligne « Points classement » cohérente avec la formule.
- Accueil → « Classement » : l'UI Play Jeux s'ouvre sur le board correspondant au nombre de questions sélectionné.
- Le score apparaît dans le board Play Jeux.
- Sans compte / hors connexion : l'app se comporte comme avant (aucun crash, aucune erreur visible).
Si la console n'est pas configurée, noter le résultat et passer à l'étape suivante (le comportement silencieux fait partie du spec).
- Step 4: Commit
git add README.MD
git commit -m "docs: prérequis Play Console pour Play Jeux Services"