Selection Box from Sprite or GUI

Hello, everyone!

I joined Defold a few weeks ago and have been migrating my RTS game to it. Along the way, I’ve faced many challenges—one of them was creating an on-screen selection box for selecting units.

After doing some research on Sprites and GUI, and with the help of Koen “Kaji” Bollen from the Defold Discord server, I learned that I could use either a 1x1 transparent Sprite or a simple box in the GUI. Creating the 1x1 pixel sprite was easy, but I wanted a hollow box with an outline rather than a solid one.

That turned out to be a big challenge! In the GUI, it was quick and straightforward, but with Sprites, it was much harder because Slice-9 scaling didn’t behave the same way as it does in the GUI. I had to find another approach, which took some time.

But in the end, I figured it out! :tada: So I wanted to share my solution with all of you.

TL;DR: Below is a script for creating a selection box with an outline using GUI or Sprite.

GUI Settings to use:


PNG to Use:
Selection Box_gui_9slice
GUI Script is as:

-- selection.gui_script
local start_pos = vmath.vector3()
local dragging = false
local box_node

function init(self)
	box_node = gui.get_node("selection_rect")
	-- Set slice9 values to create a border
	gui.set_slice9(box_node, vmath.vector4(2, 2, 2, 2))
	msg.post(".", "acquire_input_focus")
	gui.set_enabled(box_node, false)
end

function on_input(self, action_id, action)
	if action_id == hash("touch") then
		if action.pressed and not dragging then
			-- Start dragging
			start_pos.x = action.x
			start_pos.y = action.y
			dragging = true
			gui.set_enabled(box_node, true)
			gui.set_position(box_node, start_pos)
			gui.set_size(box_node, vmath.vector3(0, 0, 0))
		elseif action.released and dragging then
			-- Stop dragging
			dragging = false
			gui.set_enabled(box_node, false)
		elseif dragging then
			-- Update box size/position
			local current_x = action.x
			local current_y = action.y
			local min_x = math.min(start_pos.x, current_x)
			local min_y = math.min(start_pos.y, current_y)
			local width = math.abs(current_x - start_pos.x)
			local height = math.abs(current_y - start_pos.y)

			gui.set_position(box_node, vmath.vector3(min_x, min_y, 0))
			gui.set_size(box_node, vmath.vector3(width, height, 0))
		end
	end
end

There are three versions of the Sprite Selection Box because I preferred using this over the GUI version. You can mix and match them based on your preferences—all of them work!

There’s also a fourth version (v4), where I converted v3 into a Lua script. However, since it only took 1–2 minutes to make, I didn’t include it. But if anyone needs it, I’ll be happy to add it! :blush:

Sprite Settings to use:

Sprite PNG to use:
Selection Box_9slice

Sprite v1 code. The one it worked with:

local DISPLAY_WIDTH = sys.get_config_int("display.width")
local DISPLAY_HEIGHT = sys.get_config_int("display.height")

local start_pos = vmath.vector3()
local dragging = false
local selection_box

function init(self)
    msg.post(".", "acquire_input_focus")
    selection_box = msg.url("#selection_box")
    msg.post(selection_box, "disable")
end

local function screen_to_world(x, y, z, camera_id)
    local projection = camera.get_projection(camera_id)
    local view = camera.get_view(camera_id)
    local w, h = window.get_size()
    w = w / (w / DISPLAY_WIDTH)
    h = h / (h / DISPLAY_HEIGHT)

    local inv = vmath.inv(projection * view)
    x = (2 * x / w) - 1
    y = (2 * y / h) - 1
    z = (2 * z) - 1
    local x1 = x * inv.m00 + y * inv.m01 + z * inv.m02 + inv.m03
    local y1 = x * inv.m10 + y * inv.m11 + z * inv.m12 + inv.m13
    local z1 = x * inv.m20 + y * inv.m21 + z * inv.m22 + inv.m23
    return x1, y1, z1
end

