DEV Community

Cover image for Let’s Learn Godot 4 by Making an RPG — Part 16: Level & XP🤠
christine
christine

Posted on • Updated on

Let’s Learn Godot 4 by Making an RPG — Part 16: Level & XP🤠

Now that we have a basic enemy and player, we need to give ourselves a motive to kill the enemies. We can do this via a leveling system that increases our player’s level after a certain amount of XP is gained. If the player levels up, they get rewarded with pickups, and a stats (health, stamina) refill. We will also increase their max health and max stamina values upon a level-up.


WHAT YOU WILL LEARN IN THIS PART:
· How to work with Popup nodes.
· How to pause the scene tree.
· How to allow/disallow input processing.
· How to change a node’s Processing Mode.
· How to (optionally) change a mouse cursors image and visibility.


Levelling Overview

Leveling Overview

LEVEL UP POPUP

Once you’re ready, open up your game project, and in your Player script by your signals, let’s define three new signals that will update our xp, xp requirements, and level values.

    ### Player.gd

    # Custom signals
    signal health_updated
    signal stamina_updated
    signal ammo_pickups_updated
    signal health_pickups_updated
    signal stamina_pickups_updated
    signal xp_updated
    signal level_updated
    signal xp_requirements_updated
Enter fullscreen mode Exit fullscreen mode

Next, we will need to create the variables that these signals will update when they are emitted. You can change the xp, level, and required xp values that the player will start with to be any value you want.

    ### Player.gd

    # XP and levelling
    var xp = 0 
    var level = 1 
    var xp_requirements = 100
Enter fullscreen mode Exit fullscreen mode

If you remembered how we updated our health and stamina GUI elements in the previous parts, you will know that we will have to create functions in our XP and Level elements in our Player scene, and then connect them to our signals in our Player scene.

In your Player Scene, underneath your UI CanvasLayer, attach a new script to your XP and Level ColorRect. Make sure to save this underneath your GUI folder.

Godot RPG

f

Our XP ColorRect should also have two values, one for our XP and one for our XP Requirements, so go ahead and duplicate your Value node. It’s transform values can be seen in the images below.

Godot RPG

Godot RPG

Godot RPG

We want to update the Value child nodes from these ColorRects (not the Label), so let’s go ahead and create a function for each new script to update our XP and Level values.

    ### XPAmount.gd

    extends ColorRect

    # Node refs
    @onready var value = $Value
    @onready var value2 = $Value2

    #return xp
    func update_xp_ui(xp):
        #return something like 0
        value.text = str(xp)

    #return xp_requirements
    func update_xp_requirements_ui(xp_requirements):
        #return something like / 100

    ### LevelAmount.gd

    extends ColorRect

    # Node refs
    @onready var value = $Value

    # Return level
    func update_level_ui(level):
        #return something like 0
        value.text = str(level)
Enter fullscreen mode Exit fullscreen mode

Now we just have to connect these UI element functions to each of our newly created signals in our Player script.

    ### Player.gd

    extends CharacterBody2D

    # Node references
    @onready var animation_sprite = $AnimatedSprite2D
    @onready var health_bar = $UI/HealthBar
    @onready var stamina_bar = $UI/StaminaBar
    @onready var ammo_amount = $UI/AmmoAmount
    @onready var stamina_amount = $UI/StaminaAmount
    @onready var health_amount = $UI/HealthAmount
    @onready var xp_amount = $UI/XP
    @onready var level_amount = $UI/Level
    @onready var animation_player = $AnimationPlayer

    func _ready():
        # Connect the signals to the UI components' functions
        health_updated.connect(health_bar.update_health_ui)
        stamina_updated.connect(stamina_bar.update_stamina_ui)
        ammo_pickups_updated.connect(ammo_amount.update_ammo_pickup_ui)
        health_pickups_updated.connect(health_amount.update_health_pickup_ui)
        stamina_pickups_updated.connect(stamina_amount.update_stamina_pickup_ui)
        xp_updated.connect(xp_amount.update_xp_ui)
        xp_requirements_updated.connect(xp_amount.update_xp_requirements_ui)
        level_updated.connect(level_amount.update_level_ui)
Enter fullscreen mode Exit fullscreen mode

We want to update our xp amount when we’ve killed an enemy, so to do that we need to create a new function in our Player script that will update our xp value and that emits our xp_updated signal.


    ### Player.gd

    # older code

    # ----------------- Level & XP ------------------------------
    #updates player xp
    func update_xp(value):
        xp += value

        #emit signals
        xp_requirements_updated.emit(xp_requirements)   
        xp_updated.emit(xp)
        level_updated.emit(level)
