Try   HackMD

Learning Lua by Building a Roblox Guardian

Last weekend, my girlfriend and I were immersed in a Roblox gaming session when she presented an intriguing challenge: "Wouldn't it be cool if we could create something that follows us around in the game?" This question sparked my curiosity and presented the perfect opportunity to dive into Lua programming and Roblox scripting. I'm also currently watching Mythic Quest new season which is a sitcom about a group of people who work at a gaming company. Also you may be familiar that vibecoding games with Three.js is very popular these days on X.

In this tutorial, I'll walk you through how we created a companion cube that loyally follows your character throughout any Roblox world and serves as a digital guardian.

The History and Purpose of Lua

Before we dive into the code, let's understand what makes Lua special. Lua was created in 1993 at PUC-Rio (Pontifical Catholic University of Rio de Janeiro) in Brazil by Roberto Ierusalimschy, Luiz Henrique de Figueiredo, and Waldemar Celes. It was developed as a solution for a partnership between the university and Petrobras, the Brazilian oil company, which needed portable and customizable tools.

A Scripting Language by Design

Lua was never intended to replace system programming languages like C or C++. Instead, it was designed as a scripting language to extend applications. Think of it this way: the core of your system (the "kernel") is written in a compiled language for speed and efficiency, while Lua provides scripting capabilities on top of that system.

This approach offers several advantages:

  • Non-programmers can customize applications without touching the core code
  • The core system remains protected from scripting errors
  • Complex, performance-intensive tasks are handled by the system language, while high-level logic is managed through Lua

Lua gained international recognition around 1994-1996 through publications in Dr. Dobb's Journal. Its popularity exploded in 1997 when LucasArts adopted it for the game Grim Fandango, replacing their internal scripting system. The gaming industry quickly embraced Lua for its simplicity, portability, and small footprint - at just around 9,000 lines of C code for the entire language.

Today, Lua powers scripting in numerous popular games including Angry Birds, World of Warcraft, and Roblox. It's also used in applications like Adobe Lightroom, security tools like Wireshark and Snort, and even embedded systems and IoT devices.

Key Features of Lua

  • Portability: Written in standard ANSI C, Lua runs on virtually any device with a C compiler
  • Small size: The entire implementation is compact enough for resource-constrained environments
  • Simple syntax: Easy to learn, even for non-programmers
  • Extensibility: Designed to be embedded in other applications

In the context of Roblox, Lua serves as the scripting layer on top of the Roblox engine. This allows developers and players to create game logic without having to understand or modify the underlying engine code.

Lua's Unique Approach to Programming

What truly sets Lua apart from other scripting languages is its elegant simplicity combined with powerful features:

  • Tables as the universal data structure: Unlike most languages that have arrays, dictionaries, objects, etc., Lua uses a single construct - tables - for everything. A table can act as an array, a dictionary, an object, or a combination of these.

    ​​-- Table as an array
    ​​local fruits = {"apple", "banana", "orange"}
    ​​print(fruits[2])  -- Outputs: banana (Lua arrays are 1-indexed)
    ​​
    ​​-- Table as a dictionary
    ​​local person = {name = "Alex", age = 25, occupation = "Developer"}
    ​​print(person.name)  -- Outputs: Alex
    ​​
    ​​-- Table as an object with methods
    ​​local calculator = {
    ​​  value = 0,
    ​​  add = function(self, num) self.value = self.value + num end,
    ​​  subtract = function(self, num) self.value = self.value - num end
    ​​}
    ​​calculator:add(5)  -- The colon syntax automatically passes 'self'
    ​​print(calculator.value)  -- Outputs: 5
    
  • First-class functions: Functions in Lua are values that can be stored in variables, passed as arguments, and returned from other functions.

  • Coroutines: Lua supports cooperative multitasking through coroutines, allowing you to pause and resume execution at specific points.

The Problem: Creating a Following Companion

While playing Roblox, our requirements are to create a companion object:

  • Always follow her character
  • Maintain a consistent distance
  • Move smoothly and naturally
  • Look visually appealing
  • Serve as a digital guardian that could look after her character when I'm not around

After some research and experimentation, I realized this could be accomplished with Roblox's physics engine using BodyPosition and BodyGyro—powerful tools that let you control how objects move and orient themselves in the Roblox world.

Understanding Roblox's Physics Constraints

Before diving into the code, it's important to understand the physics tools we'll be using:

  • BodyPosition: A physics constraint that applies a force to move a part toward a target position

    • MaxForce: Determines how much force can be applied (higher values = faster movement)
    • Position: The target position the part will try to reach
    • P: Proportional gain - affects how aggressively the part moves toward the target (higher = more "springy")
    • D: Damping - affects how quickly oscillations are reduced (higher = less overshooting)
  • BodyGyro: Controls the orientation of a part

    • MaxTorque: How much rotational force can be applied
    • CFrame: The target orientation
    • P and D: Similar to BodyPosition, affect the rotation behavior

