โ† Back to skills
extension
Category: OtherNo API key required

Openplanet Plugin Dev

Create, debug, and structure Openplanet AngelScript plugins for Trackmania/Maniaplanet. Comprehensive guide with API quirks, AngelScript language pitfalls, p...

personAuthor: tomekdothubclawhub

๐ŸŽฎ Openplanet Plugin Development

๐Ÿ“‹ Overview

Openplanet is a plugin/script development platform for Nadeo games (Trackmania 2020, Maniaplanet). Plugins are written in AngelScript (.as), a C++-like scripting language. This skill covers everything from project structure to deep API quirks, performance patterns, and debugging.

๐Ÿ’ก Hard-won lessons from building: Grid Explorer & Tracker, Event Calendar, Vehicle Detector, Apeiron Galaxy, Competition Companion.


๐Ÿ—๏ธ Plugin Architecture (callback-style)

Openplanet plugins can be either:

  • Coroutine-style: one void Main() that loops with yield()/sleep(ms)
  • Callback-style: optional Main() + auto-called void Update(float dt) and void Render()

For most overlays, callback-style is simpler and lower-latency โ€” you don't need a Main() at all. Just define Update() and/or Render().

| Callback | When it fires | |---|---| | void Main() | Plugin load. Yieldable coroutine. | | void Update(float dt) | Every frame. dt is delta in milliseconds. | | void Render() | Every frame, even with overlay closed. | | void RenderInterface() | Every frame, only when overlay is open. | | void RenderMenu() | For Openplanet menu items. | | void OnEnabled/OnDisabled/OnDestroyed() | Plugin lifecycle. | | void OnSettingsChanged() | After user changes any [Setting] value. |


๐Ÿ“ Project Layout

๐Ÿ“‚ Folder-based (development) โ€” PREFERRED

Openplanet4/Plugins/<plugin-name>/
โ”œโ”€โ”€ info.toml          # Metadata (required)
โ”œโ”€โ”€ Main.as            # Entry point (required)
โ”œโ”€โ”€ src/               # Optional modules
โ”‚   โ”œโ”€โ”€ core/
โ”‚   โ”œโ”€โ”€ ui/
โ”‚   โ””โ”€โ”€ utils/
โ”œโ”€โ”€ README.md
โ””โ”€โ”€ tests/             # Optional Python test scripts

All .as files in the folder are compiled together as a single module โ€” no manual imports needed.

๐Ÿ—‚๏ธ Filesystem layout

Openplanet4/
โ”œโ”€โ”€ docs/                  # API documentation
โ”œโ”€โ”€ Plugins/               # Runtime plugins (what Openplanet loads)
โ”œโ”€โ”€ Plugins-Developer/     # Source-of-truth development tree
โ”œโ”€โ”€ PluginStorage/         # Per-plugin persistent data (IO::FromStorageFolder)
โ””โ”€โ”€ Openplanet.log         # Debug log โ€” check for compilation errors

When iterating, edit Plugins-Developer/, then copy to Plugins/ for live test.

๐Ÿ“ฆ Packaged (.op) โ€” distribution

.op files are ZIP archives. Do NOT edit them directly โ€” extract, develop as folder, re-zip for release.

Build:

cd path/to/MyPlugin
7z a MyPlugin.zip info.toml Main.as src/
ren MyPlugin.zip MyPlugin.op  # rename to .op

โš™๏ธ info.toml

[meta]
name        = "My Plugin"
author      = "yourname"
version     = "1.0.0"
category    = "Tools"

[script]
timeout         = 0
dependencies    = [ "VehicleState", "Camera" ]   # namespaces your code uses
defines         = []                              # Preprocessor defines for dev

๐Ÿ”— Common namespaces and which plugin owns them

| Namespace/function | Plugin to add to dependencies | |---|---| | VehicleState::ViewingPlayerState() | VehicleState | | Camera::ToScreenSpace(vec3) -> vec2 | Camera | | Camera::IsBehind(vec3) -> bool | Camera | | NadeoServices::AddAudience(...) | NadeoServices | | Dashboard::ViewingPlayerState() | Dashboard (optional) |

