Scripting Lua

Recomendações da Home: conserte o primeiro minuto no código

Seu jogo recebe impressão na Home e perde o jogador em 25 segundos, então o algoritmo corta as impressões seguintes. Instrumente um funil de primeira sessão com AnalyticsService, descubra onde o jogador morre e ataque o qualified play-through rate.

por Gustavo Cantino7 min de leitura

Rascunho assistido por IA, revisado, testado e editado por um humano antes de publicar. Ver Política Editorial. · Revisado por Gustavo Cantino, 03/09/2026 Política Editorial

Você não aumenta home recommendations no grito — não existe botão, tag ou chamada de API que peça mais recomendação na Home. O que existe é um modelo que estima, para cada usuário, a chance de ele clicar no seu ícone e ficar. Você sobe nas recomendações da Home mexendo nos números que a Roblox já mede pra você: click-through do botão Play, qualified play-through rate (a fatia dos plays que vira sessão de verdade) e retenção D1. Dos três, o único que se resolve com código de servidor é o do meio — e é onde a maioria dos jogos está sangrando sem saber onde.

A impressão que você ganha e joga fora no primeiro minuto

A Home é personalizada. Cada linha de recomendação é uma decisão do modelo, usuário por usuário, sobre se vale gastar uma impressão com você. Quando ele gasta e o jogador sai em 25 segundos, você não empatou: você piorou. O sinal que volta é "esse perfil não fica nesse jogo", e a próxima rodada de impressões vai para outro lugar. Ganhar impressão e desperdiçar é pior do que não ter recebido.

Coloque número nisso. Imagine 100.000 impressões em 30 dias com 3% de click-through: 3.000 plays. Se o seu qualified play-through rate é 40%, são 1.200 sessões que contam. Se fosse 55%, seriam 1.650. São 450 sessões por mês que você joga fora sem que falte um centímetro de conteúdo novo — falta o jogador chegar no conteúdo que já existe. E a conta se multiplica no dia seguinte: com D1 de 20% sobre 1.200, voltam 240 pessoas; com D1 de 28% sobre 1.650, voltam 462. Quase o dobro de jogadores no dia 2, com exatamente o mesmo tráfego de entrada.

O problema prático é que o Creator Dashboard te entrega média de sessão e taxa agregada, não te diz ONDE o jogador morreu. Média de 4 minutos pode ser 60% saindo em 30 segundos misturado com 40% ficando 9 minutos — e o tratamento é oposto em cada caso. Sem funil instrumentado, você passa o mês modelando um bioma novo enquanto uma fatia grande dos jogadores nunca viu o spawn terminar de carregar.

O objetivo aqui é sair da média e passar a ter cinco números: quantos entraram, quantos chegaram ao estado jogável, quantos fizeram a primeira ação, quantos passaram de 60 segundos e quantos passaram de 5 minutos. Com esses cinco, o próximo sprint se escolhe sozinho.

Cinco passos pra transformar impressão em sessão qualificada

1. Defina o funil antes de escrever qualquer código. Cinco passos bastam: entrou, jogavel, primeira_acao, sobreviveu_60s, sobreviveu_5min. "Jogável" não é o CharacterAdded disparar — é o momento em que a tela de loading sumiu, o mapa apareceu e o WalkSpeed voltou ao normal. Quem define esse momento é o cliente, então ele precisa avisar o servidor por um RemoteEvent. Se você medir "jogável" no servidor, vai medir mentira bonita.

2. Meça o tempo até jogável em p50 e p90, não em média. A média esconde exatamente o jogador que você está perdendo: o do celular Android antigo com internet ruim, que é maioria em muito jogo de Home. O módulo abaixo loga tempo_ate_jogavel_ms como evento customizado justamente pra você olhar a cauda. O p90 é o que decide sua recomendação, porque é o perfil que o modelo testa e desiste.

3. Corte o que roda entre entrar e jogar. Os três suspeitos de sempre são preload cego, cutscene e tutorial modal. Isto aqui trava o join inteiro esperando centenas de assets que nem estão na tela do spawn:

-- errado: espera TODO o workspace baixar antes de liberar o jogador
local ContentProvider = game:GetService("ContentProvider")
ContentProvider:PreloadAsync(workspace:GetDescendants())

