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 tick()-v > 3 then --Our time check
            table.insert(removals,i) --add it to the removal queue
        else
            avg = avg + (v-RecentCalls[1]) --normalize against t0
            num = num + 1
        end
    end
    for i,v in next, removals do
        table.remove(RecentCalls,(v-(i-1)))
    end
    num = num == 0 and 1 or num --don't divide by 0
    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.

132 Likes

Awesome, can’t wait for Part 3!

When can I expect it to come out?

5 Likes

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

6 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.

2 Likes

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!

5 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!

3 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

5 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:

Any progress so far? I’d love to see how you end this off! Enjoy your break and stay safe!

1 Like

Brilliant guide, I love it!

However:

How would I set a maximum level? I cannot seem to work it out and I feel really silly. It must be a simple validation of the current level before adding XP?

If the max level is 100 and the user is at 99, I want to ensure that they cannot overfill xp points when progressing to the last obtainable level, whatever xp points that will contribute towards the last level should be used yes, but how do I then stop it from overflowing.

I just want to set a general level cap itself so the entire leveling system knows not to continue doing anything if the max level is reached.

Here’s the code I tried, but it would overfill if getting to level 100.

local Level = 1
local currentExperience = 0
local constant, ExperienceScale = 100,10
local EF = (function(level) return constant + (level*ExperienceScale) end)

local maxLevel = 100

local function AddExp(amount)
	if Level == 100 then return end
	
    if (currentExperience+amount) > EF(Level) then
        --Step 0: Since we overflowed calculate the exp leftover
        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
	print(Level.." - "..currentExperience.."/"..EF(Level))
end

while true do
	wait(.1)
	AddExp(100)
end

Many thanks,
-Nidoxs

2 Likes

Your intuition is correct, mostly. There are a couple cases to consider:

First, the case that AddExp is called and we’re already at the max level. Returning in this case is fine.

Second, we are inside the if (currentExperience+amount) > EF(Level) then

In this case we know that we are currently not max level due to your first check, however we also know that if we are inside this if statement we are in the process of leveling up. In this case the AddExp function is called after we increment the level. Thus it should instantly return off of the call from the if statement you added.

In the next case, (currentExperience+amount) == EF(Level) we find that we are also in the process of leveling up, however in this case there is no left over experience and we instantly return. This is also fine as there is never another call to AddExp, meaning we’d get to 100 and halt.

In the final case you are simply filling (but not exceeding) the maximum level. Thus you should also be okay.

I should note that I ran your code, just to check if I missed anything, and it seems to work fine. Though I should note, your check isn’t currently checking maxLevel but rather if Level == 100 then. You may have simply forgotten your variable, I’d recommend changing it to if Level == maxLevel then

Another thing is that your while loop (which I assume is for testing) will continue calling the function. This isn’t horrible as test code, but if you use experience over time in your game (requiring a while loop or something similar) I’d recommend adding a check to the while loop such as

while Level<MaxLevel do --this will halt the experience over time, 
    --thus not polluting your game with a bad loop.
    wait(.1)
    AddExp(100)
end
AddExp(100) --This call will instantly return (just as an example of a game 
--event trying to add experience)

If I missed anything please let me know. TL;DR your solution seems to work fine and I just added in a short discussion for experience over time.

1 Like

Are you still going to make a part 3??

1 Like

Honestly I haven’t been on roblox since I suffered some hardware issues a while back. The only reason I saw this was because of a roblox email.

I can’t promise a part 3 at this point in time, but if some stuff clears up I’ll try to get one out. No guarantees though as this is obviously well past the date of the initial tutorial.

2 Likes

Sorry for the bump, but on the “Advanced” experience function, my code:
local xpScale = 3000
local xpDivision = 10
local xpConstant = 1000
local function xpToNextLevel(level)

	local xpNeeded = 0
	
	if level<50 then
		xpNeeded = xpScale*math.log(math.floor(level)) + xpConstant
	else
		xpNeeded = (math.floor(level)%5) + math.log(math.floor(level))+(math.floor(level)%10)+(math.floor(level)/xpDivision) + xpConstant
	end
	
	xpNeeded = math.floor(tonumber(xpNeeded / 100 + 0.5)) * 100
	return tonumber(xpNeeded)
end

only returns 1000 after level 50. Can anyone explain this?

EDIT: This line
xpNeeded = math.floor(tonumber(xpNeeded / 100 + 0.5)) * 100
Is only there to round to the nearest 100.

Hi!

This is a result of what becomes your dominant term in your polynomial. Perhaps I should update the guide to include an update on that specific version of the experience function, as it was mainly used as an example.

So lets break up what happens to these terms.

(level%5) can only ever be 0, 1, 2, 3, or 4.

log(level) is an increasing function, though logs are not known to increase rapidly. Notice how log(50) is 1.69 where as log(100) is 2. By this point in the function the log essentially becomes useless unless you add in another multiplicative scaling on it.

(level%10) similarly to before can be any integer [0,9]

(level/10) this is an increasing function, but again 50/10 = 5, 100/10 = 10, it does not increase rapidly enough to be noticeable.

finally we have our constant 1000.

Since all of the terms except the constant 1000 are essentially nothing - or are simply math.floor’d out of importance you have nothing to continue reasonably increasing the experience past 1000.

If you put in large enough numbers the amount of experience would increase eventually, but it would take a while, as you’d likely have to wait for that (level/10) to become dominant over the rounding to nearest 100.

Essentially you need something that scales significantly enough to overtake your rounding to the nearest 100. I’d personally recommend probably a larger linear function to give you a bit more of a kick.

Hope that helps!

Hey thank you for the tutorial! Everything has been working out so far but I just had a question regarding the sanity check part and I’m not sure if I’ve got it in the correct placement in the script cause the function stopped printing altogether.

RecentCalls = {}
function AddExp(amount,recursed) --recursed is added for an efficiency check
	if Level == MaxLevel then return end
	
	if recursed ~= true then
		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
		
		for i,v in next, SomeRandomPlayersBoosters 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(SomeRandomPlayersBoosters,(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

	print(Level.." - "..currentExperience.."/"..EF(Level))
end

ps. I’ve got a while loop adding exp.

1 Like