⚠️ Physics Parameters Can Be Tricky
When adjusting physics parameters like MaxForce and P values, small changes can lead to dramatically different behavior. I once increased the MaxForce value too much and watched in horror as my companion cube launched my character into the stratosphere! Start with conservative values and increase gradually.

These physics constraints work based on PID controllers (Proportional-Integral-Derivative), which is why they have P, I, and D properties. For our companion follower, we'll mainly adjust the P value to fine-tune the movement.

Solution Approach

Here's how we'll create our companion follower:

  1. Create a companion cube part
  2. Add physical properties to make it follow properly
  3. Create a script to control its movement
  4. Apply the proper physics forces
  5. Test and refine

Step 1: Setting Up Your Companion Object

Note: This tutorial uses Lua 5.1, which is the version that Roblox Studio implements. Some features may not work in other Lua versions.

First, we need to create the object that will follow our character:

-- Create a simple cube part
local companion_cube = Instance.new("Part")  -- Creates a new Part instance
companion_cube.Name = "CompanionCube"  -- Gives the part a descriptive name
companion_cube.Parent = workspace  -- Places the part in the workspace (game world)
companion_cube.Size = Vector3.new(2, 2, 2)  -- Sets the size to 2x2x2 studs
companion_cube.Position = Vector3.new(0, 10, 0)  -- Starting position above the terrain
companion_cube.Anchored = false  -- Allows physics to affect the part
companion_cube.CanCollide = false  -- Prevents collision with other objects
companion_cube.Massless = true  -- Makes it ignore physics mass calculations

For our project, I decided to recreate the iconic companion cube from Portal. To give it that distinctive look, you can add textures using decals:

-- Add textures to each face (simplified example)
local faces = {"Front", "Back", "Top", "Bottom", "Left", "Right"}  -- Define all cube faces
for _, face in pairs(faces) do  -- Loop through each face
  local decal = Instance.new("Decal")  -- Create a new decal for the current face
  decal.Face = Enum.NormalId[face]  -- Set which face the decal will cover
  decal.Texture = "rbxassetid://1234567"  -- Replace with actual texture ID from Roblox catalog
  decal.Parent = companion_cube  -- Attach the decal to our companion cube
end

⚠️ Naming Conventions Matter

When working with complex scripts that might grow over time, establishing clear naming conventions from the start will save you hours of debugging later. I use prefixes like "companion" for all objects related to our follower to make them easily identifiable in the Explorer panel. Also will stick to snake_case convention for variable names.

Creating a More Complex Shape

While a simple cube works well, you might want to create a more complex shape. You can use Roblox's built-in parts or create a model from multiple parts. For our guardian companion, adding visual elements that suggest protection can enhance its purpose:

-- Creating a compound shape (example: robot companion)
local robot = Instance.new("Model")
robot.Name = "GuardianCompanion"
robot.Parent = workspace

-- Create the body
local body = Instance.new("Part")
body.Name = "Body"
body.Size = Vector3.new(2, 3, 1.5)
body.Position = Vector3.new(0, 10, 0)
body.Parent = robot

-- Create the head
local head = Instance.new("Part")
head.Name = "Head"
head.Shape = Enum.PartType.Ball
head.Size = Vector3.new(1.5, 1.5, 1.5)
head.Position = Vector3.new(0, 12, 0)
head.Parent = robot

-- Create a weld to connect the head to the body
local weld = Instance.new("WeldConstraint")
weld.Part0 = body
weld.Part1 = head
weld.Parent = body

-- Set the primary part (important for models)
robot.PrimaryPart = body

Step 2: Creating the Follow Script

Now for the fun part! We need to create a script that makes our companion cube follow the character. Let's add a script inside the companion cube:

Performance Note: While Lua in Roblox is optimized for game development, infinite loops can significantly impact performance if not implemented correctly. The script below uses a while true do pattern with a wait() call to prevent the script from consuming excessive CPU resources. Without the wait(), this would create a "tight loop" that could freeze or crash the game.

-- Place this script inside your companion cube
local follower_part = script.Parent  -- References the part this script is inside
local master_name = "YourPlayerName"  -- Change this to your player's username

-- Create physics objects needed for movement
local follower_position = Instance.new("BodyPosition")  -- Creates a force that moves the part to a target position
follower_position.Parent = follower_part  -- Attaches the BodyPosition to our companion cube
follower_position.MaxForce = Vector3.new(30000, 30000, 30000)  -- How much force it can use to reach target (higher = faster)
follower_position.P = 3000  -- Proportional term - affects how aggressively it moves (higher = more "springy")

