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.
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
--!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| 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 |