-
Notifications
You must be signed in to change notification settings - Fork 399
Guard Refactoring - A Storyvore Side Quest #1318
-
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-55Side 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.
- 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:
Beta Was this translation helpful? Give feedback.
All reactions
Replies: 3 comments 3 replies
-
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?)
Beta Was this translation helpful? Give feedback.
All reactions
-
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 :)
Beta Was this translation helpful? Give feedback.
All reactions
-
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?
Beta Was this translation helpful? Give feedback.
All reactions
-
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?
Beta Was this translation helpful? Give feedback.
All reactions
-
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?
Beta Was this translation helpful? Give feedback.
All reactions
-
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/
Beta Was this translation helpful? Give feedback.