Pick one
Choosing a Language
The scripting language is chosen per project, at creation — it decides which gameplay assets the editor offers and keeps a project single-language. Widgets, the Level Script and the Game Instance are always HorizonCode graphs, in every project.
| Language | Best for | You write |
|---|---|---|
| HorizonCode | Visual-first teams; designers | Node graphs — compiled to native C++ on export. Guide → |
| Lua | Fast iteration, small footprint | Modules with lifecycle callbacks, attached via the Script component. |
| Python | Python-fluent teams, tooling-heavy projects | horizon.Behavior subclasses (real
CPython, full stdlib). |
| C++ | Maximum performance, engine-level access | A native GameLogic library against
IGameLogic, built with your own
toolchain. |
Lifecycle
How Scripts Run
A script is an asset (created in the Content Browser, edited in the built-in syntax-highlighted editor or your own IDE) attached to an entity through the Script component. The language lives on the asset — inferred from the extension — so scenes and prefabs never care which backend runs the code.
Lifecycle callbacks, all optional:
| Lua | Python | Fires |
|---|---|---|
onStart(self) |
on_start(self) |
Once, when the entity spawns in play mode. |
onUpdate(self, dt) |
on_update(self, dt) |
Every frame, with elapsed seconds. |
onCollisionEnter/Exit(self, other) |
on_collision_enter/_exit(self, other) |
Physics contacts and triggers, with the other entity's id. |
onClick, onHoverEnter,
onHoverExit |
on_click, on_hover_enter,
on_hover_exit |
In-world UI interaction on the entity. |
Each instance knows its entity — self.entityId in
Lua, self.entity_id in Python — and passes it to the
horizon API.
Default backend
Lua
A Lua script is a module that returns a table. Fields become per-instance state; functions become the callbacks:
local M = {}
M.properties = { speed = 90.0 } -- shows up in the Details panel
function M.onStart(self)
self.angle = 0
end
function M.onUpdate(self, dt)
self.angle = self.angle + self.speed * dt
horizon.setRotation(self.entityId, 0, self.angle, 0)
end
return M
The same task written out in this shape and in the other three languages side by side is further down under Practical Examples.
CPython
Python
Python scripts run on an embedded real CPython
interpreter — the full standard library is available, and
exports bundle it with your game. A script file contains exactly
one horizon.Behavior subclass; typed class
attributes become editor-exposed properties:
import horizon
class Spinner(horizon.Behavior):
speed = 90.0 # exposed in the Details panel
def on_start(self):
self.angle = 0.0
def on_update(self, dt):
self.angle += self.speed * dt
horizon.setRotation(self.entity_id, 0, self.angle, 0)
The API mirrors Lua exactly — the same function names, with
vectors returned as tuples and raycast returning a
dict (or None on miss). Every example under
Practical Examples is written out in
Python as well.
One surface
The horizon API
All frontends bind to one central engine API. Scripts see it in
two layers: flat gameplay functions directly on
horizon, and namespaced groups
(horizon.math.*, horizon.scene.* …)
generated from the same registry that powers HorizonCode's
Engine Call node — a graph and a script always reach exactly
the same engine surface.
Flat functions
| Area | Functions |
|---|---|
| Identity & logging | log, getName |
| Transform | get/setPosition,
get/setRotation,
get/setScale |
| Lifecycle | spawn(parent, name),
destroy |
| Physics | raycast, setVelocity,
isGrounded |
| Materials | get/setMaterialParam — animate
node-graph material parameters per entity |
| Entity UI | get/setUIText, …UIColor,
…UIVisible, …UIPosition,
…UISize,
setUIMaterialParam |
| Widgets & cursor | createWidget, destroyWidget,
show/hideWidget,
setWidgetZOrder,
isWidgetVisible,
callWidgetFunction,
show/hideCursor |
Namespaced groups
math, random, time,
input, string, camera,
env (every sky/weather field),
entity, audio, debug
(debug-draw lines/spheres/boxes), fs (sandboxed
save-folder file I/O), save (key-value save game
with slots) and scene (loading, streaming zones).
Full reference: every function with signatures and examples is on the Scripting API Reference page; the group-by-group catalogue is in the node reference.
Side by side
Practical Examples
Five tasks, each written once in every language. Pick a language
once and every example on this page follows — the choice sticks
across visits and can be linked to with
?lang=python.
The shapes differ, and the examples stay inside what all four can
really do. A Lua or Python script runs per entity
— self.entityId / self.entity_id is the
entity it sits on. A C++ GameLogic runs
once for the whole game, implements all three
IGameLogic methods and looks its
entities up itself. A HorizonCode graph on an Entity class gets
BeginPlay and Tick and reaches the same
registry through Engine Call. The C++ service tables
cover save, entity, physics, input and content — there is no
transform read and no logging on that side, so positions below are
passed in or come out of a hit.
Reading the graph panels:
Event starts a chain, └▸ is the next
node on it, ▸ an exec branch, · a pure
node that is only wired, and ◂ means “this pin is
fed by”. Self is the Get Owning Entity
node, and a bare name like Travelled is a graph
variable, read with Get Variable. Every Engine Call is written with the name the
node picker shows — see the
node reference.
Find an entity
Every later example needs a handle on something in the scene. A script already sits on one entity; a C++ module and a graph go looking.
local M = {}
-- findByName walks the scene: look the entity up once, not every frame.
function M.onStart(self)
-- 0 means "no such entity" — the same answer C++ and a graph get.
self.player = horizon.entity.findByName("Player")
end
return M
import horizon
class Finder(horizon.Behavior):
# findByName walks the scene: look the entity up once, not every frame.
def on_start(self):
# 0 means "no such entity" — the same answer C++ and a graph get.
self.player = horizon.entity.findByName("Player")
#include <IGameLogic.h>
#include <HorizonGameServices.h>
// Once, in one .cpp: receives the engine's service tables on load.
HE_IMPLEMENT_ENGINE_SERVICES()
class MyGame : public IGameLogic {
public:
void onStart(HorizonWorld&) override
{
// 0 means "no such entity" — the same answer a script gets.
m_player = he::entity::findByName("Player");
}
void onUpdate(HorizonWorld&, float) override {}
void onStop(HorizonWorld&) override {}
private:
uint32_t m_player = 0; // every example below keeps using this
};
extern "C" HE_GAME_API IGameLogic* HE_CreateGameLogic()
{ return new MyGame(); }
extern "C" HE_GAME_API void HE_DestroyGameLogic(IGameLogic* p)
{ delete p; }
Event BeginPlay
└▸ Set Variable Player ◂ Engine Call Find By Name ("Player")
Move something every frame
A platform that slides along +Z. All four keep the travelled distance themselves and write an absolute position — a write on an entity with a body is a teleport of that body.
local M = {}
M.properties = { speed = 2.0 } -- metres per second, in the Details panel
function M.onStart(self)
self.travelled = 0.0
end
function M.onUpdate(self, dt)
self.travelled = self.travelled + self.speed * dt
horizon.physics.setPosition(self.entityId, 0, 0, self.travelled)
end
return M
import horizon
class Platform(horizon.Behavior):
speed = 2.0 # metres per second, in the Details panel
def on_start(self):
self.travelled = 0.0
def on_update(self, dt):
self.travelled += self.speed * dt
horizon.physics.setPosition(self.entity_id, 0.0, 0.0, self.travelled)
// inside MyGame — see "Find an entity" for the class around it.
void onUpdate(HorizonWorld&, float dt) override
{
m_travelled += kSpeed * dt;
he::physics::setPosition(m_platform, { 0.0f, 0.0f, m_travelled });
}
private:
static constexpr float kSpeed = 2.0f;
float m_travelled = 0.0f;
uint32_t m_platform = 0; // he::entity::findByName("Platform") in onStart
Event Tick → Delta Seconds
└▸ Set Variable Travelled ◂ Add (Travelled,
Multiply (Speed, Delta Seconds))
└▸ Engine Call Set Position (Physics)
Entity ◂ Self
Position ◂ Make Vector 3 (0, 0, Travelled)
There is no position read in the C++ service tables, so the module carries m_travelled itself. The scripts could ask the engine with horizon.getPosition — they keep their own count here so the four panels say the same thing.
Jump when a key is pressed
Space, but only with both feet on the ground — and keeping the horizontal speed the character already had.
local M = {}
M.properties = { jumpSpeed = 6.0 }
function M.onUpdate(self, dt)
local me = self.entityId
if horizon.input.keyDown("Space") and horizon.physics.isGrounded(me) then
local vx, _, vz = horizon.physics.getVelocity(me)
horizon.physics.setVelocity(me, vx, self.jumpSpeed, vz)
end
end
return M
import horizon
class Jump(horizon.Behavior):
jump_speed = 6.0
def on_update(self, dt):
me = self.entity_id
if horizon.input.keyDown("Space") and horizon.physics.isGrounded(me):
vx, _, vz = horizon.physics.getVelocity(me)
horizon.physics.setVelocity(me, vx, self.jump_speed, vz)
// inside MyGame — see "Find an entity" for the class around it.
void onUpdate(HorizonWorld&, float) override
{
if (he::input::keyDown("Space") && he::physics::isGrounded(m_player))
{
const he::Vec3 v = he::physics::getVelocity(m_player);
he::physics::setVelocity(m_player, { v.x, kJumpSpeed, v.z });
}
}
private:
static constexpr float kJumpSpeed = 6.0f;
Event Tick
· X, Y, Z ◂ Break Vector 3 ◂ Engine Call Get Velocity (Physics) (Self)
└▸ Branch ◂ And (Engine Call Key Down ("Space"),
Engine Call Is Grounded (Physics) (Self))
True ▸ Engine Call Set Velocity
Entity ◂ Self
Velocity ◂ Make Vector 3 (X, 6.0, Z)
Cast a ray and use the hit
A jump pad casts a short ray straight up and launches whatever it finds. The origin is where the pad sits in the level.
local M = {}
M.properties = { force = 8.0 }
-- A jump pad: look straight up from the pad and launch what stands on it.
function M.onUpdate(self, dt)
local hit = horizon.raycast(0, 0.5, 0, 0, 1, 0, 1.5)
if hit then
horizon.physics.addImpulse(hit.entity, 0, self.force, 0)
end
end
return M
import horizon
class LaunchPad(horizon.Behavior):
force = 8.0
# A jump pad: look straight up from the pad and launch what stands on it.
def on_update(self, dt):
hit = horizon.raycast(0.0, 0.5, 0.0, 0.0, 1.0, 0.0, 1.5)
if hit is not None:
horizon.physics.addImpulse(hit["entity"], 0.0, self.force, 0.0)
// inside MyGame — see "Find an entity" for the class around it.
void onUpdate(HorizonWorld&, float) override
{
const he::RaycastHit hit = he::physics::raycast(
{ 0.0f, 0.5f, 0.0f }, { 0.0f, 1.0f, 0.0f }, 1.5f);
if (hit.hit)
he::physics::addImpulse(hit.entity, { 0.0f, kForce, 0.0f });
}
private:
static constexpr float kForce = 8.0f;
Event Tick
· Hit, Entity, Point, Normal, Distance, Layer
◂ Engine Call Raycast (Make Vector 3 (0, 0.5, 0),
Make Vector 3 (0, 1, 0), 1.5)
└▸ Branch ◂ Hit
True ▸ Engine Call Add Impulse
Entity ◂ Entity
Impulse ◂ Make Vector 3 (0, 8.0, 0)
The same query, three shapes for the answer: the flat script raycast hands back a table (Lua) or dict (Python) and nil/None on a miss, C++ always returns a he::RaycastHit whose hit field says whether it landed, and the node carries every field as its own pin. Points and normals are in world space everywhere.
Remember progress in the save game
Open a slot when play starts, read the level out of it, and write it back on F5.
local M = {}
function M.onStart(self)
if horizon.save.exists("slot1") then
horizon.save.load("slot1")
else
horizon.save.create("slot1")
end
self.level = horizon.save.getNumber("level", 1) -- the default is required
self.wasDown = false
end
function M.onUpdate(self, dt)
local down = horizon.input.keyDown("F5")
if down and not self.wasDown then -- keyDown is a state, not an event
horizon.save.setNumber("level", self.level)
horizon.save.write()
end
self.wasDown = down
end
return M
import horizon
class Progress(horizon.Behavior):
def on_start(self):
if horizon.save.exists("slot1"):
horizon.save.load("slot1")
else:
horizon.save.create("slot1")
self.level = horizon.save.getNumber("level", 1) # default is required
self.was_down = False
def on_update(self, dt):
down = horizon.input.keyDown("F5")
if down and not self.was_down: # keyDown is a state, not an event
horizon.save.setNumber("level", self.level)
horizon.save.write()
self.was_down = down
// inside MyGame — see "Find an entity" for the class around it.
void onStart(HorizonWorld&) override
{
if (he::save::exists("slot1")) he::save::load("slot1");
else he::save::create("slot1");
m_level = he::save::getNumber("level", 1.0f);
}
void onUpdate(HorizonWorld&, float) override
{
const bool down = he::input::keyDown("F5");
if (down && !m_wasDown) // keyDown is a state, not an event
{
he::save::setNumber("level", m_level);
he::save::write();
}
m_wasDown = down;
}
private:
float m_level = 1.0f;
bool m_wasDown = false;
Event BeginPlay
└▸ Sequence
A ▸ Branch ◂ Engine Call Save Exists ("slot1")
True ▸ Engine Call Load Save ("slot1")
False ▸ Engine Call Create Save ("slot1")
B ▸ Set Variable Level ◂ Engine Call Save Get Number ("level", 1)
Event Tick
· Down ◂ Engine Call Key Down ("F5")
└▸ Sequence
A ▸ Branch ◂ And (Down, Not (Was Down))
True ▸ Engine Call Save Set Number ("level", Level)
▸ Engine Call Write Save ()
B ▸ Set Variable Was Down ◂ Down
level has to be a field on the project’s SaveGame Template — every typed accessor is validated against it, on all four sides. The default that getNumber takes is not optional in any of them.
Designer-friendly
Exposed Properties
Scripts expose tweakables to the editor —
an M.properties table in Lua, typed class attributes
in Python. Supported types: Float, Int, Bool,
String. Each Script component instance can override the
defaults in the Details panel, and the values are injected
before onStart — one script, many differently-tuned
entities.
Iterate
Hot Reload
Save a script while play mode runs and the engine patches the new
code into every live instance — Lua swaps the module's functions
(instance data survives), Python swaps the class
(__dict__ state survives). Errors surface in the
log without ending the session.
Native
Native C++
C++ projects ship gameplay as one native
GameLogic shared library. The editor scaffolds a
compilable Source/ tree at project creation — game
runtime, GameInstance, a per-scene level script and a
CMakeLists.txt — visible as its own root in the
Content Browser, with an in-editor class editor and a
New C++ Class workflow.
The library implements the three-method
IGameLogic interface. It links against nothing and
includes exactly two engine headers, <IGameLogic.h>
for the interface and <HorizonGameServices.h> for
everything the engine can do for it:
#include <IGameLogic.h>
#include <HorizonGameServices.h>
// Once, in one .cpp: receives the engine's service tables on load.
HE_IMPLEMENT_ENGINE_SERVICES()
class MyGame : public IGameLogic {
void onStart(HorizonWorld& world) override;
void onUpdate(HorizonWorld& world, float dt) override;
void onStop(HorizonWorld& world) override;
};
extern "C" HE_GAME_API IGameLogic* HE_CreateGameLogic()
{ return new MyGame(); }
extern "C" HE_GAME_API void HE_DestroyGameLogic(IGameLogic* p)
{ delete p; }
Engine services
The HorizonWorld& the three methods receive is an
opaque handle: the library compiles against
HE_Core/include only, so it can hold the reference and
pass it on, but not call into it. Everything the module does to the
engine crosses through service tables instead —
plain C function pointers the engine injects right after the library
loads, wrapped in header-only he:: helpers that speak
std::string and Vec3. Five namespaces,
each with an available() that says whether its table
arrived:
| Namespace | What it reaches |
|---|---|
he::save |
Save lifecycle (create /
load / write /
close / exists /
remove / list) and typed field
access against the project's SaveGame Template. Struct
fields cross as JSON and pair with the generated
Generated/GameTypes.h types. |
he::entity |
findByName, plus
saveState / hasSavedState /
applySavedState for entities carrying a
Save State component. |
he::physics |
raycast, sphereCast,
overlapSphere; addForce /
addImpulse / addTorque,
get/set velocity, isGrounded,
setPosition and
setPositionAndReset, world gravity. |
he::input |
keyDown (SDL scancode names), mouse
position / delta / buttons / scroll, gamepad connected /
button / axis (Xbox names, sticks -1..+1), and the input
mode (setModeGameOnly and friends). |
he::content |
Asset residency: load a path into
an he::AssetId, then
unload / isLoaded /
typeName. |
if (he::input::keyDown("Space") && he::physics::isGrounded(player))
he::physics::addImpulse(player, { 0.0f, 6.0f, 0.0f });
const he::RaycastHit hit = he::physics::raycast(eye, forward, 50.0f);
if (hit.hit) he::save::setString("lastLookedAt", std::to_string(hit.entity));
- Never injected is a state, not an error — a
module that runs under an engine which did not hand over a table
gets safe no-ops with default return values, never a crash. The
engine says so in the log;
available()lets the module say so too. - Same semantics as the scripts — every row is
routed through the same engine API that backs
horizonin Lua and Python and the HorizonCode nodes, so a rule holds everywhere it applies. Including the awkward one: a raycast reports a world point whilesetPositiontakes a local one, exactly as in the script API. An entity is auint32_t— the same number a script reads asself.entityId. - Only values cross — there is no call that hands
back an asset object, on purpose: engine asset pointers are valid
only until the next load.
he::contentdecides what is resident, so the next scene's meshes can be paid for before they are needed. - The ABI only grows at the end — each table carries its own version and is only ever appended to, and a module accepts a table whose version is at least the one it was built against. An older module keeps working under a newer engine; a newer module under an older engine loses the calls that engine never had, and nothing else.
Building and reloading
- From the editor —
Build ▸ Build and Reload Game
Logic compiles
Source/and swaps the result into a running preview without ending it. Outside Play mode it only builds, and that module is what the next Play loads. - Hot reload — rebuild the library and the engine reloads it live (it loads a uniquely-named copy, so the build never fights a locked file). Keep persistent state in the ECS, not in the logic object — the object is recreated on reload, the world survives. The service tables are re-injected with every swap, so the fresh image starts out connected.
- Toolchain — needs CMake and a C++ compiler; the editor checks at startup and can install them for you (Preferences ▸ Tools ▸ Status).
- Shipping — the export copies
GameLogic.dll/.dylib/.sonext to the game executable. Export →