Cómo hacer un cooldown que no se pueda saltar

Claim Your Loot

·

Portada del artículo «Un cooldown que no se pueda saltar», de la sección Scripting en Luau

Tienes una habilidad que hace mucho daño y quieres que solo se pueda usar cada tres segundos. Lo montas, funciona, lo pruebas tú y va perfecto. Y un día alguien entra con un exploit, dispara sesenta veces por segundo y se lleva el juego por delante.

El fallo casi siempre es el mismo: el cooldown está en el cliente. Y el cliente es del jugador, no tuyo.

En la demo puedes simular las dos situaciones sobre tu propio servidor: alguien pulsando como una persona normal y alguien mandando veinte peticiones por segundo, contra un servidor que se fía y contra uno que no.

Respuesta rápida

El cooldown vive en el servidor, en una tabla indexada por jugador, y se comprueba en el OnServerEvent antes de hacer nada. El del cliente es solo para que el botón se ponga gris: decoración.

Lo que necesitas antes de empezar

Paso 1: el cooldown que no sirve

Este es el que sale en casi todos los tutoriales, en un LocalScript:

local puedo = true

boton.Activated:Connect(function()
	if not puedo then return end
	puedo = false

	evento:FireServer()          -- el servidor no comprueba nada

	task.wait(3)
	puedo = true
end)

Funciona de maravilla… mientras el jugador use tu botón. Un exploit no usa tu botón: llama directamente al RemoteEvent. Tu variable puedo ni se entera.

Regla general: si una comprobación se puede borrar desde la máquina del jugador, no es una comprobación.

Veinte peticiones en un segundo contra un servidor que se fía del cliente y otro con su propio cooldown

Paso 2: el cooldown que sí aguanta

En el servidor, una tabla con la última vez que cada jugador lo usó:

local Jugadores = game:GetService("Players")
local evento = game.ReplicatedStorage:WaitForChild("UsarHabilidad")

local ESPERA = 3
local ultimoUso = {}

evento.OnServerEvent:Connect(function(jugador)
	local ahora = os.clock()
	local anterior = ultimoUso[jugador]

	if anterior then
		if ahora - anterior < ESPERA then
			return                    -- todavía no toca: se ignora y ya está
		end
	end

	ultimoUso[jugador] = ahora

	-- aquí va la habilidad de verdad
	lanzarBolaDeFuego(jugador)
end)

Jugadores.PlayerRemoving:Connect(function(jugador)
	ultimoUso[jugador] = nil          -- limpieza, o la tabla crece para siempre
end)

Tres detalles:

  • os.clock(), no tick() ni os.time(). os.clock es un reloj monótono con decimales: no lo mueve el reloj del sistema y tiene precisión de sobra. os.time solo da segundos enteros, que para un cooldown de 0,5 s no vale.
  • Se guarda el momento, no un booleano. Un booleano con task.wait(3) dentro te obliga a mantener una corrutina viva por jugador; una marca de tiempo es un número y ya.
  • Se ignora en silencio. No hace falta contestarle al que se pasa de listo.

Paso 3: decírselo al jugador honrado

El servidor decide, pero el jugador merece ver algo. Devuélvele lo que le queda:

-- servidor
if ahora - anterior < ESPERA then
	evento:FireClient(jugador, "espera", ESPERA - (ahora - anterior))
	return
end
evento:FireClient(jugador, "va")
-- cliente
evento.OnClientEvent:Connect(function(estado, restante)
	if estado == "espera" then
		aviso.Text = string.format("Espera %.1f s", restante)
	else
		enfriar(boton, ESPERA)     -- animación de recarga, solo estética
	end
end)

Así el cliente hace lo que se le da bien —enseñar cosas— y el servidor hace lo que solo él puede hacer: decidir.

Paso 4: cooldowns distintos por habilidad

Con una tabla dentro de otra:

local ESPERAS = {
	bola = 3,
	curar = 12,
	dash = 0.8,
}

local usos = {}     -- usos[jugador][habilidad] = momento

local function puedeUsar(jugador, habilidad)
	local espera = ESPERAS[habilidad]
	if not espera then return false end        -- habilidad inventada

	usos[jugador] = usos[jugador] or {}
	local ahora = os.clock()
	local anterior = usos[jugador][habilidad]

	if anterior then
		if ahora - anterior < espera then return false end
	end

	usos[jugador][habilidad] = ahora
	return true
