Triggers and zones#
Reacting to where people are, rather than to what they click.
Which one to use#
| Event | Fires when | Good for |
|---|---|---|
on_region | somebody enters or leaves a sphere around the object | greeters, checkpoints, ambience, traps |
on_contact | somebody's body touches the object | pressure plates, lava, hazards |
on_touch | somebody clicks it | doors, buttons, pickups |
on_region does not need the object to be solid, or even visible. on_contact does. It is real physical contact.
A greeter#
--!strictlocal self = require("self") self:on_region({ radius = 6, near = function(p) p:show_hud({ id = "welcome", anchor = "top", pages = { main = { { "text", "Welcome, " .. p.name, size = 18, align = "center" }, } }, }) end, far = function(p) p:hide_hud("welcome") end,})Both are edges: each fires once per crossing, never repeatedly while somebody stands there.
Counting who is inside#
--!strictlocal self = require("self") local inside = 0 self:on_region({ radius = 8, near = function() inside += 1 if inside == 1 then self:sound("hum", { volume = 0.3, fade = 1.0 }) end end, far = function() inside -= 1 if inside <= 0 then inside = 0 self:stop_sound() end end,})Clamp at zero. A player who disconnects inside still gets their far, so every near gets its pair, and you would rather be safe than negative.
The sphere follows the object#
A moving platform brings its trigger with it, at no extra cost:
self:on_region({ radius = 3, near = function(p) ... end })self:tween({ to = far_side, seconds = 10 })A pressure plate#
--!strictlocal self = require("self")local events = require("events") local on = 0 self:on_contact(function() on += 1 if on == 1 then self:set({ color = "#55e38b", emissive = 1 }) events:emit("plate", { down = true }) endend) self:on_contact_end(function() on -= 1 if on <= 0 then on = 0 self:set({ color = "#3a3a3a", emissive = 0 }) events:emit("plate", { down = false }) endend)on_contact_end also fires when somebody leaves the world while standing on it, so the plate cannot get stuck down.
A hazard#
--!strictlocal self = require("self")local events = require("events") local burning = {} self:on_contact(function(p) if burning[p.id] then return end burning[p.id] = true local function tick() if not burning[p.id] then return end events:emit("combat:hit", { target = p.id, amount = 8 }) self:after(0.5, tick) end tick()end) self:on_contact_end(function(p) burning[p.id] = nilend)The damage repeats on a chain of one-shot timers rather than a loop, so each slice stays short.
A checkpoint#
--!strictlocal self = require("self") self:on_region({ radius = 4, near = function(p) local here = self:position() self.store:set("spawn." .. p.id, here.x .. "," .. here.y .. "," .. here.z) p:show_hud({ id = "checkpoint", anchor = "top", pages = { main = { { "text", "Checkpoint reached", align = "center" } } }, }) self:after(2, function() p:hide_hud("checkpoint") end) end,})Key by player id, never by name.
Store the three numbers with a separator you can split on later. tostring on a point gives you something readable but not parseable back.
Asking who is nearby, instead of waiting#
Sometimes you want the list, not an event:
local players = require("players")local events = require("events") self:on_timer(2, function() local crowd = players:nearby(20) if #crowd >= 5 then events:emit("crowd", { n = #crowd }) endend)nearby measures from the object this script is in, and reading it is free.
Ranges and behaviour#
radius | 0.5 to 96 m |
| leaving | takes half a metre more than entering, so nobody on the line flickers |
calling on_region again | replaces the volume and re-announces whoever is inside |
radius = 0 or omitted | removes the volume |
See also#
Updated 04 September 2026