This guide explains how to write C gameplay scripts for Nebula so they run correctly in:
- Windows editor/runtime (DLL path), and
- Dreamcast runtime (compiled into ELF/CDI).
- Script files are plain C source files (
.c). - Recommended location:
Scripts/(project) and/orbuild_dreamcast/scripts/(staged build). - Keep one main gameplay owner script per build unless you intentionally split logic across multiple files.
Example header skeleton:
#include <math.h>
#include <stdio.h>
#if defined(__DREAMCAST__)
#include <kos/dbgio.h>
#include "KosInput.h"
#elif defined(_WIN32)
#define WIN32_LEAN_AND_MEAN
#include <windows.h>
#endif
#if defined(_WIN32) && !defined(__DREAMCAST__)
#define NB_SCRIPT_EXPORT __declspec(dllexport)
#else
#define NB_SCRIPT_EXPORT
#endifThese functions are your script entry points:
NB_SCRIPT_EXPORT void NB_Game_OnStart(void);
NB_SCRIPT_EXPORT void NB_Game_OnUpdate(float dt);
NB_SCRIPT_EXPORT void NB_Game_OnSceneSwitch(const char* sceneName);- OnStart: called once after runtime init.
- OnUpdate(dt): called every frame;
dtis frame delta seconds. - OnSceneSwitch(name): called when runtime changes scene.
If your script does not export a hook, weak fallback hooks may run instead (no-op behavior).
Declare the runtime bridge symbols in script:
void NB_RT_GetNode3DPosition(const char* name, float outPos[3]);
void NB_RT_SetNode3DPosition(const char* name, float x, float y, float z);
void NB_RT_GetNode3DRotation(const char* name, float outRot[3]);
void NB_RT_SetNode3DRotation(const char* name, float x, float y, float z);
void NB_RT_GetCameraOrbit(const char* name, float outOrbit[3]);
void NB_RT_SetCameraOrbit(const char* name, float x, float y, float z);
void NB_RT_GetCameraRotation(const char* name, float outRot[3]);
void NB_RT_SetCameraRotation(const char* name, float x, float y, float z);
void NB_RT_GetCameraWorldForward(const char* name, float outFwd[3]);
void NB_RT_NextScene(void);
void NB_RT_PrevScene(void);
void NB_RT_SwitchScene(const char* name);Scene switching: NB_RT_NextScene() and NB_RT_PrevScene() cycle through loaded scenes. NB_RT_SwitchScene("MyLevel2") jumps directly to a scene by name (case-insensitive). All three are deferred to end of frame — navmesh is automatically rebuilt and NB_Game_OnSceneSwitch is called. Use debounce logic for Next/Prev to avoid rapid cycling (see Control4.c for an example).
Editor vs Dreamcast: In the editor,
SwitchSceneonly switches between currently open scene tabs. On Dreamcast, all exported scenes are available from disc. If testing scene triggers in the editor, make sure the target scene is open as a tab.
Trigger zones: Use NB_RT_CheckAABBOverlap + NB_RT_SwitchScene to trigger scene switches when the player walks into a Node3D's bounds. No collision flags needed on the trigger node — CheckAABBOverlap is a pure geometry test using each node's bounds scaled by its scale. See Dreamcast_Binding_API.md for a full trigger zone script example.
Collision / physics declarations (optional — only needed if your script queries physics state):
void NB_RT_GetNode3DCollisionBounds(const char* name, float outExtents[3]);
void NB_RT_SetNode3DCollisionBounds(const char* name, float ex, float ey, float ez);
void NB_RT_GetNode3DBoundPos(const char* name, float outPos[3]);
void NB_RT_SetNode3DBoundPos(const char* name, float bx, float by, float bz);
int NB_RT_GetNode3DPhysicsEnabled(const char* name);
void NB_RT_SetNode3DPhysicsEnabled(const char* name, int enabled);
int NB_RT_GetNode3DCollisionSource(const char* name);
void NB_RT_SetNode3DCollisionSource(const char* name, int enabled);
int NB_RT_GetNode3DSimpleCollision(const char* name);
void NB_RT_SetNode3DSimpleCollision(const char* name, int enabled);
float NB_RT_GetNode3DVelocityY(const char* name);
void NB_RT_SetNode3DVelocityY(const char* name, float vy);
int NB_RT_IsNode3DOnFloor(const char* name);
int NB_RT_CheckAABBOverlap(const char* name1, const char* name2);
int NB_RT_RaycastDown(float x, float y, float z, float* outHitY);
int NB_RT_RaycastDownWithNormal(float x, float y, float z, float* outHitY, float outNormal[3]);Note on wall collision and Node3D-vs-Node3D push-apart: These are engine-level physics that run automatically each frame — scripts do not need to call anything. Wall collision pushes Node3Ds out of StaticMesh3D geometry horizontally. Node3D-vs-Node3D collision pushes overlapping physics nodes apart. Both are configured via editor properties (Collision Source, Simple Collision, Physics Enabled, and Wall Threshold on StaticMesh3D). See Dreamcast_Binding_API.md for full details.
namemust match scene node names (e.g."PlayerRoot","Camera3D1","AINode"). Matching is case-insensitive.- Each script should define its target node name as a constant and pass it to all
NB_RT_*calls:static const char* PLAYER_NODE = "PlayerRoot"; NB_RT_GetNode3DPosition(PLAYER_NODE, pos); NB_RT_SetNode3DPosition(PLAYER_NODE, pos[0], pos[1], pos[2]);
- On Dreamcast, the runtime maintains a name-indexed table of all Node3D nodes. The player's parent Node3D routes to the player globals (physics, rendering, camera). All other names route to independent per-node data in the table.
- If
namedoesn't match any Node3D in the scene, get functions return zeros and set functions are no-ops.
Use GetAsyncKeyState for quick polling:
if (GetAsyncKeyState('W') & 0x8000) { /* pressed */ }Use KosInput wrappers (recommended), not raw Maple bit logic:
if (NB_KOS_HasController()) {
if (NB_KOS_ButtonDown(NB_BTN_DPAD_UP)) { /* pressed */ }
float ax = NB_KOS_GetStickX();
float ay = NB_KOS_GetStickY();
}Why: wrappers normalize button semantics and avoid active-low/raw-state mistakes.
Note:
NB_KOS_PollInput()is called once per frame by the DC runtime beforeNB_Game_OnUpdate(dt). Scripts do not need to call it themselves — just use the query functions (NB_KOS_HasController,NB_KOS_GetStickX,NB_KOS_ButtonDown, etc.) directly.
The generated Dreamcast Makefile defines -D__DREAMCAST__ in CFLAGS. Scripts should use this to guard platform-specific input:
#if defined(__DREAMCAST__)
// DC input: NB_KOS_HasController(), NB_KOS_GetStickX(), etc.
#elif defined(_WIN32)
// Editor input: GetAsyncKeyState(), etc.
#endifImportant: The DC cross-compiler (sh-elf-gcc) does not define _WIN32 or __DREAMCAST__ on its own — the engine's generated Makefile provides -D__DREAMCAST__. If neither macro is defined, both input blocks are skipped and the script receives zero input every frame (compiles without errors but produces no movement). This is the most common cause of "script works in editor but does nothing on hardware."
- Includes
- Platform macros/export macro
- Runtime bridge declarations
- Constants/tunables
- Utility helpers (
Clamp, angle wrap, etc.) - Hook implementations (
OnStart,OnUpdate,OnSceneSwitch)
- Constants:
UPPER_SNAKE_CASE - Static locals/helpers:
lowerCamelCaseorPascalCaseconsistently - Node labels:
static const char* PLAYER_NODE = "PlayerRoot";
- Guard tiny denominators with epsilon (
EPS) before divide/normalize. - Clamp pitch/angles/speeds to avoid exploding transforms.
- If
dt <= 0, set fallbackdt = 1.0f / 60.0f.
- Windows:
printf - Dreamcast:
dbgio_printf - Throttle logs in
OnUpdate(e.g. once per 30–60 frames).
- Missing
NB_SCRIPT_EXPORTon hooks (Windows symbol not exported). - C++ syntax in
.cfiles (//is fine, but no templates/classes). - Using unavailable headers per platform branch.
- Forgetting to include
KosInput.hwhen usingNB_KOS_*. - Defining duplicate global symbols across multiple script files.
-
Script compiles but no gameplay movement:
- Hook not linked (script object not in Makefile)
- Hook overridden by fallback (link/symbol issue)
- Wrong node names passed to
NB_RT_* - Input read path wrong for target platform
-
Works in editor, fails on Dreamcast:
__DREAMCAST__not defined — DC input block skipped silently (most common cause)- Windows-only input path accidentally used
- Build is compiling stale script copy
- Dreamcast Makefile not including script file(s)
NB_SCRIPT_EXPORT void NB_Game_OnUpdate(float dt)
{
if (dt <= 0.0f) dt = 1.0f / 60.0f;
float inX = 0.0f, inY = 0.0f;
#if defined(_WIN32) && !defined(__DREAMCAST__)
if (GetAsyncKeyState('A') & 0x8000) inX -= 1.0f;
if (GetAsyncKeyState('D') & 0x8000) inX += 1.0f;
#elif defined(__DREAMCAST__)
if (NB_KOS_HasController()) {
if (NB_KOS_ButtonDown(NB_BTN_DPAD_LEFT)) inX -= 1.0f;
if (NB_KOS_ButtonDown(NB_BTN_DPAD_RIGHT)) inX += 1.0f;
}
#endif
float pos[3] = {0,0,0};
NB_RT_GetNode3DPosition("PlayerRoot", pos);
pos[0] += inX * 5.0f * dt;
NB_RT_SetNode3DPosition("PlayerRoot", pos[0], pos[1], pos[2]);
}Multiple scripts can run simultaneously, each controlling different Node3D nodes by name:
// Control4.c — assigned to PlayerRoot's child mesh
static const char* PLAYER_NODE = "PlayerRoot";
NB_RT_SetNode3DPosition(PLAYER_NODE, x, y, z);
// airoam.c — assigned to AINode's child mesh
static const char* AI_NODE = "AINode";
NB_RT_SetNode3DPosition(AI_NODE, x, y, z);Each script defines NB_Game_OnStart, NB_Game_OnUpdate, and NB_Game_OnSceneSwitch. On Windows, each unique script compiles to its own DLL. On Dreamcast, the export pipeline auto-renames hooks to avoid symbol collisions (see docs/Multi_Script_Runtime.md).
Key rules:
- Each script controls nodes by name string — it does not automatically control the Node3D it's assigned to. The
scriptfield on a Node3D just tells the engine to load that script. - Multiple scripts can read the same node. A single script can control multiple nodes.
- Scripts share the same navmesh and raycast data —
NB_RT_NavMeshBuild()only needs to be called once (by any script).
- Hook functions exported with
NB_SCRIPT_EXPORT - Node names match scene exactly
- Dreamcast path uses
NB_KOS_*wrappers inside#if defined(__DREAMCAST__) - Generated Makefile includes
-D__DREAMCAST__in CFLAGS - Script file included in Dreamcast
SOURCES - Build uses updated script copy (not stale staged copy)
- Runtime logging confirms
OnStartandOnUpdateare executing