Pré-carregue só a lista do que aparece nos primeiros 10 segundos (UI do HUD, som de clique, textura do chão do spawn) e deixe o resto sob demanda, com StreamingEnabled fazendo o trabalho. E este outro é o assassino silencioso:

-- errado: 8 segundos de intro é 8 segundos de janela pra sair do jogo
humanoid.WalkSpeed = 0
camera.CameraType = Enum.CameraType.Scriptable
task.wait(8)

Se você tem cutscene, dê um botão de pular visível desde o frame 1 e logue quantos pulam. Se mais da metade pula, a cutscene é custo, não conteúdo.

4. Force uma primeira ação em menos de 10 segundos. Sessão qualificada não nasce de "o jogador entendeu o jogo", nasce de "o jogador fez alguma coisa e algo respondeu". Uma moeda a três passos do spawn, um obby de 4 pulos, um botão que solta partícula e dá +1. O passo 3 do funil existe pra você saber a distância entre jogavel e primeira_acao. Se muita gente chega em jogável e não chega na primeira ação, o problema não é performance: é que a tela não comunica o que fazer.

5. Só depois trate o D1. Retenção do dia seguinte precisa de um motivo pra voltar que exista no dia 1: progresso salvo e visível, recompensa diária com contador, algo incompleto que o jogador sabe que está incompleto. O módulo grava o último dia de acesso num DataStore e dispara retorno_d1 quando a diferença é exatamente 1 dia, o que te dá o número segmentável por coorte em vez do agregado do painel.

Dois detalhes operacionais que economizam meio dia de confusão: eventos de analytics não são registrados em sessão de Studio, então teste sempre num servidor publicado; e o relatório do Creator Dashboard não é tempo real, ele consolida por dia. Não olhe o funil 20 minutos depois de publicar e conclua que o código está quebrado.

O módulo de funil e a tabela de metas que vão junto

O artefato principal é um ModuleScript pra ServerScriptService. Ele conecta PlayerAdded e PlayerRemoving sozinho no require, abre uma sessão por jogador com um funnelSessionId único (GUID), e expõe duas funções que você chama do seu código: markPlayable(player) e markFirstAction(player, nome).

O fluxo de ligação é curto. Crie um RemoteEvent chamado ClientReady em ReplicatedStorage, dispare do cliente no exato frame em que você destrói a tela de loading, e no servidor faça:

local SessionFunnel = require(game.ServerScriptService.SessionFunnel)
ReplicatedStorage.ClientReady.OnServerEvent:Connect(function(player)
	SessionFunnel.markPlayable(player)
end)

Depois chame SessionFunnel.markFirstAction(player, "pegou_moeda") no primeiro evento que você considera engajamento real. Os passos 4 e 5 (60s e 5min) são automáticos via task.delay, e a duração total sai no PlayerRemoving e também no BindToClose, pra você não perder as sessões que morrem no shutdown do servidor.

As constantes no topo são o que você vai mexer: QUALIFIED_SECONDS está em 60 porque é o limiar que faz sentido perseguir como proxy de play-through qualificado, e DEEP_SECONDS em 300 pra separar quem entrou de quem ficou. Os campos customizados usam as chaves de Enum.AnalyticsCustomFieldKeys — são três por evento, no máximo, então não tente empurrar um JSON inteiro ali dentro.

O segundo artefato é a tabela de metas. Ela existe pra você não confundir "melhorou" com "variou": cada linha tem onde ler o número no painel, o valor atual e o valor alvo, e as contas fecham entre as linhas. Preencha a coluna do cenário atual com os SEUS números antes de tocar em qualquer código.

O erro mais caro: pagar tráfego antes de arrumar o primeiro minuto

O erro que custa dinheiro de verdade é rodar campanha paga ou sponsored com o funil quebrado. Você compra cliques, joga essa gente num jogo que perde metade antes dos 60 segundos, e o prejuízo é duplo: sai o custo da campanha e entra o sinal negativo. O modelo de recomendação vê uma leva de sessões curtas de perfis variados e recalibra pra baixo. Já vi gente descrever exatamente esse padrão: gráfico de plays subindo durante a campanha e as impressões orgânicas caindo abaixo do patamar anterior nas duas semanas seguintes.

Faça a conta com o seu CPM antes de apertar o play na campanha. Se você paga por 3.000 cliques e 60% saem em menos de um minuto, você pagou o preço cheio por 1.200 sessões úteis — o custo por sessão qualificada é 2,5 vezes o custo por clique que você acha que está pagando. Arrumar o QPTR de 40% pra 55% derruba esse multiplicador pra 1,8 sem gastar um Robux a mais em mídia.

