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 asEntity@.
A behavior’s life
Section titled “A behavior’s life”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.
Moving things
Section titled “Moving things”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)endvoid onUpdate(double dt){ m_time += float(dt); // Spin about the up axis and bob up and down. self.setRotation(Quaternion::FromAxisAngle(Float3(0.0f, 1.0f, 0.0f), m_time * spinSpeed)); self.setPosition(Float3(m_home.x, m_home.y + Math::Sin(m_time * 2.5f) * 0.2f, m_home.z));}position(),rotation()andscale()and their setters work in the parent’s space.worldPosition(),worldRotation()andworldScale()read where the entity is in the world.setRotationEuler(x, y, z)takes degrees: pitch, yaw and roll.- In Luau a
Float3is Luau’s own vector type, made withFloat3.new(x, y, z); use Luau’smathlibrary. AngelScript hasMath::Sinand 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()endlocal crouched = Input.isDown("Sneak")local move = Input.value2D("Move") -- move.x, move.y from -1 to 1if (Input::wasPressed("Jump")){ jump();}bool crouched = Input::isDown("Sneak");Float2 move = Input::value2D("Move"); // move.x, move.y from -1 to 1isDown 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.
Finding entities and their components
Section titled “Finding entities and their components”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) -- ...endvoid onStart(){ @m_player = self.scene.find("Player");}
void onUpdate(double dt){ if (!m_player.isValid()) return; LightComponent@ light = LightComponent::of(self); // ...}- The scene’s
find(name)returns the first entity with that name;findByPath("Level/Doors/Front")follows the hierarchy. An entity’sfindChildByName,parent(),firstChild()andnextSibling()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,NavAgentComponentand more. Scene-wide services work the same way with the scene:ScenePhysics.of(scene),ScenePrefabs.of(scene). On an entity without the component it isnil(Luau) ornull(AngelScript), so test it before use. If the component is removed while a script still holds it, using it is a script error.
Spawning and destroying
Section titled “Spawning and destroying”-- 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// A property: pick the prefab in the inspector.["asset:Prefab", "The pebble thrown"] Guid@ pebble;
void throwPebble(){ Float3@ p = self.worldPosition(); Entity@ stone = ScenePrefabs::of(self.scene).spawn(pebble, Float3(p.x, p.y + 0.5f, p.z));}
// In the pebble's own behavior:void onTriggerEnter(Entity@ other){ self.destroy();}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.
Messages between scripts
Section titled “Messages between scripts”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 + valueend// In the coin:m_player.send("CoinCollected", 10);self.scene.events.emit("CoinCollected", 10);
// In the player, or in the Game script:void onCoinCollected(int value){ m_coins += value;}Messages and events are delivered after the current update, not during the send or emit.
Physics
Section titled “Physics”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) endend
function Pickup:onTriggerEnter(other) other:send("PickedUp")endvoid onContactBegin(Entity@ other, Float3 point, Float3 normal, float speed){ if (speed > 2.0f) { Audio::playOneShot3DPath("Audio/Thud", point.x, point.y, point.z); }}
void onTriggerEnter(Entity@ other){ other.send("PickedUp");}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)endScenePhysics@ physics = ScenePhysics::of(self.scene);physics.applyImpulse(self, Float3(0.0f, 5.0f, 0.0f)); // the entity needs a rigid body
RayCastHit@ hit = physics.rayCast(self.worldPosition(), Float3(0.0f, -1.0f, 0.0f), 2.0f);if (hit !is null && hit.hit){ Log::info("ground at " + hit.distance);}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 secondsGuid kCoinSound = Guid("6d963695-67f8-4b7f-9cf4-d234f8034116"); // a sound's ID
Audio::playOneShot(kCoinSound, AudioBus::Effects, 0.7f);Audio::playCuePath("Audio/Footsteps");Audio::playMusicPath("Audio/Theme", 2.0f); // cross-fade over two secondsA 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.
Waiting
Section titled “Waiting”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)endPass self as the second argument, so the coroutine stops when the behavior is disabled or
destroyed. waitSeconds and waitUntil work only inside a coroutine.
void onStart(){ startCoroutine(ScriptCoroutine(this.raise));}
void raise(){ wait(1.0f); self.setPosition(0.0f, 3.0f, 0.0f); Coroutine::waitUntil(CoroutinePredicate(this.isOpen)); Log::info("open");}
bool isOpen() { return m_open; }Changing scene
Section titled “Changing scene”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)Guid kLevel2 = Guid("c0d1c3a4-58e2-4f3b-9a51-0f6e4f1d2b77");run::loadScene(kLevel2);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.
The HUD and menus
Section titled “The HUD and menus”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.livesgui.root():find("play-btn"):on("click", function(e) screens:pop(); return true end)const string kHudDoc = "{4b6c9052-1ee3-4cd0-a04b-1fdb53c7ef9a}"; // a GUI document's IDfuncdef bool EventHandler(GuiEvent e); // a click handler's shape
// In the Game class:void showHud(){ ScreenStack@ screens = ScreenStack::of(gui::root().find("screens")); screens.push(kHudDoc); Label::of(gui::root().find("hud-lives")).text = "x " + m_lives; gui::root().find("play-btn").on("click", EventHandler(this.onPlay));}
bool onPlay(GuiEvent e){ ScreenStack::of(gui::root().find("screens")).pop(); return true;}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.
Saving progress
Section titled “Saving progress”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()endif (score > Save::getInt("best.score", 0)){ Save::setInt("best.score", score); Save::flush();}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.
Logging
Section titled “Logging”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.