If you’ve successfully made it to this part of our tutorial series, then you should have a basic map, movable character, and game GUI set up. In this section, we are going to focus on adding Pickups to our game. These pickups will consist of health and stamina consumables, as well as ammo. We will also be updating our GUI values when our player picks these items up.
WHAT YOU WILL LEARN IN THIS PART:
· How to work with Enums.
· How to work with the @tool object.
· How to execute code in the editor.
· How to delete/remove nodes from a scene via the memory queue.
· How to work with Area2D detection bodies.
· How to reference TileMap properties.
PICKUPS SETUP
Instead of creating separate pickup items for each of our consumables, i.e., a scene for AmmoPickup, StaminaPickup, and HealthPickup — we are going to create a singular base scene for our Pickups that we can reuse throughout our game. We will do this via enums and our export resource.
In your project, create a new scene with an Area2D node as its root. An Area2D node serves as an area that will detect collisions, so in other words, we will use this area to detect if our Player scene has entered its body via collisions. If the player has run over the Pickup scene’s collision body, the pickup should be removed and we should update our UI values.
Rename this Area2D node to “Pickup” and save the scene as the same underneath the Scenes folder.
You will notice that the node has a warning next to it because just like our CharacterBody2D node it requires a shape or collision. Let’s go ahead and add a CollisionShape2D node to it to resolve this warning.
Before we select the shape for the collision, let’s give it a Sprite so that we can see what image it is before we give it a collision’s bounds. Add a Sprite2D node and assign any icon to it from your Assets/Icons folder.
Now, let’s assign a shape to our CollisionShape2D node. Make it a new RectangleShape2D, and then drag the bounds of the collision shape to frame your icon.
This Pickup scene will serve as the base scene for all of our pickups. From here we want to be able to dynamically choose the pickup type and add it to our player’s inventory whenever they run over it to pick it up. To do this, we need to attach a script to the root node of our Pickup scene. Save it underneath your Scripts folder.
At the top of our script, let’s create an enum of all of our pickup items (which are our ammo, health, and stamina drinks). We will export this enum so that we can dynamically choose the type of pickup we want to place down from the Inspector panel/editor. Enums are a shorthand for constants and are pretty useful if you want to assign consecutive integers to some constant. To create enums, we use the keyword enum, followed by the constants in brackets.
### Pickup.gd
extends Area2D
# Pickups to choose from
enum Pickups { AMMO, STAMINA, HEALTH }
@export var item : Pickups
Now if we look in our Inspector panel, we can choose the Pickup item from the dropdown.
Next, we want to also choose its icon or texture, because not all of our pickup items can look like diamonds! To do this we will make use of something new so that we can see real-time changes to these textures in our editor. We will use @tool, which is a powerful line of code that, when added at the top of your script, makes it execute in the editor. You can also decide which parts of the script execute in the editor, which in-game, and which in both.
You need to add @tool to the top of your script before your node class extends.
### Pickup.gd
@tool
extends Area2D
# Pickups to choose from
enum Pickups { AMMO, STAMINA, HEALTH }
@export var item : Pickups
Then we need to assign our textures, so for each of our Pickups, we will preload our textures. Preloading is when resources are loaded before a scene is playing/running. We preload resources to speed up loading and reduce freezing since resources are already loaded. Let’s assign the textures that we used for our GUI icons to each of our pickup items.
### Pickup.gd
@tool
extends Area2D
# Pickups to choose from
enum Pickups { AMMO, STAMINA, HEALTH }
@export var item : Pickups
# Texture assets/resources
var ammo_texture = preload("res://Assets/Icons/shard_01i.png")
var stamina_texture = preload("res://Assets/Icons/potion_02b.png")
var health_texture = preload("res://Assets/Icons/potion_02c.png")
Since we want to constantly check whether or not these sprite frames are changing when we assign new Pickups, we want to add the conditional check to change these textures in our process() function. We used this function before when we implemented our custom signals.
We want to execute our conditional in the editor to see our texture changes in the editor without having to run the game. To do this we will use our @tool functionality, which requires the Engine.is_editor_hint() function to run the code in the editor. We’ll also create a node reference to our Node2D node.
### Pickup.gd
# Node refs
@onready var sprite = $Sprite2D
#older code
# ----------------------- Icon --------------------------------------
#allow us to change the icon in the editor
func _process(_delta):
#executes the code in the editor without running the game
if Engine.is_editor_hint():
#if we choose x item from Inspector dropdown, change the texture
if item == Pickups.AMMO:
sprite.set_texture(ammo_texture)
elif item == Pickups.HEALTH:
sprite.set_texture(health_texture)
elif item == Pickups.STAMINA:
sprite.set_texture(stamina_texture)
Now if you instance your Pickup scene in your main scene, and you change your Pickup item in your Inspector panel, your texture should change!
We have to also reflect these settings in the game, so for that, we will set our textures in the ready() function.
### Pickup.gd
# Node refs
@onready var sprite = $Sprite2D
#older code
# ----------------------- Icon --------------------------------------
func _ready():
#executes the code in the game
if not Engine.is_editor_hint():
#if we choose x item from Inspector dropdown, change the texture
if item == Pickups.AMMO:
sprite.set_texture(ammo_texture)
elif item == Pickups.HEALTH:
sprite.set_texture(health_texture)
elif item == Pickups.STAMINA:
sprite.set_texture(stamina_texture)
If you instance multiple Pickup scenes in your Main scene and change their item value, and you run your scene, you will now see the textures changed in-game as well.
USING PICKUPS
If our player runs through these pickups, we want to remove them from our scene and add them to our player’s inventory. We will have to make use of some custom signals and functions again to make this work in our Player script, but first, let’s focus on removing the items from the scene when our player collides with it.
The Area2D node comes with a built-in signal called body_entered(), which emits when a defined body enters this area. This defined body is any body that has a collision shape added to it, such as our CharacterBody2D from our Player scene.
Connect this signal to your Pickup script. You will see that it creates a new ‘func _on_body_entered(body):’ function at the end of your script.
In this new signal function, we want to check if the body that entered our area is called “Player”, and if so, our Pickup scene should delete itself from our Main scene tree. When a node is part of a scene tree, the tree can be obtained by calling the .get_tree() method. To remove a node from a scene tree, we can use either queue_free() or queue_delete().
### Pickup.gd
#older code
# -------------------- Using Pickups -------------------
func _on_body_entered(body):
if body.name == "Player":
#todo: adding function will come here
#delete from scene tree
get_tree().queue_delete(self)
If you run your scene now and you have your player run through your pickup items, it should be removed from the scene completely.
You’ll see in my code sample that I added a line for “#todo: adding function will come here”. We will first need to create the function that will add these items to our player’s inventory from the Player script, and then reference it in our Pickup script. Our player’s inventory won’t be a traditional one that we can add many items and objects to, but more of an invisible one that will only be shown through our pre-existing GUI. In other words, our inventory will only be able to contain Pickup items.
In your Player script, let’s define some more signals to update our pickup item’s values. These signals will emit whenever we remove or add a pickup item.
### Player.gd
# older code
# Custom signals
signal health_updated
signal stamina_updated
signal ammo_pickups_updated
signal health_pickups_updated
signal stamina_pickups_updated
We also need to define our Pickups enum again so that we can use the same constants.
### Player.gd
# older code
# Custom signals
signal health_updated
signal stamina_updated
signal ammo_pickups_updated
signal health_pickups_updated
signal stamina_pickups_updated
# Pickups
enum Pickups { AMMO, STAMINA, HEALTH }
Next, we need to define a new variable for each pickup item so that we can store their amount.
### Player.gd
# older code
# Pickups
enum Pickups { AMMO, STAMINA, HEALTH }
var ammo_pickup = 0
var health_pickup = 0
var stamina_pickup = 0
Finally, we can go ahead and create our custom function that will add the pickups to our player’s inventory (the GUI in the top-left of the screen). We will call this function in our Pickups script, so we can pass the *item *variable into our parameter as that is what we will choose from in our Inspector panel.
We will do a conditional to check which item has been picked up, and then add x amount of that item to our player. We also need to emit our signals to notify the game that the value of our variables has changed.
### Player.gd
# older code
# ---------------------- Consumables ------------------------------------------
# Add the pickup to our GUI-based inventory
func add_pickup(item):
if item == Pickups.AMMO:
ammo_pickup = ammo_pickup + 3 # + 3 bullets
ammo_pickups_updated.emit(ammo_pickup)
print("ammo val:" + str(ammo_pickup))
if item == Pickups.HEALTH:
health_pickup = health_pickup + 1 # + 1 health drink
health_pickups_updated.emit(health_pickup)
print("health val:" + str(health_pickup))
if item == Pickups.STAMINA:
stamina_pickup = stamina_pickup + 1 # + 1 stamina drink
stamina_pickups_updated.emit(stamina_pickup)
print("stamina val:" + str(stamina_pickup))
Now we can go back to our Pickup script and reference this function via the body method to add that item.
### Pickup.gd
# older code
# -------------------- Using Pickups -------------------
func _on_body_entered(body):
if body.name == "Player":
body.add_pickup(item)
#delete from scene tree
get_tree().queue_delete(self)
We can now run our scene to see if this works when we have our player run through our Pickup items.
Our pickup items now get added to our player’s inventory, but we do not see a change in our GUI yet. We will add that now so that our changes can be reflected in the game and not in the console. In your Player scene, attach a script to each of your UI nodes called AmmoAmount, HealthAmount, and StaminaAmount. Save them underneath your GUI folder.
Just like with the scripts for our HealthBar and StaminaBar elements, we will create a function that will update its value via the connected signal in the Player script.
### AmmoAmount.gd
extends ColorRect
# Node ref
@onready var value = $Value
# Update ui
func update_ammo_pickup_ui(ammo_pickup):
value.text = str(ammo_pickup)
### HealthAmount.gd
extends ColorRect
# Node ref
@onready var value = $Value
# Update ui
func update_health_pickup_ui(health_pickup):
value.text = str(health_pickup)
### StaminaAmount.gd
extends ColorRect
# Node ref
@onready var value = $Value
# Update ui
func update_stamina_pickup_ui(stamina_pickup):
value.text = str(stamina_pickup)
### 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
# older code
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)
Now if you run your scene and you have your player character run through your pickups, your GUI should update. Whilst we’re adding the functionality to add pickups to our player, let’s also go ahead and add some inputs so that our player can consume our health and stamina pickups. We’ll update our code later on, to also use ammo pickups, or bullets, to attack.
Create two new input actions called “ui_consume_health” and “ui_consume_stamina” and assign input keys to them. I used the “1” and “2” keys on my keyboard but assign whatever keys you feel comfortable with.
In our Player script, in our input(event) function, let’s create new conditionals for these input actions. If we have pickups, and our health or stamina value on our progress bar is low, we want to deduct 1 from our pickups amount when our player presses “1” or “2” to consume either a health or stamina drink.
Drinking these consumables will restore 50 points of the total 100 points assigned (remember our health and stamina variables are equal to 100). After our health or stamina has been added to and a pickup has been removed, we need to then emit our signals accordingly.
### Player.gd
extends CharacterBody2D
# older code
func _input(event):
#input event for our attacking, i.e. our shooting
if event.is_action_pressed("ui_attack"):
#attacking/shooting anim
is_attacking = true
var animation = "attack_" + returned_direction(new_direction)
animation_sprite.play(animation)
#using health consumables
elif event.is_action_pressed("ui_consume_health"):
if health > 0 && health_pickup > 0:
health_pickup = health_pickup - 1
health = min(health + 50, max_health)
health_updated.emit(health, max_health)
health_pickups_updated.emit(health_pickup)
#using stamina consumables
elif event.is_action_pressed("ui_consume_stamina"):
if stamina > 0 && stamina_pickup > 0:
stamina_pickup = stamina_pickup - 1
stamina = min(stamina + 50, max_stamina)
stamina_updated.emit(stamina, max_stamina)
stamina_pickups_updated.emit(stamina_pickup)
To also have our UI nodes show the correct values when we spawn (for example, say we want to give our player 5 ammo on spawn instead of 0), we will have to update our value nodes on load in our UI scripts.
### 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)
### 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)
### 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)
### Pickup.gd
# Pickups to choose from
@export var item : Global.Pickups
# ----------------------- Icon --------------------------------------
func _ready():
#executes the code in the game
if not Engine.is_editor_hint():
#if we choose x item from Inspector dropdown, change the texture
if item == Global.Pickups.AMMO:
sprite.set_texture(ammo_texture)
elif item == Global.Pickups.HEALTH:
sprite.set_texture(health_texture)
elif item == Global.Pickups.STAMINA:
sprite.set_texture(stamina_texture)
#allow us to change the icon in the editor
func _process(_delta):
#executes the code in the editor without running the game
if Engine.is_editor_hint():
#if we choose x item from Inspector dropdown, change the texture
if item == Global.Pickups.AMMO:
sprite.set_texture(ammo_texture)
elif item == Global.Pickups.HEALTH:
sprite.set_texture(health_texture)
elif item == Global.Pickups.STAMINA:
sprite.set_texture(stamina_texture)
Now if you change your variables to have different values (such as ammo_pickup = 2) and you run your scene, the correct values should show. If you pick up your stamina pickup, sprint, and then press “2” to consume your stamina pickup, your GUI value for your stamina should update as well as your stamina progress bar!
SPAWNING PICKUPS
Instead of manually placing down our pickups, let’s create a randomizer which will randomly generate x amount of pickups on our map when we run the game. For this we will create a new Global Autoload Singleton script. This script will contain all the variables, scene resources, and methods that will be used throughout our game in multiple scenes.
What is a Singleton Script?
A singleton script is a script that is automatically loaded when the game starts and remains accessible throughout the entire lifetime of the game. This makes it ideal for managing game-wide data, functions, and systems that need to be accessed from multiple scenes or scripts.
Underneath your Scripts folder, create a new script called “Global.gd”.
Then, in your Project Settings > Autoload menu, assign the Global script as a new singleton. This will make the variables and functions stored in this script accessible in every script.
Now, in your Main scene, delete any Pickups scene that you instanced. Then add a Node2D node renamed to “SpawnedPickups”. This node will act as an organizer for our pickups, i.e., it will hold all of our spawned pickup's instances.
Now, in your newly created Global script, let’s preload the Pickups scene so that it can be instantiated later. By preloading scenes in our Global script, we can instantiate these scenes in our other scenes without loading them in each script. Preloading scenes reduces resource load times and potential lagging.
When to use load vs preload?
Both load and preload can be used to load resources in GDScript. Use preload when you know that a resource will be used on load, and you want it to be loaded during the script’s compilation time. Use load when you want to only load the resource when a certain condition is met, such as when a function is called.
### Global.gd
extends Node
# Scene resources
@onready var pickups_scene = preload("res://Scenes/Pickup.tscn")
Next, attach a new script to your Main scene. Save this script underneath your Scripts folder.
Now, in our Main script, let’s create a node reference to our TileMap node (Map) as well as the layers on our Map node. Remember, the ID of the layers start from zero, hence water = 0, grass = 1, sand = 2, foliage = n. Only add references to the layers that you have. For example, I have exterior_1 and exterior_2, but you might not.
### Main.gd
extends Node2D
# Node refs
@onready var map = $Map
# TileMap layers
const WATER_LAYER = 0
const GRASS_LAYER = 1
const SAND_LAYER = 2
const FOLIAGE_LAYER = 3
const EXTERIOR_1_LAYER = 4
const EXTERIOR_2_LAYER = 5
Now, let’s create a function that checks if a given position on the TileMap is a valid spawn location. This function will check the cell type in the specified layer (either the first or second layer) to determine if it’s a valid location for spawning a pickup. We will check the cell type on the specified layer via the get_cell_source_id() method, which retrieves information about a tile at a specific cell position in a particular layer of a TileMap.
What’s the difference between get_cell_source_id and get_cell_tile_data?
Both get_cell_source_id and get_cell_tile_data are methods used to query information about a cell in a tilemap, but they return different types of information. The method *get_cell_source_id *returns only the ID of a specific cell, and thus we use this method for simple checks, like whether a cell is empty or contains a specific type of tile. The *get_cell_tile_data *method returns more detailed information about the tile, not just its ID, and thus we use this method to get custom data, transform properties, or state values of the cell or tile.
If the cell is on layer SAND or GRASS, then we will return it as a valid spawn location. If the cell is on any other layer, then it will not be a valid spawn location. This will prevent the pickups from spawning on the buildings and water.
### Main.gd
# older code
# Valid pickup spawn location
func is_valid_spawn_location(layer, position):
var cell_coords = Vector2(position.x, position.y)
# Check if there's a tile on the water, foliage, or exterior layers
if map.get_cell_source_id(WATER_LAYER, cell_coords) != -1 || map.get_cell_source_id(FOLIAGE_LAYER, cell_coords) != -1 || map.get_cell_source_id(EXTERIOR_1_LAYER, cell_coords) != -1 || map.get_cell_source_id(EXTERIOR_2_LAYER, cell_coords) != -1:
return false
# Check if there's a tile on the grass or sand layers
if map.get_cell_source_id(GRASS_LAYER, cell_coords) != -1 || map.get_cell_source_id(SAND_LAYER, cell_coords) != -1:
return true
return false
Now, to spawn the pickups we’ll need to create a new function. This function will randomly choose a position on the TileMap and check if it’s a valid spawn location. If it is, it will instantiate a pickup at that location. We’ll instance the scene that we loaded in our Global script. In Godot 4, we instance a scene reference via the *instantiate *method.
### Main.gd
# older code
# Spawn pickup
func spawn_pickups(amount):
var spawned = 0
while spawned < amount:
# Randomly choose a location on the first or second layer
var random_position = Vector2(randi() % map.get_used_rect().size.x, randi() % map.get_used_rect().size.y)
var layer = randi() % 2
# Spawn it underneath SpawnedPickups node
if is_valid_spawn_location(layer, random_position):
var pickup_instance = Global.pickups_scene.instantiate()
pickup_instance.position = map.map_to_local(random_position)
spawned_pickups.add_child(pickup_instance)
spawned += 1
What is map_to_local?
The TileMap node’s map_to_local(Vector2i map_position) method converts a given map position (such as our player’s coordinates) to the TileMap’s local coordinate space. This is useful when you want to find the pixel position of a specific cell within the TileMap in its local coordinate system.
Finally, in our ready function, we can call our function to spawn a random number of pickups on our map on load. We’ll need to use the RandomNumberGenerator to randomize the amount of pickups so that it is different each time the function is called.
### Main.gd
extends Node2D
# Node refs
@onready var map = $Map
@onready var spawned_pickups = $SpawnedPickups
# TileMap layers
const WATER_LAYER = 0
const GRASS_LAYER = 1
const SAND_LAYER = 2
const FOLIAGE_LAYER = 3
const EXTERIOR_1_LAYER = 4
const EXTERIOR_2_LAYER = 5
var rng = RandomNumberGenerator.new()
func _ready():
# Spawn between 5 and 10 pickups
var spawn_pickup_amount = rng.randf_range(5, 10)
spawn_pickups(spawn_pickup_amount)
If you run your scene now, it will only spawn one type of pickup — ammo, health, or stamina. We want it to be randomized. Let’s remove our Pickups enum from our Player and Pickup scripts, and re-define it in our Global script. We’ll then need to update ur references to our enum.
### Global.gd
extends Node
# Scene resources
@onready var pickups_scene = preload("res://Scenes/Pickup.tscn")
# Pickups
enum Pickups { AMMO, STAMINA, HEALTH }
### 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))
### Pickup.gd
# Pickups to choose from
@export var item : Global.Pickups
# ----------------------- Icon --------------------------------------
func _ready():
#executes the code in the game
if not Engine.is_editor_hint():
#if we choose x item from Inspector dropdown, change the texture
if item == Global.Pickups.AMMO:
sprite.set_texture(ammo_texture)
elif item == Global.Pickups.HEALTH:
sprite.set_texture(health_texture)
elif item == Global.Pickups.STAMINA:
sprite.set_texture(stamina_texture)
#allow us to change the icon in the editor
func _process(_delta):
#executes the code in the editor without running the game
if Engine.is_editor_hint():
#if we choose x item from Inspector dropdown, change the texture
if item == Global.Pickups.AMMO:
sprite.set_texture(ammo_texture)
elif item == Global.Pickups.HEALTH:
sprite.set_texture(health_texture)
elif item == Global.Pickups.STAMINA:
sprite.set_texture(stamina_texture)
Now, in our Main scene, let’s randomize the pickup type before we add it to our scene.
### Main.gd
# older code
# Spawn pickup
func spawn_pickups(amount):
var spawned = 0
while spawned < amount:
# Randomly choose a location on the first or second layer
var random_position = Vector2(randi() % map.get_used_rect().size.x, randi() % map.get_used_rect().size.y)
var layer = randi() % 2
# Spawn it underneath SpawnedPickups node
if is_valid_spawn_location(layer, random_position):
var pickup_instance = Global.pickups_scene.instantiate()
# Randomly select a pickup type
pickup_instance.item = Global.Pickups.values()[randi() % 3]
# Add pickup to scene
pickup_instance.position = map.map_to_local(random_position)
spawned_pickups.add_child(pickup_instance)
spawned += 1
Additionally, the loop in spawn_pickups might be infinite if there aren’t enough valid spawn locations. To prevent this, you can add a maximum number of attempts:
### Main.gd
# older code
# Spawn pickup
func spawn_pickups(amount):
var spawned = 0
var attempts = 0
var max_attempts = 1000 # Arbitrary number, adjust as needed
while spawned < amount and attempts < max_attempts:
attempts += 1
var random_position = Vector2(randi() % map.get_used_rect().size.x, randi() % map.get_used_rect().size.y)
var layer = randi() % 2
if is_valid_spawn_location(layer, random_position):
var pickup_instance = Global.pickups_scene.instantiate()
pickup_instance.item = Global.Pickups.values()[randi() % 3]
pickup_instance.position = map.map_to_local(random_position)
spawned_pickups.add_child(pickup_instance)
spawned += 1
This will ensure that the loop doesn’t run indefinitely if there aren’t enough valid spawn locations. If you run your scene now, your pickups should spawn randomly on the map!
In the next part, we will get to the fun part of the game. We will set up our first enemy that will challenge our player in the game. It’s going to be a long ride, so remember to save your game project, and I’ll see you in the next part.
Your final source code for this part should look like this.
FULL TUTORIAL
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.