function on_input(self, action_id, action)
    if action_id == hash("touch") then
        if action.pressed and not dragging then
            local worldx, worldy = screen_to_world(action.x, action.y, 0, "RTSCamera#camera_component")
            start_pos = vmath.vector3(worldx, worldy, 0)
            dragging = true
            go.set_position(start_pos, selection_box)
            go.set(selection_box, "size", vmath.vector3(0.1, 0.1, 0))
            msg.post(selection_box, "enable")
        elseif action.released and dragging then
            dragging = false
            msg.post(selection_box, "disable")
        elseif dragging then
            local worldx, worldy = screen_to_world(action.x, action.y, 0, "RTSCamera#camera_component")
            local current_pos = vmath.vector3(worldx, worldy, 0)

            local min_x = math.min(start_pos.x, current_pos.x)
            local min_y = math.min(start_pos.y, current_pos.y)
            local max_x = math.max(start_pos.x, current_pos.x)
            local max_y = math.max(start_pos.y, current_pos.y)

            local center = vmath.vector3(
                (min_x + max_x) / 2,
                (min_y + max_y) / 2,
                0
            )

            local width = math.max(math.abs(max_x - min_x), 0.1)
            local height = math.max(math.abs(max_y - min_y), 0.1)
            go.set_position(center, selection_box)
            go.set(selection_box, "size", vmath.vector3(width, height, 0))
        end
    end
end

Version 2 (v2) is cleaner, well-commented, and easier to understand, with a slight focus on performance.

local DISPLAY_WIDTH = sys.get_config_int("display.width")
local DISPLAY_HEIGHT = sys.get_config_int("display.height")

local start_pos = vmath.vector3()
local dragging = false
local selection_box

-- Camera Variables Related to Screen_On_World
local rts_camera = nil
local rts_camera_projection = nil
local rts_camera_view = nil
local rts_camera_inv
local rts_camera_w, rts_camera_h = nil, nil
local prev_worldx, prev_worldy = nil, nil


function init(self)
	msg.post(".", "acquire_input_focus")
	selection_box = msg.url("#selection_box")
	msg.post(selection_box, "disable") -- Start disabled

	-- screen_to_world function variables
	rts_camera = msg.url("RTSCamera#camera_component") -- Gets the Camera Component
	-- rts_camera_w, rts_camera_h = window.get_size()  -- Gets Screen Size
	rts_camera_w = DISPLAY_WIDTH
	rts_camera_h = DISPLAY_HEIGHT
end

-- Custom screen-to-world conversion function
local function screen_to_world(x, y)
	-- Gets the inversion which is updated when first pressed
	local inv = rts_camera_inv

	-- Convert screen coordinates to the [-1, 1] range
	x = (2 * x / rts_camera_w) - 1 -- Z Ranges From [-1 (left) to 1 (right)]
	y = (2 * y / rts_camera_h) - 1 -- Y Ranges From [-1 (bottom) to 1 (top)]

	-- Gets the Coordinates by Reversing Camera’s projection and View transformations
	local x1 = x * inv.m00 + y * inv.m01 + inv.m03 -- Gets in X
	local y1 = x * inv.m10 + y * inv.m11 + inv.m13 -- Gets in Y
	return x1, y1                               -- Returns X, Y Coordinates
end

local function selection_update_start(action)                        -- Runs once when pressed
	-- Updates Screen_to_World Variables
	rts_camera_projection = camera.get_projection(rts_camera)        -- Gets the Projection Size
	rts_camera_view = camera.get_view(rts_camera)                    -- Gets the View Size
	rts_camera_inv = vmath.inv(rts_camera_projection * rts_camera_view) -- Gets the Inversion

	-- Sets Starting Position and Dragging
	local worldx, worldy = screen_to_world(action.x, action.y) -- Gets Global Position from Screen
	start_pos = vmath.vector3(worldx, worldy, 0)            -- Sets the Start Position
	dragging = true                                         -- Dragging True

	-- Update Sprite Properties
	go.set_position(start_pos, selection_box)              -- Apply Position
	go.set(selection_box, "size", vmath.vector3(0.1, 0.1, 0)) -- Size(0.1) in X, Y
	msg.post(selection_box, "enable")                      -- Sprite Visiable
end

