Level Systems (part 2)

This is a continuation of Level Systems (part 1). In part 2 we will be getting into some heavy math with advanced experience functions, modifier systems, and sanity checks.

I’ve decided to take out the section dealing with choosing constants for this part due to the length. If you’d like that section, I can add it later on.

Modifier Systems
I will only be approaching two methods of modifier systems, they are not necessarily the most efficient or “best”.

The first style of modifier system is simply a boolean flag with a preset modification rate.

Assuming you had our previous version of the AddExp function, this is a pretty simple addition. We will start with our variables:

local ModifierFlag,ExpMultiplier = false,2

These, likely, should be variables with the rest and be global within your environment so you can implement ways to change them. I’ll leave that part to you.

The changes to the AddExp function can be broken into steps:

  • Before the rest of the AddExp function runs we need to first check if the modifier is set.
  • Then we must multiply our experience
  • Finally continue with the rest of the function

In code this would look like this:

Hidden so you can try implementing it yourself first
local function AddExp(amount)
    if (ModifierFlag) then amount = amount * ExpMultiplier end

    if (currentExperience+amount) > EF(Level) then
        --Step 0: Since we overflowed, calculate leftover Exp
        local LeftOverExp = (currentExperience+amount)-EF(Level)
        --Step 1: increment the level
        Level = Level + 1
        --Step 2: Since we overflowed, set the exp to 0 and recurse
        currentExperience = 0
        AddExp(LeftOverExp)
    elseif (currentExperience+amount) == EF(Level) then
        --Step 1: increment the level
        Level = Level + 1
        --Step 2: reset experience
        currentExperience = 0
    else
        currentExperience = currentExperience + amount
   end
end

Now this is no where near an ideal setup, in many games for example you may want to sell a time limited experience booster, this rapidly becomes more complex, and for simplicity I’m not going to handle the datastore part of this system, other guides cover datastore better than I ever could.

So what’s the solution? Data structures!

Let’s first map out the type of information we’d need for an implementation which: Has a time limited booster, the booster has a specific multiplier, and a single booster cannot be duplicated.
(I’ll let you handle the player specific part of this, remember this is a theory guide not an implementation guide.)
So now we have:

  • An identifier for the booster
  • A timestamp for the booster
  • A duration for the booster
  • The multiplier that the booster provides

In code we’d assume that this structure might look a bit like:

local SomeRandomPlayersBoosters = {identifier,timestamp,duration,multiplier}

Your own implementation might be more readable if you include this with dictionary definitions as such:

local SomeRandomPlayersBoosters = {
    {
        Identifier = "SomeBooster"; --This doesn't have to be a string, just unique to the booster type
        TimeStamp = 0; --in reality this would be tick() of when it was created
        Duration = 259200; --I don't really care what your duration is, but it'll need to be in seconds. For now I set it to three days, handle your datastore appropriately
        Multiplier = 2; --Ideally some number greater than 1, otherwise you will hurt your players not help them
    },
    {
--This would be another booster, same properties, I left this here as an example.
--In reality this would break the next couple steps if left here.
    }
}

Cool! We now know what our data looks like, so let’s figure out how to make a booster.

The function which creates the booster will stamp the time itself, that means the only things the function needs are the duration, multiplier, and identifier.

Hidden so you may try it yourself first
function CreateModifier(identifier, duration, multiplier)
    --tick() is the provided function we will use. It returns the current time in seconds since 1970 started.
    return {Identifier = identifier, TimeStamp = tick(), Duration = duration, Multiplier = multiplier}
end

Awesome, we now have a way to create a modifier, I’ll (again) leave it to you to make this player specific, each player will need their own table of modifiers, so the function above will likely need another argument to insert the modifier into the appropriate table, or return it to a function that already has that information.

With this setup you’ll be able to do awesome things! You can for example, create a double experience weekend, and then players can still buy experience boosters! They feel good about what they earn, you still make money, etc.

Now we have to get into the less satisfying part of actually reimplementing the AddExp function to use this. I’ll post my own implementation below, but if you’d like to try it yourself, this is the algorithm broken into steps:

  • Before we run the core of the function we’ve already made, we need to find a way to remove any expired modifiers.
  • After we remove the expired modifiers we need to apply all of the modifiers to the experience
  • Finally we need to finish our function like we did before.

The following implementation will work for that same single player like above, I have faith you all can figure out how to make this player specific.

