snblog

Read my ramblings about computers, Linux, math, video games and other interests/hobbies.

Quick-and-dirty random encounter mechanic in Godot

Apr 1, 2025 - 5 minute read - Video Games Programming

We’ll be writing a barebones random encounter mechanic here. From my experience with some 90s to mid 2000s JRPGs, I think random encounters generally work as such:

  1. Start a timer of a random duration that counts down to a random encounter
  2. Pause this timer if the player stops moving / is idle
  3. Resume the same timer when the player starts moving again

This is what we shall implement.

Project setup

We’ll have a total of 3 scenes: one for the player, one for the “main” scene, and one that contains the code for the random encounters.

Player scene

I went a bit overkill with the player scene, since I set up animations with this spritesheetexternal link and added a sprinting mechanic. You don’t need to do this, but you can if you want.

This is how I have my player scene set up:

"player" is a CharacterBody2D "player" is a CharacterBody2D

Writing player movement isn’t the focus of this article, so I’ll just put the code that handles player movement below.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
extends CharacterBody2D

enum directions {UP, DOWN, LEFT, RIGHT}
var current_direction = directions.DOWN
var screen_size
var is_sprinting
@export var speed = 250
@onready var animated_sprite = get_node("AnimatedSprite2D")

func _ready() -> void:
	screen_size = get_viewport_rect().size

func _process(delta: float) -> void:
	handle_player_movement(delta)

func handle_player_movement(delta):
	if Input.is_action_just_pressed("sprint"):
		speed *= 1.5
		is_sprinting = true
	if Input.is_action_just_released("sprint"):
		speed /= 1.5
		is_sprinting = false

	# determine direction of movement
	velocity = Vector2.ZERO
	if Input.is_action_pressed("move_right"):
		velocity.x += 1
		current_direction = directions.RIGHT
	if Input.is_action_pressed("move_left"):
		velocity.x -= 1
		current_direction = directions.LEFT
	if Input.is_action_pressed("move_up"):
		velocity.y -= 1
		current_direction = directions.UP
	if Input.is_action_pressed("move_down"):
		velocity.y += 1
		current_direction = directions.DOWN
	
	# normalise movement vector
	if velocity.length() > 0:
		velocity = velocity.normalized() * speed
	
	# update player position
	position += velocity * delta
	position = position.clamp(Vector2.ZERO, screen_size)

	# handle animations
	if velocity.length() == 0:
		match current_direction:
			directions.RIGHT:
				animated_sprite.flip_h = false
				animated_sprite.play("idle_left_right")
			directions.LEFT:
				animated_sprite.flip_h = true
				animated_sprite.play("idle_left_right")
			directions.UP:
				animated_sprite.play("idle_up")
			directions.DOWN:
				animated_sprite.play("idle_down")
	else:
		if velocity.x > 0 and velocity.y == 0:
			animated_sprite.flip_h = false
			if is_sprinting:
				animated_sprite.play("sprint_left_right")
			else:
				animated_sprite.play("walk_left_right")
		if velocity.x < 0 and velocity.y == 0:
			animated_sprite.flip_h = true
			if is_sprinting:
				animated_sprite.play("sprint_left_right")
			else:
				animated_sprite.play("walk_left_right")
		if velocity.y > 0 and velocity.x == 0:
			animated_sprite.play("walk_down")
		if velocity.y < 0 and velocity.x == 0:
			animated_sprite.play("walk_up")

If you want this code to work via copy/pasting, then you’ll have to set up the animations for the sprite and the input map yourself.

Also, this code is probably pretty convoluted; I just needed something quick.

Adding signals

We’ll emit a signal whenever the player moves or stops moving. These signals we shall connect in our random encounter script, which we’ll write in just a bit.

First, define two signals.

1
2
signal player_moving
signal player_stopped

Then, at line 49, add player_stopped.emit() and at line 61, player_moving.emit(). That’s it!

Random encounter scene

There are only two things here: a regular ol’ Node and a timer (which I’ve named to “random_encounter_timer”). You’ll want to attach a script to the node.

Connecting the signals we defined

Firstly, create a “main” scene and add your player and this new random encounter scene in it. Then, select your player and find the signals we defined on the right-hand side.

Double click on each signal, and connect it to the script we created in the random encounter scene.

We’ll add one more signal to our random encounter script, which is the timer’s timeout signal. You can connect that in a similar fashion to the other signals. There should be 3 functions now in that script.

Now comes the actual meat of this article.

Remember what we read about random encounters above:

  1. Start a timer of a random duration that counts down to a random encounter
  2. Pause this timer if the player stops moving / is idle
  3. Resume the same timer when the player starts moving again

Let’s start with the timer. Remember, we don’t want the timer to run if the player is idle, so it makes sense to start it when we get the signal of player movement.

1
2
3
func _on_player_player_movement():
    timer_duration = randf.range(0.5, 1.0) * 2
    random_encounter_timer.start(timer_duration)

There’s no particular reason as to why I’m doing the timer duration that way; I thought it was easier for testing purposes.

We also know that we want to trigger a random encounter when the timer times out.

1
2
func on_random_encounter_timer_timeout():
    print("Random encounter!")

And, we also want to pause this timer when the player stops moving.

1
2
func _on_player_player_stopped():
    random_encounter_timer.set_paused(true)

The main issue now is that we’ll be starting a new timer every time the player moves. We want to allow any timer we set to complete first, before we start a new one. So, we’ll check if we currently have a timer going in the background.

1
2
3
4
5
6
7
8
9
func _on_player_player_movement():
    # if no timer is running, start a new one.
    if random_encounter_timer.is_stopped():         
        timer_duration = randf.range(0.5, 1.0) * 2
        random_encounter_timer.start(timer_duration)

    # if we have a paused timer, resume it.
    if random_encounter_timer.is_paushed():
        random_encounter_timer.set_paused(false)

That’s it!

Here’s all the code:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
extends Node

@onready var random_encounter_timer = get_node("random_encounter_timer")

var timer_duration = 0

func _on_random_encounter_timer_timeout() -> void:
	print("Random encounter!")

func _on_player_player_moving() -> void:
	if random_encounter_timer.is_stopped():
		timer_duration = randf_range(0.5, 1.0) * 2
		random_encounter_timer.start(timer_duration)
	if random_encounter_timer.is_paused():
		random_encounter_timer.set_paused(false)

func _on_player_player_stopped() -> void:
	random_encounter_timer.set_paused(true)

I’ll put a clip of this below; you should be able to see it working in the console.

I don’t write guides very often, so let me know if I can explain anything here better!

Comments?

If you have any thoughts about this post, feel free to get in touch!