Contexte
J'ai voulu explorer Next.js 16 et Zustand à travers un format de projet atypique : un idle game. Plutôt qu'un énième CRUD, un jeu clicker pose des problèmes techniques concrets — state management intensif, persistence multi-couche (localStorage + cloud), calculs offline, et une boucle de gameplay qui doit rester fluide même avec des dizaines de mises à jour par seconde.
Iron Loop est un jeu idle à thème industriel rétro-futuriste. Le joueur produit des pièces métalliques en cliquant, achète des améliorations et des unités d'automatisation, et peut effectuer un prestige pour repartir avec des multiplicateurs permanents. L'objectif était de construire l'ensemble en une journée, du schéma Prisma au leaderboard fonctionnel, en privilégiant des choix d'architecture pragmatiques.
Objectifs
- Gameplay complet sans authentification : le joueur peut jouer immédiatement, avec persistence localStorage, sans friction d'inscription
- Cloud save avec résolution de conflits : à la connexion Google, détection automatique des sauvegardes locales et cloud, avec option de merge par max-values
- Moteur de jeu pur et testable : toute la logique (production, coûts, prestige, offline) isolée dans des fonctions pures, sans dépendance au framework
- Leaderboard server-side : classement calculé à partir des données de sauvegarde côté serveur, pas des valeurs soumises par le client
Architecture technique
Moteur de jeu en fonctions pures
Toute la logique de gameplay est isolée dans lib/game/ sous forme de fonctions pures qui prennent un GameState et retournent un nouveau GameState. Ce choix permet de tester la logique indépendamment de React et de Zustand, et de réutiliser les mêmes calculs côté client et côté serveur.
// Exponential cost scaling — each upgrade level costs more
export function getUpgradeCost(
baseCost: number,
costMultiplier: number,
level: number,
): number {
return Math.floor(baseCost * Math.pow(costMultiplier, level));
}
// Tick: accumulate passive production over elapsed time
export function applyTick(state: GameState, deltaMs: number): GameState {
const seconds = deltaMs / 1000;
const earned = state.partsPerSecond * seconds;
return {
...state,
parts: state.parts + earned,
totalPartsEarned: state.totalPartsEarned + earned,
allTimeParts: state.allTimeParts + earned,
lastTickAt: Date.now(),
};
}Système de prestige et progression non-linéaire
Le prestige permet au joueur de repartir de zéro en échange de multiplicateurs permanents. La formule utilise une racine carrée du ratio parts/seuil, ce qui crée une courbe de rendements décroissants — chaque prestige suivant demande exponentiellement plus de production.
export function performPrestige(state: GameState): GameState {
const earned = Math.floor(
Math.sqrt(state.totalPartsEarned / PRESTIGE_THRESHOLD),
);
if (earned <= 0) return state;
const fresh = createInitialState();
const newPoints = state.prestigePoints + earned;
return recalculateDerived({
...fresh,
allTimeParts: state.allTimeParts,
prestigePoints: newPoints,
prestigeMultiplier: 1 + newPoints * PRESTIGE_MULTIPLIER_PER_POINT,
prestigeCount: state.prestigeCount + 1,
});
}Merge de sauvegardes local/cloud
Quand un joueur se connecte avec une progression locale existante et une sauvegarde cloud, un modal propose trois options : garder le local, garder le cloud, ou fusionner. La stratégie de merge prend le maximum de chaque valeur — parts, upgrades, prestige — pour ne jamais perdre de progression.
function mergeStates(local: GameState, cloud: GameState): GameState {
const merged: GameState = {
...local,
parts: Math.max(local.parts, cloud.parts),
allTimeParts: Math.max(local.allTimeParts, cloud.allTimeParts),
prestigeCount: Math.max(local.prestigeCount, cloud.prestigeCount),
upgrades: { ...local.upgrades },
};
// Take highest level for each upgrade across both saves
const allKeys = new Set([
...Object.keys(local.upgrades),
...Object.keys(cloud.upgrades),
]);
for (const key of allKeys) {
merged.upgrades[key] = Math.max(
local.upgrades[key] ?? 0,
cloud.upgrades[key] ?? 0,
);
}
return recalculateDerived(merged);
}Persistence multi-couche avec Zustand
Le store Zustand utilise le middleware persist pour sauvegarder automatiquement en localStorage. À la réhydratation, le store calcule la progression offline (50% du revenu passif, plafonné à 8 heures) et affiche un modal avec le montant gagné. L'auto-save cloud se déclenche toutes les 60 secondes pour les utilisateurs authentifiés, avec validation Zod côté serveur avant écriture en base.
Résultats
- 7 upgrades fonctionnelles (3 click, 4 automation) avec coûts exponentiels et scaling équilibré sur plusieurs heures de jeu
- Cycle complet guest-to-authenticated : jeu immédiat sans compte, puis cloud save, merge et leaderboard après connexion Google
- Progression offline calculée à la réhydratation du store, avec modal de feedback et cap à 8 heures
- Leaderboard server-side classé par prestige puis par total de parts, avec valeurs dérivées des sauvegardes et non soumises par le client
Ce que j'ai appris
Isoler la logique de jeu dans des fonctions pures a été le choix le plus rentable du projet. Zustand a ensuite servi uniquement de couche de distribution et de persistence — pas de logique métier dans le store. J'ai aussi retenu qu'un prototype full-stack en une journée est viable si on accepte de figer le scope tôt : pas de tests, pas de multi-provider OAuth, pas de anti-cheat — juste les mécaniques core et une architecture propre qui permettrait d'ajouter tout ça plus tard.