AchievementKit
AchievementKit turns bus traffic into permanent unlocks. An achievement is three things: a bus topic to listen for, an optional Where filter over that event’s args, and a target Count. The defining design decision is permanence: progress lives in profile.Data, so an achievement half-earned tonight is still half-earned next week, and once unlocked it can never re-lock or re-fire — not even if you delete and re-add the achievement’s config, since the guard is a flag written into the player’s own saved data.
The predicate shape (Where, a function over the raw event args) looks like Rules’ :where — both let you gate a bus topic without hand-rolling a subscription. They are not the same code, though: AchievementKit is a small, separate implementation with its own persistence and once-ever unlock semantics that Rules doesn’t have, not a wrapper around it. Reach for Rules when you want general event automation (cooldowns, counted matches, one-shots keyed per player); reach for AchievementKit specifically when the outcome must be a permanent, per-player unlock.
Mental model
Section titled “Mental model”attach() walks options.Achievements and, for each one, subscribes directly to its Topic — one subscription per achievement, not deduplicated by topic the way QuestKit builds a shared topic index. For most achievement counts this is fine; if you have dozens of achievements sharing one hot topic, that’s dozens of independent handlers each doing their own filter and store lookup.
On every publish to that topic:
Wherefilter, if present. Runs underpcallwith the raw event args. An error or an explicitly falsy return (nil/false) means the event doesn’t count; any other truthy return (including a table) does —Whereis not required to returntrueexactly, just something truthy.- Resolve the session.
SessionOf(...)if given, otherwise the first published arg (every kit’s convention). If the result isn’t a table, the event is silently ignored — no session, no progress. - Advance. Already unlocked? No-op — this is the once-ever guard, checked before anything else touches the counters. Otherwise the stored count increments by exactly 1 (regardless of how many things matched in this one event) and
Achievement.Progresspublishes with the new count. - Unlock at target. When the incremented count reaches
Count, the entry flips to unlocked, the progress counter is deleted (Progress[id] = nil— there’s nothing left to track),Achievement.Unlockedpublishes, andReward(if any) runs viatask.spawnin its own thread, so a throwing reward can’t stop the unlock from registering or break other achievements processing the same event.
Where progress lives
Section titled “Where progress lives”local Data = if session.Profile then session.Profile.Data else session.DataSame late-resolution idea as QuestKit: the store is looked up fresh on every event, not cached at session start, so progress correctly lands in session.Data before a profile attaches and in profile.Data after. Persisting past the current visit needs a DataDriver attached — without one, everything lives in transient session.Data and is gone on leave.
Under a profile, the book is keyed by Field (default "Achievements") as { Progress = { [id] = count }, Unlocked = { [id] = true } }.
Merging pre-profile progress
Section titled “Merging pre-profile progress”Events can land before the profile attaches — the window between session start and Kernel.ProfileLoaded — and until then they accumulate in session.Data[Field] as described above. attach() also subscribes directly to Kernel.ProfileLoaded, and the instant it fires, that transient book merges into profile.Data[Field]:
- Unlocks carry over unconditionally. Every id in the transient
Unlockedset is written into the persistedUnlockedset, and its persistedProgressentry (if any) is cleared — same as any other unlock, there’s nothing left to track. - Progress takes the higher count. For an id still sitting in the transient
Progresstable (not yet unlocked), the persisted count becomesmath.max(Persisted.Progress[id] or 0, Count)— a straight max, not a sum, so a partial session merging against older persisted progress can’t double-count. - Already-unlocked achievements drop the session count. The max-merge only runs when the persisted book doesn’t already have that id unlocked. If it does — the player unlocked this achievement in an earlier saved session — whatever count
session.Dataaccumulated for it this time is simply discarded. This can’t collide with rule 1, becauseProgress[id]is nil’d the moment an achievement unlocks (see step 4 in the mental model above): a transientProgressentry and a transientUnlockedentry never exist for the same id at once.
session.Data[Field] is deleted once the merge runs, so it fires exactly once per session — events processed afterward read and write profile.Data[Field] directly, same as if the profile had been present from the start.
This is the same merge shape QuestKit uses on its own Kernel.ProfileLoaded handler: completed/unlocked status wins outright, in-progress counts merge via math.max, and progress against an already-complete persisted entry is dropped. It isn’t shared code — QuestKit’s book tracks a Counts table per objective index inside a quest entry, rather than one scalar count per id — but the three rules above apply at whatever granularity each kit stores its progress in.
local ServerScriptService = game:GetService("ServerScriptService")local AchievementKit = require(ServerScriptService.ChloeKernelServer.Kits.AchievementKit)local InventoryKit = require(ServerScriptService.ChloeKernelServer.Kits.InventoryKit)
return function(kernel: any) local Inventory = InventoryKit.attach(kernel, { Items = { SlayerCape = { Name = "Slayer Cape", Stack = 1 } }, })
local Achievements = AchievementKit.attach(kernel, { Achievements = { -- Simple counter: 100 kills, no filter Slayer = { Topic = "Combat.Kill", Count = 100, Reward = function(session) Inventory:grant(session, "SlayerCape", 1) end, }, -- Filtered: only legendary drops count, and only 1 is needed FirstLegendary = { Topic = "Inventory.Granted", Where = function(_session, itemId) return Items[itemId].Rarity == "Legendary" end, Count = 1, Reward = function(session) session.Profile.Data.Coins += 1000 end, }, }, })
kernel.Bus:subscribe("Achievement.Progress", function(_, session, id, count, target) print(`{session.Player.Name}: {id} — {count}/{target}`) end) kernel.Bus:subscribe("Achievement.Unlocked", function(_, session, id) print(`{session.Player.Name} unlocked {id}`) end)
-- Reading progress for a UI (e.g. answering a request on menu open) kernel:onSession(function(session) session.Player.CharacterAdded:Connect(function() local Count, Target, Unlocked = Achievements:progress(session, "Slayer") print(`Slayer: {Count}/{Target}, unlocked = {Unlocked}`) end) end)endFirstLegendary’s Where receives the same args Inventory.Granted publishes after the session (per the default SessionOf), so it filters on itemId exactly like a Rules :where would. Rewards run through whatever your game already uses to grant things — InventoryKit, the profile directly, LootKit — there’s no reward-specific API here, just a callback.
Configuration
Section titled “Configuration”AchievementKit.attach(kernel, options) — Options
Section titled “AchievementKit.attach(kernel, options) — Options”| Field | Type | Default | Description |
|---|---|---|---|
Achievements |
{ [string]: Achievement } |
required | Achievement id → definition. |
Field |
string? |
"Achievements" |
Key in session.Profile.Data / session.Data where the progress/unlock book lives. |
Achievement
Section titled “Achievement”| Field | Type | Default | Description |
|---|---|---|---|
Topic |
string |
required | Bus topic that advances this achievement. |
Count |
number? |
1 |
Events required to unlock. |
Where |
((...any) -> boolean)? |
— | Filter over the raw published args. Errors or a falsy return don’t count; anything else does. |
SessionOf |
((...any) -> any)? |
the first published arg | Resolves the session from the event args. A non-table result silently drops the event. |
Reward |
((session: any) -> ())? |
— | Runs once, in its own thread, the instant the achievement crosses Count. |
API reference
Section titled “API reference”| Member | Description |
|---|---|
AchievementKit.attach(kernel, options: Options) |
Subscribes one Bus listener per achievement’s Topic. Returns the kit instance. |
instance:progress(session, id: string) -> (count: number, target: number, unlocked: boolean) |
Read accessor for UI. Unlocked achievements report (Count, Count, true) since the running counter is cleared at unlock. Errors on an unknown id. |
instance:unlocked(session, id: string) -> boolean |
Whether this player has ever unlocked id. |
instance:destroy() |
Disconnects every Bus subscription. |
Bus topics
Section titled “Bus topics”Published:
| Topic | Args | When |
|---|---|---|
Achievement.Progress |
session, id, count, target |
Every accepted event, including the one that reaches target (the Unlocked topic follows right after). |
Achievement.Unlocked |
session, id |
The instant count reaches target. Fires exactly once per player per achievement, ever — the unlocked flag is checked before any other work happens. |
Consumed: none — each achievement’s Topic is its only input, subscribed directly at attach().