local follower_body_gyro = Instance.new("BodyGyro")  -- Creates a force that rotates the part to a target orientation
follower_body_gyro.Parent = follower_part  -- Attaches the BodyGyro to our cube
follower_body_gyro.MaxTorque = Vector3.new(900000, 900000, 900000)  -- How much rotational force to apply
follower_body_gyro.P = 3000  -- Affects rotation responsiveness

-- Define the offset position for the follower
local follow_position = Vector3.new(0, 5, 0)  -- This will make it float 5 studs above the player

Fine-Tuning the Physics Parameters

The default values work well, but here's how different physics parameters affect your companion's behavior:

Parameter Low Value Effect High Value Effect Recommended Range
MaxForce Slow movement, struggles against gravity Fast, sometimes jerky movement 10000-50000
P Value Sluggish, slow to respond Springy, may overshoot 1000-5000
D Value Bouncy, oscillates Rigid, dampens quickly 500-1000

You can experiment with these values to get different movement styles:

-- For a slower, heavier companion
follower_position.MaxForce = Vector3.new(15000, 15000, 15000)  -- Lower force = slower movement
follower_position.P = 1500  -- Lower P value = less responsive, smoother following
follower_position.D = 800  -- Higher D value = more dampening, less overshooting

-- For a quick, agile companion
follower_position.MaxForce = Vector3.new(45000, 45000, 45000)  -- Higher force = faster movement
follower_position.P = 4500  -- Higher P value = more responsive, quicker to target
follower_position.D = 600  -- Lower D value = less dampening, more bouncy

Step 3: Adding the Main Logic Loop

Here's where we add the logic that continuously updates our companion's position:

-- Main movement loop
while true do  -- Creates an infinite loop that runs until the script is stopped
  -- Wait a frame to prevent script overload
  wait()  -- Yields the thread to prevent excessive CPU usage (crucial for performance)
  
  -- Find the master player
  local master = workspace:WaitForChild(master_name)  -- Waits until the named player appears in workspace
  local player = game.Players:FindFirstChild(master_name)  -- Gets the player object if it exists
  
  if master and master:FindFirstChild("HumanoidRootPart") then  -- Check if player character is fully loaded
    -- Get the master's position
    local master_position = master.HumanoidRootPart.Position  -- The character's current position
    
    -- Define where the follower should stop
    local stop_at_position = (follower_part.Position - master_position).magnitude  -- Calculate distance to player
    
    -- Update the follower's target position
    follower_position.Position = master_position + follow_position  -- Set target to offset from player
    
    -- Update the follower's orientation
    follower_body_gyro.CFrame = master.HumanoidRootPart.CFrame  -- Match player's facing direction
    
    -- Check master's health for guardian behavior
    local humanoid = master:FindFirstChild("Humanoid")  -- Get the character's Humanoid component
    if humanoid and humanoid.Health < humanoid.MaxHealth * 0.5 then  -- Check if health is below 50%
      -- Health is below 50%, position the guardian in front to protect
      local look_vector = master.HumanoidRootPart.CFrame.LookVector  -- Get the direction player is facing
      follower_position.Position = master_position + (look_vector * 3) + Vector3.new(0, 3, 0)  -- Position in front
    end
  end
end

Adding Distance-Based Behavior

Let's enhance the script to make our companion behave differently based on how far it is from the player and act as a guardian when needed:

