Home Recommendations: Fix the First Minute in Code
You get Home impressions, players leave in 25 seconds, and the next round of impressions goes somewhere else. Instrument a five-step session funnel on the server, find which step leaks, and fix the qualified play-through rate before you buy a single click.
AI-assisted draft, reviewed, tested and edited by a human before publishing. See our Editorial Policy. · Reviewed by Gustavo Cantino, 9/15/26 Editorial Policy

You don't increase home recommendations by shouting — there is no button, tag or API call that asks for more Home placement. What exists is a model that estimates, user by user, the odds someone clicks your icon and stays. You climb the Home recommendations by moving the numbers Roblox already measures for you: click-through on the Play button, qualified play-through rate (the share of plays that become a real session), and D1 retention. Of those three, the only one you fix with server code is the middle one — and that is where most games are bleeding without knowing where.
The impression you win and throw away in the first minute
The Home page is personalized. Every recommendation row is a decision the model makes, user by user, about whether it is worth spending an impression on you. When it spends one and the player leaves in 25 seconds, you did not break even: you made things worse. The signal that goes back is "this profile does not stay in this game," and the next round of impressions goes elsewhere. Winning an impression and wasting it is worse than never getting it.
Put numbers on that. Imagine 100,000 impressions over 30 days at a 3% click-through: 3,000 plays. If your qualified play-through rate is 40%, that is 1,200 sessions that count. At 55%, it would be 1,650. That is 450 sessions a month you throw away without missing a single inch of new content — what is missing is the player reaching the content that already exists. And the math compounds the next day: with 20% D1 on 1,200, 240 people come back; with 28% D1 on 1,650, 462 do. Almost double the players on day 2, with exactly the same inbound traffic.
The practical problem is that the Creator Dashboard hands you average session length and an aggregate rate, not WHERE the player died. A 4-minute average can be 60% leaving at 30 seconds mixed with 40% staying 9 minutes — and the treatment is the opposite in each case. Without an instrumented funnel, you spend the month modeling a new biome while a large slice of your players never saw the spawn finish loading.
The goal here is to get off the average and start owning five numbers: how many entered, how many reached a playable state, how many performed the first action, how many passed 60 seconds and how many passed 5 minutes. With those five, the next sprint picks itself.
Five steps to turn an impression into a qualified session
1. Define the funnel before writing any code. Five steps are enough: entered, playable, first_action, survived_60s, survived_5min. "Playable" is not CharacterAdded firing — it is the moment the loading screen is gone, the map is visible and WalkSpeed is back to normal. The client is the one that knows that moment, so it has to tell the server over a RemoteEvent. If you measure "playable" on the server, you will measure a pretty lie.
2. Measure time-to-playable at p50 and p90, not as an average. The average hides exactly the player you are losing: the one on an old Android phone with bad internet, who is the majority in a lot of Home-driven games. The module below logs time_to_playable_ms as a custom event precisely so you can look at the tail. The p90 is what decides your recommendation, because that is the profile the model tests and gives up on.
3. Cut whatever runs between joining and playing. The three usual suspects are blind preload, cutscene and modal tutorial. This right here stalls the entire join waiting on hundreds of assets that are not even on the spawn screen:
-- wrong: waits for the WHOLE workspace to download before releasing the player
local ContentProvider = game:GetService("ContentProvider")
ContentProvider:PreloadAsync(workspace:GetDescendants())Preload only the list of what appears in the first 10 seconds (HUD UI, click sound, spawn floor texture) and leave the rest on demand, with StreamingEnabled doing the work. And this other one is the silent killer:
-- wrong: 8 seconds of intro is an 8-second window to leave the game
humanoid.WalkSpeed = 0
camera.CameraType = Enum.CameraType.Scriptable
task.wait(8)If you have a cutscene, give it a skip button that is visible from frame 1 and log how many people skip. If more than half skip, the cutscene is cost, not content.
4. Force a first action in under 10 seconds. A qualified session is not born from "the player understood the game," it is born from "the player did something and something responded." A coin three steps from spawn, a 4-jump obby, a button that pops a particle and gives +1. Step 3 of the funnel exists so you know the distance between playable and first_action. If a lot of people reach playable and never reach the first action, the problem is not performance: it is that the screen does not communicate what to do.
5. Only then deal with D1. Next-day retention needs a reason to come back that exists on day 1: saved and visible progress, a daily reward with a counter, something unfinished that the player knows is unfinished. The module writes the last access day into a DataStore and fires d1_return when the difference is exactly 1 day, which gives you a number you can segment by cohort instead of the dashboard aggregate.
Two operational details that save you half a day of confusion: analytics events are not recorded in a Studio session, so always test on a published server; and the Creator Dashboard report is not real time, it consolidates by day. Do not check the funnel 20 minutes after publishing and conclude the code is broken.
The funnel module and the goals table that ship with it
The main artifact is a ModuleScript for ServerScriptService. It connects PlayerAdded and PlayerRemoving by itself on require, opens one session per player with a unique funnelSessionId (a GUID), and exposes two functions you call from your own code: markPlayable(player) and markFirstAction(player, name).
The wiring is short. Create a RemoteEvent named ClientReady in ReplicatedStorage, fire it from the client on the exact frame you destroy the loading screen, and on the server do this:
local SessionFunnel = require(game.ServerScriptService.SessionFunnel)
ReplicatedStorage.ClientReady.OnServerEvent:Connect(function(player)
SessionFunnel.markPlayable(player)
end)Then call SessionFunnel.markFirstAction(player, "picked_up_coin") on the first event you consider real engagement. Steps 4 and 5 (60s and 5min) are automatic via task.delay, and total duration comes out on PlayerRemoving and also on BindToClose, so you do not lose the sessions that die in a server shutdown.
The constants at the top are what you will touch: QUALIFIED_SECONDS is 60 because that is the threshold worth chasing as a proxy for a qualified play-through, and DEEP_SECONDS is 300 to separate who entered from who stayed. The custom fields use the keys from Enum.AnalyticsCustomFieldKeys — three per event, maximum, so do not try to shove an entire JSON blob in there.
The second artifact is the goals table. It exists so you do not confuse "improved" with "fluctuated": each row has where to read the number in the dashboard, the example value and the target value, and the math ties across rows. Fill the scenario column with YOUR numbers before touching any code.
The most expensive mistake: paying for traffic before fixing the first minute
The mistake that actually costs money is running a paid or sponsored campaign with a broken funnel. You buy clicks, drop those people into a game that loses half of them before the 60-second mark, and the damage is double: the campaign cost goes out and a negative signal comes in. The recommendation model sees a batch of short sessions from varied profiles and recalibrates downward. Developers describe exactly this pattern: a plays graph climbing during the campaign and organic impressions falling below the previous baseline over the following two weeks.
Run the math with your own CPM before you hit play on the campaign. If you pay for 3,000 clicks and 60% leave in under a minute, you paid full price for 1,200 useful sessions — the cost per qualified session is 2.5 times the cost per click you think you are paying. Fixing QPTR from 40% to 55% drops that multiplier to 1.8 without spending one extra Robux on media.
The way out is boring and it works: pause the campaign, collect 7 days of baseline with the funnel on, attack the step with the biggest drop, wait another 7 days, compare. Only start buying traffic again when the ratio of survived_60s to entered stops embarrassing you.
The second mistake, the technical one, is measuring session duration only on PlayerRemoving. When the server crashes or is shut down, that event may not give you time to write anything, and the sessions that vanish are exactly the long ones — which artificially inflates your sense that the problem is only the beginning. That is why the module has game:BindToClose closing everything still open. And never, under any circumstances, try to inflate play count with fake accounts or bots: plays go up, QPTR and D1 collapse, and the algorithm only learns that you cannot hold anyone.
What to measure on Monday
Open the funnel and write down four numbers: p50 and p90 of time_to_playable_ms, the rate of playable over entered, the rate of survived_60s over playable, and the rate of survived_5min over survived_60s. The smallest of those rates is your sprint. Do not pick by Discord group opinion.
Set a numeric 30-day goal — say, 10 more percentage points of QPTR — and one change per week, isolated, so you can attribute the effect. Log the date of each deploy in a notes file along with the previous day's number; without that, the dashboard graph turns into guesswork.
And track Home impressions with a 14-day lag relative to the engagement change. The model does not react the same day: you fix the first minute now and the impression curve responds later, once it has enough data to trust you again.
Ready-to-use artifact
--!strict
-- SessionFunnel: server-side session funnel for Home recommendation signals.
-- Place this as a ModuleScript named "SessionFunnel" inside ServerScriptService.
-- require() it once from a Script in ServerScriptService; all connections are made on require.
-- Analytics events do NOT record in Studio sessions. Test on a published server.
local Players = game:GetService("Players")
local AnalyticsService = game:GetService("AnalyticsService")
local DataStoreService = game:GetService("DataStoreService")
local HttpService = game:GetService("HttpService")
-- Tune these two. 60s is the threshold worth chasing as a proxy for a qualified play-through.
local QUALIFIED_SECONDS: number = 60
local DEEP_SECONDS: number = 300
local STORE_NAME: string = "SessionFunnel_v1"
-- Three custom fields per event, maximum. These are the documented keys.
local F1: string = Enum.AnalyticsCustomFieldKeys.CustomField01.Name
local F2: string = Enum.AnalyticsCustomFieldKeys.CustomField02.Name
local F3: string = Enum.AnalyticsCustomFieldKeys.CustomField03.Name
local lastSeenStore = DataStoreService:GetDataStore(STORE_NAME)
export type Session = {
sessionId: string,
joinedAt: number,
playableAt: number?,
firstActionAt: number?,
closed: boolean,
}
local sessions: { [Player]: Session } = {}
local SessionFunnel = {}
-- A malformed field table throws. Never let analytics kill the caller.
local function log(player: Player, eventName: string, value: number?, fields: { [string]: string }?)
local ok, err: any = pcall(function()
AnalyticsService:LogCustomEvent(player, eventName, value, fields)
end)
if not ok then
warn(("[SessionFunnel] %s failed: %s"):format(eventName, tostring(err)))
end
end
local function utcDay(unixTime: number): number
return math.floor(unixTime / 86400)
end
-- D1 is fired only when the gap is exactly 1 day, so you get a cohort number, not an aggregate.
local function checkReturn(player: Player, sessionId: string)
local key = "u_" .. tostring(player.UserId)
local today = utcDay(os.time())
local ok, stored: any = pcall(function()
return lastSeenStore:GetAsync(key)
end)
if ok and typeof(stored) == "number" then
local delta = today - (stored :: number)
if delta == 1 then
log(player, "d1_return", 1, { [F1] = sessionId })
elseif delta > 1 then
log(player, "return_after_days", delta, { [F1] = sessionId })
end
end
pcall(function()
lastSeenStore:SetAsync(key, today)
end)
end
-- Steps 4 and 5 are automatic. The session identity check stops a rejoin from double-counting.
local function scheduleThreshold(player: Player, session: Session, seconds: number, eventName: string)
task.delay(seconds, function()
local current = sessions[player]
if current ~= session then return end
if current.closed then return end
if player.Parent ~= Players then return end
log(player, eventName, seconds, { [F1] = session.sessionId })
end)
end
-- Call this from the server when the client says its loading screen is gone.
function SessionFunnel.markPlayable(player: Player)
local session = sessions[player]
if not session then return end
if session.playableAt ~= nil then return end -- idempotent: the client may fire twice
local now = os.clock()
session.playableAt = now
local ms = math.floor((now - session.joinedAt) * 1000)
log(player, "playable", 1, { [F1] = session.sessionId })
-- Read this one at p50 and p90. The average hides the phone you are losing.
log(player, "time_to_playable_ms", ms, { [F1] = session.sessionId, [F2] = tostring(player.UserId) })
end
-- Call this on the first event you consider real engagement.
function SessionFunnel.markFirstAction(player: Player, actionName: string)
local session = sessions[player]
if not session then return end
if session.firstActionAt ~= nil then return end
local now = os.clock()
session.firstActionAt = now
local sinceJoin = math.floor(now - session.joinedAt)
local playableAt = session.playableAt
local sincePlayable = if playableAt ~= nil then math.floor(now - playableAt) else -1
log(player, "first_action", sinceJoin, {
[F1] = session.sessionId,
[F2] = actionName,
[F3] = tostring(sincePlayable),
})
end
local function closeSession(player: Player)
local session = sessions[player]
if not session then return end
if session.closed then return end
session.closed = true
sessions[player] = nil
local duration = math.floor(os.clock() - session.joinedAt)
log(player, "session_end", duration, {
[F1] = session.sessionId,
[F2] = if session.playableAt ~= nil then "playable" else "never_playable",
[F3] = if session.firstActionAt ~= nil then "acted" else "idle",
})
end
local function onPlayerAdded(player: Player)
local session: Session = {
sessionId = HttpService:GenerateGUID(false),
joinedAt = os.clock(),
playableAt = nil,
firstActionAt = nil,
closed = false,
}
sessions[player] = session
log(player, "entered", 1, { [F1] = session.sessionId, [F2] = tostring(player.AccountAge) })
scheduleThreshold(player, session, QUALIFIED_SECONDS, "survived_60s")
scheduleThreshold(player, session, DEEP_SECONDS, "survived_5min")
task.spawn(checkReturn, player, session.sessionId)
end
for _, player in Players:GetPlayers() do
onPlayerAdded(player)
end
Players.PlayerAdded:Connect(onPlayerAdded)
Players.PlayerRemoving:Connect(closeSession)
-- The sessions that die in a shutdown are the LONG ones. Without this you only measure short ones.
game:BindToClose(function()
for _, player in Players:GetPlayers() do
closeSession(player)
end
end)
return SessionFunnel
| Metric | Where to read it | Example (30 days) | Target | Delta |
| --- | --- | --- | --- | --- |
| Home impressions | Dashboard > Analytics > Acquisition | 100,000 | 100,000 | 0 (no media spend) |
| Play button click-through | Acquisition, impressions vs. plays | 3% = 3,000 plays | 3% = 3,000 plays | 0 |
| p90 `time_to_playable_ms` | Custom events, 90th percentile | 11,000 ms | 4,000 ms | -7,000 ms |
| `first_action` / `playable` | Custom events ratio | 62% | 85% | +23 pp |
| Qualified play-through (`survived_60s` / `entered`) | Custom events ratio | 40% = 1,200 sessions | 55% = 1,650 sessions | +450 sessions |
| Depth (`survived_5min` / `survived_60s`) | Custom events ratio | 30% = 360 sessions | 40% = 660 sessions | +300 sessions |
| D1 retention | Engagement > Retention | 20% of 1,200 = 240 returns | 28% of 1,650 = 462 returns | +222 returns |
| Cost per qualified session | Campaign spend / `survived_60s` | 2.5x cost per click | 1.8x cost per click | -0.7x |


