Arcade Racer (Unity)

This is a simple game we have built using SCILL. In the following chapters we’ll go through various features from the game and highlight some code we used to build that.

Info

Please note: This game is built in Unity by using our Unity SDK. If you are an Unreal developer, we got you covered, too. We have an Unreal SDK featuring Blueprints and ready to use UI components. The basic concepts described here also apply to Unreal, so we encourage you to read this guide to understand how SCILL works.

How to play

Use you mouse to navigate the menu. First, you should click on “Set Name”, otherwise your great lap time will only be “Guest” entry, which sucks if you really have been fast ;-). Then, navigate the Battle Pass menu to see our UI prefab in all it’s glory. Of course for this game, it’s a bit boring, but you get the idea!

Next, click on “Race” and you you will be brought directly on the race track with a couple of NPCs drivers. They are quite stupid so typically it’s best not to mess too much with them.

The game has a standard WASD keyboard controller scheme:

KeyDescription
WAccelerate
SBrake
ASteer left
DSteer right
ESCBack to Main Menu

How it is built

This game is basically built on the 4Players SCILL platform. We use the battle pass system to setup an activity scheme that challenges users by achieving certain goals and rewards them with new cars. Typically, the new car is required to unlock the next level, as the challenges are getting harder or require features that only specific cars have.

Next, we use the leaderboard system to track laptimes of users in a Hall of Fame. Of course, we could also add daily, weekly leaderboards or linking it to some reward, i.e. the best driver during Formula 1 race weekend in Spa gets a reward. We kept it simple, but only your imagination is the limit in SCILL. Especially if you link challenges and leaderboards together using Webhooks. This is a great way of bringing great engagement features to your game with just a couple of lines of code.

Last but not least, we added some challenges motivating users to play longer than they would without those features. Challenges are a very good way to give users a reason to come back daily. We did not add rewards to keep it simple, but it’s up to you to unlock features or new items based on your users success achieving challenges.

Info

As you will see, not much code development is required on your side, as we already provide a lot of ready to be used functionality out of the box that are available as easy to implement Prefabs and classes available in the SCILL SDK.

Generating Access Token

4Players SCILL is user agnostic, that means, that we don’t story any private user info in our system to be fully GDPR compliant and to make sure, you don’t have to worry about that at all. We just get a user_id from you. To prevent cheating we need some form of server side authentication which we call an access token. However, to keep things simple we chose to generate the access token directly in the game. More on this topic can be found in our Guide.

Set Name

4Players SCILL only stores the user_id internally. In a leaderboard system, showing user ids would be boring. Therefore, we provide some basic user services you can use to store a nickname, and an avatar image (or url) which is then linked to the user id internally inside the SCILL platform and used in leaderboard responses.

When you see “Guest” entries in the leaderboard. These are from players that did not set a name and SCILL just has a user id for these users.

The code we use to set current user name and avatar looks like this:

Loading user settings stored in SCILL cloud
    void Start()
    {
        ClearList();
        Sprite[] sprites = Resources.LoadAll<Sprite>("Avatars");

        // Load user info from SCILL cloud and set user name input field if user set name previously
        SCILLManager.Instance.GetUserInfoAsync(userInfo =>
        {
            if (userInfo != null)
            {
                if (userInfo.username != null)
                {
                    username.text = userInfo.username;
                }
            }

            foreach (var sprite in sprites)
            {
                GameObject avatarGo = Instantiate(gridItemPrefab.gameObject, avatarsCollection.transform, false);
                var avatarItem = avatarGo.GetComponent<GridItem>();
                if (avatarItem)
                {
                    avatarItem.SetSprite(sprite);
                    if (avatarItem.button)
                    {
                        avatarItem.button.onClick.AddListener(delegate { OnAvatarClicked(avatarItem); });
                    }

                    // If an avatar image is stored in the SCILL cloud for this user, highlight it
                    if (userInfo?.avatarImage != null && avatarItem.Name == userInfo.avatarImage)
                    {
                        avatarItem.Select();
                        _selectedGridItem = avatarItem;
                    }
                }
            }

            // Select first avatar if nothing selected yet
            if (_selectedGridItem == null)
            {
                SelectAvatar(GetComponentInChildren<GridItem>());
            }
        }, null);
    }

