David Sula's Lavish Atelier

A blog for project documentation and other uploads.

I learned C#

For a couple months now, I’ve been putting several hours into learning C# programming and writing my own custom code.

Why?

A year ago I returned to hosting a community server on the game, SCP: Secret Laboratory, called Sula’s Bad Decision. I had hosted a very popular server on this game before called FloppaSCP, which was at its maximum player count everyday. However, the whole community server space for the game has changed since. All popular servers now have custom plugins which make modifications to the game programmed by paid developers exclusive to those servers. Now when it comes to me, I’m not too keen on spending money to pay custom developers, so I decided to take matters into my own hands.

Getting Started

My start was… rocky. One piece of advice for anyone who is starting off learning programming is to NOT RELY ON ARTIFICIAL INTELLIGENCE. When I started off, I immediately thought “this won’t be so hard, I can just tell ChatGPT what I want and it will write all the code for me and I won’t have to do any work!WRONG. I installed Visual Studio and spent a whole day trying to tell ChatGPT what I wanted, and I kept running into errors every time. It is impossible to rely on AI to code for you. Whilst I agree, AI can be used for general queries such are more common programming tasks like “How would I do an (if and) statement” or “how do I use hashsets in C#”, it is impossible to use it to do the whole job for you. You need to be able to understand the code for yourself.

Starting properly this time

I took a step back and decided to watch a tutorial for how to program custom plugins specifically for SCP: Secret Laboratory using C#. It was very helpful, and it got me to set up my first plugin. This plugin was simple. All it did was monitor for the event of a player joining the server, and when the event is triggered (when a player joins), the plugin displays a message to the player. Whilst practically useless, this plugin was an essential gateway to me learning to code effectively. It taught me to explore the game’s plugin API and understand the capabilities. From here on, I no longer needed tutorials. All I needed was to play around with the code on my own using different events and actions.

Making something actually useful

The next step was to make something I could actually apply to my server. Since my first plugin was UI-related, displaying a text on the screen upon joining the game, I decided to make my next plugin also UI-related. This plugin was an ammunition counter on the bottom right of the player’s screen, which displays the player’s current held gun and the amount of ammo it has, making it so that players no longer have to open their inventory to check their ammunition.

The specifics of the Ammo-Counter Plugin

Since the base game’s API is slightly flawed, on the basis that you cannot deliver multiple UI elements at the same time, I had to start working with a dependency called HintServiceMeow. This dependency gets multiple UI elements and merges them into one and delivers that to the game altogether, essentially delivering multiple of these elements whilst the game sees and accepts them as one. The main application of this dependency for this plugin is that it enables the programmer to create a persisting element which can be updated. The base game does not allow this. Without the dependency, when updating the UI elements (to change the ammo count when the player fires their gun), I would instead have to delete the past hint and deliver a new one to make it seem like the hint updated. Now I can just have one persisting hint that I update whenever I want without deleting and re-creating it whenever the player shoots their gun.

The Ammo-Counter plugin monitors five events which are fired by the game’s plugin API:

  • PlayerEvents.Joined (When the player joins the game)
  • PlayerEvents.ChangedItem (When the player changes the item they are holding)
  • PlayerEvents.ShootingWeapon (When the player shoots their weapon)
  • PlayerEvents.ReloadedWeapon (When the player reloads their weapon)
  • ServerEvents.RoundEnded (When the round ends)

When the player joins the game

Upon joining, the hint is applied to the player at the bottom right of their screen. This hint is empty and exists to be updated later.

When the player changes the item they are holding

When the player equips an item, the code reads what item the player has equipped. If the item equipped is a gun, the hint text is updated to display the name of the gun held.

When the player shoots their weapon

When the player fires their gun, the code reads the maximum amount of ammunition the gun can have loaded, and the remaining ammunition. The hint text is then updated to the following format:

“ItemName: RemainingAmmo/MaximumAmmo”. For example: “AK-47: 27/30”.