-- Main movement loop with distance-based behavior and guardian features
while true do
  wait()  -- Yield to prevent excessive CPU usage
  
  local master = workspace:WaitForChild(master_name)  -- Find the player in the world
  
  if master and master:FindFirstChild("HumanoidRootPart") then  -- Make sure character is fully loaded
    local master_position = master.HumanoidRootPart.Position  -- Get current player position
    local distance = (follower_part.Position - master_position).magnitude  -- Calculate distance to player
    local humanoid = master:FindFirstChild("Humanoid")  -- Get the player's Humanoid component
    
    -- Check for nearby threats (potentially expensive operation - only done once per frame)
    local nearby_threats = false
    for _, part in pairs(workspace:GetChildren()) do  -- Loop through all objects in workspace
      if part:IsA("Model") and part:FindFirstChild("Humanoid") and part ~= master then  -- Find characters that aren't the player
        local threat_distance = (part:GetPrimaryPartCFrame().Position - master_position).magnitude
        if threat_distance < 10 then  -- Check if they're within 10 studs
          nearby_threats = true
          break  -- Exit loop early once we find any threat (optimization)
        end
      end
    end
    
    -- Adjust follow behavior based on distance and threats
    if nearby_threats and humanoid then
      -- Guardian mode - position between player and threat
      local threats = {}
      for _, part in pairs(workspace:GetChildren()) do  -- This loop could be optimized further
        if part:IsA("Model") and part:FindFirstChild("Humanoid") and part ~= master then
          local threat_distance = (part:GetPrimaryPartCFrame().Position - master_position).magnitude
          if threat_distance < 10 then
            table.insert(threats, part:GetPrimaryPartCFrame().Position)
          end
        end
      end
      
      if #threats > 0 then
        -- Position between player and closest threat
        local threat_pos = threats[1]
        local guardian_pos = master_position + ((threat_pos - master_position).unit * -3) + Vector3.new(0, 3, 0)
        follower_position.Position = guardian_pos
        
        -- Face toward the threat for protective stance
        local look_direction = (threat_pos - follower_part.Position).unit
        local look_c_frame = CFrame.new(follower_part.Position, follower_part.Position + look_direction)
        follower_body_gyro.CFrame = look_c_frame
      end
    elseif distance > 30 then
      -- Too far away - teleport closer (for when player teleports or respawns)
      follower_part.CFrame = CFrame.new(master_position + follow_position)
      wait(0.5)  -- Give time to stabilize after teleport
    elseif distance > 15 then
      -- Far but catching up - move faster
      follower_position.MaxForce = Vector3.new(50000, 50000, 50000)  -- Temporarily increase force
      follower_position.Position = master_position + follow_position
    else
      -- Close enough - normal follow behavior
      follower_position.MaxForce = Vector3.new(30000, 30000, 30000)  -- Reset to normal force
      follower_position.Position = master_position + follow_position
      
      -- Regular orientation - look where the player is looking
      follower_body_gyro.CFrame = master.HumanoidRootPart.CFrame
    end
    
    -- Guardian behavior when player's health is low
    if humanoid and humanoid.Health < humanoid.MaxHealth * 0.5 then
      -- Flash a warning light when health is low
      if not warning_light then
        warning_light = Instance.new("PointLight")
        warning_light.Color = Color3.fromRGB(255, 0, 0)
        warning_light.Range = 10
        warning_light.Parent = follower_part
      end
      
      -- Pulse the light (using tick() for timing)
      if tick() % 1 < 0.5 then
        warning_light.Brightness = 1
      else
        warning_light.Brightness = 0.3
      end
    elseif warning_light then
      -- Remove warning light when health recovers
      warning_light:Destroy()
      warning_light = nil
    end
  end
end

Step 4: Making it More Flexible

Instead of hardcoding the master's name, we can add a StringValue object to make our script more flexible:

-- In your companion cube, add a StringValue
local master_name_value = Instance.new("StringValue")
master_name_value.Name = "MasterName"
master_name_value.Value = "YourPlayerName" -- Change this to your player's name
master_name_value.Parent = follower_part

-- Then in your script, replace the hardcoded name with:
local master_name = follower_part:WaitForChild("MasterName").Value

This way, you can easily change who the cube follows by modifying the StringValue without editing the script.

Creating a Companion Configuration System

Let's go a step further and create a complete configuration system that lets players customize their companions:

-- Create a configuration module
local CompanionConfig = {}

-- Set up the companion with default or custom settings
function CompanionConfig.setup(companion, settings)
    settings = settings or {}
    
    -- Create configuration values with defaults
    local config = {
        follow_height = settings.follow_height or 5,
        follow_distance = settings.follow_distance or 3,
        follow_speed = settings.follow_speed or 30000,
        rotation_speed = settings.rotation_speed or 3000,
        enable_particles = settings.enable_particles ~= false, -- Default to true
        enable_interaction = settings.enable_interaction ~= false, -- Default to true
        max_distance = settings.max_distance or 30,
        teleport_threshold = settings.teleport_threshold or 50
    }
    
    -- Store configuration on the companion
    for name, value in pairs(config) do
        local config_value
        
        -- Choose the appropriate value type
        if type(value) == "number" then
            config_value = Instance.new("NumberValue")
        elseif type(value) == "boolean" then
            config_value = Instance.new("BoolValue")
        else
            config_value = Instance.new("StringValue")
        end
        
        config_value.Name = name
        config_value.Value = value
        config_value.Parent = companion
    end
    
    return config
end

return CompanionConfig

Then in your main script:

-- Using the configuration system
local CompanionConfig = require(game.ReplicatedStorage.CompanionConfig)

-- Setup companion with custom settings
local config = CompanionConfig.setup(follower_part, {
    follow_height = 4,
    follow_distance = 5,
    follow_speed = 40000,
    enable_particles = true
})

-- Use the configuration in your script
follower_position.MaxForce = Vector3.new(config.follow_speed, config.follow_speed, config.follow_speed)
follow_position = Vector3.new(0, config.follow_height, -config.follow_distance)

Debugging Your Follower Script

When we first tested our companion cube, it behaved erratically—sometimes flying across the map or getting stuck. Here's how we debugged it:

  1. Add breakpoints to your script to see what's happening line-by-line
  2. Inspect variable values by hovering over them in debug mode
  3. Step through the script execution using F10 (Step Over) to see when issues occur
  4. Check for errors in the output window

