#2 Aim Assist

godot devlog hammer-time

The challenges of 3D gaming

Now that player character can move and attack, an issue that hinders the user experience starts to surface: If players try to attack the Interactables created in the previous post, they are going to often miss attacks they expected to land.

This is becuase aiming in a 3D environment can become challenging as a result of the combination different factors:

  • The player being bad at video games. (e.g. me)
  • An akward camera placement, where distances become hard to read.
  • A strict collision system where hitboxes and hurtboxes are very narrow.
  • Unreliable player inputs
PeerTube Hammer-time - Devlog #2 - Attacking with no aim assist

Player inputs might be unreliable because of hardware limitations. As an example, when using a keyboard to move, the WASD keys allow for only 8 different directions. Unless the attack target is perfectly aligned with those, it becomes impossible to reach.

Even when using a joystick instead, the majority of players won’t aim precisely most of the time, so it is crucial that some kind of aim assist system is present to prevent frustration.

Drawing depicting the issue of using the keyboard to move and aim.
Drawing depicting the issue of using the keyboard to move and aim.

Retargeting an attack

One of the tasks an aim assist system does is rotate the player character right before an attack is performed to ensure it faces the nearest target. Players can perform different attacks, and all of them should use this feature if needed.

Each of the actions the PlayerCharacter can perform, including the attacks, are a state in the Finite State Machine that drives its character controller. Taking this into consideration, I began by creating a new shared node, AttackAimAssist, that could be used on demand by any state that needed it.

Screenshot of the Godot editor showing the AttackAimAssit node and its parameters.
Screenshot of the Godot editor showing the AttackAimAssit node and its parameters.

Before I explain what happens inside this node, let’s first cover how it is used: Whenever an attack state is entered, a function is called and tries to retarget the character if a nearby target, an Interactable in this case, is found:


extends PlayerCharacterState
class_name PlayerCharacterAttackState

@export_category("Shared Nodes")
@export var _attack_aim_assist: AttackAimAssist

@export_category("Parameters")
@export var _aim_assist: bool

func enter() -> void:
    super()
    _try_to_assist_aim()

func _try_to_assist_aim() -> void:
	if _aim_assist:
            # "player_character" and "state" are defined in PlayerCharacterState
	    _attack_aim_assist.try_retarget_character(player_character, state)

Alright, now allow me summarise what that try_retarget_character function does:

  1. Performs a physics query around the player using PhysicsDirectSpaceState3D
  2. Filters the results to get the closest Interactable
  3. If there is one, it rotates the player to face it.

This summary is quite vague, I know, but I believe it is important to know the big picture before we dig deeper into the implementation of each one of these. The third point is quite tribial, so I am going to skip it.

Setting up the physics query

My first implementation was to simply query a sphere around the player, but I soon realised this was a problematic approach. Each attack has a different range, and while a sphere shape could be used for the query, asserting which results were valid in the filtering part became complex and attack specific.

Still, just a different shape might not be enough. In the case of the dolphin dive, for example, a move where the player is launched forward from the air while falling, the shape used in the query needs to be offseted to fit the trajectory of the and quite complex.

In short, the query for each attack needed a unique shape and relative position and rotation from the player character. I created a resource class to contain these and I named it AimAssistParameters.

func _get_physics_query_results(player_center_transform: Transform3D, aim_assist_parameters: AimAssistParameters) -> Array[Dictionary]:

    var shape_global_position: Vector3 = player_center_transform.translated_local(aim_assist_parameters.position_offset).origin
    var shape_global_rotation: Vector3 = player_center_transform.basis.get_euler() + aim_assist_parameters.rotation_offset
    var shape_transform: Transform3D = Transform3D(Basis.from_euler(shape_global_rotation),shape_global_position)

    # Debug shape
    _debug_shape(aim_assist_parameters.shape, shape_transform)

    var query_parameters = PhysicsShapeQueryParameters3D.new()
    query_parameters.collide_with_bodies = false
    query_parameters.collide_with_areas = true
    query_parameters.collision_mask = interactables_collision_mask
    query_parameters.shape_rid = aim_assist_parameters.shape.get_rid()
    query_parameters.transform = shape_transform
    var results = get_world_3d().direct_space_state.intersect_shape(query_parameters)

    return results

The code snipped above is shows the physics query now that it is updated to use new parameters. As one can see, I also created a _debug_shape(...) function to allow me to visualise the query when it is used. The function temporarely enables an Area3D in the scene using the parameters of the attack, which comes very handy when debugging.