When the player reloads their weapon

When the player reloads their weapon, the remaining ammunition variable is set to equal the maximum ammunition, updating the hint to display that the gun is fully loaded.

When the round ends

On SCP: Secret Laboratory, when a round ends, all players are disconnected and sent to a loading screen awaiting the next round to be loaded. This means that when these players rejoin, a new hint element is displayed to them. However, whilst they can no longer can see the old hint element, that hint remains existent, attached to a null player (a player that no longer exists). This means that after several rounds, there will be many unused hint elements causing strain to the server.

Therefore, I made it so that when a round ends, all AmmoCounter UI elements and ammo variables are purged, keeping the server optimised.

I have provided the full code for this below.

Click to view the code
{
    public class AmmoCounter
    {

        private static readonly Dictionary<Player, Hint> PlayerHint = new();
        private static readonly Dictionary<Player, string> PlayerAmmo = new();

        public static void OnJoined(PlayerJoinedEventArgs ev) 
        {
            Hint hint = new()
            {
                YCoordinate = 1056,
                XCoordinate = 725,
                Alignment = HintAlignment.Center,
                FontSize = 20,
                Id = "ammo",
            };

            // Store the hint in the dictionary
            PlayerHint[ev.Player] = hint;
            Logger.Debug("Created hint for" + ev.Player);

            PlayerDisplay playerDisplay = PlayerDisplay.Get(ev.Player);
            playerDisplay.AddHint(hint);
            
        }

        public static void OnRoundEnded(RoundEndedEventArgs ev)
        {
            Logger.Debug("Clearing all player hints.");
            PlayerHint.Clear();
            PlayerAmmo.Clear();
        }

        public static void OnShooting(PlayerShootingWeaponEventArgs ev)
        {

            if (Config.Guns.ContainsKey(ev.FirearmItem.Type)) 
            {
                PlayerHint[ev.Player].Text = (Config.Guns[ev.FirearmItem.Type] + ": " + (ev.FirearmItem.Base.GetTotalStoredAmmo() - 1) + "/" + ev.FirearmItem.Base.GetTotalMaxAmmo());
            }
        
        }

        public static void OnReloaded(PlayerReloadedWeaponEventArgs ev)
        {
            if (Config.Guns.ContainsKey(ev.FirearmItem.Type))
            {
                PlayerHint[ev.Player].Text = (Config.Guns[ev.FirearmItem.Type] + ": " + (ev.FirearmItem.Base.GetTotalStoredAmmo() - 1) + "/" + ev.FirearmItem.Base.GetTotalMaxAmmo());
            }
        }
        public static void OnChangedItem(PlayerChangedItemEventArgs ev)
        {
            if (ev.NewItem == null || !Config.Guns.ContainsKey(ev.NewItem.Type))
            {
                {
                    PlayerHint[ev.Player].Text = "";
                    PlayerAmmo[ev.Player] = null;
                }
                Logger.Debug("return");
                return;
            }

            if (Config.Guns.ContainsKey(ev.NewItem.Type))
            {
                Logger.Debug("contains");

                PlayerHint[ev.Player].Text = Config.Guns[ev.NewItem.Type];
            }
        }

    }
}

Forming a complete UI Plugin

Following on from the AmmoCounter UI plugin, I decided to make more UI plugins for my server. I made four in total:

Ammo Counter

Discussed above.

Inventory Interactions

When a player picks up an item or an update occurs in their inventory such as an item being added or removed, the player is made aware of this via a hint that appears at the lower-center of their screen.

Watermark

Having seen that some other servers like to display their server name at the bottom of the screen, I decided to also do that with my own additions. I added a watermark which also displays the round time updating every second and the community discord server link.

Lobby Hint

A text that displays at the center of the screen to all players whilst they are in the lobby waiting for the round to start. This shows a general description of the server as well as the discord link.


