AchievementKit
Turns Bus topics into permanent per-player unlocks with persisted progress, one-time rewards and optional Roblox badge awards.
- Realm
- Server
- Require
ServerScriptService.ChloeKernelServer .Kits .AchievementKit - Since
- v0.7.0
- Source
- Kits/AchievementKit.luau
AchievementKit turns Bus traffic into permanent unlocks. An achievement is a Bus topic to count, an
optional Where filter over the event’s arguments and a target Count. Progress lives in the profile, so an
achievement half-earned tonight is still half-earned next week, and an unlock is a flag in the player’s saved data: it
never re-locks and its reward never runs twice. An achievement with a BadgeId also awards that Roblox badge, under the
engine’s documented rate, and reconciles missing badges when the profile loads.
QuestKit counts topics the same way for assignable, repeatable goals.
Rules :where gates topics for general event automation; AchievementKit is a separate, smaller
implementation for one outcome, a permanent per-player unlock.
Mental model
Section titled “Mental model”attach() subscribes once per achievement to its Topic. Subscriptions are not shared by topic the way QuestKit’s
topic index is, so dozens of achievements on one hot topic are dozens of handlers, each doing its own filter and store
lookup.
On every publish to that topic:
Where, if present, runs underpcallwith the event arguments. An error or a falsy return means the event does not count. Any truthy return counts.- The session is
SessionOf(...)if given, otherwise the first argument (every kit’s publish convention). A result that is not a table drops the event. - If the achievement is already unlocked for this player, nothing happens. This once-ever check runs before the counters change.
- The count goes up by exactly 1, capped at the target, and
Achievement.Progresspublishes. - At the target, the achievement unlocks: the flag is set, the counter is removed,
Achievement.Unlockedpublishes,Rewardruns on its own thread and, with aBadgeId, a badge award is queued.
Unlocks wait for the profile. While session.ProfilePending is true the count still advances (in session.Data) but
nothing unlocks, so a reward never pays into data that is about to be replaced. The profile merge below unlocks what
reached its target.
Where progress lives
Section titled “Where progress lives”local Data = if session.Profile then session.Profile.Data else session.DataThe store is resolved on every event, as in QuestKit. Progress lands in session.Data before the profile attaches and
in profile.Data after. Keeping it past the visit needs a DataDriver. The book sits under
Field (default "Achievements") as { Progress = { [id]: count }, Unlocked = { [id]: true } }.
Merging pre-profile progress
Section titled “Merging pre-profile progress”When Kernel.ProfileLoaded fires and session.Data holds a book, it merges into profile.Data[Field]:
- Every transient unlock is copied into the persisted
Unlockedset and its persisted progress is cleared. - For an id the profile has not unlocked, the persisted count becomes the higher of the two counts. It is a max, not a sum, so a partial session cannot double-count.
- For an id the profile already unlocked, the transient count is discarded. An achievement the profile already holds never unlocks or rewards again.
The transient book is then deleted, and the kit unlocks every persisted achievement whose count has reached its target,
running Reward against the loaded profile. Events after the merge read and write profile.Data directly.
Badges
Section titled “Badges”Give an achievement a positive BadgeId and its unlock also awards that platform badge. attach asserts the id is a
positive number and resolves BadgeService only if some achievement has one.
- Awards go through a queue that runs one badge call at a time. After each call, the next call of the same kind waits
60 / (Base + PerPlayer * players)seconds, withplayersfrom the kernel’s session count (at least 1). The rates are the documented per-minute limits:AwardBadgeAsync50 + 35 per player,GetUserBadgesAsync10 + 5 per player. - A failed award retries after
2^attemptseconds (at most 60) while the player stays, up toBadgeRetries(default 5) times, then gives up with a warning. When the player leaves, their queued jobs and retry timers are dropped. - An award call that succeeds but returns
false(a disabled badge, for example) logs a warning. AtruepublishesAchievement.BadgeAwarded. - On
Kernel.ProfileLoaded, the kit collects unlocked achievements whose badge it has not seen owned and checks them with oneGetUserBadgesAsynccall per 100 ids. Badges the player lacks are queued for award. If the check keeps failing past the retries, the kit awards them all and lets the engine refuse the ones already owned.
Badge awards never run Reward. Rewards run once, at unlock.
AchievementKit is an attach kit. Attach it from the owning service’s init, after any kit its rewards use:
--!strict-- EconomyService: the inventory and the achievements that grant into it.
local ServerScriptService = game:GetService("ServerScriptService")
local AchievementKit = require(ServerScriptService.ChloeKernelServer.Kits.AchievementKit)local InventoryKit = require(ServerScriptService.ChloeKernelServer.Kits.InventoryKit)local Items = require(script.Parent.Parent.Config.Items)
local EconomyService = { Name = "EconomyService", Dependencies = { "DataService" },}
function EconomyService.init(self: any, kernel: any) self.Kernel = kernel self.Inventory = InventoryKit.attach(kernel, { Items = Items }) self.Achievements = AchievementKit.attach(kernel, { Achievements = { -- A plain counter with a platform badge Slayer = { Topic = "Combat.Kill", Count = 100, BadgeId = 2124000000, -- the badge's id from the Creator Hub Reward = function(session) self.Inventory:grant(session, "SlayerCape", 1) end, }, -- Filtered: Inventory.Granted(session, itemId, count) FirstLegendary = { Topic = "Inventory.Granted", Where = function(_session, itemId) return Items[itemId].Rarity == "Legendary" -- an unknown id errors and does not count end, Reward = function(session) -- Unlocks wait for the profile, so it exists here with a DataDriver attached local Data = session.Profile.Data Data.Coins = (Data.Coins or 0) + 1000 end, }, }, })end
function EconomyService.stop(self: any) if self.Achievements then self.Achievements:destroy() endend
return EconomyServiceRead progress for a menu with progress:
local Count, Target, Unlocked = Economy.Achievements:progress(session, "Slayer")Rewards use whatever the game already grants with: InventoryKit, the profile, or LootKit. There is no reward API, just the callback.
Configuration
Section titled “Configuration”AchievementKit.attach(kernel, options)
Section titled “AchievementKit.attach(kernel, options)”| Field | Type | Default | Description |
|---|---|---|---|
Achievements |
{ [string]: Achievement } |
required | Achievement id to definition. |
Field |
string? |
"Achievements" |
Key in session.Profile.Data or session.Data that holds the book. |
BadgeRetries |
number? |
5 |
Award or check attempts after an error while the player stays. |
BadgeService |
any? |
BadgeService |
Test injection point. |
Achievement
Section titled “Achievement”| Field | Type | Default | Description |
|---|---|---|---|
Topic |
string |
required | Bus topic that advances this achievement. Asserted at attach. |
Count |
number? |
1 |
Events required to unlock. |
Where |
((...any) -> boolean)? |
nil |
Filter over the event arguments. An error or a falsy return does not count. |
SessionOf |
((...any) -> any)? |
the first argument | Resolves the session from the event. A non-table result drops the event. |
Reward |
((session) -> ())? |
nil |
Runs once, on its own thread, at unlock. |
BadgeId |
number? |
nil |
Roblox badge awarded at unlock and reconciled on profile load. Must be positive. |
API reference
Section titled “API reference”| Member | Description |
|---|---|
AchievementKit.attach(kernel, options) -> kit |
Subscribes one handler per achievement topic, plus Kernel.ProfileLoaded and, with badges, Kernel.SessionEnd. |
kit:progress(session, id) -> (number, number, boolean) |
count, target, unlocked for UI. An unlocked achievement reports target, target, true. Errors on an unknown id. |
kit:unlocked(session, id) -> boolean |
Whether this player has unlocked id. |
kit:destroy() |
Disconnects every subscription, cancels badge cooldowns and retry timers and drops queued badge jobs. |
Bus topics
Section titled “Bus topics”| Topic | Payload | Direction | When |
|---|---|---|---|
Achievement.Progress |
session, id, count, target |
Published | Every counted event, including the one that reaches the target and those while the profile loads. |
Achievement.Unlocked |
session, id |
Published | Once per player per achievement, when the count reaches the target and the profile is loaded, or in the merge. |
Achievement.BadgeAwarded |
session, id, badgeId |
Published | AwardBadgeAsync returned true. |
Each achievement Topic |
as published | Consumed | One subscription per achievement. |
Kernel.ProfileLoaded |
session, profile |
Consumed | Merges pre-profile progress, unlocks what it finishes and reconciles badges. |
Kernel.SessionEnd |
session |
Consumed | With badges: drops the leaver’s badge jobs and timers. |