Fyn Scripting Guide (Lua)

Fyn games are scripted in Lua, a small and friendly language used by Roblox, Defold and many other games. You write scripts in any text editor, and the editor reloads them while you play. You never rebuild the engine.

There are three places logic can live. If you know Unity or Unreal, they map like this:

Fyn Like Lives for Use it for
Object script Unity MonoBehaviour one object, while the level runs doors, enemies, jump pads, pickups, moving platforms
Game Mode Unreal GameMode one level (fresh every level) the rules of a level: score, timer, win/lose, opening the exit
Game Instance Unreal GameInstance the whole play session (survives Fyn.LoadLevel) lives, total score, inventory, unlocked stuff

Prefabs (.fynprefab) are saved objects, like Unity prefabs. A prefab keeps its mesh, colours, components and its script plus property values. Place copies in any level, edit one copy and press Apply to update them all, or spawn new copies from code with Fyn.Spawn.


1. Your project folder

The editor keeps everything in a project folder. By default that's Documents/FynProjects/MyGame, and File → Open Project Folder opens it.

MyGame/
├─ Levels/     Main.fyn, Level2.fyn ...      (scenes)
├─ Scripts/    Door.lua, GameMode.lua ...    (Lua scripts)
├─ Prefabs/    Coin.fynprefab ...            (reusable objects)
└─ SCRIPTING.md                              (this guide)

Paths in scripts and scenes are relative to this folder, for example "Scripts/Door.lua" or "Levels/Level2.fyn".

The first time the editor runs, it fills the project with a working example game:

  • A level with coins, a jump pad, a spike trap, a chasing enemy and a locked door that opens when every coin is collected.
  • An exit behind the door that loads a night-time Level 2.
  • Lives that carry over between the two levels.

Every one of those things is a short script in Scripts/. Read them; they're the best examples.


2. Your first script (two minutes)

  1. Select an object in the viewport, for example a cube.
  2. In Details, choose + Add Component → Script (Lua) → New script....
  3. Name it Bouncer and press Create. The editor writes Scripts/Bouncer.lua and opens it in your text editor (Notepad if nothing else is set up for .lua).
  4. Replace the contents with:
local Bouncer = {}

Bouncer.Properties = {
    height = 1.5,
    speed = 3,
}

function Bouncer:Start()
    self.x, self.baseY, self.z = self.entity:GetPosition()
    self.t = 0
end

function Bouncer:Update(dt)
    self.t = self.t + dt
    local y = self.baseY + math.abs(math.sin(self.t * self.speed)) * self.height
    self.entity:SetPosition(self.x, y, self.z)
end

return Bouncer
  1. Save the file and press Play. The cube bounces.
  2. While playing, change * self.height to * self.height * 2 and save. The change appears straight away (hot reload).
  3. Stop. In Details you'll now see height and speed. Change them for this one cube; values changed for one object show in yellow.

Tips for attaching scripts:

  • Drag a script from Content → Scripts onto any object in the viewport to attach it.
  • Right-click a script in Content to attach it to the selected object, or to use it as the Game Mode or Game Instance.

3. How a script is built

local MyThing = {}               -- 1. a table = your script "class"

MyThing.Properties = {           -- 2. optional: editable settings
    speed = 2.0,                 --    number      -> number field
    loop = true,                 --    true/false  -> checkbox
    message = "Hello",           --    text        -> text box
    offset = { 0, 1, 0 },        --    3 numbers   -> X/Y/Z fields
    glowColor = { 1, 0.5, 0 },   --    name contains "color" -> colour picker
}

function MyThing:Start() end     -- 3. callbacks (all optional)
function MyThing:Update(dt) end

return MyThing                   -- 4. ALWAYS return the table at the end
  • Each object gets its own copy. Inside a function, self is this object's copy. Store anything on it, for example self.timer = 0.
  • self.entity is the object the script is attached to (see the Entity API below).
  • Properties become fields. self.speed, self.offset[2] and so on. Defaults come from the script, and per-object changes come from Details.
  • Use function MyThing:Name() with a colon. Lua passes self automatically.

4. Callbacks (events)

Object scripts

Function When
Start() Once, when Play starts (or right after the object is spawned)
Update(dt) Every frame. dt = seconds since the last frame
OnPlayerEnter() The player starts touching this object (walks into it, bumps it or stands on it)
OnPlayerExit() The player stops touching it
OnCollected() This object was a collectible and the player picked it up
OnDestroy() Just before the object is removed with :Destroy()

Game Mode (World Settings → Game Mode)

Function When
Start() Once per level, after every object's Start
Update(dt) Every frame
OnCollected(entity) The player picked up any collectible (entity = which one)