Once I had made four of these UI plugins, I decided to change the plugin API that our server was using from our formerly-used community-made API to the game’s official one. This was because the game didn’t have their own official Plugin API until only recently, and their new API proved to be more stable. This meant that I also had to reprogram all my plugins to work alongside the new API and its new arguments for the events I was using to fire off my code. Whilst I was doing this, I decided to merge all these UI plugins into one complete one called Sula’s UI.

As of this point, about a month into my C# journey, I could confidently say I am able to program SCP: Secret Laboratory plugins without needing to refer to external sources for support.

The Next Big Step

I wanted to do something that would really make my server stand out. My issue is that I no longer have a lot of time to spend working on the server. Everything that was written before this is discussing the things I did when I had a lot of time on my hands. I’m almost never home anymore, and very rarely able to sit down and focus on programming. Therefore, I resorted to some outsourcing.

Sula’s Custom Classes

I ended up paying a developer to program a framework which I could then work with to program my own custom roles for the game that come with special abilities, something that you don’t really see in other servers. This framework came with a system where players could spawn in the game with a custom role and have a special ability they could use via a custom key bind. This also included a pre-made UI based on HintServiceMeow which would display a countdown for when the player’s special ability is ready. I did have to amend most of code I had received from the developer as it had an issue when it came to hint implementation. Whilst making these amendments, I also implemented a “description” at the top of the player’s screen to help them understand how their special ability works. Now that the difficult part was out of the way, I could work on top of this plugin and program my own roles.

Whilst it still takes more time to program all the roles than it does to program the framework, I could just do one role at a time whenever I was available and release the plugin gradually. This started off with simple roles such as the “Phantom” that could go invisible for a set amount of time with a cooldown of 90 seconds.

I then decided that I want to spend more time on individual roles and make each one more complex and interesting, so I programmed roles such as the Ekko.

The Ekko’s special ability is to rewind time. This role took a long while to make, but the end result was fantastic, as shown in the video below:

For this special ability, I used the ability audio of Ekko from League of Legends (the character that this role was inspired by).

The specifics of the Ekko Role

Upon the player spawning as the Ekko role, the server will start a coroutine which continuously monitors the player’s position and health, making record of the player’s current state every 0.25 seconds. When the player uses their special ability, the plugin will send the player back through their past 32 states, cutting off at the last state out of the 32, leaving them at the position and health they were at roughly 8 seconds ago. If the player is killed whilst their special ability is available (not on cooldown), the special ability will play out automatically and prevent their death. This makes it so that the player is only vulnerable to death when their ability is on cooldown.

Click to view the code
// Little note for this, when I started working on the Sula's Custom Classes plugin, I switched over to Jetbrains Rider and ditched Visual Studio as Rider is much better (at least for SCP:SL plugins). 
// I did use AI to program the coroutine. As I previously stated, whilst you can't rely on AI, it is also a good shortcut for speeding up simple specific tasks. With JetBrains Rider's built-in AI, you can write your base code and then ask the AI for minor implementations - it can view the code you already have and understand where to go from there. I know how to program a coroutine myself, I just used AI to speed up the process. When using tools like this, you have to proof-read the code and make your own amendments after.