PeerTube Hammer-time - Devlog #2 - Debugging the shapes used in the aim assist system

Filtering the query results

Choosing a target from a list of results is an easier task than what I just covered, but still it can be complex enough that it is worth writing about it.

Although I want the player character to face the closest interactable automatically, I don’t want it to turn around if an enemy is behind it. That would be very noticeable and distracting. Any potential target that is outside the Field of View of the player should be discarted.

Furthermore, depending on the attack, the query results should be filtered prioritising either distance or alignment. As an example of the first case, when the player character performs a melee attack, it needs to retarget itself to the closest interactable to avoid potential damange from closeby enemies. On the other hand, when the player performs any ranged attack, it needs to retarget itself to the interactable that is most aligned with the direction of the attack. If the player character were to be retargeted by distance during this attack, players would get the feeling that the character is not responding to their inputs.

The return value of the previous function is Array[Dictionary], where each item has these fields.. The simplest way of filtering them is to iterate through the array and store the result that fits the criteria best. Combining all of this, the filter should look something like:

func _try_get_target_from(results: Array[Dictionary], character_position: Vector3, aiming_direction: Vector3, priority: Priority)-> Object:
    var current_target: Object = null
    var closest_distance: float = 999999.0
    var closest_direction_angle: float = 999999.0

    for result in results:
        # Filter if result has no collider
        if !result.collider:
            continue

        # Filter if result has no collider
        var direction_to_collider: Vector3 = result.collider.global_position - character_position
        var direction_angle: float = aiming_direction.angle_to(direction_to_collider)
        if direction_angle > _field_of_view_range_to_assist_threshold:
            continue

        # Filter if result is not an interactable
        if !result.collider.get_node_or_null(_player_character_attacks_data.interactable_hurtbox_relative_node_path):
            continue

        # Chose the result if it is closer or more aligned than the current_target
        # (The code below can be optimised by using length_squared() instead of length())
        var distance: float = direction_to_collider.length()
        if priority == Priority.DISTANCE:
            # If the distance difference is over the threshold, choose the result.
            # If the distance difference is whithin the threshold, consider the direction as well
            var difference: float =  closest_distance - distance
            if difference > _similar_distance_threshold or (difference > - _similar_distance_threshold and direction_angle < closest_direction_angle):
                closest_distance = distance
                closest_direction_angle =direction_angle
                current_target = result.collider
        if priority == Priority.ALIGNMENT:
            # If the angle difference is over the threshold, choose the result.
            # If the angle difference is whithin the threshold, consider the distance as well
            var difference: float =  closest_direction_angle - direction_angle
            if difference > _similar_direction_angle_threshold or (difference > -_similar_direction_angle_threshold and distance < closest_distance):
                closest_distance = distance
                closest_direction_angle = direction_angle
                current_target = result.collider

    return current_target

Closing the distance

Even when an attack is perfectly aimed, it will miss if the player is too far away from the target. For an upbeat title like this, which is not meant to be difficult, this can be frustraiting. Another task an aim assist system needs to do is move the player towards its target if it is slightly out of reach. There are multiple ways this could be implemented, with different levels of complexity and accuracy.

To be completely frank, after implementing the retargeting feature I was a bit burnt out so I decided to stick to the simplest approach, one that is not particuarly accurate and simply reduces the issue, but does not solve it.

What I decided to do, after retargeting the player character, is set a velocity in the forward direciton to try and extend the reach of the attack a little further. This only happens during grounded attacks and if the player is holding the move buttons:

extends PlayerCharacterAttackState

class_name PlayerCharacterGroundedAttackState

@export var _can_slide_forward: bool = false

func enter() -> void:
    # Retargetting occurs in "PlayerCharacterAttackState.enter()"
	super()
	if _can_slide_forward:
		if inputs.move != Vector2.ZERO and player_character.velocity.length_squared() < constants.grounded_attack_slide_forward_speed_squared:
			player_character.velocity = inputs.move.length()*constants.grounded_attack_slide_forward_speed*-player_character.global_basis.z

This change mitigated the issue for the most part, and as a side effect it reduced the commitment of performing attacks, as the momentum of the character is not completely lost while attacks are chained.

See it in action

PeerTube Hammer-time - Devlog #2 - Attacking with aim assist


Previous#1 Attack Collisions

Next#3 Enemy AI