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)
- Select an object in the viewport, for example a cube.
- In Details, choose + Add Component → Script (Lua) → New script....
- Name it
Bouncerand press Create. The editor writesScripts/Bouncer.luaand opens it in your text editor (Notepad if nothing else is set up for.lua). - 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
- Save the file and press Play. The cube bounces.
- While playing, change
* self.heightto* self.height * 2and save. The change appears straight away (hot reload). - 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,
selfis this object's copy. Store anything on it, for exampleself.timer = 0. self.entityis 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 passesselfautomatically.
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
- Player movement and collisions
- Events:
OnPlayerEnter,OnPlayerExit,OnCollected GameInstance:Update, thenGameMode:Update, then every object'sUpdate- 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 exampleGameMode.coins. It's an empty table if the level has none.GameInstance: the session-wide script, for exampleGameInstance: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–Zand digits0–9 Space,Enter,Tab,Shift,Ctrl,AltUp,Down,Left,RightBackspace,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.Logand 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 (
ioandosare 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
requireyet. Put shared helper functions in your Game Instance and call them throughGameInstance.
13. For C++ developers: adding your own Fyn functions
The bindings are in editor/src/Scripting.cpp.
- Write a function such as
static int F_MyThing(lua_State* L). - Read arguments with
luaL_checknumber/luaL_checkstring, then push the results. - Add
{"MyThing", F_MyThing}to thefynFuncstable. - Entity methods go in
entityMethodsand useCheckEntity(L, 1). - The world is
g_Rt->worldand the player isg_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. |
pathis relative to your project folder, for example"Assets/Sounds/coin.wav". Drag.wav,.mp3or.flacfiles onto the editor to import them intoAssets/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.pngtexture that adds bumps without extra triangles. Bumpiness scales it.