Skip to content

Getting Started with Scripting

Berke edited this page Oct 15, 2026 · 8 revisions

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.


1. What is a script?

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:

  • Start runs once when the level begins.
  • Update runs every frame (every time the screen redraws).
  • OnCollisionEnter runs 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.


2. Set up a level you can play

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:

  1. Draw at least one sector, so there is a floor to stand on.
  2. Switch to Entity Mode (press Q to cycle through modes). Point at the map and press E to create an entity. Name it Player.
  3. Add these components: Transform, Rigidbody, Collider (Sphere), Camera and Player Controller.
  4. In the Player Controller, tick Is Active.
  5. 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.


3. Your first script

Create the file

  1. Open the Asset Browser and right-click.
  2. Choose Create File > Script (.lua).
  3. 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)
end

What this does:

  • function Start() ... end makes a function called Start. The engine runs it once when the level begins.
  • entity is the object your script is attached to. Every script gets this automatically.
  • .. joins two pieces of text together.
  • Debug.Print writes a message to the in-game console.

Attach it

  1. Select the Player entity by right clicking on it in Entity mode.
  2. Click Add Component > Custom Script.
  3. Drag Hello.lua from 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.


4. Doing something every frame

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)
end

What this does:

  • local angle = 0 makes a variable (a named box that holds a value). Because it sits outside the function, it remembers its value from frame to frame.
  • GameTime.deltaTime is how many seconds the last frame took. Multiplying by it makes things move at the same speed on every computer. Here, 90 * GameTime.deltaTime means "90 degrees per second" instead of "90 degrees per frame". Use this for anything that changes over time.
  • mathT is the engine's maths toolbox. It has Sin, Cos, Lerp, Clamp, random numbers and more. Lua's built-in math also works.
  • entity.transform.rotation is the direction the entity faces. QuaternionFromEuler(0, angle, 0) turns it by angle degrees 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.


5. Settings you can change in the inspector

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)
end

The rules:

  • Write the field at the top of the file like this: public <type> <name> = <default>. The inspector label comes from the name. degreesPerSecond shows up as "Degrees Per Second".
  • The default must be a simple value, like 90, "text", true, Vector3(0, 1, 0) or nil. (nil means "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 Start and Update.

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 = nil

In 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
end

The full list of field types is in Script.


6. Reading and changing components

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 Rigidbody

Each 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 up

But 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)

7. Reading the keyboard and mouse

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
end

The three key checks:

  • GetKeyDown is true only on the frame the key is first pressed.
  • GetKey is true for as long as the key is held.
  • GetKeyUp is true only on the frame the key is let go.

Mouse buttons work the same way, for example Input.GetMouseButtonDown(Input.MouseLeft).


8. Reacting to things that happen

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. other is 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
end

In a sector script, sector is the room the script is attached to.


9. Finding other things in your level

Sometimes your script needs something that isn't its own entity. Here are the ways to find it, from best to worst:

  1. A public field, like public Sector door. You pick the object in the inspector, so it won't break if something gets renamed.
  2. A callback argument, like other in OnTriggerEnter.
  3. Tags. Give entities a tag in the editor, then use Game.FindEntitiesWithTag("enemy") or entity:HasTag("enemy").
  4. Names. Game.FindEntity("Player") gives you the first entity with exactly that name, or nil if there isn't one.
  5. Raycasts. A raycast shoots an invisible line and tells you what it hits first. Use Game.Raycast(origin, direction, length, ignoredEntityID, requireCollider). Or use entity.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.


10. Talking to other scripts

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
end

Things to know:

  • GetScript never gives back nil. If it can't find the script, isValid is false. Always check isValid before using the result.
  • To let other scripts see a variable, don't put local in front of it. Like health above.
  • 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.

11. Waiting and timers

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)
end

Each frame, the timer goes up by the frame's length. When it passes interval, the sprite blinks and the timer goes back down.


12. What you can't do

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.


13. Using an outside code editor

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.


14. Fixing problems (debugging)

  • Debug.Print(a, b, c) prints any values to the in-game console, with spaces between them.

  • Debug.LogInfo, LogWarning, LogError and LogCritical write 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

Where to go next

  • 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.

Clone this wiki locally