AddExp with Modifiers implementation
function AddExp(amount,recursed) --recursed is added for an efficiency check
    if recursed ~= true then
        local removals = {}
        for i,v in next, SomeRandomPlayersModifiers do
            if v.Duration + v.TimeStamp < tick() then
                table.insert(removals,i)
            else
                --do the multiplication on the amount
               amount = amount * v.Multiplier
            end
        end
        for i,v in next, removals do
            table.remove(SomeRandomPlayersModifiers,(v-(i-1)))
        end
    else
        --We don't do the multiplier because the experience is multiplied. We also already checked for expired boosters.
    end
    if (currentExperience+amount) > EF(Level) then
        --Step 0: Since we overflowed, calculate leftover Exp
        local LeftOverExp = (currentExperience+amount)-EF(Level)
        --Step 1: increment the level
        Level = Level + 1
        --Step 2: Since we overflowed, set the exp to 0 and recurse
        currentExperience = 0
        AddExp(LeftOverExp,true)
    elseif (currentExperience+amount) == EF(Level) then
        --Step 1: increment the level
        Level = Level + 1
        --Step 2: reset experience
        currentExperience = 0
    else
        currentExperience = currentExperience + amount
    end
end

At this point you basically have a working modifier system if you’ve been following along and making the appropriate changes for having multiple players.

Advanced Experience Functions
This part is going to get math heavy. I will not go into much of the implementation of these functions, just cover the math behind a couple common techniques. I will be including graphs, although you can graph them yourself as well, I highly recommend desmos for this.

Disclaimer: All of the graphs below are unlabeled. Assume X axis is level and Y axis is experience needed. You may have to change your constants to get something satisfying.

When we first made an experience function in part 1, we used a linearly increasing function, specifically,

constant + Level*ExperienceScale

At a graphical level a linear experience function increases like this

But wait! I hear you say, that’s not a line like it should be! That’s because we’re only using whole numbers, which is why it looks staggered. Specifically this actual function would look like:

constant + math.floor(Level)*ExperienceScale

Now, this may be fine for a game that stops gaining levels at say level 15 or 20, but if you have infinite levels then eventually you need to collect infinite experience. Which becomes more of a punishment to the player than a reward, or milestone.

For that reason, many games often use a logarithmic curve, initially levels increase in experience needed kind of rapidly and then taper off.

A simple log curve (specifically natural log) looks like this:

This of course is not totally usable, and you’d need to like the linear version add a constant so that you have some amount of experience under the curve, also don’t forget that this is a floor function on our system. Finally, that’s not a very steep curve at the beginning so we should probably add some constant multiplier on the outside. I used this:

3*math.log(math.floor(Level)) + 1
Which will produce:

Again, this is nice, and can be used in many implementations, while it still increases infinitely, it does so much slower, your players will likely be happier with a curve like this.

But, again there is another form of experience function, in my opinion, the most rewarding for the player. And that is a milestone experience function. Many more modern games will use functions similar to this. Essentially you want to create a repeating function that slowly increases. You reach your milestone, then the experience goes back down by quite a bit, etc. This also lets you build in other types of reward mechanics.

Here is an example of a very basic milestone function:

The math behind this specific function utilizes a modulus to create the milestone levels, and a linear function to create the slow rise.

(math.floor(Level)%5) + (math.floor(Level)/5)

The last type of experience function I will cover is an advanced experience function which is a hybrid between a logarithmic function and a milestone function. This is basically what League of Legends uses currently.

The math of this is a bit tedious and you’d have to play around with it for your own implementations, but it looks like this:

function EF()
    if Level<50 then
        return 13.85*math.log(math.floor(Level))
    else
        return (math.floor(Level)%5) + math.log(math.floor(Level))+(math.floor(Level)%10)+(math.floor(Level)/30) + 50
    end
end
Which creates a lovely graph like this:

And, to reference part 1, if you hate your players you can do something like this:

2^(math.floor(Level)/5)
Which makes this graph

There are more types of experience functions available, however these are rather common examples, and hopefully these examples will help you craft your own with your own constants.

Sanity Checks

Perhaps the most important part of this entire guide is sanity checks. It’d be unfortunate for example if your players overflowed their levels or experience.

As part of this, I’m going to address one concern from a previous part.
Thanks to @Quoteory for reminding me of this.

What if your experience gain is so high that it overflows multiple levels? At extremely high levels this could cause the AddExp function to recurse to an almost infinite extent and crash. The solution is a sanity check. For instance in a single call it might be a bit odd for a player to overflow their experience bar more than 3 times, that should raise flags and how you handle it is up to you, for this I’ll just reduce the experience call to 3 calls.