local function selection_update_dragging(current_pos) -- Runs when Dragging
	-- Gets the Min, Max of X and Y
	local min_x = math.min(start_pos.x, current_pos.x) -- Min X
	local min_y = math.min(start_pos.y, current_pos.y) -- Min Y
	local max_x = math.max(start_pos.x, current_pos.x) -- Max X
	local max_y = math.max(start_pos.y, current_pos.y) -- Max Y

	-- Calculates the center of the selection box
	local center = vmath.vector3(
		(min_x + max_x) / 2, -- X Direction
		(min_y + max_y) / 2, -- Y Direction
		1              -- Z Direction
	)

	-- Calculate width/height of the selection box from drag distance
	-- Ensures a minimum size of 0.1 to prevent the sprite from collapsing
	local width = math.max(math.abs(max_x - min_x), 0.1) -- Abs - Makes Negative Values to Positive.
	local height = math.max(math.abs(max_y - min_y), 0.1) -- Max - Ensures width is atleast (0.1)

	-- Apply size instead of scale
	go.set_position(center, selection_box)                      -- Applies Position
	go.set(selection_box, "size", vmath.vector3(width, height, 0)) -- Sets size based on Width, Height
end

function on_input(self, action_id, action) -- When a input is received
	if action_id == hash("touch") then     -- When action received is "touch"
		if action.pressed and not dragging then
			selection_update_start(action) -- Applies Sprite Properties at Start
		elseif action.released and dragging then
			dragging = false               -- Dragging False
			msg.post(selection_box, "disable") -- Hides Sprite
		elseif dragging then
			-- Updates Screen_to_World Variables
			-- THIS IS ONLY NEEDED IF YOU WANT TO USE SELECTION WHILE PANNING CAMERA
			rts_camera_projection = camera.get_projection(rts_camera)  -- Gets the Projection Size
			rts_camera_view = camera.get_view(rts_camera)              -- Gets the View Size
			rts_camera_inv = vmath.inv(rts_camera_projection * rts_camera_view) -- Gets the Inversion

			local worldx, worldy = screen_to_world(action.x, action.y) -- Gets Global Position from Screen
			if prev_worldx ~= worldx or prev_worldy ~= worldy then
				local current_pos = vmath.vector3(worldx, worldy, 0)   -- Stores Current Position
				selection_update_dragging(current_pos)                 -- Applies Sprite Properties during dragging
				prev_worldx = worldx                                   -- Updates prev_worldx
				prev_worldy = worldy                                   -- Updates prev_worldy
			end
		end
	end
end

Version 3 (v3) is my personal favorite! It’s fully commented and designed with performance, readability, and ease of use in mind.

-- Control_Manager.script

-- Input Variables
local dragging = false
local selection_box

-- Camera Variables
local rts_camera
local rts_camera_size
local inv_matrix -- inverse matrix cached on drag start

-- Position Variables
local start_pos = vmath.vector3()
local prev_pos = vmath.vector3()

function init(self)
	msg.post(".", "acquire_input_focus")            -- Activates to get inputs
	selection_box = msg.url("#selection_box")       -- Selection Box - Sprite Component
	msg.post(selection_box, "disable")              -- Sprite Hidden
	rts_camera = msg.url("RTSCamera#camera_component") -- Camera Component

	-- Gets Display Properties from System
	local DISPLAY_WIDTH = sys.get_config_int("display.width")
	local DISPLAY_HEIGHT = sys.get_config_int("display.height")

	-- Get Screen To World Properties
	rts_camera_size = vmath.vector3(DISPLAY_WIDTH, DISPLAY_HEIGHT, 0)
end

local function screen_to_world(x, y)
	-- Convert screen coordinates to the [-1, 1] range (Normalized Device Coordinates)
	local ndc_x = (x / rts_camera_size.x) * 2 - 1 -- X Ranges From [-1 (left) to 1 (right)]
	local ndc_y = (y / rts_camera_size.y) * 2 - 1 -- Y Ranges From [-1 (bottom) to 1 (top)]

	-- Applies the inverse camera transformation to convert NDC to world coordinates.
	local world = inv_matrix * vmath.vector4(ndc_x, ndc_y, 0, 1) -- NDC position as a 4D vector
	-- Converts homogeneous coordinates (x, y, z, w) to 3D world coordinates
	-- No effect for orthographic(2D). Depth Scaling For perspective cameras.
	return world.x / world.w, world.y / world.w -- Return X, Y