O caminho de saída é chato e funciona: pause a campanha, colete 7 dias de baseline com o funil ligado, ataque o passo com a maior queda, espere mais 7 dias, compare. Só volte a comprar tráfego quando a razão entre sobreviveu_60s e entrou parar de te envergonhar.

O segundo erro, esse técnico, é medir duração de sessão só no PlayerRemoving. Quando o servidor cai ou é desligado, esse evento pode não te dar tempo de gravar nada, e as sessões que somem são justamente as longas — o que infla artificialmente a sua percepção de que o problema é só o começo. Por isso o módulo tem game:BindToClose fechando tudo que ficou aberto. E nunca, em hipótese alguma, tente inflar play count com conta falsa ou bot: play sobe, QPTR e D1 desabam, e o algoritmo só aprende que você não segura ninguém.

O que medir na segunda-feira

Abra o funil e anote quatro números: p50 e p90 do tempo_ate_jogavel_ms, a taxa de jogavel sobre entrou, a taxa de sobreviveu_60s sobre jogavel e a de sobreviveu_5min sobre sobreviveu_60s. A menor dessas taxas é o seu sprint. Não escolha por opinião do grupo do Discord.

Defina uma meta numérica de 30 dias — por exemplo, mais 10 pontos percentuais no QPTR — e uma mudança por semana, isolada, pra você conseguir atribuir o efeito. Registre a data de cada deploy num bloco de notas junto com o número do dia anterior; sem isso, o gráfico do painel vira adivinhação.

E acompanhe impressões da Home com 14 dias de defasagem em relação à mudança de engajamento. O modelo não reage no mesmo dia: você conserta o primeiro minuto agora e a curva de impressão responde depois, quando ele tiver dados suficientes pra confiar em você de novo.

Artefato pronto pra usar

ModuleScript chamado SessionFunnel em ServerScriptService
lua
--!strict
-- SessionFunnel
-- ModuleScript em ServerScriptService. Dê require dele em um Script do servidor.
-- Mede: entrou -> jogavel -> primeira_acao -> 60s -> 5min, duracao de sessao e retorno D1.
-- ATENCAO: AnalyticsService nao registra eventos em sessao de Studio. Teste publicado.

local Players = game:GetService("Players")
local AnalyticsService = game:GetService("AnalyticsService")
local DataStoreService = game:GetService("DataStoreService")
local HttpService = game:GetService("HttpService")

local FUNNEL_NAME = "primeira_sessao"
local QUALIFIED_SECONDS = 60 -- limiar de sessao qualificada que voce persegue
local DEEP_SECONDS = 300 -- separa quem entrou de quem ficou
local SECONDS_PER_DAY = 86400

local store = DataStoreService:GetDataStore("SessionFunnel_v1")

export type Session = {
	funnelId: string,
	joinedAt: number, -- os.clock(), pra medir duracao
	joinedEpoch: number, -- os.time(), pra calcular dia
	playableAt: number?,
	firstActionAt: number?,
	steps: { [number]: boolean },
}

local sessions: { [Player]: Session } = {}

-- as chaves precisam vir de Enum.AnalyticsCustomFieldKeys (maximo 3 por evento)
local function customField(value: string): { [string]: string }
	return { [Enum.AnalyticsCustomFieldKeys.CustomField01.Name] = value }
end

local function logStep(player: Player, stepNumber: number, stepName: string, extra: string?)
	local session = sessions[player]
	if not session then
		return
	end
	if session.steps[stepNumber] then
		return -- funil nao anda pra tras nem conta duas vezes
	end
	session.steps[stepNumber] = true

	local ok, err = pcall(function()
		AnalyticsService:LogFunnelStepEvent(
			player,
			FUNNEL_NAME,
			session.funnelId,
			stepNumber,
			stepName,
			if extra then customField(extra) else nil
		)
	end)
	if not ok then
		warn("[SessionFunnel] falhou no passo " .. stepName .. ": " .. tostring(err))
	end
end

local SessionFunnel = {}