You can set breakpoints by clicking next to the line number in Roblox Studio's script editor:

-- Add this code to help debug
print("Master position: " .. tostring(master_position))
print("Follower target: " .. tostring(follower_position.Position))

Advanced Debugging Techniques

For more complex debugging, create a visual debugging system:

-- Visual debugging system
local debug_enabled = true

-- Create debug visualization
local function create_debug_part(position, color)
    if not debug_enabled then return end
    
    local part = Instance.new("Part")
    part.Anchored = true
    part.CanCollide = false
    part.Size = Vector3.new(0.5, 0.5, 0.5)
    part.Position = position
    part.Color = color or Color3.fromRGB(255, 0, 0)
    part.Transparency = 0.5
    part.Parent = workspace.DebugFolder
    
    -- Auto-cleanup after 2 seconds
    game:GetService("Debris"):AddItem(part, 2)
    
    return part
end

-- Usage in your main loop
while true do
    wait()
    
    -- Show the target position with a red marker
    local target_pos = master_position + follow_position
    create_debug_part(target_pos, Color3.fromRGB(255, 0, 0))
    
    -- Show a line representing the path
    local dist_part = create_debug_part(follower_part.Position, Color3.fromRGB(0, 0, 255))
    dist_part.Size = Vector3.new(0.2, 0.2, (follower_part.Position - target_pos).magnitude)
    dist_part.CFrame = CFrame.lookAt(
        follower_part.Position, 
        target_pos
    ) * CFrame.new(0, 0, -dist_part.Size.Z/2)
end

Enhancing Your Companion Cube

After the basic functionality was working, I made some improvements:

Adding Visual Effects

-- Add a particle emitter for a cool trail effect
local particle_emitter = Instance.new("ParticleEmitter")
particle_emitter.Parent = follower_part
particle_emitter.Texture = "rbxassetid://7851784603" -- Sparkle texture
particle_emitter.Rate = 10
particle_emitter.Speed = NumberRange.new(1, 3)
particle_emitter.Lifetime = NumberRange.new(1, 2)

Creating Advanced Particle Effects

For more impressive visual effects, combine multiple particle emitters:

-- Function to create advanced particle effects
local function create_advanced_particles(part)
    -- Main trail effect
    local trail_emitter = Instance.new("ParticleEmitter")
    trail_emitter.Parent = part
    trail_emitter.Texture = "rbxassetid://7851784603"
    trail_emitter.Rate = 15
    trail_emitter.Speed = NumberRange.new(0.5, 1.5)
    trail_emitter.Lifetime = NumberRange.new(1, 1.5)
    trail_emitter.Transparency = NumberSequence.new({
        NumberSequenceKeypoint.new(0, 0.2),
        NumberSequenceKeypoint.new(0.8, 0.5),
        NumberSequenceKeypoint.new(1, 1)
    })
    trail_emitter.Size = NumberSequence.new({
        NumberSequenceKeypoint.new(0, 0.3),
        NumberSequenceKeypoint.new(1, 0)
    })
    trail_emitter.Color = ColorSequence.new(Color3.fromRGB(255, 255, 255), Color3.fromRGB(170, 170, 255))
    
    -- Occasional sparkle effect
    local sparkle_emitter = Instance.new("ParticleEmitter")
    sparkle_emitter.Parent = part
    sparkle_emitter.Texture = "rbxassetid://7851784603"
    sparkle_emitter.Rate = 2
    sparkle_emitter.Speed = NumberRange.new(3, 6)
    sparkle_emitter.Lifetime = NumberRange.new(0.5, 1)
    sparkle_emitter.Size = NumberSequence.new({
        NumberSequenceKeypoint.new(0, 0.1),
        NumberSequenceKeypoint.new(0.5, 0.5),
        NumberSequenceKeypoint.new(1, 0)
    })
    sparkle_emitter.Color = ColorSequence.new(Color3.fromRGB(255, 255, 150))
    
    -- Add a point light for a glow effect
    local light = Instance.new("PointLight")
    light.Parent = part
    light.Range = 8
    light.Brightness = 0.5
    light.Color = Color3.fromRGB(200, 200, 255)
    
    return {trail_emitter, sparkle_emitter, light}
end

-- Use the function to add effects to your companion
local effects = create_advanced_particles(follower_part)

Making it Interact with the Environment

-- Add a touched event for interaction
follower_part.Touched:Connect(function(touched_part)
    -- Ignore character parts to prevent constant triggering
    if touched_part.Parent:FindFirstChild("Humanoid") then
        return
    end
    
    -- Make a small hop when touching objects
    follower_position.Position = follower_position.Position + Vector3.new(0, 2, 0)
    wait(0.5)
end)

