Repository navigation
Getting Started with Scripting
This guide takes you from nothing to a working script, then builds up the ideas you'll use in every script after that. Follow it in order the first time. Each step builds on the one before.
You don't need to know Lua already. The language basics used here are explained as they come up.
A script is a .lua file anywhere in your project's Assets folder. On its own it does nothing.
It runs once you attach it to something:
- An entity, through the entity's Custom Script component. Most scripts are these.
- A sector, through the Scripts section of the sector inspector. Use these for rooms that do things: doors, lifts, traps, flickering lights.
The engine never calls your script at random. It calls specific callback functions at specific
moments: Start when the level begins, Update every frame, OnCollisionEnter when something
bumps into the entity, and so on. You write the functions you care about and leave out the rest.
Callback Functions has the full list.
Each attachment gets its own copy of the script's variables. Two enemies running the same
Enemy.lua each have their own health.
Scripts only run while the game is playing, and the game only plays if the level has a camera. The quickest setup is a player:
- Draw at least one sector so there is floor to stand on.
- Switch to Entity Mode (
Qcycles modes), point at the map and pressEto create an entity there. Name itPlayer. - Add these components: Transform, Rigidbody, Collider (Sphere), Camera and Player Controller.
- In the Player Controller, tick Is Active.
- Move the entity so it stands inside the sector.
Press Save & Play. You should be able to walk around with WASD and look with the mouse. If the level doesn't start, the log will say why. The usual reason is a missing camera.
See Player Controller and Camera for what each of those does.
- Open the Asset Browser, right-click, then choose Create File > Script (.lua). Call it
Hello.
The new file already contains a template with a couple of example fields and empty Start,
Update and FixedUpdate functions. Replace everything in it with this:
function Start()
Debug.Print("Hello from " .. entity.name)
end-
function Start() ... enddefines the callback the engine runs once, when the level starts. -
entityis a variable every entity script gets automatically. It is the entity the script is attached to. -
..joins two strings in Lua. -
Debug.Printwrites to the in-game console.
- Select the
Playerentity. - Add Component > Custom Script.
- Drag
Hello.luafrom the Asset Browser onto the File Name field.
Press Save & Play. The console shows Hello from Player.
If the script has a syntax error, the inspector shows it under the File Name field before you even press play. Errors while the game is running are printed in red in the console. A script that errors keeps running; only the function that failed is cut short.
Update runs once per frame. Let's make an entity spin. Create a new entity with a
Transform and a Sprite (so you can see it), then attach this script:
-- Scripts/Spin.lua
local angle = 0
function Update()
angle = angle + 90 * GameTime.deltaTime
entity.transform.rotation = mathT.QuaternionFromEuler(0, angle, 0)
end-
local angle = 0is a variable that belongs to this script instance. It keeps its value between frames because it is declared outside the function. -
GameTime.deltaTimeis how many seconds the last frame took. Multiplying by it turns "90 per frame" into "90 per second", so the speed is the same on a fast or a slow computer. Do this for everything that changes over time. -
mathTis the engine's maths table:Sin,Cos,Lerp,Clamp, random numbers, rotations and more. Lua's ownmathtable also works. -
transform.rotationis the way the entity faces.QuaternionFromEuler(0, angle, 0)turns itangledegrees around the vertical axis, like Rotation Y in the inspector. A normal sprite always faces the camera, so you see the turn on 4- and 8-direction sprites and on models.
Hard-coding 90 means editing the script to change the speed. A public field puts it in the
inspector instead, so each entity can have its own value:
-- Scripts/Spin.lua
public number degreesPerSecond = 90
local angle = 0
function Update()
angle = angle + degreesPerSecond * GameTime.deltaTime
entity.transform.rotation = mathT.QuaternionFromEuler(0, angle, 0)
endThe rules:
- A field is declared at the top level as
public <type> <name> = <default>. The inspector label comes from the name (degreesPerSecondshows as "Degrees Per Second"). - The default must be a plain value:
90,"text",true,Vector3(0, 1, 0)ornil. The editor reads it without running your script. - The value set in the inspector is applied after the top of the file runs. So use the field
inside functions (like
StartandUpdate), not at the top level.
Fields can also point at things in the level: another entity, a sector, a wall, a component or another script.
public Entity target = nil
public Sector door = nilIn the inspector these become slots you fill by picking an object. If a slot is left empty, or the
object gets deleted, the value is nil, so check before using it:
function Update()
if target == nil or not target.isValid then return end
-- ...
endEvery supported type is listed in Script.
An entity's components are properties on entity:
local transform = entity.transform -- nil if it has no Transform
local body = entity.rigidbody -- nil if it has no RigidbodyEach component page lists what you can read and write. Vector properties can be changed one component at a time:
entity.transform.position.y = 50 -- moves the entityBut a vector you store in a variable is your own copy:
local start = entity.transform.position
start.y = 50 -- only changes `start`, the entity stays put
entity.transform.position = start -- now it moves
-- or, with vector maths:
entity.transform.position = entity.transform.position + Vector3(0, 10, 0)Input reads the keyboard and mouse. Keys come from the Key table, like Key.E, Key.Space,
Key.LShift and Key.Up. Type Key. and the editor lists them all.
-- Scripts/Jumper.lua (entity with a Rigidbody)
public number jumpSpeed = 120
function Update()
local body = entity.rigidbody
if body == nil then return end
if Input.GetKeyDown(Key.J) and body.isGrounded then
local v = body.velocity
body.velocity = Vector3(v.x, jumpSpeed, v.z)
end
end-
GetKeyDownis true only on the frame the key goes down,GetKeywhile it is held, andGetKeyUpon the frame it is released. -
Input.GetMouseButtonDown(Input.MouseLeft)works the same way for mouse buttons.
Instead of checking every frame whether something happened, let the engine tell you. These callbacks run on entity scripts:
-- Scripts/Landmine.lua
-- Entity with a Transform and a Collider with Is Trigger ticked.
function OnTriggerEnter(other)
Debug.Print(other.name .. " stepped on a mine!")
entity:Destroy()
end-
OnTriggerEnter(other)runs when another collider starts overlapping this entity's trigger.otheris the entity that came in. -
OnCollisionEnter(other)is the same for solid colliders that bump into each other. -
OnSectorChange(sector)runs when this entity walks into a different room.
Sector scripts get OnEntityEnter(entity) and OnEntityExit(entity) for anything entering or
leaving the room:
-- Scripts/Alarm.lua (sector script)
function OnEntityEnter(e)
if e.hasPlayerController then sector:FadeLight(Vector3(255, 40, 40), 0.3) end
end
function OnEntityExit(e)
if sector:IsEmpty() then sector:FadeLight(Vector3(255, 255, 255), 1.0) end
endInside a sector script, sector is the sector it is attached to.
A script often needs something that isn't its own entity. The options, best first:
-
A public field (
public Sector door). You pick it in the inspector, so it can't break if something gets renamed. -
Callback arguments, like
otherinOnTriggerEnter. -
Tags. Give entities a tag in the editor, then use
Game.FindEntitiesWithTag("enemy")orentity:HasTag("enemy"). -
Names.
Game.FindEntity("Player")returns the first entity with that exact name, ornil. -
Raycasts.
Game.Raycast(origin, direction, length, ignoredEntityID, requireCollider)returns what a line hits first.entity.camera:Raycast()does the same through the middle of the screen.
Game.Find... searches every entity in the level. Call it once in Start and keep the result,
not every frame.
entity:GetScript("Health") returns the Health script attached to that entity. You can read
and write its variables, and call its functions with a colon:
-- Scripts/Health.lua
public number maxHealth = 100
health = 0
function Start()
health = maxHealth
end
-- Called from other scripts as target:GetScript("Health"):TakeDamage(10).
-- The colon passes the script itself as the first argument, so it needs `self`.
function TakeDamage(self, amount)
health = health - amount
if health <= 0 then entity:Destroy() end
end-- Scripts/Spikes.lua (trigger)
public number damage = 25
function OnTriggerEnter(other)
local health = other:GetScript("Health")
if health.isValid then health:TakeDamage(damage) end
end-
GetScriptnever returnsnil. If nothing matches, it returns a handle whoseisValidisfalse, so check that. - Variables you want other scripts to see must be global in the script (no
local), likehealthabove. - For state the whole game shares (a score, collected keys), use the
Globaltable:Global.score = (Global.score or 0) + 1. It is shared by every script, keeps its contents when the level changes, and starts empty each time the game starts.
There is no wait() or coroutine library. Count time yourself:
-- Scripts/Blinker.lua (entity with a Sprite)
public number interval = 0.5
local timer = 0
local visible = true
function Update()
timer = timer + GameTime.deltaTime
if timer < interval then return end
timer = timer - interval
visible = not visible
local c = entity.sprite.color
entity.sprite.color = Vector4(c.x, c.y, c.z, visible and 1 or 0)
endOnly Lua's base, math, table and string libraries are loaded: no os, io, require or
coroutine. Scripts can create entities (Game.CreateEntity()) and add or remove their
components (entity:AddComponent(Component.Sprite)), but they can't attach scripts. Place anything that
needs a script in the editor ahead of time and "switch it on" when you need it: move it into place, make its sprite
visible, turn its collider on. Note that entity.enabled = false only pauses the entity's
scripts. The entity is still drawn and still collides. See Entity.
When scripting starts, the engine writes .luals/tilky_api.lua in your project folder, next to
project.tilky. It describes every Tilky type and function. Open the project folder in VS Code with the Lua extension (LuaLS)
and you get autocomplete and hover docs for entity, Input, Game and the rest. Projects made
in the launcher come with a .luarc.json that points LuaLS at that file. For an older project,
add one yourself:
{
"workspace.library": ["./.luals"],
"workspace.checkThirdParty": false
}The built-in script editor's autocomplete comes from the same data.
-
Debug.Print(a, b, c)prints any values to the in-game console, separated by spaces. -
Debug.LogInfo,LogWarning,LogErrorandLogCriticalwrite to the engine log instead. -
tostring(v)works on vectors:Debug.Print("pos", entity.transform.position). -
Some setters raise errors on bad values, like a sector ceiling below its floor. If the values come from somewhere you don't control, wrap the call:
local ok, err = pcall(function() sector:MoveFloorTo(1, height, 40) end) if not ok then Debug.LogWarning(err) end
- Callback Functions: exact timing and order of every callback.
-
Entity: everything on
entity. - The component pages, starting from the wiki index.
-
docs/scripts/: complete example scripts for doors, lifts, pickups, enemies, UI and more.