Skip to content

Displaying Achievements

Upcoming SDK release

Achievements ship in an upcoming Scorbit SDK release. This section documents that release ahead of time, so details may still change before it ships.

This page covers how to show achievements on the machine: unlock celebrations, achievement lists, progress, and DMD frames. The data comes from the definitions and progress described in Definitions, Progress and Events.

Visibility and Masking

Apply these rules for the player being displayed:

Definition Player hasn't earned it Player has earned it
visible is false Show nothing at all, not even a placeholder Show nothing: it is an ingredient of another achievement
obscure is true Show the placeholder image (obscure_image_url) with masked name and description Show the real name, description and icon
Neither Show it normally, with progress if you like Show it normally

Never show progress for an achievement the player can't see under these rules.

The Scorbit app always shows a player's full achievement catalogue. Showing achievements the player hasn't earned on the machine is optional, for example in attract mode or between balls.

Progress

Compute the progress you display from the player's live record:

  • One counting rule compared with GE (a mode, completion, stack or event count, or games played): show the rule's current value over its target, such as "4,213 / 10,000 spins". A counting rule compared with EQ, LE or NE is either satisfied or not.
  • Several rules: show how many rules are satisfied out of the total, such as "2 of 3".
  • Score and timer rules are either satisfied or not. Never show them as a fraction.
// Returns progress from 0.0 to 1.0, or -1 when there is nothing to show.
double achievement_fraction(sb_game_handle_t gs, sb_player_t player, const char *key)
{
    sb_achievement_t a;
    sb_achievement_progress_t progress;
    if (!sb_achievement_find(gs, key, &a)
        || !sb_achievement_player_progress(gs, player, key, &progress)) {
        return -1.0;
    }
    if (progress.held) {
        return 1.0;
    }

    size_t rules_count = a.rules_count;
    if (rules_count == 1) {
        sb_achievement_rule_t rule;
        sb_achievement_rule_progress_t rp;
        if (sb_achievement_rule_at(gs, key, 0, &rule)
            && sb_achievement_player_rule_progress(gs, player, key, 0, &rp)) {
            bool counting = rule.type == SB_ACHIEVEMENT_RULE_MODE
                         || rule.type == SB_ACHIEVEMENT_RULE_MODE_COMPLETED
                         || rule.type == SB_ACHIEVEMENT_RULE_MODE_STACK
                         || rule.type == SB_ACHIEVEMENT_RULE_EVENT
                         || rule.type == SB_ACHIEVEMENT_RULE_SESSION;
            if (counting && rule.comparison == SB_ACHIEVEMENT_GE && rp.target > 0) {
                double f = (double)rp.value / (double)rp.target;
                return f > 1.0 ? 1.0 : (f < 0.0 ? 0.0 : f);
            }
            return rp.satisfied ? 1.0 : 0.0;
        }
        return -1.0;
    }

    size_t satisfied = 0;
    for (size_t i = 0; i < rules_count; ++i) {
        sb_achievement_rule_progress_t rp;
        // Fails if definitions changed since sb_achievement_find: no progress to show
        if (!sb_achievement_player_rule_progress(gs, player, key, i, &rp)) {
            return -1.0;
        }
        if (rp.satisfied) {
            ++satisfied;
        }
    }
    return rules_count ? (double)satisfied / (double)rules_count : -1.0;
}
// Returns progress from 0.0 to 1.0, or std::nullopt when there is nothing to show.
std::optional<double> achievementFraction(scorbit::GameState &gs, sb_player_t player,
                                          const std::string &key)
{
    using T = scorbit::AchievementRuleType;
    scorbit::Achievement a;
    scorbit::AchievementProgress progress;
    if (!gs.findAchievement(key, a) || !gs.getAchievementProgress(player, key, progress)) {
        return std::nullopt;
    }
    if (progress.held) {
        return 1.0;
    }

    // Both come from separate calls; a definitions refresh in between can change the rule count
    if (a.rules.size() != progress.rules.size()) {
        return std::nullopt;
    }

    if (a.rules.size() == 1) {
        const auto &rule = a.rules[0];
        const auto &rp = progress.rules[0];
        const bool counting = rule.type == T::Mode || rule.type == T::ModeCompleted
                           || rule.type == T::ModeStack || rule.type == T::Event
                           || rule.type == T::Session;
        if (counting && rule.comparison == scorbit::AchievementComparison::Ge
            && rp.target > 0) {
            return std::clamp(static_cast<double>(rp.value) / rp.target, 0.0, 1.0);
        }
        return rp.satisfied ? 1.0 : 0.0;
    }

    const auto satisfied = std::count_if(progress.rules.begin(), progress.rules.end(),
                                         [](const auto &r) { return r.satisfied; });
    return progress.rules.empty()
            ? std::nullopt
            : std::optional<double>(static_cast<double>(satisfied) / progress.rules.size());
}
COUNTING = {
    scorbit.AchievementRuleType.Mode,
    scorbit.AchievementRuleType.ModeCompleted,
    scorbit.AchievementRuleType.ModeStack,
    scorbit.AchievementRuleType.Event,
    scorbit.AchievementRuleType.Session,
}

