How to Make an RTS Camera System in Godot

RTS camera motion

I will start by making a camera system for our RTS game. Inspired by real-time strategy games like Stronghold and Age of Empires, our camera will offer players a top-down perspective with a slight tilt. Users will have the ability to move the camera around the map, zoom in and out for detailed views, and rotate the perspective.

Breakdown

I will be creating a node to control the elevation (height) of the camera and camera node will be made its child. Then based on the user inputs (such as mouse motion, button pressing or mouse buttons), camera will move, pan, rotate or zoom. Constraints such as max elevation, minimum elevation, speed etc will be defined.

Getting Started with our Camera System

Start by creating a folder named “RTSController”. I am calling it RTS Controller as it is what the user will actually be seeing through & performing actions. In this folder, make a scene with the same name, and Node3D as its root node.

Make another Node3D as its child and set its name to be Elevation. Then move this Elevation node upward. Then add a Camera3D node as its child. Make the camera look downward by controlling its rotation property. Finally, attach a script to it.

rts camera system scene godot
Your scene should look like this by this point

Making the Camera Functional

We will make it functional using the script code. For each action, we have a separate function to handle it. We will call that function every frame (by calling it within _process), and we will update the camera system state via user input.

Define all variables

We need to take different parameters about our camera, such as zoom limits, rotation speed, movement speed, elevation angles limits, screen margins (to move camera if mouse crosses those margins), and whether to allow zooming, panning or rotation.

We also have to keep reference of camera, elevation node & current state of our system (is_rotating, is_panning, etc):

extends Node3D

# Parameters for Camera Control
@export_range(0, 1000) var movement_speed: float = 64
@export_range(0, 1000) var rotation_speed: float = 5
@export_range(0, 1000, 0.1) var zoom_speed: float = 50
@export_range(0, 1000) var min_zoom: float = 32
@export_range(0, 1000) var max_zoom: float = 256
@export_range(0, 90) var min_elevation_angle: float = 10
@export_range(0, 90) var max_elevation_angle: float = 90
@export var edge_margin: float = 50
@export var allow_rotation: bool = true
@export var allow_zoom: bool = true
@export var allow_pan: bool = true

# Camera Nodes
@onready var camera = $Elevation/Camera3D
@onready var elevation_node = $Elevation

# Runtime State
var is_rotating: bool = false
var is_panning: bool = false
var last_mouse_position: Vector2
var zoom_level: float = 64

Define Camera System Functionality

We need to define functions for each action our RTS camera system should perform. These include panning (smoothly moving the camera against cursor), zooming, rotating, and moving the camera based on mouse or keyboard arrow keys input.


# Movement
func handle_keyboard_movement(delta: float) -> void:
	var direction = Vector3.ZERO
	if Input.is_action_pressed("ui_up"):
		direction.z -= 1
	if Input.is_action_pressed("ui_down"):
		direction.z += 1
	if Input.is_action_pressed("ui_left"):
		direction.x -= 1
	if Input.is_action_pressed("ui_right"):
		direction.x += 1

	if direction != Vector3.ZERO:
		direction = direction.normalized()
		global_translate(direction * movement_speed * delta)

func handle_edge_movement(delta: float) -> void:
	var viewport = get_viewport()
	var mouse_pos = viewport.get_mouse_position()
	var screen_rect = viewport.get_visible_rect()
	var direction = Vector3.ZERO

	if mouse_pos.x < edge_margin:
		direction.x -= 1
	elif mouse_pos.x > screen_rect.size.x - edge_margin:
		direction.x += 1

	if mouse_pos.y < edge_margin:
		direction.z -= 1
	elif mouse_pos.y > screen_rect.size.y - edge_margin:
		direction.z += 1

	if direction != Vector3.ZERO:
		direction = direction.normalized()
		global_translate(direction * movement_speed * delta)

# Rotation
func handle_rotation(delta: float) -> void:
	if is_rotating:
		var mouse_displacement = get_viewport().get_mouse_position() - last_mouse_position
		last_mouse_position = get_viewport().get_mouse_position()

		# Horizontal rotation
		rotation.y -= deg_to_rad(mouse_displacement.x * rotation_speed * delta)

		# Elevation
		var elevation_angle = rad_to_deg(elevation_node.rotation.x)
		elevation_angle = clamp(
			elevation_angle - mouse_displacement.y * rotation_speed * delta,
			-max_elevation_angle,
			-min_elevation_angle
		)
		elevation_node.rotation.x = deg_to_rad(elevation_angle)

# Zoom
func handle_zoom(delta: float) -> void:
	zoom_level = clamp(zoom_level, min_zoom, max_zoom)
	camera.position.y = lerp(camera.position.y, zoom_level, 0.1)

# Panning
func handle_panning(delta: float) -> void:
	if is_panning:
		var current_mouse_pos = get_viewport().get_mouse_position()
		var displacement = current_mouse_pos - last_mouse_position
		last_mouse_position = current_mouse_pos

		global_translate(Vector3(-displacement.x, 0, -displacement.y) * 0.1)

Using These Functions

The above functions should be called in _process to do their work. In _process, notice that when panning occurs, other movements are disabled. This is because movements or rotations should not occur while panning, as it is desirable.

func _process(delta: float) -> void:
	if not is_panning:
		handle_edge_movement(delta)
		handle_keyboard_movement(delta)
		if allow_rotation:
			handle_rotation(delta)
		if allow_zoom:
			handle_zoom(delta)
	else:
		if allow_pan:
			handle_panning(delta)

