Contexte et motivations

Le produit Cubacadabra était initialement développé trois fois : une version Swift pour iOS, une version Kotlin pour Android et une version JavaScript pour le navigateur. Chaque client implémentait séparément la validation d’un nom d’utilisateur, la gestion des états de chargement et le traitement des réponses HTTP. Lorsque la logique changeait – par exemple la récupération d’une sauvegarde échouée – les trois bases de code devaient être modifiées indépendamment, ce qui augmentait le risque d’incohérences et de bugs.

Pour réduire cette duplication, l’auteur a proposé de placer la logique portable dans des crates Rust partagés, tout en conservant des « thin shells » natifs qui ne contiennent que les contrôles d’interface (SwiftUI, Compose, HTML). Le premier résultat concret a été une réduction de 269 lignes de code et une addition de seulement 106 lignes dans l’intégration iOS, ce qui montre un gain de densité de code dès le premier commit.

Architecture du cœur partagé

Le cœur se compose de deux crates : cubacadabra-client, qui expose les sessions multijoueur, et cubacadabra-app, qui regroupe le comportement applicatif hors jeu (ex. gestion du compte). Le moteur de jeu reste isolé dans une crate engine. Les clients natifs interagissent avec le cœur via une interface C minimale composée de sept fonctions : create, destroy, dispatch_json, get_snapshot_json, poll_effect_json, read_output_ptr et read_output_len. Cette interface est utilisée par Swift (via Data), Kotlin (via JNI byte‑array) et le navigateur (via wasm‑bindgen).

extern "C" {
    fn create() -> *mut CoreHandle;
    fn destroy(handle: *mut CoreHandle);
    fn dispatch_json(handle: *mut CoreHandle, payload: *const u8, len: usize);
    fn get_snapshot_json(handle: *mut CoreHandle) -> *const u8;
    fn poll_effect_json(handle: *mut CoreHandle) -> *const u8;
    fn read_output_ptr(handle: *mut CoreHandle) -> *const u8;
    fn read_output_len(handle: *mut CoreHandle) -> usize;
}

Les fonctions utilisent serde_json pour sérialiser les messages, ce qui garantit que les contrats restent lisibles et testables indépendamment de la plateforme.

Gestion des effets et des pointeurs

Chaque action déclenchée depuis l’interface (par ex. SaveUsername) génère un effect ID unique. L’ID n’est jamais réutilisé tant que le modèle n’est pas réinitialisé, ce qui empêche une réponse tardive d’écraser un état plus récent. Le cœur valide la réponse avant de la transmettre aux écrans, assurant ainsi que les écrans ne réagissent qu’à des effets approuvés.

Les pointeurs retournés par le cœur appartiennent à la mémoire Rust. Les adaptateurs natifs doivent copier les données avant toute mutation ultérieure ; sinon le contenu pourrait être invalidé. Cette contrainte reflète la règle d’ownership de Rust et évite les corruptions de mémoire lorsqu’un handle est détruit.

Évaluation des bénéfices et limites

Le principal avantage mesurable est la réduction du code dupliqué et la centralisation des règles métier. Le modèle d’effets unique simplifie les tests de régression : un seul jeu de scénarios couvre iOS, Android et le Web. En revanche, l’ajout d’une couche d’interfaçage introduit une charge de compilation supplémentaire et nécessite la gestion de bugs liés aux ABI (par ex. erreurs de durée de vie JNI ou de copie de buffers). Pour de petites applications, le coût initial pourrait dépasser les économies, mais le texte indique que l’investissement commence à « payer » dès le premier commit.

En résumé, la migration vers un cœur Rust partagé offre une architecture plus cohérente, réduit les risques d’incohérence fonctionnelle et fournit un point unique de vérité pour les règles métier, tout en imposant une discipline stricte sur la gestion de la mémoire et des effets asynchrones.