end

local function selection_update_start(action)         -- Runs once when pressed
	-- Updates Screen_to_World Variables
	local projection = camera.get_projection(rts_camera) -- Gets the Projection Size
	local view = camera.get_view(rts_camera)          -- Gets the View Size
	inv_matrix = vmath.inv(projection * view)         -- Gets the Inversion

	-- Initialize positions
	local x, y = screen_to_world(action.x, action.y) -- Gets Global Position
	start_pos.x, start_pos.y = x, y               -- Sets start_pos
	prev_pos.x, prev_pos.y = x, y                 -- Updates prev_pos

	-- Update Sprite Properties
	go.set_position(start_pos, selection_box)              -- Set Position
	go.set(selection_box, "size", vmath.vector3(0.1, 0.1, 0)) -- Set Size(0.1) in X, Y
	msg.post(selection_box, "enable")                      -- Sprite Visable
	dragging = true                                        -- Dragging True
end

local function selection_update_dragging(x, y) -- Runs when Dragging
	-- Calculate bounding box
	local min_x = math.min(start_pos.x, x)     -- Min X
	local min_y = math.min(start_pos.y, y)     -- Min Y
	local max_x = math.max(start_pos.x, x)     -- Max X
	local max_y = math.max(start_pos.y, y)     -- Max Y

	-- Calculates the center of the selection box
	local center = vmath.vector3(
		(min_x + max_x) * 0.5, -- X Direction
		(min_y + max_y) * 0.5, -- Y Direction
		0                -- Z Direction
	)

	-- Calculate width/height of the selection box from drag distance
	-- Ensures a minimum size of 0.1 to prevent the sprite from collapsing
	local size = vmath.vector3(
		math.max(max_x - min_x, 0.1), -- Width
		math.max(max_y - min_y, 0.1), -- Height
		0                       -- Z Direction
	)

	-- Set sprite properties
	go.set_position(center, selection_box) -- Sets Position
	go.set(selection_box, "size", size) -- Sets Size
end

function on_input(self, action_id, action)
	if action_id ~= hash("touch") then return end

	if action.pressed and not dragging then
		selection_update_start(action)             -- Applies Sprite Properties at Start
	elseif action.released and dragging then
		dragging = false                           -- Dragging False
		msg.post(selection_box, "disable")         -- Sprite Hidden
	elseif dragging then
		local x, y = screen_to_world(action.x, action.y) -- Gets Global Position

		-- Only update if position changed
		if math.abs(x - prev_pos.x) > 0.001 or math.abs(y - prev_pos.y) > 0.001 then
			prev_pos.x, prev_pos.y = x, y -- Updates prev_pos

			selection_update_dragging(x, y) -- Applies Sprite Properties during dragging
		end
	end
end

If you have any questions, feel free to ask below! :slightly_smiling_face:

5 Likes

Welcome!

It didn’t? What was different?

Thanks for sharing!

1 Like

Hello! For the GUI you can set it with gui.set_size(). For the Sprite, It did not have a go.set_size(). It only had a go.set_scale(). So i spent a lot of time using that until i realized that i had to use something like go.set(self.selection_box, “size”, vmath.vector3()) to change the size. I had come from Godot few weeks ago so i didn’t focus on the fact that they were different properties because Godot only had Scale.

1 Like

Hello! I stepped away from game development for almost a year, but yesterday I started working on my RTS again :grinning_face_with_smiling_eyes:

With the new updates, my selection box got bugged because the 9-slice wasn’t working properly when I first made it. Now it’s working fine, so I fixed issues with the outline width and the shaking.

I’ve also turned it into a Lua script, and it works perfectly now. The new selection box image and the Lua script are attached.

local SelectionBox = {}
SelectionBox.__index = SelectionBox

function SelectionBox:new(selection_box_url, camera_url, display_width, display_height)
    local obj = {
        -- Configuration
        selection_box = selection_box_url,
        camera = camera_url,
        display_width = display_width,
        display_height = display_height,

        -- State
        inv_matrix = nil,
        camera_size = vmath.vector3(display_width, display_height, 0)
    }

    return setmetatable(obj, SelectionBox)