Game Instance (World Settings → Game Instance)

Function When
Start() Once, when you press Play (before the level's objects start)
Update(dt) Every frame, in every level
OnLevelLoaded(levelName) After Fyn.LoadLevel switched level
OnCollected(entity) Any collectible picked up

Order every frame

  1. Player movement and collisions
  2. Events: OnPlayerEnter, OnPlayerExit, OnCollected
  3. GameInstance:Update, then GameMode:Update, then every object's Update
  4. Objects destroyed this frame are removed

5. Talking between scripts

Find an object and call its script:

local door = Fyn.Find("Door")           -- by name (as shown in the Outliner)
if door then
    door:GetScript():Open()             -- call any function in Door.lua
    print(door:GetScript().isOpen)      -- or read its fields
end

Find many objects:

for _, coin in ipairs(Fyn.FindAll("Coin")) do      -- names starting with "Coin"
    coin:SetColor(1, 0, 0)
end
for _, e in ipairs(Fyn.FindWithScript("Chaser")) do -- every object using Chaser.lua
    e:GetScript().speed = 5
end

Global objects available everywhere:

  • GameMode: the current level's Game Mode script, for example GameMode.coins. It's an empty table if the level has none.
  • GameInstance: the session-wide script, for example GameInstance:LoseLife(). It always exists, even with no script set, so you can always store data in it.
-- Hazard.lua
function Hazard:OnPlayerEnter()
    if GameInstance.LoseLife then GameInstance:LoseLife() else Fyn.Respawn() end
end

6. Game Mode and Game Instance in practice

Set both in World Settings → Game logic (Lua), or right-click a script in Content → Use as Game Mode or Use as Game Instance.

Game Instance: data that must survive level changes

local GameInstance = {}
GameInstance.Properties = { startLives = 3 }

function GameInstance:Start()
    self.lives = self.startLives
    self.keys = {}                    -- inventory
end

function GameInstance:OnLevelLoaded(name)
    Fyn.ShowMessage("Level: " .. name, 2)
end

return GameInstance

Game Mode: the rules of one level

local GameMode = {}

function GameMode:Start()
    self.timeLeft = 60
end

function GameMode:Update(dt)
    if Fyn.IsGameOver() then return end
    self.timeLeft = self.timeLeft - dt
    Fyn.SetHUD("time", string.format("Time left: %d", math.ceil(self.timeLeft)))
    if self.timeLeft <= 0 then
        Fyn.Lose("Out of time!")
    end
end

function GameMode:OnCollected(entity)
    if Fyn.CountCollectibles() == 0 then
        Fyn.Win("All collected with " .. math.floor(self.timeLeft) .. "s to spare!")
    end
end

return GameMode

Auto win (World Settings): when it's on, the level is won automatically once every collectible is picked up. Turn it off when your Game Mode decides when to win with Fyn.Win.

Multiple levels:

  • Fyn.LoadLevel("Levels/Level2.fyn") switches level.
  • The Game Instance keeps going. The objects and the Game Mode of the new level start fresh.
  • In the editor, Stop always returns you to the level you were editing.

7. Prefabs

Action How
Make a prefab Outliner → right-click an object → Save as Prefab. It's saved to Prefabs/<Name>.fynprefab.
Place one Drag it from Content → Prefabs into the viewport, or double-click it.
Update all copies Change one copy, then Details → Prefab → Apply. The prefab file and every copy in the open level update. Each copy keeps its own position, rotation and name.
Undo your changes Revert resets the copy to the prefab.
Make it a normal object Unlink
Spawn in code local c = Fyn.Spawn("Prefabs/Coin.fynprefab", x, y, z)

A prefab stores the script and the property values you set. For example, a "FastChaser" prefab can use Chaser.lua with speed = 6.

Copies in other levels aren't changed until you open that level and press Revert on them (or Apply from one of them).

Spawner example:

local Spawner = {}
Spawner.Properties = { prefab = "Prefabs/Coin.fynprefab", every = 3, max = 10 }

function Spawner:Start() self.timer = 0; self.count = 0 end

function Spawner:Update(dt)
    self.timer = self.timer + dt
    if self.timer >= self.every and self.count < self.max then
        self.timer = 0
        self.count = self.count + 1
        local x, y, z = self.entity:GetPosition()
        Fyn.Spawn(self.prefab, x + Fyn.Random(-4, 4), y + 1, z + Fyn.Random(-4, 4))
    end
end

return Spawner

8. API reference

Entity (self.entity, or anything returned by Fyn.Find, Fyn.Spawn and so on)

Method Description
x, y, z = e:GetPosition() World position
e:SetPosition(x, y, z) Teleport
e:Move(dx, dy, dz) Move by an offset
rx, ry, rz = e:GetRotation() Rotation in degrees
e:SetRotation(rx, ry, rz) / e:Rotate(dx, dy, dz) Set / add rotation (degrees)
sx, sy, sz = e:GetScale() Scale
e:SetScale(s) / e:SetScale(x, y, z) Uniform / per-axis scale
e:GetName() / e:SetName(text) Name
e:IsVisible() / e:SetVisible(bool) Hide or show. Hidden objects don't collide or trigger.
r, g, b = e:GetColor() / e:SetColor(r, g, b) Mesh colour (or light colour), 0..1
e:SetGlow(amount) Mesh glow (0 = none)
e:SetSolid(bool) Whether the player collides with it
e:SetLight(intensity [, range]) For light objects
e:DistanceToPlayer() Metres to the player's body
e:DistanceTo(other) Metres to another entity
e:GetScript() This object's script table (call its functions or read its fields), or nil
e:Destroy() Remove it at the end of this frame
e:IsValid() false once destroyed
e:GetId() Unique number
e1 == e2 Same object?

Fyn (global)

Function Description
Fyn.Log(...) / print(...) Write to the Console
Fyn.Warn(...) Yellow Console message
Fyn.Find(name) First object with exactly this name, or nil
Fyn.FindAll(prefix) List of objects whose name starts with prefix
Fyn.FindWithScript(name) List of objects using name.lua (or the full path)
Fyn.Spawn(prefabPath, x, y, z) Create a prefab copy. Returns the entity.
x, y, z = Fyn.GetPlayerPosition() The player's feet
Fyn.SetPlayerPosition(x, y, z) Teleport the player
Fyn.LaunchPlayer(upSpeed) Throw the player upwards (jump pads)
Fyn.Respawn() Send the player back to the start or the last checkpoint
Fyn.SetCheckpoint(x, y, z) Where Respawn sends the player from now on
Fyn.SetWalkSpeed(v) / Fyn.SetJumpHeight(m) Player movement
Fyn.GroundHeight(x, z) Terrain height there (0 if no terrain)
Fyn.KeyDown("E") / Fyn.KeyPressed("E") Held / pressed this frame (see key names below)
Fyn.MouseClicked() Left mouse pressed this frame
Fyn.Time() Seconds since Play started
Fyn.Random() / Fyn.Random(a, b) Random number 0..1 or a..b
Fyn.ShowMessage(text [, seconds]) Big message in the middle of the screen
Fyn.SetHUD(id, text) A line in the top-left HUD. The same id replaces the line; nil removes it.
Fyn.Win(text) / Fyn.Lose(text) End the game with a message
Fyn.IsGameOver() Already won or lost?
Fyn.IsPlayerInWater() Is the player swimming in a lake, river or ocean?
Fyn.CountCollectibles() Collectibles still left in the level
Fyn.LoadLevel(path) Switch to another level (Game Instance survives)
Fyn.CurrentLevel() Name of the current level, e.g. "Main"

Key names:

  • Letters A–Z and digits 0–9
  • Space, Enter, Tab, Shift, Ctrl, Alt
  • Up, Down, Left, Right
  • Backspace, F1–F4

Note: W, A, S, D, Space and Shift already move the player.


9. More recipes

Press E near an object (a lever):

local Lever = {}
Lever.Properties = { target = "Door", range = 2.5 }

function Lever:Update(dt)
    if self.entity:DistanceToPlayer() < self.range then
        Fyn.SetHUD("hint", "Press E to pull the lever")
        if Fyn.KeyPressed("E") then
            local door = Fyn.Find(self.target)
            if door then door:GetScript():Open() end
            self.entity:SetRotation(0, 0, 45)
        end
    else
        Fyn.SetHUD("hint", nil)
    end
end
return Lever

Moving platform:

local Mover = {}
Mover.Properties = { offset = { 0, 0, 6 }, duration = 3 }

function Mover:Start()
    self.ax, self.ay, self.az = self.entity:GetPosition()
    self.t = 0
end

function Mover:Update(dt)
    self.t = self.t + dt
    local k = (1 - math.cos(self.t / self.duration * math.pi)) * 0.5   -- 0 -> 1 -> 0 smoothly
    self.entity:SetPosition(self.ax + self.offset[1] * k,
                            self.ay + self.offset[2] * k,
                            self.az + self.offset[3] * k)
end
return Mover

Checkpoint flag:

local Checkpoint = {}
function Checkpoint:OnPlayerEnter()
    local x, y, z = self.entity:GetPosition()
    Fyn.SetCheckpoint(x, Fyn.GroundHeight(x, z), z)
    self.entity:SetColor(0.3, 1, 0.4)
    Fyn.ShowMessage("Checkpoint!", 1.5)
end
return Checkpoint

Key and locked door (inventory in the Game Instance):

-- Key.lua: put it on a small glowing object
local Key = {}
Key.Properties = { keyName = "red" }
function Key:OnPlayerEnter()
    GameInstance.keys = GameInstance.keys or {}
    GameInstance.keys[self.keyName] = true
    Fyn.ShowMessage("Got the " .. self.keyName .. " key!", 2)
    self.entity:Destroy()
end
return Key

-- KeyDoor.lua
local KeyDoor = {}
KeyDoor.Properties = { keyName = "red" }
function KeyDoor:OnPlayerEnter()
    if GameInstance.keys and GameInstance.keys[self.keyName] then
        self.entity:SetVisible(false)          -- hidden objects don't collide
    else
        Fyn.ShowMessage("You need the " .. self.keyName .. " key", 2)
    end
end
return KeyDoor

10. Debugging

  • Console panel (bottom of the editor) shows print, Fyn.Log and every error with the file and line number.
  • When a script errors, only that object's script pauses; the rest of the game keeps running. Fix the file and save. Hot reload un-pauses it, or Stop and Play again.
  • The status bar says SCRIPT ERRORS when there are any.
  • print(self.entity, self.speed) is your best friend.
  • If Details shows red text under Script, the file has a syntax error, or it doesn't end with return YourTable.

11. Lua in 60 seconds

local x = 5                       -- variables (always use local)
local name = "Fyn" .. " Engine"   -- join text with ..
local list = { 10, 20, 30 }       -- lists start at index 1!  list[1] == 10
local t = { hp = 100 }            -- tables hold anything: t.hp, t["hp"]

if x > 3 and name ~= "" then      -- ~= means "not equal"
    print("big")
elseif x == 3 then
    print("three")
else
    print("small")
end

for i = 1, 10 do print(i) end             -- counting loop
for i, v in ipairs(list) do print(v) end  -- list loop
for k, v in pairs(t) do print(k, v) end   -- table loop

local function add(a, b) return a + b end
-- math.sin, math.cos, math.abs, math.floor, math.min, math.max, math.random, math.pi
-- string.format("%.1f", 3.14159)  -> "3.1"

12. Limits (for now)

  • Scripts can't read or write files, or run programs (io and os are switched off), so shared games are safe to run.
  • One script per object. Make an Empty object and attach another script if you need more.
  • No require yet. Put shared helper functions in your Game Instance and call them through GameInstance.

13. For C++ developers: adding your own Fyn functions

The bindings are in editor/src/Scripting.cpp.

  1. Write a function such as static int F_MyThing(lua_State* L).
  2. Read arguments with luaL_checknumber / luaL_checkstring, then push the results.
  3. Add {"MyThing", F_MyThing} to the fynFuncs table.
  4. Entity methods go in entityMethods and use CheckEntity(L, 1).
  5. The world is g_Rt->world and the player is g_Rt->play.

New events are dispatched from ScriptRuntime::Update with Call(id, "OnSomething", nargs).

Sound and music (v0.8)

Function What it does
Fyn.PlaySound(path, volume, pitch) Plays a sound once. volume and pitch are optional (default 1).
Fyn.PlaySoundAt(path, x, y, z, volume, pitch) 3D sound: it gets quieter with distance and comes from the left or right.
Fyn.PlayMusic(path, volume, loop) Starts background music (default volume 0.6, loops). Calling it again replaces the music.
Fyn.StopMusic() / Fyn.SetMusicVolume(v) Stops the music / changes its volume.
  • path is relative to your project folder, for example "Assets/Sounds/coin.wav". Drag .wav, .mp3 or .flac files onto the editor to import them into Assets/Sounds.
  • Built-in sounds need no files: "coin", "jump", "hit", "win", "lose", "click".
  • Collecting a gem, winning and losing play built-in sounds automatically.
  • All sounds stop when you press Stop in the editor.
  • In the browser, sound starts after the player's first click or tap (a browser rule).
function Door:Open()
    local x, y, z = self.entity:GetPosition()
    Fyn.PlaySoundAt("Assets/Sounds/door_creak.wav", x, y, z)
end

Materials (PBR, v0.8)

Every shape and model has Roughness, Metallic and an optional Normal map in Details:

  • Roughness — 0 = shiny (polished, wet), 1 = matte (chalk, cloth).
  • Metallic — 1 = metal; reflections take the object's colour (gold = yellow + metallic 1 + roughness 0.3).
  • Normal map — a bluish *_Normal.png texture that adds bumps without extra triangles. Bumpiness scales it.