โš ๏ธ Symptom of missing dependency: ERR : No matching symbol 'X::Y' at compile time.


๐ŸŽš๏ธ Settings

[Setting name="Display name" description="Tooltip"]
bool S_MySetting = true;

[Setting name="Slider value" min=0 max=100]
int S_Slider = 50;

[Setting hidden]
string S_InternalData = "";

๐Ÿšจ CRITICAL โ€” API Quirks & Pitfalls

๐Ÿ”ค AngelScript Language Quirks

These bite everyone. AngelScript โ‰  C++, โ‰  C#, โ‰  Java.

๐Ÿ”ข Integer literal suffixes

u, l, ul, ull suffixes are not supported.

// โŒ ERR: Unexpected token '<identifier>'
const uint64 MASK = 0x3FFFFFFu;
const int BIG = 1ul;

// โœ… OK: bare literal; compiler picks the type
const uint64 MASK = 0x3FFFFFF;
const int BIG = 1;
const uint64 KEY = uint64(0x123);     // explicit cast

๐Ÿ“ const on value-type arrays

const works for primitives, but NOT for fixed-size arrays of value types like int2[] or vec3[].

// โŒ ERR: Expected '('
const int2 EDGES[12] = { int2(0,1), ... };
const vec3 CORNERS[8] = { ... };

// โœ… OK: dynamic, no const
int2[] g_Edges = { int2(0,1), ... };

Also: const on array<T> is unreliable โ€” keep globals un-const and use a g_ prefix instead.

๐Ÿ“ Fixed-size local arrays

Local fixed-size arrays are not supported:

void Foo() {
    // โŒ ERR: Expected ';' Instead found identifier 'pts'
    vec2 pts[8];
    float depth[8];
}

// โœ… WORKAROUND: individual variables
void Foo() {
    vec2 p0, p1, p2, p3, p4, p5, p6, p7;
    // compiler will register-allocate them
}

Or use dynamic arrays:

vec2[] GetCorners() {
    vec2[] cs = { vec2(0,0), vec2(1,0), ... };
    return cs;
}

๐Ÿ—๏ธ dictionary key types

Most surprising limitation: dictionary only accepts const string&in keys.

dictionary d;

// โŒ ERR: No matching signatures to 'dictionary::Exists(uint64)'
d[uint64(123)] = true;
d[int(456)]    = true;

// โœ… OK: build a string key
d["123"] = true;
d[gx + "," + gy + "," + gz] = true;

If you need a fast non-string key, roll your own hash table (parallel arrays + linear scan, or small open-addressed probe table). For most plugins, string keys are fine.

๐Ÿ”€ uint vs int in comparisons

array<T>.Length returns uint. Comparing a signed int loop counter produces a warning treated as error in strict mode:

// โš ๏ธ WARN: Signed/Unsigned mismatch
for (int e = 0; e < arr.Length; e++) { ... }

// โœ… CLEAN:
for (uint e = 0; e < arr.Length; e++) { ... }

๐Ÿ“ค out parameter naming

Match the parameter name exactly:

// โŒ ERR: No matching symbol 'outDepth'
bool Project(vec3 &in p, vec2 &out screen, float &out depth) {
    outDepth = 0.0f;
}

// โœ… OK:
bool Project(vec3 &in p, vec2 &out screen, float &out depth) {
    depth = 0.0f;
}

๐ŸŽจ int2, vec2, vec3, vec4 constructors

int2 a = int2(1, 2);
vec3 v = vec3(1.0f, 2.0f, 3.0f);
vec4 red = vec4(1.0f, 0.0f, 0.0f, 0.5f);

They are value types โ€” no @ needed for storage.

๐Ÿ”„ &inout on primitive types is NOT allowed

// โŒ ERR:
void ConfigRow(const string &in label, bool &inout value) { }

// โœ… OK:
void ConfigRow(const string &in label, bool value) { }

๐Ÿ“Š Array initialization โ€” inline int t[] = {...} fails inside functions