Updating the Camera States

The above functions perform their work only if some conditions are true (i.e., in certain states). For example when a user moves the mouse, or presses some keys. So we need to modify our camera state variables (such as is_panning, is_rotating) based on user input. So when this variable (or any other variable) becomes true, the camera will act accordingly:

# handling inputs
func _unhandled_input(event: InputEvent) -> void:
	if event.is_action_pressed("camera_rotate"):
		is_rotating = true
		last_mouse_position = get_viewport().get_mouse_position()
	elif event.is_action_released("camera_rotate"):
		is_rotating = false

	if event.is_action_pressed("camera_pan"):
		is_panning = true
		last_mouse_position = get_viewport().get_mouse_position()
	elif event.is_action_released("camera_pan"):
		is_panning = false

	if event.is_action_pressed("zoom_in"):
		zoom_level -= zoom_speed
	elif event.is_action_pressed("zoom_out"):
		zoom_level += zoom_speed

Full RTS Game Camera Script

extends Node3D

# Parameters for Camera Control
@export_range(0, 1000) var movement_speed: float = 64
@export_range(0, 1000) var rotation_speed: float = 5
@export_range(0, 1000, 0.1) var zoom_speed: float = 50
@export_range(0, 1000) var min_zoom: float = 32
@export_range(0, 1000) var max_zoom: float = 256
@export_range(0, 90) var min_elevation_angle: float = 10
@export_range(0, 90) var max_elevation_angle: float = 90
@export var edge_margin: float = 50
@export var allow_rotation: bool = true
@export var allow_zoom: bool = true
@export var allow_pan: bool = true

# Camera Nodes
@onready var camera = $Elevation/Camera3D
@onready var elevation_node = $Elevation

# Runtime State
var is_rotating: bool = false
var is_panning: bool = false
var last_mouse_position: Vector2
var zoom_level: float = 64

func _ready() -> void:
	# Initialize zoom level
	zoom_level = camera.position.y

func _process(delta: float) -> void:
	if not is_panning:
		handle_edge_movement(delta)
		handle_keyboard_movement(delta)
		if allow_rotation:
			handle_rotation(delta)
		if allow_zoom:
			handle_zoom(delta)
	else:
		if allow_pan:
			handle_panning(delta)

# handling inputs
func _unhandled_input(event: InputEvent) -> void:
	if event.is_action_pressed("camera_rotate"):
		is_rotating = true
		last_mouse_position = get_viewport().get_mouse_position()
	elif event.is_action_released("camera_rotate"):
		is_rotating = false

	if event.is_action_pressed("camera_pan"):
		is_panning = true
		last_mouse_position = get_viewport().get_mouse_position()
	elif event.is_action_released("camera_pan"):
		is_panning = false

	if event.is_action_pressed("zoom_in"):
		zoom_level -= zoom_speed
	elif event.is_action_pressed("zoom_out"):
		zoom_level += zoom_speed

# Movement
func handle_keyboard_movement(delta: float) -> void:
	var direction = Vector3.ZERO
	if Input.is_action_pressed("ui_up"):
		direction.z -= 1
	if Input.is_action_pressed("ui_down"):
		direction.z += 1
	if Input.is_action_pressed("ui_left"):
		direction.x -= 1
	if Input.is_action_pressed("ui_right"):
		direction.x += 1

	if direction != Vector3.ZERO:
		direction = direction.normalized()
		global_translate(direction * movement_speed * delta)

func handle_edge_movement(delta: float) -> void:
	var viewport = get_viewport()
	var mouse_pos = viewport.get_mouse_position()
	var screen_rect = viewport.get_visible_rect()
	var direction = Vector3.ZERO

	if mouse_pos.x < edge_margin:
		direction.x -= 1
	elif mouse_pos.x > screen_rect.size.x - edge_margin:
		direction.x += 1

	if mouse_pos.y < edge_margin:
		direction.z -= 1
	elif mouse_pos.y > screen_rect.size.y - edge_margin:
		direction.z += 1

	if direction != Vector3.ZERO:
		direction = direction.normalized()
		global_translate(direction * movement_speed * delta)

# Rotation
func handle_rotation(delta: float) -> void:
	if is_rotating:
		var mouse_displacement = get_viewport().get_mouse_position() - last_mouse_position
		last_mouse_position = get_viewport().get_mouse_position()

		# Horizontal rotation
		rotation.y -= deg_to_rad(mouse_displacement.x * rotation_speed * delta)

		# Elevation
		var elevation_angle = rad_to_deg(elevation_node.rotation.x)
		elevation_angle = clamp(
			elevation_angle - mouse_displacement.y * rotation_speed * delta,
			-max_elevation_angle,
			-min_elevation_angle
		)
		elevation_node.rotation.x = deg_to_rad(elevation_angle)

# Zoom
func handle_zoom(delta: float) -> void:
	zoom_level = clamp(zoom_level, min_zoom, max_zoom)
	camera.position.y = lerp(camera.position.y, zoom_level, 0.1)

# Panning
func handle_panning(delta: float) -> void:
	if is_panning:
		var current_mouse_pos = get_viewport().get_mouse_position()
		var displacement = current_mouse_pos - last_mouse_position
		last_mouse_position = current_mouse_pos

		global_translate(Vector3(-displacement.x, 0, -displacement.y) * 0.1)

Leave a Reply

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