def achievement_fraction(gs, player, key):
    """Progress from 0.0 to 1.0, or None when there is nothing to show."""
    a = gs.find_achievement(key)
    progress = gs.get_achievement_progress(player, key)
    if a is None or progress is None:
        return None
    if progress.held:
        return 1.0

    # Both come from separate calls; a definitions refresh in between can change the rule count
    if len(a.rules) != len(progress.rules):
        return None

    if len(a.rules) == 1:
        rule, rp = a.rules[0], progress.rules[0]
        if (rule.type in COUNTING
                and rule.comparison == scorbit.AchievementComparison.Ge
                and rp.target > 0):
            return max(0.0, min(1.0, rp.value / rp.target))
        return 1.0 if rp.satisfied else 0.0

    if not progress.rules:
        return None
    return sum(1 for r in progress.rules if r.satisfied) / len(progress.rules)

A rule whose prerequisite the machine can't judge (judged is false) counts as not yet satisfied on the machine. The server still decides that achievement correctly.

Groups and Tiers

Achievements that share a group_id form a ladder, such as 100, 1,000 and 10,000 spins. Order a group by display_position (1, 2, 3, ...). A common display shows the highest tier the player holds, plus progress toward the next one. Groups never affect evaluation: each tier is earned on its own rules.

Celebrating an Unlock

  • Honour notify_when_achieved when deciding how prominently to announce an unlock.
  • Present an unlock when its AchievementUpdated event arrives with UnlockedLocally, or with Confirmed if you didn't celebrate it already.
  • Celebrate once per achievement and player, and be ready to take a celebration back on Retracted.
  • Use the badge from icon_url. Badges can be high-resolution images or animations, so load them away from the event callback.
  • You may show an unlock immediately or hold it until the end of the ball, when the display is free. The decision has already been made either way.
  • Hide or mask obscured and invisible achievements consistently: an invisible ingredient should never be celebrated by name.

DMD Frames

On dot-matrix display machines, creators can draw a DMD frame for each achievement at the exact resolution and palette of the title's display. Frames are packaged per title into a versioned bundle.

  • The SDK downloads the bundle automatically together with the definitions, and again only when the bundle version changes, so a frame is normally on the machine before an unlock needs it. The download runs in the background and can fail, so always handle a missing frame, for example by falling back to text.
  • The bundle is stored in the data directory set with set_data_dir (see Feeding Achievements).
  • Read a frame by achievement key. The SDK returns the frame's bytes as stored in the bundle, or nothing when the bundle has no frame for that key.
const char *key = "game-cv-boom-balloon"; // e.g. from an AchievementUpdated event
const uint8_t *data = NULL;
size_t size = 0;
if (sb_achievement_frame(gs, key, &data, &size)) {
    // data is valid until the next achievements call on this thread: copy or draw it now
    show_dmd_frame(data, size); // User-implemented
}
const std::string key = "game-cv-boom-balloon"; // e.g. from an AchievementUpdated event
std::vector<uint8_t> frame;
if (gs.getAchievementFrame(key, frame)) {
    showDmdFrame(frame); // User-implemented
}
key = "game-cv-boom-balloon"  # e.g. from an AchievementUpdated event
frame = gs.get_achievement_frame(key)
if frame is not None:
    show_dmd_frame(frame)  # User-implemented

Frames support transparency so they can be overlaid on the game display. Show one immediately at unlock, or at the end of the ball.