- Overview
- Core Concepts
- Platform Support & Dependencies
- Testing
- Languages
- Contributing
- Learn More
- License
Nebulite is a modern C++26 2D game engine with flexible data-driven logic and a powerful expression and ruleset system.
Gameplay logic can be defined in two complementary ways:
- JSON rulesets — declarative, data-driven, and ideal for experimentation
- Native C++ rulesets — hardcoded for maximum performance and full control
Both approaches share the same runtime pipeline and context model, allowing you to freely mix data-defined and hardcoded behavior within the same project.
-
Modular Architecture
- Domains encapsulate engine subsystems (rendering, global state, entities, etc.)
- DomainModules provide reusable, isolated functionality (time, console, ruleset management, etc.)
- Hierarchical domain composition with shared capabilities (Comparable to C++ class inheritance)
-
Scripting & Automation
.nebstask files for scripted execution- Interactive runtime console
- Headless mode for testing and automation
- Chainable CLI command execution
- easily extendable python testing suite
-
Scoped Data & Access Control
- JSON-backed data model
- No hardcoded variables, you may define your own keys and data structure as you see fit
- Custom key scopes for controlled data access for encapsulated logic and modularity
-
Context-Driven Interaction
- self / other / global context model, with flexible combinations (all and local)
- Event broadcasting between objects or just per-object logic handling
- Clone & enter repo
git clone https://github.com/lbastigk/Nebulite && cd NebuliteThen, run the following commands from the repo root:
- Install dependencies, if necessary
make install-depsThis will install all necessary dependencies for Linux via the package manager and git submodules, as well as a python environment for testing and mock asset creation. apt, dnf, yum and brew are supported.
See the makefile for details on the installation process and the dependencies installed for each platform.
The installation process, especially for package managers other than dnf, is not fully tested on all platforms, so if you notice any issues with the installation process, please tell!
- Build binaries
Windows and MacOs support is currently in early stages. We're actively looking for contributors to help with testing and improving the build process on these platforms. Any help is appreciated!
# Linux
make linux-debug linux-release linux-coverage # Mac
make macos-debug macos-release # Windows
make windows-debug windows-release- Download and generate Resources
make resources- Add the completion scripts to your shell
source Scripts/Other/nebuliteCompletion.sh # for bashThis is optional but allows for autocompletion of commands in bash. The interactive console inside Nebulite also supports autocompletion.
- Run any script:
./bin/Nebulite task TaskFiles/Benchmarks/gravity_unlimited.nebs You should see a window pop up with a small gravity simulation running indefinitely.
The general directory structure is as follows:
- Documentation is located in
./doc/ - Assets like images, rulesets, RenderObjects are located in
./Resources/. - TaskFiles for various Simulations, Tests and Demos are located in
./TaskFiles/. - Tests are located in
./Tools/Tests/ - The main engine code is located in
./include/and./src/, with the entry point in./src/main.cpp.
- Open console (press
^) and typehelpfor interactive commands available for the GlobalSpace domain.
Due to locale settings, some users may need to use a different key for opening the console, such as ° or the backtick `.
See Glossary.md for further definitions of key terms and concepts.
Domains are modular engine components encapsulating specific functionality such as:
- A renderable entity (
RenderObjectDomain) - Global engine state (
GlobalSpaceDomain) - The rendering system (
RendererDomain)
Each Domain can initialize DomainModules, which further break down functionality into manageable pieces, providing update routines and functions for users to call:
- Time management for
GlobalSpaceDomain - Basic setting and array manipulation via
CommonDomainModule (available to all Domains) - Console functionality for
RendererDomainModule
Each Domain is able to parse string commands that call the respective DomainModules' functions. These commands may be called by Parent-Domains, the interactive console, or Task Files:
# Example commands, parsed in the GlobalSpace domain
set myVar 5 # Common function, available in all domains
set-fps 60 # As GlobalSpace is a parent domain of Renderer, it is able to call its functions
always eval echo {myVar}
always eval set myVar $({myVar} + 1)
wait 100
exitSee Commands.md for a full list of available commands for both GlobalSpace and RenderObject domains, including the inherited ones.
Domains may be arranged in a hierarchy, where parent Domains can contain child Domains (Domain-inheritance). This allows for shared functionality and data between related Domains.
Child Domains share their functions with their parent Domains, allowing for seamless integration and interaction between different parts of the engine. This way, you may parse commands in a parent Domain that are actually implemented in a child Domain.
Child domains can have access to their parent's full Document Scope or only to a sub-scope, depending on the use case.
Users may define custom scopes for key access restrictions and modular data management. This allows key sharing inbetween classes and throw exceptions if access is attempted outside the intended scope.
Example for restricted key access:
#include "Nebulite.hpp"
#include "Data/Document/JsonScope.hpp"
inline void exampleUsage() {
// Our Data document
Nebulite::Data::JSON doc;
// Creating a JsonScope that acts on the "physics." sub-scope
Nebulite::Data::JsonScope physicsScope(doc, "physics.");
// Using ScopedKey to access data safely
Nebulite::Data::ScopedKey const velocityKey("physics.", "velocity");
Nebulite::Data::ScopedKey const timeInMsKey("time.","t_ms");
// Set values
physicsScope.set<int>(velocityKey, 5.0);
// Now we can get values using ScopedKey
auto const velocity = physicsScope.get<double>(velocityKey).value_or(0.0); // Success
auto const timeInMs = physicsScope.get<double>(timeInMsKey).value_or(0.0); // Throws exception
Log::println("Velocity: ", velocity);
Log::println("Time in ms: ", timeInMs);
}Keys are either Defined in a DomainModule or for a specific Domain. Any runtime user-input is assumed to be at the root of the current scope, so it never throws an exception on access. The Intent is that the access restrictions apply to the hardcoded logic to enforce encapsulation, so you can safely assume that any DomainModule with a defined scope:
- accesses the correct member (if you always access based on the root of the provided scope, this is not guaranteed)
- throws an exception if the member is accessed outside its intended scope
Access and manipulate data using variables {...} inside and outside mathematical expressions $(...):
Variable Contexts:
| Variable | Description |
|---|---|
{self:*} |
the object broadcasting logic |
{other:*} |
the object listening to the broadcast |
{global:*} |
shared engine state |
{local:*} |
combination of self and other contexts |
{all:*} |
combination of all contexts |
{file.json:key.path} |
external read-only JSON files |
{global:{self:id}} |
nested resolution (multiresolve). |
{self:arr|length} |
return value transformations |
Mathematical Expressions:
| Example | Description |
|---|---|
$(1 + 2 * {self:mass}) |
arithmetic with variables |
$(gt({self:hp}, 0)) |
logical operations (gt, lt, eq, and, or, not) |
$i(3.14) |
cast to integer |
$04i(3.14) |
cast to integer with formatting |
$10.5f({self:var}) |
cast to float with formatting |
retrieved values from JSON documents inside mathematical expressions are auto-casted to double. Expressions do not offer the ability to operate on non-double values (strings, arrays, objects). If the stored value is non-numerical, the expression system tries to cast it to double, which results in 0.0 for non-castable values
If the expression is a single variable access, assignments respect the variable's type and insert them as such, giving you the ability to store complex types like arrays and objects via expressions.
Return Value Transformations: Nebulite offers transformation functions of JSON values on retrieval. They do not modify the stored value, only the returned one. Examples:
{self:arr|length}- get array length instead of the array{self:arr|map <function>}- apply function to each array element{self:val|add 5}- add 5 to value on retrieval{self:val|typeAsString}- returns the type of the value as string (value, array, object, null){self:arr|print|at 1}- Useful for debugging: prints the array to console (no modification of its value) and returns the element at index 1{self:str|transform1|transform2|...}- chain multiple transformations{|transform1|transform2|...}- start with empty value and apply transformations
See Commands.md for a full list of available transformations.
Usage Examples
Health percentage:
$3i( {self:health} / max(1,{self:maxHealth}) * 100 )%
Inventory size:
The player has {self:inventory|length} items.
Sum of two inventories:
You will have $i({self:inventory|length} + {other:inventory|length}) items after trade.
Define object interactions via JSON rulesets:
or via static rulesets in C++ code:
// Called with keyword "::physics:gravity" (both broadcast and listen)
void Physics::gravity(Interaction::Context const& context, double**& slf, double**& otr) const {
double const dx = baseVal(slf, Key::posX) - baseVal(otr, Key::posX);
double const dy = baseVal(slf, Key::posY) - baseVal(otr, Key::posY);
double const r2 = dx*dx + dy*dy + 1.0; // softening
double const invR = 1.0 / std::sqrt(r2);
double const invR3 = invR * invR * invR;
double const G = *globalVal.G;
double const m1 = baseVal(slf, Key::physics_mass);
double const m2 = baseVal(otr, Key::physics_mass);
double const coeff = G * m1 * m2 * invR3;
auto otrLock = context.other.lockDocument();
baseVal(otr, Key::physics_FX) += dx * coeff;
baseVal(otr, Key::physics_FY) += dy * coeff;
}Rulesets are either local (no broadcasting) or global. This allows for inter-object communication via broadcasting for
- hitbox collisions
- area triggers
- custom events
and inner-object logic handling for
- applying forces
- state changes
- animations
Global rulesets are executed first, followed by local rulesets.
The expressions given are evaluated in the context of
- the broadcasting object (
self) - the listening object (
other) - the global engine state (
global)
Note that the vector exprs holds assignments as
<context>.<key> <assignment-operator> <expression>,
which modify values in their respective JSON documents.
Currently, all assignment operators support only numerical or string values.
Complex types (arrays, objects) cannot be assigned or modified via expressions,
only overwritten by numerical or string literals.
Supported assignment operators:
=: direct assignment (cast to either double or string, depending on expression)+=: addition assignment (cast to double)*=: multiplication assignment (cast to double)|=: Concatenation assignment (cast to string)
The function calls are parsed and executed in their respective context after expression evaluation.
Nebulite includes two GUI libraries:
- ImGui for in-game debug interfaces and tools
- RmlUI for more complex, styled interfaces (menus, HUDs, etc.)
The RmlUi implementation contains custom plugins to allow for seamless integration with Nebulite's data model and expression system, as well as Reflection capabilities for iterating over JSON data in the UI. Instead of using the default RmlUi syntax for data binding and event handling, use the standard Nebulite expression syntax. The context of each document is determined by the context of the caller:
- If you call the document from a RenderObject, the self context is set to that object,
- If you call it from GlobalSpace, every context is set to global.
- If the document is called from an object interaction, the self and other context are set to the respective objects.
Examples:
<rml>
<head>
<title>RmlUi demo</title>
<link type="text/rcss" href="./external/RmlUi/Samples/assets/rml.rcss"/>
<link type="text/rcss" href="./Resources/Rml/window.rcss"/>
<link type="text/rcss" href="./Resources/Rml/text.rcss"/>
</head>
<!-- one data model for all RmlUi documents, the proper Nebulite Context is handled automatically -->
<body data-model="nebuliteDataSync">
<h1>RmlUi</h1>
<!-- Set actions to do on destruction -->
<!-- Any on-destroy is activated when the element is removed from the document -->
<!-- Or, if the document itself is removed. -->
<div on-destroy-invoke-ruleset="Resources/Rulesets/Debug/helloWorld.jsonc"></div>
<div on-destroy-parse="set UI_DELETED 1 ; assert $(eq({self:UI_DELETED},1))"></div>
<!-- Toggle between bold and italic every second -->
<p>
<b data-if="global:time.t|toInt|add 0|mod 2">Hello world!</b>
<i data-if="global:time.t|toInt|add 1|mod 2">Hello world!</i>
</p>
<hr />
<h2>Expression</h2>
<!-- data-eval="true" allows for expression evaluation inside the tag -->
<p data-eval="true">
<!-- Example expression of a time code: HH:MM:SS:FF at 24 fps -->
$02i({global:time.t|div 60|div 60|mod 60}):<!--
-->$02i({global:time.t|div 60|mod 60}):<!--
-->$02i({global:time.t|mod 60}):<!--
-->$02i({global:time.t|mul 24|mod 24})
</p>
<hr />
<h2>Data input</h2>
<!-- Show input -->
<p data-eval="true">
The quick brown fox jumps over the lazy {self:rml.input.animal}.
</p>
<!-- Set input, if self:rml.settings.showInput is true -->
<p data-if="self:rml.settings.showInput">
<input type="text" data-value="self:rml.input.animal"/>
</p>
<hr />
<h2>Data-Reflect</h2>
<p data-eval="true">
global:time has {global:time|listMembers|length} members:
</p>
<!-- data-reflect allows for iterating over all array indices -->
<!-- You can use JSON-Transformation to turn objects into arrays for propper reflection -->
<!-- Here we use listMembersAndValues to get an array of objects -->
<!-- with key and value members for each entry in the original object -->
<!-- Data-reflect populates the self-context, all other contexts stay unchanged -->
<p data-reflect="{global:time|listMembersAndValues}">
<!-- For every index in the generated array, the following is repeated: -->
<!-- Each entry has access to arr[i] as {self:} -->
<!-- where arr is the generated array from the data-reflect statement -->
<!-- you may also use context-marrying prefixes like "all:" or "local:" to reflect on a combination of contexts -->
<pCompact data-eval="true">
{self:key|asString|rPad 15 .}{self:value|asString|lPad 15 .}
</pCompact>
</p>
</body>
</rml>The document is loaded via the RmlUi DomainModule:
./bin/Nebulite "set rml.input.animal dog ; set rml.settings.showInput 1 ; rmlui document load myDemoDocument ./Resources/Rml/example.rml"- Interactive: Press
^for live console - Task Files:
./bin/Nebulite task script.nebs - Headless:
--headlessfor automation/testing - CLI:
./bin/Nebulite 'command ; chain'
Nebulite script files (.nebs) allow for scripted execution of commands, with support for control flow and chaining.
Many classic programming constructs are available, such as if-statements, variable assignments, assertions as well as line continuation with \.
This allows for quick prototyping and testing without building additional C++ code.
The entire testing suite is implemented as a set of Nebulite script files, which are executed in headless mode and report their results to the console.
See ./TaskFiles/Tests for examples of Nebulite script files.
If C++ code is necessary for a specific test or feature, it is recommended to implement it as a DomainModule function
and call it from the Nebulite script file, which allows for easy integration into the testing suite.
see ./include/Module/GlobalSpace/FeatureTest.hpp for an example of this approach.
Platforms:
- Linux (native)
- Windows (cross-compiled via MinGW-w64)
- MacOS
Requirements:
- CMake 3.16+
- C++26 compiler
- Python 3.8+ (for testing and mock asset creation)
Dependencies
- SDL3, SDL3_ttf, SDL3_image - rendering and input
- rapidJSON - JSON parsing
- abseil - fast hash maps
- tinyexpr - expression evaluation
- imgui, rmlui - GUI
- stb - image encoding/decoding
Run validation and tests:
python Scripts/TestingSuite.py --stop --verboseor:
make testThe .nebs (Nebulite Script) language is used for parsing commands in different domains.
Nebulite includes a VSCode language extension in ./Languages/nebs/nebulite-script-vscode/.
The extension offers proper syntax highlight for:
- available functions
- variables
- comments
- print-strings (everything after echo/error : meaning it's printed to the command line)
Language extensions for other editors may be added in the future.
Run build-and-install.sh inside its directory for installation.
Or call make docs in the root directory to generate up-to-date command documentation for the extension
and use Languages/nebs/nebulite-script-vscode/syntaxes/keywords.{txt,json} as source for the syntax highlighting.
Contributions welcome. Quick start:
- Fork & create feature branch (
feature/<short-desc>) - Run install + build + tests
- Add/update rulesets or DSL features with tests / demo TaskFile
- Open PR referencing related roadmap item or issue
See full details in CONTRIBUTING.md.
- Tutorials & Examples: Nebulite_Examples - hands-on tutorials and sample projects (work in progress, not usable yet)
- Glossary: Glossary.md - definitions of key terms and concepts
- Command Reference: Commands.md -
comprehensive documentation of all available commands for both GlobalSpace and RenderObject domains
as well as all JSON-Transformations and expression functions
(automatically generated with
make docs)
Distributed under the MIT License. See LICENSE.md.






{ "topic": "gravity", // Broadcast channel (empty = local only) "condition": "1", // Condition to execute "action": { // What to do "assign": [ // Modify values "other:physics.FX += $({global:physics.G} * {self:physics.mass} * {other:physics.mass} * ( {self:posX} - {other:posX} ) / ( 1 + (({self:posX} - {other:posX})^2 + ({self:posY} - {other:posY})^2)^(1.5)) )", "other:physics.FY += $({global:physics.G} * {self:physics.mass} * {other:physics.mass} * ( {self:posY} - {other:posY} ) / ( 1 + (({self:posX} - {other:posX})^2 + ({self:posY} - {other:posY})^2)^(1.5)) )" ], "functioncall": { // Commands to parse ... "global": [], // ...on domain GlobalSpace "self": [], // ...on domain RenderObject, self "other": [] // ...on domain RenderObject, other } } }