Level Up System - Player Gains Multiple Levels

Godot Version

<! 4.2.1 Stable →

Question

<! Hey,

Been following this tutorial - https://www.youtube.com/watch?v=LjV8rKWz9hY&list=PLtosjGHWDab682nfZ1f6JSQ1cjap7Ieeb&index=11&ab_channel=Branno

I’m stuck trying to fix a bug with my level up system. The game is a Survivors-like and so when the player grabs an exp gem they level up. Problem is, experience gems with higher values cause multiple level ups simultaneously, skipping potential upgrades. On a level up the player normally gets 3 choices but when this bug occurs the player gets 3 for every level granted. Ideally, the level up loop would pause at each level allowing for a skill choice each time the required_exp variable is met.

After testing I have found that a single gem with a high value operates correctly and allows for multiple, separate level ups. the issue seems to arise when multiple XP gems are gathered at the same time or very near each other.

I’ve reviewed the finished code from the tutorial and gone over what have several times, looked online, but just can not get this bug sorted. Any advice is appreciated. Please let me know if you need any more info.

decent amount of code to look through… thanks again for your time!

Player Level Up Script Info

func _on_collect_area_area_entered(area):
	if area.is_in_group("loot"):
		var gem_exp = area.collect()
		calculate_experience(gem_exp)  

func calculate_experience(gem_exp):
	var exp_required = calculate_experience_cap()
	collected_experience += gem_exp
	if experience + collected_experience >= exp_required: #level up
		collected_experience -= exp_required - experience
		experience_level += 1
		experience = 0
		exp_required = calculate_experience_cap()
		levelup()
	else:
		experience += collected_experience
		collected_experience = 0
	
	set_expbar(experience, exp_required)

func calculate_experience_cap():
	var exp_cap = experience_level
	if experience_level < 20:
		exp_cap = experience_level*5
	elif experience_level < 40:
		exp_cap = 95 + (experience_level - 19) * 8
	else:
		exp_cap = 255 + (experience_level - 39) * 12
	return exp_cap

func levelup():
	sndlevelUp.play()
	lblLevel.text = str("Level: ", experience_level)
	var tween = levelPanel.create_tween()
	tween.tween_property(levelPanel,"position", Vector2(440,110),0.2).set_trans(Tween.TRANS_QUINT).set_ease(Tween.EASE_IN)
	tween.play()
	levelPanel.visible = true
	var options = 0
	var optionsmax = 3
	while options < optionsmax:
		var option_choice = itemOptions.instantiate()
		option_choice.item = get_random_item()
		upgradeOptions.add_child(option_choice)
		options += 1
	get_tree().paused = true

