Making a Spaceship with Thrusters (Physics)

2D physics thrusters spaceship

In this post, I am making a 2D spaceship with two thrusters. One on left and one on right. Our objective is to make a spaceship that is in zero gravity environment, and we have to control it only using the two thrusters. I used it in my Rocket Escape game. Some other elements from this game are procedural cave generation, and 2D shockwave shader.

This makes a really interesting mechanic that can add challenge in gameplay. So this was the idea.

Creating Spaceship Scene

Create a folder named “Spaceship”, and a scene in it with same name. Scene root should be RigidBody2D. Under the root node, add a Sprite2D (to visualize the spaceship), a CollisionShape2D (or CollisionPolygon2D, whatever you prefer) (required by the RigidBody2D).

Under the root, also add additional two nodes to represent the positions of the thrusters. Later physics forces will act on these positions. – Thus create two Marker2D nodes, one on left and one on right. And (optionally) add an AnimatedSprite2D node as their child to visualize the thruster animation.

This is how the overall scene structure looks like.

Now attach a script to the root RigidBody2D node.

Script

In the script, first of all, create variables to hold the references of our nodes. I decorated these variables with @export keyword, so we will be able to assign their values from the editor:

extends RigidBody2D


# Export variables for thrust markers
@export var left_nozzle_marker: Marker2D
@export var right_nozzle_marker: Marker2D


@export var left_thrust_animation: AnimatedSprite2D
@export var right_thrust_animation: AnimatedSprite2D


@export var thrust_force: float = 128.0


@export var max_health: float = 100
var health: float = max_health


@export var max_fuel: float = 100
var fuel: float = max_fuel
Make sure to assign values to the @export variables (see right-side panel). Assign them scene’s nodes.

Now, we make the thrusters invisible since we only want them to be visible when user triggers the thruster. And, since my thruster animation is named “default” in the AnimatedSprite2D node, so I played it here with that name:

func _ready():	
	add_to_group('players')
	
	# Hide the thrust animations
	left_thrust_animation.visible = false
	right_thrust_animation.visible = false
	
	left_thrust_animation.play("default")
	right_thrust_animation.play("default")

Also, I have this habit of adding all instances of all objects to their groups, so they can be accessible from anywhere. This is a useful hacky approach that works well for smaller projects.

Adding Movement System


func _physics_process(delta: float) -> void:	
	# Hide the thrust animations before start of the loop
	# it will later be shown if thrusters are active
	left_thrust_animation.visible = false
	right_thrust_animation.visible = false
	
	var left_thruster = Input.is_action_pressed("ui_left")
	var right_thruster = Input.is_action_pressed("ui_right")
	
	if left_thruster and not right_thruster:
		var radius := left_nozzle_marker.global_position.distance_to(self.global_position)
		apply_torque(thrust_force * radius)
		
		left_thrust_animation.visible = true
		right_thrust_animation.visible = false
		
		fuel -= delta
	
	elif right_thruster and not left_thruster:
		var radius := left_nozzle_marker.global_position.distance_to(self.global_position)
		apply_torque(-thrust_force * radius)
		left_thrust_animation.visible = false
		right_thrust_animation.visible = true
		
		fuel -= delta
	
	if left_thruster and right_thruster:
		apply_central_force(Vector2.UP.rotated(self.rotation) * 2.0 * thrust_force) # Since both thrusters are open
		
		left_thrust_animation.visible = true
		right_thrust_animation.visible = true
		
		fuel -= delta * 2.0
	
	# var ui = get_tree().get_nodes_in_group("ui")[0]
	# ui.set_fuel(fuel)
	# ui.set_health(health)

In above code, if left thruster key is pressed, then apply positive torque, and vice versa. But if both thruster keys are pressed, then apply a linear force (using apply_central_force function).

Also, we decrease fuel every time the player presses thrusters. Pressing both thrusters consume 2x fuel.

Finally, if you have UI in your game, you may want to assign the fuel & health values to UI TextureProgressBar. For now, I commented-out these lines at the end.

Making Damage System

Now we add damage system. We want spaceship to get more damage if it has higher linear or angular speed during collision. Higher change in speeds after impact means more damage.

If impact of collision is less than damage threshold, then do not damage at all. Impact is calculated by adding magnitudes of linear velocity with angular velocity (absolute values of angular velocity are used so we don’t get negative values in calculation, otherwise it will give higher damage for one direction and opposite damage for other direction).

Now, go to “Node” panel (right next to “Inspector”) and attach the body_entered signal.

body_entered signal is attached by clicking on it and pressing Connect button on bottom.
var damage_threshold: float = 5.0  # Minimum velocity for damage
var impact_damage_multiplier: float = 0.25

# Called when a collision occurs
func _on_body_entered(body: Node) -> void:
	var impact = calculate_collision_impact()
	apply_impact_damage(impact)

# Calculate the impact force based on both linear and angular velocity
func calculate_collision_impact() -> float:
	# Get the magnitude of linear velocity
	var linear_impact = linear_velocity.length()
	
	# Get the magnitude of angular velocity (in radians/sec)
	var angular_impact = abs(angular_velocity)
	
	# Combine both impacts - you can adjust these weights
	var total_impact = (linear_impact * 0.8) + (angular_impact * 0.2)
	
	# Return 0 if impact is below threshold
	if total_impact < damage_threshold:
		return 0.0
		
	return total_impact

# Apply damage based on the impact force
func apply_impact_damage(impact: float) -> void:
	if impact <= 0:
		return
		
	# Calculate damage based on impact
	var damage = impact * impact_damage_multiplier
	
	# Reduce health
	health = max(0, health - damage)
		
	# Check if rocket is destroyed
	if health <= 0:
		destroy_rocket()

# add custom destruction logic (an animation or simple queue_free())
func destroy_rocket() -> void:
	pass

And do following configuration to make the signal work (explanation below the image):

Since you connected body_entered signal and mapped it to _on_body_entered function, you need to set the contact_monitor to checked, and set max_contacts_reported to some value, such as 8. This will allow the rigid body to detect collisions, otherwise the signal will never trigger. – This can be found under the Solver section of Inspector, while your root RigidBody2D node is selected.

That’s all. To use this rocket, instantiate it in some other scene. Thank you for reading.

Leave a Reply

Your email address will not be published. Required fields are marked *