First-person 3D camera and movement

Control a first-person camera using WASD and mouse to look with cursor lock.

Project files

This example shows how to build a simple first-person controller for a 3D scene. You can look around with the mouse and move on the XZ plane using the keyboard (WSAD).

What you’ll learn?

Controls

| Input | Action | |————————|——————————————————| | Left mouse click | Lock the cursor and enable mouse look | | Mouse movement | Rotate camera | | Esc | Unlock the cursor | | W/S/A/D | Move forward/backward/left/right on the ground plane |

How it works?

When the cursor is locked, the script reads mouse movement deltas and rotates the camera accordingly. Movement is normalized to keep a consistent speed in all directions and is clamped within a square area so you cannot wander off the demo scene.

Example collection consists of 3 main parts:

Assets

Tree models with textures by Kay Louseberg: https://kaylousberg.itch.io/kaykit-forest Prototype textures for Defold by Visionaire: https://github.com/Thevisionaire1/3Deforms

Script

Tuning parameters are defined at the top of character_controller.script:

Scripts

character_controller.script

-- First-person 3D camera controller

-- Tuning parameters
local look_sensitivity = 0.15 -- degrees of camera rotation per 1 pixel of mouse movement
local move_speed = 0.5        -- world units per second for camera movement on XZ plane
local move_limit = 1.25       -- bounds (half-size) for camera movement on XZ to keep it in a square area

function init(self)
	-- Acquire input focus to receive input events from the engine
	msg.post(".", "acquire_input_focus")

	-- Mouse lock state: when true, mouse deltas rotate the camera
	self.mouse_locked = false

	-- Initialize yaw/pitch from current rotation (stored in degrees in Defold)
	self.yaw = go.get(".", "euler.y")
	self.pitch = go.get(".", "euler.x")

	-- Input state for continuous movement (WASD)
	self.input = {
		forward = false,
		backward = false,
		left = false,
		right = false,
	}
end

function update(self, dt)
	-- Clamp pitch to avoid flipping the camera upside down
	if self.pitch > 89 then self.pitch = 89 end
	if self.pitch < -89 then self.pitch = -89 end

	-- Apply rotation directly via Euler angles (in degrees)
	go.set(".", "euler", vmath.vector3(self.pitch, self.yaw, 0))

	-- Build desired movement direction on XZ plane from input flags
	local x = (self.input.right and 1 or 0) - (self.input.left and 1 or 0)
	local z = (self.input.backward and 1 or 0) - (self.input.forward and 1 or 0)

	-- If there is any movement input, move the camera
	if x ~= 0 or z ~= 0 then
		-- Local space direction (camera space)
		local local_dir = vmath.vector3(x, 0, z)
		local len = math.sqrt(local_dir.x * local_dir.x + local_dir.z * local_dir.z)

		if len > 0 then
			-- Normalize to keep speed consistent diagonally
			local_dir.x = local_dir.x / len
			local_dir.z = local_dir.z / len

			-- Convert the yaw to a quaternion
			local q_yaw = vmath.quat_rotation_y(math.rad(self.yaw))

			-- Convert local movement to world space using current yaw
			local world_dir = vmath.rotate(q_yaw, local_dir)

			-- Get the current position of the character
			local pos = go.get_position()

			-- Integrate the position
			pos.x = pos.x + world_dir.x * move_speed * dt
			pos.z = pos.z + world_dir.z * move_speed * dt

			-- Clamp the position within the square bounds
			if pos.x > move_limit then pos.x = move_limit end
			if pos.x < -move_limit then pos.x = -move_limit end
			if pos.z > move_limit then pos.z = move_limit end
			if pos.z < -move_limit then pos.z = -move_limit end

			-- Set the new position
			go.set_position(pos)
		end
	end
end

-- Pre-hashed input action ids (must match project input bindings)
local KEY_W = hash("key_w")
local KEY_S = hash("key_s")
local KEY_A = hash("key_a")
local KEY_D = hash("key_d")
local KEY_ESC = hash("key_esc")
local TOUCH = hash("touch")
local MOUSE_BUTTON_1 = hash("mouse_button_1")

function on_input(self, action_id, action)
	-- Mouse look when locked: engine provides action.dx/dy even while cursor is locked
	if self.mouse_locked and (action.dx or action.dy) then
		-- Rotate the camera based on the mouse movement
		self.yaw = self.yaw - (action.dx or 0) * look_sensitivity
		self.pitch = self.pitch + (action.dy or 0) * look_sensitivity
	end

	-- Lock on first click (touch or left mouse button)
	if not self.mouse_locked and action.pressed
		and (action_id == TOUCH or action_id == MOUSE_BUTTON_1) then
		-- Lock the mouse
		window.set_mouse_lock(true)
		self.mouse_locked = true
	end

	-- WSAD - Continuous movement input state (pressed/released)
	if action_id == KEY_W then
		-- Set the forward input flag to true if the W key is pressed
		if action.pressed then self.input.forward = true end
		if action.released then self.input.forward = false end
	end
	if action_id == KEY_S then
		-- Set the backward input flag to true if the S key is pressed
		if action.pressed then self.input.backward = true end
		if action.released then self.input.backward = false end
	end
	if action_id == KEY_A then
		-- Set the left input flag to true if the A key is pressed
		if action.pressed then self.input.left = true end
		if action.released then self.input.left = false end
	end
	if action_id == KEY_D then
		-- Set the right input flag to true if the D key is pressed
		if action.pressed then self.input.right = true end
		if action.released then self.input.right = false end
	end

	-- ESC unlocks the mouse so the cursor is free again
	if action_id == KEY_ESC and action.pressed then
		-- Unlock the mouse
		window.set_mouse_lock(false)
		self.mouse_locked = false
	end
end