Creating Context-Aware Interactions with Guardian Behavior

Let's add more intelligent interactions based on what the companion touches, emphasizing its role as a guardian:

local interaction_cooldown = false
local guardian_mode = false

follower_part.Touched:Connect(function(touched_part)
    -- Prevent multiple interactions in quick succession
    if interaction_cooldown then return end
    
    -- Check what type of object was touched
    local object_type = touched_part.Name:lower()
    
    -- Different interactions based on object type
    if touched_part.Parent:FindFirstChild("Humanoid") then
        -- It's a player or NPC
        if touched_part.Parent.Name ~= master_name then
            -- It's not the master - enter guardian mode
            local start_pos = follower_part.Position
            guardian_mode = true
            
            -- Create a protection shield effect
            local shield = Instance.new("Part")
            shield.Shape = Enum.PartType.Ball
            shield.Size = Vector3.new(6, 6, 6)
            shield.CFrame = follower_part.CFrame
            shield.Transparency = 0.7
            shield.CanCollide = false
            shield.Material = Enum.Material.ForceField
            shield.Color = Color3.fromRGB(0, 200, 255)
            shield.Parent = workspace
            
            -- Create weld
            local weld = Instance.new("WeldConstraint")
            weld.Part0 = follower_part
            weld.Part1 = shield
            weld.Parent = follower_part
            
            -- Flash shield briefly
            for i = 1, 5 do
                shield.Transparency = 0.3
                wait(0.1)
                shield.Transparency = 0.7
                wait(0.1)
            end
            
            -- Remove shield after a few seconds
            wait(3)
            shield:Destroy()
            guardian_mode = false
        end
    elseif object_type:find("water") or object_type:find("liquid") then
        -- It's water - float higher to keep player accessible
        follower_position.Position = follower_position.Position + Vector3.new(0, 4, 0)
        wait(2)
    elseif touched_part.Material == Enum.Material.Fire or object_type:find("fire") then
        -- It's fire - move away quickly and warn the player
        local away_direction = (follower_part.Position - touched_part.Position).unit
        follower_position.Position = follower_part.Position + away_direction * 10
        
        -- Create warning text above companion
        local warning = Instance.new("BillboardGui")
        warning.Size = UDim2.new(0, 100, 0, 40)
        warning.StudsOffset = Vector3.new(0, 3, 0)
        warning.Parent = follower_part
        
        local text = Instance.new("TextLabel")
        text.Size = UDim2.new(1, 0, 1, 0)
        text.BackgroundTransparency = 1
        text.TextColor3 = Color3.fromRGB(255, 0, 0)
        text.TextScaled = true
        text.Font = Enum.Font.GothamBold
        text.Parent = warning
        
        -- Remove after a few seconds
        wait(3)
        warning:Destroy()
    else
        -- Default interaction for other objects
        follower_position.Position = follower_position.Position + Vector3.new(0, 2, 0)
    end
    
    -- Set cooldown
    interaction_cooldown = true
    wait(1.5)
    interaction_cooldown = false
end)

Automation and Workflow Improvements

You can automate the creation of companion cubes with this script:

-- CompanionCubeCreator.lua
local function create_companion(player)
    local cube = Instance.new("Part")
    cube.Name = "CompanionCube_" .. player.Name
    cube.Size = Vector3.new(2, 2, 2)
    cube.Parent = workspace
    
    -- Add master name value
    local master_name = Instance.new("StringValue")
    master_name.Name = "MasterName"
    master_name.Value = player.Name
    master_name.Parent = cube
    
    -- Clone the follower script into the new cube
    local script_folder = game:GetService("ServerStorage"):WaitForChild("Scripts")
    local follower_script = script_folder:WaitForChild("FollowMaster")
    local new_script = follower_script:Clone()
    new_script.Parent = cube
    
    return cube
end

-- Give every player a companion when they join
game.Players.PlayerAdded:Connect(function(player)
    wait(2) -- Wait for character to load
    create_companion(player)
end)

Creating a Companion Selection System

Let's add a system that lets players choose from different companion types:

-- Create a module with companion templates
local CompanionTemplates = {
    cube = {
        shape = "Block",
        size = Vector3.new(2, 2, 2),
        color = Color3.fromRGB(220, 170, 255),
        properties = {
            follow_height = 5,
            follow_distance = 3,
            particle_color = Color3.fromRGB(230, 200, 255)
        }
    },
    sphere = {
        shape = "Ball",
        size = Vector3.new(2, 2, 2),
        color = Color3.fromRGB(170, 220, 255),
        properties = {
            follow_height = 6,
            follow_distance = 4,
            particle_color = Color3.fromRGB(200, 230, 255)
        }
    },
    robot = {
        is_model = true,
        model_creator = function()
            -- The robot model creation code from earlier
            local robot = Instance.new("Model")
            robot.Name = "RobotCompanion"
            
            -- Body
            local body = Instance.new("Part")
            body.Size = Vector3.new(2, 3, 1.5)
            body.Color = Color3.fromRGB(100, 100, 100)
            body.Parent = robot
            
            -- Head
            local head = Instance.new("Part")
            head.Shape = Enum.PartType.Ball
            head.Size = Vector3.new(1.5, 1.5, 1.5)
            head.Color = Color3.fromRGB(150, 150, 150)
            head.Parent = robot
            
            -- Position the head above the body
            head.Position = body.Position + Vector3.new(0, 2, 0)
            
            -- Weld them together
            local weld = Instance.new("WeldConstraint")
            weld.Part0 = body
            weld.Part1 = head
            weld.Parent = robot
            
            robot.PrimaryPart = body
            return robot
        end,
        properties = {
            follow_height = 3,
            follow_distance = 5,
            particle_color = Color3.fromRGB(200, 200, 200)
        }
    }
}

-- Function to create a companion from a template
local function create_companion_from_template(player, template_name)
    local template = CompanionTemplates[template_name] or CompanionTemplates.cube
    local companion
    
    if template.is_model then
        companion = template.model_creator()
        companion.Parent = workspace
    else
        companion = Instance.new("Part")
        companion.Shape = Enum.PartType[template.shape]
        companion.Size = template.size
        companion.Color = template.color
        companion.Parent = workspace
    end
    
    companion.Name = template_name .. "Companion_" .. player.Name
    
    -- Add configuration based on the template
    local ConfigModule = require(game.ReplicatedStorage.CompanionConfig)
    ConfigModule.setup(companion, template.properties)
    
    -- Add master name
    local master_name = Instance.new("StringValue")
    master_name.Name = "MasterName"
    master_name.Value = player.Name
    master_name.Parent = companion
    
    -- Add the script
    local script_folder = game:GetService("ServerStorage"):WaitForChild("Scripts")
    local follower_script = script_folder:WaitForChild("FollowMaster")
    local new_script = follower_script:Clone()
    new_script.Parent = companion
    
    return companion
end

-- Example of a companion selection GUI
local function create_companion_selection_gui(player)
    local screen_gui = Instance.new("ScreenGui")
    screen_gui.Name = "CompanionSelector"
    screen_gui.Parent = player.PlayerGui
    
    local frame = Instance.new("Frame")
    frame.Size = UDim2.new(0.4, 0, 0.6, 0)
    frame.Position = UDim2.new(0.3, 0, 0.2, 0)
    frame.BackgroundColor3 = Color3.fromRGB(40, 40, 40)
    frame.BackgroundTransparency = 0.2
    frame.BorderSizePixel = 0
    frame.Parent = screen_gui
    
    local title = Instance.new("TextLabel")
    title.Size = UDim2.new(1, 0, 0.1, 0)
    title.Text = "Choose Your Companion"
    title.TextColor3 = Color3.fromRGB(255, 255, 255)
    title.BackgroundTransparency = 1
    title.Font = Enum.Font.GothamBold
    title.TextSize = 24
    title.Parent = frame
    
    local buttons_layout = Instance.new("UIListLayout")
    buttons_layout.Padding = UDim.new(0.02, 0)
    buttons_layout.FillDirection = Enum.FillDirection.Vertical
    buttons_layout.HorizontalAlignment = Enum.HorizontalAlignment.Center
    buttons_layout.VerticalAlignment = Enum.VerticalAlignment.Center
    
    local buttons_container = Instance.new("Frame")
    buttons_container.Size = UDim2.new(0.8, 0, 0.8, 0)
    buttons_container.Position = UDim2.new(0.1, 0, 0.15, 0)
    buttons_container.BackgroundTransparency = 1
    buttons_container.Parent = frame
    buttons_layout.Parent = buttons_container
    
    -- Create buttons for each companion type
    for name, _ in pairs(CompanionTemplates) do
        local button = Instance.new("TextButton")
        button.Size = UDim2.new(0.8, 0, 0.2, 0)
        button.Text = name:sub(1,1):upper() .. name:sub(2) .. " Companion"
        button.BackgroundColor3 = Color3.fromRGB(60, 60, 60)
        button.TextColor3 = Color3.fromRGB(255, 255, 255)
        button.Font = Enum.Font.GothamSemibold
        button.TextSize = 18
        button.Parent = buttons_container
        
        -- Button click handler
        button.MouseButton1Click:Connect(function()
            -- Remove any existing companions
            for _, obj in pairs(workspace:GetChildren()) do
                if obj.Name:find("Companion_" .. player.Name) then
                    obj:Destroy()
                end
            end
            
            -- Create the new companion
            create_companion_from_template(player, name)
            
            -- Hide the GUI
            screen_gui.Enabled = false
        end)
    end
    
    return screen_gui
