Skip to content

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.

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:

  1. Where, if present, runs under pcall with the event arguments. An error or a falsy return means the event does not count. Any truthy return counts.
  2. 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.
  3. If the achievement is already unlocked for this player, nothing happens. This once-ever check runs before the counters change.
  4. The count goes up by exactly 1, capped at the target, and Achievement.Progress publishes.
  5. At the target, the achievement unlocks: the flag is set, the counter is removed, Achievement.Unlocked publishes, Reward runs on its own thread and, with a BadgeId, 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.

local Data = if session.Profile then session.Profile.Data else session.Data

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

When Kernel.ProfileLoaded fires and session.Data holds a book, it merges into profile.Data[Field]:

  1. Every transient unlock is copied into the persisted Unlocked set and its persisted progress is cleared.
  2. 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.
  3. 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.

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, with players from the kernel’s session count (at least 1). The rates are the documented per-minute limits: AwardBadgeAsync 50 + 35 per player, GetUserBadgesAsync 10 + 5 per player.
  • A failed award retries after 2^attempt seconds (at most 60) while the player stays, up to BadgeRetries (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. A true publishes Achievement.BadgeAwarded.
  • On Kernel.ProfileLoaded, the kit collects unlocked achievements whose badge it has not seen owned and checks them with one GetUserBadgesAsync call 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:

src/Server/Game/Services/EconomyService.luau
--!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()
end
end
return EconomyService

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

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

ChloeKernel v0.9.1. Access to the framework is private and granted individually by Chloe. Games built with it credit CryptedChloes in their description.