Skip to content

Interactions

Berke edited this page Oct 10, 2026 · 1 revision

09 - Interaction

Two useful engine features for interaction:

  • Game.Raycast(origin, direction, length, ignoredEntityID, requireCollider) shoots a ray and returns nil on a miss, or a table describing what it hit:

    Key Meaning
    type "Entity", "Wall", "SectorFloor", or "SectorCeiling"
    distance How far away the hit was
    position World position of the hit (Vector3)
    entity, entityID The Entity that was hit (nil / an invalid ID if none)
    wall, wallID The Wall that was hit
    sector, sectorID The Sector involved

    With requireCollider = true only entities that have an active, non-trigger Collider can be hit. With false any entity can be hit, using a box the size of its transform's scale. Passing the shooter's own id as ignoredEntityID stops the ray from hitting them.

  • camera:Raycast(length, requireCollider) is the same ray, already aimed for you: it starts at the camera's eyes, goes through the middle of the screen, and ignores the camera's own entity. It returns the same table. Both scripts below use it.

  • Any script can be called by another script (someScript:FunctionName()), which lets you build a tiny "interface" system: anything that defines Interact can be used.

An Entity's transform.position is at its feet. The player's eyes (and camera) sit playerController.eyeHeight above that. camera:Raycast already starts there (camera:GetEyePosition()), so you only need the eye height when building a ray yourself with Game.Raycast.


Interactor: look at it and press E

Attach to: the player (needs a Camera).

Casts a ray from the player's eyes every frame. If the entity it hits has a script that defines an Interact function, pressing the key calls it. An optional UI label shows a prompt such as [E] Pull lever.

-- Scripts/Interaction/Interactor.lua (entity script)
public number range = 48
public Key interactKey = Key.E
public Entity promptLabel = nil

local camera, label

function Start()
    camera = entity.camera

    if promptLabel ~= nil then label = promptLabel.uiText end

    if camera == nil then
        Debug.LogError("Interactor: " .. entity.name .. " needs a Camera")
    end
end

local function SetPrompt(text)
    if label ~= nil and label.text ~= text then label.text = text end
end

-- Looks for a script on `entity` that defines Interact(). If there is one, optionally uses it,
-- and returns the prompt text to show. Returns nil if nothing there can be interacted with.
local function Examine(target, use)
    local scripts = target:GetScripts()

    for i = 1, #scripts do
        local script = scripts[i]

        if type(script.Interact) == "function" then
            if use then script:Interact(entity) end   -- `entity` is the player (this script's Entity)

            -- The target script can provide a `prompt` string of its own.
            return "[" .. Input.GetKeyName(interactKey) .. "] " .. (script.prompt or "Interact")
        end
    end

    return nil
end

function Update()
    if camera == nil then return end

    -- requireCollider = true: only things with a Collider can be interacted with.
    local hit = camera:Raycast(range, true)

    local prompt = nil
    if hit ~= nil and hit.entity ~= nil then
        prompt = Examine(hit.entity, Input.GetKeyDown(interactKey))
    end

    SetPrompt(prompt or "")
end

Notes

  • script.Interact and script.prompt are read straight out of the target script. If the target doesn't define them the value is nil, which is what makes this check safe.
  • The player is passed to Interact so the target knows who used it.
  • GetScripts() returns a list that only lives for the moment it's used, so the script is used inside the loop and never stored in a variable that outlives it.
  • The target entity needs a Collider (since requireCollider is true).

Lever

Attach to: an Entity with a Collider. Works with the Interactor above.

Toggles a channel that doors, alarms, and lights can listen to (see 05_doors.md and 07_lighting.md).

-- Scripts/Interaction/Lever.lua (entity script)
public string channel = "door1"
public string prompt = "Pull lever"

local isOn = false

-- Called by the Interactor. `self` is this script, `who` is the Entity that used it.
function Interact(self, who)
    isOn = not isOn

    Global.channels = Global.channels or {}
    Global.channels[channel] = isOn

    Debug.Print(who.name .. " turned '" .. channel .. "' " .. (isOn and "on" or "off"))
end

Notes

  • Functions that other scripts call with a colon must take self as their first parameter: interactable:Interact(entity) runs Interact(interactable, entity).
  • prompt is a normal public field, so the Interactor can read it, and designers can edit it.

Hitscan weapon

Attach to: the player (needs a Camera).

Left mouse fires an instant ray from the player's eyes. It supports fire rate, ammo, and reloading, and damages anything hit that has a Health script (10_health_and_damage.md).

-- Scripts/Interaction/Hitscan.lua (entity script)
public number damage = 20
public number range = 400
public number fireDelay = 0.25
public int magazineSize = 8
public number reloadTime = 1.5
public Entity ammoLabel = nil

local camera, label
local ammo = 0
local cooldown = 0.0
local reloading = 0.0
local shownText = ""

local function UpdateLabel()
    if label == nil then return end

    local text = reloading > 0 and "Reloading..." or (ammo .. " / " .. magazineSize)
    if text ~= shownText then
        shownText = text
        label.text = text
    end
end

local function Fire()
    ammo = ammo - 1
    cooldown = fireDelay

    local hit = camera:Raycast(range, true)
    if hit == nil then return end

    if hit.entity ~= nil and hit.entity:HasScriptNamed("Health") then
        hit.entity:GetScript("Health"):TakeDamage(damage)
        Debug.Print("Hit " .. hit.entity.name)
    end
end

function Start()
    camera = entity.camera
    ammo = magazineSize

    if ammoLabel ~= nil then label = ammoLabel.uiText end

    if camera == nil then
        Debug.LogError("Hitscan: " .. entity.name .. " needs a Camera")
    end
end

function Update()
    if camera == nil then return end

    local dt = GameTime.deltaTime
    cooldown = math.max(0, cooldown - dt)

    if reloading > 0 then
        reloading = reloading - dt
        if reloading <= 0 then ammo = magazineSize end
    else
        local wantsReload = Input.GetKeyDown(Key.R) and ammo < magazineSize
        if wantsReload or (ammo == 0 and Input.GetMouseButtonDown(Input.MouseLeft)) then
            reloading = reloadTime
        elseif cooldown <= 0 and ammo > 0 and Input.GetMouseButton(Input.MouseLeft) then
            Fire()
        end
    end

    UpdateLabel()
end

Notes

  • GetMouseButton is true while held (automatic fire). Use GetMouseButtonDown instead for semi-automatic.
  • The weapon skips the shooter itself by passing entity.id as the ignored entity.
  • Enemies need a Collider to be hit, since requireCollider is true.

Clone this wiki locally