func upgrade_character(upgrade):
	match upgrade:
		"chargedshot1":
			chargedshot_level = 1
			chargedshot_baseammo += 1
		"chargedshot2":
			chargedshot_level = 2
			chargedshot_baseammo += 1
		"chargedshot3":
			chargedshot_level = 3
		"chargedshot4":
			chargedshot_level = 4
			chargedshot_baseammo += 1
		"burstlaser1":
			burstlaser_level = 1
		"burstlaser2":
			burstlaser_level = 2
			burstlaser_baseammo += 3
			burstlaser_attackspeed += -.01
		"burstlaser3":
			burstlaser_level = 3
			burstlaser_baseammo += 4
			burstlaser_attackspeed += -.01
		"burstlaser4":
			burstlaser_level = 4
			burstlaser_baseammo += 7
			burstlaser_attackspeed += -.02
		"unstablebolt1":
			unstable_bolt_level = 1
		"unstablebolt2":
			unstable_bolt_level = 2
			unstable_bolt_baseammo += 5
			unstable_bolt_pulse += 1
		"unstablebolt3":
			unstable_bolt_level = 3
			unstable_bolt_baseammo += 5
			unstable_bolt_timer.wait_time += -.5
		"unstablebolt4":
			unstable_bolt_level = 4
			unstable_bolt_baseammo += 8
			unstable_bolt_pulse += 1
		"tornado1":
			tornado_level = 1
			tornado_baseammo += 1
		"tornado2":
			tornado_level = 2
			tornado_baseammo += 1
		"tornado3":
			tornado_level = 3
			tornado_attackspeed -= 0.5
		"tornado4":
			tornado_level = 4
			tornado_baseammo += 1
		"spinningblade1":
			spinning_blade_level = 1
		"spinningblade2":
			spinning_blade_level  = 2
			spinning_blade_ammo += 1
			spinning_blade_area = spinning_blade_area * 1.10
			spinning_blade_rot = spinning_blade_rot * 1.10
		"spinningblade3":
			spinning_blade_level  = 3
			spinning_blade_ammo += 1
			spinning_blade_area = spinning_blade_area * 1.15
			spinning_blade_rot = spinning_blade_rot * 1.15
		"spinningblade4":
			spinning_blade_level  = 4
			spinning_blade_ammo += 1
			spinning_blade_area = spinning_blade_area * 1.20
			spinning_blade_rot = spinning_blade_rot * 1.20
		"hellblade1":
			hellblade_level = 1
		"hellblade2":
			hellblade_level = 2
		"hellblade3":
			hellblade_level = 3
			hellblade_ammo += 1
		"hellblade4":
			hellblade_level = 4
		"shields1","shields2","shields3","shields4":
			shields += 1
		"speed1","speed2","speed3","speed4":
			speed_upgrade += 25.0
		"explosive1","explosive2","explosive3","explosive4":
			explosive += 0.10
		"liquid cooling1","liquid cooling2","liquid cooling3","liquid cooling4":
			liquid_cooling += 0.05
		"hardpoint1","hardpoint2":
			additional_attacks += 1
		"repair":
			hp += 20
			hp = clamp(hp,0,maxhp)
			set_HPBar(hp, maxhp)
		"upgrade":
			var rand_upgrade = randi_range(1,11)
			match rand_upgrade:
				1,2,3:
					shields += sh_upg 
					sh_upg = sh_upg  * .95
				4,5,6:
					speed_upgrade += 25.0 * spd_upg
					spd_upg = spd_upg  * .95
				7,8:
					liquid_cooling += 0.05 * lc_upg
					lc_upg = lc_upg  * .95
				9,10:
					explosive += 0.10 * ex_upg
					ex_upg = ex_upg  * .95
				11:
					additional_attacks += 1
	adjust_GUI_Collection(upgrade)
	has_weapon = true
	attack()
	var option_children = upgradeOptions.get_children()
	for i in option_children:
		i.queue_free()
	upgrade_options.clear()
	collected_upgrades.append(upgrade)
	levelPanel.visible = false
	levelPanel.position = Vector2(1280,110)
	get_tree().paused = false
	calculate_experience(0)



func get_random_item():
	var dblist = []
	for i in UpgradeDb.UPGRADES:
		if i in collected_upgrades: #find already chosen upgrades
			pass
		elif i in upgrade_options: # if upgrade is already selected as option
			pass
		elif UpgradeDb.UPGRADES[i]["type"] == "item": #don't pick food
			pass
		elif has_weapon == false and UpgradeDb.UPGRADES[i]["type"] != "weapon":
			pass
		elif UpgradeDb.UPGRADES[i]["prerequisite"].size() > 0: #check prereq
			var to_add = true
			for n in UpgradeDb.UPGRADES[i]["prerequisite"]:
				if not n in collected_upgrades:
					to_add = false
			if to_add:
				dblist.append(i)
		else:
			dblist.append(i)
	if dblist.size() > 0:
		var randomitem = dblist.pick_random()
		upgrade_options.append(randomitem)
		return randomitem
	else:
		return null



GEM CODE:
extends Area2D

@export var experience = 1