Enter fullscreen mode Exit fullscreen mode

This function can then be called anywhere we want to update our xp, such as in our add_pickups() function or our Enemy’s death conditional in their damage() function.

    ### Player.gd

    # older code

    # ---------------------- Consumables ------------------------------------------
    # Add the pickup to our GUI-based inventory
    func add_pickup(item):
        if item == Global.Pickups.AMMO: 
            ammo_pickup = ammo_pickup + 3 # + 3 bullets
            ammo_pickups_updated.emit(ammo_pickup)
            print("ammo val:" + str(ammo_pickup))
        if item == Global.Pickups.HEALTH:
            health_pickup = health_pickup + 1 # + 1 health drink
            health_pickups_updated.emit(health_pickup)
            print("health val:" + str(health_pickup))
        if item == Global.Pickups.STAMINA:
            stamina_pickup = stamina_pickup + 1 # + 1 stamina drink
            stamina_pickups_updated.emit(stamina_pickup)
            print("stamina val:" + str(stamina_pickup))
        update_xp(5)
Enter fullscreen mode Exit fullscreen mode
    ### Enemy.gd

    # older code

    #will damage the enemy when they get hit
    func hit(damage):
        health -= damage
        if health > 0:
            #damage
            animation_player.play("damage")
        else:
            #death
            #stop movement
            timer_node.stop()
            direction = Vector2.ZERO
            #stop health regeneration
            set_process(false)
            #trigger animation finished signal
            is_attacking = true     
            #Finally, we play the death animation and emit the signal for the spawner.
            animation_sprite.play("death")
            #add xp values
            player.update_xp(70)
            death.emit()
            #drop loot randomly at a 90% chance
            if rng.randf() < 0.9:
                var pickup = Global.pickups_scene.instantiate()
                pickup.item = rng.randi() % 3 #we have three pickups in our enum
                get_tree().root.get_node("Main/PickupSpawner/SpawnedPickups").call_deferred("add_child", pickup)
                pickup.position = position
Enter fullscreen mode Exit fullscreen mode

We’ll build on this function more throughout this part, as we still need to update our *xp_requirements *and run the check to see if our player has gained enough xp to level up. If they’ve gained enough xp, we need to reset our current xp amount back to zero and increase our player’s level and required xp values. Then we need to emit our signals to notify our game of the changes in these values.

    ### Player.gd

    # older code

    # ----------------- Level & XP ------------------------------
    #updates player xp
    func update_xp(value):
        xp += value
        #check if player leveled up after reaching xp requirements
        if xp >= xp_requirements:
            #reset xp to 0
            xp = 0
            #increase the level and xp requirements
            level += 1
            xp_requirements *= 2
        #emit signals
        xp_requirements_updated.emit(xp_requirements)   
        xp_updated.emit(xp)
        level_updated.emit(level)
Enter fullscreen mode Exit fullscreen mode

If you run your scene now and you kill some enemies (make sure you change their damage value to zero so that they can’t kill you during this test), you will see that your xp and level values update.

Godot RPG

If our player levels up, we want a screen to show with a notification that our player has leveled up, plus a summary of the rewards they’ve gained from doing so. We will accomplish this by adding a CanvasLayer node to our Player’s UI node. In your Player scene, add a new CanvasLayer node to your UI layer. Rename it to LevelUpPopup.

Godot RPG

In this CanvasLayer node, add two ColorRects and a Button node. The first ColorRect will contain our level-up label, and the second ColorRect will contain the summary of our rewards. The button will allow the player to confirm the notification and continue with their game. You can rename them as follows:

Godot RPG

Godot RPG

Change your Message node’s color to #d6c376, and its size (x: 142, y: 142); position (x: 4, y: 4); anchor_preset (center).

Godot RPG

Drag your Rewards node into your Message node to turn it into a child of that node. Change its color to #365655, and its size (x: 100, y: 75); position (x: 20, y: 30); anchor_preset (center).

Godot RPG

Also, drag your Confirm node into your Message node to turn it into a child of that node. Change its color to #365655, and its size (x: 100, y: 75); position (x: 20, y: 30); anchor_preset (center). Change its font to “Schrodinger” and its text to “CONTINUE”.

Godot RPG

Godot RPG

Now, in your Message node, at the top, let’s add a new Label node to display our welcome text “Level Up!”. Change its font to “Arcade Classic” and its font size to 15. Then change its anchor_preset (center-top); horizontal alignment (center); vertical alignment (center); and position (y: 5).