end

function SelectionBox:screen_to_world(x, y)
    -- Convert screen coordinates to the [-1, 1] range (Normalized Device Coordinates)
    local ndc_x = (x / self.camera_size.x) * 2 - 1 -- X Ranges From [-1 (left) to 1 (right)]
    local ndc_y = (y / self.camera_size.y) * 2 - 1 -- Y Ranges From [-1 (bottom) to 1 (top)]

    -- Applies the inverse camera transformation to convert NDC to world coordinates.
    local world = self.inv_matrix * vmath.vector4(ndc_x, ndc_y, 0, 1) -- NDC position as a 4D vector
    -- Converts homogeneous coordinates (x, y, z, w) to 3D world coordinates
    -- No effect for orthographic(2D). Depth Scaling For perspective cameras.
    return world.x / world.w, world.y / world.w -- Return X, Y
end

function SelectionBox:get_camera_properties()             -- Run Once Tap
    -- Update camera matrices
    local projection = camera.get_projection(self.camera) -- Gets the Projection Size
    local view = camera.get_view(self.camera)             -- Gets the View Size
    self.inv_matrix = vmath.inv(projection * view)        -- Gets the Inversion
end

function SelectionBox:update_box(start_pos, action) -- Runs when SelectionBox Dragging
    -- Snap the anchor starting point rigidly to nearest integer to eliminate shaking forever.
    local start_x = math.floor(start_pos.x + 0.5)
    local start_y = math.floor(start_pos.y + 0.5)

    -- Calculate width/height in rigid integer steps stemming exactly from the snapped anchor.
    -- Ensures a minimum size of 6.0 to prevent the sprite from collapsing.
    local width = math.max(math.floor(math.abs(action.x - start_x) + 0.5), 6.0)
    local height = math.max(math.floor(math.abs(action.y - start_y) + 0.5), 6.0)

    -- Force the size to EVEN integers so the physical center (width * 0.5) is a strict whole number.
    -- This entirely prevents fractional grid-coordinates from generating sub-pixel shimmering/shaking.
    if width % 2 ~= 0 then width = width + 1 end
    if height % 2 ~= 0 then height = height + 1 end
    
    local size = vmath.vector3(width, height, 0)

    -- Determine the direction of the drag to place the center accurately
    local sign_x = (action.x >= start_x) and 1 or -1
    local sign_y = (action.y >= start_y) and 1 or -1

    -- Calculate the exact center mathematically so both outer edges land perfectly on integer boundaries!
    local center = vmath.vector3(
        start_x + (width * 0.5 * sign_x),
        start_y + (height * 0.5 * sign_y),
        0
    )

    -- Set sprite properties
    go.set_position(center, self.selection_box) -- Sets Position
    go.set(self.selection_box, "size", size)    -- Sets Size
end

-- Helper function to conditionally check if any point is inside calculated bounds (Can be used externally)
function SelectionBox.is_point_in_bounds(point, min_x, min_y, max_x, max_y)
    return point.x >= min_x and point.x <= max_x and 
           point.y >= min_y and point.y <= max_y
end

-- Function to calculate the mathematical boundaries of a selection.
-- This keeps the SelectionBox script completely decoupled from specific unit code.
function SelectionBox:get_selection_bounds(start_pos, end_pos)
    if start_pos == end_pos then return nil end
    
    local min_x = math.min(start_pos.x, end_pos.x)
    local min_y = math.min(start_pos.y, end_pos.y)
    local max_x = math.max(start_pos.x, end_pos.x)
    local max_y = math.max(start_pos.y, end_pos.y)
    
    return min_x, min_y, max_x, max_y
end

return SelectionBox

Selection Box_9slice

1 Like

Thanks for sharing. How’s your game coming along?

1 Like

Going great, just a bit slow :grinning_face_with_smiling_eyes: I’ve mostly finished terrain mixing and unit coding. Next up is sprites—starting with units. Art is the hardest part for me since it’s so time-consuming.

1 Like

Good luck! Share it when it’s ready.

1 Like