Skip to content

In-Game Achievement Notifications

The SDK provides real-time notifications for achievement events via WebSocket connection. These are in-game notifications displayed on the pinball machine's DMD or other display—separate from push notifications sent to the Scorbit mobile app.

This page covers how to set up and handle in-game achievement notifications effectively.

Event Types

The SDK delivers three types of achievement notifications via the Centrifugo WebSocket connection:

Event Description
AchievementUnlocked A player has earned an achievement
AchievementLocked A trophy achievement has been revoked
AchievementProgress Progress toward a limited achievement has changed

Notifications are generated for both in-session unlocks (triggered during gameplay) and post-game unlocks (server-evaluated after game completion, e.g., ACHIEVEMENT chains and global-scope rules).

Setting Up the Event Callback

Register a callback function to receive achievement events. Important: The callback must be set on the Config before creating the GameState.

// Event callback function
void on_event(const sb_event_t* event, void* user_data) {
    sb_event_type_t type = sb_event_type(event);

    switch (type) {
        case SB_EVT_ACHIEVEMENT_UNLOCKED:
            handle_achievement_unlocked(event);
            break;
        case SB_EVT_ACHIEVEMENT_LOCKED:
            handle_achievement_locked(event);
            break;
        case SB_EVT_ACHIEVEMENT_PROGRESS:
            handle_achievement_progress(event);
            break;
        default:
            // Handle other event types
            break;
    }
}

// Register the callback during configuration
sb_config_t config = sb_config_create();
sb_config_set_event_callback(config, on_event, NULL);
// ... other config settings ...

// Create game state with the config
sb_game_handle_t gs = sb_create_game_state(config);
sb_config_destroy(config);  // Config can be destroyed after creation
// Event callback function
void onEvent(const scorbit::Event& event) {
    switch (event.type()) {
        case scorbit::EventType::AchievementUnlocked:
            handleAchievementUnlocked(event);
            break;
        case scorbit::EventType::AchievementLocked:
            handleAchievementLocked(event);
            break;
        case scorbit::EventType::AchievementProgress:
            handleAchievementProgress(event);
            break;
        default:
            // Handle other event types
            break;
    }
}

// Register the callback during configuration
scorbit::Config config;
config.setEventCallback(onEvent);
// ... other config settings ...

// Create game state with the config
auto gs = scorbit::createGameState(config);
def on_event(event):
    event_type = event.type()

    if event_type == scorbit.EventType.AchievementUnlocked:
        handle_achievement_unlocked(event)
    elif event_type == scorbit.EventType.AchievementLocked:
        handle_achievement_locked(event)
    elif event_type == scorbit.EventType.AchievementProgress:
        handle_achievement_progress(event)

# Register the callback during configuration
config = scorbit.Config()
config.set_event_callback(on_event)
# ... other config settings ...

# Create game state with the config
gs = scorbit.create_game_state(config)

Handling Unlock Notifications

When an achievement is unlocked, display a notification to the player:

void handle_achievement_unlocked(const sb_event_t* event) {
    const char *key = NULL;
    const char *name = NULL;
    const char *user_id = NULL;
    const char *username = NULL;
    const char *icon_url = NULL;
    bool is_trophy = false;

    if (!sb_event_achievement_unlocked(event, &key, &name, &user_id,
                                       &username, &icon_url, &is_trophy)) {
        return;
    }

    printf("=== ACHIEVEMENT UNLOCKED ===\n");
    printf("Player: %s\n", username ? username : "");
    printf("Achievement: %s\n", name ? name : "");

    // Trigger visual notification
    show_unlock_popup(name, icon_url, is_trophy);

    // Play sound effect
    play_sound("achievement_unlock");

    // Log for analytics
    log_achievement_unlock(key, user_id);
}
void handleAchievementUnlocked(const scorbit::Event& event) {
    std::string key, name, userId, username, iconUrl;
    bool isTrophy = false;

    if (!event.getAchievementUnlocked(key, name, userId, username, iconUrl, isTrophy)) {
        return;
    }

    std::cout << "=== ACHIEVEMENT UNLOCKED ===\n";
    std::cout << "Player: " << username << "\n";
    std::cout << "Achievement: " << name << "\n";

    // Trigger visual notification
    showUnlockPopup(name, iconUrl, isTrophy);

    // Play sound effect
    playSound("achievement_unlock");

    // Log for analytics
    logAchievementUnlock(key, userId);
}
def handle_achievement_unlocked(event):
    success, key, name, user_id, username, icon_url, is_trophy = \
        event.get_achievement_unlocked()

    if not success:
        return

    print("=== ACHIEVEMENT UNLOCKED ===")
    print(f"Player: {username}")
    print(f"Achievement: {name}")

    # Trigger visual notification
    show_unlock_popup(name, icon_url, is_trophy)

    # Play sound effect
    play_sound("achievement_unlock")

    # Log for analytics
    log_achievement_unlock(key, user_id)

