You can use a material with your own shader without problem. Do make note of the fact that materials are shared between instances which means that setting the uniform of the material of one of the sprites will change the color for all instances using that same material. To support per-instance uniform values, you should make use of the instance uniform type in your shader. The uniform can then be set via CanvasItem’s set_instance_shader_parameter() method.
That said, it is much simpler to just set the CanvasItem’s modulate value instead which tints the color of the sprite. However, if you want to use a shader, that’s good too. It depends on what you want to make. Here is an example I made using modulate:
EnemySpawner.gd
extends Node2D
@export var amount_to_spawn = 5
@export var enemy_prefab: PackedScene
func _input(event: InputEvent) -> void:
if event is InputEventKey:
if event.pressed and event.keycode == KEY_SPACE:
spawn_enemies()
func spawn_enemies():
for i in range(amount_to_spawn):
# Instantiate and set random color
var instance = enemy_prefab.instantiate()
var color = Color.from_hsv(randf(), 1, 1, 1) # Color with random hue
instance.set_color(color)
# Compute random position from spawner
var random = (randf() * 2.0 - 1.0) # Random number in [-1, 1] range
var spawn_pos = global_position + Vector2.RIGHT * random * 200.0
# Add instance to scene tree and set its position
add_child(instance)
instance.global_position = spawn_pos
EnemyController.gd
extends RigidBody2D
@export var sprite_path = "Sprite2D"
func set_color(color: Color):
get_node(sprite_path).modulate = color

Without going too much into detail, as this is a frequently taught OOP concept, an EnemyController script is used to encapsulate enemy-specific systems and methods in its own class. The EnemySpawner then uses the enemy’s change_color() method to… change its color.
The important thing to understand is that any paths that relate to the inner structure of the enemy is kept within the enemy’s class. You can imagine having several different types of enemies, each with their own node tree structure where the path to the color-changing sprite isn’t the same. If you were to let the EnemySpawner evaluate the location of the Sprite2D’s location in the enemy’s node hierarchy, you will quickly end up with spawner code specialized for spawning enemies of various types rather than the class exclusively implementing generalized spawning logic.
As a rule of thumb, anything under an object’s root should not be touched by another system. This is also reflected in Godot’s scene system which hides underlying nodes of any scene instance.
Differing enemy node tree examples
Enemy 1 Node Tree:
hypothetical example
Enemy 2 Node Tree:
hypothetical example
It doesn’t matter for uniforms. The only time the execution order matters, in the context of adding nodes to the tree, is when the tree affects the variable in question. This is the case for a node’s transform (position, rotation, scale) which depends on the parent it is a child of. Rendering stuff is sometimes affected as well. You just have to look it up or test it.
If you want to keep track of your enemy instances, you can store the instances in an Array somewhere for later reference. A common method is to make use of a singleton (usually an Autoload) which stores globally accessible variables.
Should you have any further questions, let me know.