end

-- Show the selection GUI when a player joins
game.Players.PlayerAdded:Connect(function(player)
    wait(3) -- Wait for character to load and player to be ready
    create_companion_selection_gui(player)
end)

Performance Optimization

When your game has many players, each with their own companion, performance can become an issue. Here are some optimization techniques:

Important Performance Consideration: In Lua 5.1 (which Roblox uses), infinite while true do loops with wait() calls have certain inefficiencies. The Roblox engine provides RunService events that offer better performance, more consistent timing, and lower overall CPU usage.

-- Performance optimization for companion scripts
local RunService = game:GetService("RunService")  -- Roblox service that provides timing events

-- Store the last calculated distance to avoid unnecessary updates
local last_distance = nil  -- Will track the previous distance to player

-- Use heartbeat instead of while-wait loops
RunService.Heartbeat:Connect(function(delta_time)  -- Runs once per frame, right before rendering
  -- Skip if no player is found (avoids errors and wasted computation)
  if not workspace:FindFirstChild(master_name) then return end
  
  local master = workspace[master_name]
  if not master:FindFirstChild("HumanoidRootPart") then return end
  
  -- Get the target position
  local master_pos = master.HumanoidRootPart.Position
  
  -- Calculate distance to player
  local distance = (follower_part.Position - master_pos).magnitude
  
  -- Only update if the distance has changed significantly
  -- This optimization reduces physics calculations when positions are stable
  if not last_distance or math.abs(distance - last_distance) > 0.5 then
    follower_position.Position = master_pos + follow_position
    follower_body_gyro.CFrame = master.HumanoidRootPart.CFrame
    last_distance = distance
  end
end)

Additional Performance Tips for Lua in Roblox

When working with Lua in Roblox, especially for companions that need to follow many players, consider these additional optimizations:

  1. Throttle expensive operations:

    ​​​-- Check for threats less frequently (once per second instead of every frame)
    ​​​local last_threat_check = 0
    ​​​
    ​​​RunService.Heartbeat:Connect(function(delta_time)
    ​​​  local current_time = tick()
    ​​​  
    ​​​  -- Only check for threats once per second
    ​​​  if current_time - last_threat_check >= 1 then
    ​​​    -- Perform threat detection here
    ​​​    last_threat_check = current_time
    ​​​  end
    ​​​  
    ​​​  -- Update position every frame (more responsive)
    ​​​  follower_position.Position = master_pos + follow_position
    ​​​end)
    
  2. Cache frequently accessed values:

    ​​​-- Cache player's humanoid to avoid repeated FindFirstChild calls
    ​​​local player_humanoid = nil
    ​​​
    ​​​if master and not player_humanoid then
    ​​​  player_humanoid = master:FindFirstChild("Humanoid")
    ​​​end
    ​​​
    ​​​-- Now use player_humanoid directly
    ​​​if player_humanoid and player_humanoid.Health < player_humanoid.MaxHealth * 0.5 then
    ​​​  -- Guardian behavior
    ​​​end
    
  3. Use spatial partitioning for threat detection:

    ​​​-- Only check objects that are already reasonably close
    ​​​for _, part in pairs(workspace:GetPartBoundsInRadius(master_position, 20)) do
    ​​​  -- This is more efficient than checking every object in workspace
    ​​​  local model = part:FindFirstAncestorOfClass("Model")
    ​​​  if model and model:FindFirstChild("Humanoid") and model ~= master then
    ​​​    -- It's a character that's not the player
    ​​​    local threat_distance = (model:GetPrimaryPartCFrame().Position - master_position).magnitude
    ​​​    if threat_distance < 10 then
    ​​​      nearby_threats = true
    ​​​      break
    ​​​    end
    ​​​  end
    ​​​end
    

Conclusion

Throughout this tutorial, we've explored the process of creating a companion cube follower in Roblox using Lua. What started as a simple idea—"wouldn't it be cool if something followed us around in the game?"—evolved into a deep dive into Roblox's physics system, Lua programming, and creative problem-solving to create a digital guardian.

The guardian companion doesn't just follow its master around; it actively protects them when threats are near, alerts when health is low, and even creates protective barriers when needed. These behaviors transform a simple follower into a truly useful companion that provides both practical assistance and peace of mind when you can't be there to help your friend directly.

There are several advanced topics that could enhance your guardian companion further. Implementing pathfinding would allow for more intelligent navigation around obstacles in the game world. Adding animations would give your creations more personality and visual appeal through fluid movement and expressive behaviors. Custom UI development enables deeper customization options for players to configure their companions. Expanding the AI behavior allows your guardian to recognize and intelligently respond to different types of threats. Finally, implementing persistence would allow the guardian to remain active and protective even when its creator is offline.