Skip to content

Displaying Achievements

This page covers how to display achievements in your game, including loading badge images, handling obscured achievements, and creating effective achievement UI.

Achievement Badge Images

Each achievement has associated images for display:

Property Description
image_url URL to the achievement badge (shown when unlocked or visible)
obscure_image_url URL to the hidden badge (shown for obscured achievements)

Loading Achievement Images

Retrieve image URLs from cached achievements:

// Get image URL for an achievement
sb_achievement_t achievement;
if (sb_get_cached_achievement(gs, "wizard_mode", &achievement)) {
    // Check if obscured and not unlocked
    sb_achievement_progress_t progress;
    bool is_unlocked = false;
    if (sb_get_cached_progress(gs, user_id, "wizard_mode", &progress)) {
        is_unlocked = progress.unlocked;
    }

    const char* image_url;
    if (achievement.obscure && !is_unlocked) {
        image_url = achievement.obscure_image_url;
    } else {
        image_url = achievement.image_url;
    }

    // Load and display the image
    load_image_from_url(image_url);
}
// Get image URL for an achievement
auto achievement = gs.getAchievement("wizard_mode");
if (achievement) {
    auto progress = gs.getProgress(userId, "wizard_mode");
    bool isUnlocked = progress && progress->unlocked;

    std::string imageUrl;
    if (achievement->obscure && !isUnlocked) {
        imageUrl = achievement->obscureImageUrl;
    } else {
        imageUrl = achievement->imageUrl;
    }

    // Load and display the image
    loadImageFromUrl(imageUrl);
}
# Get image URL for an achievement
achievement = gs.get_achievement("wizard_mode")
if achievement:
    progress = gs.get_progress(user_id, "wizard_mode")
    is_unlocked = progress and progress.unlocked

    if achievement.obscure and not is_unlocked:
        image_url = achievement.obscure_image_url
    else:
        image_url = achievement.image_url

    # Load and display the image
    load_image_from_url(image_url)

Pre-caching Images

For smooth UI, pre-cache achievement images when achievements are loaded:

// Pre-cache all achievement images
void preload_achievement_images(sb_game_handle_t gs) {
    if (!sb_has_achievements(gs)) return;

    size_t count = sb_get_cached_achievements_count(gs);
    for (size_t i = 0; i < count; i++) {
        sb_achievement_t ach;
        if (sb_get_cached_achievement_at(gs, i, &ach)) {
            if (ach.image_url && ach.image_url[0])
                queue_image_download(ach.image_url);       // User-implemented
            if (ach.obscure_image_url && ach.obscure_image_url[0])
                queue_image_download(ach.obscure_image_url);
        }
    }
}
class AchievementImageCache {
public:
    void preloadAll(scorbit::GameState& gs) {
        if (!gs.hasAchievements()) return;

        auto achievements = gs.getAchievements();
        for (const auto& ach : achievements) {
            // Queue image download
            if (!ach.imageUrl.empty()) {
                downloadImage(ach.imageUrl);
            }
            if (!ach.obscureImageUrl.empty()) {
                downloadImage(ach.obscureImageUrl);
            }
        }
    }

    void downloadImage(const std::string& url) {
        if (m_cache.count(url)) return;  // Already cached

        // Download asynchronously
        asyncDownload(url, [this, url](const std::vector<uint8_t>& data) {
            std::lock_guard<std::mutex> lock(m_mutex);
            m_cache[url] = data;
        });
    }

    std::optional<std::vector<uint8_t>> getImage(const std::string& url) {
        std::lock_guard<std::mutex> lock(m_mutex);
        auto it = m_cache.find(url);
        if (it != m_cache.end()) {
            return it->second;
        }
        return std::nullopt;
    }

private:
    std::unordered_map<std::string, std::vector<uint8_t>> m_cache;
    std::mutex m_mutex;
};
import threading
import urllib.request

class AchievementImageCache:
    def __init__(self):
        self.cache = {}
        self.lock = threading.Lock()

    def preload_all(self, gs):
        if not gs.has_achievements():
            return

        achievements = gs.get_achievements()
        for ach in achievements:
            if ach.image_url:
                self.download_image(ach.image_url)
            if ach.obscure_image_url:
                self.download_image(ach.obscure_image_url)

    def download_image(self, url):
        with self.lock:
            if url in self.cache:
                return

        def download():
            try:
                data = urllib.request.urlopen(url).read()
                with self.lock:
                    self.cache[url] = data
            except Exception as e:
                print(f"Failed to download {url}: {e}")

        threading.Thread(target=download, daemon=True).start()

    def get_image(self, url):
        with self.lock:
            return self.cache.get(url)

Handling Obscured Achievements

Obscured achievements have hidden names and descriptions until unlocked:

// Get display-ready name and image, handling obscured state
void get_display_info(sb_game_handle_t gs, int64_t user_id, const char *key,
                      const char **out_name, const char **out_desc,
                      const char **out_image) {
    sb_achievement_t ach;
    if (!sb_get_cached_achievement(gs, key, &ach)) return;

    sb_achievement_progress_t prog;
    bool unlocked = sb_get_cached_progress(gs, user_id, key, &prog)
                    && prog.unlocked;

    if (unlocked || !ach.obscure) {
        *out_name  = ach.name;
        *out_desc  = ach.description;
        *out_image = ach.image_url;
    } else {
        *out_name  = "???";
        *out_desc  = "Hidden achievement - unlock to reveal";
        *out_image = ach.obscure_image_url;
    }
}
struct AchievementDisplayInfo {
    std::string name;
    std::string description;
    std::string imageUrl;
    bool isUnlocked;
    bool isObscured;
};

AchievementDisplayInfo getDisplayInfo(
    scorbit::GameState& gs,
    int64_t userId,
    const std::string& key
) {
    AchievementDisplayInfo info;

    auto ach = gs.getAchievement(key);
    if (!ach) return info;

    auto progress = gs.getProgress(userId, key);
    info.isUnlocked = progress && progress->unlocked;
    info.isObscured = ach->obscure;

    // Show real info if unlocked or not obscured
    if (info.isUnlocked || !ach->obscure) {
        info.name = ach->name;
        info.description = ach->description;
        info.imageUrl = ach->imageUrl;
    } else {
        // Show obscured info
        info.name = "???";
        info.description = "Hidden achievement - unlock to reveal";
        info.imageUrl = ach->obscureImageUrl;
    }

    return info;
}
def get_display_info(gs, user_id, key):
    """Get display-ready achievement info, handling obscured state."""
    ach = gs.get_achievement(key)
    if not ach:
        return None

    progress = gs.get_progress(user_id, key)
    is_unlocked = progress and progress.unlocked

    info = {
        'is_unlocked': is_unlocked,
        'is_obscured': ach.obscure
    }

    # Show real info if unlocked or not obscured
    if is_unlocked or not ach.obscure:
        info['name'] = ach.name
        info['description'] = ach.description
        info['image_url'] = ach.image_url
    else:
        # Show obscured info
        info['name'] = "???"
        info['description'] = "Hidden achievement - unlock to reveal"
        info['image_url'] = ach.obscure_image_url

    return info

Creating an Achievement List Display

void display_achievement_list(sb_game_handle_t gs, int64_t user_id) {
    if (!sb_has_achievements(gs)) {
        printf("Loading achievements...\n");
        return;
    }

    size_t count = sb_get_cached_achievements_count(gs);
    for (size_t i = 0; i < count; i++) {
        sb_achievement_t ach;
        if (!sb_get_cached_achievement_at(gs, i, &ach)) continue;

        sb_achievement_progress_t prog;
        bool unlocked = sb_get_cached_progress(gs, user_id, ach.key, &prog)
                        && prog.unlocked;

        // Skip invisible unearned achievements
        if (!ach.visible && !unlocked) continue;

        const char *name, *desc, *image;
        get_display_info(gs, user_id, ach.key, &name, &desc, &image);

        printf("%s %s%s\n    %s\n",
               unlocked ? "[✓]" : "[ ]", name,
               ach.is_trophy ? " 🏆" : "", desc);

        if (!unlocked && ach.count > 1 && prog.progress > 0)
            printf("    Progress: %d/%d\n", prog.progress, ach.count);
        printf("\n");
    }
}
void displayAchievementList(
    scorbit::GameState& gs,
    int64_t userId
) {
    if (!gs.hasAchievements()) {
        std::cout << "Loading achievements...\n";
        return;
    }

    auto achievements = gs.getAchievements();

    // Sort: unlocked first, then by name
    std::sort(achievements.begin(), achievements.end(),
        [&gs, userId](const auto& a, const auto& b) {
            auto pa = gs.getProgress(userId, a.key);
            auto pb = gs.getProgress(userId, b.key);
            bool unlockedA = pa && pa->unlocked;
            bool unlockedB = pb && pb->unlocked;

            if (unlockedA != unlockedB) return unlockedA > unlockedB;
            return a.name < b.name;
        });

    for (const auto& ach : achievements) {
        // Skip invisible achievements that aren't unlocked
        auto progress = gs.getProgress(userId, ach.key);
        bool isUnlocked = progress && progress->unlocked;

        if (!ach.visible && !isUnlocked) {
            continue;
        }

        auto info = getDisplayInfo(gs, userId, ach.key);

        // Display achievement
        std::cout << (isUnlocked ? "[✓] " : "[ ] ");
        std::cout << info.name;

        if (ach.isTrophy) {
            std::cout << " 🏆";
        }

        std::cout << "\n    " << info.description << "\n";

        // Show progress for limited achievements
        if (!isUnlocked && ach.count > 1 && progress) {
            std::cout << "    Progress: " << progress->progress
                      << "/" << ach.count << "\n";
        }

        std::cout << "\n";
    }
}
def display_achievement_list(gs, user_id):
    """Display a formatted list of achievements."""
    if not gs.has_achievements():
        print("Loading achievements...")
        return

    achievements = gs.get_achievements()

    # Sort: unlocked first, then by name
    def sort_key(ach):
        progress = gs.get_progress(user_id, ach.key)
        is_unlocked = progress and progress.unlocked
        return (not is_unlocked, ach.name)

    achievements.sort(key=sort_key)

    for ach in achievements:
        progress = gs.get_progress(user_id, ach.key)
        is_unlocked = progress and progress.unlocked

        # Skip invisible achievements that aren't unlocked
        if not ach.visible and not is_unlocked:
            continue

        info = get_display_info(gs, user_id, ach.key)

        # Display achievement
        status = "[✓]" if is_unlocked else "[ ]"
        trophy = " 🏆" if ach.is_trophy else ""

        print(f"{status} {info['name']}{trophy}")
        print(f"    {info['description']}")

        # Show progress for limited achievements
        if not is_unlocked and ach.count > 1 and progress:
            print(f"    Progress: {progress.progress}/{ach.count}")

        print()

Creating Achievement Unlock Popups

typedef struct {
    bool visible;
    const char *name;
    const char *icon_url;
    bool is_trophy;
    float elapsed;
    float alpha;
} achievement_popup_t;

void popup_show(achievement_popup_t *p, const char *name,
                const char *icon_url, bool is_trophy) {
    p->visible  = true;
    p->name     = name;
    p->icon_url = icon_url;
    p->is_trophy = is_trophy;
    p->elapsed  = 0.0f;
    p->alpha    = 0.0f;
}

void popup_update(achievement_popup_t *p, float dt) {
    if (!p->visible) return;
    p->elapsed += dt;

    if      (p->elapsed < 0.3f) p->alpha = p->elapsed / 0.3f;
    else if (p->elapsed < 2.7f) p->alpha = 1.0f;
    else if (p->elapsed < 3.0f) p->alpha = 1.0f - (p->elapsed - 2.7f) / 0.3f;
    else                         p->visible = false;
}
class AchievementPopup {
public:
    void show(
        const std::string& name,
        const std::string& iconUrl,
        bool isTrophy
    ) {
        m_visible = true;
        m_name = name;
        m_iconUrl = iconUrl;
        m_isTrophy = isTrophy;
        m_showTime = std::chrono::steady_clock::now();
        m_alpha = 0.0f;
    }

    void update(float deltaTime) {
        if (!m_visible) return;

        auto elapsed = std::chrono::duration_cast<std::chrono::milliseconds>(
            std::chrono::steady_clock::now() - m_showTime
        ).count() / 1000.0f;

        // Fade in (0-0.3s)
        if (elapsed < 0.3f) {
            m_alpha = elapsed / 0.3f;
        }
        // Hold (0.3-2.7s)
        else if (elapsed < 2.7f) {
            m_alpha = 1.0f;
        }
        // Fade out (2.7-3.0s)
        else if (elapsed < 3.0f) {
            m_alpha = 1.0f - (elapsed - 2.7f) / 0.3f;
        }
        // Done
        else {
            m_visible = false;
        }
    }

    void render() {
        if (!m_visible) return;

        // Draw popup with current alpha
        drawRect(/* popup background */);
        drawImage(m_iconUrl, /* position */);
        drawText(m_isTrophy ? "TROPHY UNLOCKED!" : "ACHIEVEMENT UNLOCKED!", /* position */);
        drawText(m_name, /* position */);
    }

private:
    bool m_visible = false;
    std::string m_name;
    std::string m_iconUrl;
    bool m_isTrophy = false;
    std::chrono::steady_clock::time_point m_showTime;
    float m_alpha = 0.0f;
};
import time

class AchievementPopup:
    def __init__(self):
        self.visible = False
        self.name = ""
        self.icon_url = ""
        self.is_trophy = False
        self.start_time = 0.0
        self.alpha = 0.0

    def show(self, name, icon_url, is_trophy):
        self.visible = True
        self.name = name
        self.icon_url = icon_url
        self.is_trophy = is_trophy
        self.start_time = time.monotonic()
        self.alpha = 0.0

    def update(self):
        if not self.visible:
            return
        elapsed = time.monotonic() - self.start_time

        if elapsed < 0.3:
            self.alpha = elapsed / 0.3
        elif elapsed < 2.7:
            self.alpha = 1.0
        elif elapsed < 3.0:
            self.alpha = 1.0 - (elapsed - 2.7) / 0.3
        else:
            self.visible = False

Best Practices

1. Handle Missing Images Gracefully

void loadAchievementImage(const std::string& url) {
    auto image = imageCache.getImage(url);
    if (image) {
        displayImage(*image);
    } else {
        displayPlaceholder();  // Show default badge
        imageCache.downloadImage(url);  // Queue download
    }
}

2. Respect Visibility Settings

// Only show achievements that should be visible
if (!achievement.visible) {
    auto progress = gs.getProgress(userId, achievement.key);
    if (!progress || !progress->unlocked) {
        continue;  // Don't show
    }
}

3. Optimize Image Loading

  • Pre-cache images when achievements are first loaded
  • Use thumbnail versions for lists, full resolution for detail views
  • Implement lazy loading for long achievement lists

4. Provide Visual Distinction

  • Use different styles for unlocked vs locked achievements
  • Highlight trophy achievements with special styling
  • Show progress bars for limited achievements
  • Use grayscale or dimmed images for locked achievements