Handling Lock Notifications (Trophies)

Trophy achievements can be revoked. Handle this gracefully:

void handle_achievement_locked(const sb_event_t* event) {
    const char *key = NULL;
    const char *name = NULL;
    const char *user_id = NULL;
    const char *username = NULL;
    const char *icon_url = NULL;

    if (!sb_event_achievement_locked(event, &key, &name, &user_id,
                                     &username, &icon_url)) {
        return;
    }

    printf("Trophy revoked: %s\n", name ? name : "");
    printf("Player: %s\n", username ? username : "");

    // Update UI to show trophy was lost
    update_trophy_status(key, user_id, false);

    // Optionally show notification
    show_trophy_lost_notification(name, username);
}
void handleAchievementLocked(const scorbit::Event& event) {
    std::string key, name, userId, username, iconUrl;

    if (!event.getAchievementLocked(key, name, userId, username, iconUrl)) {
        return;
    }

    std::cout << "Trophy revoked: " << name << "\n";
    std::cout << "Player: " << username << "\n";

    // Update UI to show trophy was lost
    updateTrophyStatus(key, userId, false);

    // Optionally show notification
    showTrophyLostNotification(name, username);
}
def handle_achievement_locked(event):
    success, key, name, user_id, username, icon_url = \
        event.get_achievement_locked()

    if not success:
        return

    print(f"Trophy revoked: {name}")
    print(f"Player: {username}")

    # Update UI to show trophy was lost
    update_trophy_status(key, user_id, False)

    # Optionally show notification
    show_trophy_lost_notification(name, username)

Handling Progress Notifications

Show progress updates for limited achievements:

// Track previous percentages to detect milestone crossings
static float last_percent[MAX_ACHIEVEMENTS];  // User-managed storage

void handle_achievement_progress(const sb_event_t* event) {
    const char *key = NULL;
    const char *name = NULL;
    const char *user_id = NULL;
    const char *username = NULL;
    const char *icon_url = NULL;
    int current_value = 0;
    int target_value = 0;

    if (!sb_event_achievement_progress(event, &key, &name, &user_id,
                                       &username, &icon_url,
                                       &current_value, &target_value)) {
        return;
    }

    printf("Progress: %s - %d/%d\n", name ? name : "", current_value, target_value);

    // Update progress bar/counter
    update_progress_display(key, current_value, target_value);

    // Show milestone notifications using state tracking
    // This approach ensures milestones aren't missed on large increments
    if (target_value > 0) {
        int key_index = get_achievement_index(key);  // User-implemented lookup
        float old_percent = last_percent[key_index];
        float new_percent = (float)current_value / target_value * 100;

        if (old_percent < 50 && new_percent >= 50) {
            show_milestone_notification(name, "Halfway there!");
        }
        if (old_percent < 75 && new_percent >= 75) {
            show_milestone_notification(name, "Almost there!");
        }

        last_percent[key_index] = new_percent;
    }
}
// Member variable to track previous percentages
std::unordered_map<std::string, float> m_lastPercent;

void handleAchievementProgress(const scorbit::Event& event) {
    std::string key, name, userId, username, iconUrl;
    int currentValue = 0, targetValue = 0;

    if (!event.getAchievementProgress(key, name, userId, username, iconUrl,
                                      currentValue, targetValue)) {
        return;
    }

    std::cout << "Progress: " << name
              << " - " << currentValue << "/" << targetValue << "\n";

    // Update progress bar/counter
    updateProgressDisplay(key, currentValue, targetValue);

    // Show milestone notifications using state tracking
    // This approach ensures milestones aren't missed on large increments
    if (targetValue > 0) {
        float oldPercent = m_lastPercent[key];
        float newPercent = (float)currentValue / targetValue * 100;

        if (oldPercent < 50 && newPercent >= 50) {
            showMilestoneNotification(name, "Halfway there!");
        }
        if (oldPercent < 75 && newPercent >= 75) {
            showMilestoneNotification(name, "Almost there!");
        }

        m_lastPercent[key] = newPercent;
    }
}
# Module-level or class member to track previous percentages
last_percent = {}

def handle_achievement_progress(event):
    success, key, name, user_id, username, icon_url, current_value, target_value = \
        event.get_achievement_progress()

    if not success:
        return

    print(f"Progress: {name} - {current_value}/{target_value}")

    # Update progress bar/counter
    update_progress_display(key, current_value, target_value)

    # Show milestone notifications using state tracking
    # This approach ensures milestones aren't missed on large increments
    if target_value > 0:
        old_percent = last_percent.get(key, 0)
        new_percent = current_value / target_value * 100

        if old_percent < 50 <= new_percent:
            show_milestone_notification(name, "Halfway there!")
        if old_percent < 75 <= new_percent:
            show_milestone_notification(name, "Almost there!")

        last_percent[key] = new_percent

Notification Queue Management

For games with many achievements, manage notification timing:

#define NOTIF_QUEUE_SIZE 32

typedef struct {
    const char *type;   // "unlock", "lock", "progress"
    const char *key;
    const char *name;
    const char *icon_url;
} notification_t;

static notification_t s_queue[NOTIF_QUEUE_SIZE];
static int s_head = 0, s_tail = 0;
static bool s_displaying = false;
static float s_display_timer = 0.0f;

void notif_enqueue(notification_t n) {
    s_queue[s_tail % NOTIF_QUEUE_SIZE] = n;
    s_tail++;
}

void notif_process(float dt) {
    if (s_displaying) {
        s_display_timer -= dt;
        if (s_display_timer <= 0) s_displaying = false;
        return;
    }
    if (s_head < s_tail) {
        notification_t n = s_queue[s_head % NOTIF_QUEUE_SIZE];
        s_head++;
        display_notification(n.name, n.icon_url);  // User-implemented
        s_displaying = true;
        s_display_timer = 3.0f;
    }
}
class AchievementNotificationQueue {
public:
    struct Notification {
        std::string type;  // "unlock", "lock", "progress"
        std::string key;
        std::string name;
        std::string iconUrl;
        std::string userId;
        std::chrono::steady_clock::time_point queuedAt;
    };

    void enqueue(const Notification& notification) {
        std::lock_guard<std::mutex> lock(m_mutex);
        m_queue.push(notification);
    }

    void processQueue() {
        std::lock_guard<std::mutex> lock(m_mutex);

        auto now = std::chrono::steady_clock::now();

        // Process one notification at a time with delay
        if (!m_queue.empty() && !m_isDisplaying) {
            auto notification = m_queue.front();
            m_queue.pop();

            displayNotification(notification);
            m_isDisplaying = true;
            m_displayEndTime = now + std::chrono::seconds(3);
        }

        // Clear display flag after timeout
        if (m_isDisplaying && now >= m_displayEndTime) {
            m_isDisplaying = false;
        }
    }

private:
    void displayNotification(const Notification& n);

    std::queue<Notification> m_queue;
    std::mutex m_mutex;
    bool m_isDisplaying = false;
    std::chrono::steady_clock::time_point m_displayEndTime;
};
import queue
import threading
import time

class AchievementNotificationQueue:
    def __init__(self):
        self.queue = queue.Queue()
        self.is_displaying = False
        self.display_duration = 3.0  # seconds

    def enqueue(self, notification):
        self.queue.put(notification)

    def process_queue(self):
        if self.queue.empty() or self.is_displaying:
            return

        notification = self.queue.get()
        self.display_notification(notification)

    def display_notification(self, notification):
        self.is_displaying = True
        # Show the notification
        show_notification_ui(notification)

        # Schedule clearing after duration
        def clear():
            time.sleep(self.display_duration)
            self.is_displaying = False

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

Best Practices

1. Don't Block the Callback

Event callbacks should return quickly. Queue heavy operations:

void onEvent(const scorbit::Event& event) {
    // Quick: queue for later processing
    Notification n;
    n.type = "unlock";
    // ... fill notification data ...
    notificationQueue.enqueue(n);

    // Don't do this: blocks the event loop
    // loadImage(iconUrl);  // Network call
    // playAnimation();     // Long animation
}

2. Handle Reconnection

After reconnection, you may receive events for achievements unlocked while disconnected. Deduplicate:

std::set<std::string> shownNotifications;

void handleUnlock(const std::string& key, const std::string& userId) {
    std::string notifId = key + "_" + userId;
    if (shownNotifications.count(notifId)) {
        return;  // Already shown
    }
    shownNotifications.insert(notifId);
    showNotification();
}

3. Respect Game State

Don't show notifications during critical gameplay moments:

std::vector<Notification> deferredNotifications;

void handleAchievementUnlocked(const scorbit::Event& event) {
    Notification n = extractNotification(event);

    if (isInCriticalGameplay()) {
        // Queue for later
        deferredNotifications.push_back(n);
    } else {
        showNotificationImmediately(n);
    }
}

void onBallDrain() {
    // Good time to show deferred notifications
    for (const auto& n : deferredNotifications) {
        showNotificationImmediately(n);
    }
    deferredNotifications.clear();
}

4. Provide Audio Feedback

Different sounds for different events enhance the experience:

void handleEvent(const scorbit::Event& event) {
    if (event.type() == scorbit::EventType::AchievementUnlocked) {
        std::string key, name, userId, username, iconUrl;
        bool isTrophy = false;
        if (event.getAchievementUnlocked(key, name, userId, username, iconUrl, isTrophy)) {
            playSound(isTrophy ? "trophy_unlock" : "achievement_unlock");
        }
    } else if (event.type() == scorbit::EventType::AchievementLocked) {
        playSound("trophy_lost");
    } else if (event.type() == scorbit::EventType::AchievementProgress) {
        playSound("progress_tick");
    }
}