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! 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:

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!
Sprite Settings to use:
Sprite PNG to use:
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!