// โŒ ERR inside functions:
// int t[] = {0, 3, 2, 5, 0, 3, 5, 1, 4, 6, 2, 4};

// โœ… OK: use array<T> with InsertLast
array<int64> items;
items.InsertLast(123);

// โœ… OK: pre-allocate at global scope
int[] g_Array;
void Main() { g_Array.Resize(16); }

// โœ… OK: inline array init works at global scope
int[] monthDays = {31, 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31};

๐Ÿ” string::IndexOf โ€” takes exactly ONE parameter

// โŒ ERR:
int idx = text.IndexOf("[", startPos);

// โœ… OK:
int idx = text.IndexOf("[");
// For offset: use SubStr first
int idx = text.SubStr(startPos).IndexOf("[");

๐Ÿ“ Text::Format takes exactly ONE value argument

// โŒ ERR:
Text::Format("%.6f (%.2f%%)", sidereal, sidereal * 100.0);

// โœ… OK:
Text::Format("%.6f", sidereal) + " (" + Text::Format("%.2f%%", sidereal * 100.0) + ")";

โฐ Time API Quirks

๐Ÿ…ฐ๏ธ Time::Info uses PascalCase, NOT lowercase

'year' is not a member of 'Time::Info'

// โŒ ERR:
info.year, info.month, info.day, info.hour, info.minute, info.second

// โœ… OK:
info.Year, info.Month, info.Day, info.Hour, info.Minute, info.Second

๐Ÿ“… Weekday is NOT a member of Time::Info

info.Weekday will fail. Use Zeller's formula (0=Sun..6=Sat):

int GetDayOfWeek(int y, int m, int d) {
    if (m < 3) { m += 12; y -= 1; }
    int K = y % 100;
    int J = y / 100;
    int h = (d + (13 * (m + 1)) / 5 + K + K / 4 + J / 4 + 5 * J) % 7;
    return (h + 6) % 7; // 0=Sun
}

๐Ÿ• Time functions

int64 now = Time::Stamp;                          // Epoch seconds
uint64 gameTime = Time::Now;                      // ms since game start
string formatted = Time::FormatString("%H:%M", now);  // strftime format
Time::Info info = Time::Parse(now);               // Local time

๐Ÿ–ฅ๏ธ UI API Quirks

๐Ÿ”ค No UI::Font enum โ€” use PushFontSize

// โœ… OK:
UI::PushFontSize(22.0);
UI::Text("Big text");
UI::PopFontSize();

// โŒ ERR (does not exist):
UI::PushFont(UI::Font::OpenSansBold);

๐ŸŽจ No UI::TextColored โ€” use PushStyleColor

// โœ… OK:
UI::PushStyleColor(UI::Col::Text, vec4(0.3f, 1.0f, 0.5f, 1.0f));
UI::Text("Green text");
UI::PopStyleColor();

// โŒ ERR:
UI::TextColored(color, "text");

๐Ÿ“ Window position uses int coords

// โœ… OK (cast floats to int):
UI::SetNextWindowPos(int(posX), int(posY), UI::Cond::Appearing);

๐ŸชŸ UI::Begin takes a bool reference

bool S_WindowOpen = false;
if (!UI::Begin("My Window", S_WindowOpen, UI::WindowFlags::NoSavedSettings)) {
    UI::End();
    return;
}

โŒจ๏ธ UI::InputText โ€” return type vs. bool&out

// โŒ ERR โ€” InputText ALWAYS returns string, can't use in if():
if (UI::InputText("##Input", g_Text, changed, flags)) { }

// โœ… OK:
bool changed = false;
UI::InputText("##Input", g_Text, changed, flags);
if (changed) { /* Enter was pressed */ }

๐Ÿ–Œ๏ธ NanoVG Essentials

The drawing API is per-frame immediate-mode. State persists until changed.

nvg::BeginPath();
nvg::MoveTo(p1);
nvg::LineTo(p2);
nvg::Stroke();          // โ† actually draws the path

nvg::FontSize(13.0f);
nvg::FillColor(vec4(1.0f, 1.0f, 1.0f, 0.9f));
nvg::TextAlign(nvg::Align::Middle | nvg::Align::Center);
nvg::Text(p, "label");
  • nvg::BeginPath() is required for LineTo/Rect/Circle/etc.
  • nvg::Text() does NOT need BeginPath.
  • For minimum state changes, batch draws that share a state.

๐Ÿ“ท Camera API

๐ŸŽฏ Camera::ToScreenSpace returns vec2, not vec3

vec2 screenPos = Camera::ToScreenSpace(worldPos);   // 2D only
// NO z/depth component!

For behind-camera test:

if (Camera::IsBehind(worldPos)) {
    // skip this point
}

โš ๏ธ Don't assume a vec3 overload exists. The vec2 return is the only signature.

๐Ÿš— VehicleState::ViewingPlayerState() returns CSmPlayer

auto state = VehicleState::ViewingPlayerState();
if (state is null) return;       // null when not in a vehicle / not in a map
vec3 pos = state.Position;        // world-space player position

Use is null โ€” AngelScript's null-comparison operator for handles.


๐Ÿงฑ Trackmania Block Dimensions

Standard blocks are 32 ร— 32 ร— 8 (X ร— Z ร— Y) in world units.

const float BLOCK_XZ = 32.0f;
const float BLOCK_Y  = 8.0f;

int gx = int(Math::Floor(worldPos.x / BLOCK_XZ));
int gy = int(Math::Floor(worldPos.y / BLOCK_Y));
int gz = int(Math::Floor(worldPos.z / BLOCK_XZ));

โšก Performance Patterns

๐ŸŽจ Minimize NVG state changes

Batch draws by state โ€” the single biggest FPS win:

// โŒ BAD: stroke state changes per block
for (each block) {
    nvg::StrokeColor(...);
    nvg::StrokeWidth(...);
    DrawEdgesOfBlock(...);
}

// โœ… GOOD: sort by state, then batch
nvg::StrokeColor(green);  // once per "visited" pass
for (each visited block) DrawEdgesOfBlock(...);
nvg::StrokeColor(red);    // once per "unvisited" pass
for (each unvisited block) DrawEdgesOfBlock(...);

State-change guard:

bool g_LastDrewVisited = false;
void ApplyStroke(bool visited) {
    if (visited == g_LastDrewVisited) return;
    g_LastDrewVisited = visited;
    nvg::StrokeColor(visited ? green : red);
    nvg::StrokeWidth(visited ? 3.0f : 2.0f);
}

๐Ÿง  Don't recompute in Render()

Anything that can be computed in Update() and cached as a global is one less per-frame allocation.

๐Ÿ‘๏ธ Spatial culling

For 3D grids:

  1. Per-block AABB cull: project all 8 corners; if 0 in front of camera, skip block
  2. Edge-level cull: only draw edges whose two endpoints were both visible

๐Ÿ”ค Avoid string concatenation in hot loops

"a" + "b" + "c" allocates 3 new strings. For cache keys hit thousands of times per frame, cache the per-frame key. For 4โ€“8 char keys, the cost is acceptable โ€” the lookup hash dominates.


๐Ÿ“Š Diagnostic UI

The UI::* namespace is immediate-mode (like Dear ImGui):

UI::SetNextWindowSize(width, height, UI::Cond::FirstUseEver);
if (UI::Begin("My Diagnostics")) {
    UI::Text("Static label: " + value);
    if (UI::Button("Reset")) {
        // handle click
    }
    UI::Separator();
}
UI::End();

Window titles with icons: UI::Begin(Icons::Eye + " Diagnostics")

The Icons:: namespace has 600+ Unicode glyphs (Icons::Eye, Icons::Cog, Icons::Trash, Icons::Clock, Icons::Car, Icons::Info, Icons::Calendar, Icons::Star, Icons::QuestionCircle, etc.).

๐Ÿ—‚๏ธ Config & Debug Window Pattern

void RenderDebugWindow() {
    UI::SetNextWindowSize(580, 520, UI::Cond::FirstUseEver);
    if (!UI::Begin("Config", g_ShowDebugWindow)) { UI::End(); return; }
    UI::BeginTabBar("Tabs");
    if (UI::BeginTabItem("Config")) { RenderConfigTab(); UI::EndTabItem(); }
    if (UI::BeginTabItem("Status")) { RenderStatusTab(); UI::EndTabItem(); }
    if (UI::BeginTabItem("History")) { RenderHistoryTab(); UI::EndTabItem(); }
    UI::EndTabBar(); UI::End();
}

| Tab | Content | |---|---| | โš™๏ธ Config | All [Setting] toggles in a table with ON/OFF | | ๐Ÿ“Š Status | Live values, cache queues, computed data | | ๐Ÿ“œ History | Data tables, map visits, recorded events | | ๐Ÿ“‹ Reference | Static reference data (nodes, calibration tables) |


๐ŸŒ™ Lunar Calendar & Date Conversion

๐Ÿ“… Gregorian โ†” Day of Year

// Day of year from Gregorian (1-based)
int GetDayOfYear(int year, int month, int day) {
    int[] daysBefore = {0, 0, 31, 59, 90, 120, 151, 181, 212, 243, 273, 304, 334};
    int doy = daysBefore[month] + day;
    if (month > 2 && IsLeapYear(year)) doy++;
    return doy;
}

// Day of year back to Gregorian
void DayOfYearToGregorian(int year, int dayOfYear, int &out month, int &out day) {
    int[] mdays = {31, 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31};
    if (IsLeapYear(year)) mdays[1] = 29;
    month = 1;
    int remaining = dayOfYear;
    for (int i = 0; i < 12; i++) {
        if (remaining <= mdays[i]) { month = i + 1; day = remaining; return; }
        remaining -= mdays[i];
    }
    month = 12; day = 31;
}

// Unix timestamp from Gregorian date (midnight UTC)
uint64 UnixFromGregorian(int year, int month, int day) {
    Time::Info info;
    info.Year = year;
    info.Month = month;
    info.Day = day;
    info.Hour = 0;
    info.Minute = 0;
    info.Second = 0;
    return Time::Unix(info);
}

// Gregorian from Unix timestamp
void GetGregorianFromUnix(uint64 unixTime, int &out year, int &out month, int &out day) {
    Time::Info info = Time::Parse(unixTime);
    year = info.Year;
    month = info.Month;
    day = info.Day;
}

bool IsLeapYear(int year) {
    return (year % 4 == 0 && year % 100 != 0) || (year % 400 == 0);
}

๐ŸŒ“ Moon phase (synodic ~29.53 days)

// Returns 0.0 (new moon) to 1.0 (next new moon)
float GetMoonPhase(uint64 unixTime) {
    // Known new moon: 2000-01-06 18:14 UTC = 947182440
    const float SYNODIC = 29.53058867;
    double daysSince = double(unixTime - 947182440) / 86400.0;
    return float(fmod(daysSince, SYNODIC) / SYNODIC);
}

// Moon texture for current phase (requires Moon plugin textures)
auto@ GetMoonTexture(uint64 unixTime) {
    float phase = GetMoonPhase(unixTime);
    // 0.0=new, 0.25=first quarter, 0.5=full, 0.75=last quarter
    // Map to your texture array index
    int idx = int(phase * 8.0) % 8;
    return Moon::GetTexture(idx);
}

๐Ÿ“† Calendar Day Indicators

๐ŸŸข Event dots โ€” mark days that have events

bool[] g_DaysWithEvents;

void Main() {
    g_DaysWithEvents.Resize(31);
    while (true) {
        RebuildEventDays();
        yield();
    }
}

void RebuildEventDays() {
    for (int i = 0; i < 31; i++) g_DaysWithEvents[i] = false;
    Time::Info now = Time::Parse(Time::Stamp);
    int curDOW = GetDayOfWeek(now.Year, now.Month, now.Day);
    // Convert Sun=0 to Mon=1..Sun=7 convention if needed
    curDOW = (curDOW == 0) ? 7 : curDOW;
    int curDay = now.Day;
    for (int i = 0; i < g_EventCount; i++) {
        int diff = g_WeekDay[i] - curDOW;
        int eventDay = curDay + diff;
        if (eventDay >= 1 && eventDay <= 31) g_DaysWithEvents[eventDay - 1] = true;
    }
}

In your calendar draw loop:

// Highlight days with event dots
if (g_DaysWithEvents[day - 1])
    UI::PushStyleColor(UI::Col::Button, vec4(0.4f, 0.8f, 0.4f, 0.25f));
UI::Button(dayStr);
if (g_DaysWithEvents[day - 1])
    UI::PopStyleColor();

๐Ÿ“Š Inline data in cells โ€” show computed value without hover

// In each calendar cell, after drawing the day number button:
UI::TextDisabled(Text::Format("%.0f%%", progress * 100.0));  // e.g. "23%" = 23%

// Moon phase icon alongside:
auto@ tex = GetMoonTexture(Time::Stamp);
if (tex !is null) {
    UI::SameLine();
    UI::Image(tex, vec2(14, 14));
}

โฑ๏ธ Upcoming Events Countdown

array<int64> eTs; array<string> eLabel;

void BuildUpcomingList() {
    eTs.Resize(0);
    eLabel.Resize(0);
    int64 now = Time::Stamp;
    for (int i = 0; i < g_Count; i++) {
        int64 ets = GetNextEventTs(g_WeekDay[i], g_Hour[i], g_Min[i]);
        if (ets > now) {
            eTs.InsertLast(ets);
            eLabel.InsertLast(g_Label[i]);
        }
    }
    // Bubble sort by timestamp (ascending)
    for (uint i = 0; i < eTs.Length - 1; i++) {
        for (uint j = 0; j < eTs.Length - 1 - i; j++) {
            if (eTs[j] > eTs[j + 1]) {
                int64 tmpT = eTs[j]; eTs[j] = eTs[j + 1]; eTs[j + 1] = tmpT;
                string tmpL = eLabel[j]; eLabel[j] = eLabel[j + 1]; eLabel[j + 1] = tmpL;
            }
        }
    }
}

// Display first 5 in UI:
void RenderUpcoming() {
    BuildUpcomingList();
    uint count = eTs.Length;
    if (count > 5) count = 5;
    for (uint i = 0; i < count; i++) {
        int64 ago = eTs[i] - Time::Stamp;
        string countdown = FormatCountdown(ago);
        UI::Text(eLabel[i] + " โ€” " + countdown);
    }
}

string FormatCountdown(int64 seconds) {
    int h = int(seconds / 3600);
    int m = int((seconds % 3600) / 60);
    return Text::Format("%dh %dm", h, m);
}

๐Ÿ” Recurring Events Pattern

const int MAX_EVENTS = 16;
int g_Count = 0;
int[] g_WeekDay; int[] g_Hour; int[] g_Min; string[] g_Label;

void AddEvent(int d, int h, int m, const string &in l) {
    if (g_Count >= MAX_EVENTS) return;
    g_WeekDay[g_Count] = d; g_Hour[g_Count] = h;
    g_Min[g_Count] = m; g_Label[g_Count] = l; g_Count++;
}

โฑ๏ธ Upcoming events with countdown:

array<int64> eTs; array<string> eLabel;
for (int i = 0; i < g_EventCount; i++) {
    int64 ets = GetNextEventTs(g_WeekDay[i], g_Hour[i], g_Min[i]);
    if (ets > now) { eTs.InsertLast(ets); eLabel.InsertLast(g_Label[i]); }
}
// Bubble sort, display first 5

๐Ÿ”€ Preprocessor Directives

#if TMNEXT
    // Trackmania (2020) only
#elif MP4
    // Maniaplanet 4 only
#endif

๐Ÿ› Common Build/Compile Errors

| Error message | Cause | Fix | |---|---|---| | ERR : No matching symbol 'X::Y' at function call | Missing dependencies in info.toml | Add the owning plugin to [script].dependencies | | ERR : Unexpected token '<identifier>' after numeric literal | Integer suffix (u, l, etc.) not supported | Drop suffix or use uint64(x) cast | | ERR : Expected '(' Instead found '[' on const TYPE name[N] | const on fixed-size value-type array | Use TYPE[] name = { ... }; (dynamic, no const) | | ERR : Expected ';' Instead found identifier 'pts' on vec2 pts[8]; | Local fixed-size array not supported | Use individual variables | | ERR : No matching signatures to 'dictionary::Exists(uint64)' | Dictionary only takes string keys | Convert to string key | | WARN : Signed/Unsigned mismatch in for loop | int i vs uint Length | Use uint i | | ERR : Can't implicitly convert from 'vec2' to 'vec3' | Camera::ToScreenSpace returns vec2 | Use vec2; test Camera::IsBehind for cull | | 'year' is not a member of 'Time::Info' | Wrong case on member | Use PascalCase: info.Year, info.Month, info.Day, etc. | | 'Weekday' is not a member of 'Time::Info' | Weekday doesn't exist on Time::Info | Use Zeller's formula (see Time API section) | | No matching symbol 'UI::Font::...' | Font enum doesn't exist | Use PushFontSize/PopFontSize | | No matching symbol 'UI::TextColored' | Function doesn't exist | Use PushStyleColor(UI::Col::Text, ...) | | Float value truncated in implicit conversion | float where int expected | Cast: int(value) | | ERR : No matching symbol 'outDepth' | out param name mismatch | Match parameter name exactly | | ERR : Can't implicitly convert from 'string' to 'bool' | UI::InputText return in if() | Call separately, check changed bool after | | ERR on '&inout' with primitive | &inout not allowed on primitives | Pass by value for reads | | ERR on 'IndexOf' with 2 args | string::IndexOf takes 1 param | Use SubStr first for offset | | ERR on 'Text::Format' with 2 values | Text::Format takes 1 value arg | Chain multiple Text::Format calls |


๐Ÿ”ง Debugging Tips

  • ๐Ÿ“ F3 โ†’ Log โ€” print("hello") lands here
  • ๐Ÿ”„ Reload scripts after every save via F3 โ†’ Developer โ†’ Reload Scripts โ€” no restart needed
  • ๐Ÿ“‹ F3 โ†’ Developer โ†’ Plugin Manager โ€” shows load order and compile errors
  • ๐Ÿ” Nod Explorer (F3 โ†’ Developer โ†’ Nod Explorer) โ€” browse live CGameCtnApp tree
  • ๐Ÿ“„ Openplanet.log (%USERPROFILE%\OpenplanetNext\Openplanet.log) โ€” stack traces for runtime crashes

๐Ÿš€ Quick-Start Template

# info.toml
[meta]
name        = "My Plugin"
author      = "Me"
category    = "Tools"
version     = "1.0.0"

[script]
timeout         = 0
dependencies    = [ "VehicleState" ]
// main.as
[Setting name="Enabled" category="General"]
bool S_Enabled = true;

void Update(float dt) {
    if (!S_Enabled) return;
    auto state = VehicleState::ViewingPlayerState();
    if (state is null) return;
    // ... per-frame work ...
}

void Render() {
    if (!S_Enabled) return;
    if (UI::Begin(Icons::Cog + " My Plugin")) {
        UI::Text("Hello world");
    }
    UI::End();
}

๐Ÿงน Cleanup When Removing a Feature

Check EVERY .as file:

grep -rn "DeletedName" Plugins/<name>/

๐Ÿ“š Reference

Full API reference: ๐ŸŒ https://openplanet.dev/docs

Hermes skills repo reference files: ๐Ÿ“Ž https://github.com/tomekdot/hermes-skills/tree/main/skills/software-development/openplanet-plugin-dev/references


Last updated: 2026-06-12. Covers AngelScript build as of OpenplanetNext 2026.