-- Chame quando o CLIENTE avisar que a tela de loading sumiu (RemoteEvent ClientReady).
function SessionFunnel.markPlayable(player: Player)
	local session = sessions[player]
	if not session or session.playableAt then
		return
	end
	session.playableAt = os.clock()
	local ms = math.floor((session.playableAt :: number - session.joinedAt) * 1000)
	logStep(player, 2, "jogavel", tostring(ms) .. "ms")
	pcall(function()
		AnalyticsService:LogCustomEvent(player, "tempo_ate_jogavel_ms", ms)
	end)
end

-- Chame na primeira acao que voce considera engajamento real (pegou moeda, pulou obby...).
function SessionFunnel.markFirstAction(player: Player, actionName: string)
	local session = sessions[player]
	if not session or session.firstActionAt then
		return
	end
	session.firstActionAt = os.clock()
	logStep(player, 3, "primeira_acao", actionName)
end

local function onPlayerAdded(player: Player)
	local session: Session = {
		funnelId = HttpService:GenerateGUID(false),
		joinedAt = os.clock(),
		joinedEpoch = os.time(),
		playableAt = nil,
		firstActionAt = nil,
		steps = {},
	}
	sessions[player] = session
	logStep(player, 1, "entrou")

	-- retorno D1: compara o dia salvo na ultima saida com o dia de hoje
	task.spawn(function()
		local ok, data = pcall(function()
			return store:GetAsync("u_" .. player.UserId)
		end)
		if ok and typeof(data) == "number" then
			local daysAway = math.floor(os.time() / SECONDS_PER_DAY) - data
			if daysAway == 1 then
				pcall(function()
					AnalyticsService:LogCustomEvent(player, "retorno_d1", 1)
				end)
			elseif daysAway >= 2 then
				pcall(function()
					AnalyticsService:LogCustomEvent(player, "retorno_tardio", daysAway)
				end)
			end
		end
	end)

	task.delay(QUALIFIED_SECONDS, function()
		if player.Parent ~= nil then
			logStep(player, 4, "sobreviveu_60s")
		end
	end)

	task.delay(DEEP_SECONDS, function()
		if player.Parent ~= nil then
			logStep(player, 5, "sobreviveu_5min")
		end
	end)
end

local function finishSession(player: Player)
	local session = sessions[player]
	if not session then
		return
	end
	sessions[player] = nil

	local duration = math.floor(os.clock() - session.joinedAt)
	pcall(function()
		AnalyticsService:LogCustomEvent(player, "duracao_sessao_s", duration)
	end)

	local today = math.floor(os.time() / SECONDS_PER_DAY)
	pcall(function()
		store:UpdateAsync("u_" .. player.UserId, function()
			return today
		end)
	end)
end

Players.PlayerAdded:Connect(onPlayerAdded)
Players.PlayerRemoving:Connect(finishSession)

for _, player in Players:GetPlayers() do
	task.spawn(onPlayerAdded, player)
end

-- sem isto, as sessoes longas somem no shutdown e sua media mente pra baixo
game:BindToClose(function()
	for _, player in Players:GetPlayers() do
		task.spawn(finishSession, player)
	end
	task.wait(2)
end)

return SessionFunnel
Aba de metas da planilha do time — a coluna Exemplo é ilustrativa, troque pelos seus números
markdown
| Etapa | Onde ler | Exemplo | Meta (30 dias) | Conta |
|---|---|---|---|---|
| Impressoes na Home (30d) | Analytics > Acquisition | 100.000 | 100.000 | base fixa para comparar |
| CTR do botao Play | Analytics > Acquisition | 3,0% | 3,0% | 100.000 x 0,030 = 3.000 |
| Plays | Analytics > Acquisition | 3.000 | 3.000 | igual nos dois cenarios |
| Chegou ao estado jogavel | Funil, passo 2 / passo 1 | 78% | 92% | 3.000 x 0,78 = 2.340 / 3.000 x 0,92 = 2.760 |
| Qualified play-through (>60s) | Funil, passo 4 / passo 1 | 40% | 55% | 3.000 x 0,40 = 1.200 / 3.000 x 0,55 = 1.650 |
| Passou de 5 min | Funil, passo 5 / passo 4 | 45% | 55% | 1.200 x 0,45 = 540 / 1.650 x 0,55 = 908 |
| Retencao D1 | Analytics > Engagement | 20% | 28% | 1.200 x 0,20 = 240 / 1.650 x 0,28 = 462 |
| Jogadores no dia 2 | resultado | 240 | 462 | +222 pessoas (+92,5%) com o mesmo trafego |