Skip to content

Navigation Menu

Sign in
Appearance settings

Search code, repositories, users, issues, pull requests...

Provide feedback

We read every piece of feedback, and take your input very seriously.

Saved searches

Use saved searches to filter your results more quickly

Sign up
Appearance settings

Guard Refactoring - A Storyvore Side Quest #1318

manuq started this conversation in Submit a Guide!
Discussion options

The why

Since @JuanFdS created the guards for the stealth game mechanic, we started talking about state machines and reusable character behaviors. But back then we were rushing an MVP (minimum viable product). After that milestone, we continued discussing and ended up opting for reusable character walk behaviors.

Now is time to integrate them with the existing characters. So I picked the guard. How hard can it be to refactor the character while keeping the existing behavior and not breaking the stealth levels? :)

These are my lessons learned while doing the refactor. Most of this is not going to the pull request, so I decided to post it here. By dissecting Juan's guard we can see the genious design behind this game mechanic. I hope this is useful for someone wanting to create a complex behavior for their own characters inside Threadbare.

Setting a test scene

I wanted to put the original and the new guards side by side in a scene, in order to compare them. So I started by duplicating the guard and renaming the copy (scene, scripts, class name, etc) to "guard old". For a setting, I started by duplicating the gym scene and removing the existing characters. Then I instantiated the two guards and two player characters, and placed them at the same distance of each corresponding one. I added a fixed camera too, framing all four characters. Then I added a Path2D node and draw the patrolling path for one guard. I duplicated it for the other guard, and the curve information was shared (nothing special here, the Curve2D resource is shared by default). So I can edit the curve and both paths will be updated at the same time!

Captura desde 2025年10月08日 18-22-55

Side by side recording

Here are some recordings. Luckly Godot 4.5 can record in OGV format with sound! Although I had to convert them to WEBM for uploading here, ugh.

guard-refactor-001.webm
guard-refactor-002.webm
guard-refactor-003.webm
guard-refactor-004.webm
guard-refactor-005.webm
guard-refactor-006.webm

States graph

A big part of the refactor was about understanding the existing logic of the guard, for replicating it with the new character behaviors. This is, the different states and how they transition (the arrows drawn below). In the case of the guard, the state transitions are triggered by:

  • Areas detection. When the player collision shape enters or exists an area.
  • Timers. Like the player awareness timer or the waiting timer.
  • Signals emited when paths are travelled.
guard-states
  • 1, 2: The guard starts patrolling or waiting. These are the entry points to the graph.
  • 3, 4: While patrolling the guard waits standing in the path corner points, and then resumes patrolling. If the player stays out of sight, the guard will forever be in a patrolling / waiting loop.
  • 5: The guard goes immediately to the alerted state (defeating the player) if:
    The player enters the "instant detection area". This is a circular shape around the guard sprite. Basically, if the player gets too close, it is immediately defeated. Or:
    The player enters the sight of the guard (its cone of light area), and the guard has the "player instantly detected on sight" setting enabled.
    This is transition 5 in the graph but actually there should be arrows from any state to "Alerted". They are not drawn to keep the graph readable.
  • 6, 7: While patrolling or waiting, the player enters the sight of the guard (its cone of light area). So the guard stops patrolling and starts detecting the player. Its player awareness starts growing, the exclamation icon starts filling.
  • 8: The player didn't escape (didn't move enough to get out of the cone of light area). So the guard becomes alerted, defeating the player.
  • 9: The player gets out of sight. So the guard starts investigating, going to the place that the player was last seen. While this chase happens, the player may enter and get out of sight again, in which case the guard retargets its destination and drops a breadcrumb to mark the way back.
  • 10: Like 8 but while investigating.
  • 11: The guard arrives to the place where the player was last seen and stays waiting before returning.
  • 12: The guard starts returning, following a path formed by the breadcrumbs dropped during the chase.
  • 13: After walking back consuming all the breadcrumbs, the guard starts the patrolling / waiting loop again.

Refactoring

I started considering which of the reusable walk behaviors could be used. The PathWalkBehavior was done with the guard patrolling state in mind. To my surprise, when I tested it in the actual stealth level in the first Lore quest, some guards were misplaced. This was because their Path2D nodes were scaled, and the PathWalkBehavior wasn't considering the path being scaled or rotated. This was one of several fixes that I had to do. Another issue was that the guards didn't wait in the last (and first) point of the closed path.

For the investigating state I used the FollowWalkBehavior but I couldn't target the player directly. I added a Marker2D and moved it to the player last seen position (transition 9 in the graph).

For the returning state I used a second PathWalkBehavior but unlike the one for patrolling, its path was set dynamically from the breadcrumb points. It can be seen in the videos above, with the breadcrumbs drawn as purple circles.

With this all the walking states were covered.

Long term, I think that a NavigationFollowWalkBehavior would be better for the returning state.

Visual debugging

In this case I wanted to see the paths. So I enabled Debug -> Visible Paths in the editor. There are more options under the Debug menu that can be useful for other cases: visual collision shapes, visual navigation paths, etc.

Text label node

The guard as originally made by Juan has a useful text label node attached, which is made visible with a debug setting. For the new guard, I reduced the information presented and made it specific per state. For instance, the waiting state shows the time remaining in seconds, while the investigating state shows the distance remaining to the target.

Slowing / speeding the game

It was very useful to play the game in slow motion, by adding this line in a script:

Engine.time_scale = 0.5

To understand what was going on and have time to read the label information. Otherwise the states change too fast to keep up with. And also later it became useful to speed up the game for faster iterations.

Painting the canvas

To debug the breadcrumbs and other points I added a Control node to a CanvasLayer, and added a script to it:

extends Control
@onready var on_the_ground: Node2D = $"../../OnTheGround"
@onready var guard: Guard = $"../../OnTheGround/Guard"
@onready var guard_old: GuardOld = $"../../OnTheGround/GuardOld"
var origin: Vector2
func _ready() -> void:
	origin = on_the_ground.get_global_transform_with_canvas().origin
	# Play the game in slow motion:
	Engine.time_scale = 0.5
func _process(_delta: float) -> void:
	# Force redraw at every frame:
	queue_redraw()
func _draw() -> void:
	for p in guard.breadcrumbs:
		draw_circle(origin + p, 10.0, Color.BLUE_VIOLET)
	for p in guard_old.breadcrumbs:
		draw_circle(origin + p, 10.0, Color.BLUE_VIOLET)
	if guard._last_seen:
		var p := guard._last_seen.position
		draw_circle(origin + p, 10.0, Color.LIGHT_CORAL)
	if guard_old.guard_movement.destination:
		var p := guard_old.guard_movement.destination
		draw_circle(origin + p, 10.0, Color.LIGHT_CORAL)

I found that It is better to do this externally in a Control node, rather than overriding the _draw method of the guard, because otherwise when the guard moves the points move along with them.

Good old print

Also in the state setter has the current and the new state, so we can print a line for debugging the transitions:

# Uncomment to debug the state transitions:
print("%-15s → %-15s" % [State.keys()[state], State.keys()[new_state]])

Here is sample output:

PATROLLING → WAITING 
WAITING → PATROLLING 
PATROLLING → WAITING 
WAITING → PATROLLING 
PATROLLING → DETECTING 
DETECTING → INVESTIGATING 
INVESTIGATING → ALERTED

Exported properties

Last but not least! It was also useful to play with the exported properties of the guard to make it more forgiving while debugging. Extending its time to detect player, reducing its walk speed.

Is it worth it?

As usual with big refactors, they are a lot of work for no visible benefits. The real benefit in this case is the simplification of the code and that it is reusing existing walk behaviors.

While I was doing this refactor, more Stealth levels were added by contributed Story Quests. So I need to be careful to not break those levels!

Current state

I noticed that when the guard is detecting, it should stay still. That was originally, but the current guard has regressed (probably due to my own previous refactors) and goes directly to chase the player. Should I keep the original behavior or be accurate with the current one?

When the guard turns around at a path ending, it can do it clockwise or counter clockwise. That is not deterministic. What should we do about it?

Remaining work:

  • Bring back the in-editor debugging.
  • Continue comparing and reduce the differences to a minimum.

Feedback welcome!

You can help me by playing the levels and comparing. My hope is that the differences are reduced so we can merge this refactor.

Released Stealth level:

Stealth level with refactored guards:

You must be logged in to vote

Replies: 3 comments 3 replies

Comment options

This is great, thank you for writing up these notes! What a great idea to make a scene with two players that you control simultaneously. (That makes me wonder: could we design a level where you control multiple StoryWeavers at once and make them both avoid traps?)

You must be logged in to vote
1 reply
Comment options

manuq Oct 9, 2025
Maintainer Author

This is great, thank you for writing up these notes! What a great idea to make a scene with two players that you control simultaneously. (That makes me wonder: could we design a level where you control multiple StoryWeavers at once and make them both avoid traps?)

There is nothing preventing a developer to do so :)

Comment options

When the guard turns around at a path ending, it can do it clockwise or counter clockwise. That is not deterministic. What should we do about it?

Where does the non-determinism come from? I would expect it to be deterministic except perhaps in the case where the guard is doing a 180° turn, because the detection angle is adjusted with move_towards and the guard's current velocity's direction?

You must be logged in to vote
2 replies
Comment options

manuq Oct 9, 2025
Maintainer Author

When the guard turns around at a path ending, it can do it clockwise or counter clockwise. That is not deterministic. What should we do about it?

Where does the non-determinism come from? I would expect it to be deterministic except perhaps in the case where the guard is doing a 180° turn, because the detection angle is adjusted with move_towards and the guard's current velocity's direction?

Yes sorry, I meant to say when the guard does a 180 degrees (PI radians) turn. The code to turn the DetectionArea is the same:

	var target_angle: float = character.velocity.angle()
	rotation = rotate_toward(rotation, target_angle, delta * LOOK_AT_TURN_SPEED)

But the 2 algorithms move_and_slide() the character using a slightly different velocity vector.

(By the way while recording I saw a regression that I introduced in the current guard and opened #1321).

Although it looks like it is deterministic, just behaving differently in the two cases? In particular this tricky part of the level with insta-detection becomes super hard because the guard at the north of the bridge now turns clockwise!

Current main (counter clockwise):

stealth-level-current.webm

New (clockwise):

stealth-level-new.webm

I guess that we can enhace the guard with an export option to rotate in the direction that the level designer wants?

Comment options

wjt Oct 9, 2025
Maintainer

Looking closely it seems that at the two ends of the top island guard's path, in one video it turns clockwise on the left and anticlockwise on the right; while in the other video it's anticlockwise on the left and clockwise on the right. Interesting problem! I'm trying to think of a geometrically-satisfying reason to make it predictable.. or can we use the control points to control this?

Comment options

manuq
Feb 5, 2026
Maintainer Author

I resumed back work of this refactor. Now I want to keep the existing implementation of the guards, and put that in a behavior. This is to prevent regressions as much as possible.

Draft pull request: #1880

Playable at https://play.threadbare.game/branches/endlessm/guard-behavior/

You must be logged in to vote
0 replies
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
2 participants

AltStyle によって変換されたページ (->オリジナル) /