Godot RPG

In your Rewards node, add six new Label nodes.

Godot RPG

Rename them as follows:

Godot RPG


Change their properties as follows:

All of them:

  • Text = “1”

  • Font = “Schrödinger”

  • Font-size = 10

  • Anchor Preset = center-top

  • Horizontal Alignment = center

  • Vertical Alignment = center

LevelGained:

  • Position= y: 0

HealthIncreaseGained:

  • Position= y: 10

StaminaIncreaseGained:

  • Position= y: 20

HealthPickupsGained:

  • Position= y: 30

StaminaPickupsGained:

  • Position= y: 40

AmmoPickupsGained:

  • Position= y: 50

Godot RPG

With our Popup created, we can go ahead and hide our popup for now. You can do this in the Inspector panel underneath Canvas Item > Visibility, or just click the eye icon next to the node to hide it.

Godot RPG

We need to go back to our update_xp() function in our Player scene to update our conditional that checks if the player levels up. If they are leveling up, we need to pause the game, display the popup with all of the reward values and only hide the popup if the player clicks the confirm button. In this function we will have to up the player’s max health and stamina, as well as give them some ammo, and health and stamina drinks. After we’ve done this, we’ll need to reflect these changes in our UI elements.

If we want to pause the game, we simply use the SceneTree.paused method. If the game is paused, no input from the player or us will be accepted, because everything is paused. That is unless we change our node’s process mode. Each node in Godot has a “Process Mode” that defines when it processes. It can be found and changed under a node’s Node properties in the inspector.

Godot RPG

This is what each mode tells a node to do:

  • Inherit: The node will work or be processed depending on the state of the parent. If the parent’s process mode is pausable, it will be pausable, etc.

  • Pausable: The node will work or be processed only when the game is not paused.

  • When Paused: The node will work or be processed only when the game is paused.

  • Always: The node will work or be processed if the game is both paused or not.

  • Disabled: The node will not work, nor will it be processed.

We need our LevelUpPopup node to only work when the game is paused. This will allow us to click the Confirm button to unpause the game, thus allowing the other nodes to continue processing since they all only work or process input if the game is in an unpaused state. Let’s change our LevelUpPopup’s Process Mode to WhenPaused. You can find this option under Node > Process > Mode.

Godot RPG

Because we changed the LevelUpPopup node’s process mode, all of its children will also inherit that processing mode, so all of them will work when the game is paused. Before we pause our game in our code, we’ll also need to first allow input to be processed via the set_process_input method. This method enables or disables input processing. Then we will increase our health, stamina, xp, level and pickup values and reflect these changes on our UI! Let’s make these changes in our code.

    ### Player.gd

    extends CharacterBody2D

    # Node references
    @onready var animation_sprite = $AnimatedSprite2D
    @onready var health_bar = $UI/HealthBar
    @onready var stamina_bar = $UI/StaminaBar
    @onready var ammo_amount = $UI/AmmoAmount
    @onready var stamina_amount = $UI/StaminaAmount
    @onready var health_amount = $UI/HealthAmount
    @onready var xp_amount = $UI/XP
    @onready var level_amount = $UI/Level
    @onready var animation_player = $AnimationPlayer
    @onready var level_popup = $UI/LevelPopup

    # ----------------- Level & XP ------------------------------
    #updates player xp
    func update_xp(value):
        xp += value
        #check if player leveled up after reaching xp requirements
        if xp >= xp_requirements:
            #allows input
            set_process_input(true)
            #pause the game
            get_tree().paused = true
            #make popup visible
            level_popup.visible = true
            #reset xp to 0
            xp = 0
            #increase the level and xp requirements
            level += 1
            xp_requirements *= 2

            #update their max health and stamina
            max_health += 10 
            max_stamina += 10 

            #give the player some ammo and pickups
            ammo_pickup += 10 
            health_pickup += 5
            stamina_pickup += 3

            #update signals for Label values
            health_updated.emit(health, max_health)
            stamina_updated.emit(stamina, max_stamina)
            ammo_pickups_updated.emit(ammo_pickup)
            health_pickups_updated.emit(health_pickup)
            stamina_pickups_updated.emit(stamina_pickup)
            xp_updated.emit(xp)
            level_updated.emit(level)

            #reflect changes in Label
            $UI/LevelPopup/Message/Rewards/LevelGained.text = "LVL: " + str(level)
            $UI/LevelPopup/Message/Rewards/HealthIncreaseGained.text = "+ MAX HP: " + str(max_health)
            $UI/LevelPopup/Message/Rewards/StaminaIncreaseGained.text = "+ MAX SP: " + str(max_stamina)
            $UI/LevelPopup/Message/Rewards/HealthPickupsGained.text = "+ HEALTH: 5" 
            $UI/LevelPopup/Message/Rewards/StaminaPickupsGained.text = "+ STAMINA: 3" 
            $UI/LevelPopup/Message/Rewards/AmmoPickupsGained.text = "+ AMMO: 10" 

        #emit signals
        xp_requirements_updated.emit(xp_requirements)   
        xp_updated.emit(xp)
        level_updated.emit(level)
