Skip to content

Writing gameplay

This page shows the script API for the jobs most gameplay needs. The API browser on the editor’s script page lists 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 types are reference types, held through handles such as 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, the first frame the entity is active in play
onUpdate(dt) Every frame, dt the seconds since the last one
onEnable(), onDisable() When the behavior is turned on or off; onEnable also runs once before onStart
onDestroy() Before the entity is destroyed, or the Script component removed
onContactBegin, onContactEnd, onTriggerEnter, onTriggerExit On physics contacts (below)
on<Name>(payload) When a message or event named <Name> arrives (below)

A behavior has no fixed-rate update; a Level script has one. A Level script gets onStart, onUpdate(dt), onFixedUpdate(dt) (at the physics rate) and onStop. A Game script gets launch(), update(dt) and exit(). Both can also declare on<Name> handlers for events.

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, not keys or buttons. 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 the action is held, and wasPressed and wasReleased are true on the frame it changed. value reads a one-axis action. Input.rumble(low, high, seconds) shakes the gamepad, and stopRumble() stops it.

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 PebblePrefab = Guid.new("245b459c-4b93-4876-9496-6e4eb3daa145")
function Thief:throw()
local p = self.entity:worldPosition()
local stone = ScenePrefabs.of(self.entity.scene):spawn(PebblePrefab, Float3.new(p.x, p.y + 0.5, p.z))
end
-- In the pebble's own behavior:
function Pebble:onTriggerEnter(other)
self.entity:destroy()
end

spawn also takes a rotation, and a rotation and a parent. destroy() removes the entity and its children; during the frame’s update it waits until the update ends. scene.createEntity(name) makes an empty entity.

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 publishes an event to every script that has a handler for it: the behaviors in the scene, the Level script and the Game script. A Game script, which has no entity, publishes with run.events().emit(...).

-- 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 and events are delivered after the current update, not during the send or emit.

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(self.entity, Float3.new(0, 5, 0)) -- the entity needs a rigid body
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 hit (whether the ray hit anything), position, normal, distance and entity(). sphereCast, nearestOverlap and overlapSphere work the same way, and each also takes a collision group mask. A character moves through its CharacterComponent: move(velocityX, velocityZ), jump(speed) and 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 (playOneShotPath, playCuePath, playMusicPath; right-click an asset and choose Copy Path). playOneShot3D, playCue3D and playOneShot3DPath play at a position. The buses are Master, Effects, Music and UI; the path versions play on Effects, except music.

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 is disabled or destroyed. waitSeconds and waitUntil work only inside a coroutine.

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 destroyed once the new one is in. loadScene returns false when the scene could not load. loadSceneAsync loads in the background and returns a ticket to check with loadComplete(ticket) and loadFailed(ticket). setTimeScale(0) pauses the game (behaviors and physics stop; the Game script keeps running), and requestExit() quits.

gui reaches the game’s GUI. gui.root() is the screen layer, and the element with the id screens inside it is the game’s screen stack. The stack shows GUI documents by their ID, and find reaches into them by element id:

local HudDoc = "{4b6c9052-1ee3-4cd0-a04b-1fdb53c7ef9a}" -- a GUI document's ID
local screens = ScreenStack.of(gui.root():find("screens"))
screens:push(HudDoc)
Label.of(gui.root():find("hud-lives")).text = "x " .. self.lives
gui.root():find("play-btn"):on("click", function(e) screens:pop(); return true end)

push shows a document over what is there, pop removes the top one, replace swaps it and clear empties the stack. A control’s own properties are reached through its type: Label.of(node).text, ProgressBar.of(node).value (Label::of in AngelScript). A click handler that returns true takes the click. The user interface page has the rest.

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() gives the last frame’s duration in seconds, and Time.now() the seconds since the run started. Random.value() gives a number from 0 up to 1, Random.range(a, b) one between a and b, and Random.intRange(a, b) an integer.