- Mobile
- TypeScript
- Tests
- iOS
Un jeu en C# et son prototype TypeScript doivent tirer les mêmes pièces : RNG transposé, graine du jour, tests miroirs, et un refus App Store évitable.
Stakko est un jeu de blocs pour mobile : une grille de 8×8, trois pièces à placer, des lignes qui s'effacent. Il existe en deux implémentations. La première est un prototype Phaser en TypeScript, jouable dans le navigateur. La seconde est l'application Unity en C#, publiée sur l'App Store. Les deux doivent donner exactement le même résultat, à la décimale près, pour la même graine.
Ce n'est pas un caprice. Le jeu propose un défi du jour identique pour tous, et des duels : un joueur termine une manche, envoie un code, et son adversaire rejoue les mêmes pièces sur un autre appareil, éventuellement sur le web. Si les deux moteurs divergent d'un seul tirage, le duel ne rejoue plus la même partie.
Le prototype comme spécification exécutable
Le projet a commencé en Phaser. Le jour même, le passage à Unity a été décidé pour la qualité du rendu mobile, mais le prototype n'a pas été jeté. Il est devenu la spécification du jeu : un dossier web-reference/src/core qui contient toutes les règles, sans une ligne de rendu, et que le cœur C# reproduit fichier par fichier.
On y trouve le plateau, les 29 formes de pièces, le générateur des fournées, le calcul du score, le robot adverse, l'encodage des duels, le rejeu, le défi du jour et même la politique d'affichage des publicités. La règle d'équipe est écrite dans le dépôt : toute modification d'une règle se fait des deux côtés, et se prouve par un test des deux côtés.
Pourquoi ne pas partager un seul code ? Parce qu'il n'y a pas de langage commun raisonnable entre un navigateur et un moteur Unity. Deux implémentations sont un coût réel. En contrepartie, la version TypeScript se teste en quelques secondes, sans éditeur, et sert de banc d'essai pour toute nouvelle règle avant qu'elle ne touche le jeu.
Côté C#, le cœur n'importe pas UnityEngine. Les mêmes fichiers .cs compilent dans un petit projet .NET 8 indépendant, avec NUnit, que la CI lance sans ouvrir Unity. Un job compare aussi la copie de travail et la copie intégrée au projet Unity, pour qu'elles ne dérivent pas.
Le générateur pseudo-aléatoire, ligne à ligne
Tout part du générateur. J'ai choisi mulberry32 : un état de 32 bits, quelques opérations, une qualité largement suffisante pour tirer des pièces. Surtout, il se transpose à l'identique. Voici les deux versions côte à côte :
// TypeScript
export function createSeededRng(seed: number): Rng {
let state = seed >>> 0;
return {
next() {
state = (state + 0x6d2b79f5) >>> 0;
let t = state;
t = Math.imul(t ^ (t >>> 15), t | 1);
t ^= t + Math.imul(t ^ (t >>> 7), t | 61);
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
},
};
}// C#
public double Next()
{
_state = unchecked(_state + 0x6d2b79f5u);
uint t = _state;
t = unchecked((t ^ (t >> 15)) * (t | 1u));
t ^= unchecked(t + ((t ^ (t >> 7)) * (t | 61u)));
return (t ^ (t >> 14)) / 4294967296.0;
}La traduction repose sur trois correspondances qu'il faut connaître :
- en JavaScript, les nombres sont des flottants ;
>>> 0force un entier non signé de 32 bits, ce que fait naturellement le typeuinten C# ; - une multiplication JavaScript classique perd de la précision au-delà de 253 ;
Math.imulfait la multiplication entière 32 bits tronquée, équivalente à une multiplicationuinten contexteunchecked; >>>(décalage non signé) correspond à>>appliqué à unuint.
La graine du jour
Le défi du jour tire sa graine d'un hachage FNV-1a 32 bits appliqué à la date au format AAAA-MM-JJ. Le calcul est trivial. Le piège ne l'était pas.
La première version C# formatait la date avec date.ToString("yyyy-MM-dd"). Ce formatage dépend de la culture courante de l'appareil : selon la langue et le calendrier configurés, la chaîne produite n'est pas forcément l'année grégorienne attendue. La graine change, et le défi du jour n'est plus le même pour tout le monde. Le correctif tient en un argument :
date.ToString("yyyy-MM-dd", CultureInfo.InvariantCulture)Le test qui le protège force une culture arabe (ar-SA) et exige la chaîne 2026-09-10. Un détail à assumer : la date utilisée est la date locale de l'appareil, des deux côtés, et non l'UTC. Deux joueurs dans des fuseaux différents peuvent donc avoir des défis différents pendant quelques heures. C'est un choix de produit, cohérent avec l'idée d'un défi « de votre journée ».
Le même commit a corrigé un second écart, plus subtil. Le robot adverse trie les coups candidats par valeur. En JavaScript, Array.prototype.sort est stable : deux coups de même valeur gardent leur ordre. En C#, List.Sort ne l'est pas. À égalité de valeur, les deux robots pouvaient donc jouer des coups différents. Le C# utilise désormais OrderByDescending, qui est stable.
Des tests miroirs, pas des vecteurs partagés
Il n'y a pas de fichier de vecteurs de test commun aux deux projets. Les valeurs attendues sont écrites en dur dans chaque suite, avec un commentaire qui renvoie à la suite jumelle. C'est volontairement simple : chaque suite reste lisible seule, et un changement de valeur d'un côté fait échouer l'autre.
Le test de parité le plus fort ne vérifie pas le générateur isolément : il fait jouer le robot une partie entière à partir de trois graines, et fige le score obtenu.
// parity.test.ts ↔ CrossPlatformParityTests.cs
// graine robot par défaut robot optimal
// 1 349 379
// 4242 227 262
// 123456789 224 229Un score de partie complète dépend de tout : le générateur, le tirage pondéré des pièces, la détection des lignes, le score, les combos, le tri des coups. Si une seule de ces briques dérive, le score change. Côté C#, les premières valeurs du générateur pour une graine donnée et la graine du jour pour une date donnée sont aussi figées, avec une tolérance de 10-12. Le défi du jour est vérifié sur cinq dates de référence, de part et d'autre d'un changement d'année.
Le code de duel montre pourquoi cette rigueur est nécessaire. Il ne transporte que la graine et les coups joués, un octet par coup :
export function encodeRun(seed: number, moves: readonly Move[]): string {
const bytes = new Uint8Array(4 + moves.length);
bytes[0] = (seed >>> 24) & 0xff;
bytes[1] = (seed >>> 16) & 0xff;
bytes[2] = (seed >>> 8) & 0xff;
bytes[3] = seed & 0xff;
moves.forEach((m, i) => {
bytes[4 + i] = ((m.trayIndex & 0x3) << 6) | ((m.origin.col & 0x7) << 3) | (m.origin.row & 0x7);
});
return RUN_CODE_PREFIX + toBase64Url(bytes);
}Les pièces ne sont pas transmises : elles sont régénérées par l'appareil qui rejoue. Le rejeu refuse tout coup illégal, ce qui en fait aussi une protection contre la triche. Tout le système suppose que les deux moteurs produisent les mêmes pièces.
Ce que la revue App Store a refusé
La logique de jeu est passée sans difficulté. Le refus est venu d'ailleurs : la fiche. Le jeu est aussi publié sur Android, et la description App Store annonçait « sur iPhone, iPad et tablettes Android ». Apple a refusé la version au titre de la règle 2.3.10, qui interdit de mentionner d'autres plateformes dans les métadonnées de l'App Store.
La correction n'a demandé aucun nouveau build. La description a été modifiée directement par l'API App Store Connect, pour devenir « sur téléphone et tablette », en français comme en anglais. La nouvelle soumission par l'API renvoyait une erreur 409 indiquant que la version n'était pas prête ; elle s'est faite depuis l'interface. Depuis, la fiche du dépôt porte une consigne explicite : aucune mention d'Android ni de Google Play dans le texte destiné à l'App Store.
Avant la première soumission, un audit interne avait déjà traité les motifs de refus les plus courants : bouton de restauration des achats toujours visible, lien vers la politique de confidentialité, icône sans canal alpha, et suppression des mentions de Game Center, annoncé mais pas intégré. La leçon est la même dans les deux cas : la revue juge ce que la fiche promet autant que ce que l'application fait.
Ce que j'en retiens
- Un prototype sans rendu fait une excellente spécification. Il se teste en secondes et survit à la réécriture.
- Le déterminisme se perd dans les détails de plateforme. Entiers 32 bits, culture de formatage, stabilité du tri : aucun ne se voit en relisant.
- Figer un résultat de bout en bout vaut mieux que cent tests unitaires isolés. Un score de partie complète détecte toute dérive, où qu'elle soit.
- La fiche du store fait partie du produit. Elle se relit contre les règles de la plateforme, comme le code.
Un projet similaire, un jeu ou une application multiplateforme à publier ? Parlons-en.