Enter fullscreen mode Exit fullscreen mode

Finally, we need to give our Confirm button the ability to close the popup and unpause our game. We can do this by connecting its pressed() signal to our Player script.

Godot RPG

In this newly created _on_confirm_pressed(): function, we will simply hide the popup again and unpause the game.

    ### Player.gd

    # older code

    # close popup
    func _on_confirm_pressed():
        level_popup.visible = false
        get_tree().paused = false
Enter fullscreen mode Exit fullscreen mode

Now if we run our scene and we shoot enough enemies, we will see the popup appear with our rewards values, and if we click on the confirm button, our popup closes, and we can continue the game with our new values!

Godot RPG

Godot RPG

SHOWING & HIDING CURSOR

I don’t like the way our cursor always shows. Whether or not the game is paused, our cursor can always be found lingering on our screen. Since this is not a point-and-click game, there is no reason for our cursor to show when our game is not paused, since we’ll spend our time running around and shooting enemies. Our cursor should hence only show if we are in a menu screen such as a pause or main menu, or even during our dialog screen — i.e. when the game is paused and we need the cursor for input.

Luckily, this is a quick fix. We can change the visibility of our mouse’s cursor via our Input singletons MouseMode method. In our Player’s script we will show the cursor whenever game is paused, and if it is not paused then we will hide the cursor.


    ### Player.gd

    # ----------------- Level & XP ------------------------------
    #updates player xp
    func update_xp(value):
        xp += value
        #check if player leveled up after reaching xp requirements
        if xp >= xp_requirements:
            #allows input
            set_process_input(true)
            Input.set_mouse_mode(Input.MOUSE_MODE_VISIBLE)
            #pause the game

        #emit signals
        xp_requirements_updated.emit(xp_requirements)   
        xp_updated.emit(xp)
        level_updated.emit(level)

    # close popup
    func _on_confirm_pressed():
        level_popup.visible = false
        get_tree().paused = false
        Input.set_mouse_mode(Input.MOUSE_MODE_HIDDEN)
Enter fullscreen mode Exit fullscreen mode

We also need to hide our cursor on load.

    ### Player.gd

    func _ready():
        # Connect the signals to the UI components' functions
        health_updated.connect(health_bar.update_health_ui)
        stamina_updated.connect(stamina_bar.update_stamina_ui)
        ammo_pickups_updated.connect(ammo_amount.update_ammo_pickup_ui)
        health_pickups_updated.connect(health_amount.update_health_pickup_ui)
        stamina_pickups_updated.connect(stamina_amount.update_stamina_pickup_ui)
        xp_updated.connect(xp_amount.update_xp_ui)
        xp_requirements_updated.connect(xp_amount.update_xp_requirements_ui)
        level_updated.connect(level_amount.update_level_ui)

        # Reset color
        animation_sprite.modulate = Color(1,1,1,1)
        Input.set_mouse_mode(Input.MOUSE_MODE_HIDDEN)
Enter fullscreen mode Exit fullscreen mode

We can also change our cursor’s image. If you go into your Project Settings > Display > Mouse Cursor, you can change your mouse cursor’s image.

Godot RPG

You can find free mouse cursor packs here. I used the Free Basic Cursor Pack from VOiD1 Gaming.

Godot RPG

Now if you run your game, your cursor should be hidden/shown when your pause state changes.

SHOWING VALUES ON LOAD

If we change the values of our variables and we run our game, you might’ve noticed that your Level, XP, and Pickup values aren’t updating. We need to fix this by going into our UI scripts and calling our values from our Player script on load.

    ### HealthAmount.gd
    extends ColorRect

    # Node ref
    @onready var value = $Value
    @onready var player = $"../.."

    # Show correct value on load
    func _ready():
        value.text = str(player.health_pickup)

    # Update ui
    func update_health_pickup_ui(health_pickup):
        value.text = str(health_pickup)