Once the user has entered his username, selected an avatar image and pressed the OK button, this code is executed to set the user info within SCILL:

Storing user data in SCILL cloud
    public void OnOkClicked()
    {
        try
        {
            SCILLManager.Instance.SetUserInfoAsync(null, username.text, _selectedGridItem.Name);
        }
        catch (ApiException e)
        {
            SCILLNotificationManager.Instance?.AddNotification(SCILLNotificationType.Error, "Failed to set user info");
            Debug.Log(e);
        }

        MainMenu.Instance?.ShowMainMenu();
    }

The data is stored in the user service of SCILL.

Battle Pass

This game features various cars that you need to unlock one after the other by achieving challenges. These can be achieving a lap time or some other things like not colliding with NPCs or the environment.

We added a couple of different cars with different driving characteristics and you need to manage those challenges to unlock the next car. Once you have unlocked a car, you can use that car when driving the race track.

4Players SCILL offers a class SCILLBattlePassManager which is a singleton and implements communication with the SCILL cloud. It offers various delegates to get notified of events happening in the SCILL cloud. This makes it easy for you to check out the current battle pass level of the user everywhere in your game and as everything is updated in real-time, you don’t need to worry about that, at all.

Unlocking the battle pass

Battle Pass implementation of Arcade Racer

Battle Pass implementation of Arcade Racer

Battle Passes need to be unlocked. You can do that via script automatically once a users loads the battle pass for the first time, or you can also put that after a payment process, so that the battle pass costs some money. That’s a good way of adding monetization to your game by brining real value to the gamer.

Adding the battle pass to your game is just drag & dropping the Battle Pass prefab into your game and that’s it! You can adjust the behaviour that happens once a user unlocks the battle pass by using methods and delegates provided in the SCILLBattlePass.

You can adjust many different aspects of the Battle Pass UI very quickly using the classes we provide. Of course, you can also dive deeper and use the underlying C# SDK to directly work with the SCILL cloud.

Retrieving current level

Once a user wants to race, a car has to be chosen. One car is “free” to try. The others need to be unlocked with the battle pass. 4Players SCILL keeps track of the current level and current state of the users challenges. You don’t need to do any state management. If events are sent, SCILL tracks those and increments underlying challenge counters automatically for you and notifies the frontend with real time notifications that are used to inform the user and to update the UI.

Now, in the dialog the user sees to select a car, the current level of the user and unlocked items need to be retrieved.

If the dialog to choose a car is opened, this code runs to download the current user battle pass levels:

Loading current battle pass state of user
    private void OnEnable()
    {
        if (SCILLBattlePassManager.Instance)
        {
            _levels = SCILLBattlePassManager.Instance.BattlePassLevels;
        }
        SCILLBattlePassManager.OnBattlePassLevelsUpdatedFromServer += OnBattlePassLevelsUpdatedFromServer;
        UpdateUI();
    }

    private void OnBattlePassLevelsUpdatedFromServer(List<BattlePassLevel> battlePassLevels)
    {
        _levels = battlePassLevels;
        UpdateUI();
    }

Once data is loaded the current state of the users battle pass is available. We now know, which levels the user has managed so far and what the current state in the current level is (i.e. how many challenges to go).

Show rewards unlocked by the user
    void UpdateUI()
    {
        // Clear the list of cars
        ClearList();

        if (_levels == null)
        {
            return;
        }

        // Add the default cars available
        foreach (var reward in defaultCars)
        {
            AddReward(reward, true);
        }

        // Now go through all battle pass levels and show the car linked to this battle pass
        foreach (var level in _levels)
        {
            var reward = Resources.Load<SCILLReward>(level.reward_amount);
            if (reward)
            {
                AddReward(reward, level.level_completed == true);
            }
        }
    }

    // Instantiate a car item prefab
    private void AddReward(SCILLReward reward, bool unlocked)
    {
        var gridItemGO = Instantiate(gridItemPrefab.gameObject, grid, false);
        var gridItem = gridItemGO.GetComponent<RewardGridItem>();
        gridItem.SetReward(reward, unlocked);

        if (gridItem.button)
        {
            gridItem.button.onClick.AddListener(delegate{OnCarClicked(gridItem);});
        }
    }