end

Fíjate en if not espera then return false end: el cliente manda el nombre de la habilidad, así que puede mandar cualquier cosa. Si no está en tu tabla, fuera.

Paso 5: el límite que le falta a casi todos los juegos

Un cooldown por habilidad no te protege de que alguien mande mil peticiones por segundo de una habilidad que sí tiene lista. Aunque las rechaces, cada una gasta trabajo del servidor. Pon un límite general por jugador:

local PETICIONES_POR_SEGUNDO = 20
local contador = {}

local function pasaElFiltro(jugador)
	local ahora = os.clock()
	local c = contador[jugador]

	if not c then
		contador[jugador] = {ventana = ahora, n = 1}
		return true
	end

	if ahora - c.ventana > 1 then
		c.ventana = ahora
		c.n = 1
		return true
	end

	c.n = c.n + 1
	if c.n > PETICIONES_POR_SEGUNDO then
		return false
	end
	return true
end

Lo llamas lo primero de todo en cada OnServerEvent. A un jugador normal no le afecta jamás: nadie pulsa veinte veces en un segundo con la mano.

Paso 6: la habilidad entera

evento.OnServerEvent:Connect(function(jugador, habilidad)
	if not pasaElFiltro(jugador) then return end
	if type(habilidad) ~= "string" then return end
	if not puedeUsar(jugador, habilidad) then
		evento:FireClient(jugador, "espera")
		return
	end

	local personaje = jugador.Character
	if not personaje then return end

	-- Y una última: ¿está donde dice estar?
	local raiz = personaje:FindFirstChild("HumanoidRootPart")
	if not raiz then return end

	ejecutar(habilidad, jugador, raiz.Position)
end)

El orden importa: primero lo barato (el filtro, el tipo), luego lo caro (buscar el personaje, calcular).

Ponte en el peor caso

Una pulsación normal contra veinte por segundo

La misma habilidad contra dos servidores: uno que se fía del cooldown del cliente y otro que lleva el suyo. Cooldown: 3 segundos.

Servidor que se fía del cliente
0
de daño hecho · 0 bolas de fuego
Servidor con su propio cooldown
0
de daño hecho · 0 bolas de fuego
Los dos empiezan igual. Prueba primero a pulsar una vez.

Errores habituales

El cooldown funciona hasta que alguien lo salta

Está en un LocalScript. Cópialo al servidor; el del cliente se queda solo para el botón gris.

Usa tick() y a veces se comporta raro

tick() está desaconsejado desde hace tiempo. Para medir diferencias de tiempo, os.clock().

La tabla de cooldowns crece hasta llenar la memoria

Falta borrar la entrada en PlayerRemoving. En un servidor que lleva días con gente entrando y saliendo se nota.

Al reaparecer, el jugador puede usar la habilidad al momento

Si eso te molesta, guarda el cooldown también en CharacterAdded. Y si prefieres lo contrario (que morir sea un alivio), déjalo como está: es una decisión de diseño, no un fallo.

Dos jugadores comparten el cooldown

Estás usando una sola variable en vez de una tabla por jugador. Clásico de copiar y pegar el ejemplo del LocalScript.

El cooldown se salta cambiando de servidor

Si es una habilidad importante (una recompensa, un cofre diario), el cooldown tiene que ir al DataStore, no a una tabla en memoria: cómo guardar los datos.

Preguntas frecuentes

¿Debería contestar al que manda peticiones de más?

No. Cuanta menos información le des a un exploit sobre qué le has detectado, mejor. Ignóralo.

¿Y si quiero echar del juego a quien lo intente?

Ten cuidado: la latencia y los reenvíos hacen que a veces lleguen dos peticiones legítimas casi juntas. Echar a alguien por eso es como cazar moscas a cañonazos. Cuenta los intentos y actúa solo con números claramente absurdos.

¿Se puede hacer el cooldown con un atributo en vez de una tabla?

Sí: jugador:SetAttribute("ultimaBola", os.clock()). Es cómodo porque se ve en el explorador mientras depuras, pero el cliente puede leerlo (no escribirlo). Para cooldowns normales da igual.

¿Cuánto debería durar un cooldown?

El que haga que la habilidad se note especial sin aburrir. Empieza por lo que te parezca y ajústalo mirando cuánta gente la usa: si es la única que se usa, es corta; si no la usa nadie, es larga.


Ú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 *