Attachment & lifecycle
How Scripts Run
A complete reference for the scripting surface: how a script is
attached and run, the lifecycle callbacks, and every function the
horizon API exposes. Lua and Python share one
underlying API, so the same calls are available in both — only the
way you package a script differs.
You attach a script to an entity with the Script
component, which references a script asset by UUID plus a logical
module name. Each script asset is either
Lua or Python — the language is
stored on the asset and is inferred from the file extension
(.py → Python, everything else → Lua). Both languages
are hosted side by side; an instance is routed to the right runtime
automatically.
The lifecycle is driven by the editor's Play Mode.
When you enter play mode, every entity that carries an
enabled Script component gets one script
instance bound to it. Each instance is bound to its
entity: the raw entity id is exposed as
self.entityId in Lua and self.entity_id
in Python. That id is the first argument to nearly every
horizon function.
| Phase | When | Callback |
|---|---|---|
| Start | Once, when play mode begins (after property overrides are injected). | onStart(self) / on_start(self) |
| Update | Every play-mode frame. dt is the elapsed
time in seconds. |
onUpdate(self, dt) / on_update(self, dt) |
| Collision enter | When another physics body starts touching this entity.
otherId is the other entity's id. |
onCollisionEnter(self, otherId) /
on_collision_enter(self, otherId) |
| Collision exit | When another body stops touching this entity. | onCollisionExit(self, otherId) /
on_collision_exit(self, otherId) |
Every callback is optional — if a script doesn't define one, it's simply skipped. Collision callbacks only fire for entities that also have physics colliders.
Current scope: scripts are executed by the editor's Play Mode. The standalone runtime that ships with an exported game does not yet drive script lifecycles, so gameplay scripts run inside the editor for now.
Two languages, one API
Lua vs. Python
A Lua script is a module: create a table, add
lifecycle functions to it, and return it. Every
callback takes self as its first parameter (the
per-instance table). A Python script defines a
single subclass of horizon.Behavior; the callbacks are
ordinary methods, and each instance is one object of that class.
-- Lua: return a table of callbacks
local M = {}
function M.onStart(self) end
function M.onUpdate(self, dt) end
return M
# Python: subclass horizon.Behavior
import horizon
class MyScript(horizon.Behavior):
def on_start(self): pass
def on_update(self, dt): pass
Naming: the lifecycle uses each language's
convention — onUpdate in Lua, on_update
in Python. The horizon.* functions, however, are
camelCase in both languages — you call
horizon.setPosition(...) from Python too, not
set_position.
Per-instance data
Exposed Properties
You can expose typed fields that appear as editable controls in the
Details panel, set per instance. In Lua,
add a properties table to your module; in
Python, declare typed class attributes (any
non-underscore attribute). Supported types are
number (int or float), bool and
string. The value you set in the editor is
injected into the instance before onStart
runs, so read it there or in onUpdate.
-- Lua
local M = {}
M.properties = { speed = 4.0, jumpHeight = 2.0, canFly = false }
function M.onUpdate(self, dt)
-- self.speed is the per-instance value from the editor
end
return M
# Python: typed class attributes become editable properties
class Player(horizon.Behavior):
speed = 4.0
jump_height = 2.0
can_fly = False
Function reference
The horizon API
The same functions are available in Lua (as the global
horizon table) and Python (via import
horizon). Entity arguments are the raw entity id. Invalid
ids are tolerated: getters return neutral defaults, setters are
no-ops.
| Function | Returns | Description |
|---|---|---|
log(message) |
— | Write a string to the engine log. |
getName(id) |
string | The entity's name. |
getPosition(id) |
x, y, z | The entity's position. Default (0, 0, 0). |
setPosition(id, x, y, z) |
— | Move the entity's transform. |
getRotation(id) |
x, y, z | Euler angles in degrees. Default (0, 0, 0). |
setRotation(id, x, y, z) |
— | Set Euler rotation in degrees. |
getScale(id) |
x, y, z | Scale. Default (1, 1, 1). |
setScale(id, x, y, z) |
— | Set scale. |
spawn(parentId, name) |
id | Create a named entity parented under parentId,
returns its id. name is optional
(defaults to "Entity"). |
destroy(id) |
— | Remove an entity. |
raycast(ox, oy, oz, dx, dy, dz [, maxDist]) |
table / nil |
Physics ray from an origin along a direction. Returns a
hit table (see below) or nil/None
on a miss. maxDist defaults to
1000. |
setVelocity(id, vx, vy, vz) |
— | Set a character controller's velocity. |
isGrounded(id) |
bool | Whether a character controller stands on ground. |
Physics functions
(raycast, setVelocity,
isGrounded) need an active physics world. Without
one, raycast returns a miss,
isGrounded returns false and
setVelocity is a no-op.
The raycast hit table
A hit is a flat table (Lua) / dict (Python) — the point and normal are spread across individual keys, not nested:
| Key | Meaning |
|---|---|
entity |
Id of the entity that was hit. |
x, y, z |
The hit point in world space. |
nx, ny, nz |
The surface normal at the hit point. |
distance |
Distance from the ray origin to the hit. |
Python subclass
horizon.Behavior (Python)
Python scripts must define exactly one subclass of
horizon.Behavior. The base class provides the bound
entity_id attribute; every lifecycle method is
optional and overridden as needed:
| Member | Description |
|---|---|
entity_id |
Attribute: the id of the entity this instance is bound to. |
on_start(self) |
Called once when play mode begins. |
on_update(self, dt) |
Called each frame with the delta time in seconds. |
on_collision_enter(self, other_id) |
Called when another body starts touching this entity. |
on_collision_exit(self, other_id) |
Called when another body stops touching this entity. |
Worked examples
Complete Examples
A minimal starter in each language — these mirror the skeletons the editor generates when you create a new script:
local M = {}
function M.onStart(self)
end
function M.onUpdate(self, dt)
end
return M
import horizon
class NewScript(horizon.Behavior):
def on_start(self):
pass
def on_update(self, dt):
pass
And a working example in each language — a mover that logs its name, spawns a child, and drives its own transform every frame:
local M = {}
M.properties = { speed = 2.0 }
function M.onStart(self)
self.t = 0
horizon.log("Patroller start: " .. horizon.getName(self.entityId))
-- spawn a marker parented to this entity
self.marker = horizon.spawn(self.entityId, "Marker")
end
function M.onUpdate(self, dt)
self.t = self.t + dt
local x = math.sin(self.t) * self.speed
horizon.setPosition(self.entityId, x, 0, 0)
end
function M.onCollisionEnter(self, otherId)
horizon.log("Hit " .. horizon.getName(otherId))
end
return M
import horizon
import math
class Patroller(horizon.Behavior):
speed = 2.0
def on_start(self):
self.t = 0.0
horizon.log("Patroller start: " + horizon.getName(self.entity_id))
# spawn a marker parented to this entity
self.marker = horizon.spawn(self.entity_id, "Marker")
def on_update(self, dt):
self.t += dt
x = math.sin(self.t) * self.speed
horizon.setPosition(self.entity_id, x, 0, 0)
def on_collision_enter(self, other_id):
horizon.log("Hit " + horizon.getName(other_id))
Hot reload: while play mode is running, saving a
script patches its callbacks into the live instances — your data
fields (self.t, self.marker, …) are
preserved across the reload.