Skip to content

Writing gameplay

This page shows the script API for the jobs most gameplay needs. The script page’s API list, in the editor, has the rest.

The two languages call the same API in their own way:

  • Luau calls an object’s methods with a colon (self.entity:name()) and the engine’s shared services with a dot (Log.info, Input.isDown).
  • AngelScript uses a dot for methods (self.name()) and :: for services (Log::info, Input::isDown). Engine objects are handles: Float3@, Entity@.

A behavior is a class. The engine makes one for each entity it is attached to, and calls its methods when there is something to do. Write only the ones you need:

Method Called
onStart() Once, when the entity is in play
onUpdate(dt) Every frame, dt the seconds since the last one
onFixedUpdate(dt) At the physics rate
onEnable(), onDisable() When the behavior is turned on or off
onDestroy() Before the entity goes
onContactBegin, onContactEnd, onTriggerEnter, onTriggerExit On physics contacts (below)
on<Name>(payload) When a message named <Name> arrives (below)

A Level script gets onStart, onUpdate, onFixedUpdate and onStop. A Game script gets launch(), update(dt) and exit().

Scripts update before the systems that act on what they set, so a sound started or a destination set in onUpdate takes effect the same frame.

function Coin:onUpdate(dt)
self.time = self.time + dt
-- Spin about the up axis and bob up and down.
self.entity:setRotation(Quaternion.FromAxisAngle(Float3.new(0, 1, 0), self.time * self.spinSpeed))
local home = self.home
self.entity:setPosition(home.x, home.y + math.sin(self.time * 2.5) * 0.2, home.z)
end
  • position(), rotation() and scale() and their setters work in the parent’s space. worldPosition(), worldRotation() and worldScale() read where the entity is in the world.
  • setRotationEuler(x, y, z) takes degrees: pitch, yaw and roll.
  • In Luau a Float3 is Luau’s own vector type, made with Float3.new(x, y, z); use Luau’s math library. AngelScript has Math::Sin and the rest.

Scripts read the actions of the project’s input map, by name, never raw keys. That way a player can rebind them, and a gamepad works too.

if Input.wasPressed("Jump") then
self:jump()
end
local crouched = Input.isDown("Sneak")
local move = Input.value2D("Move") -- move.x, move.y from -1 to 1

isDown is true while held, wasPressed and wasReleased on the frame it changed. value reads a one-axis action. Input.rumble(low, high, seconds) shakes the gamepad.

function Coin:onStart()
self.player = self.entity.scene:find("Player")
end
function Coin:onUpdate(dt)
if not self.player:isValid() then return end
local light = LightComponent.of(self.entity)
-- ...
end
  • The scene’s find(name) returns the first entity with that name; findByPath("Level/Doors/Front") follows the hierarchy. An entity’s findChildByName, parent(), firstChild() and nextSibling() walk it.
  • Check isValid() before using an entity you found or kept: an entity that was destroyed, or never found, does nothing when called.
  • A component’s of(entity) gives its API on that entity: RigidBodyComponent, CharacterComponent, MeshComponent, LightComponent, CameraComponent, AudioSourceComponent, NavAgentComponent and more. Scene-wide services work the same way with the scene: ScenePhysics.of(scene), ScenePrefabs.of(scene). On an entity without the component it is nil (Luau) or null (AngelScript), so test it before use. If the component is removed while a script still holds it, using it is a script error.
-- Copy the prefab's ID with right-click > Copy GUID in the Assets panel.
local Pebble = Guid.new("245b459c-4b93-4876-9496-6e4eb3daa145")
function Thief:throw()
local p = self.entity:worldPosition()
local stone = ScenePrefabs.of(self.entity.scene):spawn(Pebble, Float3.new(p.x, p.y + 0.5, p.z))
end
function Pebble:onTriggerEnter(other)
self.entity:destroy()
end

spawn also takes a rotation and a parent. destroy() removes the entity and its children at the end of the frame. scene.createEntity(name) makes an empty one.

send delivers a message to the behaviors of one entity: a message named CoinCollected calls their onCoinCollected method, with the payload if there is one. scene.events.emit does the same for every script listening, Level and Game scripts included.

-- In the coin:
player:send("CoinCollected", 10)
self.entity.scene.events:emit("CoinCollected", 10)
-- In the player, or in the Game script:
function Player:onCoinCollected(value)
self.coins = self.coins + value
end

Messages are delivered after the current update, not during the send.

Contacts call methods on the behaviors of both entities, each told about the other:

function Crate:onContactBegin(other, point, normal, speed)
if speed > 2.0 then
Audio.playOneShot3DPath("Audio/Thud", point.x, point.y, point.z)
end
end
function Pickup:onTriggerEnter(other)
other:send("PickedUp")
end

onContactEnd takes the same values, and onTriggerExit(other) fires when something leaves a trigger.

Push a body with an impulse, and look for what is below or ahead with a ray:

local physics = ScenePhysics.of(self.entity.scene)
physics:applyImpulse(stone, Float3.new(0, 5, 0))
local hit = physics:rayCast(self.entity:worldPosition(), Float3.new(0, -1, 0), 2.0)
if hit and hit.hit then
Log.info("ground at " .. hit.distance)
end

A hit has position, normal, distance and entity(). sphereCast and overlapSphere are there too. A character moves through its CharacterComponent: move, jump, grounded().

local CoinSound = Guid.new("6d963695-67f8-4b7f-9cf4-d234f8034116") -- a sound's ID
Audio.playOneShot(CoinSound, AudioBus.Effects, 0.7)
Audio.playCuePath("Audio/Footsteps")
Audio.playMusicPath("Audio/Theme", 2.0) -- cross-fade over two seconds

A sound plays by its asset ID (playOneShot, playCue, playMusic) or by its path in the Assets panel (the Path versions). The 3D versions play at a position. The buses are Master, Effects, Music and UI.

A coroutine runs over several frames, waiting where it needs to:

function Door:onStart()
local this = self
startCoroutine(function()
waitSeconds(1.0)
this.entity:setPosition(0, 3, 0)
waitUntil(function() return this.open end)
Log.info("open")
end, self)
end

Pass self as the second argument, so the coroutine stops when the behavior goes.

run controls the game as a whole. Load a scene by its ID:

local Level2 = Guid.new("c0d1c3a4-58e2-4f3b-9a51-0f6e4f1d2b77")
run.loadScene(Level2)

The scene that was playing is removed once the new one is in. loadSceneAsync loads in the background (check loadComplete), setTimeScale(0) pauses the game, and requestExit() quits.

ui shows UI documents and reaches into them by element name:

local Hud = Guid.new("628098f1-29ea-4b5e-bdea-e6a828da27f5") -- a UI document's ID
ui.push(Hud)
ui.findLabel("hud-lives"):setText("x " .. self.lives)
ui.findButton("play-btn"):onClick(function() ui.pop() end)

push shows a document over what is there, pop and back remove it, and replace swaps it.

Save keeps values between runs: a best time, an unlocked level, the game’s options. Values are ints, floats, booleans or strings, under any key you choose:

if score > Save.getInt("best.score", 0) then
Save.setInt("best.score", score)
Save.flush()
end

The second argument of a get is what it returns when nothing is saved yet. The game writes what changed when it ends; flush() writes now, so a crash loses nothing. Playing in the editor keeps its own save, apart from a player’s.

Log.info, Log.warn and Log.error (Log::info and so on in AngelScript) write to the Console in the editor and to the log of an exported game.

Time.delta() and Time.now() give the frame time and the time, and Random.value(), Random.range(a, b) give random numbers.