Reference

Engine API

All 582 engine functions in 42 groups, each with its Lua, Python, HorizonCode and C++ name, plus the 35 flat horizon.* functions and every callback a script receives.

Read this first

Calling Conventions

Every function on this page comes from one table inside the engine, the API registry. A HorizonCode graph, a Lua script, a Python script and C++ game code all call the same row, so a function behaves the same in all four. What differs is how you spell the call and how arguments travel. These rules apply to every row below.

One function, four names

FrontendHow physics.addImpulse is called
HorizonCodethe node Add Impulse (search the node menu by that name)
Luahorizon.physics.addImpulse(self.entityId, 0, 5, 0)
Pythonhorizon.physics.addImpulse(self.entity_id, 0, 5, 0)
C++ inside the engineHE::api::physics::addImpulse(ctx, entity, {0, 5, 0})
C++ game modulehe::physics::addImpulse(entity, {0, 5, 0}), where a service helper exists

The C++ name listed with every row is the engine's own function behind it. A native GameLogic module does not link against the engine and cannot call it; it reaches a smaller set (save, entity lookup, physics, input, content, networking and anti-cheat) through the he:: service helpers, see Native C++.

Function names are camelCase in Python too: horizon.physics.addImpulse, not add_impulse. Four groups are not reachable as horizon.<group>.* from Lua and Python: transform, material, cursor and log. Scripts use the flat functions for them instead (horizon.setPosition, horizon.setMaterialParam, horizon.showCursor, horizon.log). Rows without a flat twin, such as transform.getWorldPosition, are HorizonCode and C++ only. Each group below says which case it is.

Arguments and results

Arguments are passed flat, in the order the signature lists them. A vector is spread into its numbers, it is not a table or a tuple:

Type in the signatureLuaPython
floatone numberone float (an int is accepted)
int, entity, ref, enumone integerone int
booltrue / falseTrue / False
stringone stringone str
vec22 numbers: x, y2 numbers
vec33 numbers: x, y, z3 numbers
color, vec44 numbers: r, g, b, a4 numbers
structone table with __typeone dict with "__type"

Results spread the same way. One value comes back as that value, several as Lua multiple return values or a Python tuple, none as nil / None. physics.raycast takes two vec3 and a float (seven numbers) and returns a bool, an entity, two vec3, a float and an int (ten values):

ground.lua
local x, y, z = horizon.getPosition(self.entityId)
local hit, other, px, py, pz, nx, ny, nz, dist, layer =
    horizon.physics.raycast(x, y, z,  0, -1, 0,  50)
if hit then
    horizon.log("ground is " .. dist .. " below")
end
ground.py
x, y, z = horizon.getPosition(self.entity_id)
hit, other, px, py, pz, nx, ny, nz, dist, layer = \
    horizon.physics.raycast(x, y, z,  0, -1, 0,  50)
if hit:
    horizon.log(f"ground is {dist} below")

Too few arguments is an error: Lua raises bad argument #n … (number expected, got no value), Python raises IndexError. The one exception is a missing bool in Lua, which silently reads as false. Extra arguments are ignored in both languages. Optional arguments exist only where a signature shows them in brackets, which is only on the flat functions.

Entity ids and “Self”

An entity is passed as its integer id: self.entityId in Lua, self.entity_id in Python, or whatever a lookup like horizon.entity.findByName returned. Invalid ids are tolerated: getters return a neutral default, setters do nothing.

▲

Always pass your own id. Parameters marked self in HorizonCode mean “the calling object” when a graph leaves the pin unconnected. A Lua or Python script is not such an object, so there it does not apply: 0 is passed on unchanged, the engine logs “Entity is empty and there is no calling object”, and the call acts on entity 0, the scene root. Write horizon.entity.getName(self.entityId), never horizon.entity.getName(0).

List results (not yet usable from scripts)

▲

A few rows return a list, such as physics.overlapSphere, physics.raycastAll, fs.list, save.list or scene.available, and process.run takes one. HorizonCode and C++ handle these fine. The Lua and Python bridge does not yet translate lists: such a call currently returns a single 0 or "" instead of the list, and the list argument of process.run only accepts a string, which it then drops. These rows are marked list: HorizonCode/C++ only below. Until the bridge is fixed, use them from a HorizonCode graph.

Events in the descriptions

The descriptions come from the editor and name HorizonCode events such as On Timer or On Http Response. A script receives only the events listed under Callbacks; those are linked where they appear. The others (On Http Response, On File Changed, On Menu Item, On Tray Item, On Row Bind, On Window Closed, On Dismissed, On Selection Changed, On Right Clicked) have no Lua or Python callback today. Where a query exists, poll it instead, for example horizon.http.done(ticket) each frame after horizon.http.get. Otherwise handle that part in a HorizonCode graph.

Actions and queries

Every row is either an action (it changes something: moves, spawns, plays, writes) or a query (it only reads and returns). In HorizonCode an action is a node with white execution pins that runs in sequence, a query is a data node without them. In a script both are ordinary calls, but the tag tells you whether a call is safe to repeat freely.

Project permissions

A few rows reach outside the game and only run when the project allows it, under Project Settings ▸ Permissions. Without the permission the call does nothing and returns its “failed” value (false, 0, an empty result), and the log says which permission is missing. The settings travel into the exported app, and they bind play mode in the editor the same way.

PermissionRows
Files outside the projectfs.* with an absolute path that the user did not pick in a file dialog, print.toPdf, db.open
Run other programsprocess.run, process.openUrl, print.file, app.setAutostart
Network accesshttp.get, http.post

Dialogs and the clipboard need no permission. A path the user picked in a file dialog stands in for Files outside the project, but only for the fs.* rows: print.toPdf and db.open need the permission for every path, a relative one inside the project included (build 7d49d44f). The log line about a missing permission is written for the first refused call only; further refusals of any row within the next ten seconds are not logged.

Remote calls take any arguments

horizon.net.callServer(entity, name, …), horizon.net.callClient(player, entity, name, …) and horizon.net.callAllClients(entity, name, …) accept any number of extra arguments after the function name and pass them on. Their types are taken from the values themselves: a boolean, an integer, any other number, a string, or a list/table of those. This is the one place where the registry signature (which has no slot for them) does not describe the script call. The receiving side is a method of that name on the target entity's script, see Callbacks.

Your own enums and structs

Project Types in Scripts

Enums and structs you create as assets in the Content Browser are available to scripts under horizon.enums and horizon.structs, built when the script runtime starts.

WhatLuaPython
Enum entry (an integer)horizon.enums.Weapon.Swordhorizon.enums.Weapon.Sword
New struct with its default valueshorizon.structs.Loadout() → tablehorizon.structs.Loadout() → dict

A struct value is a plain table / dict: one key per field plus __type, the path of the struct asset. Keep __type when you hand the value back, it is how the engine knows which struct it is. Missing fields keep their defaults, unknown keys are ignored. Inside a struct, fields are single values rather than spread: a vec2 is {x, y} / [x, y], a colour {r, g, b, a}, a transform {pos = {…}, rot = {…}, scl = {…}}, a list a sequence, and a nested struct a nested table. A map field is a Python dict (it keeps its order); in Lua it is a table plus a __keys list that holds the order. Structs go in and out of save.setStruct / save.getStruct this way.

Lua and Python only

Flat horizon.* Functions

Besides the groups, horizon carries 35 functions directly: horizon.setPosition(...) rather than horizon.transform.setPosition(...). They predate the registry, exist only in Lua and Python, and keep a signature of their own: some arguments are optional (in brackets), and raycast returns a table instead of separate values. Where a registry row does the same job it is linked; for transform, material, cursor and log these functions are the only way in from a script.

Log and entities

FunctionReturnsDescription
horizon.log(message)—Writes a line to the Console panel and to HorizonEngine.log. The only way to reach the log row from a script: there is no horizon.log.* group. Same job as log.
horizon.getName(entity)stringThe entity's name, "" for an invalid id. Same job as entity.getName.
horizon.spawn(parent [, name])entityCreates an empty entity named name (default "Entity") under parent and returns its id. parent 0 or invalid = top level. Unlike horizon.entity.spawn, the name is optional here. Same job as entity.spawn.
horizon.destroy(entity)—Removes the entity; an invalid id is ignored. Check the id first: in a script 0 is the scene root, not “nothing”. Same job as entity.destroy.

Transform

FunctionReturnsDescription
horizon.getPosition(entity)x, y, zLocal position, i.e. relative to the parent. (0, 0, 0) for an invalid id. There is no horizon.transform.* group, so this is the only script route to the row. Same job as transform.getPosition.
horizon.setPosition(entity, x, y, z)—Sets the local position. On an entity with a rigid body or a character controller this is a teleport, so the physics step does not snap it back. Same job as transform.setPosition.
horizon.getRotation(entity)x, y, zLocal Euler rotation in degrees. (0, 0, 0) for an invalid id. Same job as transform.getRotation.
horizon.setRotation(entity, x, y, z)—Sets the local Euler rotation in degrees; a teleport on physics bodies, like setPosition. Same job as transform.setRotation.
horizon.getScale(entity)x, y, zLocal scale. (1, 1, 1) for an invalid id. Same job as transform.getScale.
horizon.setScale(entity, x, y, z)—Sets the local scale. Same job as transform.setScale.

Physics

FunctionReturnsDescription
horizon.raycast(ox, oy, oz, dx, dy, dz [, maxDist])hit table / nilCasts a ray from the origin along the direction, up to maxDist (default 1000). Returns a flat table (Lua) / dict (Python) with the keys entity, x, y, z (hit point), nx, ny, nz (surface normal) and distance, or nil / None on a miss or without a physics world. horizon.physics.raycast answers the same question as ten return values and also reports the hit layer. Same job as physics.raycast.
horizon.setVelocity(entity, vx, vy, vz)—Drives the character controller if the entity has one, its rigid body otherwise. No-op without a physics world. Same job as physics.setVelocity.
horizon.isGrounded(entity)boolWhether the entity's character controller stands on ground. false without a physics world or a controller. Same job as physics.isGrounded.

Material

