Cómo guardar los datos con DataStore y no perderlos

Claim Your Loot

·

Portada del artículo «Guardar los datos sin perderlos», de la sección Scripting en Luau

El día que a un jugador se le borra el progreso, no vuelve. Y el guardado en Roblox falla más de lo que la gente cree: la petición al servidor de datos puede tardar, dar error o llegar tarde a un servidor que ya se está apagando. Un DataStore escrito «a la primera» funciona en Studio y pierde partidas en producción.

En la demo puedes forzar los fallos que ocurren en producción (caída de la petición, cierre del servidor, dos guardados a la vez) y ver qué pasa con un script sin protección y con uno bien hecho.

Respuesta rápida

Envuelve todas las llamadas al DataStore en pcall, guarda en PlayerRemoving y en game:BindToClose(), y no guardes en cada cambio: guarda por eventos importantes y cada pocos minutos. Sin pcall, un error de red revienta el script y el jugador pierde la sesión entera.

Lo que necesitas antes de empezar

  • Los datos ya montados en el jugador: si vienes de cómo crear un leaderboard de puntuación, es justo el paso siguiente.
  • Activar el acceso a la API en Studio, o no vas a poder probar nada (paso 1).
  • Unos 30 minutos, y ganas de probar los fallos a propósito.

Paso 1: activar el acceso a la API

En Studio: File → Game Settings → Security → Enable Studio Access to API Services. Sin eso, cualquier llamada a un DataStore desde Studio da error y te vuelves loco buscando el fallo en tu código.

Dos avisos:

  • Solo se puede activar en un lugar ya publicado. Un archivo local sin subir no tiene a qué DataStore apuntar.
  • Studio y el juego publicado usan los mismos datos. Si pruebas cosas raras en Studio con tu propia cuenta, te estás tocando tu propio guardado.

Paso 2: la estructura que sí aguanta

local DatosServicio = game:GetService("DataStoreService")
local Jugadores = game:GetService("Players")

local almacen = DatosServicio:GetDataStore("DatosJugador_v1")

local POR_DEFECTO = {
	puntos = 0,
	monedas = 0,
	nivel = 1,
}

Ese _v1 del nombre no es decoración. El día que cambies la forma de los datos, subes a _v2 y los jugadores empiezan de cero en vez de cargar algo que tu código nuevo no sabe leer. Es feo, pero es mejor que un attempt to index nil en mitad de la carga.

La tabla POR_DEFECTO es igual de importante: es lo que recibe un jugador nuevo, y también el molde con el que rellenas campos que aún no existían cuando ese jugador guardó por última vez.

Paso 3: cargar con reintentos

local function cargar(jugador)
	local clave = "jug_" .. jugador.UserId
	local datos = nil
	local exito = false

	for intento = 1, 3 do
		local ok, resultado = pcall(function()
			return almacen:GetAsync(clave)
		end)

		if ok then
			exito = true
			datos = resultado
			break
		end

		warn("Fallo al cargar (intento " .. intento .. "): " .. tostring(resultado))
		task.wait(2)
	end

	if not exito then
		return nil, false      -- no pudimos leer: OJO, no es lo mismo que "no tiene datos"
	end

	if datos == nil then
		datos = table.clone(POR_DEFECTO)   -- jugador nuevo
	end

	-- Rellenamos campos que se anadieron despues
	for campo, valor in pairs(POR_DEFECTO) do
		if datos[campo] == nil then
			datos[campo] = valor
		end
	end

	return datos, true
end

La distinción del final es la más importante de todo el artículo:

SituaciónQué devuelve GetAsyncQué debes hacer
Jugador nuevonil, sin errorDarle los valores por defecto
Error de rederror, capturado por pcallNo darle valores por defecto

Si tratas las dos igual, un fallo temporal de Roblox le pone el progreso a cero a un jugador veterano… y luego se lo guardas encima. Ese es el bug que borra cuentas.

Paso 4: qué hacer cuando la carga falla

Lo correcto es no dejarle jugar con datos falsos:

Jugadores.PlayerAdded:Connect(function(jugador)
	local datos, ok = cargar(jugador)

	if not ok then
		jugador:Kick("No hemos podido cargar tu progreso. Vuelve a entrar en un minuto: tus datos estan a salvo.")
		return
	end

	sesiones[jugador] = datos
	montarLeaderstats(jugador, datos)
end)

Echar al jugador suena agresivo, pero es la opción amable: prefiere una molestia de treinta segundos a borrarle diez horas. Y en el mensaje, díselo claro.

Paso 5: guardar

local function guardar(jugador)
	local datos = sesiones[jugador]
	if not datos then return false end       -- nunca cargo: no escribimos nada

	local clave = "jug_" .. jugador.UserId

	for intento = 1, 3 do
		local ok, err = pcall(function()
			almacen:SetAsync(clave, datos)
		end)

		if ok then return true end

		warn("Fallo al guardar (intento " .. intento .. "): " .. tostring(err))
		task.wait(2)
	end

	return false
end

Jugadores.PlayerRemoving:Connect(function(jugador)
	guardar(jugador)
	sesiones[jugador] = nil
end)