Example:
function ExperienceOverflowCheck(Exp)
    if Exp > EF(Level) + EF(Level+1) + EF(Level+2) then
        --Could be a hacker depending on your implementation, handle it appropriately.
        return EF(Level) + EF(Level+1) + EF(Level+2)
    else
        return Exp
    end
end

This is a simple check that can be done before or after your multiplier code, I recommend after, so that stacking multipliers has some upper limit in how many levels you can gain with one call.

There is of course one more problem, ideally your implementation wont allow this anyways, but what if there was some way the client could force AddExp(someRandomAmount) to be called? What if it did that a thousand times a second? That seems a bit ridiculous to me, so we can handle it with a log structure of some kind.

All we really need over a given period of time is the average call rate, and we can put a limit on that average rate. We could for instance, poll this every 3-5 seconds. In 3 seconds you may say only want them to be able to call this 15 times, maybe they had some sort of AOE multikill, let’s give them the benefit of the doubt.

A simple check could look like this:
RecentCalls = {}
function AddExp()
    table.insert(RecentCalls, tick())
    local removals = {}
    local avg,num = 0,0
    for i,v in next, RecentCalls do
        if v - tick() > 3 then --Our time check
            table.insert(removals,i) --add it to the removal queue
        else
            avg = avg + v
            num = num + 1
        end
    end
    for i,v in next, removals do
        table.remove(RecentCalls,(v-(i-1)))
    end
    avg = avg/num
    if avg > 15 then --Our sanity check finally
        return --exit, maybe handle this as a hacker, up to you. But it's a high number.
    end
    --body that we've been working on
end

This is where I will wrap up this part. If there is significant interest I can do more parts, but I have not decided to as of now. I’m happy to answer questions in the comments, and if there are concerns with any of the code posted please let me know so I can fix it.

As stated a couple times, this code is meant to be a guide not an absolute, make your own. I’ve tried to glitch proof everything that I’ve written but something probably slipped through, and it’s obviously not highly-efficient in multiple places.

That said, I hope this helps.

Good luck, Vathriel

Edit: If you have happened to be following along, all of your level values will be whole numbers, so your experience functions will not need the math.floor calls, I was mainly using those for graphical purposes. You may be able to gain a marginal efficiency boost by removing those calls and instead using the raw Level value.

59 Likes

Awesome, can’t wait for Part 3!

When can I expect it to come out?

1 Like

I’m still in the planning phases for part 3, might be a week or two out.

4 Likes

Why not have made this one whole thread to keep from making multiple post? You can use the hide details to collapse your chapters like I did.

I’m just bringing this up cause when I asked about being allowed to do something like this it wasn’t recommended for making multiple threads.

1 Like

It simply slipped my mind to do so. I’ll likely just update part 2 to be part 2 and 3 like your chapters.

Thanks for bringing this up!

3 Likes

Thank you for making this Tutorial, the best i could do until now was this:

Bad Code
leaderstats.Experience:GetPropertyChangedSignal("Value"):Connect(function()
	if leaderstats.Experience.Value >= leaderstats.Level.Value*100 then
		leaderstats.Level.Value = leaderstats.Level.Value + 1
		leaderstats.Experience.Value = 0
	end
end)

Keep with the tutorials going! :wink:

it’s been two weeks since this part was released, yay!

2 Likes

Will probably append part 3 on to this post either tomorrow or the day after.

EDIT: expect part three on Tuesday. I had to fly out for an emergency.

2 Likes

Any update on part 3? I’ve liked your tutorial thus far and am interested in seeing what other input you can provide on the matter.

1 Like

I’m about 80% done with part 3. In part 3 I step back from some of the code and math to talk a bit about balancing theory and implementation within a larger game. Things like how to make the system feel good for the player while doing the job of slowing progression enough to provide potential income streams (like the boosters we talked about earlier and other things.)

Because it’s so heavy on theory I’m trying to cover it broadly, but there is a section on minor efficiency changes to be made to the code.

If any of you have suggestions that you’d like to see in part 3 let me know. Otherwise it’s going to be out soontm

3 Likes

last post 7 mounths, ago may i ask when the third part of this serie will come out ?

2 Likes

Sorry about that. I had to take a break for a while. Uni got a bit busy. Perhaps this next week (praise spring break) I’ll finally finish up the last section. Thanks for the reminder!

Edit: Should be up about Monday ± one day.

3 Likes

can’t wait for that last part I am sure it will as good as the previous ones :+1: