Manual

Scripting

Four ways to write gameplay — Lua, Python, native C++ and visual HorizonCode — all reaching one engine API.

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.

LanguageBest forYou 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:

LuaPythonFires
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:

spinner.lua
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:

spinner.py
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

AreaFunctions
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 entityself.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.

finder.lua
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
finder.py
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")
Game.cpp
#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; }
Finder (graph)
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.

platform.lua
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
platform.py
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)
Game.cpp
// 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
Platform (graph)
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.

jump.lua
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
jump.py
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)
Game.cpp
// 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;
Jump (graph)
Event  Tick
  · X, Y, Z ◂ Break Vector 3Engine Call Get Velocity (Physics) (Self)
  └▸ BranchAnd (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.

launchpad.lua
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
launchpad.py
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)
Game.cpp
// 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;
LaunchPad (graph)
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.

progress.lua
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
progress.py
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
Game.cpp
// 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;
Progress (graph)
Event  BeginPlay
  └▸ Sequence
       A ▸ BranchEngine 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 ▸ BranchAnd (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:

GameLogic.cpp
#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:

NamespaceWhat 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.
PlayerLevelScript.cpp
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 horizon in Lua and Python and the HorizonCode nodes, so a rule holds everywhere it applies. Including the awkward one: a raycast reports a world point while setPosition takes a local one, exactly as in the script API. An entity is a uint32_t — the same number a script reads as self.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::content decides 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 editorBuild ▸ 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/.so next to the game executable. Export →