Repository navigation
Getting Started with Scripting
This guide takes you from nothing to a working script. Follow it in order the first time, because each step builds on the one before.
You don't need to know Lua already. Everything you need is explained as it comes up.
A script is a text file ending in .lua. You keep it anywhere inside your project's Assets folder. On its own, it does nothing. It only runs once you attach it to something:
- An entity. An entity is any object in your level. You attach a script to it with the Custom Script component. Most scripts work this way.
- A sector. A sector is a room. You attach a script in the Scripts section of the sector inspector. Use these for rooms that do things, like doors, lifts, traps and flickering lights.
Your script doesn't run all the time. Instead, the engine runs particular functions in your script when something happens. These are called callback functions. For example:
-
Startruns once when the level begins. -
Updateruns every frame (every time the screen redraws). -
OnCollisionEnterruns when the entity bumps into something.
You only write the functions you need. Leave out the rest. See Callback Functions for the full list.
If you attach the same script to two entities, each one gets its own separate variables. Two enemies using Enemy.lua each have their own health.
Scripts only run while the game is playing, and the game can only play if the level has a camera. The easiest way to get one is to make a player:
- Draw at least one sector, so there is a floor to stand on.
- Switch to Entity Mode (press
Qto cycle through modes). Point at the map and pressEto create an entity. 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 is inside the sector.
Press Save & Play. You should be able to walk with WASD and look around with the mouse. If the level doesn't start, check the log. The most common reason is a missing camera.
To learn more, see Player Controller and Camera.
- Open the Asset Browser and right-click.
- Choose Create File > Script (.lua).
- Name it
Hello.
The new file comes with some example code. Delete it all and paste this in:
function Start()
Debug.Print("Hello from " .. entity.name)
endWhat this does:
-
function Start() ... endmakes a function calledStart. The engine runs it once when the level begins. -
entityis the object your script is attached to. Every script gets this automatically. -
..joins two pieces of text together. -
Debug.Printwrites a message to the in-game console.
- Select the
Playerentity by right clicking on it in Entity mode. - Click Add Component > Custom Script.
- Drag
Hello.luafrom the Asset Browser onto the File Name box.
Press Save & Play. The console shows Hello from Player.
If something goes wrong: If your script has a typing mistake (a syntax error), the inspector shows it under the File Name box before you even press play. Errors that happen while the game runs show up in red in the console. The game keeps going after an error. Only the function that failed stops.
Update runs once every frame. Let's use it to make something 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)
endWhat this does:
-
local angle = 0makes a variable (a named box that holds a value). Because it sits outside the function, it remembers its value from frame to frame. -
GameTime.deltaTimeis how many seconds the last frame took. Multiplying by it makes things move at the same speed on every computer. Here,90 * GameTime.deltaTimemeans "90 degrees per second" instead of "90 degrees per frame". Use this for anything that changes over time. -
mathTis the engine's maths toolbox. It hasSin,Cos,Lerp,Clamp, random numbers and more. Lua's built-inmathalso works. -
entity.transform.rotationis the direction the entity faces.QuaternionFromEuler(0, angle, 0)turns it byangledegrees around the up-and-down axis. This is the same as Rotation Y in the inspector.
A normal sprite always faces the camera, so you won't see it spin. You'll see the turn on 4-direction sprites, 8-direction sprites and models.
In the spin script, the speed 90 is written inside the code. To change it, you have to edit the script. A public field fixes this by putting the setting in the inspector, so you can change it without touching the code. Each entity can even 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:
- Write the field at the top of the file like this:
public <type> <name> = <default>. The inspector label comes from the name.degreesPerSecondshows up as "Degrees Per Second". - The default must be a simple value, like
90,"text",true,Vector3(0, 1, 0)ornil. (nilmeans "nothing".) - The value you set in the inspector is applied after the top of the file has run. So only use the field inside functions like
StartandUpdate.
Fields can also point at things in your level, such as another entity, a sector, a wall, a component or another script:
public Entity target = nil
public Sector door = nilIn the inspector, these become slots where you pick an object. If a slot is empty, or the object was deleted, the value is nil. Always check before using it:
function Update()
if target == nil or not target.isValid then return end
-- safe to use target here
endThe full list of field types is in Script.
Components are the parts that make up an entity, like Transform or Rigidbody. You get them from entity:
local transform = entity.transform -- nil if the entity has no Transform
local body = entity.rigidbody -- nil if the entity has no RigidbodyEach component page lists what you can read and change.
You can change one part of a position at a time:
entity.transform.position.y = 50 -- moves the entity upBut be careful. If you save a position in a variable, you get a copy. Changing the copy does not move the entity:
local start = entity.transform.position
start.y = 50 -- only changes the copy. The entity stays put.
entity.transform.position = start -- now the entity moves
-- Or do it in one line with maths:
entity.transform.position = entity.transform.position + Vector3(0, 10, 0)Input tells you what the player is pressing. Keys come from the Key list, like Key.E, Key.Space, Key.LShift and Key.Up. Type Key. in the editor and it will show you all of them.
-- Scripts/Jumper.lua (needs an 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
endThe three key checks:
-
GetKeyDownis true only on the frame the key is first pressed. -
GetKeyis true for as long as the key is held. -
GetKeyUpis true only on the frame the key is let go.
Mouse buttons work the same way, for example Input.GetMouseButtonDown(Input.MouseLeft).
Instead of checking every frame whether something happened, you can let the engine tell you. These callback functions work in entity scripts:
-- Scripts/Landmine.lua
-- Needs an 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 something walks into this entity's trigger.otheris the thing that walked in. -
OnCollisionEnter(other)is the same, but for solid objects bumping into each other. -
OnSectorChange(sector)runs when this entity walks into a different room.
Sector scripts have two callbacks of their own, OnEntityEnter(entity) and OnEntityExit(entity). They run when anything enters or leaves the room:
-- Scripts/Alarm.lua (a 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
endIn a sector script, sector is the room the script is attached to.
Sometimes your script needs something that isn't its own entity. Here are the ways to find it, from best to worst:
-
A public field, like
public Sector door. You pick the object in the inspector, so it won't break if something gets renamed. -
A callback argument, like
otherinOnTriggerEnter. -
Tags. Give entities a tag in the editor, then use
Game.FindEntitiesWithTag("enemy")orentity:HasTag("enemy"). -
Names.
Game.FindEntity("Player")gives you the first entity with exactly that name, ornilif there isn't one. -
Raycasts. A raycast shoots an invisible line and tells you what it hits first. Use
Game.Raycast(origin, direction, length, ignoredEntityID, requireCollider). Or useentity.camera:Raycast()to shoot a line from the middle of the screen.
The Game.Find... functions search the whole level, so they are slow. Call them once in Start and save the result. Don't call them every frame.
entity:GetScript("Health") gives you the Health script on that entity. You can read and change its variables, and call its functions using a colon (:).
-- Scripts/Health.lua
public number maxHealth = 100
health = 0
function Start()
health = maxHealth
end
-- Other scripts call this like: target:GetScript("Health"):TakeDamage(10)
-- The colon passes the script itself in as the first value, so the function needs `self`.
function TakeDamage(self, amount)
health = health - amount
if health <= 0 then entity:Destroy() end
end-- Scripts/Spikes.lua (a trigger)
public number damage = 25
function OnTriggerEnter(other)
local health = other:GetScript("Health")
if health.isValid then health:TakeDamage(damage) end
endThings to know:
-
GetScriptnever gives backnil. If it can't find the script,isValidisfalse. Always checkisValidbefore using the result. - To let other scripts see a variable, don't put
localin front of it. Likehealthabove. - To share information across the whole game, such as a score or collected keys, use the
[Global](https://claude.ai/chat/Global.md)table:Global.score = (Global.score or 0) + 1. Every script can see it, it keeps its contents when the level changes, and it starts empty each time the game starts.
There is no wait() in this engine, and no coroutines. Instead, count the time yourself:
-- Scripts/Blinker.lua (needs an 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)
endEach frame, the timer goes up by the frame's length. When it passes interval, the sprite blinks and the timer goes back down.
Only some of Lua is available: the base, math, table and string parts. You can't use os, io, require or coroutine.
Scripts can:
- Create entities with
Game.CreateEntity(). - Add and remove components with
entity:AddComponent(Component.Sprite).
Scripts can't attach other scripts. If a new object needs a script, place it in the editor ahead of time and "switch it on" when you need it. For example, move it into place, make its sprite visible, or turn its collider on.
One thing to watch out for: entity.enabled = false only pauses the entity's scripts. The entity is still drawn on screen and still collides with things. See Entity.
When scripting starts, the engine creates a file called .luals/tilky_api.lua in your project folder, next to project.tilky. It describes every Tilky type and function.
If you open your project folder in VS Code with the Lua extension (called LuaLS), you get autocomplete and help popups for entity, Input, Game and everything else.
Projects created in the launcher already have a .luarc.json file that sets this up. For an older project, create a file with that name in the project folder and put this in it:
{
"workspace.library": ["./.luals"],
"workspace.checkThirdParty": false
}The built-in script editor uses the same information for its autocomplete.
-
Debug.Print(a, b, c)prints any values to the in-game console, with spaces between them. -
Debug.LogInfo,LogWarning,LogErrorandLogCriticalwrite to the engine log instead of the console. -
Debug.Print("pos", entity.transform.position)works. You can print vectors directly. -
Some changes cause an error if you give them a bad value. For example, setting a sector's ceiling lower than its floor. If a value might be bad, wrap the call in
pcall. That way an error won't stop your function:local ok, err = pcall(function() sector:MoveFloorTo(1, height, 40) end) if not ok then Debug.LogWarning(err) end
- Callback Functions: when each callback runs, and in what order.
-
Entity: everything you can do with
entity. - The component pages, starting from the [wiki index](README.md).
- Example Scripts: complete example scripts for doors, lifts, pickups, enemies, UI and more.