DEV Community

Cover image for Enemies, spawns and escalating chaos
Viktor de Pomian Sandell
Viktor de Pomian Sandell

Posted on

Enemies, spawns and escalating chaos

Enemies

Hmm. They're not really enemies, are they? We're not fighting anything. We're collecting fish.

That reminds me, there's an awesome podcast called No Such Thing as a Fish. Completely unrelated. Anyway.

Back to the game.

The so-called "enemies" are fish falling from the sky, and our job is to gather them. I've made two versions:

Normal fish - worth 1 point
Gold fish - worth 3 points

Yes, I know I mentioned this in the first post. Repetition builds character. Or at least reinforces game mechanics.

I made two fish sprites in Aseprite. Amazing software. Aseprite

A image of two pixel fishes

I decided to split them into two scenes: normal_fish and gold_fish.

Both scenes use an Area2D as the root node, with a Sprite2D and a CollisionShape2D as children. I went with Area2D because I want the fish to disappear the moment they're collected by the Player.

I also attached a script to each fish.

extends Area2D

@export var fall_speed = 200

func _ready() -> void:
    body_entered.connect(_on_body_entered)

func _process(delta: float) -> void:
    position.y += fall_speed * delta

func _on_body_entered(body):
    if body.is_in_group("Player"):
        queue_free()
Enter fullscreen mode Exit fullscreen mode

This script simply sends a signal and connects to it on the same object, meaning it handles its own collision instead of notifying another node.

So when something enters the Area2D (basically touches it), the script checks if that body belongs to the Player group. If it does, it deletes itself, the fish removes its own node from the scene.

Flow is simple:

Fish falls down -> touches the boat -> script verifies it's the Player -> fish gets deleted from the viewport.

Clean and self-contained.

One important detail: in the Player scene, I added the CharacterBody2D (the root node) to a Group called Player.

That way, checking collisions becomes simple. Instead of hard-referencing a specific node, the fish just asks:

β€œIs this body in the Player group?”

If yes -> delete fish.

Spawn Logic

The spawn logic took me a while to figure out. It's not 100% polished yet, but it works, and working beats perfection.

I created a Marker2D node called SpawnPoint, and gave it a Timer node as a child, renamed to SpawnTimer.

The idea is simple:
The SpawnPoint decides where things appear.
The SpawnTimer decides when they appear.

Position + timing. That's the whole trick.

Image of Godot node tree

I moved the SpawnPoint to the middle of the screen and pushed it straight up, just outside the visible area. That way, the fish spawn off-screen and fall into view.

On the SpawnTimer, I only changed one thing: Wait Time = 3.0 seconds.

Then I attached a script to SpawnPoint, because a timer that just sits there politely counting to three doesn't actually spawn anything on its own.

extends Marker2D

var marker_pos = self.global_position.x
var viewport_size_x

const GOLD_FISH = preload("uid://cr8ekydc55yid")
const NORMAL_FISH = preload("uid://b0uukpf476i0q")

@onready var spawn_timer: Timer = $SpawnTimer
@onready var spawn_objects_group: Node = $"../SpawnObjectsGroup"

func _ready():
    viewport_size_x = get_tree().root.get_visible_rect().size.x
    spawn_timer.start()

func _on_spawn_timer_timeout() -> void:
    _spawn_random_object()

func _spawn_random_object() -> void:
    var spawn_random_position = randf_range(32, viewport_size_x - 32)
    var gold_fish_chance = 0.2 # This means a 10% chance
    var object
    if randf() < gold_fish_chance:
        object = GOLD_FISH.instantiate()
    else:
        object = NORMAL_FISH.instantiate()
    object.position.x = spawn_random_position
    spawn_objects_group.add_child(object)
Enter fullscreen mode Exit fullscreen mode

Explanation:

var marker_pos = self.global_position.x
This grabs the X position of the Marker2D in global space.

We need that value so we know exactly where to spawn the fish horizontally in the world. Since the marker is positioned just above the screen, its global position becomes the reference point for where new objects appear.

var viewport_size_x
This creates a variable called viewport_size_x.

Its job is to store the width of the viewport so we can use that value later.

const GOLD_FISH = preload("uid://cr8ekydc55yid")
const NORMAL_FISH = preload("uid://b0uukpf476i0q")
These are simply references to the two scenes I created earlier. By preloading them, I can quickly instantiate either the gold fish or the normal fish without repeatedly typing long paths.

It also makes the code cleaner and easier to read.

Small tip: in Godot, you can hold CTRL and drag the scene file directly into the script, and it will automatically create the correct preload reference for you.

@onready var spawn_timer: Timer = $SpawnTimer
@onready var spawn_objects_group: Node = $"../SpawnObjectsGroup"
Same idea here. Instead of manually typing node paths and hoping you don't mess them up, you can CTRL + drag the nodes directly from the Scene Tree into the script.

viewport_size_x = get_tree().root.get_visible_rect().size.x
spawn_timer.start()
The first line grabs the exact width of the viewport (X axis). I don’t bother with Y because the spawn position is always off the top of the screen, we only care about horizontal placement.

The second line starts the timer so spawning actually begins when the game launches. That's why it's in _ready(), it only needs to fire once, right at the start.

func _on_spawn_timer_timeout() -> void:`
    `_spawn_random_object()
Enter fullscreen mode Exit fullscreen mode

This function is connected to the timeout signal of SpawnTimer.

When the timer hits 3 seconds, it fires _on_spawn_timer_timeout(), which then calls _spawn_random_object().

That _spawn_random_object() function is where the real magic happens, this is where the fish actually appear in the world.

func _spawn_random_object() -> void:
    var spawn_random_position = randf_range(32, viewport_size_x - 32)
    var gold_fish_chance = 0.2 # This means a 20% chance
    var object
    if randf() < gold_fish_chance:
        object = GOLD_FISH.instantiate()
    else:
        object = NORMAL_FISH.instantiate()
    object.position.x = spawn_random_position
    spawn_objects_group.add_child(object)
Enter fullscreen mode Exit fullscreen mode

This function randomly spawns either a normal fish or a gold fish somewhere along the screen width. The gold fish is rarer, appearing only about 20% of the time.

Once a fish is chosen and its horizontal position is set, it's added to the game world, specifically into spawn_objects_group. This node acts as a container for all spawned fish, keeping the scene organized instead of scattering fish everywhere in the root.

Oh, and by the way. I also finished the sprite for my boat.

A sprite of my pixel boat

My dear readers. Remember that everything I write is not always 100% correct. I do mistakes and I'm a novice at this. So if you find a mistake or just have some random feedback on anything. Please comment!

Top comments (3)

Collapse
 
theminimalcreator profile image
Guilherme Zaia

Love the approach on handling fish spawning! Using Area2D for simple collision management is clean and helps keep the code modular. However, I wonder if you considered adding a slight randomness to the fall speed for each fish instance. This could enhance the gameplay experience by adding a bit of unpredictability, making it more engaging. What's your take on it? 🐟

Collapse
 
depoco profile image
Viktor de Pomian Sandell • Edited

Thanks! The spawn script took a while to figure out, but it's working perfectly.

Yeah, good call. The fish actually fall at different speeds. I just change the "fall_speed" value in the editor for each fish.

I guess that's ok? I haven't really figured out if it's better to have multiple speeds in code or just change one variable/value in the editor.

Some comments may only be visible to logged-in visitors. Sign in to view all comments.