var spr_green = preload("res://Assets/Sprites/Gems/gemstone15.png")
var spr_blue = preload("res://Assets/Sprites/Gems/gemstone21.png")
var spr_red = preload("res://Assets/Sprites/Gems/gemstone6.png")
@onready var spr_anim = $Sprite2D/GPUParticles2D

var target = null
var speed = -1
@onready var sprite = $Sprite2D
@onready var collision = $CollisionShape2D
@onready var sound = $AudioStreamPlayer

func _ready():
	if experience < 5:
		return
	elif experience < 26:
		sprite.texture = spr_blue
		spr_anim.texture = spr_blue
	else:
		sprite.texture = spr_red
		spr_anim.texture = spr_red

func _physics_process(delta):
	if target != null:
		global_position = global_position.move_toward(target.global_position, speed)
		speed += 2*delta
		

func collect():
	sound.play()
	collision.call_deferred("set","disabled",true)
	sprite.visible = false
	return experience




func _on_audio_stream_player_finished():
	queue_free()


It’s important to keep your posts concise to ensure they are easily readable. Instead of including a large amount of code, it’s best to shorten it.

You’ll likely need to troubleshoot it on your own. This type of problem often occurs when too much code is added at once. It’s better to write a little, test, and then repeat.

To resolve this, you should start from scratch by commenting out all the code, then gradually uncomment the code and test each time, the core parts of your system first, then the others.

This approach will help you identify the most problematic section and observe its behavior. If you notice any changes in the functionality while making modifications, focus on that specific part.

In my experience, I have often met mysterious but ultimately ridiculous mistakes caused by my overconfidence in my code.

Thanks Jo! I’ll give take a break from the code for a bit and then give this a shot. Maybe that will help too!

cheers!

I went through and deleted some of the extra fluff so it might be a bit easier to spot the issue if anyone else has a chance to take a look.

Again, level Up system works well but upon collecting multiple XP gems at once, simultaneous level ups/level up skips happen!

Player Level Up Script Info

func _on_collect_area_area_entered(area):
	if area.is_in_group("loot"):
		var gem_exp = area.collect()
		calculate_experience(gem_exp)  

func calculate_experience(gem_exp):
	var exp_required = calculate_experience_cap()
	collected_experience += gem_exp
	if experience + collected_experience >= exp_required: #level up
		collected_experience -= exp_required - experience
		experience_level += 1
		experience = 0
		exp_required = calculate_experience_cap()
		levelup()
	else:
		experience += collected_experience
		collected_experience = 0
	
	set_expbar(experience, exp_required)

func calculate_experience_cap():
	var exp_cap = experience_level
	if experience_level < 20:
		exp_cap = experience_level*5
	elif experience_level < 40:
		exp_cap = 95 + (experience_level - 19) * 8
	else:
		exp_cap = 255 + (experience_level - 39) * 12
	return exp_cap

func levelup():
	sndlevelUp.play()
	lblLevel.text = str("Level: ", experience_level)
	var tween = levelPanel.create_tween()
	tween.tween_property(levelPanel,"position", Vector2(440,110),0.2).set_trans(Tween.TRANS_QUINT).set_ease(Tween.EASE_IN)
	tween.play()
	levelPanel.visible = true
	var options = 0
	var optionsmax = 3
	while options < optionsmax:
		var option_choice = itemOptions.instantiate()
		option_choice.item = get_random_item()
		upgradeOptions.add_child(option_choice)
		options += 1
	get_tree().paused = true

func upgrade_character(upgrade):
	match upgrade:
		"chargedshot1":
			chargedshot_level = 1
			chargedshot_baseammo += 1
		"chargedshot2":
			chargedshot_level = 2
			chargedshot_baseammo += 1
		"chargedshot3":
			chargedshot_level = 3
		

.... More upgrade code, likely not an issue...

	adjust_GUI_Collection(upgrade)
	has_weapon = true
	attack()
	var option_children = upgradeOptions.get_children()
	for i in option_children:
		i.queue_free()
	upgrade_options.clear()
	collected_upgrades.append(upgrade)
	levelPanel.visible = false
	levelPanel.position = Vector2(1280,110)
	get_tree().paused = false
	calculate_experience(0)