{
    public class Ekko
    {
        public static readonly Dictionary<Player, DateTime> EkkoCooldown = new Dictionary<Player, DateTime>();

        public static readonly HashSet<Player> HasUsedAbility = new HashSet<Player>();

        public static float InitialCooldownSeconds = 10f;
        public static float StandardCooldownSeconds = 90f;

        public class PlayerState
        {
            public Vector3 Position { get; set; }
            public float Health { get; set; }

        }

        public static readonly Dictionary<Player, Queue<PlayerState>> StateHistory =
            new Dictionary<Player, Queue<PlayerState>>();

        private const int MaxHistoryLength = 32; // 5 seconds of history

        private static readonly Dictionary<Player, CoroutineHandle> PlayerCoroutines =
            new Dictionary<Player, CoroutineHandle>();

        // Handles activation of the Archangel's special ability
        public static void ActivateEkkoAbility(Player player)
        {
            if (EkkoCooldown.TryGetValue(player, out var lastUseTime))
            {
                var currentCooldown =
                    HasUsedAbility.Contains(player) ? StandardCooldownSeconds : InitialCooldownSeconds;

                var elapsed = (DateTime.Now - lastUseTime).TotalSeconds;
                if (elapsed < currentCooldown)
                {
                    player.SendHint(
                        $"<color=#FF4500><b>Ability is on cooldown. Time remaining: </b></color><color=#FFD700>{currentCooldown - elapsed:F1}</color> <color=#87CEEB>seconds</color>");
                    return;
                }
            }
            
            if (player.GetEffect<PocketCorroding>().IsEnabled)
            {
                player.SendHint("<color=#FF4500><b>Cannot use ability while in the pocket dimension!</b></color>");
                return;
            }

            
            if (!HasUsedAbility.Contains(player))
            {
                HasUsedAbility.Add(player);
            }

            EkkoAbilityAudio(player);

            if (StateHistory.TryGetValue(player, out var history) && history.Count > 0)
            {
                // Start the gradual rewind coroutine
                Timing.RunCoroutine(RewindPlayerHistory(player, history));
            }

            EkkoCooldown[player] = DateTime.Now;
        }

        private static IEnumerator<float> RewindPlayerHistory(Player player, Queue<PlayerState> history)
        {
            if (history.Count == 0)
                yield break;

            player.IsGodModeEnabled = true;
            player.EnableEffect<Fade>(150, 0f);
            player.EnableEffect<Blindness>(50, 0f);

            // Convert queue to list to access states in reverse order
            List<PlayerState> states = new List<PlayerState>(history);

            // Calculate time between each state transition
            float totalDuration = 0.3f; // 1 second total
            float timePerState = totalDuration / states.Count;

            // Go through states from newest to oldest (reverse order)
            for (int i = states.Count - 1; i >= 0; i--)
            {
                PlayerState state = states[i];
                player.Position = state.Position;
                player.Health = state.Health;

                // Wait before moving to next state (except on the last one)
                if (i > 0)
                {
                    yield return Timing.WaitForSeconds(timePerState);
                }
            }

            player.IsGodModeEnabled = false;
            player.DisableEffect<Fade>();
            player.DisableEffect<Blindness>();

        }

        // Handles initialization when a Juggernaut role is assigned to a player
        public static void EkkoRoleAdded(Player player, CustomRoleBaseInfo roleInfo)
        {
            if (player == null || roleInfo == null)
                return;

            if (roleInfo.Rolename != "Ekko")
                return;

            EkkoCooldown[player] = DateTime.Now;

            HasUsedAbility.Remove(player);

            // Initialize state history for this player
            if (!StateHistory.ContainsKey(player))
            {
                StateHistory[player] = new Queue<PlayerState>();
            }
            else
            {
                StateHistory[player].Clear();
            }

            // Start tracking the player's position and health

            // Kill existing coroutine if running
            if (PlayerCoroutines.TryGetValue(player, out var existingHandle))
            {
                Timing.KillCoroutines(existingHandle);
            }

            // Start tracking the player's position and health
            PlayerCoroutines[player] = Timing.RunCoroutine(PastCheck(player));
        }
    
        public static void EkkoAbilityAudio(Player player)
        {
            AudioPlayer audioPlayer = AudioPlayer.CreateOrGet($"Player {player.Nickname}", onIntialCreation: (p) =>
            {
                // Attach created audio player to player.
                p.transform.parent = player.GameObject.transform;

                // This created speaker will be in 3D space.
                Speaker speaker = p.AddSpeaker("Main", isSpatial: true, volume: 1.5F, minDistance: 5f, maxDistance: 15f);

                // Attach created speaker to player.
                speaker.transform.parent = player.GameObject.transform;

                // Set local positino to zero to make sure that speaker is in player.
                speaker.transform.localPosition = Vector3.zero;
            });

            audioPlayer.AddClip("Ekko");
        }
        
        public static IEnumerator<float> PastCheck(Player player)
        {
            while (player.IsOnline)
            {
                // Log position and health here
                if (!StateHistory.ContainsKey(player))
                {
                    StateHistory[player] = new Queue<PlayerState>();
                }
                
                var history = StateHistory[player];
                
                // Add current state (position and health) to history
                history.Enqueue(new PlayerState
                {
                    Position = player.Position,
                    Health = player.Health
                });
                
                // Keep only the last 5 states (5 seconds of history)
                if (history.Count > MaxHistoryLength)
                {
                    history.Dequeue();
                }
                
                yield return Timing.WaitForSeconds(0.25f);
            }
            
            // Cleanup when player goes offline
            if (StateHistory.ContainsKey(player))
            {
                StateHistory.Remove(player);
            }
        }

        public static void OnDying(PlayerDyingEventArgs ev)
        {
            var player = ev.Player;

            if (SCREvents.TryGetCustomRole(player, out var customRoleInfo))
            {
                if (customRoleInfo.Rolename == "Ekko")
                {
                    if (EkkoCooldown.TryGetValue(player, out var lastUseTime))
                    {
                        var currentCooldown = HasUsedAbility.Contains(player) ? StandardCooldownSeconds : InitialCooldownSeconds;

                        var elapsed = (DateTime.Now - lastUseTime).TotalSeconds;
                        if (elapsed > currentCooldown)
                        {
                            if (!player.GetEffect<PocketCorroding>().IsEnabled)
                            {
                                ev.IsAllowed = false;
                                ActivateEkkoAbility(player); 
                            }
                        }
                        else
                        {
                            ev.IsAllowed = true;
                            if (PlayerCoroutines.TryGetValue(player, out var handle))
                            {
                                Timing.KillCoroutines(handle);
                                PlayerCoroutines.Remove(player);
                            }
            
                            // Clear state history
                            if (StateHistory.ContainsKey(player))
                            {
                                StateHistory.Remove(player);
                            }
                        }
                    }
                }
            }
        }
    }
}

