Aller au contenu
Forma Roblox

Rechercher dans la formation

Module 05 · Leçon 2 sur 6

Sauvegarder sans perdre de données

Sauvegarde l’étape, les pièces et l’inventaire de chaque joueur avec les DataStores, de façon fiable, même quand Roblox a un raté ou que le serveur ferme.

  • ⏱️ 40 min
  • 📝 Quiz de 7 questions
  • 🔄 Vérifié le 24 octobre 2026
Sur cette page

🎯 Dans cette leçon, tu vas apprendre à…

  • Charger et sauvegarder les données d’un joueur avec DataStoreService
  • Gérer les erreurs avec pcall, des nouvelles tentatives et UpdateAsync
  • Éviter les pertes de données au départ du joueur et à la fermeture du serveur
  • Connaître les limites, le verrouillage de session et les bibliothèques reconnues

Rien ne fait fuir un joueur plus vite qu’une progression perdue. Il a passé une heure à grimper jusqu’à l’étape 30 de l’Obby des Nuages, il revient le lendemain… et il est revenu au tout début. Dans cette leçon, tu construis une sauvegarde fiable : elle charge les données à l’arrivée, les garde en mémoire, les sauvegarde régulièrement et ne les écrase jamais par erreur.

C’est la leçon la plus technique du module. Prends ton temps, et teste chaque étape dans Studio.

Les DataStores en deux minutes

Un DataStore est une base de données fournie par Roblox, partagée par tous les serveurs de ton jeu. On l’utilise via le service DataStoreService. Il fonctionne comme un grand dictionnaire :

  • une clé (un texte) identifie une entrée, par exemple "Joueur_123456" ;
  • une valeur est associée à cette clé : un nombre, un texte, un booléen ou une table.

Les valeurs sont stockées sous forme de texte JSON. Garde donc des données simples : nombres, textes, booléens et tables. Un objet Roblox comme une Part ou une couleur Color3 ne se stocke pas tel quel : on enregistre plutôt son nom ou ses composantes.

Les méthodes principales :

Méthode Rôle
GetAsync(cle) lit la valeur (renvoie nil si la clé n’existe pas)
SetAsync(cle, valeur) écrit la valeur, en écrasant l’ancienne sans la lire
UpdateAsync(cle, fonction) lit la valeur la plus récente, la passe à ta fonction, puis écrit ce qu’elle renvoie
IncrementAsync(cle, n) ajoute n à un nombre entier stocké
RemoveAsync(cle) supprime l’entrée

Activer l’accès dans Studio

Par défaut, les tests dans Studio n’ont pas accès aux DataStores. Pour l’activer, ton jeu doit être publié, puis :

  1. ouvre File › Experience Settings ;
  2. va dans Security ;
  3. active Enable Studio Access to API Services ;
  4. clique sur Save.

Sans cette option, tu obtiens une erreur StudioAccessToApisNotAllowed. Avec le code de cette leçon, le chargement échoue alors à chaque test et tu es expulsé au bout de quelques secondes : c’est normal, active l’option (ou teste sur ton jeu publié).

La documentation met en garde : Studio lit et écrit les mêmes données que le jeu en ligne. Pour un jeu déjà lancé, active plutôt cette option sur une copie de test de ton jeu, pour ne pas abîmer les sauvegardes des vrais joueurs.

Les trois règles d’une sauvegarde fiable

Règle 1 : tout appel réseau peut échouer

Chaque appel à un DataStore passe par Internet. Il peut échouer : service momentanément indisponible, limite de requêtes atteinte… Quand ça arrive, la méthode déclenche (« lève ») une erreur, qui arrêterait ton script. On entoure donc chaque appel de pcall (« protected call »), qui attrape l’erreur au lieu de planter :

-- Script serveur : exemple minimal de lecture protégée
local DataStoreService = game:GetService("DataStoreService")
local magasin = DataStoreService:GetDataStore("DonneesJoueurs_v1")

local succes, resultat = pcall(function()
	return magasin:GetAsync("Joueur_123456")
end)

if succes then
	print("Données lues :", resultat) -- nil si la clé n'existe pas encore
else
	warn("Échec de la lecture :", resultat) -- resultat contient le message d'erreur
end

En cas d’échec, on réessaie quelques fois en attendant un peu plus à chaque tentative. La page « Best practices for data stores » recommande un délai croissant (exponential backoff) avec un peu de hasard, pour que tous les serveurs ne réessaient pas au même instant, en plafonnant le délai et le nombre d’essais. Elle conseille aussi de ne pas réessayer une requête invalide (par exemple une valeur impossible à enregistrer) : notre version, volontairement simple, réessaie tout, mais trois fois au maximum.

Règle 2 : un échec n’est pas un nouveau joueur

C’est le piège qui détruit des sauvegardes. GetAsync renvoie nil pour un joueur qui n’a jamais joué : on lui donne alors des données de départ. Mais si GetAsync échoue, il ne faut surtout pas faire pareil ! Sinon, au départ du joueur, ces données vides seront sauvegardées par-dessus sa vraie progression.

Règle 3 : peu de requêtes, au bon moment

Chaque serveur dispose d’un budget de requêtes par minute. On ne sauvegarde donc pas à chaque pièce ramassée. Le schéma recommandé par la documentation :

  • charger une fois à l’arrivée du joueur ;
  • garder les données en mémoire sur le serveur pendant la partie ;
  • sauvegarder au départ du joueur, à la fermeture du serveur, régulièrement (sauvegarde automatique) et aux moments critiques, comme un achat avec des Robux (module 7).

SetAsync ou UpdateAsync ?

SetAsync est rapide et ne compte que dans la limite d’écriture. Mais il écrase la valeur sans la lire : si deux serveurs écrivent la même clé en même temps, les données peuvent devenir incohérentes.

UpdateAsync lit la valeur la plus récente, te la donne, et écrit ce que ta fonction renvoie. Il est un peu plus lent et compte à la fois dans les limites de lecture et d’écriture, mais c’est la méthode que la documentation conseille dès que plusieurs serveurs peuvent écrire la même clé ou que la nouvelle valeur dépend de l’ancienne. Deux détails importants :

  • si ta fonction renvoie nil, l’écriture est annulée ;
  • ta fonction ne doit pas attendre (pas de task.wait, pas d’appel réseau dedans). Elle peut être appelée plusieurs fois si un autre serveur a modifié la clé entre-temps.

Le code complet de l’Obby des Nuages

On découpe la sauvegarde en deux scripts, tous deux côté serveur :

  • un ModuleScript DonneesJoueurs dans ServerScriptService : il charge, garde en mémoire et sauvegarde. D’autres scripts (comme la boutique, leçon 3) pourront l’utiliser avec require ;
  • un Script GestionJoueurs dans ServerScriptService : il branche le module sur les événements du jeu. Il remplace le Script Leaderstats de la leçon 1 (supprime ce dernier).

Vérifie aussi qu’aucun autre script ne crée de dossier leaderstats : depuis la leçon 1, le Script « Progression » du module 4 ne le fait plus. C’est important : un second dossier leaderstats (à 0) pourrait être celui que la sauvegarde lit, et elle enregistrerait alors 0 pièce.

Dans l’ordre, dans Studio :

  1. Dans l’Explorer, survole ServerScriptService, clique sur ⊕ et choisis ModuleScript. Renomme-le DonneesJoueurs, efface le code de départ que Studio y a mis (local module = {} … return module), puis colle le code du module ci-dessous.
  2. Toujours dans ServerScriptService, crée un Script nommé GestionJoueurs et colle son code (plus bas).
  3. Supprime le Script Leaderstats de la leçon 1 (clic droit › Delete).
  4. Active l’accès aux API (section précédente), puis lance un test : ton nom doit apparaître dans le tableau avec Etape et Pieces (à 0 la première fois). Pour vérifier que la sauvegarde fonctionne vraiment, suis le Défi en fin de leçon.