func get_random_item():
	var dblist = []
	for i in UpgradeDb.UPGRADES:
		if i in collected_upgrades: #find already chosen upgrades
			pass
		elif i in upgrade_options: # if upgrade is already selected as option
			pass
		elif UpgradeDb.UPGRADES[i]["type"] == "item": #don't pick food
			pass
		elif has_weapon == false and UpgradeDb.UPGRADES[i]["type"] != "weapon":
			pass
		elif UpgradeDb.UPGRADES[i]["prerequisite"].size() > 0: #check prereq
			var to_add = true
			for n in UpgradeDb.UPGRADES[i]["prerequisite"]:
				if not n in collected_upgrades:
					to_add = false
			if to_add:
				dblist.append(i)
		else:
			dblist.append(i)
	if dblist.size() > 0:
		var randomitem = dblist.pick_random()
		upgrade_options.append(randomitem)
		return randomitem
	else:
		return null



GEM CODE:
extends Area2D

@export var experience = 1

var spr_green = preload("res://Assets/Sprites/Gems/gemstone15.png")
var spr_blue = preload("res://Assets/Sprites/Gems/gemstone21.png")
var spr_red = preload("res://Assets/Sprites/Gems/gemstone6.png")
@onready var spr_anim = $Sprite2D/GPUParticles2D

var target = null
var speed = -1
@onready var sprite = $Sprite2D
@onready var collision = $CollisionShape2D
@onready var sound = $AudioStreamPlayer

func _ready():
	if experience < 5:
		return
	elif experience < 26:
		sprite.texture = spr_blue
		spr_anim.texture = spr_blue
	else:
		sprite.texture = spr_red
		spr_anim.texture = spr_red

func _physics_process(delta):
	if target != null:
		global_position = global_position.move_toward(target.global_position, speed)
		speed += 2*delta
		

func collect():
	sound.play()
	collision.call_deferred("set","disabled",true)
	sprite.visible = false
	return experience




func _on_audio_stream_player_finished():
	queue_free()

Hey Jo, and maybe anyone who finds this in the future…

I narrowed the issue down to an issue with my experience gems (_on_collect_area_area_entered(area):slight_smile: being collected at the exact same time/frame which I guess called my (func calculate_experience(gem_exp):slight_smile: as many times as there were gems to collect on that frame.

I was able to figure out a way to store the exp gained in an array, then pop_front in my calculate to grab the experience. this essentially queues all of my levelling.

Here’s my updated code:

var exp_pool = []

func _process(delta):
	if exp_pool != []:
		var gem_exp = exp_pool.pop_front()
		calculate_experience(gem_exp)

#EXP gem collection
func _on_collect_area_area_entered(area):  
	if area.is_in_group("loot"):
		var gem_exp = area.collect()
		exp_pool.append(gem_exp)

 #EXP/LEVEL up calc
func calculate_experience(gem_exp):        
	var exp_required = calculate_experience_cap()
	collected_experience += gem_exp
	print ("collected_experience ", collected_experience)
	if experience + collected_experience >= exp_required: #level up
		print("experience ", experience, "exp_required ",  exp_required, "collected_experience ", collected_experience)
		collected_experience -= exp_required - experience
		experience_level += 1
		experience = 0
		exp_required = calculate_experience_cap()
		levelup()

2 Likes

It seems you have enabled multi-threading, so this is a problem with thread safety. Your solution is pretty straightforward, but it’s not my type. If I were you, I would spend hours making it thread-safe, and then I could remove that if which will run repeatedly in the process…

Yeah…

In C++ we’ll have a set of utilities like atom, lock, mutex etc.

I’m gonna put this on the back burner for now since it’s functional. You’ve given me a new topic of research though, so I’ll be looking into these utilities and tips for unthreading in the near future.

Thanks Jo!