Enter fullscreen mode Exit fullscreen mode
    ### StaminaAmount.gd
    extends ColorRect

    # Node ref
    @onready var value = $Value
    @onready var player = $"../.."

    # Show correct value on load
    func _ready():
        value.text = str(player.stamina_pickup)

    # Update ui
    func update_stamina_pickup_ui(stamina_pickup):
        value.text = str(stamina_pickup)
Enter fullscreen mode Exit fullscreen mode
    ### LevelAmount.gd

    extends ColorRect

    # Node refs
    @onready var value = $Value
    @onready var player = $"../.."

    # On load
    func _ready():
        value.text = str(player.level)

    # Return level
    func update_level_ui(level):
        #return something like 0
        value.text = str(level)
Enter fullscreen mode Exit fullscreen mode
    ### XPAmount.gd

    extends ColorRect

    # Node refs
    @onready var value = $Value
    @onready var value2 = $Value2
    @onready var player = $"../.."

    # On load
    func _ready():
        value.text = str(player.xp)
        value2.text = "/" + str(player.xp_requirements)

    #return xp
    func update_xp_ui(xp):
        #return something like 0
        value.text = str(xp)

    #return xp_requirements
    func update_xp_requirements_ui(xp_requirements):
        #return something like / 100
        value2.text = "/" + str(xp_requirements)
Enter fullscreen mode Exit fullscreen mode
    ### AmmoAmount.gd
    extends ColorRect

    # Node ref
    @onready var value = $Value
    @onready var player = $"../.."

    # Show correct value on load
    func _ready():
        value.text = str(player.ammo_pickup)

    # Update ui
    func update_ammo_pickup_ui(ammo_pickup):
        value.text = str(ammo_pickup)
Enter fullscreen mode Exit fullscreen mode

Now if you run your scene, your values should show correctly, which will be useful when we load our game later on!

Godot RPG

Congratulations, your player can now get rewarded for killing bad guys. We’re still not 100% done with this, for in the next part we will add a basic NPC and quest that will also reward our player with XP upon completing this quest. Remember to save your project, and I’ll see you in the next part.

The final source code for this part should look like this.

Buy Me a Coffee at ko-fi.com

FULL TUTORIAL

Godot RPG

The tutorial series has 23 chapters. I’ll be releasing all of the chapters in sectional daily parts over the next couple of weeks.

If you like this series or want to skip the wait and access the offline, full version of the tutorial series, you can support me by buying the offline booklet for just $4 on Ko-fi!😊

You can find the updated list of the tutorial links for all 23 parts in this series here.

Top comments (4)

Collapse
 
facepalm profile image
Chris • Edited

I noticed that valid_spawn_location(position) in EnemySpawner doesn't use the position passed in. Calculating tilemap.get_layer_name(1) || tilemap.get_layer_name(2) always returns true... I tried solving this problem, no progress so far. Any advice?

Collapse
 
christinec_dev profile image
christine • Edited

We don't use the position value in the parameters in the function itself, but we do call this value when we check the enemies' location for a valid position {valid_location = valid_spawn_location(enemy.position)}.

Our function checks if the layers for sand and grass exist in the tilemap, which will always be true if those layers are defined in your tilemap. However, now that I'm looking at my original logic, I see that I could improve on this - since we want to only return true if the position is on a sand or grass tile, and false otherwise.

Replace the existing function with this:

func valid_spawn_location(position : Vector2):
    if tilemap != null:
        var tile_coords = tilemap.local_to_map(position)
        var cell_coords = Vector2(tile_coords.x, tile_coords.y)
        var tile_type_at_position_layer1 = tilemap.get_cell_source_id(1, cell_coords, 1)
        var tile_type_at_position_layer2 = tilemap.get_cell_source_id(2, cell_coords, 2)

        # Check if the tile type at the position is 1 (sand) or 2 (grass)
        return tile_type_at_position_layer1 || tile_type_at_position_layer2

    return false
Enter fullscreen mode Exit fullscreen mode

Let me know if this works?

Collapse
 
facepalm profile image
Chris

Yes this now works! I saw these functions in the documentation (local_to_map, get_cell_source_id) but didn't know how to put it together.

Thank you!!

Collapse
 
christinec_dev profile image
christine

*Please note that popup nodes close by default if you press the ESC key or if they click outside of the popup box. If you don’t like this, you can simply change the Instruction node’s type to be a CanvasLayer, and then instead of .show() use $node.visible = true, and .hide() should change to $node.visible = false.