Ese if not datos then return false end es la otra mitad de la protección del paso 4: si no llegamos a cargar, no escribimos jamás. Sin esa línea, un fallo de lectura acaba escribiendo ceros encima de lo bueno.

Paso 6: el guardado que se olvida siempre

Cuando Roblox apaga un servidor (porque se ha quedado vacío o porque actualizas el juego), PlayerRemoving puede no llegar a completarse. Para eso está BindToClose:

game:BindToClose(function()
	for _, jugador in ipairs(Jugadores:GetPlayers()) do
		task.spawn(function()
			guardar(jugador)
		end)
	end

	task.wait(3)   -- margen para que terminen las escrituras
end)

Roblox te da unos segundos antes de cortar. Ese task.wait(3) los usa. Sin BindToClose, la gente que estaba jugando cuando cerró el servidor pierde lo de la última sesión, y es un fallo que en Studio no se ve nunca.

El ciclo de una partida: cargar con pcall, guardar cada dos minutos, al salir y con BindToClose

Paso 7: UpdateAsync cuando hay dinero de por medio

SetAsync escribe encima. UpdateAsync lee y escribe en una sola operación, así que si dos servidores tocan al mismo jugador a la vez no se pisan:

local ok, err = pcall(function()
	almacen:UpdateAsync(clave, function(anterior)
		anterior = anterior or table.clone(POR_DEFECTO)
		anterior.monedas = anterior.monedas + 50
		return anterior
	end)
end)

Para el progreso normal de un jugador, SetAsync va bien. Para monedas, compras o cualquier cosa que se pueda duplicar, usa UpdateAsync.

Paso 8: cada cuánto guardar

Hay un límite de peticiones por minuto y por jugador. Guardar en cada cambio te lo come y las escrituras empiezan a fallar. Un patrón que funciona:

task.spawn(function()
	while true do
		task.wait(120)                       -- cada dos minutos
		for jugador, _ in pairs(sesiones) do
			guardar(jugador)
		end
	end
end)

Más un guardado extra en los momentos que duelen si se pierden: una compra, subir de nivel, conseguir un objeto raro. Lo demás, que espere al reloj.

Rómpelo aquí

Los cuatro fallos que borran partidas

Elige qué se tuerce y compara el mismo momento en un script sin protección y en uno bien hecho. El jugador de la prueba lleva 1.240 monedas y 18 horas.

Sin protección
Con pcall, BindToClose y UpdateAsync
Progresos destrozados por el script sin protección: 0

Errores habituales

En Studio no guarda nada y no hay error claro

No has activado Enable Studio Access to API Services en Game Settings → Security. Es el número uno.

104: Cannot store Instance in DataStore

Estás intentando guardar algo que no es un dato: un Part, un Color3, un Vector3 o un Instance. Los DataStores solo guardan números, textos, booleanos y tablas con esas cosas dentro. Convierte primero: un Color3 se guarda como {r = 255, g = 120, b = 0}.

Se pierde el progreso solo cuando se cierra el servidor

Falta BindToClose. Es exactamente ese síntoma.

Guarda a veces sí y a veces no

Estás guardando demasiado a menudo y te has comido el límite de peticiones, o no envolviste las llamadas en pcall y el primer error tumbó el script entero.

Las claves con nombre en vez de con UserId

Si usas jugador.Name como clave, el día que alguien se cambie el nombre pierde todo. Usa siempre jugador.UserId, que no cambia nunca.

El jugador aparece con 0 monedas de vez en cuando

El caso del paso 4: estás tratando un error de lectura como si fuera un jugador nuevo. Sepáralos.

Preguntas frecuentes

¿Puedo guardar una tabla con el inventario entero?

Sí, mientras dentro solo haya números, textos, booleanos y más tablas. Hay un límite de tamaño por clave: si tu inventario crece sin freno, guarda identificadores cortos ("esp_01") en vez de nombres largos y descripciones.

¿Los datos de Studio y los del juego publicado son los mismos?

Sí, salvo que uses un DataStore con nombre distinto para pruebas. Es buena idea: DatosJugador_v1 en producción y DatosJugador_test mientras desarrollas.

¿Qué es eso del session locking?

Marcar en el propio dato que el jugador tiene una sesión abierta, para que dos servidores no carguen y guarden a la vez el mismo perfil (pasa al saltar de servidor). Es lo que resuelven librerías como ProfileService. Si tu juego mueve objetos valiosos, mírala antes de escribirte la tuya.

¿Puedo recuperar unos datos que se han sobrescrito?

Con ListVersionsAsync y GetVersionAsync se pueden recuperar versiones anteriores dentro de la ventana de retención. Es una red de seguridad, no una excusa para no hacerlo bien.

¿Hace falta guardar cuando el jugador cambia de servidor dentro del mismo juego?

Sí: para el DataStore es como salir y volver a entrar. Es justo el momento donde más duplicaciones aparecen si no usas UpdateAsync: en el inventario se ve por qué duele tanto.


Última revisión: 1 de septiembre de 2026

Claim Your Loot

Explora otras secciones

Deja una respuesta

Tu dirección de correo electrónico no será publicada. Los campos obligatorios están marcados con *