Avec Claude Code, ces deux fichiers se demandent avec le prompt de l’encadré « Avec Claude Code », plus bas. Relis ensuite le code produit ligne par ligne avec les explications de cette section : c’est le code de ton jeu où une erreur coûte le plus cher.

Le code utilise des annotations de type, comme player: Player ou { [string]: any } (« une table dont les clés sont des textes et les valeurs n’importe quoi »). Elles sont facultatives en Luau : elles ne changent rien à l’exécution, mais aident Studio à repérer tes fautes de frappe.

Le ModuleScript DonneesJoueurs

-- ModuleScript « DonneesJoueurs » dans ServerScriptService (côté serveur)
-- Charge, garde en mémoire et sauvegarde les données de chaque joueur.
local DataStoreService = game:GetService("DataStoreService")

local magasin = DataStoreService:GetDataStore("DonneesJoueurs_v1")

local VERSION_DONNEES = 1 -- à augmenter quand la structure des données change
local MAX_TENTATIVES = 3
local DELAI_MAX_FERMETURE = 25 -- secondes : Roblox n'attend que 30 s à la fermeture

local DonneesJoueurs = {}

-- Données en mémoire pendant la partie : [Player] = table de données
local sessions: { [Player]: { [string]: any } } = {}
-- Nombre de sauvegardes en cours, toutes origines confondues
-- (départ du joueur, sauvegarde automatique, fermeture du serveur)
local sauvegardesEnCours = 0

-- Structure d'une sauvegarde toute neuve
local function donneesParDefaut(): { [string]: any }
	return {
		version = VERSION_DONNEES,
		etape = 0, -- dernier checkpoint validé (0 = aucun)
		pieces = 0,
		inventaire = {}, -- objets possédés (leçon 3)
		equipe = "", -- objet équipé (leçon 3), "" = aucun
	}
end

-- Complète une ancienne sauvegarde avec les champs ajoutés depuis
local function completer(donnees: { [string]: any }): { [string]: any }
	for cle, valeurParDefaut in donneesParDefaut() do
		if donnees[cle] == nil then
			donnees[cle] = valeurParDefaut
		end
	end
	-- La sauvegarde a maintenant la structure actuelle : on met à jour son
	-- numéro de version (sans jamais baisser celui d'une sauvegarde plus récente)
	if donnees.version < VERSION_DONNEES then
		donnees.version = VERSION_DONNEES
	end
	return donnees
end

-- Exécute une requête réseau, avec plusieurs tentatives en cas d'échec
local function avecTentatives(requete: () -> any): (boolean, any)
	local succes: boolean, resultat: any = false, nil
	for tentative = 1, MAX_TENTATIVES do
		succes, resultat = pcall(requete)
		if succes then
			return true, resultat
		end
		warn(`DataStore : tentative {tentative}/{MAX_TENTATIVES} échouée ({resultat})`)
		if tentative < MAX_TENTATIVES then
			-- Attente croissante (environ 2 s, puis 4 s) + un peu de hasard
			task.wait(2 ^ tentative + math.random())
		end
	end
	return false, resultat
end

-- Copie les leaderstats (la référence pendant la partie) dans la session
local function lireLeaderstats(player: Player, donnees: { [string]: any })
	local leaderstats = player:FindFirstChild("leaderstats")
	if leaderstats == nil then
		return
	end
	local etape = leaderstats:FindFirstChild("Etape")
	local pieces = leaderstats:FindFirstChild("Pieces")
	if etape and etape:IsA("IntValue") then
		donnees.etape = etape.Value
	end
	if pieces and pieces:IsA("IntValue") then
		donnees.pieces = pieces.Value
	end
end

-- Charge les données ; renvoie nil en cas d'échec
function DonneesJoueurs.Charger(player: Player): { [string]: any }?
	local cle = "Joueur_" .. player.UserId
	local succes, resultat = avecTentatives(function()
		return magasin:GetAsync(cle)
	end)
	if not succes then
		-- Échec : on ne crée PAS de session (règle 2)
		return nil
	end

	local donnees
	if resultat == nil then
		donnees = donneesParDefaut() -- premier passage : nouveau joueur
	else
		donnees = completer(resultat)
	end
	sessions[player] = donnees
	return donnees
end

-- Renvoie les données en mémoire d'un joueur (nil si pas chargées)
function DonneesJoueurs.Obtenir(player: Player): { [string]: any }?
	return sessions[player]
end

-- Sauvegarde les données d'un joueur ; renvoie true SEULEMENT si elles ont
-- réellement été écrites dans le DataStore (false sinon)
function DonneesJoueurs.Sauvegarder(player: Player): boolean
	local donnees = sessions[player]
	if donnees == nil then
		return false -- jamais chargées correctement : surtout, ne rien écrire
	end
	lireLeaderstats(player, donnees)

	sauvegardesEnCours += 1
	local cle = "Joueur_" .. player.UserId
	local ecritureAnnulee = false
	local succes, erreur = avecTentatives(function()
		return magasin:UpdateAsync(cle, function(ancienne)
			-- Si la sauvegarde vient d'une version plus récente du jeu,
			-- on n'y touche pas : renvoyer nil annule l'écriture
			if ancienne ~= nil and (ancienne.version or 0) > VERSION_DONNEES then
				ecritureAnnulee = true
				return nil
			end
			-- (cette fonction peut être rappelée : on remet l'indicateur à jour)
			ecritureAnnulee = false
			-- Nouvelle valeur + UserId du joueur associé à la clé
			return donnees, { player.UserId }
		end)
	end)
	sauvegardesEnCours -= 1
	if not succes then
		warn(`Sauvegarde impossible pour {player.Name} : {erreur}`)
		return false
	end
	if ecritureAnnulee then
		warn(`Sauvegarde annulée pour {player.Name} : données d'une version plus récente du jeu`)
		return false -- rien n'a été écrit
	end
	return true
end

-- Oublie les données en mémoire (à appeler quand le joueur est parti)
function DonneesJoueurs.Liberer(player: Player)
	sessions[player] = nil
end

-- Fermeture du serveur : sauvegarde EN PARALLÈLE toutes les sessions encore
-- en mémoire, puis attend la fin de TOUTES les sauvegardes en cours,
-- y compris celles déjà lancées par PlayerRemoving
function DonneesJoueurs.SauvegarderTout()
	for player in sessions do
		task.spawn(DonneesJoueurs.Sauvegarder, player)
	end
	local debut = os.clock()
	while sauvegardesEnCours > 0 and os.clock() - debut < DELAI_MAX_FERMETURE do
		task.wait()
	end
end

return DonneesJoueurs

Les fonctions que les autres scripts peuvent appeler (on les réutilisera jusqu’au module 11) :

Fonction Rôle Renvoie
DonneesJoueurs.Charger(player) lit la sauvegarde et crée la session la table de données, ou nil en cas d’échec
DonneesJoueurs.Obtenir(player) donne accès à la table en mémoire (la même table, pas une copie) la table, ou nil si pas chargée
DonneesJoueurs.Sauvegarder(player) écrit la session dans le DataStore true seulement si l’écriture a eu lieu
DonneesJoueurs.Liberer(player) oublie la session d’un joueur parti rien
DonneesJoueurs.SauvegarderTout() fermeture du serveur : sauvegarde tout et attend rien

Quelques explications :

  • La clé suit la convention du projet : "Joueur_" .. player.UserId. On utilise toujours le UserId, qui ne change jamais, et pas le nom du joueur, qui peut changer. La documentation recommande justement des clés stables comme User_123456.
  • Une seule clé par joueur, qui contient toute sa table : moins de requêtes, et tout est enregistré d’un bloc.
  • La valeur renvoyée par Sauvegarder est true seulement si les données ont vraiment été écrites. Elle vaut false si le chargement avait échoué, si toutes les tentatives ont échoué, ou si l’écriture a été annulée (sauvegarde d’une version plus récente du jeu). Le module 7 s’en servira pour ne confirmer un achat en Robux qu’une fois la sauvegarde faite.
  • Le champ version prépare l’avenir. Quand tu ajouteras un champ (par exemple meilleurTemps), augmente VERSION_DONNEES : completer remplira le nouveau champ dans les anciennes sauvegardes et passera leur version au nouveau numéro. À la sauvegarde suivante, la clé porte donc le numéro le plus récent, et un serveur qui tourne encore avec l’ancienne version du jeu refusera de l’écraser (grâce au test sur ancienne.version dans Sauvegarder).
  • Le deuxième élément renvoyé par la fonction d’UpdateAsync, { player.UserId }, associe la clé au joueur. La référence API le recommande fortement (paramètre userIds) pour faciliter le suivi et la suppression des données personnelles (RGPD). On le renvoie à chaque sauvegarde : d’après la référence d’UpdateAsync, des UserIds non renvoyés sont effacés.
  • Le compteur sauvegardesEnCours compte toutes les sauvegardes en cours, quelle que soit leur origine. SauvegarderTout s’en sert à la fermeture du serveur (voir plus bas).
  • task.spawn(fonction, argument) lance la fonction tout de suite, dans un fil d’exécution à part : le script continue sans attendre qu’elle finisse. C’est ce qui permet de lancer plusieurs sauvegardes en même temps.

Le Script GestionJoueurs

-- Script « GestionJoueurs » dans ServerScriptService (côté serveur)
-- Remplace le Script « Leaderstats » de la leçon 1 (dont il reprend rendrePrincipale).
local Players = game:GetService("Players")
local RunService = game:GetService("RunService")
local ServerScriptService = game:GetService("ServerScriptService")

local DonneesJoueurs = require(ServerScriptService:WaitForChild("DonneesJoueurs"))

local INTERVALLE_SAUVEGARDE = 180 -- secondes, comme dans l'exemple officiel de Roblox
-- false = comme l'exemple officiel : pas de sauvegarde à la fermeture d'un test Studio.
-- Passe-le à true, sur ta copie de test uniquement, pour tester la sauvegarde dans Studio.
local SAUVEGARDER_EN_STUDIO = false

-- Fait d'une statistique la colonne principale du tableau (leçon 1)
local function rendrePrincipale(stat: IntValue)
	local estPrincipale = Instance.new("BoolValue")
	estPrincipale.Name = "IsPrimary"
	estPrincipale.Value = true
	estPrincipale.Parent = stat
end

-- Crée les leaderstats à partir des données chargées
-- (même ordre qu'à la leçon 1 : Etape en colonne principale, puis Pieces)
local function creerLeaderstats(player: Player, donnees: { [string]: any })
	local leaderstats = Instance.new("Folder")
	leaderstats.Name = "leaderstats"

	local etape = Instance.new("IntValue")
	etape.Name = "Etape"
	etape.Value = donnees.etape
	rendrePrincipale(etape)
	etape.Parent = leaderstats

	local pieces = Instance.new("IntValue")
	pieces.Name = "Pieces"
	pieces.Value = donnees.pieces
	pieces.Parent = leaderstats

	leaderstats.Parent = player
end

local function surArrivee(player: Player)
	local donnees = DonneesJoueurs.Charger(player)
	if donnees == nil then
		player:Kick("Tes données n'ont pas pu être chargées. Réessaie dans quelques minutes !")
		return
	end
	-- Le joueur a pu partir pendant le chargement (qui prend parfois quelques secondes)
	if player.Parent ~= Players then
		DonneesJoueurs.Liberer(player)
		return
	end
	creerLeaderstats(player, donnees)
end

local function surDepart(player: Player)
	DonneesJoueurs.Sauvegarder(player) -- ne fait rien si le chargement avait échoué
	DonneesJoueurs.Liberer(player)
end

Players.PlayerAdded:Connect(surArrivee)
Players.PlayerRemoving:Connect(surDepart)
for _, player in Players:GetPlayers() do
	task.spawn(surArrivee, player)
end

-- Fermeture du serveur : on sauvegarde tout le monde EN PARALLÈLE et on attend
game:BindToClose(function()
	if RunService:IsStudio() and not SAUVEGARDER_EN_STUDIO then
		return -- recommandation de la référence API pour les tests dans Studio
	end
	DonneesJoueurs.SauvegarderTout()
end)

-- Sauvegarde automatique : un filet de sécurité si la sauvegarde finale échoue
task.spawn(function()
	while true do
		-- Un peu de hasard pour que tous les serveurs ne sauvegardent pas en même temps
		task.wait(INTERVALLE_SAUVEGARDE + math.random(0, 30))
		for _, player in Players:GetPlayers() do
			task.spawn(DonneesJoueurs.Sauvegarder, player)
		end
	end
end)

Et si le chargement échoue ?

Suivons ce qui se passe, ligne par ligne, quand GetAsync échoue trois fois de suite :

  1. Charger renvoie nil sans remplir sessions[player] ;
  2. surArrivee expulse le joueur avec un message (player:Kick) ; il n’a jamais reçu de leaderstats ;
  3. l’expulsion déclenche PlayerRemoving, donc surDepart : Sauvegarder ne trouve aucune session et renvoie false sans rien écrire. La sauvegarde automatique et SauvegarderTout font de même.

Sa vraie progression reste donc intacte dans le DataStore. Il la retrouvera en revenant, quand le service répondra de nouveau.

Pourquoi BindToClose ?

Quand un serveur s’arrête (mise à jour du jeu, dernier joueur parti, redémarrage), les sauvegardes lancées par PlayerRemoving pourraient ne pas avoir le temps de finir. game:BindToClose enregistre une fonction appelée avant l’arrêt. D’après la référence API, le serveur attend 30 secondes que les fonctions liées se terminent, puis s’arrête quoi qu’il arrive. D’où trois précautions dans SauvegarderTout :

  • elle sauvegarde en parallèle avec task.spawn : 20 sauvegardes d’une seconde chacune prennent environ une seconde, et non vingt ;
  • elle parcourt les sessions en mémoire, et pas Players:GetPlayers() : à la fermeture, les joueurs sont déconnectés et peuvent avoir déjà quitté la liste des joueurs alors que leur sauvegarde de départ n’est pas finie ;
  • elle attend que le compteur sauvegardesEnCours revienne à 0, donc aussi la fin des sauvegardes déjà lancées par PlayerRemoving, avec une limite de 25 secondes pour rester sous les 30 secondes.

Un même joueur peut ainsi être sauvegardé deux fois de suite (départ + fermeture) : c’est sans danger, car les deux écritures contiennent la même table. C’est pareil si un joueur part pendant une sauvegarde automatique : deux UpdateAsync sont en cours pour lui en même temps, mais tous deux écrivent la même table en mémoire, qui contient ses données les plus récentes. Quel que soit celui qui termine en dernier, rien n’est perdu. Et comme Liberer ne fait qu’oublier la table (sans la vider), une sauvegarde encore en cours garde accès à ses données.

Et dans Studio ?

La référence de DataModel:BindToClose conseille de vérifier RunService:IsStudio(), et son exemple officiel de sauvegarde ne sauvegarde pas à la fermeture d’un test Studio, pour deux raisons données en commentaire : ne pas écrire des données de test dans les données réelles du jeu, et ne pas bloquer l’arrêt du test. On suit cette recommandation : avec SAUVEGARDER_EN_STUDIO = false, la fonction liée s’arrête tout de suite dans Studio.

Conséquence : dans Studio, seule la sauvegarde de PlayerRemoving est tentée quand tu cliques sur Stop, et rien ne garantit qu’elle ait le temps de se terminer avant la fin du test. Attention : si l’accès aux API est activé, cette sauvegarde-là écrit bel et bien dans les données du jeu. C’est une raison de plus pour ne tester que sur une copie de test.

Pour tester ta sauvegarde (sur ta copie de test), passe temporairement SAUVEGARDER_EN_STUDIO à true : l’arrêt du test peut alors prendre quelques secondes, le temps que les sauvegardes se terminent. Remets-le à false ensuite. Sur les serveurs en ligne, IsStudio() renvoie false : la sauvegarde à la fermeture a toujours lieu.

Les limites à connaître

Les chiffres suivants viennent de la page officielle « Data store error codes and limits », au moment de la rédaction (octobre 2026). Ils évoluent : consulte la page avant de concevoir un système gourmand.

Requêtes par serveur (limites par défaut, par minute) :

Type Méthodes Limite par minute
Lecture GetAsync, UpdateAsync… 60 + nombre de joueurs × 40
Écriture SetAsync, IncrementAsync, UpdateAsync 60 + nombre de joueurs × 40
Liste ListKeysAsync… 5 + nombre de joueurs × 2
Suppression RemoveAsync 60 + nombre de joueurs × 40

Il existe aussi une limite à l’échelle du jeu (tous serveurs confondus), qui dépend du nombre de joueurs connectés, ainsi qu’un débit maximal par clé : 25 Mo de lecture et 4 Mo d’écriture par minute.

Taille des données : le nom d’un DataStore et le nom d’une clé sont limités à 50 caractères ; la valeur d’une clé à 4 194 304 caractères une fois convertie en JSON (environ 4 Mo). Une table d’obby n’en approche même pas.

Stockage total : 500 Mo + 1 Mo × nombre de joueurs uniques ayant déjà rejoint le jeu. Seule la dernière version de chaque clé compte.

Avec notre code, un joueur coûte une lecture à l’arrivée (GetAsync), puis un UpdateAsync toutes les trois minutes environ et un au départ. Chaque UpdateAsync compte à la fois comme une lecture et comme une écriture. On reste très loin des limites.

Versions et métadonnées

Chaque écriture dans un DataStore crée une nouvelle version de la clé. Les anciennes versions sont conservées 30 jours après avoir été remplacées (la dernière version n’expire jamais). Tu peux les lister avec ListVersionsAsync et en relire une avec GetVersionAsync. C’est ton filet de sécurité si un bug corrompt des sauvegardes : tu peux revenir en arrière.

Chaque clé peut aussi porter des métadonnées :

  • des métadonnées fournies par Roblox : numéro de version, date de création et de dernière mise à jour (objet DataStoreKeyInfo, renvoyé en second par GetAsync) ;
  • des UserIds associés à la clé (comme notre { player.UserId }) ;
  • des métadonnées personnalisées, via DataStoreSetOptions:SetMetadata, limitées à 300 caractères au total.

Le verrouillage de session

Notre code a une faiblesse connue. Imagine : un joueur quitte le serveur A, dont la sauvegarde est lente (nouvelles tentatives, file d’attente). Pendant ce temps, il rejoint le serveur B, qui charge… l’ancienne sauvegarde. Le serveur A finit sa sauvegarde, puis, plus tard, le serveur B sauvegarde ses données périmées par-dessus. Résultat : des pertes, voire des objets dupliqués si ton jeu permet des échanges. La documentation de Roblox décrit précisément ce scénario.

Le verrouillage de session (session locking) règle le problème : quand un serveur charge les données d’un joueur, il pose un « verrou » dans les métadonnées de la clé, dans le même UpdateAsync que la lecture. Tant que ce verrou est actif, les autres serveurs refusent de lire ou d’écrire cette clé. Le serveur retire le verrou lors de la sauvegarde finale, et le verrou expire s’il n’est plus rafraîchi (par exemple si le serveur plante).

Pour l’Obby des Nuages version 1 (des pièces et des traînées cosmétiques, sans échanges entre joueurs), le risque reste limité et notre code suffit pour apprendre. Pour un jeu avec des échanges, des objets rares ou beaucoup d’achats, utilise une solution qui gère le verrouillage.

Les bibliothèques reconnues

La documentation de Roblox indique que les modules de DataStore tiers sont « toujours une option », souvent préférable à un système écrit de zéro, à condition de vérifier qui les maintient et comment récupérer tes données sans eux. Deux pistes :

  • ProfileStore, de loleris (MadStudioRoblox), successeur de ProfileService. Ce module communautaire (il n’est pas développé par Roblox) gère pour toi le verrouillage de session et la sauvegarde automatique (toutes les 300 secondes par défaut, d’après son auteur). Son auteur précise qu’il est conçu pour les données des joueurs, pas pour les classements ni les données globales. Liens de l’auteur : documentation de ProfileStore et fil de présentation sur le DevForum (octobre 2024).
  • Le code de référence de Roblox (« Player data and purchasing systems ») : il montre une implémentation complète avec nouvelles tentatives ordonnées et verrouillage de session. Roblox prévient qu’il sert d’exemple à étudier, pas à copier tel quel sans tests approfondis.

Ce qu’il faut retenir

  • DataStoreService sauvegarde des données côté serveur ; on active l’accès dans Studio via Experience Settings › Security, de préférence sur une copie de test.
  • Chaque appel est protégé par pcall, avec quelques nouvelles tentatives ; un échec de chargement ne doit jamais être traité comme un nouveau joueur.
  • On charge une fois, on garde en mémoire, et on sauvegarde au départ, à la fermeture (BindToClose, 30 secondes maximum, en attendant toutes les sauvegardes en cours) et régulièrement ; dans Studio, on suit l’exemple officiel qui saute la sauvegarde de fermeture.
  • UpdateAsync est préférable à SetAsync quand plusieurs serveurs peuvent écrire la même clé ; un champ version permet de faire évoluer la structure.
  • Le verrouillage de session évite les écrasements entre serveurs ; ProfileStore le gère pour toi si ton jeu en a besoin.

Sources et pour aller plus loin

Quiz : vérifie tes acquis

7 questions · réussite à partir de 70 % · valide chaque réponse pour voir l’explication

  1. Question 1Vrai ou fauxPour enregistrer une valeur qui dépend de l’ancienne (par exemple ajouter des pièces), SetAsync est la méthode la plus sûre.
  2. Question 2QCMCombien de temps un serveur Roblox attend-il les fonctions liées avec game:BindToClose avant de s’arrêter ?
  3. Question 3Trouve le bugCe code de chargement fait perdre leur progression à certains joueurs quand Roblox a un raté. Quelle ligne est en cause ?
    local function charger(player)
    	local cle = "Joueur_" .. player.UserId
    	local succes, donnees = pcall(function()
    		return magasin:GetAsync(cle)
    	end)
    	if not succes or donnees == nil then
    		donnees = { etape = 0, pieces = 0 }
    	end
    	sessions[player] = donnees
    end
  4. Question 4QCMTon script de sauvegarde affiche une erreur StudioAccessToApisNotAllowed pendant tes tests. Que faire ?
  5. Question 5QCMAu moment de la rédaction, la limite par défaut des lectures d’un serveur est de 60 + nombre de joueurs × 40 requêtes par minute. Combien de lectures par minute pour un serveur de 5 joueurs ?
  6. Question 6QCMQuel problème le verrouillage de session (session locking) cherche-t-il à éviter ?
  7. Question 7Trouve le bugTrouve la faille dans ce code généré par IA. Voici une version simplifiée (sans le test de version) de Sauvegarder. Elle doit renvoyer true seulement si les données ont vraiment été écrites. Quelle ligne trahit cette promesse ?
    function DonneesJoueurs.Sauvegarder(player: Player): boolean
    	local donnees = sessions[player]
    	if donnees == nil then
    		return false
    	end
    	local succes, erreur = avecTentatives(function()
    		return magasin:UpdateAsync("Joueur_" .. player.UserId, function()
    			return donnees, { player.UserId }
    		end)
    	end)
    	if not succes then
    		warn("Sauvegarde impossible :", erreur)
    	end
    	return true
    end