§ How to · with Picoo

How to make badges and achievements in Roblox

By Sametcan Tasgiran, Founder & Developer·Published ·Updated

Badges are the one progression feature Roblox hosts for you — and the one where a naive loop gets you rate limited within a minute.

Every badge call is a web request, so the whole design is about asking once, caching the answer, and knowing when a badge is the wrong tool.

Server-side awarding

AwardBadge called from the server, where it is the only place it works.

Cached ownership checks

UserHasBadgeAsync asked once per player per session, not per frame.

Protected calls everywhere

Badge endpoints fail during outages; a wrapped call degrades instead of erroring the script.

In-game achievements

A DataStore-backed achievement layer for the dozens of milestones badges are too heavy for.

Progress tracking

Counters that persist, so "defeat 100 enemies" survives a rejoin.

Unlock UI

A toast on unlock plus a browsable list with progress bars.

Files Picoo ships for this prompt

2 files · 180 lines · ~30s · 1 credit

BadgeService

Awarding, cached ownership, retry and failure handling.

95 lines

AchievementStore

Persistent counters, unlock rules, progress queries.

85 lines

Sample output: ServerScriptService.BadgeService

-- AwardBadge only works from the SERVER. Called from a LocalScript it fails,
-- and it fails quietly enough that people ship it and wonder why nobody has
-- the badge.
local BadgeService = game:GetService("BadgeService")

-- Every one of these is a web request. Asking on a loop, or on every kill, is
-- how you get throttled — and a throttled call does not award the badge.
local owned: { [number]: { [number]: boolean } } = {}

local function hasBadge(userId: number, badgeId: number): boolean
	local cache = owned[userId]
	if cache and cache[badgeId] ~= nil then return cache[badgeId] end

	local ok, result = pcall(BadgeService.UserHasBadgeAsync, BadgeService, userId, badgeId)
	if not ok then return false end        -- outage: skip, do not crash the caller

	owned[userId] = owned[userId] or {}
	owned[userId][badgeId] = result
	return result
end

local function award(plr: Player, badgeId: number)
	if hasBadge(plr.UserId, badgeId) then return end
	local ok, granted = pcall(BadgeService.AwardBadge, BadgeService, plr.UserId, badgeId)
	if ok and granted then
		owned[plr.UserId][badgeId] = true    -- do not re-ask the API
		notify(plr, badgeId)
	end
end

Building badges and achievements in Roblox

Badges look like the easiest feature on the platform: one call, one permanent reward that shows up on the player's Roblox profile. The trap is that the call is a web request, and web requests have rules.

The first rule is location. AwardBadge works from the server and nowhere else. From a LocalScript it simply does not award, and it does not shout about it, so the code ships and the badge never appears and there is nothing obvious to debug. If a badge is not landing, that is the first thing to check.

The second is volume. UserHasBadgeAsync is tempting to call whenever you need to know something — before every award, on every kill, in a loop across every player. Each of those is a network round trip, and Roblox throttles them. A throttled check does not just slow down; it fails, and a failed check means a missed award. Ask once per player when they join, keep the answer in a table, and update that table yourself when you award something. You know the answer changed; you do not need to ask the API again.

The third is fragility. These endpoints have outages, and an unprotected call throws. If that call lives inside your kill handler, an unrelated Roblox incident takes your combat system down with it. Wrapping every badge call means the worst case is a badge that arrives late.

And then the design question: badges are the wrong shape for a long achievement list. Each one is an asset you create and manage, each check is a network call, and none of them can express progress. Use badges for the handful of milestones a player would want on their profile, and keep the other thirty in your own persistent data where they are free to check, free to update, and can show a bar.

See more on the Luau generator, the game builder, or browse the full blog.

Frequently asked

Why isn't my badge being awarded?+

The three usual causes: the call is on the client (AwardBadge only works from the server), the badge belongs to a different place or universe than the one running, or the player already owns it — awarding an owned badge returns false, which looks identical to a failure. Check ownership first and log which case you hit.

Can I call UserHasBadgeAsync whenever I need it?+

No. It is a web request, and calling it per frame, per kill, or in a loop over every player gets you throttled — and throttled calls do not award badges either. Ask once per player per session, cache the result, and update the cache yourself when you award.

Badges or my own achievement system?+

Badges are permanent, visible on the Roblox profile and cost you nothing to store — good for a handful of milestones players want to show off. They are heavy for a long list: each one is an asset you create and every check is a network call. Thirty achievements belong in your own DataStore, with badges reserved for the few that matter.

Do badge calls need pcall?+

Yes. They hit Roblox web endpoints, and those have outages. An unprotected call throws and kills whatever script it is in — which, if it is your kill handler, takes combat with it. Wrap it and degrade: no badge is far better than no game.

How do I track progress toward an achievement?+

Keep the counter in your own persistent data and check the threshold when it changes. Badges have no progress concept — a badge is owned or not — so anything with a bar behind it is your data with a badge awarded at the end.

Related Picoo prompts