My Favourite Custom Class So Far

I’ve spent time really perfecting some of these custom classes, and so far, the class I am most proud of having programmed is the Spartan. Whilst the spartan isn’t as useful as the Ekko role, in my opinion, it is really the most epic role as it includes a cinematic section to it. The spartan role does not have an active special ability, but rather a passive ability. Upon the player’s death, the player instead enters “The Last Stand”. A short-lasting period of time where they are empowered before finally dying. Here’s how it looks:

I made the audio for the Spartan by combining three separate sound effects: A Kratos (God of War) voiceline, an explosion sound effect, and an Attack on Titan roar.

As of writing this, I have programmed 16 roles that feature unique special abilities.

Conclusion

Apart from this, I have made other C# SCP: Secret Laboratory plugins, however I made sure to feature only the ones that are really worth talking about as they were the ones that really contributed to my learning journey.

Really all it takes to learn C# programming is a tutorial on the basics as a starting point, and then jumping into the deep end on your own and exploring the capabilities. Of course, this wasn’t all that helped point me in the right direction.

Two tips I’d give to those starting off learning how to code are to, firstly, after watching a tutorial and learning the bare minimum basics, join a community where you can ask for support. I had a lot of support on small things and silly errors from the SCP: Secret Laboratory developer community. If I ever ran into an issue I didn’t understand or know how to fix (it was always something small that needed the tiniest change to fix), I’d ask in the developer chat and people were there to help me. Secondly, viewing other open-source programs and reading through the code is incredibly helpful. When in comes to SCP: Secret Laboratory, most common plugins are open-source on GitHub and developers can take inspiration from others’ code. That’s how I learned how to use the HintServiceMeow dependency. It was all through viewing example codes and taking inspiration.

It’s all possible and becomes very easy if you really immerse yourself in your projects.

And do not start with f@^king ChatGPT.