That’s it. Now, whenever a user achieves all challenges set in the Admin Panel for the current level, the next level will be unlocked and the car will be available in the car selection.

Tip

If you already have a reward database linked to the user, either in the cloud or in the game itself, it’s easy to add the 4Players SCILL battle pass. On client side, you can hook up to various delegates that inform you once a level has been unlocked and which reward is linked to that level (see OnBattlePassLevelRewardClaimed. If you have a cloud based backend, you can use Webhooks to trigger a cloud function that unlocks the reward in your database.

Leaderboards

Adding leaderboards to your game is really simple. You just need to drop the Leaderboard Prefab into your game which will show the current leaderboard with infinite scrolling functionality.

Setting up the leaderboard in Admin Panel

In Admin Panel, setting up the leaderboard is a couple of clicks. This is how it looks like in the Admin Panel when setup:

Arcade Racer Leaderboard

Arcade Racer Leaderboard

A couple of notes here:

  • The leaderboard listens on achieve-score events that have set the value lap-time for the time_condition property. This way, the same event can be used to deliver different types of data.
  • It has an ascending sort order. That is, only if incoming values (in this case the score property) that are smaller than the previously stored score for the user will update the leaderboard. If this happens, a realtime notification will be sent to the SDK (use delegates to implement your own logic - see below) and optionally a Webhook on your side will be called to implement server side behaviour.

Notifications if rank changes

However, we wanted to show the user inside the game while driving if he managed to climb up the leaderboard. For this, we added an instance of the SCILLLeaderboardManager and hooked up the OnLeaderboardRankingChanged event to our RaceManager class which handles all the race logic inside the game.

Show notifications if leaderboard changed
public void OnLeaderboardRankingUpdated(LeaderboardUpdatePayload payload)
    {
        // Load the current leaderboard position for the user to update the "Best Lap" inside the game with the best
        // lap of the user.
        if (payload.new_leaderboard_ranking == null || payload.old_leaderboard_ranking == null)
        {
            SCILLManager.Instance.GetPersonalRankingAsync(memberRanking =>
            {
                if (memberRanking != null && memberRanking.member != null && memberRanking.member.rank >= 1)
                {
                    var score = memberRanking.member.score.Value;
                    playerCar.GetComponent<CarController>().RaceStats.bestLapTime = (float) ((float) score / 100.0f);
                }
            }, null, "641184035742547979");

            return;
        }

        if (!payload.new_leaderboard_ranking.rank.HasValue || !payload.old_leaderboard_ranking.rank.HasValue)
        {
            return;
        }

        if (!payload.new_leaderboard_ranking.score.HasValue || !payload.old_leaderboard_ranking.score.HasValue)
        {
            return;
        }

        // Show notifications if the user has improved his own best score (but did not change his position in the leaderboard)
        if (payload.new_leaderboard_ranking.score.Value < payload.old_leaderboard_ranking.score.Value)
        {
            SCILLNotificationManager.Instance?.AddCenterNotification("New personal record!");
        }

        // Show notification if the user managed to climb up the letter
        if (payload.new_leaderboard_ranking.rank.Value < payload.old_leaderboard_ranking.rank.Value)
        {
            SCILLNotificationManager.Instance?.AddCenterNotification("New rank: " +
                                                                     payload.new_leaderboard_ranking.rank.Value +
                                                                     ", old was: " +
                                                                     payload.old_leaderboard_ranking.rank.Value);
        }
    }
Tip

Please note: You don’t need to track everything in your game. Just implement UI, hook up some delegates and create items in the 4Players Admin Panel. Events, that you send are processed in real time in the SCILL cloud, if anything relevant happens the delegates will be firing, and you can implement UI or whatever you like to inform or reward the user.

Challenges and Achievements

We offer a usage variety of challenges, you can build:

  • Manual challenges (users need to unlock them themselves and then the timeout counter starts)
  • Daily, Weekly or Monthly challenges that are activated automatically and are reset if the user did not achieve the challenge in time
  • Achievements, that typically are used to track some “lifecycle” values.
  • Any combination of these

Adding challenges and achievements is as easy as it gets, as you just need to add the Personal Challenges Canvas to your game to show a current list of available challenges.

As we also wanted to give feedback inside the game if a user made progress in a challenge, we hooked up the challenge updated event of the SCILLPersonalChallengesManager and implemented this code:

Show notifications if challenges completed or progressed
    private void OnPersonalChallengeUpdated(Challenge challenge, SCILLPersonalChallengeModificationType modificationtype)
    {
        if (modificationtype == SCILLPersonalChallengeModificationType.Progress)
        {
            // Do not show achievements
            if (challenge.challenge_duration_time > 0)
            {
                SCILLNotificationManager.Instance?.AddChallengeUpdate(challenge, "CHALLENGE PROGRESSED!");
            }
        }
        else if (modificationtype == SCILLPersonalChallengeModificationType.Completed)
        {
            SCILLNotificationManager.Instance?.AddChallengeUpdate(challenge, "CHALLENGE COMPLETED!");
        }
    }

Showing Notifications

As notifications are such a crucial part of a game, we have added a simple UI and functionality to the SCILL SDKs. For many games this might already be enough, but if you want a more fancy UI and animations, you are free to use your own or one of the great options in the asset store. However, for quick prototyping you can use or SCILLNotificationManager as shown in the various code examples above.

Sending Events

Now comes the most important part of your SCILL experience: Sending Events. Events drive the SCILL engine and the SCILL backend. That’s basically all you have to do to make challenges progress, update the leaderboards and unlock Battle Pass levels.

In this chapter, we want to go into some of the events we used inside this game. We don’t go into details of sending events, as we already have a document for that, that goes into detail. Please check out our Understanding Events guide.

In Arcade Racer we created a simple CheckpointTrigger class, that is attached to a BoxCollider and is triggered, whenever a car collides with it. Every CheckpointTrigger has a id starting at 1. In total, we have 12 checkpoints at each corner of the race track.

Now, when the player collides with a checkpoint trigger, a counter stored at each car is incremented by the id of the checkpoint. If the id is 1 the user just crossed the start/finish line. In this case, the RaceManager script checks if the counter of the player is equal of the sum of all available checkpoints. This way, a lap only counts if the user stayed on the track and did not shortcut anywhere.

Once this happens, various events are sent to SCILL cloud, depending on the current condition of the car.

Sending Lap time events

Let’s check out the first event, that is sending the lap time to the SCILL cloud via the achieve-score event type.

Sending lap-time event
    public void SendLapTime(CarController carController)
    {
        SCILLManager.Instance?.SendEventForUserIdAsync(carController.UserId, "achieve-score", "group", new EventMetaData
        {
            score = (int) (carController.RaceStats.lapTime * 100),
            time_condition = "lap-time"
        });
    }

A couple of things to note here:

  • Using the time_condition we can create different events for the same event name (achieve-score)
  • By using group as the event type we directly set the value, and the leaderboard will only change if that value is smaller than the currently stored value for the user. If you would use single, then the score of this event will be added to the currently stored value for the user. This can be used for number of frag counters, for example.
  • SCILL does not support float values. As we store the lap time internally as a float, we multiply it by 100. In the leaderboard system you can specify numberOfDecimals which is then reversing that when displaying the values.
  • The leaderboard shown above will be triggered by this event and will update the users score (i.e. laptime) and the rank in real time.

Sending damages

It’s always good to send different types of events, so the community team has enough “meat” to play with. This leverages SCILLs greatest feature: Flexibility and Customizablity without the need to constantly update the code and to deploy updates of the game. Just create new content in the Admin Panel and that’s all there is to do.

As we already store collisions with the environment and with other cars in the lap stats, we can also send this data to SCILL. We do this with this code:

Sending health stats
    public void SendHealth(CarController carController)
    {
        int health = 100;
        health -= carController.RaceStats.impacts * 10;
        if (health < 0)
        {
            health = 0;
        }

        // Send health information of the car to SCILL cloud
        SCILLManager.Instance?.SendEventForUserIdAsync(carController.UserId, "finish-round", "single", new EventMetaData
        {
            health = health,
            required_time = (int) (carController.RaceStats.lapTime * 100),
            round_id = carController.RaceStats.numRounds + 1
        });

        // Notify the SCILL cloud of a completed lap
        SCILLManager.Instance?.SendEventForUserIdAsync(carController.UserId, "trigger-event", "single", raceSessionId,
            new EventMetaData
            {
                amount = 1,
                event_type = "laps-completed"
            });

        // Show notifications and send an event if this has been a perfect round
        if (carController.RaceStats.impacts <= 0)
        {
            if (carController.IsPlayer())
            {
                SCILLNotificationManager.Instance?.AddCenterNotification($"Perfect round!");
            }

            // Send event for no impact streaks
            SCILLManager.Instance?.SendEventForUserIdAsync(carController.UserId, "trigger-event", "single",
                carController.RaceStats.damageSessionId, new EventMetaData
                {
                    amount = 1,
                    event_type = "perfect-round"
                });
        }
        else
        {
            if (carController.IsPlayer())
            {
                SCILLNotificationManager.Instance?.AddCenterNotification(
                    $"{carController.RaceStats.impacts} impacts! You can do better!");
            }
        }
    }

A couple of things I want to point out here:

  • You might question yourself: Why can I not send just one event instead of multiple events? We have a longer answer to that question: Why this event structure?
  • We are using SendEventForUserIdAsync here, which requires us to add the user id. By using SendEventAsync the users user id will be used (see SCILLManager class reference for more info on that). However, as we have prepared Arcade Racer to be a multiplayer game, this code will be executed on server side and therefore needs to be done like shown above. If you have a single player game, you don’t need to worry about that at all.

Let’s have a look at this code piece from above:

Using Session IDs
  // Send event for no impact streaks
  SCILLManager.Instance?.SendEventForUserIdAsync(carController.UserId, "trigger-event", "single",
      carController.RaceStats.damageSessionId, new EventMetaData
      {
          amount = 1,
          event_type = "perfect-round"
      });

What you can see here, is that we use a carController.RaceStats.damageSessionId as a session ID for the SCILL event.

Whenever the car collides with the environment or another car, this code is executed:

Damage Session ID
    public void AddImpact(CarController carController)
    {
        carController.RaceStats.impacts += 1;
        carController.RaceStats.totalImpacts += 1;

        // Reset session so we start SCILL events from scratch

        carController.RaceStats.damageSessionId = DateTimeOffset.Now.ToUnixTimeSeconds().ToString();

        if (impacts == 1)
        {
            var metaData = new EventMetaData();
            // Send default round finished event
            SCILLManager.Instance?.SendEventAsync("instant-damage", "single", metaData);
        }
    }

That means, that whenever the car collides, the damageSessionId changes. Session-IDs in SCILL are used to group events into a “streak”. When the session changes, the challenge listening on this event will reset the counter. This way you can easily create streak challenges.

We use this event to create a “10 Laps in a row without damage” challenge. This challenges counter will reset whenever the user crashes the car and user will have to start from scratch.

In the code above, we also send an instant-damage event, but only for the first impact.

Final words

4Players SCILL is a very powerful toolkit that allows you to build a lot of complex use case with just a couple of lines of codes and full flexibility afterwards by setting up content in the Admin Panel.

Join us on Discord if you want to reach out to other game developers and please don’t hesitate to any question you like.

Join us on Discord

References

A couple of references to the documentation: