Sessions
Gives every player one server-side session that owns their per-visit state and tears down everything bound to it when they leave.
- Realm
- Server
- Require
ServerScriptService.ChloeKernelServer .Session - Since
- v0.1.0
- Source
- Session.luau
A session is a per-player lifetime object: created when the player joins, destroyed when they leave, one per player,
server only. Anything a system attaches with session:bind() (a connection, an instance, a cleanup function) is torn
down on leave, so per-player state never leaks across joins and no system wires its own PlayerAdded and
PlayerRemoving pair. A session also carries how the player arrived (JoinData), their engine identity (User) and,
at the end, why they left (ExitReason). Sessions live in ServerScriptService.ChloeKernelServer and never replicate;
the layer split is on Kernel & Boot. Moments that happen once per session, such as a loaded
profile, are replayed to late subscribers by Milestones, and persistent data comes from
DataDriver.
Mental model
Section titled “Mental model”The server kernel wires sessions once, at boot:
Players.PlayerAddedcreates a session and stores it inkernel.Sessions[player]. Players already in the game at boot get sessions immediately, so a kernel that boots late (a Studio play test, an in-place restart) misses no one.Players.PlayerRemovingends the session with the engine’s exit reason.- The live session count is written to the
ChloeKernelSessionsattribute onReplicatedStorageat every join and leave, so tooling can watch population without touching the kernel. - No session is created once server shutdown has begun.
Each transition announces itself twice, on purpose: a hook first, then a Bus topic. Hooks run synchronously in priority order on one shared context table, so a hook handler that seeds per-player state finishes before any Bus subscriber sees the session.
On join:
Session.new(player):Active = true, an emptyDatatable,JoinDataread once fromPlayer:GetJoinData()andUserfromPlayer.User.- The session goes into
kernel.Sessions, and the count and attribute update. - Hook
Session.Startfires with{ Session, Player }. kernel.OnSessionStartfires. This is whatonSessioncallbacks connect to.- Bus topic
Kernel.SessionStartpublishes with the session.
On leave:
- The session leaves
kernel.Sessions(getSession(player)already returnsnil), the count and attribute update, andsession.ExitReasonis set. - Hook
Session.Endfires with{ Session, Player, ExitReason }. The session is stillActive. - Bus topic
Kernel.SessionEndpublishes with(session, exitReason). StillActive. session:destroy()runs even if one of the steps above threw:Activebecomesfalse, bindings tear down in insertion order,OnEndfires and is destroyed, then thebindLastfinalizers run in order.
Per-player behavior goes in a service’s start through onSession:
--!strict-- StreakService: counts spawns per visit.
local StreakService = { Name = "StreakService",}
function StreakService.init(self: any, kernel: any) self.Kernel = kernelend
function StreakService.start(self: any) self.SessionConnection = self.Kernel:onSession(function(session) session.Data.JoinedAt = os.clock() session.Data.Spawns = 0
-- Disconnected on leave session:bind(session.Player.CharacterAdded:Connect(function(character) session.Data.Spawns += 1 character:SetAttribute("Spawns", session.Data.Spawns) end))
-- Runs on leave, before OnEnd fires session:bind(function() print(session.Player.Name, "played for", os.clock() - session.Data.JoinedAt) end) end)end
function StreakService.stop(self: any) if self.SessionConnection then self.SessionConnection:disconnect() endend
return StreakServiceonSession runs fn for current and future sessions. Sessions that already exist are delivered at once, each on its
own thread through task.spawn, and the returned Signal connection covers everyone who joins later.
A system that registers late misses no one, which a plain Kernel.SessionStart subscription would: players present at
boot get their sessions before any service registers.
Fetch a session anywhere after boot:
local ServerKernel = require(game:GetService("ServerScriptService").ChloeKernelServer)
local PlayerSession = ServerKernel.current():getSession(SomePlayer)if PlayerSession then PlayerSession.Data.Spawns += 1endJoin data, user and exit reason
Section titled “Join data, user and exit reason”Three fields describe the visit itself (added in 0.9.0):
| Field | Source | Notes |
|---|---|---|
session.JoinData |
Player:GetJoinData(), read once when the session starts |
SourcePlaceId, TeleportData, ReferredByPlayerId, LaunchData, GameJoinContext, Members. {} when the call fails or returns something other than a table. |
session.User |
Player.User |
The engine’s identity object for this player, or nil on engines that do not expose it. |
session.ExitReason |
The Enum.PlayerExitReason passed by PlayerRemoving |
Unknown, PlatformKick or CreatorKick. nil while the session is active, and nil when the kernel ended the session during shutdown. |
The exit reason also reaches the Session.End hook context and the Kernel.SessionEnd topic:
local Logger = require(game:GetService("ReplicatedStorage").ChloeKernel.Logger)
local Log = Logger.scope("Arrivals")local LobbyPlaceId = 1234567890
kernel:onSession(function(session) if session.JoinData.SourcePlaceId == LobbyPlaceId then session.Data.FromLobby = true endend)
kernel.Bus:subscribe("Kernel.SessionEnd", function(_topic, session, exitReason) if exitReason == Enum.PlayerExitReason.CreatorKick then Log.info("kicked", session.Player.UserId) endend)Data vs Profile
Section titled “Data vs Profile”A session carries two state containers with different lifetimes:
| Field | Lifetime | Backed by |
|---|---|---|
session.Data |
This visit only. Starts as {} on every join and is dropped on leave. |
Nothing; a plain table. |
session.Profile |
Persistent across visits. nil until a data driver sets it. |
DataDriver through DataDriver:attach. |
Scratch state (combo counters, join timestamps, per-life flags) belongs in Data. Anything that must survive a rejoin
belongs in Profile.Data. The source types Profile as the untyped Data.Profile?; cast it to your profile shape for
typed access.
Profiles load asynchronously, so the load is still in flight when Session.Start fires. session.ProfilePending is
true while DataDriver:attach loads the profile, and nil before a load starts, after it finishes (whether it
succeeded or failed) and when no DataDriver is attached. While it is true, write nothing that must persist: a reward
paid into a session without a profile is lost. The framework kits already account for it: achievement unlocks and
quest completions wait for the profile, and timed crafts refuse with Loading.
Per-player setup that needs data waits for the profile with Milestones.on(kernel, "Kernel.ProfileLoaded", fn). It also
replays profiles that loaded before the subscription, which a plain Bus subscription would miss. See
Milestones.
Other modules add their own fields: Passes sets session.Passes, Policy sets
session.Policy, and Milestones records session.Milestones.
bind() semantics
Section titled “bind() semantics”session:bind(item) accepts four kinds of item and returns the item unchanged, so it can wrap an expression inline. On
teardown each is released by type:
| Item type | Teardown | Errors |
|---|---|---|
function |
Called with no arguments. | Swallowed by pcall, so one throwing cleanup cannot block the rest. |
RBXScriptConnection |
:Disconnect(). |
Not wrapped; cannot throw. |
Instance |
:Destroy(). |
Not wrapped. |
table |
The first method found among disconnect, destroy and Destroy, called once. |
Swallowed by pcall. |
The table case covers Signal connections (disconnect), kernel objects such as scopes (destroy) and Roblox-style
objects (Destroy) without the session knowing their types. Bindings run in insertion order.
kernel:onSession(function(session) -- Instance: destroyed on leave local Marker = session:bind(Instance.new("Part")) Marker.Anchored = true Marker.Parent = workspace
-- Signal connection: disconnected on leave session:bind(kernel.Bus:subscribe("Round.Ended", function() session.Data.Streak = 0 end))
-- Scope: everything it owns closes on leave local Lifetime = session:bind(kernel:createScope()) Lifetime:add(kernel.Scheduler:every(5, function() session.Data.Ticks = (session.Data.Ticks or 0) + 1 end))end)Binding to an ended session errors cannot bind to an ended session, so a system holding a stale session reference
fails loudly instead of leaking.
bindLast()
Section titled “bindLast()”session:bindLast(fn) registers a function that runs after every binding and after the OnEnd listeners. DataDriver
binds the player’s final save this way. Writes made by ordinary bindings, including cleanups bound inside a
Kernel.ProfileLoaded handler, therefore land in that save. Before 0.9.0 the final save ran first and those leave-time
writes were lost.
Game code uses bind. bindLast is for persistence layers that must run after everything else. It takes functions only,
runs each under pcall and errors on an ended session the same way bind does.
API reference
Section titled “API reference”ServerKernel session surface
Section titled “ServerKernel session surface”| Member | Description |
|---|---|
kernel:onSession(fn: (session) -> ()): Connection |
Runs fn for every current session (through task.spawn) and every future one. Asserts server kernel is shut down after shutdown. |
kernel:getSession(player: Player): Session? |
The live session, or nil once the player has left (already during Session.End). |
kernel.Sessions |
The live { [Player]: Session } map. |
kernel.SessionCount |
Number of live sessions. |
kernel.OnSessionStart |
The underlying Signal. onSession is this plus delivery of existing sessions. |
Session
Section titled “Session”| Member | Description |
|---|---|
session.Player |
The Player. |
session.Data |
Per-visit scratch table, empty at start. |
session.Profile |
Persistent profile set by DataDriver:attach; nil until then. |
session.ProfilePending |
true while DataDriver:attach loads the profile. |
session.JoinData |
Player:GetJoinData() read at start, or {}. |
session.User |
Player.User, or nil. |
session.ExitReason |
Enum.PlayerExitReason from PlayerRemoving; nil while active and after a shutdown. |
session.Active |
true from creation until destroy() begins. |
session.Milestones |
Moments recorded by Milestones.publish, by name. |
session.OnEnd |
Signal fired once with the session, after the bindings run. |
session:bind(item): item |
Registers an item for teardown and returns it. Errors on an ended session. |
session:bindLast(fn) |
Registers a function to run after every binding and OnEnd listener. Errors on an ended session. |
session:destroy() |
Called by the kernel on leave. Idempotent. |
Hooks and bus topics
Section titled “Hooks and bus topics”| Name | Type | Payload | Fired |
|---|---|---|---|
Session.Start |
Hook (observational) | { Session, Player } |
On join, before the Bus topic. The kernel ignores the verdict, so a join cannot be vetoed, but handlers run synchronously: seed per-player state here and Bus subscribers already see it. |
Session.End |
Hook (observational) | { Session, Player, ExitReason } |
On leave, before the Bus topic and before teardown. |
Kernel.SessionStart |
Bus topic | session |
After Session.Start and after onSession callbacks are dispatched. |
Kernel.SessionEnd |
Bus topic | session, exitReason? |
After Session.End, before destroy(). exitReason is the third handler argument after the topic, and nil during shutdown. |
Kernel.ProfileLoaded |
Bus topic (milestone) | session, profile |
Published by DataDriver once session.Profile is set. Subscribe with Milestones.on to receive loads that already happened. |
-- Hook: seed state synchronously, before anything else observes the sessionkernel.Hooks:on("Session.Start", function(context) context.Session.Data.Team = if context.Player.UserId % 2 == 0 then "Red" else "Blue"end)
-- Bus: react to the factkernel.Bus:subscribe("Kernel.SessionEnd", function(_topic, session, exitReason) kernel.Bus:publish("Lobby.Left", session.Player.Name, exitReason)end)