FunctionReturnsDescription
horizon.setMaterialParam(entity, name, x [, y, z, w])boolSets the node-graph material parameter name on the entity's material at runtime. One to four numbers, omitted ones are 0, so a scalar parameter takes just x. false when the entity has no material or the parameter does not exist. Known issue: it writes the material asset, so every entity using that material changes with it (see Material). There is no horizon.material.* group. Same job as material.setParam.
horizon.getMaterialParam(entity, name)x, y, z, wReads a material parameter back as four numbers, from the material asset (an override on the entity's Details panel is not seen); (0, 0, 0, 0) when it cannot be found. Same job as material.getParam.

In-game UI elements

These act on entities that carry UI components (a UI canvas in the scene). Widgets built in the Widget Designer are a different thing, see the next table.

FunctionReturnsDescription
horizon.setUIText(entity, text)—Replaces the text of the entity's UI Text component. Same job as ui.setText.
horizon.getUIText(entity)stringThe text of the UI Text component, "" without one. Same job as ui.getText.
horizon.setUIColor(entity, r, g, b [, a])—Sets the element's primary colour: image tint, text colour or button normal colour, whichever exists (image first). a defaults to 1. Same job as ui.setColor.
horizon.getUIColor(entity)r, g, b, aThe primary colour in the same order; (1, 1, 1, 1) when there is none. Same job as ui.getColor.
horizon.setUIVisible(entity, visible)—Switches the UI element on or off, together with everything under it. Same job as ui.setVisible.
horizon.isUIVisible(entity)boolWhether the UI element is switched on. Same job as ui.getVisible.
horizon.setUIPosition(entity, x, y)—Moves the element, in canvas units. Same job as ui.setPosition.
horizon.getUIPosition(entity)x, yThe element's position in canvas units. Same job as ui.getPosition.
horizon.setUISize(entity, w, h)—Resizes the element, in canvas units. Same job as ui.setSize.
horizon.getUISize(entity)w, hThe element's size in canvas units. Same job as ui.getSize.
horizon.setUIMaterialParam(entity, name, x [, y, z, w])boolsetMaterialParam for the material of the entity's UI Image. false without one or for an unknown parameter. Same job as ui.setMaterialParam.

Widgets

Widgets are instances of Widget Designer assets. They live outside the entity world and are addressed by the widget id createWidget returns, not by an entity id. The rest of the widget API (lists, animation, popups, children) is horizon.widget.*.

FunctionReturnsDescription
horizon.createWidget(assetPath)widgetInstantiates the widget asset at the content-relative path and returns its id, 0 on failure. The widget starts hidden; showWidget puts it on screen. HorizonCode does the same with the Create Widget node.
horizon.destroyWidget(widget)—Removes the widget instance.
horizon.showWidget(widget)—Shows the widget on screen.
horizon.hideWidget(widget)—Hides it again; the instance and its state stay.
horizon.setWidgetZOrder(widget, z)—Draw order among widgets: higher is on top. Same job as widget.setZOrder.
horizon.isWidgetVisible(widget)boolWhether the widget is currently shown. Same job as widget.isVisible.
horizon.callWidgetFunction(widget, name)boolCalls a public function of the widget's graph by name. false for a private or unknown function. Same job as widget.callFunction.

Cursor

FunctionReturnsDescription
horizon.showCursor()—Releases the mouse and shows the pointer, e.g. for a menu. There is no horizon.cursor.* group. Same job as cursor.setVisible.
horizon.hideCursor()—Hides the pointer and grabs the mouse again for camera control. Same job as cursor.setVisible.

What the engine calls on your script

Callbacks

A Lua script returns a table of functions, a Python script defines one subclass of horizon.Behavior. The engine calls the functions below on it by name; every one is optional, and a missing one is simply skipped. Lua names are camelCase, Python names snake_case. Receives says who gets the call: this entity means only the script on the entity concerned, every script means every Lua and Python instance in the session, so check the arguments before reacting. Results of many calls on this page arrive here: timers, input actions, network events, anti-cheat reports.

Lifecycle

Lua / PythonReceivesWhen
onStart(self)
on_start(self)
this entityOnce, when the script starts: at the start of play mode, in a packaged game when its scene loads, and for scripts on entities that arrive later (an additive zone). Exposed properties are already set.
onUpdate(self, dt)
on_update(self, dt)
this entityEvery frame. dt is the scaled frame time in seconds (0 while the game is paused through horizon.time.setTimeScale(0)).

Physics contacts

Both entities of a contact get the event, each with the other's id. A contact where either side no longer exists is dropped entirely. A trigger contact fires both pairs: onCollisionEnter and onBeginOverlap, then onCollisionExit and onEndOverlap.

Lua / PythonReceivesWhen
onCollisionEnter(self, other)
on_collision_enter(self, other)
this entityAnother physics body starts touching this entity.
onCollisionExit(self, other)
on_collision_exit(self, other)
this entityIt stops touching.
onBeginOverlap(self, other)
on_begin_overlap(self, other)
this entitySomething enters a trigger collider on this entity, or this entity enters one.
onEndOverlap(self, other)
on_end_overlap(self, other)
this entityThe overlap ends.

Input actions

Actions are the ones defined in the project's input mapping. These go to every script instance, not only to the player's entity; pick the action by name. For polling instead of events use horizon.input.actionDown and friends.

Lua / PythonReceivesWhen
onInputPressed(self, name)
on_input_pressed(self, name)
every scriptA button action went down this frame.
onInputReleased(self, name)
on_input_released(self, name)
every scriptA button action went up this frame.
onInputAxis(self, name, value)
on_input_axis(self, name, value)
every scriptEvery frame, for each 1D axis action, with its current value (not multiplied by dt).
onInputAxis2D(self, name, x, y)
on_input_axis2d(self, name, x, y)
every scriptEvery frame, for each 2D axis action. Note the lower-case d in the Python name.

Timers

Lua / PythonReceivesWhen
onTimer(self, handle)
on_timer(self, handle)
every scriptA timer started with horizon.timer.after or horizon.timer.every went off. handle is the value those calls returned; compare it to tell your timers apart, because every instance receives every timer. Timers run on real time, a paused game does not stretch them.

Animation notifies

Lua / PythonReceivesWhen
onAnimationNotify(self, name)
on_animation_notify(self, name)
this entityThe entity's animation passed a single-frame notify called name.
onAnimationNotifyBegin(self, name)
on_animation_notify_begin(self, name)
this entityA notify state (a notify with a duration) started.
onAnimationNotifyEnd(self, name)
on_animation_notify_end(self, name)
this entityThe notify state ended.

In-game UI pointer

Sent to scripts on entities with an interactive UI element (a button, or any element carrying a script) under the mouse.

Lua / PythonReceivesWhen
onClick(self)
on_click(self)
this entityThe element was clicked: pressed and released over it.
onHoverEnter(self)
on_hover_enter(self)
this entityThe pointer moved onto the element.
onHoverExit(self)
on_hover_exit(self)
this entityThe pointer left it.

Multiplayer session

Every script instance on the machine concerned receives these. Each event happens on one side only: the host hears players come and go, a client hears its own connection. Measured with host and client in Lua and Python.

Lua / PythonReceivesWhen
onConnected(self)
on_connected(self)
every scriptClient only: the connection is up and the host's world has arrived (net.status() is 3). A client gets no onSessionStarted.
onDisconnected(self, reason)
on_disconnected(self, reason)
every scriptClient only: the host is gone or removed us; the status is now 4. reason: 0 left, 1 connection lost, 2 kicked, 3 refused, 4 other engine version, 5 other project. A kick with anti-cheat off arrives as 1. Not sent for a leave() of our own, and not followed by onSessionEnded.
onPlayerJoined(self, player)
on_player_joined(self, player)
every scriptHost only: a player joined and has the world. player is their player number (2, 3, …), the one net.playerName takes, not an anti-cheat connection number.
onPlayerLeft(self, player)
on_player_left(self, player)
every scriptHost only: a player left, was kicked or timed out. net.playerName(player) is already empty.
onSessionStarted(self)
on_session_started(self)
every scriptHost only: net.host opened a session.
onSessionEnded(self)
on_session_ended(self)
every scriptnet.leave() ended the session on this machine, on the host also when hosting again. Not sent to a client that was removed or lost the host (that is onDisconnected).

Replication and remote calls

Lua / PythonReceivesWhen
onRep_<var>(self, old)
on_rep_<var>(self, old)
this entityA replicated variable (declared with horizon.net.declareVarInt and its siblings) arrived with a new value on this client. Never called on the host, which set the value itself. <var> is the variable's name verbatim (health gives onRep_health / on_rep_health); old is the previous value: in Python a list [x, y, z] for a Vector, in Lua the number 0 for a Vector (known issue). Read the new one with horizon.net.getVarInt and its siblings. Only on the entity whose variable changed, and only for variables declared with notify. A client that wrote the variable itself gets it again when the host's value comes back.
<name>(self, …)
<name>(self, …)
this entityA remote call from horizon.net.callServer, callClient or callAllClients runs the method of exactly that name on the target entity's script, with the caller's extra arguments. No renaming between languages: a Lua takeDamage is reached by a Python caller as takeDamage. On the host the method runs only for a call from a client: offline and on the host itself these rows reach a HorizonCode class only, see Remote calls.

Anti-cheat

Lua / PythonReceivesWhen
onCheatDetected(self, reportId)
on_cheat_detected(self, reportId)
every scriptThe host's anti-cheat filed a report. Read it with horizon.anticheat.reportLevel, reportRule, reportPlayer … and override the host's reaction with horizon.anticheat.respond in the same frame. On a client it fires once, when the host is removing it (reportPlayer 0). Never fires in build 7d49d44f, see Anti-Cheat.

The whole registry at a glance

All Groups

GroupFunctionsIn Lua / Python
Math31horizon.math.*
String12horizon.string.*
Random5horizon.random.*
Time13horizon.time.*
Timer5horizon.timer.*
Date and Time9horizon.datetime.*
JSON8horizon.json.*
Debug4horizon.debug.*
Log1horizon.log (flat)
Entity18horizon.entity.*
Transform8flat functions only
Scene12horizon.scene.*
Content4horizon.content.*
Save17horizon.save.*
File System13horizon.fs.*
Preferences9horizon.prefs.*
Physics33horizon.physics.*
Navigation6horizon.nav.*
Movement6horizon.movement.*
Locomotion6horizon.locomotion.*
Player6horizon.player.*
Input17horizon.input.*
Animator8horizon.animator.*
Particles4horizon.particle.*
Audio18horizon.audio.*
Camera26horizon.camera.*
Material2flat functions only
Environment116horizon.env.*
UI Elements12horizon.ui.*
Widget28horizon.widget.*
Cursor1flat functions only
Theme6horizon.theme.*
Window5horizon.window.*
Dialog5horizon.dialog.*
Clipboard3horizon.clipboard.*
App24horizon.app.*
Process3horizon.process.*
Print3horizon.print.*
Database7horizon.db.*
HTTP9horizon.http.*
Multiplayer44horizon.net.*
Anti-Cheat15horizon.anticheat.*

Core and data · 31 functions

Math

Lua / Python: horizon.math.<function> · C++: HE::api::math::<function>

Pure functions over numbers: none of them changes anything, so they can be called anywhere and as often as needed. In a script the language's own math library does most of this too, and with more precision. horizon.math is for the results a HorizonCode graph would get, and for what the languages lack: lerp, clamp, the vector helpers and 32-bit bit operations.

  • Angles. sin, cos, tan and atan2 work in radians, while the engine's rotations are in degrees. radians and degrees convert between them; horizon.math.sin(90) is 0.894, not 1.
  • Precision. Every float crosses into the engine as a 32-bit number, about seven significant digits: normalize3(3, 0, 4) returns 0.6000000238…, 0, 0.8000000119…. Compare results with a tolerance, not with ==.
  • Vectors are spread like every other argument: length(x, y), distance3(ax, ay, az, bx, by, bz). normalize3 and cross return three numbers.
  • Bit operations (bitAnd to shiftRight) take and return 32-bit signed integers. Lua accepts an integer or a float with no fraction (6.0); 6.5 raises number has no integer representation. Python accepts only an int; any float raises TypeError. Results wrap at 32 bits: shiftLeft(1, 31) is −2147483648.

Example: math.dot3

view_cone.lua
local M = {}

-- True when target lies within coneDeg degrees around the direction
-- (fx, fy, fz) this entity faces.
function M.canSee(self, target, fx, fy, fz, coneDeg)
    local x, y, z = horizon.getPosition(self.entityId)
    local tx, ty, tz = horizon.getPosition(target)
    local dx, dy, dz = horizon.math.normalize3(tx - x, ty - y, tz - z)
    local limit = horizon.math.cos(horizon.math.radians(coneDeg / 2))
    return horizon.math.dot3(dx, dy, dz, fx, fy, fz) >= limit
end

return M
view_cone.py
import horizon

class ViewCone(horizon.Behavior):
    def can_see(self, target, fx, fy, fz, cone_deg):
        """True when target lies within cone_deg degrees around (fx, fy, fz)."""
        x, y, z = horizon.getPosition(self.entity_id)
        tx, ty, tz = horizon.getPosition(target)
        dx, dy, dz = horizon.math.normalize3(tx - x, ty - y, tz - z)
        limit = horizon.math.cos(horizon.math.radians(cone_deg / 2))
        return horizon.math.dot3(dx, dy, dz, fx, fy, fz) >= limit
FunctionReturnsDescription
sin(x: float)result: floatThe sine of an angle given in RADIANS. Feed degrees through To Radians first — this is the one that catches everyone.
Lua/Python horizon.math.sin · HorizonCode Sine · C++ HE::api::math::sin · query
cos(x: float)result: floatThe cosine of an angle in radians. Paired with Sine it turns an angle into a direction: cos for X, sin for Z.
Lua/Python horizon.math.cos · HorizonCode Cosine · C++ HE::api::math::cos · query
tan(x: float)result: floatThe tangent of an angle in radians. Runs off to infinity near 90 degrees, so keep the input away from it.
Lua/Python horizon.math.tan · HorizonCode Tangent · C++ HE::api::math::tan · query
sqrt(x: float)result: floatThe square root. A negative input has none and yields NaN, which then spreads silently through everything downstream — clamp at 0 first if the value can go negative.
Lua/Python horizon.math.sqrt · HorizonCode Square Root · C++ HE::api::math::sqrt · query
abs(x: float)result: floatDrops the sign: -3 and 3 both come out as 3. How a distance is made out of a difference.
Lua/Python horizon.math.abs · HorizonCode Absolute · C++ HE::api::math::abs · query
floor(x: float)result: floatRounds DOWN to a whole number: 2.9 becomes 2, and -2.1 becomes -3.
Lua/Python horizon.math.floor · HorizonCode Floor · C++ HE::api::math::floor · query
ceil(x: float)result: floatRounds UP to a whole number: 2.1 becomes 3.
Lua/Python horizon.math.ceil · HorizonCode Ceil · C++ HE::api::math::ceil · query
round(x: float)result: floatRounds to the nearest whole number, halves away from zero: 2.5 becomes 3, -2.5 becomes -3.
Lua/Python horizon.math.round · HorizonCode Round · C++ HE::api::math::round · query
sign(x: float)result: float-1 for a negative number, 1 for a positive one, 0 for zero. Which way, without how far.
Lua/Python horizon.math.sign · HorizonCode Sign · C++ HE::api::math::sign · query
radians(x: float)result: floatConverts degrees to radians. The engine's rotations are in degrees and the trigonometry is in radians, so this sits between them.
Lua/Python horizon.math.radians · HorizonCode Degrees to Radians · C++ HE::api::math::radians · query
degrees(x: float)result: floatConverts radians back to degrees — the direction to use before writing an angle into a transform.
Lua/Python horizon.math.degrees · HorizonCode Radians to Degrees · C++ HE::api::math::degrees · query
pow(base: float, exp: float)result: floatRaises the base to the exponent.
Lua/Python horizon.math.pow · HorizonCode Power · C++ HE::api::math::pow · query
mod(a: float, b: float)result: floatThe remainder after division, for doing something every Nth item. It keeps the sign of A: -1 mod 360 is -1, not 359, so wrapping an angle into 0..360 takes a second Modulo of the result plus 360. A divisor of 0 is a runtime error: it is logged and the result is 0 rather than a NaN.
Lua/Python horizon.math.mod · HorizonCode Modulo · C++ HE::api::math::mod · query
bitAnd(a: int, b: int)result: intKeeps only the bits set in BOTH integers. Testing a flag: AND the flags with the one you ask about, and the result is non-zero when it is set. Not the bool And — this works on the bits of an Int.
Lua/Python horizon.math.bitAnd · HorizonCode Bitwise AND · C++ HE::api::math::bitAnd · query
bitOr(a: int, b: int)result: intSets every bit set in EITHER integer. Adding a flag to a set of flags.
Lua/Python horizon.math.bitOr · HorizonCode Bitwise OR · C++ HE::api::math::bitOr · query
bitXor(a: int, b: int)result: intSets the bits that differ between the two integers. Toggling a flag: XOR with it once turns it on, XOR again turns it off.
Lua/Python horizon.math.bitXor · HorizonCode Bitwise XOR · C++ HE::api::math::bitXor · query
bitNot(x: int)result: intFlips every bit of the integer (two's complement, so 0 becomes -1). Clearing a flag: AND with the NOT of it.
Lua/Python horizon.math.bitNot · HorizonCode Bitwise NOT · C++ HE::api::math::bitNot · query
shiftLeft(x: int, count: int)result: intMoves the bits of X toward the high end by Count places, filling with zeros: each place doubles the value (1 shifted by 3 is 8). Packing two small numbers into one Int. A negative count shifts right instead; a count of 32 or more shifts everything out and gives 0.
Lua/Python horizon.math.shiftLeft · HorizonCode Shift Left · C++ HE::api::math::shiftLeft · query
shiftRight(x: int, count: int)result: intMoves the bits of X toward the low end by Count places: each place halves the value, rounding toward minus infinity. The sign is kept (-8 shifted by 1 is -4), so a negative value never turns positive. A negative count shifts left instead; a count of 32 or more leaves 0 (or -1 for a negative value).
Lua/Python horizon.math.shiftRight · HorizonCode Shift Right · C++ HE::api::math::shiftRight · query
atan2(y: float, x: float)result: floatThe angle of the vector (X, Y) in radians, over the full circle. This is how a direction becomes a heading, and it gets the quadrant right where plain arctangent cannot.
Lua/Python horizon.math.atan2 · HorizonCode Atan2 · C++ HE::api::math::atan2 · query
min(a: float, b: float)result: floatThe smaller of the two values.
Lua/Python horizon.math.min · HorizonCode Min · C++ HE::api::math::min · query
max(a: float, b: float)result: floatThe larger of the two values. Max with 0 is the usual way to stop a value going negative.
Lua/Python horizon.math.max · HorizonCode Max · C++ HE::api::math::max · query
clamp(x: float, lo: float, hi: float)result: floatKeeps a value inside Lo..Hi. Below Lo it becomes Lo, above Hi it becomes Hi.
Lua/Python horizon.math.clamp · HorizonCode Clamp · C++ HE::api::math::clamp · query
lerp(a: float, b: float, t: float)result: floatBlends from A to B by T: 0 gives A, 1 gives B, 0.5 the middle. T outside 0..1 is not clamped — it keeps going past either end.
Lua/Python horizon.math.lerp · HorizonCode Lerp · C++ HE::api::math::lerp · query
length(v: vec2)result: floatThe length of a 2D vector.
Lua/Python horizon.math.length · HorizonCode Length (Vec2) · C++ HE::api::math::length · query
distance(a: vec2, b: vec2)result: floatThe distance between two 2D points.
Lua/Python horizon.math.distance · HorizonCode Distance (Vec2) · C++ HE::api::math::distance · query
length3(v: vec3)result: floatThe length of a 3D vector — how far it reaches, regardless of direction.
Lua/Python horizon.math.length3 · HorizonCode Length (Vec3) · C++ HE::api::math::length3 · query
distance3(a: vec3, b: vec3)result: floatThe distance between two 3D points in metres.
Lua/Python horizon.math.distance3 · HorizonCode Distance (Vec3) · C++ HE::api::math::distance3 · query
normalize3(v: vec3)result: vec3The same direction at length 1. A zero vector has no direction and stays zero rather than becoming a NaN.
Lua/Python horizon.math.normalize3 · HorizonCode Normalize (Vec3) · C++ HE::api::math::normalize3 · query
dot3(a: vec3, b: vec3)result: floatThe dot product: how much two directions agree. 1 means the same way, 0 perpendicular, -1 opposite. With normalised vectors it is the cosine of the angle between them, which is how a field-of-view check is written.
Lua/Python horizon.math.dot3 · HorizonCode Dot Product · C++ HE::api::math::dot3 · query
cross(a: vec3, b: vec3)result: vec3A vector perpendicular to both inputs. Forward crossed with up gives right, which is how a local axis is built from a facing.
Lua/Python horizon.math.cross · HorizonCode Cross Product · C++ HE::api::math::cross · query

Core and data · 12 functions

String

Lua / Python: horizon.string.<function> · C++: HE::api::str::<function>

Text functions with the same results a HorizonCode graph gets. Three rules decide most surprises:

  • Positions count from 0, also in Lua, whose own string.sub and string.find count from 1. find returns −1 when the piece is not there.
  • Lengths and positions are bytes. Text is UTF-8: letters, digits and punctuation from plain ASCII take one byte, an accented or non-Latin letter two to four. length("ä") is 2, and a substring can cut such a letter in half. toUpper and toLower change only A to Z.
  • Comparisons are exact. equals, contains, find, startsWith and endsWith are case-sensitive; lower both sides first when case should not matter.

An empty piece is found everywhere: contains(s, "") is true and find(s, "") is 0, while replace(s, "", x) returns s unchanged. A negative start in substring uses up part of count, so substring("hello", -2, 4) is "he". toNumber reads the number at the start of the text and ignores the rest: "3.5abc" gives 3.5, " 42 " 42, "1e3" 1000 and "0x10" 16. The result is a 32-bit float, exact for whole numbers only up to 16,777,216.

Example: string.find

settings_line.lua
local M = {}

-- "volume = 75" -> "volume", 75. Positions count from 0.
local function parse(line)
    local eq = horizon.string.find(line, "=")
    if eq < 0 then return nil end
    local key = horizon.string.trim(horizon.string.substring(line, 0, eq))
    local rest = horizon.string.substring(line, eq + 1, horizon.string.length(line))
    return key, horizon.string.toNumber(rest)
end

function M.onStart(self)
    local key, value = parse("volume = 75")
    if key then horizon.log(key .. " is " .. value) end
end

return M
settings_line.py
import horizon

def parse(line):
    """'volume = 75' -> ('volume', 75.0). Positions count from 0."""
    eq = horizon.string.find(line, "=")
    if eq < 0:
        return None
    key = horizon.string.trim(horizon.string.substring(line, 0, eq))
    rest = horizon.string.substring(line, eq + 1, horizon.string.length(line))
    return key, horizon.string.toNumber(rest)

class Settings(horizon.Behavior):
    def on_start(self):
        parsed = parse("volume = 75")
        if parsed:
            key, value = parsed
            horizon.log(f"{key} is {value}")
FunctionReturnsDescription
length(s: string)length: intHow long the text is in bytes. Plain letters and digits are one byte each, but an accented or non-Latin letter takes two to four ("ä" is 2).
Lua/Python horizon.string.length · HorizonCode String Length · C++ HE::api::str::length · query
equals(a: string, b: string)result: boolAre the two texts the same? Exact and case-sensitive — put To Lower on both sides when case should not matter. This is what to branch on after On Menu Item or On Tray Item: the Equals node next door compares NUMBERS, and two texts arriving there both count as 0, so every id would match every other one.
Lua/Python horizon.string.equals · HorizonCode String Equals · C++ HE::api::str::equals · query
substring(s: string, start: int, count: int)result: stringCount bytes starting at Start (counting from 0). A range past the end is clamped rather than an error.
Lua/Python horizon.string.substring · HorizonCode Substring · C++ HE::api::str::substring · query
contains(s: string, needle: string)contains: boolDoes the text contain this piece? Case-sensitive.
Lua/Python horizon.string.contains · HorizonCode String Contains · C++ HE::api::str::contains · query
find(s: string, needle: string)index: intWhere the piece first occurs, counting from 0, or -1 when it does not. In Lua, add 1 before handing the result to string.sub.
Lua/Python horizon.string.find · HorizonCode String Find · C++ HE::api::str::find · query
replace(s: string, from: string, to: string)result: stringReplaces every occurrence of From with To.
Lua/Python horizon.string.replace · HorizonCode String Replace · C++ HE::api::str::replace · query
toUpper(s: string)result: stringThe text in upper case. Only A to Z change; accented letters stay as they are.
Lua/Python horizon.string.toUpper · HorizonCode To Upper · C++ HE::api::str::toUpper · query
toLower(s: string)result: stringThe text in lower case. Only A to Z change; accented letters stay as they are.
Lua/Python horizon.string.toLower · HorizonCode To Lower · C++ HE::api::str::toLower · query
trim(s: string)result: stringRemoves whitespace from both ends — what a text input needs before it is compared to anything.
Lua/Python horizon.string.trim · HorizonCode Trim · C++ HE::api::str::trim · query
startsWith(s: string, prefix: string)result: boolDoes the text begin with this piece?
Lua/Python horizon.string.startsWith · HorizonCode Starts With · C++ HE::api::str::startsWith · query
endsWith(s: string, suffix: string)result: boolDoes the text end with this piece?
Lua/Python horizon.string.endsWith · HorizonCode Ends With · C++ HE::api::str::endsWith · query
toNumber(s: string)number: floatParses the text as a number. Text that is not one yields 0, so this cannot tell "0" from nonsense — validate first if that matters.
Lua/Python horizon.string.toNumber · HorizonCode To Number · C++ HE::api::str::toNumber · query

Core and data · 5 functions

Random

Lua / Python: horizon.random.<function> · C++: HE::api::random::<function>

One random generator for the whole game, shared by every Lua and Python script and every HorizonCode graph. It starts from the same built-in seed at every launch, so a game that never calls seed draws the same numbers every time it starts. Seed it once at start: from the clock when every run should differ, from a constant when a bug should happen again.

Because it is shared, a seed restarts the sequence for everyone, and another script drawing in between changes the numbers you get. A repeated run matches only if the same scripts draw in the same order. Lua's math.random and Python's random module are separate generators and seed does not touch them.

All five are actions, because every call draws a new number (see Actions and queries). range can return min but not max; rangeInt includes both ends. Either one swaps the bounds when max is smaller than min. chance treats anything below 0 as 0 and anything above 1 as 1.

Example: random.seed

dice.lua
local M = {}

function M.onStart(self)
    -- Without this, every launch rolls the same numbers. Seed in one
    -- script only: every seed restarts the sequence for all of them.
    horizon.random.seed(os.time())
end

function M.roll(self)
    local eyes = horizon.random.rangeInt(1, 6)      -- 1 to 6, both included
    local critical = horizon.random.chance(0.05)     -- one in twenty
    return eyes, critical
end

return M
dice.py
import time
import horizon

class Dice(horizon.Behavior):
    def on_start(self):
        # Without this, every launch rolls the same numbers. Seed in one
        # script only: every seed restarts the sequence for all of them.
        horizon.random.seed(int(time.time()))

    def roll(self):
        eyes = horizon.random.rangeInt(1, 6)     # 1 to 6, both included
        critical = horizon.random.chance(0.05)   # one in twenty
        return eyes, critical
FunctionReturnsDescription
seed(seed: int)—Restarts the random sequence from this seed, so a run can be repeated exactly. Without it the sequence starts from the same built-in seed at every launch, so a game that should differ each time seeds once at start, from the clock.
Lua/Python horizon.random.seed · HorizonCode Seed Random · C++ HE::api::random::seed · action
value()value: floatA random number from 0 up to (but not including) 1. An exec node, not a pure one, precisely because it changes every call: a pure node would be re-evaluated per reader and hand two of them different numbers.
Lua/Python horizon.random.value · HorizonCode Random Value · C++ HE::api::random::value · action
range(min: float, max: float)value: floatA random number between Min and Max. Can return min, never max.
Lua/Python horizon.random.range · HorizonCode Random Range · C++ HE::api::random::range · action
rangeInt(min: int, max: int)value: intA random whole number from Min to Max, both ends included.
Lua/Python horizon.random.rangeInt · HorizonCode Random Range (Int) · C++ HE::api::random::rangeInt · action
chance(p: float)value: boolTrue with probability P (0 never, 1 always). The coin flip, spelled out.
Lua/Python horizon.random.chance · HorizonCode Random Chance · C++ HE::api::random::chance · action

Core and data · 13 functions

Time

Lua / Python: horizon.time.<function> · C++: HE::api::time::<function>

The game clock. It advances once per frame, and every query gives the same answer for the rest of that frame. In the editor it runs only during Play and starts from 0 each time. For the time of day use Date and Time; to be called back later, use a Timer.

deltaTime and elapsed are scaled: they follow the time scale and stand still while the game is paused or in a hit stop. unscaledDeltaTime and unscaledElapsed follow real time, which is what a pause menu or a UI animation needs.

Three separate controls stop or slow the clock, and effectiveScale is their result:

  • Time scale (setTimeScale), limited to 0…5. A larger value becomes 5, a negative one 0.
  • Pause (pause / resume). It also silences input actions, except the ones with Fires while the game is paused ticked in the Input asset. Tick it on the key that opens the pause menu, or that key cannot close it again. All scripts and graphs share one pause, so a resume anywhere lifts a pause from anywhere. The pause the window sets when it loses focus is separate; resume cannot lift it.
  • Hit stop (hitStop): a freeze for a stretch of real time. Input keeps arriving, and isPaused stays false.

Pause and hit stop leave the time scale alone, so the game resumes at the speed it had. When a game loads another scene, the scale goes back to 1 and the script pause and any hit stop are lifted; elapsed and frameCount keep counting.

Example: time.pause

pause_menu.lua
local M = {}

-- "Pause" is an input action with "Fires while the game is paused"
-- ticked; without it the second press would never arrive.
function M.onInputPressed(self, name)
    if name ~= "Pause" then return end
    if horizon.time.isPaused() then
        horizon.time.resume()
    else
        horizon.time.pause()
    end
end

function M.onCollisionEnter(self, other)
    horizon.time.hitStop(0.1)   -- a landed hit freezes for a tenth of a second
end

return M
pause_menu.py
import horizon

class PauseMenu(horizon.Behavior):
    # "Pause" is an input action with "Fires while the game is paused"
    # ticked; without it the second press would never arrive.
    def on_input_pressed(self, name):
        if name != "Pause":
            return
        if horizon.time.isPaused():
            horizon.time.resume()
        else:
            horizon.time.pause()

    def on_collision_enter(self, other):
        horizon.time.hitStop(0.1)   # a landed hit freezes for a tenth of a second
FunctionReturnsDescription
deltaTime()dt: floatSeconds since the previous frame, scaled by the time scale. Multiply anything per-second by it and the motion stays the same on a fast machine and a slow one.
Lua/Python horizon.time.deltaTime · HorizonCode Delta Time · C++ HE::api::time::deltaTime · query
elapsed()seconds: floatSeconds since the game started, scaled.
Lua/Python horizon.time.elapsed · HorizonCode Elapsed Time · C++ HE::api::time::elapsed · query
frameCount()frame: intHow many frames have been drawn. For "every Nth frame" work; frames are not a unit of time, so do not pace anything with it.
Lua/Python horizon.time.frameCount · HorizonCode Frame Count · C++ HE::api::time::frameCount · query
setTimeScale(scale: float)—Slows down or speeds up game time: 0.5 is half speed, 0 is slow motion down to a standstill, 1 is normal. This is the scale you asked for; a Pause Game or a Hit Stop overrides it without erasing it. Real-time Delay nodes and Unscaled Delta Time are deliberately unaffected. Limited to 0…5.
Lua/Python horizon.time.setTimeScale · HorizonCode Set Time Scale · C++ HE::api::time::setTimeScale · action
timeScale()scale: floatThe time scale that was asked for — what Set Time Scale last wrote, even while a pause or a hit stop is holding the clock at zero. Effective Time Scale is the one actually in effect.
Lua/Python horizon.time.timeScale · HorizonCode Get Time Scale · C++ HE::api::time::timeScale · query
unscaledDeltaTime()dt: floatThe same, but ignoring Set Time Scale — so a UI animation keeps running at normal speed while the game is in slow motion or paused.
Lua/Python horizon.time.unscaledDeltaTime · HorizonCode Unscaled Delta Time · C++ HE::api::time::unscaledDeltaTime · query
unscaledElapsed()seconds: floatReal seconds since the game started — it keeps counting through slow motion, a pause and a hit stop, which is what a pause menu needs to time anything at all.
Lua/Python horizon.time.unscaledElapsed · HorizonCode Unscaled Elapsed Time · C++ HE::api::time::unscaledElapsed · query
pause()—Pauses the game: Delta Time goes to zero and the player's input stops being delivered, which is what makes a pause menu a pause menu. The time scale is left alone, so resuming returns to the slow motion it came from. Input actions stop arriving unless they have Fires while the game is paused ticked.
Lua/Python horizon.time.pause · HorizonCode Pause Game · C++ HE::api::time::pause · action
resume()—Lifts the pause this script asked for. It cannot lift the one the window put in place when the game lost focus — those are separate reasons, and the game runs again only once none of them is left.
Lua/Python horizon.time.resume · HorizonCode Resume Game · C++ HE::api::time::resume · action
isPaused()paused: boolTrue while the game is paused. Note it stays FALSE during a hit stop: a freeze stops the world, it does not stop the player being heard.
Lua/Python horizon.time.isPaused · HorizonCode Is Paused · C++ HE::api::time::isPaused · query
hitStop(seconds: float)—Freezes the game for a moment of real time — 0.08 to 0.12 seconds is the usual weight for a landed hit. It returns to exactly the speed it came from, slow motion included, and input is never swallowed. Triggering it again takes the longer of the two windows rather than adding them up. 0 or less does nothing, and does not end a hit stop that is running.
Lua/Python horizon.time.hitStop · HorizonCode Hit Stop · C++ HE::api::time::hitStop · action
isFrozen()frozen: boolTrue while a Hit Stop is still running. For the effect that spawned it, not for gameplay logic.
Lua/Python horizon.time.isFrozen · HorizonCode Is Frozen · C++ HE::api::time::isFrozen · query
effectiveScale()scale: floatWhat actually multiplies Delta Time this frame: the time scale, or zero while a pause or a hit stop is on. The number to put on a debug HUD.
Lua/Python horizon.time.effectiveScale · HorizonCode Effective Time Scale · C++ HE::api::time::effectiveScale · query

Core and data · 5 functions

Timer

Lua / Python: horizon.timer.<function> · C++: HE::api::timer::<function>

A timer calls back instead of waiting. after and every return a handle at once and the script carries on; when the time is up the engine calls onTimer(self, handle) (Python on_timer, see Callbacks) on every script instance, and fires the GameInstance's On Timer event. Keep the handle and compare it, because the other scripts' timers arrive too.

  • Timers run on real time: pause, slow motion and hit stop do not stretch them. To wait in game time, count horizon.time.deltaTime() in onUpdate.
  • Handles count up from 1. 0 means the timer did not start: the interval was shorter than 0.001 seconds or not a number, or 64 timers were already running.
  • A timer belongs to the session, not to the script or entity that started it. Destroying the entity or loading another scene does not stop it; stopping Play or quitting the game does. cancelAll stops every timer, other scripts' and the GameInstance's included. To stop only your own, use cancel(handle).

Example: timer.every

reminders.lua
local M = {}

function M.onStart(self)
    self.autosave = horizon.timer.every(30)
    self.hint = horizon.timer.after(5)
end

function M.onTimer(self, handle)
    if handle == self.autosave then
        horizon.log("autosave")
    elseif handle == self.hint then
        horizon.log("Press E to open the door")
    end
    -- any other handle belongs to another script
end

return M
reminders.py
import horizon

class Reminders(horizon.Behavior):
    def on_start(self):
        self.autosave = horizon.timer.every(30)
        self.hint = horizon.timer.after(5)

    def on_timer(self, handle):
        if handle == self.autosave:
            horizon.log("autosave")
        elif handle == self.hint:
            horizon.log("Press E to open the door")
        # any other handle belongs to another script
FunctionReturnsDescription
after(seconds: float)handle: intFires On Timer once, after the seconds you give it, and hands back a handle so you can tell it apart from other timers (and cancel it before it goes off). Unlike Delay it does not park the graph: everything carries on and the event arrives later. 0 means it did not start. 0 also when 64 timers are already running.
Lua/Python horizon.timer.after · HorizonCode Timer After · C++ HE::api::timer::after · action
every(seconds: float)handle: intThe same, but it keeps firing until you cancel it — a clock, an autosave, a poll. If the application was busy or away it fires ONCE when it comes back, not once for every tick it missed. 0 when the interval is under 0.001 seconds or 64 timers are already running.
Lua/Python horizon.timer.every · HorizonCode Timer Every · C++ HE::api::timer::every · action
cancel(handle: int)ok: boolStops a timer, using the handle it gave you. Ok is false when there was no such timer running — it already fired, or it was cancelled before.
Lua/Python horizon.timer.cancel · HorizonCode Cancel Timer · C++ HE::api::timer::cancel · action
active(handle: int)active: boolIs this timer still running? False for a one-shot that has already gone off, for a cancelled one, and for a handle that never existed.
Lua/Python horizon.timer.active · HorizonCode Timer Is Running · C++ HE::api::timer::active · query
cancelAll()—Stops every timer this application started. They also all stop when it closes. That includes the timers of every other script and of the GameInstance.
Lua/Python horizon.timer.cancelAll · HorizonCode Cancel All Timers · C++ HE::api::timer::cancelAll · action

Core and data · 9 functions

Date and Time

Lua / Python: horizon.datetime.<function> · C++: HE::api::datetime::<function>

The wall clock: calendar date and time of day in the time zone of the computer the game runs on. Unlike Time it keeps going through pauses and has nothing to do with frames. A point in time is a number of seconds since 1 January 1970 (the Unix epoch), which is what now returns and every other function takes. format uses strftime patterns: %Y-%m-%d, %H:%M, %A for the weekday name. An empty pattern, or a result longer than 511 bytes, gives an empty string. weekday counts from 0 for Sunday.

▲

Known issue: accurate only to about a minute. The seconds pass through the engine as a 32-bit float, which today can only hold multiples of 128. now is therefore up to 64 seconds off, and so are second, minute and format with %S or %M, also when you pass in an exact time. The hour, day, month, year and weekday are only wrong in the minute around a change. Until this is fixed, take a time that has to be exact from the language: os.time() and os.date() in Lua, time.time() and datetime in Python.

Example: datetime.now

clock_label.lua
local M = {}

local DAYS = { [0] = "Sunday", "Monday", "Tuesday", "Wednesday",
               "Thursday", "Friday", "Saturday" }

function M.onStart(self)
    local now = horizon.datetime.now()
    horizon.log("Today is " .. DAYS[horizon.datetime.weekday(now)] .. ", "
                .. horizon.datetime.format(now, "%d.%m.%Y"))
    -- To the second: from Lua itself until the precision issue is fixed.
    horizon.log("It is " .. os.date("%H:%M:%S"))
end

return M
clock_label.py
import time
import horizon

DAYS = ["Sunday", "Monday", "Tuesday", "Wednesday",
        "Thursday", "Friday", "Saturday"]

class ClockLabel(horizon.Behavior):
    def on_start(self):
        now = horizon.datetime.now()
        day = DAYS[horizon.datetime.weekday(now)]
        horizon.log(f"Today is {day}, {horizon.datetime.format(now, '%d.%m.%Y')}")
        # To the second: from Python itself until the precision issue is fixed.
        horizon.log("It is " + time.strftime("%H:%M:%S"))
FunctionReturnsDescription
now()epochSeconds: floatThe current wall-clock time as seconds since 1970. This is the clock the operating system shows, not the game clock: pausing does not stop it. Accurate only to about a minute, see the known issue.
Lua/Python horizon.datetime.now · HorizonCode Now · C++ HE::api::datetime::now · query
format(epochSeconds: float, format: string)text: stringTurns a time into text using a strftime pattern, in local time. "%Y-%m-%d %H:%M" gives you 2026-08-27 14:32. %M and %S are accurate only to about a minute, see the known issue.
Lua/Python horizon.datetime.format · HorizonCode Format Time · C++ HE::api::datetime::format · query
year(epochSeconds: float)value: intThe year of that time, in local time, as a full number like 2026.
Lua/Python horizon.datetime.year · HorizonCode Year · C++ HE::api::datetime::year · query
month(epochSeconds: float)value: intThe month of that time in local time, 1 for January through 12.
Lua/Python horizon.datetime.month · HorizonCode Month · C++ HE::api::datetime::month · query
day(epochSeconds: float)value: intThe day of the month of that time, in local time, from 1 to 31.
Lua/Python horizon.datetime.day · HorizonCode Day · C++ HE::api::datetime::day · query
hour(epochSeconds: float)value: intThe hour of that time in local time, from 0 to 23.
Lua/Python horizon.datetime.hour · HorizonCode Hour · C++ HE::api::datetime::hour · query
minute(epochSeconds: float)value: intThe minute of that time in local time, from 0 to 59. Accurate only to about a minute, see the known issue.
Lua/Python horizon.datetime.minute · HorizonCode Minute · C++ HE::api::datetime::minute · query
second(epochSeconds: float)value: intThe second of that time in local time, 0 to 60 (leap seconds). Not usable until the known issue is fixed.
Lua/Python horizon.datetime.second · HorizonCode Second · C++ HE::api::datetime::second · query
weekday(epochSeconds: float)value: intThe day of the week of that time in local time, with 0 for Sunday through 6 for Saturday.
Lua/Python horizon.datetime.weekday · HorizonCode Weekday · C++ HE::api::datetime::weekday · query

Core and data · 8 functions

JSON

Lua / Python: horizon.json.<function> · C++: HE::api::json::<function>

Reading and writing JSON text without a parser of your own: a config file from File System, a reply from HTTP. Every call takes the whole document as a string. The setters do not change it in place; they return the new document, so keep the result: doc = horizon.json.setNumber(doc, "volume", 0.8).

A path is a chain of keys separated by dots, with [i] for an array element counted from 0: user.name, items[2].id. The empty path is the document itself, so count(text, "") counts a top-level array. A key that itself contains a dot cannot be reached.

  • Getters never fail loudly. A missing path, a value of another type and text that is not JSON all return the fallback. has is true for a key whose value is null.
  • Setters create missing objects along the path, and an empty string starts a new document. They cannot add array elements: a path through a missing index, or through a value that is not an object, returns the document unchanged, as does text that is not JSON.
  • The returned text is compact and its keys come out in alphabetical order, whatever order they had.
  • Numbers pass through a 32-bit float. getNumber is exact for whole numbers up to 16 777 216 and keeps about seven digits otherwise; setNumber writes 3 as 3.0 and 0.1 as 0.10000000149011612. Read ids and other large numbers that the source wrote as strings with getString.

Example: json.getString

settings_file.lua
local M = {}
local FILE = "settings.json"

-- settings.json in the game's data folder, e.g.
-- {"video": {"width": 1920}, "profiles": [{"name": "Ada"}, {"name": "Lin"}]}
function M.onStart(self)
    local doc = horizon.fs.readText(FILE)   -- "" when the file is missing
    local width = horizon.json.getNumber(doc, "video.width", 1280)
    for i = 0, horizon.json.count(doc, "profiles") - 1 do
        horizon.log(horizon.json.getString(doc, "profiles[" .. i .. "].name", "?"))
    end
    -- Setters return the new text; store it and write it back yourself.
    doc = horizon.json.setNumber(doc, "video.width", width)
    doc = horizon.json.setBool(doc, "video.seen", true)
    horizon.fs.writeText(FILE, doc)
end

return M
settings_file.py
import horizon

FILE = "settings.json"

class SettingsFile(horizon.Behavior):
    # settings.json in the game's data folder, e.g.
    # {"video": {"width": 1920}, "profiles": [{"name": "Ada"}, {"name": "Lin"}]}
    def on_start(self):
        doc = horizon.fs.readText(FILE)   # "" when the file is missing
        width = horizon.json.getNumber(doc, "video.width", 1280)
        for i in range(horizon.json.count(doc, "profiles")):
            horizon.log(horizon.json.getString(doc, f"profiles[{i}].name", "?"))
        # Setters return the new text; store it and write it back yourself.
        doc = horizon.json.setNumber(doc, "video.width", width)
        doc = horizon.json.setBool(doc, "video.seen", True)
        horizon.fs.writeText(FILE, doc)
FunctionReturnsDescription
getString(text: string, path: string, fallback: string)value: stringReads a text value out of JSON. The path is dotted, with [i] for array elements: "user.name", "items[2].id". Missing, wrong type or unparsable text all give you the fallback.
Lua/Python horizon.json.getString · HorizonCode JSON Get String · C++ HE::api::json::getString · query
getNumber(text: string, path: string, fallback: float)value: floatReads a number out of JSON at a dotted path. Missing, wrong type or unparsable text all give you the fallback rather than an error. Exact for whole numbers up to 16 777 216.
Lua/Python horizon.json.getNumber · HorizonCode JSON Get Number · C++ HE::api::json::getNumber · query
getBool(text: string, path: string, fallback: bool)value: boolReads a true/false value out of JSON at a dotted path. Falls back to the given value when the path is missing or holds something else.
Lua/Python horizon.json.getBool · HorizonCode JSON Get Bool · C++ HE::api::json::getBool · query
has(text: string, path: string)present: boolTrue when the dotted path exists in the JSON text. Says nothing about what type the value has, only that something is there. true also for a key whose value is null.
Lua/Python horizon.json.has · HorizonCode JSON Has · C++ HE::api::json::has · query
count(text: string, path: string)count: intHow many elements the array at this path has, or zero when the path is not an array. Use it to walk items[0], items[1] without guessing the end.
Lua/Python horizon.json.count · HorizonCode JSON Array Count · C++ HE::api::json::count · query
setString(text: string, path: string, value: string)result: stringWrites a text value into JSON and returns the whole document as new text. Missing objects along the path are created; an empty input starts a new document.
Lua/Python horizon.json.setString · HorizonCode JSON Set String · C++ HE::api::json::setString · query
setNumber(text: string, path: string, value: float)result: stringWrites a number into JSON at a dotted path and returns the whole document as new text. Missing objects along the path are created. Writes whole numbers as 3.0 and keeps about seven digits.
Lua/Python horizon.json.setNumber · HorizonCode JSON Set Number · C++ HE::api::json::setNumber · query
setBool(text: string, path: string, value: bool)result: stringWrites a true/false value into JSON at a dotted path and returns the whole document as new text. Missing objects along the path are created.
Lua/Python horizon.json.setBool · HorizonCode JSON Set Bool · C++ HE::api::json::setBool · query

Core and data · 4 functions

Debug

Lua / Python: horizon.debug.<function> · C++: HE::api::debug::<function>

Lines, spheres and boxes drawn into the 3D view, to see what a script is doing: where a ray went, what an overlap covered. A shape stays for seconds; 0 draws it for one frame, so a shape that follows something moving is drawn again every frame from onUpdate. seconds counts down in real time, so a paused game does not keep a shape alive longer; in the editor the countdown runs only during Play. The color is red, green and blue from 0 to 1; its alpha is ignored. clear removes every shape, including the ones other scripts drew. Debug shapes appear in a packaged game too, so take them out, or put them behind a flag of your own, before you ship.

▲

Known issue: four numbers per position. The positions of line, sphere and box are declared as colors, so Lua and Python read four numbers for each one. Pass a fourth number (it is ignored) after every x, y, z: line takes 13 arguments, not 10. With too few, Lua reports bad argument #11 and Python raises IndexError. The signatures below show the extra number as _.

Example: debug.line

ground_probe.lua
local M = {}

function M.onUpdate(self, dt)
    local x, y, z = horizon.getPosition(self.entityId)
    -- Four numbers per position and color, the fourth is ignored.
    -- 0 seconds: drawn this frame only, so the shapes follow the entity.
    horizon.debug.line(x, y, z, 0,   x, y - 2, z, 0,   1, 0, 0, 1,   0)
    horizon.debug.sphere(x, y - 2, z, 0,   0.25,   0, 1, 0, 1,   0)
end

return M
ground_probe.py
import horizon

class GroundProbe(horizon.Behavior):
    def on_update(self, dt):
        x, y, z = horizon.getPosition(self.entity_id)
        # Four numbers per position and color, the fourth is ignored.
        # 0 seconds: drawn this frame only, so the shapes follow the entity.
        horizon.debug.line(x, y, z, 0,   x, y - 2, z, 0,   1, 0, 0, 1,   0)
        horizon.debug.sphere(x, y - 2, z, 0,   0.25,   0, 1, 0, 1,   0)
FunctionReturnsDescription
line(x1, y1, z1, _, x2, y2, z2, _, r, g, b, _, seconds)—Draws a line in the world for Seconds (0 = this frame only). A packaged game draws it too, so take debug drawing out before you ship.
Lua/Python horizon.debug.line · HorizonCode Draw Debug Line · C++ HE::api::debug::line · action
sphere(x, y, z, _, radius, r, g, b, _, seconds)—Draws a wireframe sphere in the world for Seconds. Useful for showing what an overlap or sphere cast actually covered.
Lua/Python horizon.debug.sphere · HorizonCode Draw Debug Sphere · C++ HE::api::debug::sphere · action
box(minX, minY, minZ, _, maxX, maxY, maxZ, _, r, g, b, _, seconds)—Draws a wireframe box between two world corners for Seconds.
Lua/Python horizon.debug.box · HorizonCode Draw Debug Box · C++ HE::api::debug::box · action
clear()—Removes every debug shape drawn so far, including ones whose time has not run out.
Lua/Python horizon.debug.clear · HorizonCode Clear Debug Draw · C++ HE::api::debug::clear · action

Core and data · 1 function

Log

◆

In Lua and Python this row is the flat function horizon.log(message).

horizon.log writes one line at Info level to the editor's Console and to HorizonEngine.log, in the editor and in a packaged game alike. In the editor the line is prefixed with the project's script language, for example [Lua]. The message must be text. Lua turns a number into text on its own, and anything else (nil, a table, a boolean) is an error. Python accepts only a str, so pass numbers through an f-string or str(). The language's own print writes to standard output only, which the Console does not show.

FunctionReturnsDescription
log(message: string)—Writes a line to the editor's Console panel and to HorizonEngine.log. The cheapest way to find out whether an exec chain ran at all. Lua also accepts a number; Python only a str.
Lua/Python horizon.log (flat) · HorizonCode Log · C++ HE::api::log · action

World and storage · 18 functions

Entity

Lua / Python: horizon.entity.<function> · C++: HE::api::entity::<function>

Creating, finding and removing entities. An entity is an integer id (see Entity ids). When a destroyed entity's slot is reused, the newcomer gets a different, larger id, so a kept id never points at a stranger; it just stops existing. Guard it with exists. exists(0) is false: 0 is what findByName returns when it finds nothing, even though it is also the scene root.

  • destroy takes the entity's children with it.
  • spawnClass and spawnClassRotated need the running game or play mode; they return 0 and log the reason when the class path is unknown or not an Entity class. The path is the class asset's content path, as in the Content Browser.
  • self, selfObject and the Ref rows (instance, owned, destroyObject) belong to HorizonCode objects. A Lua or Python script is not one, so self() and selfObject() always return 0 there; use self.entityId / self.entity_id.
  • Visibility only affects drawing. An entity with nothing to draw always reports getVisible = true, also right after setVisible(e, false).
▲

Known issue: spawn creates no transform. The new entity has a name and a parent, but no Transform component, so setPosition, setRotation and setScale on it do nothing and getPosition returns 0, 0, 0 (measured on build 7d49d44f, also for the flat horizon.spawn). Until this is fixed, spawn a class with spawnClass, or give an entity its position by placing it in the scene.

saveState, hasSavedState and applySavedState store an entity's pose and visibility in the active save, keyed by the entity's stable UUID, so the same entity finds its state again after the scene is loaded anew. Three things must hold, and each missing one logs its own warning and returns false: the game is running (play mode or the packaged game), a save is active (save.create / save.load), and the entity has an enabled Save State component, whose checkboxes choose what is stored. Nothing is on disk until save.write.

Example: entity.exists

boss_gate.lua
local M = {}

-- Opens the gate once the boss is gone. Look the ids up once, not every
-- frame: findByName walks every named entity.
function M.onStart(self)
    self.boss = horizon.entity.findByName("Boss")
    self.gate = horizon.entity.findByName("Gate")
end

function M.onUpdate(self, dt)
    if self.gate ~= 0 and not horizon.entity.exists(self.boss) then
        horizon.entity.destroy(self.gate)   -- takes its children along
        self.gate = 0
    end
end

return M
boss_gate.py
import horizon

class BossGate(horizon.Behavior):
    # Opens the gate once the boss is gone. Look the ids up once, not every
    # frame: findByName walks every named entity.
    def on_start(self):
        self.boss = horizon.entity.findByName("Boss")
        self.gate = horizon.entity.findByName("Gate")

    def on_update(self, dt):
        if self.gate != 0 and not horizon.entity.exists(self.boss):
            horizon.entity.destroy(self.gate)   # takes its children along
            self.gate = 0
FunctionReturnsDescription
getName(entity: int)name: stringThe entity's name as the World Outliner shows it. Empty for an entity that does not exist.
Lua/Python horizon.entity.getName, or flat horizon.getName · HorizonCode Get Name · C++ HE::api::entity::getName · query · entity is Self in HorizonCode
spawn(parent: int, name: string)entity: intCreates an empty entity at run time and returns it. It has a transform and nothing else — add what it needs with the other calls, or use Spawn Class, which brings the mesh, the collider, the body and the logic an author gave the class. An empty name becomes "Entity"; parent 0 puts it at the top level. Known issue: the entity gets no transform, so position calls on it do nothing (see the group notes).
Lua/Python horizon.entity.spawn, or flat horizon.spawn · HorizonCode Spawn Entity · C++ HE::api::entity::spawn · action
destroy(entity: int)—Removes the entity and everything parented under it. Any id still held elsewhere becomes invalid; check with Exists before using one you kept.
Lua/Python horizon.entity.destroy, or flat horizon.destroy · HorizonCode Destroy Entity · C++ HE::api::entity::destroy · action
spawnClass(class: string, x: float, y: float, z: float)entity: intCreates an instance of a HorizonCode class at this position and returns its entity. Unlike Spawn Entity it arrives furnished: the components the class carries, its physics body and its graph are all in place before Construct and Begin Play run, so its first frame already knows where it stands. Rotation stays as the class authored it. Returns 0 and logs why if the class is unknown or is not an Entity class.
Lua/Python horizon.entity.spawnClass · HorizonCode Spawn Class · C++ HE::api::entity::spawnClass · action
spawnClassRotated(class: string, x: float, y: float, z: float, rx: float, ry: float, rz: float)entity: intSpawn Class with the rotation stated as well, in Euler degrees. The two are separate calls because "leave the rotation the class authored" and "face zero degrees" are different requests, and a defaulted 0,0,0 could not tell them apart.
Lua/Python horizon.entity.spawnClassRotated · HorizonCode Spawn Class Rotated · C++ HE::api::entity::spawnClassRotated · action
destroyObject(object: ref)—Destroys a spawned object by its reference: the class instance, the entity under it and that entity's physics bodies. The counterpart to Spawn Class — reach the reference with Get Object On Entity. Destroy Entity is the other half of the pair and takes an entity instead.
Lua/Python horizon.entity.destroyObject · HorizonCode Destroy Object (Ref) · C++ HE::api::entity::destroyObject · action
self()entity: intThe entity this graph is running on. 0 in a graph that has none — the Game Instance, or a widget that was never attached to one. Always 0 in Lua and Python; use self.entityId / self.entity_id.
Lua/Python horizon.entity.self · HorizonCode Get Owning Entity · C++ HE::api::entity::self · query
selfObject()object: refThis graph's own instance as an object reference, for handing to Bind Event, Call External or anything else that takes a target. Always 0 in Lua and Python.
Lua/Python horizon.entity.selfObject · HorizonCode Get Owning Object · C++ HE::api::entity::selfObject · query
instance(entity: int)object: refThe HorizonCode object running on this entity, if one is. An empty reference when the entity carries no class.
Lua/Python horizon.entity.instance · HorizonCode Get Object On Entity · C++ HE::api::entity::instance · query · entity is Self in HorizonCode
owned(object: ref)entity: intThe entity an object reference sits on — the inverse of Instance.
Lua/Python horizon.entity.owned · HorizonCode Get Entity Of · C++ HE::api::entity::owned · query
distance(a: int, b: int)distance: floatStraight-line distance between two entities in metres. Cheaper than subtracting two positions and taking the length, and reads better. Negative when there is no world to measure in, which no running game has. Known issue: it measures between the local positions, so the answer is right only for entities with the same parent (or none). An entity without a transform counts as standing at the origin.
Lua/Python horizon.entity.distance · HorizonCode Distance Between · C++ HE::api::entity::distance · query
findByName(name: string)entity: intFinds the first entity with this name in the active scene. A linear search over every named entity: fine once in a while, wasteful every frame — store the id in a variable instead.
Lua/Python horizon.entity.findByName · HorizonCode Find By Name · C++ HE::api::entity::findByName · query
exists(entity: int)exists: boolIs this entity id still valid? The guard to run before touching an entity that something else may have destroyed. false for 0.
Lua/Python horizon.entity.exists · HorizonCode Entity Exists · C++ HE::api::entity::exists · query
setVisible(entity: int, visible: bool)—Shows or hides whatever the entity draws — mesh, skeletal mesh, light. Physics and scripts are untouched; it stops being drawn, not being there.
Lua/Python horizon.entity.setVisible · HorizonCode Set Entity Visible · C++ HE::api::entity::setVisible · action · entity is Self in HorizonCode
saveState(entity: int)ok: boolStores this entity's flagged attributes (see its Save State component) in the active save, keyed by its UUID. Play mode only. Needs play mode, an active save and an enabled Save State component; see the group notes.
Lua/Python horizon.entity.saveState · HorizonCode Save Entity State · C++ HE::api::entity::saveState · action · entity is Self in HorizonCode
hasSavedState(entity: int)has: boolDoes the active save carry state for this entity?
Lua/Python horizon.entity.hasSavedState · HorizonCode Has Saved State · C++ HE::api::entity::hasSavedState · query · entity is Self in HorizonCode
applySavedState(entity: int)ok: boolApplies every attribute the active save carries for this entity back onto it. Partial: what the save lacks stays as it is. Play mode only. On an entity with a physics body it moves the body too and stops its motion.
Lua/Python horizon.entity.applySavedState · HorizonCode Apply Saved State · C++ HE::api::entity::applySavedState · action · entity is Self in HorizonCode
getVisible(entity: int)visible: boolWhether the entity is currently drawn. True for an entity that has nothing to draw at all.
Lua/Python horizon.entity.getVisible · HorizonCode Get Entity Visible · C++ HE::api::entity::getVisible · query · entity is Self in HorizonCode

World and storage · 8 functions

Transform

◆

Lua and Python have no horizon.transform.*: use the flat horizon.getPosition / setPosition / getRotation / setRotation / getScale / setScale. The world-space rows are HorizonCode and C++ only.

Position, rotation and scale are local: relative to the parent, in metres and Euler degrees (X pitch, Y yaw, Z roll). Without a parent, local and world are the same. On an entity with a rigid body or a character controller, setPosition and setRotation teleport the body as well, so the physics step does not pull it back; nothing checks for walls on the way.

The world-space pair getWorldPosition / setWorldPosition exists only in HorizonCode and C++. A script that needs where a child really stands keeps such entities unparented, or does that part in a HorizonCode graph with Get World Position. Adding up the parents' positions is not enough once a parent is rotated or scaled.

FunctionReturnsDescription
getPosition(entity: int)position: vec3LOCAL position in metres: for a child entity this is its offset from the parent, not where it ends up. Use Get World Position for that. Without a parent the two are the same, which is why the difference only shows up on the first attached object.
Lua/Python only as flat horizon.getPosition · HorizonCode Get Position · C++ HE::api::transform::getPosition · query · entity is Self in HorizonCode
setPosition(entity: int, position: vec3)—Moves the entity to a LOCAL position — relative to its parent, if it has one. It teleports: nothing sweeps for collisions on the way, so a physics body can end up inside a wall. For a character use Move instead.
Lua/Python only as flat horizon.setPosition · HorizonCode Set Position · C++ HE::api::transform::setPosition · action · entity is Self in HorizonCode
getRotation(entity: int)rotation: vec3Rotation as euler angles in degrees (X pitch, Y yaw, Z roll).
Lua/Python only as flat horizon.getRotation · HorizonCode Get Rotation · C++ HE::api::transform::getRotation · query · entity is Self in HorizonCode
setRotation(entity: int, rotation: vec3)—Turns the entity to these euler angles in degrees. Y is the one that turns a standing character to face elsewhere.
Lua/Python only as flat horizon.setRotation · HorizonCode Set Rotation · C++ HE::api::transform::setRotation · action · entity is Self in HorizonCode
getScale(entity: int)scale: vec3Scale per axis. 1 is the mesh at its authored size.
Lua/Python only as flat horizon.getScale · HorizonCode Get Scale · C++ HE::api::transform::getScale · query · entity is Self in HorizonCode
setScale(entity: int, scale: vec3)—Resizes the entity. A physics collider follows the scale only approximately — for anything that has to collide accurately, scale the collider's own extents.
Lua/Python only as flat horizon.setScale · HorizonCode Set Scale · C++ HE::api::transform::setScale · action · entity is Self in HorizonCode
getWorldPosition(entity: int)position: vec3Where the entity actually stands, with every parent taken into account. Composed on the spot from the parent chain, so it is true even for an entity that moved or was spawned this frame.
not reachable from Lua/Python · HorizonCode Get World Position · C++ HE::api::transform::getWorldPosition · query · entity is Self in HorizonCode
setWorldPosition(entity: int, position: vec3)—Puts the entity at a world position whatever its parents are doing, by converting through them. Teleports like Set Position.
not reachable from Lua/Python · HorizonCode Set World Position · C++ HE::api::transform::setWorldPosition · action · entity is Self in HorizonCode

World and storage · 12 functions

Scene

Lua / Python: horizon.scene.<function> · C++: HE::api::scene::<function>

Every call that changes scenes is a request: it is queued and carried out at the start of the next frame, before any script's onUpdate, in the order it was made. So the code after load still runs in the old scene, and a zone you just loaded is not there yet: zonePosition returns 0, 0, 0 and zoneScene an empty string until the next frame. Show, hide and move requests made right after loadAdditive still apply to the new zone, because they queue behind it. Scene paths are relative to the project folder, for example Scenes/Level2.hescene.

  • Switching (load) replaces the whole scene. With hidden = true it only preloads the scene and its assets; hasPendingLevel turns true, and activate swaps it in without a loading hitch. A second hidden load replaces the pending one.
  • Zones (loadAdditive) add a scene next to the current one, at a world offset, and return a zone id at once. Zone ids count up from 1 and are never reused. A hidden zone is loaded and simulated but not drawn until showZone. A zone gets collision, and in the packaged game its scripts start when it arrives.
◆

In the editor's play mode zones work, but load and activate only log “runs in the packaged game” and keep the current scene. Leaving play mode removes every zone again.

Example: scene.loadAdditive

cave_stream.lua
local M = {}

-- This entity is a trigger volume at the start of the corridor. The cave
-- loads hidden, 200 m along X, while the player walks; the door shows it.
function M.onBeginOverlap(self, other)
    if self.zone then return end
    -- The position takes FOUR numbers from a script; the fourth is ignored.
    self.zone = horizon.scene.loadAdditive("Scenes/Cave.hescene", true, 200, 0, 0, 0)
end

-- "Interact" is an input action.
function M.onInputPressed(self, name)
    if name == "Interact" and self.zone then
        horizon.scene.showZone(self.zone)   -- queued behind the load
    end
end

return M
cave_stream.py
import horizon

class CaveStream(horizon.Behavior):
    # This entity is a trigger volume at the start of the corridor. The cave
    # loads hidden, 200 m along X, while the player walks; the door shows it.
    def on_start(self):
        self.zone = 0

    def on_begin_overlap(self, other):
        if self.zone:
            return
        # The position takes FOUR numbers from a script; the fourth is ignored.
        self.zone = horizon.scene.loadAdditive("Scenes/Cave.hescene", True, 200, 0, 0, 0)

    # "Interact" is an input action.
    def on_input_pressed(self, name):
        if name == "Interact" and self.zone:
            horizon.scene.showZone(self.zone)   # queued behind the load
FunctionReturnsDescription
load(scene: string, hidden: bool)—Replaces the current scene with another one. It happens at the end of the frame, not inside this call — the exec chain after it still runs, in the old scene. In the editor's play mode this only logs a warning.
Lua/Python horizon.scene.load · HorizonCode Load Scene · C++ HE::api::scene::load · action
loadAdditive(scene, hidden, x, y, z, _)zone: intLoads another scene ALONGSIDE this one at a world offset and returns its zone id, which every other zone call takes. Hidden loads it without showing it, so the streaming in can be paid for before the player arrives. Known issue: the position pin is typed as a colour, so Lua and Python must pass four numbers; the fourth is ignored. With three, Lua raises bad argument #6 and Python IndexError. An offset of 0, 0, 0 leaves the zone where its scene put it.
Lua/Python horizon.scene.loadAdditive · HorizonCode Load Scene Additive · C++ HE::api::scene::loadAdditive · action
unloadZone(zone: int)—Removes an additively loaded zone and everything in it.
Lua/Python horizon.scene.unloadZone · HorizonCode Unload Zone · C++ HE::api::scene::unloadZone · action
activate()—Makes the pending level the active one. The second half of a load that was started earlier. Logs a warning when nothing was preloaded. In the editor's play mode this only logs a warning.
Lua/Python horizon.scene.activate · HorizonCode Activate Loaded Scene · C++ HE::api::scene::activate · action
hasPendingLevel()pending: boolIs a level load waiting to be activated?
Lua/Python horizon.scene.hasPendingLevel · HorizonCode Has Pending Scene · C++ HE::api::scene::hasPendingLevel · query
showZone(zone: int)—Shows a zone that was loaded hidden. The entities were there all along; this is the moment they start being drawn and ticked.
Lua/Python horizon.scene.showZone · HorizonCode Show Zone · C++ HE::api::scene::showZone · action
hideZone(zone: int)—Hides a zone without unloading it — cheap to undo, unlike unload.
Lua/Python horizon.scene.hideZone · HorizonCode Hide Zone · C++ HE::api::scene::hideZone · action
zonePosition(zone: int)position: vec3The world offset a zone was loaded at. 0, 0, 0 until the zone has arrived, the frame after loadAdditive.
Lua/Python horizon.scene.zonePosition · HorizonCode Get Zone Position · C++ HE::api::scene::zonePosition · query
setZonePosition(zone: int, position: vec3)—Moves a whole loaded zone to a new world offset. How an endless world recycles a chunk instead of loading a new one. Rebuilds the zone's collision at the new place, which stops moving bodies in it.
Lua/Python horizon.scene.setZonePosition · HorizonCode Set Zone Position · C++ HE::api::scene::requestZonePosition · action
zoneScene(zone: int)scene: stringWhich scene asset a zone was loaded from. Empty until the zone has arrived.
Lua/Python horizon.scene.zoneScene · HorizonCode Get Zone Scene · C++ HE::api::scene::zoneScene · query
loadedZones()zones: int[]The ids of every additively loaded zone, as an array.
Lua/Python horizon.scene.loadedZones · HorizonCode Loaded Zones · C++ HE::api::scene::loadedZones · query · list: HorizonCode/C++ only
available()scenes: string[]Every scene asset in the project, as an array of paths — so a level select does not have to hardcode the list.
Lua/Python horizon.scene.available · HorizonCode Available Scenes · C++ HE::api::scene::availableScenes · query · list: HorizonCode/C++ only

World and storage · 4 functions

Content

Lua / Python: horizon.content.<function> · C++: HE::api::content::<function>

Loading assets ahead of time. Everything a scene uses is loaded with it, so these calls are for what a scene does not name yet: the level behind a door, a mesh a script will spawn. An asset is named by its path inside the project's Content folder, as the Content Browser shows it, for example Meshes/Crate.hasset. load reads the file right away, in the calling frame, so call it before the moment the asset is needed.

load on an asset that is already in memory returns true without reading it again. A path that does not exist returns false and logs where it looked. unload returns false the second time. typeName answers only for a loaded asset, with names like StaticMesh, Texture or Material, and an empty string otherwise.

FunctionReturnsDescription
load(asset: string)loaded: boolBrings an asset into memory ahead of the moment it is needed — the mesh behind the door, the music for the next area. Already loaded is a success, not a second load. Reading the file is the slow part, so do it early rather than in the frame that needs it.
Lua/Python horizon.content.load · HorizonCode Load Asset · C++ HE::api::content::load · action
unload(asset: string)unloaded: boolLets go of an asset again and frees what it held. False means it was not loaded in the first place. Anything still using it — a mesh component, a playing sound — will stop finding it, so unload what you have left behind, not what is on screen.
Lua/Python horizon.content.unload · HorizonCode Unload Asset · C++ HE::api::content::unload · action
isLoaded(asset: string)loaded: boolWhether this asset is in memory right now. Asking does not load it, so this is the safe way to check before deciding to.
Lua/Python horizon.content.isLoaded · HorizonCode Is Asset Loaded · C++ HE::api::content::isLoaded · query
typeName(asset: string)type: stringWhat kind of asset this is, as text: StaticMesh, Texture, Material, Widget and so on. Empty for an asset that is not loaded — Load Asset first. Useful when a path arrives from somewhere and you want to know what you were handed.
Lua/Python horizon.content.typeName · HorizonCode Asset Type Name · C++ HE::api::content::typeName · query

World and storage · 17 functions

Save

Lua / Python: horizon.save.<function> · C++: HE::api::save::<function>

Savegames with a fixed shape. A save follows a SaveGame Template asset, which lists its fields and their defaults; the project's default template (Project Settings) is the one create uses. One save is active at a time and every field call works on it, in memory, until write puts it on disk as Saves/<id>.json in the game's data folder (see File System). write replaces the file in one step, so a crash leaves the old save or the new one, never half of each.

  • A save id may only contain A–Z a–z 0–9 _ -; anything else (a space, a dot) fails with a warning.
  • Each getter takes a default and returns it when there is no active save, the template has no such field, or the field has another type; the matching setter returns false. All three cases log a warning.
  • Numbers: a Float, Int or Enum field goes through setNumber / getNumber. An Int field drops the fraction toward zero (2.7 → 2, −2.7 → −2). A Float field keeps about seven digits: 0.1 comes back as 0.10000000149011612.
  • A struct field takes a value built with horizon.structs.<Name>() or read with getStruct (see Project Types). A plain table without __type is refused.
  • A save remembers its template, and load uses that one, not the project default. It keeps the fields the template still has and fills new ones with their defaults, so a save from an older version of the game still loads.
  • delete removes the file only. If it was the active save, it stays active, and the next write creates the file again; call close as well.

Example: save.write

checkpoint.lua
local M = {}
local SLOT = "slot1"

-- "level" (Int) and "stats" (a struct with "kills") are fields of the
-- project's SaveGame Template.
function M.onStart(self)
    -- Continue from the save if there is one, else start a new one.
    if not (horizon.save.exists(SLOT) and horizon.save.load(SLOT)) then
        horizon.save.create(SLOT)
    end
    horizon.log(string.format("Level %d", horizon.save.getNumber("level", 1)))
end

-- This entity is the checkpoint's trigger volume.
function M.onBeginOverlap(self, other)
    horizon.save.setNumber("level", horizon.save.getNumber("level", 1) + 1)
    local stats = horizon.save.getStruct("stats")   -- keeps __type
    stats.kills = stats.kills + 1
    horizon.save.setStruct("stats", stats)
    horizon.save.write()   -- nothing is on disk before this
end

return M
checkpoint.py
import horizon

SLOT = "slot1"

class Checkpoint(horizon.Behavior):
    # "level" (Int) and "stats" (a struct with "kills") are fields of the
    # project's SaveGame Template.
    def on_start(self):
        # Continue from the save if there is one, else start a new one.
        if not (horizon.save.exists(SLOT) and horizon.save.load(SLOT)):
            horizon.save.create(SLOT)
        horizon.log(f"Level {horizon.save.getNumber('level', 1):.0f}")

    # This entity is the checkpoint's trigger volume.
    def on_begin_overlap(self, other):
        horizon.save.setNumber("level", horizon.save.getNumber("level", 1) + 1)
        stats = horizon.save.getStruct("stats")   # keeps __type
        stats["kills"] += 1
        horizon.save.setStruct("stats", stats)
        horizon.save.write()   # nothing is on disk before this
FunctionReturnsDescription
create(id: string)ok: boolStarts a NEW save from the project's SaveGame Template, with the fields seeded from the template defaults, and makes it active. In memory only until Write Save. Replaces the active save without writing it.
Lua/Python horizon.save.create · HorizonCode Create Save · C++ HE::api::save::create · action
load(id: string)ok: boolLoads Saves/<id>.json, re-validates it against the template it names and makes it active. Fails if the save or its template is missing.
Lua/Python horizon.save.load · HorizonCode Load Save · C++ HE::api::save::load · action
write()ok: boolPersists the active save to disk, atomically.
Lua/Python horizon.save.write · HorizonCode Write Save · C++ HE::api::save::write · action
close()—Drops the active save without writing it.
Lua/Python horizon.save.close · HorizonCode Close Save · C++ HE::api::save::close · action
activeId()id: stringId of the active save; empty when there is none.
Lua/Python horizon.save.activeId · HorizonCode Active Save Id · C++ HE::api::save::activeId · query
list()ids: string[]Ids of every save on disk.
Lua/Python horizon.save.list · HorizonCode List Saves · C++ HE::api::save::list · query · list: HorizonCode/C++ only
exists(id: string)exists: boolIs there a save with this id on disk?
Lua/Python horizon.save.exists · HorizonCode Save Exists · C++ HE::api::save::exists · query
delete(id: string)ok: boolDeletes a save file. Does not touch the active save. Deleting the active save leaves it active; write would create the file again.
Lua/Python horizon.save.delete · HorizonCode Delete Save · C++ HE::api::save::remove · action
fields()names: string[]Field names the active save's template declares — so a graph never has to hardcode them.
Lua/Python horizon.save.fields · HorizonCode Save Fields · C++ HE::api::save::fields · query · list: HorizonCode/C++ only
setNumber(field: string, value: float)ok: boolWrites a Float, Int or Enum field of the active save. The field is picked from the template on the node itself. An Int or Enum field drops the fraction toward zero.
Lua/Python horizon.save.setNumber · HorizonCode Save Set Number · C++ HE::api::save::setNumber · action
getNumber(field: string, default: float)value: floatReads a Float, Int or Enum field. An unknown field, or no active save, yields the Default input rather than zero. A Float field keeps about seven digits.
Lua/Python horizon.save.getNumber · HorizonCode Save Get Number · C++ HE::api::save::getNumber · query
setString(field: string, value: string)ok: boolWrites a String field of the active save.
Lua/Python horizon.save.setString · HorizonCode Save Set String · C++ HE::api::save::setString · action
getString(field: string, default: string)value: stringReads a String field; falls back to the Default input.
Lua/Python horizon.save.getString · HorizonCode Save Get String · C++ HE::api::save::getString · query
setBool(field: string, value: bool)ok: boolWrites a Bool field of the active save.
Lua/Python horizon.save.setBool · HorizonCode Save Set Bool · C++ HE::api::save::setBool · action
getBool(field: string, default: bool)value: boolReads a Bool field; falls back to the Default input.
Lua/Python horizon.save.getBool · HorizonCode Save Get Bool · C++ HE::api::save::getBool · query
setStruct(field: string, value: struct)ok: boolWrites a Struct field. The value's definition must match the one the template declares. Needs a value from horizon.structs.<Name>() or getStruct; a table without __type is refused.
Lua/Python horizon.save.setStruct · HorizonCode Save Set Struct · C++ HE::api::save::setStructV · action
getStruct(field: string)value: structReads a Struct field of the active save.
Lua/Python horizon.save.getStruct · HorizonCode Save Get Struct · C++ HE::api::save::getStructV · query

World and storage · 13 functions

File System

Lua / Python: horizon.fs.<function> · C++: HE::api::fs::<function>

Relative paths point into the game's own data folder, the sandbox; .. cannot leave it. An absolute path works only for a file or folder the user picked in a file dialog, or when the project allows Files outside the project. Anything else fails with the call's “failed” value and a log line that names the path.

Where the sandbox is: in the editor, the Saved folder next to the project file. In the exported game, a per-user folder named after the project, HorizonCreations/<Project>/Saved under %APPDATA% on Windows, ~/Library/Application Support on macOS and ~/.local/share on Linux. Savegames (Saves/) and Preferences (Prefs.json) live there too, so fs.* can see and delete them.

  • writeText creates missing folders on the way. readText returns an empty string for a missing file.
  • copy never overwrites: it returns false when the target exists. rename does overwrite an existing file at the target, without asking.
  • remove deletes files only, never a folder. makeDir returns true when the folder already exists.
  • An empty path means the top of the sandbox for list and watch, and is refused everywhere else.
  • size and modified return -1 for anything that is not there, and size also for a folder. Both pass through a 32-bit float: sizes are exact up to 16 MB, and modified is up to 64 seconds off, the same issue as Date and Time. It can even lie a little in the future.
◆

watch reports changes through the HorizonCode event On File Changed on the GameInstance, and only in the exported game: the editor's play mode does not check watched paths, and scripts have no callback for it (see Events). From a script, read the file again on a timer and compare; modified is too coarse for that.

FunctionReturnsDescription
writeText(path: string, text: string)ok: boolWrites a text file, replacing whatever was there. Paths are relative to the game's own data directory; it cannot write outside it. For savegames use the Save calls, which handle the format and the atomic write.
Lua/Python horizon.fs.writeText · HorizonCode Write Text File · C++ HE::api::fs::writeText · action
readText(path: string)text: stringReads a whole text file. Empty when the file does not exist — ask Exists first if the difference matters.
Lua/Python horizon.fs.readText · HorizonCode Read Text File · C++ HE::api::fs::readText · action
exists(path: string)exists: boolIs there a file at this path?
Lua/Python horizon.fs.exists · HorizonCode File Exists · C++ HE::api::fs::exists · query
remove(path: string)ok: boolDeletes a file. Ok is false if it was not there. Files only; a folder is refused.
Lua/Python horizon.fs.remove · HorizonCode Delete File · C++ HE::api::fs::remove · action
makeDir(path: string)ok: boolCreates a directory, parents included. Ok is true if it already existed.
Lua/Python horizon.fs.makeDir · HorizonCode Make Directory · C++ HE::api::fs::makeDir · action
isDir(path: string)isDir: boolIs there a DIRECTORY at this path? Exists answers about anything; this one answers about the thing you can list.
Lua/Python horizon.fs.isDir · HorizonCode Is Directory · C++ HE::api::fs::isDir · query
size(path: string)bytes: floatThe file's size in bytes, or -1 for anything that is not a readable file — a directory included, since a folder's size is not a number one call can honestly give. Exact up to 16 MB (32-bit float).
Lua/Python horizon.fs.size · HorizonCode File Size · C++ HE::api::fs::size · query
modified(path: string)time: floatWhen the file was last written, in seconds, on the same clock Now uses. So "how old is this file" is Now minus this, and not a second time format to learn. -1 when there is nothing there. Known issue: up to 64 seconds off, as in Date and Time.
Lua/Python horizon.fs.modified · HorizonCode File Modified Time · C++ HE::api::fs::modified · query
list(dir: string)names: string[]The names of everything directly inside a directory, sorted. Names only, not full paths — joining stays yours. Empty for a path that is not a directory. Leave the path empty for the top of your own folder.
Lua/Python horizon.fs.list · HorizonCode List Directory · C++ HE::api::fs::list · query · list: HorizonCode/C++ only
rename(from: string, to: string)ok: boolMoves or renames, in one step. Both paths follow the same rules as every other file call. Overwrites an existing file at to, unlike copy.
Lua/Python horizon.fs.rename · HorizonCode Rename File · C++ HE::api::fs::rename · action
copy(from: string, to: string)ok: boolCopies a file. It will NOT overwrite: if something is already at the destination it does nothing and Ok is false. This is the one file call that could destroy something you did not name, so it refuses instead.
Lua/Python horizon.fs.copy · HorizonCode Copy File · C++ HE::api::fs::copy · action
watch(path: string)handle: intKeeps an eye on a file or a folder. From now on On File Changed fires with the path whenever it appears, disappears or changes; for a folder that means its immediate contents, one level deep. Returns a handle for Stop Watching, or 0 when the path is out of reach. Checked about once a second, so it is for reacting, not for timing. Only in the exported game, and only the GameInstance's On File Changed hears it; scripts get no callback.
Lua/Python horizon.fs.watch · HorizonCode Watch File · C++ HE::api::fs::watch · action
unwatch(handle: int)—Stops a watch, using the handle Watch File gave you. Watches also all end when the application does.
Lua/Python horizon.fs.unwatch · HorizonCode Stop Watching File · C++ HE::api::fs::unwatch · action

World and storage · 9 functions

Preferences

Lua / Python: horizon.prefs.<function> · C++: HE::api::prefs::<function>

Small settings that outlive the game: volume, the last folder, “don't show this again”. Each value sits under a key of your choosing in Prefs.json in the game's data folder (see File System), shared by every script and graph of the game. Every set and remove writes the file at once. Progress belongs in a save instead.

  • A getter returns its fallback when the key was never set or holds another type: getBool on a number gives the fallback, not true. has tells a missing key from one that is set to the default value.
  • Numbers are stored with about seven significant digits: setNumber("volume", 0.1) writes 0.10000000149011612, and whole numbers are exact only up to 16 777 216. Keep ids and large counts as strings.
▲

Known issue in the editor: preferences follow you into the next project. The file is read once per run of the program. After opening another project in the same editor session, the getters still answer with the first project's preferences, and the next set writes all of them into the new project's Prefs.json (measured on build 7d49d44f). Restart the editor after switching projects before you play. An exported game has only one project and is not affected.

Example: prefs.getBool

tutorial_hint.lua
local M = {}

function M.onStart(self)
    if not horizon.prefs.getBool("tutorial.skip", false) then
        horizon.log("Tutorial: press Space to jump")
    end
end

-- "SkipTutorial" is an input action, e.g. on the hint's close button.
function M.onInputPressed(self, name)
    if name == "SkipTutorial" then
        horizon.prefs.setBool("tutorial.skip", true)   -- on disk at once
    end
end

return M
tutorial_hint.py
import horizon

class TutorialHint(horizon.Behavior):
    def on_start(self):
        if not horizon.prefs.getBool("tutorial.skip", False):
            horizon.log("Tutorial: press Space to jump")

    # "SkipTutorial" is an input action, e.g. on the hint's close button.
    def on_input_pressed(self, name):
        if name == "SkipTutorial":
            horizon.prefs.setBool("tutorial.skip", True)   # on disk at once
FunctionReturnsDescription
getString(key: string, fallback: string)value: stringReads a saved setting as text, or the fallback when it was never set. Preferences are small scraps like a last folder — not the save system, which is shaped by a template and belongs to a game's progress.
Lua/Python horizon.prefs.getString · HorizonCode Get Pref String · C++ HE::api::prefs::getString · query
getNumber(key: string, fallback: float)value: floatReads a saved setting as a number, or the fallback when it was never set or holds something else.
Lua/Python horizon.prefs.getNumber · HorizonCode Get Pref Number · C++ HE::api::prefs::getNumber · query
getBool(key: string, fallback: bool)value: boolReads a saved setting as true/false, or the fallback when it was never set. The natural home for "don't show this again".
Lua/Python horizon.prefs.getBool · HorizonCode Get Pref Bool · C++ HE::api::prefs::getBool · query
setString(key: string, value: string)—Saves a text setting under a key. Written to disk immediately, so a crash cannot cost more than the last change.
Lua/Python horizon.prefs.setString · HorizonCode Set Pref String · C++ HE::api::prefs::setString · action
setNumber(key: string, value: float)—Saves a numeric setting under a key. Written to disk immediately. Stored with about seven digits: 0.1 becomes 0.10000000149011612.
Lua/Python horizon.prefs.setNumber · HorizonCode Set Pref Number · C++ HE::api::prefs::setNumber · action
setBool(key: string, value: bool)—Saves a true/false setting under a key. Written to disk immediately.
Lua/Python horizon.prefs.setBool · HorizonCode Set Pref Bool · C++ HE::api::prefs::setBool · action
has(key: string)present: boolTrue when this key has ever been set. Lets a first run be told apart from one where the user deliberately chose the default.
Lua/Python horizon.prefs.has · HorizonCode Has Pref · C++ HE::api::prefs::has · query
remove(key: string)removed: boolForgets one setting, so the next read gets its fallback again. False when there was nothing under that key.
Lua/Python horizon.prefs.remove · HorizonCode Remove Pref · C++ HE::api::prefs::remove · action
clear()—Forgets every setting at once — what a "reset to defaults" button does.
Lua/Python horizon.prefs.clear · HorizonCode Clear Prefs · C++ HE::api::prefs::clear · action

Gameplay · 33 functions

Physics

Lua / Python: horizon.physics.<function> · C++: HE::api::physics::<function>

Queries against the physics world and pushes on its bodies. Without a running physics world (edit mode, a world with no bodies built) every query misses, every push returns false and every read is zero. Units are metres, seconds, newtons and kilograms; the one exception is spin, which is in radians per second.

  • Hits from Lua/Python. raycast and the four casts return ten values: hit, entity, px, py, pz, nx, ny, nz, distance, layer. On a miss they are false and zeros. The flat horizon.raycast returns a table / dict (entity, x, y, z, nx, ny, nz, distance, no layer) or nil / None. The direction does not have to be normalised.
  • A ray started inside a collider hits that collider at distance 0. A ray from a character's own position therefore finds the character. Start it outside the own capsule (a step ahead, see the example below) or give the character its own collision layer and leave that bit out of the mask.
  • Shape casts report the centre, not the contact. For sphereCast, boxCast and capsuleCast, point is where the shape's centre stopped (a 0.25 m sphere dropped on a floor at y = 1 reports y = 1.25), which is what a camera boom or a placement check wants. The shape casts ignore trigger volumes; raycast and the overlaps see them.
  • Layer masks are bitfields: bit N is collision layer N, 65535 (or -1) is every layer, 0 sees nothing. The layer names are in Project Settings under Collision Layers.
  • World and local. Every point a query returns is in world space, and so are the positions of addForceAtPosition / addImpulseAtPosition. setPosition and setPositionAndReset take a local position, like horizon.setPosition: for a child entity the value is relative to its parent. Joint anchors are local to their own entity.
  • Forces (addForce, addImpulse, addTorque, the two …AtPosition) need a Dynamic rigid body and return false with a log line on a static or kinematic one, so also on a character. A character moves through Locomotion.
  • setVelocity addresses the character controller when there is one, otherwise the rigid body. On a character with a Movement component the next frame overwrites the horizontal part with whatever locomotion.move asked for (nothing = standing still), so use setVelocity on characters only without one.
  • setGravity changes gravity for rigid bodies in the whole world. A character falls by its own Character Controller gravity and does not follow it.

Joints tie two rigid bodies together; the entity passed first owns the joint, and only it reports hasJoint. A type outside 0–4 becomes Fixed. setJointMotor works on a Hinge or Slider only and returns false otherwise. A joint made at runtime is written to the owner's Joint component, the same component the editor places, and removeJoint or a break removes it again.

▲

List results do not reach Lua or Python. overlapSphere, overlapSphereLayers, overlapBox, overlapCapsule, raycastAll and pollJointBroken return scalar zeros there instead of lists (see Lists, measured on build 7d49d44f). pollJointBroken still empties the queue, so a script calling it takes the events away from a HorizonCode graph that polls the same queue. Until this is fixed, use these from HorizonCode, and in scripts react to contacts with onBeginOverlap on a trigger volume.

Example: physics.raycast

push_crate.lua
local M = {}
local REACH = 2.0   -- metres in front of the character
local PUSH  = 4.0   -- impulse, newton-seconds

-- "Interact" is a button action. Pushes whatever dynamic body is in front.
function M.onInputPressed(self, name)
    if name ~= "Interact" then return end
    local e = self.entityId
    local x, y, z = horizon.getPosition(e)
    local _, yaw = horizon.getRotation(e)
    local r = horizon.math.radians(yaw)
    local fx, fz = -horizon.math.sin(r), -horizon.math.cos(r)   -- facing, -Z at yaw 0
    -- Start a step ahead: a ray from inside the own capsule would hit it.
    local ox, oz = x + fx * 0.75, z + fz * 0.75
    local hit, other, px, py, pz = horizon.physics.raycast(ox, y, oz, fx, 0, fz, REACH)
    if hit and horizon.physics.hasPhysics(other) then
        -- The hit point is world space, which is what AtPosition expects.
        horizon.physics.addImpulseAtPosition(other, fx * PUSH, 0, fz * PUSH, px, py, pz)
    end
end

return M
push_crate.py
import horizon

REACH = 2.0   # metres in front of the character
PUSH = 4.0    # impulse, newton-seconds

class PushCrate(horizon.Behavior):
    # "Interact" is a button action. Pushes whatever dynamic body is in front.
    def on_input_pressed(self, name):
        if name != "Interact":
            return
        e = self.entity_id
        x, y, z = horizon.getPosition(e)
        _, yaw, _ = horizon.getRotation(e)
        r = horizon.math.radians(yaw)
        fx, fz = -horizon.math.sin(r), -horizon.math.cos(r)   # facing, -Z at yaw 0
        # Start a step ahead: a ray from inside the own capsule would hit it.
        ox, oz = x + fx * 0.75, z + fz * 0.75
        hit, other, px, py, pz, *_ = horizon.physics.raycast(ox, y, oz, fx, 0, fz, REACH)
        if hit and horizon.physics.hasPhysics(other):
            # The hit point is world space, which is what AtPosition expects.
            horizon.physics.addImpulseAtPosition(other, fx * PUSH, 0, fz * PUSH, px, py, pz)

Example: physics.setPositionAndReset

kill_plane.lua
local M = {}
local FLOOR = -20   -- below this height the character has fallen out

function M.onStart(self)
    self.sx, self.sy, self.sz = horizon.getPosition(self.entityId)
end

function M.onUpdate(self, dt)
    local _, y = horizon.getPosition(self.entityId)
    if y < FLOOR then
        -- Back to where the level started it, without the speed of the fall.
        horizon.physics.setPositionAndReset(self.entityId, self.sx, self.sy, self.sz)
    end
end

return M
kill_plane.py
import horizon

FLOOR = -20   # below this height the character has fallen out

class KillPlane(horizon.Behavior):
    def on_start(self):
        self.spawn = horizon.getPosition(self.entity_id)

    def on_update(self, dt):
        _, y, _ = horizon.getPosition(self.entity_id)
        if y < FLOOR:
            # Back to where the level started it, without the speed of the fall.
            horizon.physics.setPositionAndReset(self.entity_id, *self.spawn)
FunctionReturnsDescription
raycast(origin: vec3, direction: vec3, maxDistance: float)hit, entity, px, py, pz, nx, ny, nz, distance, layerFires a ray from Origin along Direction and reports the first thing it hits. Direction does not have to be normalised. Hit is false when nothing is in the way, and the other outputs are then meaningless rather than stale — always branch on Hit first. A ray that starts inside a collider hits it at distance 0, so a ray from a character's own position finds the character. Sees trigger volumes.
Lua/Python horizon.physics.raycast, or flat horizon.raycast · HorizonCode Raycast · C++ HE::api::physics::raycast · query
setVelocity(entity: int, velocity: vec3)—Sets a body's velocity in metres per second outright, discarding whatever it had. For a nudge use Add Impulse, which adds to the motion instead of replacing it. On a character with a Movement component the next frame replaces the horizontal part with what locomotion.move asked for.
Lua/Python horizon.physics.setVelocity, or flat horizon.setVelocity · HorizonCode Set Velocity · C++ HE::api::physics::setVelocity · action · entity is Self in HorizonCode
isGrounded(entity: int)grounded: boolIs this character controller standing on something right now? False for an entity that has no character controller.
Lua/Python horizon.physics.isGrounded, or flat horizon.isGrounded · HorizonCode Is Grounded (Physics) · C++ HE::api::physics::isGrounded · query · entity is Self in HorizonCode
sphereCast(origin: vec3, direction: vec3, radius: float, maxDistance: float)hit, entity, px, py, pz, nx, ny, nz, distance, layerLike Raycast, but sweeps a sphere of Radius along the ray, so it catches what a thin ray slips past — a gap between two crates, the edge of a step. The extra cost is worth it for anything a character has to stand on. point is where the sphere's centre stopped, not the contact point. Ignores trigger volumes.
Lua/Python horizon.physics.sphereCast · HorizonCode Sphere Cast · C++ HE::api::physics::sphereCast · query
overlapSphere(center: vec3, radius: float)entities: int[]Every entity whose collider overlaps this sphere, as an array. The explosion-radius query; walk the result with a For Each. From Lua/Python this returns 0 instead of a list (build 7d49d44f).
Lua/Python horizon.physics.overlapSphere · HorizonCode Overlap Sphere · C++ HE::api::physics::overlapSphere · query · list: HorizonCode/C++ only
raycastLayers(origin: vec3, direction: vec3, maxDistance: float, layerMask: int)hit, entity, px, py, pz, nx, ny, nz, distance, layerRaycast, but it only sees the collision layers you name. Layer Mask is a bitfield and not a layer number: add 1 for layer 0, 2 for layer 1, 4 for layer 2 and so on, or pass 65535 for every layer. Zero sees nothing at all. The layer names live in Project Settings under Collision Layers.
Lua/Python horizon.physics.raycastLayers · HorizonCode Raycast (Layers) · C++ HE::api::physics::raycastLayers · query
sphereCastLayers(origin: vec3, direction: vec3, radius: float, maxDistance: float, layerMask: int)hit, entity, px, py, pz, nx, ny, nz, distance, layerSphere Cast restricted to the collision layers in Layer Mask, which is a bitfield: 1 is layer 0, 2 is layer 1, 4 is layer 2, 65535 is all of them. What a camera boom wants, so it stops on walls and ignores the player. point is the sphere's centre where it stopped.
Lua/Python horizon.physics.sphereCastLayers · HorizonCode Sphere Cast (Layers) · C++ HE::api::physics::sphereCastLayers · query
overlapSphereLayers(center: vec3, radius: float, layerMask: int)entities: int[]Overlap Sphere restricted to the collision layers in Layer Mask, a bitfield in the same shape as the two casts above. An explosion that hurts enemies and leaves the scenery alone is this node with one bit set.
Lua/Python horizon.physics.overlapSphereLayers · HorizonCode Overlap Sphere (Layers) · C++ HE::api::physics::overlapSphereLayers · query · list: HorizonCode/C++ only
boxCast(origin: vec3, halfExtents: vec3, rotation: vec3, direction: vec3, maxDistance: float, layerMask: int)hit, entity, px, py, pz, nx, ny, nz, distance, layerSweeps a box along a direction and reports the first thing it touches. Half Extents is half the box's size on each axis, Rotation is its tilt in degrees, the same numbers the Details panel shows for an entity. Layer Mask is a bitfield: 1 is layer 0, 2 is layer 1, 65535 is all of them. Distance is where the box's centre stopped, not where it touched. point is the box's centre where it stopped. Ignores trigger volumes.
Lua/Python horizon.physics.boxCast · HorizonCode Box Cast · C++ HE::api::physics::boxCast · query
capsuleCast(origin: vec3, radius: float, height: float, rotation: vec3, direction: vec3, maxDistance: float, layerMask: int)hit, entity, px, py, pz, nx, ny, nz, distance, layerSweeps a capsule along a direction: the query that asks whether a character fits through a gap, using that character's own Radius and Height. Height is the full height including both rounded ends. Rotation tilts the capsule in degrees, Layer Mask is a bitfield as on the other casts. point is the capsule's centre where it stopped. Ignores trigger volumes.
Lua/Python horizon.physics.capsuleCast · HorizonCode Capsule Cast · C++ HE::api::physics::capsuleCast · query
overlapBox(center: vec3, halfExtents: vec3, rotation: vec3, layerMask: int)entities: int[]Every entity whose collider is inside this box right now, as an array. Half Extents is half the box's size on each axis and Rotation turns it, both in the same units the Details panel uses. A room, a corridor or a selection rectangle, where Overlap Sphere would also catch the corners.
Lua/Python horizon.physics.overlapBox · HorizonCode Overlap Box · C++ HE::api::physics::overlapBox · query · list: HorizonCode/C++ only
overlapCapsule(center: vec3, radius: float, height: float, rotation: vec3, layerMask: int)entities: int[]Every entity inside an upright capsule, as an array. Height is the full height including both ends, and Rotation lays it on its side if you want that. The query a respawn makes before it puts a player somewhere: is anybody already standing there?
Lua/Python horizon.physics.overlapCapsule · HorizonCode Overlap Capsule · C++ HE::api::physics::overlapCapsule · query · list: HorizonCode/C++ only
raycastAll(origin: vec3, direction: vec3, maxDistance: float, layerMask: int)entities: int[], points: vec3[], normals: vec3[], distances: float[], layers: int[]Fires a ray and reports EVERY body along it rather than the first, nearest first. The five outputs are parallel arrays, so index 3 of Points belongs to index 3 of Entities. A shot that passes through two enemies, or a line of sight that has to know it crossed a window. Each entity appears once. From Lua/Python this returns nine zeros instead of five lists (build 7d49d44f).
Lua/Python horizon.physics.raycastAll · HorizonCode Raycast All · C++ HE::api::physics::raycastAll · query · list: HorizonCode/C++ only
addForce(entity: int, force: vec3)ok: boolAdds a continuous force (newtons) for this tick — call it every frame while it should push. Returns false when the entity has no dynamic body, which is the usual reason nothing moves. false on a static or kinematic body, which includes a character.
Lua/Python horizon.physics.addForce · HorizonCode Add Force · C++ HE::api::physics::addForce · action · entity is Self in HorizonCode
addImpulse(entity: int, impulse: vec3)ok: boolAdds an instant change in momentum: a jump, a hit, an explosion. Call it once, not every frame. false on a static or kinematic body, which includes a character; a character jumps with locomotion.jump.
Lua/Python horizon.physics.addImpulse · HorizonCode Add Impulse · C++ HE::api::physics::addImpulse · action · entity is Self in HorizonCode
addTorque(entity: int, torque: vec3)ok: boolAdds rotational force about the world axes, spinning the body.
Lua/Python horizon.physics.addTorque · HorizonCode Add Torque · C++ HE::api::physics::addTorque · action · entity is Self in HorizonCode
addForceAtPosition(entity: int, force: vec3, position: vec3)ok: boolA continuous force applied at a point rather than through the middle, so it turns the body as well as moving it. Position is a point in the WORLD, not an offset inside the entity, so a raycast hit point can be wired straight into it. Call it every frame while it should push.
Lua/Python horizon.physics.addForceAtPosition · HorizonCode Add Force At Position · C++ HE::api::physics::addForceAtPosition · action · entity is Self in HorizonCode
addImpulseAtPosition(entity: int, impulse: vec3, position: vec3)ok: boolAn instant push at a point rather than through the middle: the crate tumbles away from the blast instead of sliding away from it. Position is a point in the WORLD, so a hit point wires straight in. Call it once, not every frame.
Lua/Python horizon.physics.addImpulseAtPosition · HorizonCode Add Impulse At Position · C++ HE::api::physics::addImpulseAtPosition · action · entity is Self in HorizonCode
getVelocity(entity: int)velocity: vec3The body's current velocity in metres per second. Zero for an entity without a physics body.
Lua/Python horizon.physics.getVelocity · HorizonCode Get Velocity (Physics) · C++ HE::api::physics::getVelocity · query · entity is Self in HorizonCode
setAngularVelocity(entity: int, angularVelocity: vec3)ok: boolSets how fast the body spins, in RADIANS per second about the world axes and not in degrees. One full turn a second is about 6.28. It turns rigid bodies only: a character controller is kept upright and does not spin.
Lua/Python horizon.physics.setAngularVelocity · HorizonCode Set Angular Velocity · C++ HE::api::physics::setAngularVelocity · action · entity is Self in HorizonCode
getAngularVelocity(entity: int)angularVelocity: vec3How fast the body is spinning, in RADIANS per second about the world axes and not in degrees. Zero for an entity without a rigid body.
Lua/Python horizon.physics.getAngularVelocity · HorizonCode Get Angular Velocity · C++ HE::api::physics::getAngularVelocity · query · entity is Self in HorizonCode
setGravity(gravity: vec3)—Sets gravity for the WHOLE physics world, not for one entity. The default is (0, -9.81, 0). Rigid bodies only: a character falls by the gravity on its own Character Controller.
Lua/Python horizon.physics.setGravity · HorizonCode Set Gravity · C++ HE::api::physics::setGravity · action
getGravity()gravity: vec3The physics world's gravity vector.
Lua/Python horizon.physics.getGravity · HorizonCode Get Gravity · C++ HE::api::physics::getGravity · query
setPosition(entity: int, position: vec3)ok: boolTeleports the entity's physics body and its transform to this position in one step. Nothing sweeps on the way, and the velocity is kept — Set Position And Stop is the respawn variant. The position is local, relative to the parent, like horizon.setPosition. For a world point on a child use transform.setWorldPosition (HorizonCode only). false and a log line without a body.
Lua/Python horizon.physics.setPosition · HorizonCode Set Position (Physics) · C++ HE::api::physics::setPosition · action · entity is Self in HorizonCode
setPositionAndReset(entity: int, position: vec3)ok: boolTeleports like Set Position and zeroes the velocity on arrival. What a respawn wants, so a player put back at a checkpoint does not keep the fall that killed them. Local position like setPosition; zeroes the linear and the angular velocity.
Lua/Python horizon.physics.setPositionAndReset · HorizonCode Set Position And Stop (Physics) · C++ HE::api::physics::setPositionAndReset · action · entity is Self in HorizonCode
hasPhysics(entity: int)has: boolWhether the entity has a rigid body or a character controller at all. False without a physics world, and the honest answer to why a force did nothing.
Lua/Python horizon.physics.hasPhysics · HorizonCode Has Physics · C++ HE::api::physics::hasPhysics · query · entity is Self in HorizonCode
addJoint(entityA: int, entityB: int, type: int, anchorA: vec3, anchorB: vec3, axis: vec3, minLimit: float, maxLimit: float)ok: boolTies two rigid bodies together. Type picks the kind: 0 Fixed welds them, 1 Point is a ball socket, 2 Hinge turns on one axis, 3 Slider travels along one, 4 Distance keeps two points apart like a rope. Anchors are local to their own entity, Min and Max Limit are degrees for a hinge and metres for a slider, and Min above Max means no limit. A type outside 0–4 becomes Fixed. The first entity owns the joint.
Lua/Python horizon.physics.addJoint · HorizonCode Add Joint · C++ HE::api::physics::addJoint · action
removeJoint(entity: int)ok: boolCuts an entity's joint, both the constraint and the component, so it does not come back the next time the scene is loaded. The call that drops a grapple line or breaks a chain apart on purpose.
Lua/Python horizon.physics.removeJoint · HorizonCode Remove Joint · C++ HE::api::physics::removeJoint · action · entity is Self in HorizonCode
hasJoint(entity: int)has: boolTrue while this entity is actually jointed to something in the simulation. A joint whose other end has not spawned yet is authored but not built, and this reports the simulation rather than the component. Only the owner, the first entity passed to addJoint, reports true.
Lua/Python horizon.physics.hasJoint · HorizonCode Has Joint · C++ HE::api::physics::hasJoint · query · entity is Self in HorizonCode
setJointMotor(entity: int, targetSpeed: float, maxForce: float)ok: boolDrives a hinge or a slider under its own power: the door swings open, the platform rises. Target Speed is radians per second for a hinge and metres per second for a slider. Max Force is the switch — zero turns the motor off, and a target of zero with force behind it is a brake that holds the joint still. The other three joint types have no motor. false and an error log on a Fixed, Point or Distance joint.
Lua/Python horizon.physics.setJointMotor · HorizonCode Set Joint Motor · C++ HE::api::physics::setJointMotor · action · entity is Self in HorizonCode
setJointBreakForce(entity: int, breakForce: float)ok: boolHow much force this joint carries before it lets go, in newtons; 0 never breaks. When it breaks the joint is gone for good — the component with it — and the pair shows up once in Poll Joint Broken.
Lua/Python horizon.physics.setJointBreakForce · HorizonCode Set Joint Break Force · C++ HE::api::physics::setJointBreakForce · action · entity is Self in HorizonCode
setJointCollideConnected(entity: int, collide: bool)ok: boolWhether the two jointed bodies may touch each other. Off by default, which is what a chain of overlapping links needs; on for a door that must not swing through its own frame.
Lua/Python horizon.physics.setJointCollideConnected · HorizonCode Set Joint Collide Connected · C++ HE::api::physics::setJointCollideConnected · action · entity is Self in HorizonCode
pollJointBroken()entitiesA: int[], entitiesB: int[]Every joint that BROKE since the last call, as two parallel arrays: index i of Entities A owned the joint, index i of Entities B was the other end. Reading empties the list, so read it in one place. A joint that was removed or whose entity was destroyed never appears here. From Lua/Python this returns 0, 0 and still empties the queue (build 7d49d44f).
Lua/Python horizon.physics.pollJointBroken · HorizonCode Poll Joint Broken · C++ HE::api::physics::pollJointBroken · action · list: HorizonCode/C++ only

Gameplay · 6 functions

Movement

Lua / Python: horizon.movement.<function> · C++: HE::api::movement::<function>

What a character is doing right now, read from its Character Controller. Queries only; to make a character move use Locomotion. An entity without a Character Controller reads 0 and false, without a log line.

  • All speeds are metres per second. speed is horizontal only (falling is not running), verticalSpeed is positive while rising.
  • forwardAmount and rightAmount are the same horizontal velocity split along the character's facing, also in metres per second, not a fraction: a character strafing right at 5 m/s reads rightAmount 5. Divide by speed for −1…1, and guard against a speed of 0. Forward is the entity's −Z axis turned by its yaw.
  • isGrounded is the same answer as physics.isGrounded and the flat horizon.isGrounded, and the one locomotion.jump checks.
FunctionReturnsDescription
speed(entity: int)speed: floatHow fast the character is moving across the ground, in metres per second, ignoring any vertical motion. The value an animator's walk/run blend wants.
Lua/Python horizon.movement.speed · HorizonCode Get Speed · C++ HE::api::movement::speed · query · entity is Self in HorizonCode
verticalSpeed(entity: int)speed: floatThe character's vertical speed in metres per second: positive rising, negative falling. What tells a jump from a fall.
Lua/Python horizon.movement.verticalSpeed · HorizonCode Get Vertical Speed · C++ HE::api::movement::verticalSpeed · query · entity is Self in HorizonCode
isGrounded(entity: int)grounded: boolIs the character standing on something? Read from the character controller, so it is the same answer physics used this tick.
Lua/Python horizon.movement.isGrounded · HorizonCode Is Grounded · C++ HE::api::movement::isGrounded · query · entity is Self in HorizonCode
velocity(entity: int)velocity: vec3The character's full velocity vector in metres per second.
Lua/Python horizon.movement.velocity · HorizonCode Get Velocity · C++ HE::api::movement::velocity · query · entity is Self in HorizonCode
forwardAmount(entity: int)amount: floatHow fast the character moves the way it is facing, in metres per second: positive ahead, negative backwards, 0 when still or moving sideways. It is a speed and not a -1 to 1 fraction, so divide by Get Speed for that. One half of a locomotion blend space.
Lua/Python horizon.movement.forwardAmount · HorizonCode Get Forward Amount · C++ HE::api::movement::forwardAmount · query · entity is Self in HorizonCode
rightAmount(entity: int)amount: floatThe sideways half of the same pair, also in metres per second: negative strafing left, positive strafing right.
Lua/Python horizon.movement.rightAmount · HorizonCode Get Right Amount · C++ HE::api::movement::rightAmount · query · entity is Self in HorizonCode

Gameplay · 6 functions

Locomotion

Lua / Python: horizon.locomotion.<function> · C++: HE::api::locomotion::<function>

Tells a character what to do this frame. move, look, setMaxSpeed and setOrientToMovement write the entity's Movement component and do nothing, silently, on an entity without one; the jumps need a Character Controller and log when there is none. A Player Character gets both components automatically. The Movement system turns the request into velocity once per frame before physics steps.

  • move is an intent for one frame: call it every frame the character should walk. Two calls in the same frame add up, and the sum is cut back to length 1, so move(e, 1, 0, 0) twice still walks at the top speed, not twice it. Length 0.5 walks at half speed.
  • The direction is read as the Movement component's Move Direction Is setting says: World (the default) takes it as it is, Camera (the default for a Player Character) turns it by the main camera rig's heading, so (0, 0, -1) is "away from the camera". With an input action from the WASD preset, forward is move(e, x, 0, -y).
  • The Y component is dropped, but only after the length is cut to 1: move(e, 0.5, 3, 0) walks at 0.16 of the top speed. Pass 0 for Y.
  • look adds its yaw to the character's rotation; several calls in a frame add up. The pitch is accepted and ignored, nothing reads it (build 7d49d44f). Tilt the view through the camera rig with camera.addYawPitch.
  • setOrientToMovement(e, true) turns the character towards its travel direction at the component's turn rate (720°/s by default). Leave it off while look or a camera rig owns the facing, or the two fight each other.
  • jump uses the Character Controller's Jump Speed, jumpWith a speed of its own. Both return true only when the character left the ground; a second call in the same frame, a call in mid-air or a speed of 0 or less returns false. A jump within 0.12 s of walking off a ledge is still granted.

Example: locomotion.move

player_move.lua
local M = {}

-- On a Player Character. "Move" is an Axis2D input action (the WASD
-- preset: W is +y), "Sprint" and "Jump" are button actions.
function M.onUpdate(self, dt)
    local e = self.entityId
    local x, y = horizon.input.actionAxis2D("Move")
    -- Forward is -Z. With "Move Direction Is: Camera" (the Player Character
    -- default) that is away from the camera.
    horizon.locomotion.move(e, x, 0, -y)
    horizon.locomotion.setMaxSpeed(e, horizon.input.actionDown("Sprint") and 8 or 5)
end

function M.onInputPressed(self, name)
    if name == "Jump" and horizon.locomotion.jump(self.entityId) then
        horizon.log("jump")
    end
end

return M
player_move.py
import horizon

class PlayerMove(horizon.Behavior):
    # On a Player Character. "Move" is an Axis2D input action (the WASD
    # preset: W is +y), "Sprint" and "Jump" are button actions.
    def on_update(self, dt):
        e = self.entity_id
        x, y = horizon.input.actionAxis2D("Move")
        # Forward is -Z. With "Move Direction Is: Camera" (the Player Character
        # default) that is away from the camera.
        horizon.locomotion.move(e, x, 0, -y)
        horizon.locomotion.setMaxSpeed(e, 8 if horizon.input.actionDown("Sprint") else 5)

    def on_input_pressed(self, name):
        if name == "Jump" and horizon.locomotion.jump(self.entity_id):
            horizon.log("jump")
FunctionReturnsDescription
move(entity: int, direction: vec3)—Tells the character to move in a direction this frame; the length is the throttle, 0 to 1, and anything longer is cut back to 1. The direction is read as the Movement component's "Move Direction Is" says: World by default, the main camera's heading for a Player Character. Up and down are ignored. It has to be called every frame it should move — the intent is cleared at the end of each one, so a missed frame reads as "stopped" rather than as "keep going". Two calls in one frame add up. Does nothing, without a log line, on an entity without a Movement component. The Y component counts towards the length before it is dropped.
Lua/Python horizon.locomotion.move · HorizonCode Move · C++ HE::api::locomotion::move · action · entity is Self in HorizonCode
look(entity: int, yaw: float, pitch: float)—Turns the character by Yaw degrees this frame. Pitch is accepted but nothing reads it yet; tilt the view with the camera rig instead. Several calls in a frame add up.
Lua/Python horizon.locomotion.look · HorizonCode Look · C++ HE::api::locomotion::look · action · entity is Self in HorizonCode
setMaxSpeed(entity: int, speed: float)—The character's top speed in metres per second at full input. Changing it is how sprinting and wading are done. Default 5 m/s. Not the speed of a Nav Agent, see nav.setSpeed.
Lua/Python horizon.locomotion.setMaxSpeed · HorizonCode Set Max Speed · C++ HE::api::locomotion::setMaxSpeed · action · entity is Self in HorizonCode
setOrientToMovement(entity: int, on: bool)—Whether the character turns to face the way it walks. Switch it off while something else owns the facing — a camera rig with coupled rotation, an aim mode.
Lua/Python horizon.locomotion.setOrientToMovement · HorizonCode Set Orient To Movement · C++ HE::api::locomotion::setOrientToMovement · action · entity is Self in HorizonCode
jump(entity: int)jumped: boolThrows the character upward at the Jump Speed its Character Controller was authored with. Unlike Move this is a single event, not an intent held per frame: call it once when the button goes down. It returns whether the character actually left the ground, and refuses in mid-air — which is what stops a held button from flying. So when a jump never fires, look at Is Grounded before you look at the speed. It is not quite that strict: a jump asked for within a tenth of a second of walking off a ledge is still granted, because a player who presses a frame late means to jump.
Lua/Python horizon.locomotion.jump · HorizonCode Jump · C++ HE::api::locomotion::jump · action · entity is Self in HorizonCode
jumpWith(entity: int, speed: float)jumped: boolA jump at the upward speed given here in metres per second, instead of the authored one. For the jumps one number cannot cover: a charge that grows while the button is held, a low hop out of a crouch, a launch pad that throws whoever steps on it. The ground rule is the same, and a speed of zero or less is refused rather than treated as a jump.
Lua/Python horizon.locomotion.jumpWith · HorizonCode Jump With Speed · C++ HE::api::locomotion::jumpWith · action · entity is Self in HorizonCode

Gameplay · 6 functions

Player

Lua / Python: horizon.player.<function> · C++: HE::api::player::<function>

Which player controller drives which character. The references here are HorizonCode object references, not entity ids: a controller is an instance of a PlayerController class, a character an instance of a PlayerCharacter class. In Lua and Python they are plain integers, 0 meaning "none". To get from one to an entity use horizon.entity.owned(ref), the other way horizon.entity.instance(e).

  • controller() is the session's first controller, 0 in a project without a PlayerController class; character() is what it possesses. Both are 0 outside a play session.
  • A controller drives one character and a character has one controller: possess releases the controller's previous character and takes the new one away from any other controller. possess(0, …) does nothing.
  • The table only records numbers and checks none of them, so a wrong reference is stored as it is. Pass what controller() and entity.instance return.

A Lua or Python script is not a controller, so possession does not decide which script hears input: input actions reach every script (see Callbacks). What possession changes is which HorizonCode character graph receives the controller's input events, and which character the camera rig follows.

FunctionReturnsDescription
possess(controller: ref, character: ref)—Hands a character to a player controller: from here on the controller's input drives it, and the camera rig follows it. Possessing a second character releases the first. In Lua/Python both references are integers; possess(0, …) does nothing.
Lua/Python horizon.player.possess · HorizonCode Possess · C++ HE::api::player::possess · action
unpossess(controller: ref)—Releases whatever the controller was driving. The character stays in the world, just without anyone at the controls.
Lua/Python horizon.player.unpossess · HorizonCode Un Possess · C++ HE::api::player::unpossess · action
possessed(controller: ref)character: refThe character this controller is currently driving, or an empty reference.
Lua/Python horizon.player.possessed · HorizonCode Get Possessed Character · C++ HE::api::player::possessed · query
controllerOf(character: ref)controller: refWhich controller is driving this character — the inverse of Possessed.
Lua/Python horizon.player.controllerOf · HorizonCode Get Controller · C++ HE::api::player::controllerOf · query
controller()controller: refThe local player's controller. The starting point in a single-player game, where there is exactly one. 0 in a project without a PlayerController class. A reference, not an entity: entity.owned gives the entity.
Lua/Python horizon.player.controller · HorizonCode Get Player Controller · C++ HE::api::player::controller · query
character()character: refThe character the local player is driving right now. Shorthand for Possessed of Controller. A reference, not an entity: horizon.entity.owned(horizon.player.character()) is the character's entity.
Lua/Python horizon.player.character · HorizonCode Get Player Character · C++ HE::api::player::character · query

Gameplay · 17 functions

Input

Lua / Python: horizon.input.<function> · C++: HE::api::input::<function>

Polling the keyboard, mouse, gamepad and the project's input actions. The engine takes one snapshot per frame, so every query gives the same answer for the rest of that frame. Only a running game or play mode fills it; in the editor outside play the values are stale. For one-shot reactions prefer the action callbacks (onInputPressed and friends) over polling.

  • Key names are SDL scancode names and case-sensitive: "W", "Space", "Left Shift", "Return", "Escape", "Up". keyDown("w") is always false.
  • Mouse buttons: 0 left, 1 right, 2 middle. mousePosition and mouseDelta return two numbers in Lua and a tuple (x, y) in Python.
  • Gamepad names are SDL's, not case-sensitive: buttons "a", "b", "x", "y", "leftshoulder", "dpup", "start"; axes "leftx", "lefty", "rightx", "righty", "lefttrigger", "righttrigger". "south" and "cross" are not names and read false. Sticks go −1…1 with Y positive downwards, triggers 0…1, and the deadzone is already applied.
  • Actions are looked up by the InputAction asset's name, case-sensitive; an unknown name reads false / 0. onUpdate runs before the actions are updated, so a poll from there sees the previous frame's state; actionPressed is still true for exactly one frame per press. The callbacks always carry the current frame. While the game is paused or the input mode is UI only, an action reads as released unless its asset has Fires while the game is paused ticked.

Input modes decide who receives input. The default is GameAndUI: both do, and a click on a widget is masked out of the game. GameOnly leaves the UI drawn but deaf. UIOnly gives the UI everything: the game then reads no keys, no mouse buttons and no gamepad (it reports disconnected), only mousePosition stays true. The mode does not show or hide the cursor; do that with the flat horizon.showCursor / hideCursor. Play mode starts in GameAndUI.

▲

Known issue: scrollDelta is always 0 in the running game and in play mode (build 7d49d44f). The per-frame snapshot is filled without the mouse wheel. Read the wheel through an input action instead: bind the Mouse Wheel source to an Axis action and poll it with actionAxis or handle onInputAxis.

Example: input.setModeGameAndUI

inventory.lua
local M = {}

-- "Inventory" is a button action. While the inventory is open the cursor is
-- visible and clicks go to the UI; the game still reads the keys.
function M.onInputPressed(self, name)
    if name ~= "Inventory" then return end
    self.open = not self.open
    if self.open then
        horizon.input.setModeGameAndUI()
        horizon.showCursor()
    else
        horizon.input.setModeGameOnly()
        horizon.hideCursor()
    end
end

return M
inventory.py
import horizon

class Inventory(horizon.Behavior):
    open = False

    # "Inventory" is a button action. While the inventory is open the cursor
    # is visible and clicks go to the UI; the game still reads the keys.
    def on_input_pressed(self, name):
        if name != "Inventory":
            return
        self.open = not self.open
        if self.open:
            horizon.input.setModeGameAndUI()
            horizon.showCursor()
        else:
            horizon.input.setModeGameOnly()
            horizon.hideCursor()
FunctionReturnsDescription
keyDown(key: string)down: boolIs this key held down right now? The name is SDL's spelling ("W", "Space", "Escape"). True for as long as it is held, so use it for movement and put one-shot actions behind an Input Action instead. Case-sensitive: "w" is always false. Two-word keys read "Left Shift", "Left Ctrl".
Lua/Python horizon.input.keyDown · HorizonCode Key Down · C++ HE::api::input::keyDown · query
mouseButton(button: int)down: boolIs this mouse button held? 0 left, 1 right, 2 middle.
Lua/Python horizon.input.mouseButton · HorizonCode Mouse Button · C++ HE::api::input::mouseButton · query
mousePosition()position: vec2The pointer's position in window pixels, measured from the top-left.
Lua/Python horizon.input.mousePosition · HorizonCode Mouse Position · C++ HE::api::input::mousePosition · query
mouseDelta()delta: vec2How far the pointer moved since the previous frame, in pixels. This is what mouse look reads — not the position, which stops changing once the cursor is captured.
Lua/Python horizon.input.mouseDelta · HorizonCode Mouse Delta · C++ HE::api::input::mouseDelta · query
scrollDelta()scroll: floatHow far the wheel turned this frame; positive is away from the user. Always 0 in the running game and in play mode (build 7d49d44f): the wheel is not put into the snapshot. Bind Mouse Wheel to an Axis action and use actionAxis.
Lua/Python horizon.input.scrollDelta · HorizonCode Scroll Delta · C++ HE::api::input::scrollDelta · query
gamepadConnected()connected: boolIs a gamepad plugged in? Worth checking before showing button prompts.
Lua/Python horizon.input.gamepadConnected · HorizonCode Gamepad Connected · C++ HE::api::input::gamepadConnected · query
gamepadButton(button: string)down: boolIs this gamepad button held? SDL's names, so "a", "b", "dpup" — not "south" and not "cross". Not case-sensitive; "south" and "cross" are not names and read false.
Lua/Python horizon.input.gamepadButton · HorizonCode Gamepad Button · C++ HE::api::input::gamepadButton · query
gamepadAxis(axis: string)value: floatA gamepad axis from -1 to 1, deadzone already applied. Names are SDL's: "leftx", "lefty", "righttrigger". Y axes are positive downwards, as in SDL.
Lua/Python horizon.input.gamepadAxis · HorizonCode Gamepad Axis · C++ HE::api::input::gamepadAxis · query
actionDown(action: string)down: boolWhether an input action (by the name of its InputAction asset, "Jump") is held this frame. The polling twin of the Input.<Action>.Pressed event: ask this inside Tick rather than keeping a flag yourself. Reads as released while the game is paused or the UI has the input.
Lua/Python horizon.input.actionDown · HorizonCode Input Action Down · C++ HE::api::input::actionDown · query
actionPressed(action: string)pressed: boolWhether an input action went down THIS frame — true for one frame per press, the same moment Input.<Action>.Pressed fires. Polled from onUpdate this is last frame's press (still exactly one frame per press); onInputPressed is current.
Lua/Python horizon.input.actionPressed · HorizonCode Input Action Pressed · C++ HE::api::input::actionPressed · query
actionReleased(action: string)released: boolWhether an input action went up THIS frame — true for one frame per release, the same moment Input.<Action>.Released fires.
Lua/Python horizon.input.actionReleased · HorizonCode Input Action Released · C++ HE::api::input::actionReleased · query
actionAxis(action: string)value: floatThe value of a one-dimensional axis action this frame, as the mapping context resolved it (-1 to 1 for keys and sticks, a displacement for the mouse). Zero while silenced by a pause or the UI.
Lua/Python horizon.input.actionAxis · HorizonCode Input Action Axis · C++ HE::api::input::actionAxis · query
actionAxis2D(action: string)value: vec2The value of a two-dimensional axis action this frame, as a Vec2 — a stick, WASD, the mouse. Zero while silenced by a pause or the UI.
Lua/Python horizon.input.actionAxis2D · HorizonCode Input Action Axis 2D · C++ HE::api::input::actionAxis2D · query
setModeGameOnly()—The game receives input and the UI does not: the cursor is captured and hidden, mouse look works, buttons cannot be clicked. First-person play.
Lua/Python horizon.input.setModeGameOnly · HorizonCode Set Input Mode: Game Only · C++ HE::api::input::setModeGameOnly · action
setModeGameAndUI()—Both receive input — the cursor is visible, the UI is clickable, and the game still reads keys. Inventory screens and build modes. The default, and what play mode starts in.
Lua/Python horizon.input.setModeGameAndUI · HorizonCode Set Input Mode: Game and UI · C++ HE::api::input::setModeGameAndUI · action
setModeUIOnly()—Only the UI receives input; the game sees nothing. What a pause menu wants, so a click behind it cannot fire a weapon. Also reports the gamepad as disconnected to the game. Actions with Fires while the game is paused still work, so the key that opened the menu can close it.
Lua/Python horizon.input.setModeUIOnly · HorizonCode Set Input Mode: UI Only · C++ HE::api::input::setModeUIOnly · action
mode()mode: stringWhich input mode is active: "GameOnly", "GameAndUI" or "UIOnly".
Lua/Python horizon.input.mode · HorizonCode Input Mode · C++ HE::api::input::modeName · query

Presentation · 8 functions

Animator

Lua / Python: horizon.animator.<function> · C++: HE::api::animator::<function>

Steering a character's animation from gameplay: the parameters its animator state machine tests, and the weights of its animation layers. Parameters live on the entity's Animator State Machine component, layers on its Animation Layers component; without the component the setters do nothing and the getters answer 0 or an empty string.

  • Parameters are numbers. setParam takes a float. A yes/no parameter is 1 or 0: in Lua setParam(e, "Grounded", true) is an error (number expected, got boolean), Python turns True into 1. A name the state machine asset never declared is created, not rejected, so a typo writes a parameter nothing reads. getParam answers 0 for a name that was never set.
  • Layers are found by name, first match. Two layers may share a name; every function then acts on the first of them. setLayerWeight clamps to 0 to 1. playLayer restarts that layer's own playhead and leaves its weight alone.
  • notifiesOf and layerNames return lists, which scripts cannot receive yet: from Lua and Python they answer an empty string (see List results). Notifies themselves reach a script as the onAnimationNotify callbacks. notifiesOf loads the clip if it is not in memory.

Example: animator.setParam

anim_speed.lua
local M = {}

-- On a character with Movement and an Animator State Machine whose
-- transitions test "Speed" (m/s) and "Grounded" (1 or 0).
function M.onUpdate(self, dt)
    local e = self.entityId
    horizon.animator.setParam(e, "Speed", horizon.movement.speed(e))
    -- Parameters are numbers: a boolean has to become 1 or 0.
    horizon.animator.setParam(e, "Grounded", horizon.movement.isGrounded(e) and 1 or 0)
end

return M
anim_speed.py
import horizon

class AnimSpeed(horizon.Behavior):
    # On a character with Movement and an Animator State Machine whose
    # transitions test "Speed" (m/s) and "Grounded" (1 or 0).
    def on_update(self, dt):
        e = self.entity_id
        horizon.animator.setParam(e, "Speed", horizon.movement.speed(e))
        # Parameters are numbers: a boolean has to become 1 or 0.
        horizon.animator.setParam(e, "Grounded", 1 if horizon.movement.isGrounded(e) else 0)
FunctionReturnsDescription
setParam(entity: int, name: string, value: float)—Writes a named parameter of the entity's animator state machine — the values its transitions test. This is how gameplay drives animation: set Speed, let the graph decide between walk and run. The value is a number: in Lua true is an error, Python turns True into 1. A name the asset never declared is created.
Lua/Python horizon.animator.setParam · HorizonCode Set Animator Param · C++ HE::api::animator::setParam · action · entity is Self in HorizonCode
getParam(entity: int, name: string)value: floatReads a named animator parameter back. 0 for an unknown name or an entity without an animator.
Lua/Python horizon.animator.getParam · HorizonCode Get Animator Param · C++ HE::api::animator::getParam · query · entity is Self in HorizonCode
getState(entity: int)state: stringThe name of the state playing right now, as the animator asset spells it. Empty when there is no state machine.
Lua/Python horizon.animator.getState · HorizonCode Get Animator State · C++ HE::api::animator::getState · query · entity is Self in HorizonCode
notifiesOf(clipPath: string)names: string[]The notify names written on an animation clip's timeline, by the clip's asset path. Notify names are free text with nothing checking them, so a misspelled one fires nothing and says nothing — this is how to see what a clip actually carries. Empty for an unknown path or a clip without any. Loads the clip if it is not in memory yet.
Lua/Python horizon.animator.notifiesOf · HorizonCode Get Clip Notifies · C++ HE::api::animator::notifiesOf · query · list: HorizonCode/C++ only
setLayerWeight(entity: int, layer: string, weight: float)—How strongly one animation layer is laid over the base pose, 0 to 1. The layer is named the way it is named in the Animation Layers component — by name and not by position, because reordering the stack must not silently fade a different body part. Clamped; unknown names do nothing. With two layers of the same name, the first one.
Lua/Python horizon.animator.setLayerWeight · HorizonCode Set Layer Weight · C++ HE::api::animator::setLayerWeight · action · entity is Self in HorizonCode
getLayerWeight(entity: int, layer: string)weight: floatThe weight an animation layer is currently at. 0 for an unknown layer or an entity without an Animation Layers component. With two layers of the same name, the first one.
Lua/Python horizon.animator.getLayerWeight · HorizonCode Get Layer Weight · C++ HE::api::animator::getLayerWeight · query · entity is Self in HorizonCode
playLayer(entity: int, layer: string)—Restarts one layer's own playhead at 0, so a one-shot layer (a reload, a flinch) plays again instead of resuming where it stopped. The weight is left alone: fading in is a separate decision and usually takes frames.
Lua/Python horizon.animator.playLayer · HorizonCode Play Layer · C++ HE::api::animator::playLayer · action · entity is Self in HorizonCode
layerNames(entity: int)names: string[]The layer names on an entity, in the order they are laid onto the base pose. Layer names are free text with nothing checking them, so this is how to see what a character actually carries.
Lua/Python horizon.animator.layerNames · HorizonCode Get Layer Names · C++ HE::api::animator::layerNames · query · entity is Self in HorizonCode · list: HorizonCode/C++ only

Presentation · 4 functions

Particles

Lua / Python: horizon.particle.<function> · C++: HE::api::particle::<function>

Firing and stopping the Particle System component on an entity. An emitter plays on its own from the start (its Playing box is ticked by default); these functions are for effects that have to happen at a moment. Calling play, stop or burst on an entity without a Particle System writes a warning to the log (at most every five seconds) and does nothing; isPlaying answers false without a warning, because asking is how a script finds out.

  • One-shots. An emitter whose graph does not loop emits its Max Particles once and finishes. play restarts it from the beginning, however often. isPlaying stays true until the last particle has died, not only until the emitting ends.
  • stop is soft. It stops the emitting and lets the particles that are out live their lifetime, so isPlaying is still true right after it and turns false when the last one dies.
  • burst(e, count) emits at the emitter's current world position and returns how many it actually made: never more than Max Particles alive at once (with 10 alive and a cap of 30, a burst of 500 makes 20), and 0 for a count of 0 or below. A burst also restarts a stopped or finished emitter.

Example: particle.burst

impact.lua
local M = {}

-- On a crate with a Particle System whose graph does not loop (sparks):
-- every hit fires a handful of sparks and gives the camera a short jolt.
function M.onCollisionEnter(self, other)
    local made = horizon.particle.burst(self.entityId, 24)
    if made > 0 then
        -- 5 cm, 0.5 degrees, 18 shakes per second, a quarter of a second.
        horizon.camera.playShake(0.05, 0.5, 18, 0.25)
    end
end

return M
impact.py
import horizon

class Impact(horizon.Behavior):
    # On a crate with a Particle System whose graph does not loop (sparks):
    # every hit fires a handful of sparks and gives the camera a short jolt.
    def on_collision_enter(self, other):
        made = horizon.particle.burst(self.entity_id, 24)
        if made > 0:
            # 5 cm, 0.5 degrees, 18 shakes per second, a quarter of a second.
            horizon.camera.playShake(0.05, 0.5, 18, 0.25)
FunctionReturnsDescription
play(entity: int)—Starts the emitter, or starts it again from the beginning. A one-shot that has already run needs this to run a second time: it resets the burst it has emitted, so firing the same effect twice fires it twice. Particles still in the air are left alone, which is what lets a fast repeat overlap its own tail. Also on an emitter that is still running: its one-shot count starts again.
Lua/Python horizon.particle.play · HorizonCode Play Effect · C++ HE::api::particle::play · action · entity is Self in HorizonCode
stop(entity: int)—Stops the emitter producing anything more, while the particles already out live out their lifetime and fade normally. Deliberately not the same as hiding the entity, which leaves it simulating unseen, nor the same as a pause, which would freeze a cloud of smoke in mid-air. isPlaying stays true until the last particle already out has died.
Lua/Python horizon.particle.stop · HorizonCode Stop Effect · C++ HE::api::particle::stop · action · entity is Self in HorizonCode
burst(entity: int, count: int)emitted: intEmits a number of particles at once from an entity's Particle System, ignoring its emit rate entirely. This is what a hit, a muzzle flash or a footstep is: so many at a moment, which a rate cannot express — an emit rate high enough to look instant depends on the frame rate. It answers how many were actually made, which can be fewer than asked for when the emitter's Max Particles is already reached. Emits at the emitter's current world position. Also restarts a stopped or finished emitter. 0 for a count of 0 or below.
Lua/Python horizon.particle.burst · HorizonCode Burst Particles · C++ HE::api::particle::burst · action · entity is Self in HorizonCode
isPlaying(entity: int)playing: boolWhether the effect is still running — either still emitting, or with particles still alive. It goes false the moment a one-shot has completely finished, which is how a graph waits for an effect instead of guessing a duration. false without a Particle System, and no warning.
Lua/Python horizon.particle.isPlaying · HorizonCode Is Effect Playing · C++ HE::api::particle::isPlaying · query · entity is Self in HorizonCode

Presentation · 18 functions

Audio

Lua / Python: horizon.audio.<function> · C++: HE::api::audio::<function>

Sounds by asset path, controlled afterwards through the handle play or playAt returns. Handles are whole numbers counting up from 1; 0 means nothing was started: the asset path did not load, or there is no audio engine. The audio engine belongs to a play session, so outside one (the editor while not playing, a tool script) every function here does nothing and the queries answer 0, false, or 1 for the pitch.

  • Every argument is required. play(asset, volume, pitch, loop) has no defaults in Python (a missing loop is an IndexError); in Lua a missing loop is read as false. playAt takes the position as three numbers: playAt(asset, x, y, z, volume, pitch, loop, minDist, maxDist).
  • Stop what you start. A sound that finishes on its own is no longer playing, but the engine keeps it, with its own copy of the clip, until it is stopped: its handle still answers its volume and pitch, and fifty finished one-shots are fifty held voices (build 7d49d44f). For sounds fired again and again (footsteps, shots) call stop on the previous handle before playing the next, or stopAll at a natural break. A scene change stops everything.
  • Transport. isPlaying is false both for a paused and for a finished sound; isPaused tells them apart. seek clamps to the clip (5 s into a 1 s clip lands at 1 s, −2 at 0) and works while paused; getTime stands still while paused.
  • Values are not clamped, with one exception: a volume above 1 amplifies and a negative one is stored as it is, while a pitch of 0 or below is ignored and the old pitch stays.
  • Spatial sounds are heard from the scene's Audio Listener (the first one). Without a listener the ears stay at the world origin. setSoundPosition only matters for a sound started with playAt.
  • setBusVolume creates a bus that does not exist yet, so a misspelled bus name silently becomes a new, empty bus.

Example: audio.playAt

footsteps.lua
local M = {}
local STEP = "Audio/footstep.hasset"

-- The walk clip carries "FootL" and "FootR" notifies on its timeline.
function M.onAnimationNotify(self, name)
    if name ~= "FootL" and name ~= "FootR" then return end
    -- A finished sound is held until it is stopped, so free the last step.
    if self.step then horizon.audio.stop(self.step) end
    local x, y, z = horizon.getPosition(self.entityId)
    local pitch = horizon.random.range(0.9, 1.1)   -- no two steps alike
    self.step = horizon.audio.playAt(STEP, x, y, z, 0.7, pitch, false, 1, 25)
end

return M
footsteps.py
import horizon

STEP = "Audio/footstep.hasset"

class Footsteps(horizon.Behavior):
    step = 0

    # The walk clip carries "FootL" and "FootR" notifies on its timeline.
    def on_animation_notify(self, name):
        if name not in ("FootL", "FootR"):
            return
        # A finished sound is held until it is stopped, so free the last step.
        if self.step:
            horizon.audio.stop(self.step)
        x, y, z = horizon.getPosition(self.entity_id)
        pitch = horizon.random.range(0.9, 1.1)   # no two steps alike
        self.step = horizon.audio.playAt(STEP, x, y, z, 0.7, pitch, False, 1, 25)

Example: audio.setVolume

music_fade.lua
local M = {}
local MUSIC = "Audio/theme.hasset"
local VOLUME, FADE = 0.6, 2.0   -- fade length in seconds

function M.onStart(self)
    self.music = horizon.audio.play(MUSIC, VOLUME, 1, true)
    self.left = 0
end

-- "EndLevel" is a button action.
function M.onInputPressed(self, name)
    if name == "EndLevel" and self.music ~= 0 then self.left = FADE end
end

function M.onUpdate(self, dt)
    if self.left <= 0 then return end
    self.left = self.left - dt
    if self.left <= 0 then
        horizon.audio.stop(self.music)   -- stop, not volume 0: frees the voice
        self.music = 0
    else
        horizon.audio.setVolume(self.music, VOLUME * self.left / FADE)
    end
end

return M
music_fade.py
import horizon

MUSIC = "Audio/theme.hasset"
VOLUME, FADE = 0.6, 2.0   # fade length in seconds

class MusicFade(horizon.Behavior):
    def on_start(self):
        self.music = horizon.audio.play(MUSIC, VOLUME, 1, True)
        self.left = 0.0

    # "EndLevel" is a button action.
    def on_input_pressed(self, name):
        if name == "EndLevel" and self.music != 0:
            self.left = FADE

    def on_update(self, dt):
        if self.left <= 0:
            return
        self.left -= dt
        if self.left <= 0:
            horizon.audio.stop(self.music)   # stop, not volume 0: frees the voice
            self.music = 0
        else:
            horizon.audio.setVolume(self.music, VOLUME * self.left / FADE)
FunctionReturnsDescription
play(asset: string, volume: float, pitch: float, loop: bool)handle: intPlays a sound flat — the same in both ears, wherever the listener is. Music, UI clicks, narration. The handle it returns is what Stop and Is Playing take; a one-shot can simply drop it. 0 when the asset does not load or outside a play session. In Lua a missing loop reads as false; Python needs all four arguments.
Lua/Python horizon.audio.play · HorizonCode Play Sound · C++ HE::api::audio::play · action
playAt(asset: string, position: vec3, volume: float, pitch: float, loop: bool, minDist: float, maxDist: float)handle: intPlays a sound at a world position, quieter with distance. Full volume inside Min Dist, silent past Max Dist. Distance is measured from the scene's Audio Listener; without one the ears stay at the world origin. The position is three numbers: playAt(asset, x, y, z, volume, pitch, loop, minDist, maxDist).
Lua/Python horizon.audio.playAt · HorizonCode Play Sound At · C++ HE::api::audio::playAt · action
stop(handle: int)—Stops one playing sound by handle. A handle that has already finished is harmless. The only way, besides stopAll, to free a sound that finished by itself (see the group notes).
Lua/Python horizon.audio.stop · HorizonCode Stop Sound · C++ HE::api::audio::stop · action
stopAll()—Stops every sound at once — what a scene change or a hard cut wants.
Lua/Python horizon.audio.stopAll · HorizonCode Stop All Sounds · C++ HE::api::audio::stopAll · action
isPlaying(handle: int)playing: boolIs this handle still sounding? False once it has finished or been stopped.
Lua/Python horizon.audio.isPlaying · HorizonCode Is Sound Playing · C++ HE::api::audio::isPlaying · query
setBusVolume(bus: string, volume: float)—Sets the volume of a mixer bus by name, so a whole group — music, sfx, voice — moves together. This is what a settings screen writes to. Creates the bus when none has that name, so a misspelled name is a new, empty bus.
Lua/Python horizon.audio.setBusVolume · HorizonCode Set Bus Volume · C++ HE::api::audio::setBusVolume · action
setSoundPosition(handle: int, position: vec3)—Moves a playing spatial sound to a new world position. For a sound that has to follow something that moves while it plays.
Lua/Python horizon.audio.setSoundPosition · HorizonCode Set Sound Position · C++ HE::api::audio::setSoundPosition · action
pause(handle: int)—Pauses one sound by handle, keeping its place. Resume Sound picks up exactly there. Unlike Stop, the sound is still alive afterwards — a pause menu wants this, a scene change wants Stop.
Lua/Python horizon.audio.pause · HorizonCode Pause Sound · C++ HE::api::audio::pause · action
resume(handle: int)—Continues a paused sound from where Pause Sound left it. Harmless on a sound that is not paused.
Lua/Python horizon.audio.resume · HorizonCode Resume Sound · C++ HE::api::audio::resume · action
isPaused(handle: int)paused: boolIs this handle paused? Is Playing says false for a paused sound AND for one that has finished; this is how to tell them apart.
Lua/Python horizon.audio.isPaused · HorizonCode Is Sound Paused · C++ HE::api::audio::isPaused · query
setVolume(handle: int, volume: float)—Changes the volume of one playing sound. 1 is as played, 0 is silent; a fade is this called every tick with a shrinking value. Not clamped: above 1 amplifies, a negative value is stored as it is.
Lua/Python horizon.audio.setVolume · HorizonCode Set Sound Volume · C++ HE::api::audio::setVolume · action
getVolume(handle: int)volume: floatThe current volume of one playing sound — what Play or Set Sound Volume last gave it. 0 for a handle that is gone. A sound that finished by itself but was not stopped still answers its last volume.
Lua/Python horizon.audio.getVolume · HorizonCode Get Sound Volume · C++ HE::api::audio::getVolume · query
setPitch(handle: int, pitch: float)—Changes the pitch of one playing sound. 1 is as recorded, 2 is an octave up and twice as fast, 0.5 an octave down. An engine note or a slow-motion moment is this. A pitch of 0 or below is ignored; the old pitch stays.
Lua/Python horizon.audio.setPitch · HorizonCode Set Sound Pitch · C++ HE::api::audio::setPitch · action
getPitch(handle: int)pitch: floatThe current pitch of one playing sound. 1 for a handle that is gone.
Lua/Python horizon.audio.getPitch · HorizonCode Get Sound Pitch · C++ HE::api::audio::getPitch · query
setLooping(handle: int, loop: bool)—Turns looping on or off for a sound that is already playing — a loop that should end after its current pass gets looping switched off, not Stop.
Lua/Python horizon.audio.setLooping · HorizonCode Set Sound Looping · C++ HE::api::audio::setLooping · action
seek(handle: int, seconds: float)—Jumps a playing sound to a time in seconds from its start. Past the end goes to the end, below zero to the start. Works on a paused sound too.
Lua/Python horizon.audio.seek · HorizonCode Seek Sound · C++ HE::api::audio::seek · action
getTime(handle: int)seconds: floatHow far into the sound the playhead is, in seconds — of the clip itself, so pitch does not stretch it. 0 for a handle that is gone.
Lua/Python horizon.audio.getTime · HorizonCode Get Sound Time · C++ HE::api::audio::getTime · query
getLength(handle: int)seconds: floatHow long the sound is, in seconds. Together with Get Sound Time this is a progress bar. 0 for a handle that is gone.
Lua/Python horizon.audio.getLength · HorizonCode Get Sound Length · C++ HE::api::audio::getLength · query

Presentation · 26 functions

Camera

Lua / Python: horizon.camera.<function> · C++: HE::api::camera::<function>

The camera the picture comes from, and the camera rig that moves it. No function here takes an entity: they all act on the active camera, the one marked Main, or the first camera in the scene when none is. The rig functions act on that camera's Camera Rig component; without one they do nothing and read 0.

  • Position and rotation are the camera's own transform. getPosition and getRotation read it and the setters write it, relative to the camera's parent. A camera parented under an entity at (10, 0, 0) and sitting 5 m behind it reads 0, 2, 5, not 10, 2, 5. For a camera at the top of the hierarchy that is the world position. While a rig drives the camera it rewrites the transform every frame, so setPosition/setRotation last until the next frame; setFov stays.
  • Rig numbers are forgiving, not checked. setRigMode treats anything but 0 as third person. setTargetYawMode treats anything but 0 and 2 as Follow. addYawPitch clamps the pitch to the rig's limits (by default −80° to 75°) but lets the yaw run past 360. setLagSpeeds turns negative speeds into 0. setArmLength takes a negative length as it is, which puts the camera in front of the target.
  • Shake moves the transform, the FOV kick does not. The rig writes the shaken pose into the camera's transform, so getPosition and getRotation jitter while a shake runs. A FOV kick is added on top of the field of view and getFov keeps answering the authored value. playShake returns 0 when there is no rig; handles start at 1.
  • Blending. blendTo(camera, seconds, curve) makes that camera the main one at once (every other camera loses Main), so from the next call on the other functions act on it. The ease itself needs a Camera Rig on the camera blended to; without one it is a cut and isBlending stays false. Curves outside 0 to 2 become smoothstep.

Example: camera.kickFov

sprint_fov.lua
local M = {}

-- "Sprint" is a button action. The kick widens the view for a moment and
-- falls back by itself; getFov keeps answering the authored value.
function M.onInputPressed(self, name)
    if name == "Sprint" then
        horizon.camera.kickFov(8, 0.15, 0.6, 0.4)   -- degrees, attack, hold, decay
        horizon.camera.setLagEnabled(true)           -- the camera trails a little
    end
end

function M.onInputReleased(self, name)
    if name == "Sprint" then horizon.camera.setLagEnabled(false) end
end

return M
sprint_fov.py
import horizon

class SprintFov(horizon.Behavior):
    # "Sprint" is a button action. The kick widens the view for a moment and
    # falls back by itself; getFov keeps answering the authored value.
    def on_input_pressed(self, name):
        if name == "Sprint":
            horizon.camera.kickFov(8, 0.15, 0.6, 0.4)   # degrees, attack, hold, decay
            horizon.camera.setLagEnabled(True)           # the camera trails a little

    def on_input_released(self, name):
        if name == "Sprint":
            horizon.camera.setLagEnabled(False)
FunctionReturnsDescription
getPosition()position: vec3The active camera's position, read from its own transform: relative to its parent when it has one, so it is the world position only for a camera at the top of the hierarchy. The active camera is the one marked Main, else the first camera in the scene.
Lua/Python horizon.camera.getPosition · HorizonCode Get Camera Position · C++ HE::api::camera::getPosition · query
setPosition(position: vec3)—Moves the active camera. Ignored the moment a camera rig is driving it — the rig recomputes the position from its target every frame. Relative to the camera's parent. While a rig drives the camera it lasts one frame.
Lua/Python horizon.camera.setPosition · HorizonCode Set Camera Position · C++ HE::api::camera::setPosition · action
getRotation()rotation: vec3The active camera's rotation as euler degrees, from its own transform — relative to its parent, like Get Camera Position.
Lua/Python horizon.camera.getRotation · HorizonCode Get Camera Rotation · C++ HE::api::camera::getRotation · query
setRotation(rotation: vec3)—Turns the active camera. Same caveat as Set Position: a rig overrides it. While a rig drives the camera it lasts one frame.
Lua/Python horizon.camera.setRotation · HorizonCode Set Camera Rotation · C++ HE::api::camera::setRotation · action
getFov()degrees: floatThe vertical field of view in degrees.
Lua/Python horizon.camera.getFov · HorizonCode Get Camera FOV · C++ HE::api::camera::getFov · query
setFov(degrees: float)—Sets the vertical field of view in degrees. Widening it while sprinting is the classic speed cue; animate it rather than snapping.
Lua/Python horizon.camera.setFov · HorizonCode Set Camera FOV · C++ HE::api::camera::setFov · action
setRigMode(mode: int)—Switches the camera rig between first person (0) and third person (1). First person is the same rig with an arm length of zero. Any value other than 0 is third person.
Lua/Python horizon.camera.setRigMode · HorizonCode Set Camera Mode · C++ HE::api::camera::setRigMode · action
getRigMode()mode: int0 first person, 1 third person. 0 when there is no rig at all.
Lua/Python horizon.camera.getRigMode · HorizonCode Get Camera Mode · C++ HE::api::camera::getRigMode · query
setRigTarget(entity: int)—Points the rig at an entity to follow. Setting it to nothing puts the rig back on whichever character the player possesses. 0, or an id that is not an entity, also means the possessed character.
Lua/Python horizon.camera.setRigTarget · HorizonCode Set Camera Target · C++ HE::api::camera::setRigTarget · action · entity is Self in HorizonCode
setArmLength(length: float)—How far behind the target the third-person camera sits, in metres. 0 puts it in the target's head. Not clamped: a negative length puts the camera in front of the target.
Lua/Python horizon.camera.setArmLength · HorizonCode Set Camera Distance · C++ HE::api::camera::setArmLength · action
getArmLength()length: floatThe rig's current arm length in metres.
Lua/Python horizon.camera.getArmLength · HorizonCode Get Camera Distance · C++ HE::api::camera::getArmLength · query
setTargetYawMode(mode: int)—Free (0) lets the character face where it walks while the camera looks elsewhere. Follow (1) turns the character with the camera, which is what makes strafing and walking backwards work. Follow Smoothed (2) does the same, but swings the body after the camera at the rig's turn rate. Any value other than 0 and 2 is Follow.
Lua/Python horizon.camera.setTargetYawMode · HorizonCode Set Target Rotation Mode · C++ HE::api::camera::setTargetYawMode · action
getTargetYawMode()mode: int0 Free (the character faces where it walks), 1 Follow (it turns with the camera), 2 Follow Smoothed (it swings after the camera).
Lua/Python horizon.camera.getTargetYawMode · HorizonCode Get Target Rotation Mode · C++ HE::api::camera::getTargetYawMode · query
getRigYaw()degrees: floatWhich way the rig is looking, in degrees.
Lua/Python horizon.camera.getRigYaw · HorizonCode Get Camera Yaw · C++ HE::api::camera::getRigYaw · query
getRigPitch()degrees: floatHow far up or down the rig is looking, in degrees.
Lua/Python horizon.camera.getRigPitch · HorizonCode Get Camera Pitch · C++ HE::api::camera::getRigPitch · query
addYawPitch(deltaYaw: float, deltaPitch: float)—Turns the rig by these degrees, clamped to the pitch limits the rig carries. This is what a look input drives — adding to the angles rather than setting them is what keeps two input sources from fighting. A default rig limits the pitch to −80° … 75°. The yaw is not wrapped: it runs past 360.
Lua/Python horizon.camera.addYawPitch · HorizonCode Turn Camera · C++ HE::api::camera::addYawPitch · action
setLagEnabled(enabled: bool)—Switches camera lag on or off. With it on the camera eases after its target instead of being welded to it; off is the default, so an existing scene looks exactly as it did.
Lua/Python horizon.camera.setLagEnabled · HorizonCode Set Camera Lag · C++ HE::api::camera::setLagEnabled · action
getLagEnabled()enabled: boolWhether the rig is easing after its target rather than following it rigidly.
Lua/Python horizon.camera.getLagEnabled · HorizonCode Get Camera Lag · C++ HE::api::camera::getLagEnabled · query
setLagSpeeds(position: float, rotation: float)—How quickly the camera catches up, per second: the first number moves the camera to its target, the second swings the boom round to the look direction. Larger is tighter; 0 never catches up at all. Negative speeds become 0.
Lua/Python horizon.camera.setLagSpeeds · HorizonCode Set Camera Lag Speeds · C++ HE::api::camera::setLagSpeeds · action
snapRig()—Puts the camera exactly where it belongs on the next frame instead of easing into it. This is what a teleport, a respawn or a cut needs — without it the camera sails across the level to catch up.
Lua/Python horizon.camera.snapRig · HorizonCode Snap Camera · C++ HE::api::camera::snapRig · action
playShake(positionAmplitude: float, rotationAmplitude: float, frequency: float, duration: float)handle: intShakes the camera: a position amplitude in metres, a rotation amplitude in degrees, a frequency in shakes per second and a duration. A duration of 0 or less runs until it is stopped, which is what the handle it returns is for; a one-shot can drop it. 0 without a rig. The shake is written into the camera's transform, so getPosition jitters while it runs and returns to the unshaken pose afterwards.
Lua/Python horizon.camera.playShake · HorizonCode Play Camera Shake · C++ HE::api::camera::playShake · action
stopShake(handle: int)—Ends one running shake by the handle Play Camera Shake gave out. An unknown or already-finished handle does nothing.
Lua/Python horizon.camera.stopShake · HorizonCode Stop Camera Shake · C++ HE::api::camera::stopShake · action
stopAllShakes()—Ends every shake on the camera at once — what a cutscene or a death screen wants, rather than remembering each handle.
Lua/Python horizon.camera.stopAllShakes · HorizonCode Stop All Camera Shakes · C++ HE::api::camera::stopAllShakes · action
kickFov(degrees: float, attack: float, hold: float, decay: float)—Pushes the field of view by these degrees and lets it fall back: attack, hold and decay in seconds. Negative degrees zoom in. It never changes the camera's own FOV setting, so Get Camera FOV keeps answering what the author set. Added on top of the field of view; getFov does not include it.
Lua/Python horizon.camera.kickFov · HorizonCode Kick Camera FOV · C++ HE::api::camera::kickFov · action
blendTo(camera: int, seconds: float, curve: int)—Hands the view to another camera over a number of seconds. Curve 0 is linear, 1 smoothstep, 2 ease-out. 0 seconds is a straight cut, and so is switching the main camera by hand — a blend only ever starts here. The camera blended to needs a camera rig of its own; to a camera without one this is always a cut. The new camera is Main at once. A curve outside 0 to 2 becomes smoothstep.
Lua/Python horizon.camera.blendTo · HorizonCode Blend To Camera · C++ HE::api::camera::blendTo · action
isBlending()blending: boolWhether the picture is currently easing in from another camera. True until the blend has fully arrived.
Lua/Python horizon.camera.isBlending · HorizonCode Is Camera Blending · C++ HE::api::camera::isBlending · query

Presentation · 2 functions

Material

◆

Lua and Python have no horizon.material.*: use the flat horizon.getMaterialParam / setMaterialParam, which take the value as one to four numbers.

The named parameters of a node-graph material, the ones its Param nodes declare, read and written at runtime. Every parameter holds four numbers whatever the shader reads, so a scalar parameter is its first number. From Lua and Python, horizon.setMaterialParam(e, name, x [, y, z, w]) fills missing numbers with 0 and returns false when the entity has no Material component or the material declares no such parameter; horizon.getMaterialParam returns four zeros then.

▲

Known issue (build 7d49d44f): a set changes every entity with the same material. The value is written into the material asset in memory, not onto the entity. Set a parameter on one crate and every crate sharing its material changes. An entity that overrides the parameter in its Details panel keeps its override, and getParam does not see that override: it reads the asset. Until this is fixed, give an entity that needs its own value its own material asset.

FunctionReturnsDescription
getParam(entity: int, name: string)value: colorReads one named parameter of the entity's material as a colour. The name is the one the material graph's Param node declares; all four components are stored, whatever the shader reads. It reads the material asset's value, not an override set on this entity's Details panel.
Lua/Python only as flat horizon.getMaterialParam · HorizonCode Get Material Param · C++ HE::api::material::getParam · query · entity is Self in HorizonCode
setParam(entity: int, name: string, value: color)ok: boolWrites one named parameter of the entity's material. It writes the material ASSET, not this entity: every entity using the same material changes with it, while an entity whose Details panel overrides this parameter keeps its override. Ok is false when the entity has no material or the parameter is not declared. Known issue: every entity with the same material changes too (see the group notes).
Lua/Python only as flat horizon.setMaterialParam · HorizonCode Set Material Param · C++ HE::api::material::setParam · action · entity is Self in HorizonCode

Presentation · 116 functions

Environment

Lua / Python: horizon.env.<function> · C++: HE::api::env::<function>

The sky, the day-night cycle and the weather look of the scene: every field of the Environment component on the Sky entity, which the Environment window adds. If a scene carries more than one Sky, these functions and the renderer both use the first. The table below is generated from the engine's own field list, so it always has every field there is.

  • Colours take four numbers. A color field is set from Lua and Python with r, g, b, a: horizon.env.setSunColor(1, 0.8, 0.6, 1). The fourth number is ignored, but it has to be there: with three, Lua raises bad argument #4 and Python IndexError. The getters return four numbers with alpha always 1. int fields need a whole number (1.7 is an error in both languages); bool fields accept true/false or, in Python, 1/0.
  • Nothing is clamped. A setter stores exactly what it is given: a time of day of 1.7, a negative fog density or a cloud quality of 9 read back unchanged. Stay inside the ranges the descriptions give. Time of day is wrapped into 0 to 1 only when Auto Advance moves it on.
  • Time of day needs the cycle switched on. setTimeOfDay only moves the sun while DayNightCycle is true; the Details panel switches that on for you when you drag the slider, a script does not. For a clock that runs by itself set AutoAdvance as well: with a CycleSeconds of 10, one second moves the time by 0.1, and with MoonPhaseAuto the moon phase moves one cycle per MoonCycleDays days.
  • Weather. A Weather component writes six of these fields (cloud coverage, fog density, wind speed, rain, snow, wetness) toward its preset, blending over its transition time. It backs off a field as soon as its value differs from the last value Weather wrote, so a value a script sets stays until a new preset is selected, which takes all six back. The lightning Flash is rewritten every tick. Every other field is never touched by Weather.
  • No Sky entity (removed in the Environment window): every getter answers 0, false or black, and every setter does nothing. A time of day of 0 then does not mean midnight.

Example: env.setTimeOfDay

sleep.lua
local M = {}

-- "Sleep" is a button action: skip to the next morning.
function M.onInputPressed(self, name)
    if name ~= "Sleep" then return end
    horizon.env.setDayNightCycle(true)   -- without it the sun ignores the clock
    horizon.env.setTimeOfDay(0.3)        -- a little after sunrise
    -- Colours are four numbers; the fourth is ignored but required.
    horizon.env.setSunColor(1.0, 0.85, 0.7, 1)
    -- Weather lets go of a field a script changed, until the next preset.
    horizon.env.setFogDensity(0.02)
end

return M
sleep.py
import horizon

class Sleep(horizon.Behavior):
    # "Sleep" is a button action: skip to the next morning.
    def on_input_pressed(self, name):
        if name != "Sleep":
            return
        horizon.env.setDayNightCycle(True)   # without it the sun ignores the clock
        horizon.env.setTimeOfDay(0.3)        # a little after sunrise
        # Colours are four numbers; the fourth is ignored but required.
        horizon.env.setSunColor(1.0, 0.85, 0.7, 1)
        # Weather lets go of a field a script changed, until the next preset.
        horizon.env.setFogDensity(0.02)

All 58 fields as one table, 116 functions: for a field Name, horizon.env.getName() reads it and horizon.env.setName(value) writes it; in HorizonCode they are Get … and Set … nodes, in C++ HE::api::env::getName(ctx) / setName(ctx, value). Setters are actions, getters queries. Each field is the value of the same name on the Sky entity's Environment component, which its Details panel shows. The Weather column says what a Weather component in the scene does to the field.

FieldTypeWeatherDescription
Time Of Day
getTimeOfDay / setTimeOfDay
float—The sky's clock: 0 and 1 are midnight, 0.25 sunrise, 0.5 noon. It only moves the sun while Day Night Cycle is on. Not wrapped by the setter: 1.7 reads back as 1.7 until Auto Advance moves the clock on.
Day Cycle Seconds
getCycleSeconds / setCycleSeconds
float—How many real seconds a full day takes while Auto Advance runs the clock.
Sun Intensity
getSunIntensity / setSunIntensity
float—How strong the sun is, and with it the whole daylit scene.
Moon Intensity
getMoonIntensity / setMoonIntensity
float—How strong the moonlight is — the difference between a night you can see in and a black screen.
Moon Phase
getMoonPhase / setMoonPhase
float—The moon's phase: 0 new, 0.5 full.
Moon Cycle Days
getMoonCycleDays / setMoonCycleDays
float—How many days a full new-to-full-to-new cycle takes.
Cloud Coverage
getCloudCoverage / setCloudCoverage
floatsteered toward the preset, released once changedHow much of the sky the cloud layer fills: 0 clear, 1 overcast.
Wind Direction
getWindDirection / setWindDirection
float—Which way the clouds drift, in degrees.
Wind Speed
getWindSpeed / setWindSpeed
floatsteered toward the preset, released once changedHow fast the clouds drift.
Cloud Height
getCloudHeight / setCloudHeight
float—The world height of the cloud deck's base, in metres.
Cloud Shadow Strength
getCloudShadowStrength / setCloudShadowStrength
float—How dark the shadows the cloud layer casts on the ground get.
Cloud Evolution
getCloudEvolution / setCloudEvolution
float—How fast clouds change shape as they drift; 0 freezes the formation.
Cloud Density
getCloudDensity / setCloudDensity
float—How solid the clouds are: low is wispy, high is thick and dark underneath.
Cloud Fluffiness
getCloudFluffiness / setCloudFluffiness
float—How broken up the cloud edges are, from a smooth sheet to billowy.
Contrail Amount
getContrailAmount / setContrailAmount
float—How many vapour trails cross the high sky.
Cirrus Amount
getCirrusAmount / setCirrusAmount
float—How much thin fibrous cirrus sits above the main clouds.
Cirrus Seed
getCirrusSeed / setCirrusSeed
float—Which cirrus pattern is drawn — a different sky, not a different amount.
God Rays
getGodRays / setGodRays
float—The shafts of light through gaps in the cloud; they need broken cover to shine through.
Shooting Stars
getShootingStars / setShootingStars
float—How often meteors streak across the night sky.
Lens Flare
getLensFlare / setLensFlare
float—The camera artefact when the sun is in shot.
Fog Density
getFogDensity / setFogDensity
floatsteered toward the preset, released once changedHow thick the atmospheric haze is; even a very small amount gives a landscape distance.
Fog Height Falloff
getFogHeightFalloff / setFogHeightFalloff
float—How much the fog pools near the ground instead of filling the air evenly.
Rain Amount
getRainAmount / setRainAmount
floatsteered toward the preset, released once changedHow hard it is raining, 0 to 1.
Snow Amount
getSnowAmount / setSnowAmount
floatsteered toward the preset, released once changedHow hard it is snowing, 0 to 1.
Wetness
getWetness / setWetness
floatsteered toward the preset, released once changedHow wet surfaces look after rain.
Lightning Flash
getFlash / setFlash
floatrewritten every tickThe lightning flash, driven per strike by the Weather system.
Aurora Intensity
getAuroraIntensity / setAuroraIntensity
float—How strong the aurora ribbons are; 0 switches them off.
Milky Way Intensity
getMilkyWayIntensity / setMilkyWayIntensity
float—How bright the galaxy's band is across the night sky.
Nebula Intensity
getNebulaIntensity / setNebulaIntensity
float—How visible the deep-space nebula is behind the stars.
Nebula Seed
getNebulaSeed / setNebulaSeed
float—Which nebula shape is generated.
Nebula Coverage
getNebulaCoverage / setNebulaCoverage
float—How much of the sky the nebula spans.
Aurora Height
getAuroraHeight / setAuroraHeight
float—How tall the aurora ribbons stand above the horizon.
Aurora Fragmentation
getAuroraFragmentation / setAuroraFragmentation
float—How broken the ribbons are, from a smooth curtain to ragged streaks.
Star Brightness
getStarBrightness / setStarBrightness
float—How bright the star field is; stars fade out by themselves as the sky lightens.
Star Size
getStarSize / setStarSize
float—How large stars are drawn.
Star Size Variation
getStarSizeVariation / setStarSizeVariation
float—How much star sizes differ; 0 makes every star the same, which reads as artificial.
Star Glow
getStarGlow / setStarGlow
float—The halo around each star: 0 crisp points, higher a softer sky.
Star Twinkle
getStarTwinkle / setStarTwinkle
float—How much the stars flicker.
Star Density
getStarDensity / setStarDensity
float—How many stars there are.
Day Night Cycle
getDayNightCycle / setDayNightCycle
bool—Whether Time Of Day drives the sun, sky and shadows; off, the scene's own directional light is used.
Auto Advance Time
getAutoAdvance / setAutoAdvance
bool—Whether Time Of Day runs on by itself, one day per Day Cycle Seconds; it needs Day Night Cycle on as well.
Moon Phase Auto
getMoonPhaseAuto / setMoonPhaseAuto
bool—Whether the moon phase moves on with the days while Auto Advance runs the clock.
Cloud Shadows
getCloudShadows / setCloudShadows
bool—Whether the cloud layer darkens the ground under it.
Cloud Inter Shadows
getCloudInterShadows / setCloudInterShadows
bool—Whether clouds cast shadows within their own body, so a tall tower darkens what is behind it.
Low Res Clouds
getLowResClouds / setLowResClouds
bool—Whether the clouds are raymarched at quarter resolution and upscaled — much cheaper, slightly softer.
Cloud Mode
getCloudMode / setCloudMode
int—The cloud layer: 0 painted on the sky dome (cheap, never comes closer), 1 real 3D volumes the camera can fly into.
Cloud Quality
getCloudQuality / setCloudQuality
int—How many steps the cloud raymarch takes: 0 low, 1 medium, 2 high — the most expensive sky setting there is.
Cloud Style
getCloudStyle / setCloudStyle
int—0 the original flat drifting layer, 1 cauliflower shapes that tower and dissolve.
Nebula Quality
getNebulaQuality / setNebulaQuality
int—The nebula's detail level: 0 performance, 1 high, 2 max.
Sun Color
getSunColor / setSunColor
color—The tint of the sunlight; the sky reddens the sun near the horizon on its own.
Moon Color
getMoonColor / setMoonColor
color—The tint of the moonlight at night.
Cloud Tint
getCloudTint / setCloudTint
color—The colour multiplied into the clouds.
Nebula Color 1
getNebulaColor / setNebulaColor
color—The nebula's cool interior veil.
Nebula Color 2
getNebulaColor2 / setNebulaColor2
color—The nebula's warm filament regions.
Nebula Color 3
getNebulaColor3 / setNebulaColor3
color—The nebula's deep red filament regions.
Aurora Color
getAuroraColor / setAuroraColor
color—The aurora's colour at its lower edge, where it is brightest.
Aurora Color Top
getAuroraColorTop / setAuroraColorTop
color—The colour the aurora fades into at the top of the ribbon.
Star Color
getStarColor / setStarColor
color—The tint over the whole star field; the per-star variation survives it.

Interface and app · 12 functions

UI Elements

Lua / Python: horizon.ui.<function> · C++: HE::api::ui::<function>

These rows work on entities that carry UI components: an element on a UI canvas in the scene, addressed by its entity id like any other entity. Widgets built in the Widget Designer are a different thing with their own ids, see Widget. From a script, pass the id: the element the script sits on is self.entityId (Python self.entity_id), another one is found once with horizon.entity.findByName.

  • Which component answers. The text rows need a UI Text component: setText on anything else does nothing and getText answers "". The colour is the Image tint, else the Text colour, else a Button's normal colour, in that order. An element with none of the three ignores setColor and reads as white (1, 1, 1, 1). Visibility, position and size belong to every element; an entity without UI answers false and 0, 0.
  • A colour is four numbers and a position or size two: setColor(e, 1, 0.5, 0, 1), setPosition(e, x, y). With three colour numbers Lua throws bad argument #5 and Python an IndexError. A missing visible reads as false in Lua, so the element disappears; Python throws.
  • Nothing is clamped or checked: a negative size is stored as it is.
  • pointerOverUI asks the widget layer: it is true while the pointer is over a widget, and while a widget dialog (widget.showModal) is open, wherever the pointer is. UI elements on a scene canvas do not count.
▲

Known issue (build 7d49d44f): setMaterialParam changes every element with the same material. The value is written into the material asset in memory, not onto the element, the same as material.setParam. Two progress bars sharing one fill material show the same fill. Give an element that needs its own value its own material asset.

Example: ui.setText

level_clock.lua
local M = {}
local LIMIT = 90   -- seconds for the level

-- This script sits on the HUD's UI Text entity.
function M.onStart(self)
    self.left = LIMIT
end

function M.onUpdate(self, dt)
    if self.left <= 0 then return end
    self.left = math.max(0, self.left - dt)
    local e = self.entityId
    horizon.ui.setText(e, string.format("%d:%02d", self.left // 60, math.floor(self.left) % 60))
    if self.left < 10 then
        horizon.ui.setColor(e, 1, 0.25, 0.2, 1)   -- a colour is four numbers
    end
    if self.left == 0 then
        horizon.ui.setVisible(e, false)
    end
end

return M
level_clock.py
import horizon

LIMIT = 90  # seconds for the level

# This script sits on the HUD's UI Text entity.
class LevelClock(horizon.Behavior):
    def on_start(self):
        self.left = LIMIT

    def on_update(self, dt):
        if self.left <= 0:
            return
        self.left = max(0.0, self.left - dt)
        e = self.entity_id
        horizon.ui.setText(e, "%d:%02d" % (self.left // 60, int(self.left) % 60))
        if self.left < 10:
            horizon.ui.setColor(e, 1, 0.25, 0.2, 1)   # a colour is four numbers
        if self.left == 0:
            horizon.ui.setVisible(e, False)
FunctionReturnsDescription
getText(entity: int)text: stringThe text of a UI Text element, by entity. Empty for an element that is not one.
Lua/Python horizon.ui.getText, or flat horizon.getUIText · HorizonCode Get UI Text · C++ HE::api::ui::getText · query · entity is Self in HorizonCode
setText(entity: int, text: string)—Replaces a UI Text element's text. The usual way a score, a timer or a subtitle reaches the screen. Only a UI Text element takes it; on any other element it does nothing.
Lua/Python horizon.ui.setText, or flat horizon.setUIText · HorizonCode Set UI Text · C++ HE::api::ui::setText · action · entity is Self in HorizonCode
getColor(entity: int)color: colorThe element's colour, alpha included: an Image's tint, else a Text's colour, else a Button's normal colour. White for an element with none of the three. (1, 1, 1, 1) for an element with no Image, Text or Button.
Lua/Python horizon.ui.getColor, or flat horizon.getUIColor · HorizonCode Get UI Color · C++ HE::api::ui::getColor · query · entity is Self in HorizonCode
setColor(entity: int, color: color)—Sets the element's colour. On an Image element it is the tint over the texture, on a Text element the text itself, on a Button its normal colour. Any other element ignores it. Four numbers: setColor(e, r, g, b, a). Image tint first, else Text colour, else a Button's normal colour; any other element ignores it.
Lua/Python horizon.ui.setColor, or flat horizon.setUIColor · HorizonCode Set UI Color · C++ HE::api::ui::setColor · action · entity is Self in HorizonCode
getVisible(entity: int)visible: boolIs this UI element currently shown?
Lua/Python horizon.ui.getVisible, or flat horizon.isUIVisible · HorizonCode Get UI Visible · C++ HE::api::ui::getVisible · query · entity is Self in HorizonCode
setVisible(entity: int, visible: bool)—Shows or hides one UI element. Hiding a container hides everything inside it, and a hidden element stops receiving clicks. In Lua a missing visible reads as false and hides the element.
Lua/Python horizon.ui.setVisible, or flat horizon.setUIVisible · HorizonCode Set UI Visible · C++ HE::api::ui::setVisible · action · entity is Self in HorizonCode
getPosition(entity: int)position: vec2The element's position inside its canvas, in UI units from its anchor.
Lua/Python horizon.ui.getPosition, or flat horizon.getUIPosition · HorizonCode Get UI Position · C++ HE::api::ui::getPosition · query · entity is Self in HorizonCode
setPosition(entity: int, position: vec2)—Moves a UI element inside its canvas. Measured from its anchor, so what this means depends on which corner or edge the element is anchored to.
Lua/Python horizon.ui.setPosition, or flat horizon.setUIPosition · HorizonCode Set UI Position · C++ HE::api::ui::setPosition · action · entity is Self in HorizonCode
getSize(entity: int)size: vec2The element's width and height in UI units.
Lua/Python horizon.ui.getSize, or flat horizon.getUISize · HorizonCode Get UI Size · C++ HE::api::ui::getSize · query · entity is Self in HorizonCode
setSize(entity: int, size: vec2)—Resizes a UI element. A stretched anchor overrides this on the axes it stretches. Not checked: a negative size is stored as it is.
Lua/Python horizon.ui.setSize, or flat horizon.setUISize · HorizonCode Set UI Size · C++ HE::api::ui::setSize · action · entity is Self in HorizonCode
setMaterialParam(entity: int, name: string, value: color)ok: boolSets a material parameter for a UI Image element that uses a material — a progress bar's fill, a shader-driven panel. It writes the MATERIAL ASSET, not the element: every element and entity using the same material changes with it, so give an element that needs its own value its own material. Ok is false when the element has no material or the parameter is not declared. The value is four numbers. Known issue: it writes the material asset, so every element sharing that material changes (see the group notes).
Lua/Python horizon.ui.setMaterialParam, or flat horizon.setUIMaterialParam · HorizonCode Set UI Material Param · C++ HE::api::ui::setMaterialParam · action · entity is Self in HorizonCode
pointerOverUI()over: boolIs the pointer over any UI right now? The guard to put in front of a click that acts on the world, so a press on a button does not also shoot. Counts widgets only, and is true the whole time a widget dialog is open.
Lua/Python horizon.ui.pointerOverUI · HorizonCode Is Pointer Over UI · C++ HE::api::ui::pointerOverUI · query

Interface and app · 28 functions

Widget

Lua / Python: horizon.widget.<function> · C++: HE::api::widget::<function>

Live instances of Widget Designer assets. A widget is addressed by the id that horizon.createWidget(path) returns (0 when the asset does not load); showing, hiding and destroying it are the flat showWidget, hideWidget and destroyWidget. A new widget starts hidden until showWidget (or showModal, openPopup, window.show) puts it on screen. Widgets live outside the entity world, so a widget id is not an entity id, and in an exported game a script and a HorizonCode graph share the same widgets.

  • Always pass the widget. Several descriptions say “leave Widget unwired” for the calling widget's own. That is a HorizonCode graph living in the widget; a script has no widget of its own, and 0 answers false or 0.
  • Scripts receive none of the widget events. On Row Bind, On Selection Changed, On Dismissed, On Right Clicked, On Animation Finished and On Clip Finished go to the widget's own HorizonCode graph only. Neither are there Set External, Call External or Bind Event for scripts. In practice: a script can size, select and scroll a ListView, but its rows are filled by the row widget's own graph; a script notices a closed dialog by asking isVisible each frame (see the example under showModal).
  • References are not widget ids. addChild, listRow and childRef return object references for those HorizonCode nodes; isVisible on one answers false. From a script a child reference is good for removeChild.
  • Animation ok checks less than it seems. It is false for a missing widget or element, but a property name the element does not have is accepted and animates nothing, and an unknown easing name runs as "Linear". The easings are "Linear", "In Quad", "Out Quad", "In Out Quad", "In Cubic", "Out Cubic", "In Out Cubic" and "Out Back"; directions for the clip rows are "Forward", "Backward" and "Ping Pong" (anything else is Forward). Colours are four numbers, points two: animateColor(w, "Card", "Color", 1, 0, 0, 1, 0.5, "Linear").
  • Lists are named by the element's name in the designer. A negative count becomes 0; an index at or past the count leaves the selection as it was, and still answers true. listRow is 0 for an item that is scrolled out of view.
  • Closing is hiding. After closeTopLayer, Escape or Back, a dialog or popup widget answers isVisible false; show it again to reopen it.

Example: widget.showModal

pause_dialog.lua
local M = {}
local MENU = "UI/pause_menu.hasset"   -- a widget with an element called "Panel"

function M.onStart(self)
    self.menu = horizon.createWidget(MENU)   -- starts hidden; 0 if the asset is missing
end

function M.onInputPressed(self, name)
    if name ~= "Pause" or self.paused or self.menu == 0 then return end
    self.paused = true
    horizon.time.pause()
    horizon.showCursor()
    -- A dialog: dims the game, takes the input, closes on Escape or Back.
    horizon.widget.showModal(self.menu)
    horizon.widget.animate(self.menu, "Panel", "Render Opacity", 0, 0, "Linear")
    horizon.widget.animate(self.menu, "Panel", "Render Opacity", 1, 0.2, "Out Quad")
end

function M.onUpdate(self, dt)
    -- A script gets no On Dismissed: notice the close by asking.
    if self.paused and not horizon.widget.isVisible(self.menu) then
        self.paused = false
        horizon.time.resume()
        horizon.hideCursor()
    end
end

return M
pause_dialog.py
import horizon

MENU = "UI/pause_menu.hasset"   # a widget with an element called "Panel"

class PauseMenu(horizon.Behavior):
    paused = False

    def on_start(self):
        self.menu = horizon.createWidget(MENU)   # starts hidden; 0 if the asset is missing

    def on_input_pressed(self, name):
        if name != "Pause" or self.paused or self.menu == 0:
            return
        self.paused = True
        horizon.time.pause()
        horizon.showCursor()
        # A dialog: dims the game, takes the input, closes on Escape or Back.
        horizon.widget.showModal(self.menu)
        horizon.widget.animate(self.menu, "Panel", "Render Opacity", 0, 0, "Linear")
        horizon.widget.animate(self.menu, "Panel", "Render Opacity", 1, 0.2, "Out Quad")

    def on_update(self, dt):
        # A script gets no On Dismissed: notice the close by asking.
        if self.paused and not horizon.widget.isVisible(self.menu):
            self.paused = False
            horizon.time.resume()
            horizon.hideCursor()
FunctionReturnsDescription
setZOrder(widget: ref, z: int)—Which widget draws over which: higher is nearer the front. For keeping a pause menu above a HUD. Takes the Widget from a Create Widget node.
Lua/Python horizon.widget.setZOrder, or flat horizon.setWidgetZOrder · HorizonCode Set Widget Z-Order · C++ HE::api::widget::setZOrder · action
isVisible(widget: ref)visible: boolIs this widget on screen? A new widget starts hidden, so this is false until Show Widget (or Show Modal, Open Popup), again after Hide Widget or a closed dialog, and for a Widget that was destroyed. false right after createWidget, and for a child or row reference, which is not a widget id.
Lua/Python horizon.widget.isVisible, or flat horizon.isWidgetVisible · HorizonCode Is Widget Visible · C++ HE::api::widget::isVisible · query
callFunction(widget: ref, function: string)ok: boolCalls a public function on the widget's own graph by name — the way the outside talks to a screen. Ok is false when the widget or the function is not there. Only a widget with a HorizonCode graph has functions to call.
Lua/Python horizon.widget.callFunction, or flat horizon.callWidgetFunction · HorizonCode Call Widget Function · C++ HE::api::widget::callFunction · action
addChild(widget: ref, parent: string, widgetAsset: string)child: refPuts another widget INSIDE this one, while it runs: the asset is grafted under the element you name, so a Vertical Box called "List" stacks a row per call. That is how a list of unknown length is built — a todo list, search results, a chat. The Child that comes back is a live object: Set External writes its public variables, Call External runs its functions, Bind Event listens to what it emits. 0 when the widget, the element or the asset is not there. The reference is for HorizonCode's Set/Call External; from a script it is good for removeChild.
Lua/Python horizon.widget.addChild · HorizonCode Add Widget Child · C++ HE::api::widget::addChild · action
removeChild(widget: ref, child: ref)ok: boolTakes one of those rows out again, by the Child the Add gave you. Its elements and its logic both go. Ok is false when that child is not in this widget (already removed, or it belongs to another one).
Lua/Python horizon.widget.removeChild · HorizonCode Remove Widget Child · C++ HE::api::widget::removeChild · action
clearChildren(widget: ref, parent: string)removed: intEmpties one element of everything Add Widget Child put in it, and says how many went. The short way to rebuild a list from scratch instead of tracking every row you added.
Lua/Python horizon.widget.clearChildren · HorizonCode Clear Widget Children · C++ HE::api::widget::clearChildren · action
setListCount(widget: ref, list: string, count: int)ok: boolTells a ListView how many items there are. It keeps no items of its own: it works out which of them fit on screen, puts up only those rows, and asks you to fill each one in through On Row Bind. That is why ten thousand items cost ten rows instead of ten thousand elements. Name the list by the name its element has in the designer. Safe to call from inside On Row Bind — an endless list that loads more when its last row appears is written exactly that way; the new count takes effect on the next frame. A negative count becomes 0. Rows are filled by the row widget's own graph: a script gets no On Row Bind.
Lua/Python horizon.widget.setListCount · HorizonCode Set List Count · C++ HE::api::widget::setListCount · action
listCount(widget: ref, list: string)count: intHow many items the list was last told it has. Not how many rows are on screen — that is the list's own business.
Lua/Python horizon.widget.listCount · HorizonCode Get List Count · C++ HE::api::widget::listCount · query
listRow(widget: ref, list: string, index: int)row: refThe live row showing that item, or 0 when it is scrolled out of sight. This is what an On Row Bind handler writes into: Set External for the row's public variables, Call External for its functions. Do not hold on to it — the same row is pointed at a different item as soon as the list scrolls. A reference, not a widget id; a script has no Set External to write into it.
Lua/Python horizon.widget.listRow · HorizonCode Get List Row · C++ HE::api::widget::listRow · query
refreshList(widget: ref, list: string)ok: boolAsks every row on screen to be filled in again, without moving anything. What you call after sorting, filtering or editing your data: the list never saw it, so it cannot notice that it changed. The selection is by INDEX, so after a sort it points at whichever items now sit at those positions — remap it yourself if it has to follow the same rows. Calling this from inside On Row Bind is allowed: it takes effect on the next frame rather than looping back into the bind that asked for it.
Lua/Python horizon.widget.refreshList · HorizonCode Refresh List · C++ HE::api::widget::refreshList · action
setListSelected(widget: ref, list: string, index: int, selected: bool)ok: boolPicks or unpicks one item. An index of -1 clears the selection. Does nothing when the list's Selection is None; in Single mode picking one drops the other. Fires On Selection Changed when the set really changed. An index at or past the count changes nothing and still answers true. A script gets no On Selection Changed.
Lua/Python horizon.widget.setListSelected · HorizonCode Set List Selected · C++ HE::api::widget::setListSelected · action
listSelected(widget: ref, list: string)index: intThe picked item's index, or -1 when nothing is picked. With Multiple selection this is the lowest of them.
Lua/Python horizon.widget.listSelected · HorizonCode Get List Selected · C++ HE::api::widget::listSelected · query
scrollListToItem(widget: ref, list: string, index: int)ok: boolScrolls just far enough for that item to be fully visible, and does nothing when it already is — so stepping through a list does not re-centre it on every step.
Lua/Python horizon.widget.scrollListToItem · HorizonCode Scroll List To Item · C++ HE::api::widget::scrollListToItem · action
animate(widget: ref, element: string, property: string, to: float, seconds: float, easing: string)ok: boolMoves a NUMBER of one element from where it is to where you say, over the given seconds, along an easing curve ("Linear", "Out Quad", "Out Back"…). Opacity, corner radius, rotation, font size. Zero seconds writes it at once. Starting a second one on the same property replaces the first from wherever the value has got to, so a value can be retargeted mid-flight without a jump. OnAnimationFinished says when it lands, and names the property it was. ok is true for a property name the element does not have (nothing moves), and an unknown easing runs as "Linear".
Lua/Python horizon.widget.animate · HorizonCode Animate Number · C++ HE::api::widget::animate · action · widget is Self in HorizonCode
animateColor(widget: ref, element: string, property: string, to: color, seconds: float, easing: string)ok: boolThe same for a COLOUR — a fade, a flash, a hover that arrives instead of snapping. Every colour an element has can be animated, including a button's hovered and pressed ones. The colour is four numbers, as everywhere.
Lua/Python horizon.widget.animateColor · HorizonCode Animate Color · C++ HE::api::widget::animateColor · action · widget is Self in HorizonCode
animateVec2(widget: ref, element: string, property: string, to: vec2, seconds: float, easing: string)ok: boolThe same for a POINT: Position to slide something in, Size to grow it. "Out Back" is the curve that makes a dialog land rather than arrive. The point is two numbers.
Lua/Python horizon.widget.animateVec2 · HorizonCode Animate Position · C++ HE::api::widget::animateVec2 · action · widget is Self in HorizonCode
stopAnimation(widget: ref, element: string, property: string)stopped: intStops what is running on that property, or on the whole element when the property is left empty. The value stays where it got to — a stop is not a rewind — and a stopped animation reports nothing, because cancelled is not finished.
Lua/Python horizon.widget.stopAnimation · HorizonCode Stop Animating Property · C++ HE::api::widget::stopAnimation · action · widget is Self in HorizonCode
playAnimation(widget: ref, animation: string, restoreAfterCompleted: bool, direction: string)ok: boolPlays one of the animations the widget carries — the ones made in the Designer's timeline — picked from the dropdown. Leave Widget unwired and it plays this widget's own. Whether it loops is the animation's own decision. Direction runs it forwards, backwards, or out and back. Restore After Completed puts the properties it moved back the way they were when it finishes, which is the whole of "flash this and undo it". An embedded component's animations count too, so a page can play what its component brought with it. OnClipFinished says when it ends; a looping one never does, and never restores either. A clip name the widget does not carry answers false. Directions: "Forward", "Backward", "Ping Pong".
Lua/Python horizon.widget.playAnimation · HorizonCode Play Animation · C++ HE::api::widget::playAnimation · action · widget is Self in HorizonCode
playAnimationLooped(widget: ref, animation: string, loop: bool, direction: string)ok: boolThe same, but you decide about looping instead of the animation. For the case where one clip is both the spinner that runs until you stop it and the flourish that plays once.
Lua/Python horizon.widget.playAnimationLooped · HorizonCode Play Animation Looped · C++ HE::api::widget::playAnimationLooped · action · widget is Self in HorizonCode
stopAnimationClip(widget: ref, animation: string)stopped: intStops that animation, or every one of the widget's when the name is left empty. Values stay where they got to, and nothing is reported — cancelled is not finished.
Lua/Python horizon.widget.stopAnimationClip · HorizonCode Stop Animation · C++ HE::api::widget::stopAnimationClip · action · widget is Self in HorizonCode
isPlayingAnimation(widget: ref, animation: string)playing: boolWhether that animation is running right now. What a toggle asks before deciding which way to go.
Lua/Python horizon.widget.isPlayingAnimation · HorizonCode Is Animation Playing · C++ HE::api::widget::isPlayingAnimation · query · widget is Self in HorizonCode
childRef(widget: ref, element: string)child: refA reference to the component sitting in one of this widget's slots, by the slot's name. A component is a class like any other, so this is how you call its functions, bind its events and read its public variables — and how a page tells one of three identical cards apart. Leave Widget unwired for this widget's own slots. 0 for an element that is not a component slot.
Lua/Python horizon.widget.childRef · HorizonCode Get Child Widget · C++ HE::api::widget::childRef · query · widget is Self in HorizonCode
stopAllAnimations(widget: ref)stopped: intStops everything moving in the widget: the authored animations and the single-property ones both. What a screen being torn down or swapped reaches for, because it does not have to name what it started. Values stay where they got to; Restore Original State is the one that puts them back.
Lua/Python horizon.widget.stopAllAnimations · HorizonCode Stop All Animations · C++ HE::api::widget::stopAllAnimations · action · widget is Self in HorizonCode
restoreOriginalState(widget: ref)restored: intStops everything and puts every property an animation ever touched back the way it was BEFORE anything animated it — not the way it was a moment ago. Stopping is part of it: a clip left running would write over the restored values on the next frame. Outputs how many properties were put back, which is 0 when nothing had been animated.
Lua/Python horizon.widget.restoreOriginalState · HorizonCode Restore Original State · C++ HE::api::widget::restoreOriginalState · action · widget is Self in HorizonCode
showModal(widget: ref)—Puts this widget up as a DIALOG: the screen behind it dims, nothing under it can be clicked, scrolled or reached with the keyboard, and the focus starts inside it. It is raised above every other widget, so it cannot end up blocking input while drawing behind something. It stays until Close Top Layer, Escape or the Back button — that is what makes it modal. A script gets no On Dismissed: ask isVisible to notice that the dialog was closed.
Lua/Python horizon.widget.showModal · HorizonCode Show Modal Widget · C++ HE::api::widget::showModal · action
openPopup(widget: ref, x: float, y: float)—Puts the widget up at a point on screen — in render-target pixels — and lets a click anywhere else dismiss it. Its root elements are moved there and pushed back inside the screen, whatever anchors they were drawn with, so a menu near the bottom edge opens upwards instead of off the screen. No dimming: a popup is left by looking elsewhere, a dialog has to be answered.
Lua/Python horizon.widget.openPopup · HorizonCode Open Popup · C++ HE::api::widget::openPopup · action
openPopupAtPointer(widget: ref)—The same, at the mouse. This is the context menu: answer On Right Clicked with it and the menu appears where the click was. The engine remembers where the pointer last was, so the graph does not have to.
Lua/Python horizon.widget.openPopupAtPointer · HorizonCode Open Popup At Pointer · C++ HE::api::widget::openPopupAtPointer · action
closeTopLayer()closed: boolCloses the topmost dialog, popup or open dropdown and says whether there was one. Exactly what Escape and the gamepad's Back button do — so a "Cancel" button is this node and nothing else. The widget that closes gets On Dismissed, and the keyboard focus goes back where it was before it opened. Hide Widget on a dialog does the same thing — hiding one IS closing it, so it lets go of the input, the focus and the dimming and fires On Dismissed too. This node is the one to reach for when you do not have the dialog's own id, which is the usual case for a Cancel button. false when nothing was open.
Lua/Python horizon.widget.closeTopLayer · HorizonCode Close Top Layer · C++ HE::api::widget::closeTopLayer · action

Interface and app · 1 function

Cursor

◆

Lua and Python have no horizon.cursor.*: use the flat horizon.showCursor() / horizon.hideCursor().

One row, and no horizon.cursor.* group: from a script use the flat horizon.showCursor() and horizon.hideCursor(). Hiding the cursor also captures the mouse for camera control, showing it releases the mouse. In the editor this applies to play mode only.

FunctionReturnsDescription
setVisible(show: bool)—Shows or hides the mouse cursor. A first-person game hides it while playing and shows it again for menus; see also the Input Mode calls, which decide who receives the clicks.
Lua/Python only as flat horizon.showCursor / horizon.hideCursor · HorizonCode Set Cursor Visible · C++ HE::api::cursor::setVisible · action

Interface and app · 6 functions

Theme

Lua / Python: horizon.theme.<function> · C++: HE::api::theme::<function>

The colours and text size of every widget. A Theme asset carries a light and a dark set of colour roles; set switches the asset, setMode picks between its two sets. Widgets that name their own theme in the designer keep it.

  • Mode names are exact: "Light", "Dark" or "System". Anything else, including "dark", is ignored with a log line.
  • Preference and mode are two answers. getPreference returns what was asked for, including "System"; getMode returns what is on screen, only ever Light or Dark. Store the preference, show the mode (see the example under setMode). Where the desktop reports nothing, System resolves to Dark.
  • Font scale is clamped to 0.5 … 3: setFontScale(5) reads back 3, 0.1 reads back 0.5.
  • set answers false for a path that does not load and for a Theme asset whose JSON cannot be read; the current theme stays.

Example: theme.setMode

theme_toggle.lua
local M = {}
-- Store the PREFERENCE, not the mode: System is a rule that follows the desktop.
local NEXT = { System = "Light", Light = "Dark", Dark = "System" }

function M.onStart(self)
    horizon.theme.setMode(horizon.prefs.getString("theme", "System"))
    horizon.theme.setFontScale(horizon.prefs.getNumber("textScale", 1))
end

function M.onInputPressed(self, name)
    if name == "CycleTheme" then
        local pref = NEXT[horizon.theme.getPreference()]
        horizon.theme.setMode(pref)
        horizon.prefs.setString("theme", pref)
        horizon.log("theme " .. pref .. ", showing " .. horizon.theme.getMode())
    elseif name == "BiggerText" or name == "SmallerText" then
        local step = name == "BiggerText" and 0.25 or -0.25
        horizon.theme.setFontScale(horizon.theme.getFontScale() + step)   -- kept in 0.5..3
        horizon.prefs.setNumber("textScale", horizon.theme.getFontScale())
    end
end

return M
theme_toggle.py
import horizon

# Store the PREFERENCE, not the mode: System is a rule that follows the desktop.
NEXT = {"System": "Light", "Light": "Dark", "Dark": "System"}

class ThemeToggle(horizon.Behavior):
    def on_start(self):
        horizon.theme.setMode(horizon.prefs.getString("theme", "System"))
        horizon.theme.setFontScale(horizon.prefs.getNumber("textScale", 1))

    def on_input_pressed(self, name):
        if name == "CycleTheme":
            pref = NEXT[horizon.theme.getPreference()]
            horizon.theme.setMode(pref)
            horizon.prefs.setString("theme", pref)
            horizon.log("theme %s, showing %s" % (pref, horizon.theme.getMode()))
        elif name in ("BiggerText", "SmallerText"):
            step = 0.25 if name == "BiggerText" else -0.25
            horizon.theme.setFontScale(horizon.theme.getFontScale() + step)   # kept in 0.5..3
            horizon.prefs.setNumber("textScale", horizon.theme.getFontScale())
FunctionReturnsDescription
set(themeAsset: string)ok: boolSwitches the whole application to another Theme asset. Every element bound to a colour role takes the new colour at once — that is what a role is for. Ok is false when there is no such asset or it cannot be read.
Lua/Python horizon.theme.set · HorizonCode Set Theme · C++ HE::api::theme::set · action
setMode(mode: string)—"Light", "Dark" or "System". Both colours live in the SAME theme, so this is a switch and not a second set of widgets: every bound colour re-resolves immediately. System is the default and follows the desktop, including while the application runs. Anything else is ignored. The names are exact: "dark" is ignored.
Lua/Python horizon.theme.setMode · HorizonCode Set Theme Mode · C++ HE::api::theme::setMode · action
getMode()mode: stringWhich of the two colours is on screen right now — Light or Dark, never System. This is what to ask when the answer decides something else, like which icon to show. Dark where the desktop reports no preference.
Lua/Python horizon.theme.getMode · HorizonCode Get Theme Mode · C++ HE::api::theme::mode · query
getPreference()preference: stringWhat was ASKED for: Light, Dark or System. This is what a Preferences screen shows and writes back, because System is a rule and not a colour — storing what it resolved to today would stop it following tomorrow.
Lua/Python horizon.theme.getPreference · HorizonCode Get Theme Preference · C++ HE::api::theme::preference · query
setFontScale(scale: float)—How big the text is for the person reading it: 1 is what the designer drew, 1.5 is half again. Only the TEXT grows — corners, padding and tab strips stay where they were authored, because a whole interface zoomed is a different setting (the display scale, which the system supplies). Clamped to 0.5 … 3. Clamped to 0.5 … 3.
Lua/Python horizon.theme.setFontScale · HorizonCode Set Text Size · C++ HE::api::theme::setFontScale · action
getFontScale()scale: floatThe text size actually in use, after clamping — what a Preferences screen shows next to its slider.
Lua/Python horizon.theme.getFontScale · HorizonCode Get Text Size · C++ HE::api::theme::fontScale · query

Interface and app · 5 functions

Window

Lua / Python: horizon.window.<function> · C++: HE::api::window::<function>

Second windows for a desktop application: a tool window beside the main one, a document per window. Window 0 is the main window; window.setTitle(0, …) and window.setSize(0, …) are app.setTitle and app.setSize, and close(0) is refused with a pointer to app.quit. Only an exported application has windows to give: in the editor open answers 0, and so it does on a renderer without a path into a second window (Software and Metal have one). A width or height of 0 or less opens at 640 × 480.

▲

Known issue (build 7d49d44f): from Lua and Python only window.show works. The script bridge hands a script no window service, in the exported app as well: open answers 0 and close, setTitle and setSize only log “no window service bound by the host”. show moves a widget between windows and works, but with no second window to open, 0 is the only useful target. Use these rows from a HorizonCode graph.

FunctionReturnsDescription
open(title: string, width: int, height: int)window: intOpens a SECOND window with its own widget tree, and answers its id. A tool window beside the main one, a document per window, a panel off to the side. Answers 0 when it was refused: in the editor, and on a renderer with no path into a second window (Software and Metal have one). From Lua/Python always 0, see the group notes. A width or height of 0 or less opens at 640 × 480.
Lua/Python horizon.window.open · HorizonCode Open Window · C++ HE::api::window::open · action
close(window: int)—Closes a window this application opened. Everything that hung in it is destroyed with it and On Window Closed fires — the same event its own close button gives you, because it is the same thing happening. Ending the whole application is Quit Game, not this. From Lua/Python this only logs, see the group notes.
Lua/Python horizon.window.close · HorizonCode Close Window · C++ HE::api::window::close · action
setTitle(window: int, title: string)—Sets the title of one window by its id. Window 0 is the main one, and for it this does exactly what Set Window Title does. From Lua/Python this only logs, see the group notes. Window 0 is app.setTitle.
Lua/Python horizon.window.setTitle · HorizonCode Set Title Of Window · C++ HE::api::window::setTitle · action
setSize(window: int, width: int, height: int)—Resizes one window by its id, in logical points. Window 0 is the main one. Both sides must be positive. From Lua/Python this only logs, see the group notes. Window 0 is app.setSize.
Lua/Python horizon.window.setSize · HorizonCode Set Size Of Window · C++ HE::api::window::setSize · action
show(window: int, widget: ref)—Puts a widget in a window and shows it there. A real move: anything the widget was holding in the window it came from — a dialog's grab, the keyboard focus — stays behind, because a dialog that keeps its old window sealed is a window nobody can click again. Window 0 brings it back to the main window. Works from scripts, and shows the widget as well.
Lua/Python horizon.window.show · HorizonCode Show Widget In Window · C++ HE::api::window::show · action

Interface and app · 5 functions

Dialog

Lua / Python: horizon.dialog.<function> · C++: HE::api::dialog::<function>

The operating system's own message boxes and file pickers. Every one of them blocks: the frame stops until the person answers, so a game keeps them for what really has to be read or chosen first. No permission is needed.

  • Choosing is the permission for the fs rows: a path returned by openFile or saveFile, and everything inside a folder from pickFolder, may be read and written with an absolute path from then on, even when the project does not allow files outside the project. It does not open db.open or print.toPdf, see Database.
  • An empty path means cancelled, and it is also the answer when the picker fails. A picker left open for five minutes is given up on and answers empty as well.
  • message kind 1 is a warning, 2 an error, anything else information. confirm returns true for the first button; empty labels become "OK" and "Cancel", Return takes the first button and Escape the second.
  • Hidden runs (HE_HIDDEN_WINDOW, headless exports and tests) show no box: message writes its text to the log and confirm answers false. The three pickers do not check for a hidden run, so keep them out of one.
FunctionReturnsDescription
message(title: string, text: string, kind: int)—Shows a native message box and waits until the user dismisses it. Kind is 0 for information, 1 for a warning, 2 for an error. Blocking on purpose: it is for what must be read before anything else happens. Kind 1 is a warning, 2 an error, anything else information. A hidden run writes the text to the log instead.
Lua/Python horizon.dialog.message · HorizonCode Show Message Dialog · C++ HE::api::dialog::message · action
confirm(title: string, text: string, affirmative: string, negative: string)confirmed: boolAsks a yes/no question in a native dialog and returns true for the first button. Both labels are yours, so it can ask "Save" against "Discard" rather than only ever Yes and No. Return takes the first button, Escape the second. A hidden run answers false.
Lua/Python horizon.dialog.confirm · HorizonCode Show Confirm Dialog · C++ HE::api::dialog::confirm · action
openFile(filter: string)path: stringThe system's own file picker. Returns the chosen path, or empty if the person cancelled — cancelling is a normal answer, not a failure. What comes back is ALLOWED to the file calls from then on, even in a project that permits nothing outside its own folder: somebody choosing a file is the permission. This is how an application opens a document. Filter is a description and its extensions in one string, "Text files:txt;md", or empty for anything. The pick opens the path to the fs rows, not to db.open or print.toPdf.
Lua/Python horizon.dialog.openFile · HorizonCode Open File Dialog · C++ HE::api::dialog::openFile · action
saveFile(filter: string)path: stringThe system's own "save as" picker. Same as Open File Dialog in every other way, including that the path it returns becomes writable to the file calls. The file usually does not exist yet — that is the point.
Lua/Python horizon.dialog.saveFile · HorizonCode Save File Dialog · C++ HE::api::dialog::saveFile · action
pickFolder()path: stringAsks for a directory. Everything INSIDE it becomes reachable to the file calls, not just the folder itself, which is what makes "choose a workspace folder" work with no permission set anywhere. The pick opens the folder to the fs rows, not to db.open or print.toPdf.
Lua/Python horizon.dialog.pickFolder · HorizonCode Pick Folder Dialog · C++ HE::api::dialog::pickFolder · action

Interface and app · 3 functions

Clipboard

Lua / Python: horizon.clipboard.<function> · C++: HE::api::clipboard::<function>

The system clipboard, shared with every other application. Text only, and no permission is needed. Reading an empty clipboard gives "", as does a clipboard holding something that is not text; hasText is the cheap check for greying out a Paste button. A process without a window (a headless tool) has no clipboard: setText fails with a log line and getText answers "".

FunctionReturnsDescription
getText()text: stringThe text currently on the system clipboard, or an empty string when there is none. The same clipboard Ctrl+C and Ctrl+V use in a text field.
Lua/Python horizon.clipboard.getText · HorizonCode Get Clipboard Text · C++ HE::api::clipboard::getText · query
setText(text: string)—Puts text on the system clipboard, replacing whatever was there. Every other application on the machine can then paste it.
Lua/Python horizon.clipboard.setText · HorizonCode Set Clipboard Text · C++ HE::api::clipboard::setText · action
hasText()has: boolTrue when the system clipboard currently holds text. Use it to grey out a Paste button instead of pasting nothing.
Lua/Python horizon.clipboard.hasText · HorizonCode Has Clipboard Text · C++ HE::api::clipboard::hasText · query

Interface and app · 24 functions

App

Lua / Python: horizon.app.<function> · C++: HE::api::app::<function>

The application around the game: its window, menu bar, tray icon, notifications and login item. These rows exist for desktop applications built with the engine and need a host that provides each piece. The exported application provides all of them. Play mode in the editor provides only quit (it stops play mode), size and requestRedraw; everything else logs “no … bound by the host” and does nothing.

▲

Known issue (build 7d49d44f): from Lua and Python only app.quit reaches the host. The script bridge passes a script the quit handler and nothing else, in the exported application as well. Every other row either logs “no … bound by the host” and does nothing, or answers its empty value: size 0, 0, isMaximized, notifyAvailable, autostart, menuItemEnabled and menuItemChecked false, notify false. requestRedraw is silent. Build window chrome, menus and the tray from a HorizonCode graph; its On Menu Item and On Tray Item events have no script counterpart either.

  • Ids and labels. A menu, menu entry or tray entry without an id is refused with a log line; an empty label shows the id. Adding a menu id twice is one menu. A shortcut that does not parse is dropped with a log line and the entry arrives without it; "Ctrl" means Command on a Mac.
  • Refused values. setSize with a side of 0 or less is ignored; notify with neither title nor text answers false. On macOS notify needs the packaged app.
  • setAutostart needs the project permission Run other programs; reading autostart needs none.
FunctionReturnsDescription
quit()—Ends the game. In the editor it stops play mode instead, so a quit button can be tested without closing the editor. The one app row that works from Lua and Python.
Lua/Python horizon.app.quit · HorizonCode Quit Game · C++ HE::api::app::quit · action
setTitle(title: string)—Sets the text in the window's title bar. Only an application owns its window outright — in the editor this is ignored rather than renaming the editor itself. From Lua/Python this does nothing, see the group notes.
Lua/Python horizon.app.setTitle · HorizonCode Set Window Title · C++ HE::api::app::setTitle · action
setSize(width: int, height: int)—Resizes the window, in logical points. Both sides must be positive; a zero or negative size is refused rather than passed on to the platform. From Lua/Python this does nothing, see the group notes.
Lua/Python horizon.app.setSize · HorizonCode Set Window Size · C++ HE::api::app::setSize · action
size()size: vec2The window's current size in logical points, as X and Y. On a high-DPI display this is the points size, not the pixel count behind it. From Lua/Python always 0, 0, see the group notes.
Lua/Python horizon.app.size · HorizonCode Get Window Size · C++ HE::api::app::size · query
requestRedraw()—Draws one more frame. An application sleeps until something happens, so anything that changes the screen without an input event behind it — a timer, a finished load — has to say so here. From Lua/Python this does nothing, see the group notes.
Lua/Python horizon.app.requestRedraw · HorizonCode Request Redraw · C++ HE::api::app::requestRedraw · action
minimize()—Puts the window away into the dock or the taskbar. The other half of a title bar a window draws itself: without a system frame there is no minimise button but the one you drew. From Lua/Python this does nothing, see the group notes.
Lua/Python horizon.app.minimize · HorizonCode Minimize Window · C++ HE::api::app::minimize · action
maximize(maximized: bool)—Fills the screen's work area with the window, or puts it back where it was when this is off. One call for both, because one button on a title bar does both: wire it to Is Window Maximized inverted. From Lua/Python this does nothing, see the group notes.
Lua/Python horizon.app.maximize · HorizonCode Maximize Window · C++ HE::api::app::maximize · action
isMaximized()maximized: boolWhether the window is maximised right now. Asked of the system rather than remembered, so it stays right after somebody double-clicked the title bar or used the platform's own shortcut. From Lua/Python always false, see the group notes.
Lua/Python horizon.app.isMaximized · HorizonCode Is Window Maximized · C++ HE::api::app::isMaximized · query
showTray(tooltip: string)—Puts the application's icon in the system tray (the menu bar on macOS) with a tooltip. Called again while it is up, it only changes the tooltip. The icon is the one the export generated, so the tray and the window agree about what this application looks like. From Lua/Python this does nothing, see the group notes.
Lua/Python horizon.app.showTray · HorizonCode Show Tray Icon · C++ HE::api::app::showTray · action
hideTray()—Takes the tray icon away again. It also goes when the application exits — an icon left behind by a program that is gone is the worst thing a tray can do — so this is for hiding it while still running. From Lua/Python this does nothing, see the group notes.
Lua/Python horizon.app.hideTray · HorizonCode Hide Tray Icon · C++ HE::api::app::hideTray · action
addTrayItem(id: string, label: string)—Adds an entry to the tray menu, in order. The ID is what On Tray Item receives when somebody chooses it, and the label is what they read: translating the menu then cannot quietly rewire what its entries do. From Lua/Python this does nothing, see the group notes.
Lua/Python horizon.app.addTrayItem · HorizonCode Add Tray Item · C++ HE::api::app::addTrayItem · action
clearTrayMenu()—Removes every entry from the tray menu, leaving the icon in place. What a menu offers usually depends on what the application is doing, and rebuilding it is how that stays true. From Lua/Python this does nothing, see the group notes.
Lua/Python horizon.app.clearTrayMenu · HorizonCode Clear Tray Menu · C++ HE::api::app::clearTrayMenu · action
addMenu(id: string, label: string)—Adds a menu to the application's menu bar, in order. The id is how Add Menu Item names it afterwards; the label is what is drawn in the strip. Adding the same id twice is one menu, not two. On macOS the same menus go into the SYSTEM bar next to the Apple symbol instead, and the strip in the window is not drawn at all — a page that leaves room for the bar gets that room back there. From Lua/Python this does nothing, see the group notes.
Lua/Python horizon.app.addMenu · HorizonCode Add Menu · C++ HE::api::app::addMenu · action
addMenuItem(menu: string, id: string, label: string, shortcut: string)—Adds an entry to the menu with that id. Choosing it fires On Menu Item with the ENTRY's id — the label is only what somebody reads, so translating the menu leaves what it does alone. Shortcut is the chord that chooses it without opening the menu, written the way people write one: "Ctrl+Shift+S", "F5", "Alt+Left". It fires the same On Menu Item, because it is the same entry answered a faster way. Ctrl means Command on a Mac, so a chord is written once and is right on every platform; leave it empty for an entry that has none. From Lua/Python this does nothing, see the group notes.
Lua/Python horizon.app.addMenuItem · HorizonCode Add Menu Item · C++ HE::api::app::addMenuItem · action
addMenuSeparator(menu: string)—Adds a dividing line to a menu. It carries no id and cannot be chosen: it is there to group the entries above and below it. From Lua/Python this does nothing, see the group notes.
Lua/Python horizon.app.addMenuSeparator · HorizonCode Add Menu Separator · C++ HE::api::app::addMenuSeparator · action
clearMenuBar()—Removes the whole menu bar. A menu usually changes as a set, so the way to change one is to clear it and build the new one. From Lua/Python this does nothing, see the group notes.
Lua/Python horizon.app.clearMenuBar · HorizonCode Clear Menu Bar · C++ HE::api::app::clearMenuBar · action
setMenuItemEnabled(id: string, enabled: bool)—Greys a menu entry out, or brings it back. Addressed by the ENTRY's id — the same id On Menu Item carries — so an id used in two menus is one command offered twice and both rows follow. A disabled row cannot be chosen and its shortcut does nothing either, which is the half that matters: a greyed-out Save whose Ctrl+S still saves has said one thing and done another. From Lua/Python this does nothing, see the group notes.
Lua/Python horizon.app.setMenuItemEnabled · HorizonCode Set Menu Item Enabled · C++ HE::api::app::setMenuItemEnabled · action
setMenuItemChecked(id: string, checked: bool)—Puts a mark beside a menu entry, or takes it away — for a row that names a state ("Show Toolbar") rather than an action. Choosing the row still fires On Menu Item and nothing else: whether the mark then moves is the application's decision, because a menu that ticked itself would be wrong the moment the command it named failed. From Lua/Python this does nothing, see the group notes.
Lua/Python horizon.app.setMenuItemChecked · HorizonCode Set Menu Item Checked · C++ HE::api::app::setMenuItemChecked · action
menuItemEnabled(id: string)enabled: boolWhether that menu entry can be chosen right now. False for an id no row carries. From Lua/Python always false, see the group notes.
Lua/Python horizon.app.menuItemEnabled · HorizonCode Is Menu Item Enabled · C++ HE::api::app::menuItemEnabled · query
menuItemChecked(id: string)checked: boolWhether that menu entry carries its mark right now — what a row bound to a setting reads to flip it instead of remembering it. From Lua/Python always false, see the group notes.
Lua/Python horizon.app.menuItemChecked · HorizonCode Is Menu Item Checked · C++ HE::api::app::menuItemChecked · query
notify(title: string, text: string)shown: boolPuts a banner in the system's notification centre — what an application says when it has finished something nobody is watching any more. It is not a dialog: nothing waits for it and nothing comes back. True means the system took it, not that somebody read it; whether it appears is the system's decision (Do Not Disturb, this app's own switch). On macOS it needs the packaged app — a game runtime started straight from a build folder has no identity to post as. From Lua/Python always false, see the group notes.
Lua/Python horizon.app.notify · HorizonCode Notify · C++ HE::api::app::notify · action
notifyAvailable()available: boolCan this program show notifications at all? False in the editor preview, and on a Linux without notify-send. Ask once instead of finding out per notification. From Lua/Python always false, see the group notes.
Lua/Python horizon.app.notifyAvailable · HorizonCode Notifications Available · C++ HE::api::app::notifyAvailable · query
setAutostart(enabled: bool)—Arranges for this application to start when the user logs in, or takes that back. It needs the project's "Run other programs" permission: it asks the SYSTEM to run a program at every login, which is more than running one now, not less. From Lua/Python this does nothing, see the group notes.
Lua/Python horizon.app.setAutostart · HorizonCode Set Start at Login · C++ HE::api::app::setAutostart · action · needs permission Run other programs
autostart()enabled: boolWhether this application is currently set to start at login. Reads what the system was told, not what the application believes — so a checkbox bound to it stays right even after somebody changed it elsewhere. From Lua/Python always false, see the group notes.
Lua/Python horizon.app.autostart · HorizonCode Starts at Login · C++ HE::api::app::autostart · query

Interface and app · 3 functions

Process

Lua / Python: horizon.process.<function> · C++: HE::api::process::<function>

Other programs on the machine. run and openUrl need the project permission Run other programs; which needs none, so a script can find out what is installed before anyone decides to allow it.

  • run blocks the frame until the program ends or the timeout kills it; a timeout of 0 means 30 seconds. It returns four values, ok, exitCode, out, err: ok is true for exit code 0, out and err are the complete output. A program that could not be started gives exit code −1. Nothing goes through a shell.
  • openUrl hands the address to the desktop (browser, mail client, file manager) and answers false for an empty one or when the desktop refuses it. which answers the full path, or "".
▲

Known issue (build 7d49d44f): from Lua and Python run passes no arguments. The argument list is a list pin. A Lua table or Python list is rejected (bad argument #2 … string expected, TypeError); a string is accepted and dropped. So horizon.process.run("git", "status", 10) runs git with no arguments at all. From a script only programs that need no arguments work; everything else belongs in a HorizonCode graph for now.

Example: process.openUrl

open_manual.lua
local M = {}
local MANUAL = "https://example.com/manual/"

function M.onInputPressed(self, name)
    if name ~= "Help" then return end
    -- openUrl needs "Run other programs". Without it, hand the address over.
    if horizon.process.openUrl(MANUAL) then return end
    horizon.clipboard.setText(MANUAL)
    horizon.dialog.message("Manual", "The manual's address is on your clipboard:\n" .. MANUAL, 0)
end

return M
open_manual.py
import horizon

MANUAL = "https://example.com/manual/"

class OpenManual(horizon.Behavior):
    def on_input_pressed(self, name):
        if name != "Help":
            return
        # openUrl needs "Run other programs". Without it, hand the address over.
        if horizon.process.openUrl(MANUAL):
            return
        horizon.clipboard.setText(MANUAL)
        horizon.dialog.message("Manual", "The manual's address is on your clipboard:\n" + MANUAL, 0)
FunctionReturnsDescription
run(exe, argsIgnored, timeoutSeconds)ok, exitCode, out, errRuns another program and WAITS for it. Arguments go in one at a time — this is not a command line, so a path with a space in it needs no quoting and cannot inject anything. Four answers, because a caller who only wants to know whether it worked reads Ok, and one who has to explain a failure to a person needs the exit code and Err. A non-zero exit code is an ANSWER, not a breakage. It blocks the frame, so give it a timeout you are willing to wait; left at zero it is thirty seconds. Needs the project's "Run other programs" permission. Known issue: from Lua/Python the arguments are not passed, see the group notes. A program that cannot be started gives exit code −1.
Lua/Python horizon.process.run · HorizonCode Run Program · C++ HE::api::process::run · action · list: HorizonCode/C++ only · needs permission Run other programs
openUrl(url: string)ok: boolHands a web address (or a file) to whatever this desktop opens it with — a browser, the file manager, the mail client. The usual way to show a manual or a release page. Needs the project's "Run other programs" permission.
Lua/Python horizon.process.openUrl · HorizonCode Open URL · C++ HE::api::process::openUrl · action · needs permission Run other programs
which(exe: string)path: stringWhere the system would find this program, or empty if it would not find it at all. Deliberately needs NO permission: asking whether something is installed runs nothing, and it is how a script tells somebody what it would need before they decide to allow it.
Lua/Python horizon.process.which · HorizonCode Find Program · C++ HE::api::process::which · query

Interface and app · 3 functions

Print

Lua / Python: horizon.print.<function> · C++: HE::api::print::<function>

Paper and PDF. toPdf writes a plain page of text itself; file hands an existing file to the system's print queue.

  • The PDF layout is fixed: A4, Courier 10 pt, 80 characters by 60 lines per page. Lines break at your newlines and at the last space that fits, pages when they are full. title becomes the document title in its properties. Characters of Latin-1 come through (ä, é); every other one, the euro sign included, prints as ?. Missing folders on the way to the path are created.
  • toPdf always needs the permission Files outside the project, even for a relative path, which lands in the project's Saved folder like the fs rows. A path the user picked in a file dialog does not stand in for it (build 7d49d44f).
  • file and available use the system's lp, on macOS and Linux. On Windows both answer false. file needs Run other programs and answers true once the queue took the file, which says nothing about paper; a missing file is false.
FunctionReturnsDescription

Interface and app · 7 functions

Database

Lua / Python: horizon.db.<function> · C++: HE::api::db::<function>

SQLite databases, as files. open returns a handle for the other rows, 0 when it failed; at most 16 are open at once, and all close when the application ends. The path follows the fs rules: relative lands in the project's Saved folder, .. is refused, an absolute path needs the permission. There is no in-memory database: ":memory:" is an ordinary file name.

▲

open always needs the permission Files outside the project, even for a relative path inside the project. A file or folder the user picked in a dialog does not stand in for it, although it does for the fs rows and the editor's permission help says it would (build 7d49d44f). Without the permission open answers 0.

  • Values go behind ?, as a JSON array in params: '["Ada", 36.5]'. Strings, numbers, true/false (stored as 1/0) and null; a nested array or object is stored as its JSON text. Placeholders the array does not reach are NULL, and a params that is not an array is ignored. Python builds it with json.dumps. Lua has no JSON writer, and horizon.json.set* can overwrite array slots but not add them: start from a template such as '["", 0]' (see the example under query).
  • exec runs one statement. Of "INSERT …; INSERT …" only the first runs, and the answer is still true. exec on a SELECT runs it and throws the rows away.
  • query answers JSON text: an array with one object per row, keyed by column name. Integers come back as integers, REAL as floats (12.0), NULL and BLOB as null. An empty result and a failed query both give "[]"; lastError tells them apart. More than 10 000 rows are cut off, with a note in lastError.
  • lastError is SQLite's own message and empty after a call that worked. On a handle that is not open, exec is false, query "[]" and lastError empty. lastInsertId reads back ids above 2 147 483 647 as 2 147 483 647.
  • SQL cannot reach other files: ATTACH, VACUUM INTO and the pragmas that move SQLite's own files fail with “not authorized”.

Example: db.query

highscores.lua
local M = {}
local PLAYER = "Player 1"

function M.onStart(self)
    -- Lands in the project's Saved folder, but needs the "Files outside the
    -- project" permission all the same (see the group notes).
    self.db = horizon.db.open("scores.db")
    if self.db ~= 0 then
        horizon.db.exec(self.db, "CREATE TABLE IF NOT EXISTS scores(name TEXT, points INTEGER)", "")
    end
end

function M.onInputPressed(self, name)
    if name ~= "LevelDone" or self.db == 0 then return end
    local points = math.max(0, 1000 - math.floor(horizon.time.elapsed() * 10))
    -- Values travel as a JSON array behind the ?s, never pasted into the SQL.
    -- json.set* can overwrite array slots but not add them, so start from two.
    local params = horizon.json.setNumber(horizon.json.setString('["", 0]', "[0]", PLAYER), "[1]", points)
    if not horizon.db.exec(self.db, "INSERT INTO scores(name, points) VALUES(?, ?)", params) then
        horizon.log("scores: " .. horizon.db.lastError(self.db))
        return
    end
    local rows = horizon.db.query(self.db,
        "SELECT name, points FROM scores ORDER BY points DESC LIMIT 5", "")
    for i = 0, horizon.json.count(rows, "") - 1 do
        horizon.log(string.format("%d. %s  %d", i + 1,
            horizon.json.getString(rows, "[" .. i .. "].name", "?"),
            horizon.json.getNumber(rows, "[" .. i .. "].points", 0)))
    end
end

return M
highscores.py
import horizon
import json

PLAYER = "Player 1"

class Highscores(horizon.Behavior):
    def on_start(self):
        # Lands in the project's Saved folder, but needs the "Files outside the
        # project" permission all the same (see the group notes).
        self.db = horizon.db.open("scores.db")
        if self.db:
            horizon.db.exec(self.db, "CREATE TABLE IF NOT EXISTS scores(name TEXT, points INTEGER)", "")

    def on_input_pressed(self, name):
        if name != "LevelDone" or not self.db:
            return
        points = max(0, 1000 - int(horizon.time.elapsed() * 10))
        # Values travel as a JSON array behind the ?s, never pasted into the SQL.
        if not horizon.db.exec(self.db, "INSERT INTO scores(name, points) VALUES(?, ?)",
                               json.dumps([PLAYER, points])):
            horizon.log("scores: " + horizon.db.lastError(self.db))
            return
        rows = json.loads(horizon.db.query(
            self.db, "SELECT name, points FROM scores ORDER BY points DESC LIMIT 5", ""))
        for i, row in enumerate(rows, 1):
            horizon.log("%d. %s  %d" % (i, row["name"], row["points"]))
FunctionReturnsDescription
open(path: string)handle: intOpens a SQLite database file, creating it if it is not there yet, and gives you a handle for the other Database nodes. A relative path lands in the project's Saved folder, like the file nodes. Needs the project's "Files outside the project" permission for EVERY path, a relative one included, and unlike the file nodes a path picked in a dialog does not stand in for it. 0 means it did not open. Needed even for a relative path; a path picked in a dialog does not replace it (see the group notes).
Lua/Python horizon.db.open · HorizonCode Open Database · C++ HE::api::db::open · action · needs permission Files outside the project
close(handle: int)—Closes a database. They also all close when the application does.
Lua/Python horizon.db.close · HorizonCode Close Database · C++ HE::api::db::close · action
exec(handle: int, sql: string, params: string)ok: boolRuns SQL that gives no rows back — CREATE, INSERT, UPDATE, DELETE. Put a ? where each value goes and pass the values as a JSON array in Params (for example ["Ada", 36]); never paste them into the SQL yourself, or a name with a quote in it becomes somebody else's command. Ok is false when it failed, and Database Error says why. Runs only the first statement of several, and still answers true.
Lua/Python horizon.db.exec · HorizonCode Run SQL · C++ HE::api::db::exec · action
query(handle: int, sql: string, params: string)rows: stringRuns a SELECT and gives the rows back as JSON — an array of objects, one per row, which the JSON nodes read. Same ? and Params as Run SQL. An empty result and a failed one both come back as [], so check Database Error to tell them apart. Very large results are cut off (Database Error says so); use LIMIT.
Lua/Python horizon.db.query · HorizonCode Query SQL · C++ HE::api::db::query · action
changes(handle: int)rows: intHow many rows the last Run SQL on this database actually changed. This is how you tell "it worked" from "nothing matched".
Lua/Python horizon.db.changes · HorizonCode Rows Changed · C++ HE::api::db::changes · query
lastInsertId(handle: int)id: intThe row id the last INSERT on this database created. Ids above 2 147 483 647 read back as 2 147 483 647.
Lua/Python horizon.db.lastInsertId · HorizonCode Last Insert Id · C++ HE::api::db::lastInsertId · query
lastError(handle: int)error: stringWhy the last call on this database failed, in SQLite's own words, or empty when it did not.
Lua/Python horizon.db.lastError · HorizonCode Database Error · C++ HE::api::db::lastError · query

Interface and app · 9 functions

HTTP

Lua / Python: horizon.http.<function> · C++: HE::api::http::<function>

Web requests that do not stop the game. get and post return a ticket at once; the answer is read later with that ticket. Both need the project permission Network access. Ticket 0 means the request never started: no permission, an empty URL, or a build without an HTTP backend (available). Plain http:// works as well as https://.

  • Scripts poll. HorizonCode gets On Http Response; a script does not, so it asks done(ticket) in onUpdate (see the example under get).
  • ok is about the connection, not the answer. A 404 is ok true with status 404. When the server was never reached, ok is false, the status 0 and error says why (“Could not connect to the server.”).
  • One request at a time. Requests go out one after another in the order they were made, each with a 5-second timeout, so a slow server delays the ones queued behind it. post with an empty content type sends application/json.
  • Answers are kept for a while, not forever. The last 32 are kept. An older one, a forgotten one and an unknown ticket all read the same: done false, status 0, empty body. Read an answer when it arrives, then forget it.

Example: http.get

fetch_news.lua
local M = {}
local URL = "https://example.com/news.json"   -- answers {"headline": "..."}

-- This script sits on a UI Text entity; the headline goes there.
function M.onStart(self)
    self.ticket = horizon.http.get(URL)   -- 0: no "Network access" permission
    horizon.ui.setText(self.entityId, self.ticket ~= 0 and "Loading..." or "")
end

function M.onUpdate(self, dt)
    local t = self.ticket
    -- A script gets no On Http Response: ask until the ticket is answered.
    if t == 0 or not horizon.http.done(t) then return end
    self.ticket = 0
    if not horizon.http.ok(t) then                    -- never reached the server
        horizon.ui.setText(self.entityId, "Offline")
        horizon.log("news: " .. horizon.http.error(t))
    elseif horizon.http.status(t) ~= 200 then         -- reached it, and it said no
        horizon.ui.setText(self.entityId, "")
        horizon.log("news: HTTP " .. horizon.http.status(t))
    else
        local body = horizon.http.body(t)
        horizon.ui.setText(self.entityId, horizon.json.getString(body, "headline", ""))
    end
    horizon.http.forget(t)
end

return M
fetch_news.py
import horizon

URL = "https://example.com/news.json"   # answers {"headline": "..."}

# This script sits on a UI Text entity; the headline goes there.
class FetchNews(horizon.Behavior):
    def on_start(self):
        self.ticket = horizon.http.get(URL)   # 0: no "Network access" permission
        horizon.ui.setText(self.entity_id, "Loading..." if self.ticket else "")

    def on_update(self, dt):
        t = self.ticket
        # A script gets no On Http Response: ask until the ticket is answered.
        if t == 0 or not horizon.http.done(t):
            return
        self.ticket = 0
        if not horizon.http.ok(t):                     # never reached the server
            horizon.ui.setText(self.entity_id, "Offline")
            horizon.log("news: " + horizon.http.error(t))
        elif horizon.http.status(t) != 200:            # reached it, and it said no
            horizon.ui.setText(self.entity_id, "")
            horizon.log("news: HTTP %d" % horizon.http.status(t))
        else:
            body = horizon.http.body(t)
            horizon.ui.setText(self.entity_id, horizon.json.getString(body, "headline", ""))
        horizon.http.forget(t)
FunctionReturnsDescription
get(url: string)ticket: intStarts a GET and returns straight away with a ticket number. The answer arrives later as On Http Response, carrying that same ticket. Needs the project's "Network access" permission; 0 means it never started.
Lua/Python horizon.http.get · HorizonCode HTTP Get · C++ HE::api::http::get · action · needs permission Network access
post(url: string, contentType: string, body: string)ticket: intStarts a POST with this body. An empty Content Type means application/json. Like Get it returns a ticket and answers later.
Lua/Python horizon.http.post · HorizonCode HTTP Post · C++ HE::api::http::post · action · needs permission Network access
done(ticket: int)done: boolHas this ticket been answered yet? Usually not needed — On Http Response fires exactly when it turns true — but useful for a screen that shows "loading" while it waits. false again once the answer is forgotten or pushed out by 32 newer ones.
Lua/Python horizon.http.done · HorizonCode Response Arrived · C++ HE::api::http::done · query
ok(ticket: int)ok: boolDid the request REACH the server and come back? This is about the connection, not about the answer: a 404 is ok=true with status 404.
Lua/Python horizon.http.ok · HorizonCode Response OK · C++ HE::api::http::ok · query
status(ticket: int)status: intThe HTTP status code, 200 for the ordinary success. 0 when the request never got an answer, and then Response Error says why.
Lua/Python horizon.http.status · HorizonCode Response Status · C++ HE::api::http::status · query
body(ticket: int)body: stringWhat came back, as text. Feed it to the JSON nodes when it is JSON.
Lua/Python horizon.http.body · HorizonCode Response Body · C++ HE::api::http::body · query
error(ticket: int)error: stringWhy the request failed to reach anybody (no network, bad host, timed out). Empty when Response OK is true.
Lua/Python horizon.http.error · HorizonCode Response Error · C++ HE::api::http::error · query
forget(ticket: int)—Drops a response you are finished with. Optional: the engine keeps the last 32 and forgets the oldest by itself.
Lua/Python horizon.http.forget · HorizonCode Forget Response · C++ HE::api::http::forget · action
available()available: boolDoes this build have a network stack at all? False only on a Linux build made without libcurl, where every request would fail — worth saying out loud once instead of failing per request.
Lua/Python horizon.http.available · HorizonCode HTTP Available · C++ HE::api::http::available · query

Multiplayer · 44 functions

Multiplayer

Lua / Python: horizon.net.<function> · C++: HE::api::net::<function>

Multiplayer: one machine hosts and is the authority, the others join as clients and play in the host's world. The rows fall into five parts: the session (host, join, leave, status), the players, who owns an entity, replicated variables, and remote calls. Session events, onRep_<var> and the remote-call targets are script methods, listed under Callbacks. The examples below ran word for word in Lua and Python, host and client in both languages, over a real connection on one machine.

  • Hosting. host(port, name) answers true once the socket is open; the status is 1 at once and the host's scripts get onSessionStarted. Port 0 takes the project's Default port (Project Settings, Game, Multiplayer, 47824 in a new project). If that is 0 as well the system picks one, and no row reads it back, so a game that tells joiners its port hosts on a fixed one. Hosting again while a session runs ends it first (onSessionEnded, then onSessionStarted, a new session id and join code). It goes through leave(), so players who had joined are dropped; that part is read from the code, the re-host was measured with nobody connected.
  • Joining. joinDirect(address, port, code, name) answers true when the attempt has started (status 2); false for port 0 or above 65535, and for joinLan with no LAN entry at that index. The client's scripts get onConnected once the whole world has arrived (status 3), the host's get onPlayerJoined(player). A wrong join code never fails: the host refuses the handshake, but the client stays at status 2 with an empty lastError (measured for 8 seconds). Give up yourself, as the lobby example does.
  • LAN browsing needs the system's permission to use the local network. Without it (macOS asks per application) the host logs “the system refused to send an announcement” and every list stays empty; joinDirect keeps working. The LAN rows were not measured with the permission granted.
  • Player numbers. The host is player 1, joiners are 2, 3, … in join order, and a number is never reused. That is the number playerName, ping, kick, callClient, rpcSender and onPlayerJoined/onPlayerLeft use. It is not the connection number the anticheat rows take.
  • Ownership. Scene entities belong to nobody: ownerOf is 0. isLocallyControlled is true offline and for any entity without Replicates; for a replicated entity nobody owns it is true on the host and false on a client.
RowOfflineHostClient
status012 while connecting, 3 joined, 4 after losing the host
isAuthority / isClienttrue / falsetrue / falsefalse / true
localPlayer11its own number (2, 3, …); 0 while connecting
playerCount / playerAt1 / playerAt(0) = 1everybody, in join order1: a client knows only itself
playerName""every player'sonly its own
ping0round trip to each client, msalways 0
sessionId / joinCode""8 / 26 characters"" / ""

Replicated variables

  • Declare, then write on the host. declareVar…(entity, name, initial, notify) works on any entity and also offline; a declaration made before the session starts is carried into it (the health example declares in onStart). Only entities with Replicates on are sent to clients: on any other the variable exists on this machine only. Declaring a name a second time answers true and changes nothing (measured offline).
  • What a client sees. A write on the host reaches every client a moment later (well within half a second on one machine); with notify the client's script gets onRep_<name>(old), never the host's (it made the change) and never for a variable declared without notify. A client's own setVar… changes only its copy; the host sends its value again within a moment, even an unchanged one, and onRep_ fires once more with old = the client's value.
  • Types are not checked on write. A getter of another type answers zero (false, 0, "", 0,0,0). A setter of another type answers true and changes the variable's type, after which the declared getter reads zero; during a session the host then re-sends the entity's whole variable table every tick. Ints are 32-bit: 3000000000 is stored as −1294967296.
  • After the session ends, a client keeps the last values it received.
▲

Known issue (build 7d49d44f): in Lua, old of a Vector variable is the number 0. The value handed to onRep_<name>(old) goes through a converter that has no case for vectors, so Lua always receives 0 (measured with old values 0,0,0 and 1,2,3). Python receives a list [x, y, z]. Read the new value with getVarVec3, and keep the previous one yourself if you need it. A Vector argument of a remote call from a HorizonCode graph goes through the same converter.

Remote calls

  • Who runs what. callServer on a client sends the call to the host, which runs the method of that name on the entity: its HorizonCode class first, else its Lua or Python script, else the C++ module (first one that has it). callClient and callAllClients go from the host to the clients' copies of the entity. The arguments after the name arrive with their types: integers, other numbers, booleans and strings as they are, a Lua table or Python list as a list, a Python dict as a table with __keys. Inside the method rpcSender() is the caller's player number (on a client: 1, the host).
  • Nothing runs locally in a script. Offline and on the host, callServer, callClient to the host's own player and the host's own share of callAllClients reach only a HorizonCode class. A Lua or Python method is not called and the row answers false, for callAllClients even though the clients were sent the call. On the host, call your own method directly (lever example).
  • Who may call. A client may call the entities it owns, plus functions opened with allowAnyClient (or Any Client on a HorizonCode function header). Anything else the host drops without a log line, while the client's callServer still answered true; with anti-cheat on it is a Hard report and the caller is removed at once (measured). allowAnyClient lasts for one session and answers false offline, so call it in onSessionStarted. It answers true on a client, where it changes nothing, and for an entity without a network id, which is refused with a log line.
  • A call needs a replicated entity: for any other the row answers false. The host accepts up to 60 calls per second from one client, averaged over 2 seconds; more are dropped (default, the Multiplayer page can change it).

Leaving and being removed

  • leave() ends the session on this machine, sends onSessionEnded and puts the status back to 0.
  • A client that is removed or loses the host gets onDisconnected(reason) and status 4, and no onSessionEnded. net.kick with anti-cheat off only closes the connection, so the client sees reason 1 and “Lost the connection to the host.”; with anti-cheat on the client is told first and sees reason 2 and “Removed from the session.”. The host's scripts get onPlayerLeft(player), by which time playerName of that player is already empty.
▲

Known issue (build 7d49d44f): a second session in the same scene replicates no scene entity. Hosting walks the scene for entities with Replicates on and skips those that already carry a network id; leave() does not clear it. After host → leave → host, or hosting twice, the log says “Session walk: 0 replicated scene entities”, and clients that join the new session get none of those entities: their variables and remote calls do not arrive.

Example: net.joinDirect

lobby.lua
local M = {}
local PORT = 47824   -- the project's Default port. Fixed on purpose: no row reads back a port the OS picked.

function M.onInputPressed(self, action)
    if action == "Host" and horizon.net.status() == 0 then
        if horizon.net.host(PORT, "Host") then
            horizon.log("Hosting. Join code: " .. horizon.net.joinCode())
        else
            horizon.log("Could not host: " .. horizon.net.lastError())
        end
    end
end

-- Called by the join screen with what the player typed in.
function M.join(self, address, code)
    horizon.net.leave()                       -- a new attempt replaces an old one
    if horizon.net.joinDirect(address, PORT, code, "Guest") then
        self.waited = 0
    end
end

function M.onUpdate(self, dt)
    if self.waited == nil then return end
    -- A wrong join code never fails: the attempt stays at 2 (Connecting). Give up yourself.
    self.waited = self.waited + dt
    if horizon.net.status() == 2 and self.waited > 10 then
        horizon.net.leave()
        self.waited = nil
        horizon.log("No answer from the host. Check the address and the join code.")
    end
end

function M.onConnected(self)
    self.waited = nil
    horizon.log("Joined as player " .. horizon.net.localPlayer())
end

function M.onDisconnected(self, reason)   -- 1 lost, 2 kicked, 3 refused
    self.waited = nil
    horizon.log("Out of the session (" .. reason .. "): " .. horizon.net.lastError())
end

function M.onPlayerJoined(self, player)  -- host only
    horizon.log(horizon.net.playerName(player) .. " joined")
end

return M
lobby.py
import horizon

PORT = 47824   # the project's Default port. Fixed on purpose: no row reads back a port the OS picked.

class Lobby(horizon.Behavior):
    waited = None

    def on_input_pressed(self, action):
        if action == "Host" and horizon.net.status() == 0:
            if horizon.net.host(PORT, "Host"):
                horizon.log("Hosting. Join code: " + horizon.net.joinCode())
            else:
                horizon.log("Could not host: " + horizon.net.lastError())

    def join(self, address, code):
        """Called by the join screen with what the player typed in."""
        horizon.net.leave()                   # a new attempt replaces an old one
        if horizon.net.joinDirect(address, PORT, code, "Guest"):
            self.waited = 0.0

    def on_update(self, dt):
        if self.waited is None:
            return
        # A wrong join code never fails: the attempt stays at 2 (Connecting). Give up yourself.
        self.waited += dt
        if horizon.net.status() == 2 and self.waited > 10:
            horizon.net.leave()
            self.waited = None
            horizon.log("No answer from the host. Check the address and the join code.")

    def on_connected(self):
        self.waited = None
        horizon.log("Joined as player %d" % horizon.net.localPlayer())

    def on_disconnected(self, reason):    # 1 lost, 2 kicked, 3 refused
        self.waited = None
        horizon.log("Out of the session (%d): %s" % (reason, horizon.net.lastError()))

    def on_player_joined(self, player):   # host only
        horizon.log(horizon.net.playerName(player) + " joined")

Example: net.declareVarInt

health.lua
local M = {}

function M.onStart(self)
    -- The same script runs on every machine; each declares the variable.
    -- The host's value is the one every client ends up with.
    horizon.net.declareVarInt(self.entityId, "health", 100, true)
end

-- Only the host decides what a hit does.
function M.onCollisionEnter(self, other)
    if not horizon.net.isAuthority() then return end
    local hp = horizon.net.getVarInt(self.entityId, "health") - 10
    horizon.net.setVarInt(self.entityId, "health", math.max(hp, 0))
    self:showHealth()      -- onRep_health never runs on the host
end

-- On the clients: a new value arrived. `old` is the one before it.
function M.onRep_health(self, old)
    self:showHealth()
end

function M.showHealth(self)
    horizon.log("health " .. horizon.net.getVarInt(self.entityId, "health"))
end

return M
health.py
import horizon

class Health(horizon.Behavior):
    def on_start(self):
        # The same script runs on every machine; each declares the variable.
        # The host's value is the one every client ends up with.
        horizon.net.declareVarInt(self.entity_id, "health", 100, True)

    def on_collision_enter(self, other):
        # Only the host decides what a hit does.
        if not horizon.net.isAuthority():
            return
        hp = horizon.net.getVarInt(self.entity_id, "health") - 10
        horizon.net.setVarInt(self.entity_id, "health", max(hp, 0))
        self.show_health()     # on_rep_health never runs on the host

    def on_rep_health(self, old):
        # On the clients: a new value arrived. `old` is the one before it.
        self.show_health()

    def show_health(self):
        horizon.log("health %d" % horizon.net.getVarInt(self.entity_id, "health"))

Example: net.allowAnyClient

lever.lua
local M = {}

function M.onStart(self)
    horizon.net.declareVarBool(self.entityId, "open", false, true)
end

-- Per session: the list of calls any client may make starts empty with every session.
function M.onSessionStarted(self)
    -- The lever belongs to nobody. Without this only its owner could pull it,
    -- and a refused call counts against the caller as cheating.
    horizon.net.allowAnyClient(self.entityId, "pull")
end

function M.onInputPressed(self, action)
    if action ~= "Interact" then return end
    if horizon.net.isAuthority() then
        self:pull()        -- host or offline: callServer would not reach a script method here
    else
        horizon.net.callServer(self.entityId, "pull")
    end
end

-- Runs on the host, whoever asked.
function M.pull(self)
    local who = horizon.net.rpcSender()       -- 0: the host pulled it itself
    local open = not horizon.net.getVarBool(self.entityId, "open")
    horizon.net.setVarBool(self.entityId, "open", open)
    local name = who ~= 0 and horizon.net.playerName(who) or "The host"
    horizon.log(name .. (open and " opened" or " closed") .. " the gate")
end

function M.onRep_open(self, old)
    horizon.log(horizon.net.getVarBool(self.entityId, "open") and "gate opens" or "gate closes")
end

return M
lever.py
import horizon

class Lever(horizon.Behavior):
    def on_start(self):
        horizon.net.declareVarBool(self.entity_id, "open", False, True)

    def on_session_started(self):
        # Per session: the list of calls any client may make starts empty with every session.
        # The lever belongs to nobody. Without this only its owner could pull it,
        # and a refused call counts against the caller as cheating.
        horizon.net.allowAnyClient(self.entity_id, "pull")

    def on_input_pressed(self, action):
        if action != "Interact":
            return
        if horizon.net.isAuthority():
            self.pull()        # host or offline: callServer would not reach a script method here
        else:
            horizon.net.callServer(self.entity_id, "pull")

    def pull(self):
        """Runs on the host, whoever asked."""
        who = horizon.net.rpcSender()          # 0: the host pulled it itself
        is_open = not horizon.net.getVarBool(self.entity_id, "open")
        horizon.net.setVarBool(self.entity_id, "open", is_open)
        name = horizon.net.playerName(who) if who else "The host"
        horizon.log(name + (" opened" if is_open else " closed") + " the gate")

    def on_rep_open(self, old):
        horizon.log("gate opens" if horizon.net.getVarBool(self.entity_id, "open") else "gate closes")
FunctionReturnsDescription
host(port: int, displayName: string)ok: boolOpens a multiplayer session on this machine and makes it joinable. Port 0 takes the project's Default port (Project Settings, Game, Multiplayer), which is where the seats, the tick rate and the rest come from too. If that is 0 as well the system picks one, and no node reads it back, so a game that tells joiners its port hosts on a fixed one; hand them the Join Code too. Hosting again ends the running session first. Everyone who joins plays in YOUR world: the host decides what really happened.
Lua/Python horizon.net.host · HorizonCode Host Session · C++ HE::api::net::host · action
joinDirect(address: string, port: int, code: string, displayName: string)ok: boolJoins a session by address and port, with the host's Join Code. The code is not a password you can guess past: without the right one the connection never completes at all. Answers true when the attempt has started. With a wrong join code the status stays 2 and lastError empty (measured for 8 seconds): time the attempt out yourself, as the example does.
Lua/Python horizon.net.joinDirect · HorizonCode Join By Address · C++ HE::api::net::joinDirect · action
joinLan(index: int, code: string, displayName: string)ok: boolJoins the session at this index in the LAN list (Refresh LAN Sessions first). Same Join Code as a direct join. false when there is no entry at that index, which without the system's Local Network permission is always.
Lua/Python horizon.net.joinLan · HorizonCode Join LAN Session · C++ HE::api::net::joinLan · action
leave()—Leaves the session, or closes it if you are the host. Says goodbye rather than vanishing, so the others learn the seat is free now instead of in thirty seconds. Sends onSessionEnded on this machine. Offline it does nothing.
Lua/Python horizon.net.leave · HorizonCode Leave Session · C++ HE::api::net::leave · action
status()status: int0 Idle, 1 Hosting, 2 Connecting, 3 Joined, 4 Failed. Joined means the whole world has arrived, not just the connection — a lobby screen may wait for it before it hands over to the game. After a kick or a lost host it stays 4 until the next leave or join.
Lua/Python horizon.net.status · HorizonCode Session Status · C++ HE::api::net::status · query
lastError()error: stringWhy the last host or join attempt failed, in words meant for a player. Empty when nothing has failed. After a removal: “Removed from the session.” (anti-cheat on) or “Lost the connection to the host.”.
Lua/Python horizon.net.lastError · HorizonCode Session Error · C++ HE::api::net::lastError · query
sessionId()sessionId: stringThis session's short id, for showing to somebody who is about to join. Only the host has it: empty on a client and outside a session.
Lua/Python horizon.net.sessionId · HorizonCode Session Id · C++ HE::api::net::sessionId · query
joinCode()code: stringThe secret a joiner needs. Only the HOST gets it — on a client this is deliberately empty, so a client's own UI cannot hand out seats to a session it does not own. 26 characters.
Lua/Python horizon.net.joinCode · HorizonCode Join Code · C++ HE::api::net::joinCode · query
refreshLan()—Starts listening for sessions announced on the local network, or starts over. Only game sessions are listed; an editor collaboration session on the same network is not one you can join. Needs the system's Local Network permission, see the group notes above.
Lua/Python horizon.net.refreshLan · HorizonCode Refresh LAN Sessions · C++ HE::api::net::refreshLan · action
lanSessionCount()count: intHow many sessions the LAN browser is currently hearing. Zero right after Refresh is normal: announcements arrive a moment later.
Lua/Python horizon.net.lanSessionCount · HorizonCode LAN Session Count · C++ HE::api::net::lanSessionCount · query
lanSessionName(index: int)name: stringThe host's display name for the session at this index.
Lua/Python horizon.net.lanSessionName · HorizonCode LAN Session Name · C++ HE::api::net::lanSessionName · query
lanSessionPlayers(index: int)players: intHow many players are in the session at this index, as it last announced itself.
Lua/Python horizon.net.lanSessionPlayers · HorizonCode LAN Session Players · C++ HE::api::net::lanSessionPlayers · query
isAuthority()isAuthority: boolIs THIS machine the one that decides? True on the host and true with no session at all — a single-player game is its own authority. Ask this before anything that changes the world: simulation only with Is Authority, display everywhere, intentions through the server.
Lua/Python horizon.net.isAuthority · HorizonCode Is Authority · C++ HE::api::net::isAuthority · query
isClient()isClient: boolAre we a client in somebody else's session? False offline, unlike Is Authority, because offline there is no host to be a client of.
Lua/Python horizon.net.isClient · HorizonCode Is Client · C++ HE::api::net::isClient · query
localPlayer()player: intOur own player number. 1 on the host and 1 offline; the host hands out 2 and up in join order. A number is minted once and never reused, so a score kept under it stays the right player's. 0 on a client that is still connecting.
Lua/Python horizon.net.localPlayer · HorizonCode Local Player · C++ HE::api::net::localPlayer · query
playerCount()count: intHow many players the session are known HERE. On the host that is all of them; on a client it is 1 — itself — because nothing sends a client the list yet. 1 offline as well.
Lua/Python horizon.net.playerCount · HorizonCode Player Count · C++ HE::api::net::playerCount · query
playerAt(index: int)player: intThe player number of the index'th player, in join order. 0 past the end, so a loop can stop on it. Only the host sees more than itself (see Player Count). On a client playerAt(0) is its own number.
Lua/Python horizon.net.playerAt · HorizonCode Player At · C++ HE::api::net::playerAt · query
playerName(player: int)name: stringThe display name that player joined with. Empty for a player this machine does not know about — on a client that is everybody but itself. Inside onPlayerLeft the name is already gone.
Lua/Python horizon.net.playerName · HorizonCode Player Name · C++ HE::api::net::playerName · query
ping(player: int)ms: floatRound trip to that player in milliseconds, as the host measures it. 0 when there is nothing to measure: ourselves, a player nobody has timed yet, and every player on a client, which knows only itself.
Lua/Python horizon.net.ping · HorizonCode Player Ping · C++ HE::api::net::ping · query
kick(player: int)—Host only: remove a player from the session. With anti-cheat on it takes the anti-cheat's path: the player is told and sees reason 2, Kicked. Without, the connection is simply closed and the player sees reason 1, a lost connection. Does nothing on a client or offline.
Lua/Python horizon.net.kick · HorizonCode Kick From Session · C++ HE::api::net::kick · action
ownerOf(entity: int)player: intWhich player this entity belongs to, or 0 for the host's own and for anything nobody owns (a door, a crate).
Lua/Python horizon.net.ownerOf · HorizonCode Owner Of · C++ HE::api::net::ownerOf · query · entity is Self in HorizonCode
isLocallyControlled(entity: int)local: boolDo WE drive this entity? True for our own character, false for everybody else's, and true for anything not replicated at all. This is the question a character's graph asks before it reacts to input. Entity 0 or an id nothing answers to: false.
Lua/Python horizon.net.isLocallyControlled · HorizonCode Is Locally Controlled · C++ HE::api::net::isLocallyControlled · query · entity is Self in HorizonCode
localCharacter()entity: intThe entity this machine drives — our own character — or 0 before the host has given us one. 0 in every state measured here (no host assigned a character).
Lua/Python horizon.net.localCharacter · HorizonCode Local Character · C++ HE::api::net::localCharacter · query
declareVarBool(entity: int, name: string, initial: bool, notify: bool)ok: boolDeclares a replicated Bool on this entity: the host owns it, every client is sent its value. Call it in On Init. With Notify on, the clients get On Rep when a value arrives. A HorizonCode class does not need this — it ticks Replicated in the variable list instead. Entity 0 or an empty name: false. A missing notify is false in Lua and an IndexError in Python. See Replicated variables.
Lua/Python horizon.net.declareVarBool · HorizonCode Declare Replicated Bool · C++ HE::api::net::declareVarBool · action · entity is Self in HorizonCode
declareVarInt(entity: int, name: string, initial: int, notify: bool)ok: boolDeclares a replicated Int on this entity. See Declare Replicated Bool. 32-bit: 3000000000 is stored as −1294967296.
Lua/Python horizon.net.declareVarInt · HorizonCode Declare Replicated Int · C++ HE::api::net::declareVarInt · action · entity is Self in HorizonCode
declareVarFloat(entity: int, name: string, initial: float, notify: bool)ok: boolDeclares a replicated Float on this entity. See Declare Replicated Bool.
Lua/Python horizon.net.declareVarFloat · HorizonCode Declare Replicated Float · C++ HE::api::net::declareVarFloat · action · entity is Self in HorizonCode
declareVarString(entity: int, name: string, initial: string, notify: bool)ok: boolDeclares a replicated String on this entity. See Declare Replicated Bool.
Lua/Python horizon.net.declareVarString · HorizonCode Declare Replicated String · C++ HE::api::net::declareVarString · action · entity is Self in HorizonCode
declareVarVec3(entity: int, name: string, initial: vec3, notify: bool)ok: boolDeclares a replicated Vector on this entity. See Declare Replicated Bool. In Lua the old value of onRep_<name> is the number 0 (known issue, see Replicated variables).
Lua/Python horizon.net.declareVarVec3 · HorizonCode Declare Replicated Vector · C++ HE::api::net::declareVarVec3 · action · entity is Self in HorizonCode
setVarBool(entity: int, name: string, value: bool)ok: boolWrites a declared Bool. On the host every client is sent the new value; on a client it takes effect HERE and the host's next value replaces it, which is what makes a prediction possible. False = never declared. On a client the write is its own prediction until the host's value comes back. A variable of another type is overwritten too: the row answers true and the variable changes type. See Replicated variables.
Lua/Python horizon.net.setVarBool · HorizonCode Set Replicated Bool · C++ HE::api::net::setVarBool · action · entity is Self in HorizonCode
setVarInt(entity: int, name: string, value: int)ok: boolWrites a declared Int. See Set Replicated Bool.
Lua/Python horizon.net.setVarInt · HorizonCode Set Replicated Int · C++ HE::api::net::setVarInt · action · entity is Self in HorizonCode
setVarFloat(entity: int, name: string, value: float)ok: boolWrites a declared Float. See Set Replicated Bool.
Lua/Python horizon.net.setVarFloat · HorizonCode Set Replicated Float · C++ HE::api::net::setVarFloat · action · entity is Self in HorizonCode
setVarString(entity: int, name: string, value: string)ok: boolWrites a declared String. See Set Replicated Bool.
Lua/Python horizon.net.setVarString · HorizonCode Set Replicated String · C++ HE::api::net::setVarString · action · entity is Self in HorizonCode
setVarVec3(entity: int, name: string, value: vec3)ok: boolWrites a declared Vector. See Set Replicated Bool.
Lua/Python horizon.net.setVarVec3 · HorizonCode Set Replicated Vector · C++ HE::api::net::setVarVec3 · action · entity is Self in HorizonCode
getVarBool(entity: int, name: string)value: boolReads a declared Bool. False when the name was never declared here, which Has Replicated Variable tells apart from a value that is simply false. A variable declared with another type reads as false.
Lua/Python horizon.net.getVarBool · HorizonCode Get Replicated Bool · C++ HE::api::net::getVarBool · query · entity is Self in HorizonCode
getVarInt(entity: int, name: string)value: intReads a declared Int. 0 when the name was never declared here.
Lua/Python horizon.net.getVarInt · HorizonCode Get Replicated Int · C++ HE::api::net::getVarInt · query · entity is Self in HorizonCode
getVarFloat(entity: int, name: string)value: floatReads a declared Float. 0 when the name was never declared here.
Lua/Python horizon.net.getVarFloat · HorizonCode Get Replicated Float · C++ HE::api::net::getVarFloat · query · entity is Self in HorizonCode
getVarString(entity: int, name: string)value: stringReads a declared String. Empty when the name was never declared here.
Lua/Python horizon.net.getVarString · HorizonCode Get Replicated String · C++ HE::api::net::getVarString · query · entity is Self in HorizonCode
getVarVec3(entity: int, name: string)value: vec3Reads a declared Vector. Zero when the name was never declared here.
Lua/Python horizon.net.getVarVec3 · HorizonCode Get Replicated Vector · C++ HE::api::net::getVarVec3 · query · entity is Self in HorizonCode
callServer(entity, name, …)ok: boolAsk the HOST to run a function on this entity. The ordinary way to say "I pulled the lever": a client may not change the world, so it asks. Nothing comes back — a remote call has no return value. Allowed for the entity you own, or for a function whose header has Any Client ticked; anything else the host drops, and with anti-cheat on it is a Hard report that removes the caller. Offline and on the host it runs here, on the entity's HorizonCode class, so a graph works in single player; a Lua or Python method is not reached that way. In Lua and Python the arguments after name are passed on as the remote function's arguments, as many as it takes (see Remote calls). On a client true means sent, also for a call the host then refuses. Offline and on the host a Lua or Python method is not reached: the row answers false and nothing runs, so call the method directly (see Remote calls).
Lua/Python horizon.net.callServer · HorizonCode Call Server · C++ HE::api::net::callServer · action · entity is Self in HorizonCode
callClient(player, entity, name, …)ok: boolHost only: run a function on ONE player's machine — the hit marker, the message only they should see. Addressed by PlayerId. Nothing comes back. In Lua and Python the arguments after name are passed on as the remote function's arguments, as many as it takes (see Remote calls). Answers false and runs nothing in a script on a client, for the host's own player and for a player number nobody has.
Lua/Python horizon.net.callClient · HorizonCode Call Client · C++ HE::api::net::callClient · action
callAllClients(entity, name, …)ok: boolHost only: run a function on EVERY machine, including this one — the round-over horn, the explosion everybody sees. Nothing comes back. Here on the host only the entity's HorizonCode class runs it (a Lua or Python method is not reached, and the result is then false although the clients were sent the call). In Lua and Python the arguments after name are passed on as the remote function's arguments, as many as it takes (see Remote calls). Answers false on a client, and on the host for an entity without a HorizonCode class, although the clients were sent the call and ran it; the host's own script method does not run.
Lua/Python horizon.net.callAllClients · HorizonCode Call All Clients · C++ HE::api::net::callAllClients · action · entity is Self in HorizonCode
allowAnyClient(entity: int, function: string)ok: boolLet any client call that function on this entity, not just its owner. For Lua, Python and C++ classes, which have no function header to tick. A door belongs to nobody, so without this nobody could open it. Lasts one session: call it in onSessionStarted. false offline or for an empty name; true on a client and for an entity without a network id, where it does nothing. See Remote calls.
Lua/Python horizon.net.allowAnyClient · HorizonCode Allow Any Client · C++ HE::api::net::allowAnyClient · action · entity is Self in HorizonCode
rpcSender()player: intWhich player asked for the call being handled right now: a player number, as Player Name and Kick From Session take it. 0 at any other moment. Not a connection number, so it is not what the anti-cheat rows take (Report Player gives that one). On a client, inside a call the host made: 1.
Lua/Python horizon.net.rpcSender · HorizonCode Call Sender · C++ HE::api::net::rpcSender · query
hasVar(entity: int, name: string)declared: boolIs this name declared on this entity at all — by a script or by the entity's HorizonCode class? The one row that tells a missing declaration apart from a value still at its default. Also true for a variable a client declared on its own copy.
Lua/Python horizon.net.hasVar · HorizonCode Has Replicated Variable · C++ HE::api::net::hasVar · query · entity is Self in HorizonCode

Multiplayer · 15 functions

Anti-Cheat

Lua / Python: horizon.anticheat.<function> · C++: HE::api::anticheat::<function>

The host's anti-cheat scores what clients send and decides what to do about it. It runs on the host of a multiplayer session whose project has anti-cheat switched on (Project Settings, Game, Anti-Cheat). Each report reaches every script as onCheatDetected(reportId); the report… rows read it, respond overrules the host's reaction. A client gets the same callback once, when the host tells it that it is being removed.

▲

Known issue (build 7d49d44f): anti-cheat never runs, in the exported game and in Play as Host alike. The project setting does not reach the session: net.host, --host and Play as Host all open it without the anti-cheat settings. And the anti-cheat that scripts, graphs and C++ modules talk to is a second object that no session is connected to. So in every frontend isEnabled is false, check true, report, respond, kick and setPlayerLabel do nothing, the readers answer 0 or "", and onCheatDetected never fires. The rest of this section was measured with the anti-cheat connected by hand, the way the engine intends it.

  • player is a connection number, not a player number. Every row here that takes a player, and reportPlayer, counts the host's connections, which are numbered separately from the players of net. The first joiner was player 2 on connection 1; after a failed join attempt had used connection 1, the next joiner was player 2 on connection 2. No row converts one into the other, so take the number from reportPlayer. A report filed under net.rpcSender() (player 2) went to connection 2, which was nobody, and the real caller's score stayed 0. A connection nobody holds is scored and reported all the same.
  • Score and level. report(player, rule, weight, detail) adds weight to that connection's score, which halves every 30 seconds (the defaults on the Anti-Cheat page). Reaching 5 files a Suspect report (level 1), 20 a Confirmed one (level 2). Hard (level 3) is a single observation no honest build sends, for example a remote call to an entity the client may not call. reportDetail lists the observations behind the score (“Custom 6.00 (Aimbot: through wall)”).
  • Reactions. Unless a handler says otherwise the host logs Suspect and Confirmed reports and kicks on Hard. respond(reportId, response) replaces that, but only inside onCheatDetected, in the frame the report fired: respond(id, 1 + 8) made the host log and flag, 1 + 16 log and kick. A later call is ignored with the log line “no such pending report”. Log is always kept.
  • check always answers true. The rules on the Anti-Cheat page are saved with the project but nothing reads them yet (measured with a rule “Damage” from 0 to 100 and a value of 500), so code that applies a value only when check agrees keeps working when they arrive.
  • What the kicked client learns. Its scripts get onCheatDetected with reportPlayer 0 (“you”), then onDisconnected(2) with “Removed from the session.”. After kick(player, code) it reads reportReason = code, level 2 and an empty rule; after a kick by the host's policy or a respond, the rule's name and reason 0. Score and detail are never sent to a client. In the editor's Play mode nobody is kicked.
  • Labels. A client's join name is its label from the start; setPlayerLabel replaces it in the log and in later reports (“conn 1 [Anna#1]”). expectDisplacement was not measured here: it needs a moving character.

Example: anticheat.respond

cheat_log.lua
local M = {}
local AC = horizon.anticheat

function M.onCheatDetected(self, id)
    if AC.reportPlayer(id) == 0 then
        -- On a client: the host is removing us. Say why before the link drops.
        horizon.log("Removed from the session: " .. AC.reportRule(id) .. " (code " .. AC.reportReason(id) .. ")")
        return
    end
    -- On the host. reportPlayer is a CONNECTION number, not a net player number.
    horizon.log(string.format("[anti-cheat] %s, connection %d, score %.1f: %s",
        AC.reportRule(id), AC.reportPlayer(id), AC.reportScore(id), AC.reportDetail(id)))
    if AC.reportLevel(id) == 2 then
        AC.respond(id, 1 + 16)    -- Confirmed: log and kick (the default policy only logs)
    end
end

return M
cheat_log.py
import horizon

AC = horizon.anticheat

class CheatLog(horizon.Behavior):
    def on_cheat_detected(self, rid):
        if AC.reportPlayer(rid) == 0:
            # On a client: the host is removing us. Say why before the link drops.
            horizon.log("Removed from the session: %s (code %d)" % (AC.reportRule(rid), AC.reportReason(rid)))
            return
        # On the host. reportPlayer is a CONNECTION number, not a net player number.
        horizon.log("[anti-cheat] %s, connection %d, score %.1f: %s" % (
            AC.reportRule(rid), AC.reportPlayer(rid), AC.reportScore(rid), AC.reportDetail(rid)))
        if AC.reportLevel(rid) == 2:
            AC.respond(rid, 1 + 16)    # Confirmed: log and kick (the default policy only logs)
FunctionReturnsDescription
check(rule: string, value: float, player: int)ok: boolAsks the host's anti-cheat whether a value a client claimed (damage, a pickup, a currency delta) is allowed by the rule of that name. True = apply it. The rule table is not built yet: the rules on the project's Anti-Cheat page are saved but never read, so every check answers true. That is also the answer on a client, with anti-cheat off and for a rule nobody declared: a check the engine cannot make never blocks the game. Player is a connection number, like Report Player's.
Lua/Python horizon.anticheat.check · HorizonCode Check Rule · C++ HE::api::anticheat::check · action
expectDisplacement(entity: int, maxDistance: float)—Tells the anti-cheat that this entity is about to move a long way on purpose (respawn, portal, dash), up to Max Distance. One shot: the next move is allowed, then the usual speed check is back. Without it a respawn would count as a teleport against the player.
Lua/Python horizon.anticheat.expectDisplacement · HorizonCode Expect Displacement · C++ HE::api::anticheat::expectDisplacement · action · entity is Self in HorizonCode
report(player: int, rule: string, weight: float, detail: string)—Reports something the engine cannot see for itself — a shot through a wall, an impossible pickup — against this player, under a rule name of your choosing. Weight is what it adds to the player's score (5 is the default Suspect threshold, 20 Confirmed); the detail goes into the log. Filed at the end of the frame; onCheatDetected fires then. See the group notes on connection numbers.
Lua/Python horizon.anticheat.report · HorizonCode Report Observation · C++ HE::api::anticheat::report · action
setPlayerLabel(player: int, label: string)—Gives a player a name for the anti-cheat's log, reports and telemetry. Without one a player is only a connection number. Also what a Ban holds on to for the rest of the session.
Lua/Python horizon.anticheat.setPlayerLabel · HorizonCode Set Player Label · C++ HE::api::anticheat::setPlayerLabel · action
respond(reportId: int, response: int)—Inside On Cheat Detected: replaces what the host would do for this report. Response is a sum of 1 log, 2 event, 4 telemetry, 8 flag, 16 kick, 32 ban; 0 means log only. The host acts at the END of the frame, so this only counts in the frame the event fired.
Lua/Python horizon.anticheat.respond · HorizonCode Respond To Report · C++ HE::api::anticheat::respond · action
kick(player: int, reasonCode: int)—Removes a player from the session at the end of this frame, whatever the score says. The client is told the Reason Code first, so it can show why instead of a dead connection. Ignored in the editor's play mode. The client reads reasonCode back through reportReason; its ticket has level 2 and an empty rule.
Lua/Python horizon.anticheat.kick · HorizonCode Kick Player · C++ HE::api::anticheat::kick · action
reportLevel(reportId: int)level: intHow serious the report is: 0 Info, 1 Suspect, 2 Confirmed, 3 Hard. Hard means a single thing no honest client can send; the other two are the decaying score crossing a threshold.
Lua/Python horizon.anticheat.reportLevel · HorizonCode Report Level · C++ HE::api::anticheat::reportLevel · query
reportRule(reportId: int)rule: stringThe name of what was violated: an engine rule like DtBudget or Displacement, or the name your own Report Observation used. On a client this is the one thing the host tells you about a kick. Empty on the client after an explicit kick, which sends a reason code instead.
Lua/Python horizon.anticheat.reportRule · HorizonCode Report Rule · C++ HE::api::anticheat::reportRule · query
reportPlayer(reportId: int)player: intThe connection the report is about — the number Check Rule, Report Observation, Set Player Label, Kick Player and Player Score take. Not a multiplayer player number: players are numbered 1, 2, … in join order, connections separately (the first joiner is usually player 2 on connection 1). 0 on a client, where the report is about you.
Lua/Python horizon.anticheat.reportPlayer · HorizonCode Report Player · C++ HE::api::anticheat::reportPlayer · query
reportEntity(reportId: int)entity: intThe network id of the entity involved, when the report names one (a movement report names the character). 0 when it does not. A network id, not an entity id. 0 in every report measured here.
Lua/Python horizon.anticheat.reportEntity · HorizonCode Report Entity · C++ HE::api::anticheat::reportEntity · query
reportScore(reportId: int)score: floatThe player's score when the report was made. 0 on a client: the host never tells a player their score.
Lua/Python horizon.anticheat.reportScore · HorizonCode Report Score · C++ HE::api::anticheat::reportScore · query
reportDetail(reportId: int)detail: stringThe observations behind the report as one readable line, for a log or an admin overlay. On a client only the rule name — nothing about how the host decides.
Lua/Python horizon.anticheat.reportDetail · HorizonCode Report Detail · C++ HE::api::anticheat::reportDetail · query
reportReason(reportId: int)reasonCode: intThe reason code a kick carried (Kick Player's Reason Code), read on the client to show the right message. 0 when the host's policy kicked.
Lua/Python horizon.anticheat.reportReason · HorizonCode Report Reason · C++ HE::api::anticheat::reportReason · query
playerScore(player: int)score: floatThe player's current anti-cheat score, decaying over time. For a diagnostic overlay; 0 with anti-cheat off or on a client. Halves every 30 seconds with the default settings.
Lua/Python horizon.anticheat.playerScore · HorizonCode Player Score · C++ HE::api::anticheat::playerScore · query
isEnabled()enabled: boolIs the anti-cheat running in this session? True only on a host that has it enabled in the project settings. Useful to hide an admin overlay everywhere else. false on a client.
Lua/Python horizon.anticheat.isEnabled · HorizonCode Anti-Cheat Enabled · C++ HE::api::anticheat::isEnabled · query