Skip to content

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.

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:

  1. Where filter, if present. Runs under pcall with 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 — Where is not required to return true exactly, just something truthy.
  2. 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.
  3. 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.Progress publishes with the new count.
  4. 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.Unlocked publishes, and Reward (if any) runs via task.spawn in its own thread, so a throwing reward can’t stop the unlock from registering or break other achievements processing the same event.
local Data = if session.Profile then session.Profile.Data else session.Data

Same 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 } }.

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]:

  1. Unlocks carry over unconditionally. Every id in the transient Unlocked set is written into the persisted Unlocked set, and its persisted Progress entry (if any) is cleared — same as any other unlock, there’s nothing left to track.
  2. Progress takes the higher count. For an id still sitting in the transient Progress table (not yet unlocked), the persisted count becomes math.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.
  3. 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.Data accumulated for it this time is simply discarded. This can’t collide with rule 1, because Progress[id] is nil’d the moment an achievement unlocks (see step 4 in the mental model above): a transient Progress entry and a transient Unlocked entry 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)
end

FirstLegendary’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.

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

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().