Arcade Racer (Unity)
This is a simple game we have built using SCILL to integrate Achievements, Battlepasses and Leaderboards. In the following chapters we’ll go through various features from the game and highlight some code we used to build that.
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:
Key | Description |
---|---|
W | Accelerate |
S | Brake |
A | Steer left |
D | Steer right |
ESC | Back 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.
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:
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:
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 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.
- Fully working, interactive, ready to go Battle Pass UI Prefab
- SCILL Battle Pass Manager C# Class
- SCILL Battle Pass C# Class
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:
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).
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.
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
A couple of notes here:
- The leaderboard listens on achieve-score events that have set the value
lap-time
for thetime_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 thescore
property) that are smaller than the previously storedscore
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.
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);
}
}
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:
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.
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 usesingle
, then thescore
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:
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 usingSendEventAsync
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:
// 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:
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.
References
A couple of references to the documentation: