NicyRuntime
A blazing-fast Luau runtime environment with a modular architecture. Built in Rust.
NicyRuntime is a high-performance runtime for executing Luau scripts — Roblox’s gradual typing dialect of Lua. It’s designed for embedding in applications, game engines, or any system that needs a fast, sandboxable scripting layer.
Why NicyRuntime?
⚡ Performance
Built entirely in Rust, NicyRuntime leverages the safety and speed of systems-level programming. With optional Luau CodeGen/JIT support, your scripts compile to native machine code at runtime for maximum performance.
🔌 Dynamic Library Architecture
The core runtime is a cdylib (dynamic library) that can be loaded at runtime by any host application. This means:
- Zero coupling: Your host app doesn’t need to link against the runtime at compile time
- Hot-reloadable: Update the runtime without recompiling your host
- Language agnostic: Embed from C, C++, Rust, Python, Node.js — anything with C FFI support
📦 Custom Module Resolver
A sophisticated require() implementation with:
- Smart caching based on file fingerprint (mtime + size)
.luaurcalias support with directory tree inheritance- Circular dependency detection with clear error messages
- Concurrent loading support with cooperative yielding
- Bytecode priority (
.luauc>.luau>.lua)
🔄 Async Task Scheduler
Cooperative multitasking built on Luau coroutines:
task.spawn(function()
print("Running concurrently!")
end)
local id = task.delay(2.0, function()
print("Delayed execution")
end)
task.wait(1.0) -- Non-blocking wait
task.cancel(id) -- Cancel a delayed task
🌍 Cross-Platform
Pre-built binaries for every major platform:
| Platform | Architecture | Status |
|---|---|---|
| Windows | x64, x86, ARM64 | ✅ Stable |
| macOS | x64, ARM64 (Apple Silicon) | ✅ Stable |
| Linux | x64, ARM64 | ✅ Stable |
| Android | ARM64, ARMv7 | ✅ Stable |
🛡️ Robust Error Handling
- Concise errors by default — clean, readable output
- Verbose mode via
NICY_VERBOSE_ERRORS=1— full stack traces with require chain tracking - SEH crash protection on Windows for
runtime.loadlib() - Memory safety with complete static state cleanup between runtime calls
Architecture Overview
Host Application (C / C++ / Rust)
│
▼
┌───────────────────────────┐
│ nicyruntime (cdylib) │
│ │
│ ┌───────────────────┐ │
│ │ Luau VM │ │
│ │ (mlua-sys) │ │
│ │ + CodeGen (JIT) │ │
│ └───────────────────┘ │
│ │
│ ┌───────────────────┐ │
│ │ Module Resolver │ │
│ │ + Cache System │ │
│ └───────────────────┘ │
│ │
│ ┌───────────────────┐ │
│ │ Task Scheduler │ │
│ │ (coroutines) │ │
│ └───────────────────┘ │
│ │
│ ┌───────────────────┐ │
│ │ Error Reporter │ │
│ └───────────────────┘ │
└───────────────────────────┘
▲
│
┌───────────────────────────┐
│ nicy (CLI) │
│ Dynamic loader + router │
└───────────────────────────┘
Project Structure
NicyRuntime/
├── Runtime/ # Core cdylib library
│ ├── src/
│ │ ├── lib.rs # Main entry point, FFI
│ │ ├── require_resolver.rs # Custom module resolver
│ │ ├── task_scheduler.rs # Async task scheduler
│ │ ├── ffi_exports.rs # 65+ C-ABI Lua API wrappers
│ │ └── error.rs # Error reporting system
│ └── tests/ # Luau test suite (32 files)
├── Nicy/ # CLI executable
│ └── src/main.rs # Dynamic loading & routing
├── build.ps1 # Multi-platform build script
└── Docs/ # This documentation site
Quick Links
- Getting Started — Install and run your first script
- CLI Reference — All
nicycommands - Runtime API — FFI functions for embedding
- FFI Reference — Complete Lua C API wrapper docs
- Guides — Practical tutorials
License
NicyRuntime is licensed under the Mozilla Public License Version 2.0 (MPL 2.0).
Source Code · Releases · Report Bug
Installation
NicyRuntime is distributed as pre-built binaries for every major platform. You can also build from source.
Download Pre-built Binaries
Visit the Releases page and download the archive for your platform:
| Platform | Architecture | Archive | Contents |
|---|---|---|---|
| Windows | x64 | nicy-win-x64.zip | nicy.exe, nicyruntime.dll |
| Windows | x86 | nicy-win-x86.zip | nicy.exe, nicyruntime.dll |
| Windows | ARM64 | nicy-win-arm.zip | nicy.exe, nicyruntime.dll |
| macOS | x64 | nicy-mac-x64.zip | nicy, libnicyruntime.dylib |
| macOS | ARM64 | nicy-mac-arm.zip | nicy, libnicyruntime.dylib |
| Linux | x64 | nicy-linux-x64.zip | nicy, libnicyruntime.so |
| Linux | ARM64 | nicy-linux-arm.zip | nicy, libnicyruntime.so |
| Android | ARM64 | nicy-android-arm.zip | nicy, libnicyruntime.so |
| Android | ARMv7 | nicy-android-v7.zip | nicy, libnicyruntime.so |
Windows
- Download
nicy-win-x64.zip - Extract to a folder (e.g.,
C:\tools\nicy\) - Add the folder to your
PATH:
# Temporary (current session)
$env:PATH += ";C:\tools\nicy"
# Permanent (user-level)
[Environment]::SetEnvironmentVariable(
"PATH",
[Environment]::GetEnvironmentVariable("PATH", "User") + ";C:\tools\nicy",
"User"
)
- Verify installation:
nicy --version
macOS
- Download
nicy-mac-arm.zip(Apple Silicon) ornicy-mac-x64.zip(Intel) - Extract and move to
/usr/local/bin/:
unzip nicy-mac-arm.zip
sudo mv nicy /usr/local/bin/
sudo mv libnicyruntime.dylib /usr/local/lib/
- Verify:
nicy --version
Linux
- Download the appropriate archive for your architecture
- Extract and move to your preferred location:
unzip nicy-linux-x64.zip
sudo mv nicy /usr/local/bin/
sudo mv libnicyruntime.so /usr/local/lib/
sudo ldconfig # Update shared library cache
- Verify:
nicy --version
Android
The Android builds are primarily intended for embedding. The CLI binary may require a rooted device or Termux environment.
For embedding in Android apps, include libnicyruntime.so in your jniLibs/ folder and use JNI to call the FFI functions.
Verify Installation
Run the following command to verify everything is working:
nicy --version
You should see output like:
nicy 1.0.0-alpha
Luau 0.650 (with CodeGen)
💡 Tip: The
Luauversion number andwith CodeGenindicator confirm that JIT compilation is available on your platform.
What’s Next?
Now that NicyRuntime is installed, move on to Quick Start to run your first script.
Quick Start
Let’s run your first Luau script with NicyRuntime.
Hello, Luau!
Create a file called hello.luau:
-- hello.luau
print("Hello from NicyRuntime!")
print("Luau version: " .. _VERSION)
print("Runtime: " .. runtime.version)
-- Check if JIT is available
if runtime.hasJIT() then
print("CodeGen/JIT is enable")
else
print("Running in interpreted mode")
end
Run it:
nicy run hello.luau
Output:
Hello from NicyRuntime!
Luau version: Luau
Runtime: 1.0.0-alpha
CodeGen/JIT is enabled! ⚡
Evaluate Inline Code
No need to create a file for quick tests:
nicy eval 'print("Hello from CLI!")'
Working with Modules
Create two files to see the module resolver in action:
math_utils.luau:
local MathUtils = {}
function MathUtils.add(a, b)
return a + b
end
function MathUtils.multiply(a, b)
return a * b
end
return MathUtils
main.luau:
-- main.luau
local MathUtils = require("math_utils")
local sum = MathUtils.add(10, 20)
local product = MathUtils.multiply(5, 6)
print("10 + 20 = " .. sum)
print("5 * 6 = " .. product)
Run:
nicy run main.luau
Output:
10 + 20 = 30
5 * 6 = 30
Compile to Bytecode
Compile your script to .luauc bytecode for faster loading and obfuscation:
nicy compile hello.luau
# Creates hello.luauc
# Run the bytecode directly
nicy run hello.luauc
Using Native Compiler Directives
Enable Luau’s native code generation for specific functions:
fast_math.luau:
--!native
--!optimize 2
local function fibonacci(n)
if n <= 1 then return n end
return fibonacci(n - 1) + fibonacci(n - 2)
end
local start = os.clock()
local result = fibonacci(35)
local elapsed = os.clock() - start
print("fibonacci(35) = " .. result)
print("Time: " .. string.format("%.4f", elapsed) .. "s")
Run with native compilation:
nicy run fast_math.luau
Task Scheduler Demo
Try the async task scheduler:
tasks.luau:
-- Spawn concurrent tasks
task.spawn(function()
for i = 1, 3 do
print("Task A: " .. i)
task.wait(0.5)
end
end)
task.spawn(function()
for i = 1, 3 do
print("Task B: " .. i)
task.wait(0.3)
end
end)
-- Delayed execution
task.delay(1.0, function()
print("This runs after 1 second!")
end)
-- Wait in the main thread (non-blocking)
print("Main thread waiting...")
task.wait(2.0)
print("Done!")
Run:
nicy run tasks.luau
Output:
Main thread waiting...
Task A: 1
Task B: 1
Task B: 2
Task A: 2
Task B: 3
This runs after 1 second!
Task A: 3
Done!
What’s Next?
- Learn about all CLI commands
- Explore the Runtime API for embedding
- Read the Custom Modules guide for advanced
require()usage
Project Structure
Understanding the NicyRuntime project layout.
Workspace Overview
NicyRuntime is a Cargo workspace with two crates:
NicyRuntime/
├── Cargo.toml # Workspace root
├── Runtime/ # Core library (cdylib)
│ ├── Cargo.toml
│ ├── NicyRuntime.h # C header for embedding
│ ├── README.md
│ ├── libs/
│ │ └── libunwind.a # Static unwind library
│ ├── src/
│ │ ├── lib.rs # Main entry point
│ │ ├── require_resolver.rs # Module resolver
│ │ ├── task_scheduler.rs # Async scheduler
│ │ ├── ffi_exports.rs # C-ABI exports
│ │ └── error.rs # Error system
│ └── tests/ # Luau test suite
├── Nicy/ # CLI executable
│ ├── Cargo.toml
│ ├── README.md
│ ├── libs/
│ │ └── libunwind.a
│ └── src/
│ └── main.rs # CLI entry point
├── build.ps1 # Build script
├── Docs/ # Documentation site
└── .github/workflows/ # CI/CD
Runtime Crate (Runtime/)
The core library — a cdylib (dynamic library) that contains:
Source Files
| File | Lines | Purpose |
|---|---|---|
lib.rs | ~1,250 | Main entry, Luau state init, FFI functions, OS extensions |
require_resolver.rs | ~1,205 | Custom require() with caching, aliases, circular detection |
task_scheduler.rs | ~782 | Cooperative async scheduler with coroutines |
ffi_exports.rs | ~522 | 70+ Lua C API wrappers with stable C-ABI |
error.rs | ~1,997 | Error reporting (concise/verbose modes) |
Key Features
nicy_start()— Initialize runtime and execute a script filenicy_eval()— Evaluate inline code in an isolated statenicy_compile()— Compile source to.luaucbytecodenicy_version()— Return runtime version string- 70+
nicy_lua_*functions — Full Lua C API exposed via FFI
Nicy Crate (Nicy/)
The CLI executable — a minimal wrapper (~361 lines) that:
- Dynamically loads
nicyruntimevialibloading - Routes commands (
run,eval,compile) - Handles argument parsing and output
Why Dynamic Loading?
The CLI doesn’t link against the runtime at compile time. Instead, it:
- Discovers the runtime library at runtime
- Allows swapping runtime versions without recompiling the CLI
- Demonstrates the embedding pattern for end users
Build Artifacts
After building, you’ll find:
target/
├── release/
│ ├── nicy # CLI executable (or nicy.exe)
│ └── libnicyruntime.so # Runtime library (.dll / .dylib)
Test Suite
Located in Runtime/tests/ — 32 Luau files covering:
| Category | Files | Tests |
|---|---|---|
| Core API | 11 | stdlib, bit32, buffers, GC, IO, metatables, vectors, etc. |
| Require System | 6 + fixtures | aliases, bytecode, circular deps, concurrent loading |
| Runtime | 6 | debug, error handler, globals, shutdown, traceback |
| Task Scheduler | 7 | spawn, defer, delay, wait, cancel, stress tests |
Run all tests:
nicy run Runtime/tests/run_all.luau
Configuration Files
Cargo.toml (workspace)
[workspace]
members = ["Nicy", "Runtime"]
resolver = "2"
[profile.release]
strip = "symbols"
lto = true
codegen-units = 1
opt-level = "z" # Optimize for size
Runtime/Cargo.toml (dependencies)
[dependencies]
libloading = "0.9"
[target.'cfg(not(target_os = "android"))'.dependencies]
mlua-sys = { version = "0.10.0", features = ["luau", "luau-codegen", "luau-vector4", "vendored"] }
Environment Variables
| Variable | Purpose |
|---|---|
NICY_VERBOSE_ERRORS=1 | Enable verbose error output |
NICY_NO_COLOR=1 | Disable ANSI colors in error messages |
NICY_HIRES_TIMER=1 | Enable high-resolution timer on Windows |
Build from Source
Building NicyRuntime from source gives you full control over the build configuration and enables contributions.
Prerequisites
Required
- Rust 1.75+ — Install via rustup
- Git — For cloning the repository
Optional (for cross-compilation)
- Zig 0.14.0 — For cross-compiling Linux/macOS binaries
- Android NDK r26d — For Android builds
- cargo-zigbuild —
cargo install cargo-zigbuild --locked - cargo-ndk —
cargo install cargo-ndk --locked
Clone the Repository
git clone https://github.com/nicy-luau/Runtime.git
cd Runtime
Quick Build (Native Target)
The simplest build — compiles for your current platform:
# Build both CLI and Runtime
cargo build --release --workspace
# Or build individually
cargo build --release -p nicy
cargo build --release -p nicyruntime
Output:
target/release/nicy(ornicy.exeon Windows)target/release/libnicyruntime.so(or.dll/.dylib)
Using the Build Script
The included build.ps1 script simplifies cross-platform builds:
# Build for your current platform (auto-detected)
.\build.ps1 -target user
# Build for all supported platforms
.\build.ps1 -target all
# Build for a specific platform
.\build.ps1 -target win-x64
.\build.ps1 -target linux-arm
.\build.ps1 -target mac-arm
# Force clean rebuild
.\build.ps1 -target user -force
Supported Targets
| Target | Platform | Architecture | Toolchain |
|---|---|---|---|
win-x64 | Windows | x86_64 | MSVC (native) |
win-x86 | Windows | x86 | MSVC (native) |
win-arm | Windows | ARM64 | MSVC (native) |
mac-x64 | macOS | x86_64 | Zig |
mac-arm | macOS | ARM64 | Zig |
linux-x64 | Linux | x86_64 | Zig |
linux-arm | Linux | ARM64 | Zig |
linux-x86 | Linux | x86 | Zig |
android-arm | Android | ARM64 | cargo-ndk |
android-v7 | Android | ARMv7 | cargo-ndk |
Cross-Compilation Setup
Linux/macOS (via Zig)
# Install Zig 0.14.0
# https://ziglang.org/download/
# Install cargo-zigbuild
cargo install cargo-zigbuild --locked
# Build for Linux x64
cargo zigbuild --release --target x86_64-unknown-linux-gnu -p nicyruntime
# Build for macOS ARM64
cargo zigbuild --release --target aarch64-apple-darwin -p nicyruntime
Android
# Install Android NDK r26d
# https://developer.android.com/ndk/downloads
# Install cargo-ndk
cargo install cargo-ndk --locked
# Build for ARM64
cargo ndk -t arm64-v8a build --release -p nicyruntime
# Build for ARMv7
cargo ndk -t armeabi-v7a build --release -p nicyruntime
Build Configuration
Release Profile
The default release profile is optimized for minimum binary size:
[profile.release]
strip = "symbols" # Remove debug symbols
lto = true # Link-time optimization
codegen-units = 1 # Single codegen unit for best optimization
opt-level = "z" # Optimize for size (use "3" for speed)
Customizing the Build
To optimize for speed instead of size, create .cargo/config.toml:
[profile.release]
opt-level = 3
lto = "fat"
codegen-units = 1
Feature Flags
The Runtime crate doesn’t expose optional features currently, but you can modify Runtime/Cargo.toml:
# Disable CodeGen on platforms where it's unstable
[target.'cfg(target_os = "android")'.dependencies]
mlua-sys = { version = "0.10.0", features = ["luau", "vendored"] }
# Note: no "luau-codegen" feature for Android
Verify the Build
# Run the built CLI
./target/release/nicy --version
# Run the test suite
./target/release/nicy run Runtime/tests/run_all.luau
Build Artifacts
After a successful build:
target/
├── release/
│ ├── nicy # CLI binary
│ ├── libnicyruntime.so # Runtime library
│ │ ├── deps/ # Dependency libraries
│ │ └── .fingerprint/ # Build fingerprints
│ └── build/ # Build scripts output
Troubleshooting
“zig not found”
Install Zig and ensure it’s in your PATH:
zig version # Should output 0.14.0
“NDK not found” (Android)
Set the ANDROID_NDK_HOME environment variable:
export ANDROID_NDK_HOME=/path/to/android-ndk-r26d
Build fails on Windows x86
Some Luau features may not be fully compatible with 32-bit Windows. Try building for x64 instead.
What’s Next?
Commands
The nicy CLI supports three main commands plus version info.
nicy run
Execute a Luau script file.
Syntax
nicy run <file>
Arguments
| Argument | Required | Description |
|---|---|---|
<file> | Yes | Path to the .luau, .lua, or .luauc file to execute |
Examples
# Run a script
nicy run myscript.luau
# Run compiled bytecode
nicy run myscript.luauc
nicy eval
Evaluate inline Luau code.
Syntax
nicy eval "<code>"
Arguments
| Argument | Required | Description |
|---|---|---|
<code> | Yes | Luau code string to evaluate (must be quoted) |
Examples
# Simple expression
nicy eval "print(2 + 2)"
# Multi-line code
nicy eval "
local function greet(name)
print('Hello, ' .. name .. '!')
end
greet('World')
"
nicy compile
Compile a Luau source file to bytecode (.luauc).
Syntax
nicy compile <file>
Arguments
| Argument | Required | Description |
|---|---|---|
<file> | Yes | Path to the .luau or .lua source file |
Examples
# Compile with defaults
nicy compile myscript.luau
# Creates: myscript.luauc (same directory, same name)
No CLI Flags
The compile command does not accept --native, --optimize, --output, or other flags. All compiler configuration is done via in-source compiler directives:
-- myscript.luau
--!native
--!optimize 2
-- Your code here
# Correct: configure via source
nicy compile myscript.luau
# Wrong: CLI flags do not exist
nicy compile myscript.luau --native # DOES NOT EXIST
See Compiler Directives for details.
nicy version
Display the CLI version.
nicy version
Output:
nicy 1.0.0-alpha
nicy runtime-version
Display both CLI and runtime library versions.
nicy runtime-version
Output:
Engine: 1.0.0-alpha
Luau: 0.650
nicy help
Show usage information.
nicy help
Output:
nicy - The Ultimate Luau Runtime
Usage:
nicy run <script.luau>
nicy eval <"code">
nicy compile <script.luau>
nicy help
nicy version
nicy runtime-version
Flags
The NicyRuntime CLI does not use flags for compilation configuration.
Environment Variables
| Variable | Effect |
|---|---|
NICY_VERBOSE_ERRORS=1 | Enable verbose error output |
NICY_NO_COLOR=1 | Disable ANSI colors in errors |
NICY_HIRES_TIMER=1 | Enable high-resolution timer (Windows) |
Compiler Configuration
Compiler configuration is done via in-source compiler directives, not CLI flags:
--!native -- Enable CodeGen/JIT
--!optimize 2 -- Optimization level (0-2)
# Correct: configure via source directives
nicy compile myscript.luau
# Wrong: CLI flags do not exist
nicy compile myscript.luau --native # DOES NOT EXIST
See Compiler Directives for details.
Examples
Common usage patterns for the NicyRuntime CLI.
Basic Script Execution
Run a Simple Script
nicy run hello.luau
Evaluate Inline Code
nicy eval "print(42)"
Compilation
Compile and Run
# Compile (respects --!native, --!optimize from source)
nicy compile game.luau
# Run the bytecode
nicy run game.luauc
Module System
Simple Module
math_utils.luau:
return {
add = function(a, b) return a + b end,
mul = function(a, b) return a * b end,
}
main.luau:
local utils = require("math_utils")
print(utils.add(10, 20))
nicy run main.luau
Async Tasks
Concurrent Execution
for i = 1, 5 do
task.spawn(function()
print("Task " .. i .. " starting")
task.wait(math.random() * 2)
print("Task " .. i .. " done")
end)
end
task.wait(3)
print("All done!")
Delayed Execution
local id = task.delay(2.0, function()
print("2 seconds elapsed!")
end)
task.wait(1.0)
task.cancel(id)
print("Cancelled before firing")
Error Handling
# Concise error (default)
nicy run broken.luau
# Verbose error
NICY_VERBOSE_ERRORS=1 nicy run broken.luau
# No colors
NICY_NO_COLOR=1 nicy run broken.luau
nicy_start
Initialize the Luau runtime and execute a script file.
C Signature
void nicy_start(const char* file_path);
Parameters
| Parameter | Type | Description |
|---|---|---|
file_path | const char* | Path to the .luau, .lua, or .luauc file to execute |
Description
nicy_start is the main entry point for executing Luau scripts. It:
- Loads the dynamic runtime library
- Creates a new Luau state with standard libraries
- Installs the error handler and
runtime/taskglobals - Loads, compiles (with optional CodeGen), and executes the script
- Runs the task scheduler until idle (processes all
task.spawn,task.delay, etc.) - Cleans up (unloads libraries, destroys state, resets static state)
Example (C)
#include "NicyRuntime.h"
int main() {
nicy_start("myscript.luau");
return 0;
}
Compiler Directives
The source file can include compiler directives on the first lines:
--!native -- Enable CodeGen/JIT
--!optimize 2 -- Set optimization level (0-2)
--!coverage -- Enable coverage tracking
--!profile -- Enable profiling
--!typeinfo 1 -- Enable type info generation
Global Environment
After initialization, the following globals are available:
runtime table
| Property | Type | Description |
|---|---|---|
runtime.version | string | NicyRuntime version |
runtime.loadlib | function | Dynamically load a native library |
runtime.hasJIT(spec?) | function | Check if JIT is available for a spec |
runtime.entry_file | string | Path of the executed script |
runtime.entry_dir | string | Directory of the executed script |
task table
| Function | Description |
|---|---|
task.spawn(fn, ...) | Spawn a concurrent task |
task.defer(fn, ...) | Defer execution to the end of the queue |
task.delay(seconds, fn, ...) | Schedule delayed execution |
task.wait(seconds?) | Non-blocking wait |
task.cancel(thread_or_id) | Cancel a task or delay |
OS extensions
| Function | Description |
|---|---|
os.exit(code) | Exit the process |
os.getenv(name) | Get environment variable |
os.remove(path) | Delete a file |
os.rename(old, new) | Rename a file |
os.sleep(ms) | Sleep (in milliseconds) |
os.tmpname() | Generate a unique temp filename |
Standard os.clock(), os.time(), os.date(), os.difftime() are also available.
Environment Variables
| Variable | Effect |
|---|---|
NICY_VERBOSE_ERRORS=1 | Enable verbose error output |
NICY_NO_COLOR=1 | Disable ANSI colors in errors |
NICY_HIRES_TIMER=1 | Enable high-resolution timer (Windows) |
Thread Safety
nicy_start is not thread-safe. Each call creates and destroys its own state. Static state is cleaned up between calls.
nicy_eval
Evaluate inline Luau code and print the result.
C Signature
void nicy_eval(const char* code);
Parameters
| Parameter | Type | Description |
|---|---|---|
code | const char* | Luau source code to evaluate |
Description
nicy_eval creates an isolated Luau state, loads the code, and executes it. Results are printed to stdout. Errors are printed to stderr.
Unlike nicy_start, it:
- Does not run the task scheduler
- Creates a fresh state each time (no shared globals)
- Is intended for quick one-liners and tests
Example (C)
#include "NicyRuntime.h"
int main() {
nicy_eval("print(2 + 2)");
nicy_eval("print('Hello from Luau!')");
return 0;
}
Notes
- The code is loaded as a chunk — no file-based module resolution
- Each call is independent (no state sharing)
- Errors terminate the program (no recovery)
Task Scheduler
NicyRuntime includes a built-in async task scheduler based on Luau coroutines.
Overview
The task global table provides these functions:
| Function | Description |
|---|---|
task.spawn(fn, ...) | Create and schedule a concurrent task |
task.defer(fn, ...) | Schedule for execution after current tasks |
task.delay(seconds, fn, ...) | Schedule execution after a delay |
task.wait(seconds?) | Pause the current coroutine |
task.cancel(thread_or_id) | Cancel a spawned task or delayed task |
task.spawn
Create a new coroutine and schedule it for immediate execution.
task.spawn(function()
print("Running in a separate task!")
end)
-- With arguments
task.spawn(function(name, count)
for i = 1, count do
print(name .. ": " .. i)
end
end, "Worker", 5)
Returns
Returns the coroutine thread (useful for task.cancel).
local t = task.spawn(function()
while true do
print("Running...")
task.wait(0.5)
end
end)
task.wait(2.0)
task.cancel(t)
task.defer
Schedule a function to run after all currently ready tasks yield or complete.
print("Before defer")
task.defer(function()
print("Deferred!")
end)
print("After defer")
-- Output:
-- Before defer
-- After defer
-- Deferred!
Use task.defer when you want to ensure a function runs after the current iteration of the scheduler.
task.delay
Schedule a function to run after a delay (in seconds).
local id = task.delay(2.0, function()
print("2 seconds later!")
end)
print("Waiting...")
task.wait(3.0)
print("Done!")
Returns
Returns a numeric ID that can be used with task.cancel:
local id = task.delay(5.0, function()
print("This won't fire")
end)
task.cancel(id)
Safe Integer Validation
task.cancel validates IDs against the safe integer range (2^53). IDs exceeding this range are rejected to prevent incorrect cancellations.
task.wait
Pause the current coroutine for the specified duration (in seconds).
print("Waiting 1 second...")
local elapsed = task.wait(1.0)
print(string.format("Waited %.3f seconds", elapsed))
In the Main Thread
When called from the main thread (not inside task.spawn), task.wait runs the scheduler for the specified duration:
task.spawn(function()
for i = 1, 10 do
print("Spawned: " .. i)
task.wait(0.1)
end
end)
-- This runs the scheduler for 2 seconds
task.wait(2.0)
print("Main thread resumed")
In a Spawned Task
When called inside task.spawn, task.wait yields the current coroutine and the scheduler resumes it after the delay.
Auto-registration
If task.wait is called from a coroutine that wasn’t created via task.spawn (e.g., coroutine.create), the scheduler automatically registers it so it can be managed.
task.cancel
Cancel a running task or a scheduled delay.
-- Cancel a spawned task
local t = task.spawn(function()
while true do
print("Running...")
task.wait(0.5)
end
end)
task.wait(2.0)
task.cancel(t)
-- Cancel a delayed task
local id = task.delay(5.0, function()
print("Won't execute")
end)
task.wait(1.0)
task.cancel(id)
Behavior
- Returns
trueif the task was found and cancelled - Returns
falseif the task was not found or already completed - Safely handles invalid IDs (no errors)
Scheduler Behavior
Execution Order
The scheduler processes tasks in this order:
- Ready queue (
task.spawn) — Tasks that are ready to run - Yielded queue (
task.defer) — Tasks that yielded and are ready to resume - Timers (
task.delay,task.wait) — Delayed tasks whose time has come
Cooperative Multitasking
Tasks must cooperate by calling task.wait() or completing. A task that runs an infinite loop without yielding will block all other tasks:
-- Bad: Blocks the scheduler
task.spawn(function()
while true do
-- No yield!
end
end)
-- Good: Cooperative
task.spawn(function()
while true do
-- Do work
task.wait(0) -- Yield to other tasks
end
end)
Main Thread Behavior
When running via nicy run, the main thread:
- Executes the entry script
- Runs the scheduler until all tasks complete
- Exits
This means nicy run will wait for all spawned tasks to finish before exiting.
Advanced Patterns
Worker Pool
local function worker(id, jobs)
while true do
local job = table.remove(jobs, 1)
if not job then break end
print("Worker " .. id .. " processing: " .. job)
task.wait(0.5)
end
end
local jobs = {"job1", "job2", "job3", "job4", "job5"}
for i = 1, 3 do
task.spawn(worker, i, jobs)
end
task.wait(10)
print("All jobs complete!")
Timeout Pattern
local function withTimeout(duration, fn)
local done = false
local result = nil
task.spawn(function()
result = fn()
done = true
end)
task.wait(duration)
if not done then
error("Operation timed out!")
end
return result
end
nicy_version / nicy_luau_version
Get version information strings.
C Signatures
const char* nicy_version(void);
const char* nicy_luau_version(void);
Return Value
| Function | Returns |
|---|---|
nicy_version() | Engine version string (e.g., "1.0.0-alpha") |
nicy_luau_version() | Luau version string (e.g., "0.650") |
Both return static string pointers. Do not modify or free them.
Example (C)
#include "NicyRuntime.h"
#include <stdio.h>
int main() {
printf("Engine: %s\n", nicy_version());
printf("Luau: %s\n", nicy_luau_version());
return 0;
}
CLI Usage
# Engine version only
nicy version
# → nicy 1.0.0-alpha
# Engine + Luau version
nicy runtime-version
# → Engine: 1.0.0-alpha
# → Luau: 0.650
runtime Table
The runtime global table provides NicyRuntime-specific functionality.
Properties
runtime.version
The NicyRuntime version string.
print(runtime.version)
-- "1.0.0-alpha"
runtime.hasJIT(spec?)
A function that checks if CodeGen/JIT is available.
-- Check default
if runtime.hasJIT() then
print("JIT is available")
end
-- Check for a specific module spec
if runtime.hasJIT("@self/perf_module") then
print("JIT available for this module")
end
runtime.loadlib(path)
Load a dynamic library (.dll, .so, .dylib) from a specified path.
local result = runtime.loadlib("@self/mylib.dll")
Path Formats
| Format | Description |
|---|---|
@self/path | Relative to the entry script’s directory |
./relative/path | Relative to the current working directory |
/absolute/path | Absolute path |
Caching
Libraries are cached by their resolved path. Subsequent calls with the same path return the cached result.
SEH Crash Protection (Windows)
On Windows, library loading is wrapped in SEH to catch access violations. If a library crashes during load, an error is returned instead of crashing the process.
runtime.entry_file
The absolute path of the script passed to nicy_start.
print(runtime.entry_file)
-- e.g., "/path/to/myscript.luau"
runtime.entry_dir
The directory containing the entry script.
print(runtime.entry_dir)
-- e.g., "/path/to/"
warn Override
NicyRuntime provides a custom warn() implementation that integrates with the error reporting system. If the global warn is nil, it’s replaced with NicyRuntime’s version.
Compiler Directives
Compiler directives are special comments at the top of Luau source files that control compilation behavior.
Syntax
Directives are placed at the beginning of the file (before any code):
--!native
--!optimize 2
-- Your code starts here
local function main()
-- ...
end
Available Directives
--!native
Enable Luau CodeGen (native code generation) for this file.
--!native
local function hot_function(x)
-- This will be compiled to native code
return x * x * x
end
Effect: The Luau VM will generate native machine code for functions in this file, providing faster execution.
Platform Support:
- ✅ Windows, macOS, Linux (x64, ARM64)
- ❌ Android (disabled)
Equivalent CLI flag: --native
--!optimize <level>
Set the optimization level for this file.
--!optimize 2
local function compute()
-- Aggressively optimized
end
Levels:
| Level | Description |
|---|---|
0 | No optimization (fastest compilation, slowest execution) |
1 | Default optimization (balanced) |
2 | Aggressive optimization (slowest compilation, fastest execution) |
Equivalent CLI flag: --optimize <level>
--!coverage
Enable code coverage instrumentation.
--!coverage
-- Code will track which lines are executed
Effect: Generates coverage data that can be used to analyze which parts of the code are executed during a run.
--!profile
Enable profiling instrumentation.
--!profile
-- Functions will include profiling hooks
Effect: Adds overhead to track function call counts and execution times.
--!typeinfo <level>
Enable type info generation.
--!typeinfo 1
local function add(a: number, b: number): number
return a + b
end
Levels:
0— No type info1— Basic type info2— Full type info (includes inferred types)
Multiple Directives
Multiple directives can be combined:
--!native
--!optimize 2
--!typeinfo 1
-- This file uses native code generation,
-- aggressive optimization, and full type info
Directive Precedence
- CLI flags (highest priority) — Flags passed to
nicy compileornicy_compile()override source directives - Source directives — Directives in the source file
- Defaults — If neither is specified, defaults apply (no native, optimize level 1, no type info)
Implementation Details
Directives are parsed from the first lines of the source file before compilation. The parser:
- Reads lines starting with
--! - Extracts the directive name and optional value
- Strips the directive lines from the source (they are not executed)
- Applies the directives during compilation
Lines after the first non-directive line are treated as regular code.
Examples
Production Build
--!native
--!optimize 2
-- Production code with maximum performance
Debug Build
--!optimize 0
-- Debug code with no optimization for easier debugging
Typed Code
--!native
--!typeinfo 2
local function factorial(n: number): number
if n <= 1 then return 1 end
return n * factorial(n - 1)
end
See Also
FFI C-ABI Reference
NicyRuntime exports 85 functions with a stable extern "C-unwind" ABI. This includes 83 Lua C API wrappers for complete Luau state management and 2 error code utilities.
Header File
Include in your C/C++ project:
#include "NicyRuntime.h"
Calling Convention
All functions use extern "C-unwind", allowing exceptions to propagate across FFI boundaries.
Stack Operations
| Function | Signature |
|---|---|
nicy_lua_gettop | c_int(*l) |
nicy_lua_settop | void(*l, idx) |
nicy_lua_pushvalue | void(*l, idx) |
nicy_lua_remove | void(*l, idx) |
nicy_lua_insert | void(*l, idx) |
nicy_lua_absindex | c_int(*l, idx) |
nicy_lua_checkstack | c_int(*l, extra) |
nicy_lua_pop | void(*l, n) (macro: settop(l, -n-1)) |
Push Operations
| Function | Signature |
|---|---|
nicy_lua_pushnil | void(*l) |
nicy_lua_pushboolean | void(*l, b: c_int) |
nicy_lua_pushnumber | void(*l, n: lua_Number) |
nicy_lua_pushinteger | void(*l, n: lua_Integer) |
nicy_lua_pushstring | void(*l, s: *const c_char) |
nicy_lua_pushlstring | void(*l, s: *const c_char, len: usize) |
nicy_lua_pushcfunction | void(*l, f: lua_CFunction) |
nicy_lua_pushcclosure | void(*l, f: lua_CFunction, n: c_int) |
nicy_lua_pushlightuserdata | void(*l, p: *mut c_void) |
nicy_lua_newuserdata | *mut c_void(*l, sz: usize) |
nicy_lua_newthread | *mut LuauState(*l) |
Type Checking
| Function | Signature |
|---|---|
nicy_lua_type | c_int(*l, idx) |
nicy_lua_typename | *const c_char(*l, tp: c_int) |
nicy_lua_isnil | c_int(*l, idx) |
nicy_lua_isboolean | c_int(*l, idx) |
nicy_lua_isnumber | c_int(*l, idx) |
nicy_lua_isstring | c_int(*l, idx) |
nicy_lua_istable | c_int(*l, idx) |
nicy_lua_isfunction | c_int(*l, idx) |
nicy_lua_isuserdata | c_int(*l, idx) |
nicy_lua_isthread | c_int(*l, idx) |
nicy_lua_iscfunction | c_int(*l, idx) |
nicy_lua_isinteger | c_int(*l, idx) |
Get & Conversion
| Function | Signature |
|---|---|
nicy_lua_tostring | *const c_char(*l, idx) |
nicy_lua_tolstring | *const c_char(*l, idx, len: *mut usize) |
nicy_lua_toboolean | c_int(*l, idx) |
nicy_lua_tonumber | lua_Number(*l, idx) |
nicy_lua_tointeger | lua_Integer(*l, idx) |
nicy_lua_touserdata | *mut c_void(*l, idx) |
Table Access
| Function | Signature |
|---|---|
nicy_lua_getfield | void(*l, idx, k: *const c_char) |
nicy_lua_getglobal | void(*l, k: *const c_char) |
nicy_lua_setglobal | void(*l, k: *const c_char) |
nicy_lua_gettable | void(*l, idx) |
nicy_lua_settable | void(*l, idx) |
nicy_lua_rawget | void(*l, idx) |
nicy_lua_rawgeti | void(*l, idx, n: lua_Integer) |
nicy_lua_rawset | void(*l, idx) |
nicy_lua_rawseti | void(*l, idx, n: lua_Integer) |
nicy_lua_getmetatable | c_int(*l, idx) |
nicy_lua_setmetatable | c_int(*l, idx) |
nicy_lua_createtable | void(*l, narr: c_int, nrec: c_int) |
nicy_lua_next | c_int(*l, idx) |
Call & Execution
| Function | Signature |
|---|---|
nicy_lua_call | void(*l, nargs: c_int, nresults: c_int) |
nicy_lua_pcall | c_int(*l, nargs, nresults, errfunc: c_int) |
nicy_lua_error | c_int(*l) |
nicy_lua_resume | c_int(*l, from: *mut LuauState, nargs, nres: *mut c_int) |
nicy_lua_yield | c_int(*l, nresults: c_int) |
Comparison & Other
| Function | Signature |
|---|---|
nicy_lua_equal | c_int(*l, idx1, idx2) |
nicy_lua_lessthan | c_int(*l, idx1, idx2) |
nicy_lua_rawequal | c_int(*l, idx1, idx2) |
nicy_lua_concat | void(*l, n: c_int) |
nicy_lua_gc | c_int(*l, what: c_int, data: c_int) |
nicy_lua_rawlen | usize(*l, idx) |
Lua 5.1 Compatibility
| Function | Signature |
|---|---|
nicy_lua_getfenv | void(*l, idx) |
nicy_lua_setfenv | c_int(*l, idx) |
Auxiliary Library (lauxlib)
| Function | Signature |
|---|---|
nicy_luaL_checkstring | *const c_char(*l, narg: c_int) |
nicy_luaL_checklstring | *const c_char(*l, narg, len: *mut usize) |
nicy_luaL_checknumber | lua_Number(*l, narg) |
nicy_luaL_checkboolean | c_int(*l, narg) |
nicy_luaL_checkinteger | lua_Integer(*l, narg) |
nicy_luaL_checktype | void(*l, narg, t: c_int) |
nicy_luaL_checkany | void(*l, narg) |
nicy_luaL_optstring | *const c_char(*l, narg, d: *const c_char) |
nicy_luaL_optinteger | lua_Integer(*l, narg, d: lua_Integer) |
nicy_luaL_optnumber | lua_Number(*l, narg, d: lua_Number) |
nicy_luaL_argerror | c_int(*l, narg, extramsg: *const c_char) |
nicy_luaL_where | void(*l, lvl: c_int) |
nicy_luaL_traceback | void(*l, l1: *mut LuauState, msg: *const c_char, level: c_int) |
nicy_luaL_ref | c_int(*l, t: c_int) |
nicy_luaL_unref | void(*l, t, r: c_int) |
nicy_luaL_len | lua_Integer(*l, idx) |
nicy_luaL_newmetatable | c_int(*l, tname: *const c_char) |
nicy_luaL_getmetatable | c_int(*l, tname: *const c_char) |
nicy_luaL_error | c_int(*l, msg: *const c_char) |
Error Code Utilities
These functions help FFI integrators work with NicyRuntime’s error codes:
| Function | Signature | Description |
|---|---|---|
nicy_error_name | *const c_char(code: c_int) | Convert error code to name string (e.g., 103 → "NICY_ERR_CYCLIC_REQUIRE") |
nicy_is_nicy_error | c_int(code: c_int) | Returns 1 if code is Nicy-specific (100+), 0 if standard Luau |
Example (C)
int err = nicy_start("script.luau");
if (err != 0) {
const char* name = nicy_error_name(err);
int is_nicy = nicy_is_nicy_error(err);
if (is_nicy) {
fprintf(stderr, "Nicy error: %s\n", name);
} else {
fprintf(stderr, "Luau error: %s\n", name);
}
}
Embedding in C
This guide shows how to embed NicyRuntime in a C application.
Prerequisites
- A C compiler (MSVC, GCC, Clang)
NicyRuntime.hheader filenicyruntimeshared library (.dll,.so, or.dylib)
Setup
my_app/
├── main.c
├── NicyRuntime.h
└── nicyruntime.dll # or .so / .dylib
Basic Example
#include <stdio.h>
#include "NicyRuntime.h"
int main(int argc, char* argv[]) {
if (argc < 2) {
fprintf(stderr, "Usage: %s <script.luau>\n", argv[0]);
return 1;
}
// Print version info
printf("NicyRuntime %s\n", nicy_version());
printf("Powered by %s\n\n", nicy_luau_version());
// Execute the script
nicy_start(argv[1]);
return 0;
}
Compiling
Windows (MSVC)
cl /I. main.c nicyruntime.lib /Fe:my_app.exe
Linux (GCC)
gcc -o my_app main.c -L. -lnicyruntime -Wl,-rpath,.
macOS (Clang)
clang -o my_app main.c -L. -lnicyruntime -Wl,-rpath,@executable_path
Using the Lua C API
For fine-grained control, use the exported FFI functions:
#include <stdio.h>
#include "NicyRuntime.h"
int main() {
// nicy_eval prints results directly
nicy_eval("print('hello from luau')");
// For more control, get a lua_State through your own initialization
// or use nicy_start() for full scripts
return 0;
}
Dynamic Loading (No Linking)
Load the library at runtime with dlopen/LoadLibrary:
#include <stdio.h>
#include <stdlib.h>
#ifdef _WIN32
#include <windows.h>
#define LIB_HANDLE HMODULE
#define LIB_LOAD(path) LoadLibraryA(path)
#define LIB_SYM(lib, name) GetProcAddress(lib, name)
#else
#include <dlfcn.h>
#define LIB_HANDLE void*
#define LIB_LOAD(path) dlopen(path, RTLD_NOW)
#define LIB_SYM(lib, name) dlsym(lib, name)
#endif
typedef void (*nicy_start_fn)(const char*);
int main() {
LIB_HANDLE lib = LIB_LOAD(
#ifdef _WIN32
"nicyruntime.dll"
#elif __APPLE__
"libnicyruntime.dylib"
#else
"libnicyruntime.so"
#endif
);
if (!lib) {
fprintf(stderr, "Failed to load runtime library\n");
return 1;
}
nicy_start_fn start = (nicy_start_fn)LIB_SYM(lib, "nicy_start");
if (!start) {
fprintf(stderr, "Failed to find nicy_start\n");
return 1;
}
start("myscript.luau");
return 0;
}
Error Handling
Errors from nicy_start are printed to stderr and the function returns. To control error output, use environment variables:
// Enable verbose errors
#ifdef _WIN32
_putenv("NICY_VERBOSE_ERRORS=1");
#else
setenv("NICY_VERBOSE_ERRORS", "1", 1);
#endif
nicy_start("myscript.luau");
See Also
- FFI Reference — Complete C API
- Embedding in Rust
Embedding in Rust
Embed NicyRuntime in a Rust application using FFI.
⚠️ Important:
nicyruntimeis acdylib, NOT anrlib. You have two options:
- Dynamic loading (recommended): Use
libloadingto load the library at runtime- Static linking: Use
#[link(name = "nicyruntime")]with the library in your library pathDo NOT add
nicyruntimeas a Cargo dependency — this will fail with a crate type error.
Prerequisites
- Rust 1.75+
nicyruntimeshared library in the library search path
Project Setup
Cargo.toml:
[package]
name = "my-host-app"
version = "0.1.0"
edition = "2021"
[dependencies]
libc = "0.2"
Basic Example
src/main.rs:
use std::ffi::{CStr, CString};
#[link(name = "nicyruntime")]
extern "C" {
fn nicy_start(file: *const std::os::raw::c_char);
fn nicy_eval(code: *const std::os::raw::c_char);
fn nicy_compile(path: *const std::os::raw::c_char);
fn nicy_version() -> *const std::os::raw::c_char;
fn nicy_luau_version() -> *const std::os::raw::c_char;
}
fn main() {
// Print version info
let runtime_version = unsafe {
CStr::from_ptr(nicy_version()).to_string_lossy().into_owned()
};
let luau_version = unsafe {
CStr::from_ptr(nicy_luau_version()).to_string_lossy().into_owned()
};
println!("NicyRuntime {}", runtime_version);
println!("Powered by {}", luau_version);
// Run a script
let script = CString::new("myscript.luau").unwrap();
unsafe { nicy_start(script.as_ptr()) };
}
Safe Wrapper
#![allow(unused)]
fn main() {
use std::ffi::{CStr, CString};
pub fn run_script(path: &str) -> Result<(), String> {
let c_path = CString::new(path).map_err(|_| "Invalid path")?;
unsafe { nicy_start(c_path.as_ptr()) };
Ok(())
}
pub fn eval(code: &str) -> Result<(), String> {
let c_code = CString::new(code).map_err(|_| "Invalid code")?;
unsafe { nicy_eval(c_code.as_ptr()) };
Ok(())
}
pub fn compile(path: &str) -> Result<(), String> {
let c_path = CString::new(path).map_err(|_| "Invalid path")?;
unsafe { nicy_compile(c_path.as_ptr()) };
Ok(())
}
pub fn version() -> String {
unsafe {
CStr::from_ptr(nicy_version())
.to_string_lossy()
.into_owned()
}
}
}
Dynamic Loading
For maximum flexibility, load the runtime dynamically:
use libloading::Library;
use std::ffi::CString;
fn main() {
let lib = Library::new("nicyruntime.dll").expect("Failed to load");
unsafe {
let nicy_start: libloading::Symbol<
unsafe extern "C" fn(*const std::os::raw::c_char)
> = lib.get(b"nicy_start").expect("Symbol not found");
let script = CString::new("myscript.luau").unwrap();
nicy_start(script.as_ptr());
}
}
See Also
Custom Modules
NicyRuntime provides a powerful custom require() implementation with caching, aliases, and circular dependency detection.
Basic Usage
math_utils.luau:
local MathUtils = {}
function MathUtils.add(a, b)
return a + b
end
function MathUtils.multiply(a, b)
return a * b
end
return MathUtils
main.luau:
local MathUtils = require("math_utils")
print(MathUtils.add(10, 20)) -- 30
Module Resolution Order
When you call require("module"), NicyRuntime searches for files in this order:
module.luauc(compiled bytecode — fastest)module.luau(Luau source)module.lua(Lua source)module/init.luaucmodule/init.luaumodule/init.lua
Relative Paths
Modules can be required using relative paths:
-- From: /project/src/main.luau
local utils = require("./utils/math_utils") -- /project/src/utils/math_utils.luau
local core = require("../lib/core") -- /project/lib/core.luau
Absolute Paths
local config = require("/etc/myapp/config")
Module Caching
Loaded modules are cached by their resolved file path. Subsequent require() calls for the same module return the cached result without re-loading.
Cache Invalidation
The cache uses file fingerprints based on:
- Modification time (
mtime) — nanosecond precision - File size — in bytes
A cached module is reloaded when either the modification time or the file size changes. This provides fast cache validation without the overhead of computing content hashes.
⚠️ Cache Limitations
False positive scenario: The cache uses mtime + size fingerprinting, NOT content hashing. This means:
- If a file is edited and reverted to different content with the same size
- AND the filesystem preserves the original
mtime(e.g., viatouch -ton Linux, or certain backup/restore tools) - The runtime will serve the stale cached version instead of reloading the file
Example of cache miss:
# File has content "A" at time T1
echo "print('version A')" > module.luau
require("module") -- Loads and caches version A
# File is edited to content "B" (same size), mtime is artificially reset to T1
echo "print('version B')" > module.luau
touch -t 202604150000 module.luau -- Resets mtime to original time
require("module") -- ❌ Still returns cached version A!
When this matters:
- Automated backup/restore tools that preserve timestamps
- Git operations that restore files with original timestamps
- Manual
touchcommands that reset modification times - File synchronization tools (rsync, robocopy) with timestamp preservation
When this is NOT a problem:
- Normal development workflow (editing files changes mtime)
- Build pipelines that copy/regenerate files (mtime changes)
- Production deployments (files are static)
Workarounds
If you need robust cache invalidation:
- Manual cache clear: Restart the runtime (clears all cached modules)
- Future feature:
robust-cachefeature flag using SHA-256 content hashing (planned) - Development workflow: Always save files normally (don’t manipulate timestamps)
Cache Scope
- Cache is per-runtime-instance. Each
nicy_start()call creates a fresh cache. - Cache is not shared between separate runtime invocations.
- Cache entries are automatically cleaned up when the runtime shuts down.
Circular Dependencies
NicyRuntime detects circular dependencies and throws a clear error:
-- a.luau
local B = require("b") -- Error: Cyclic require detected: a -> b -> a
Error output:
Error: Cyclic require detected
require chain:
a.luau
→ b.luau
→ a.luau (circular)
Concurrent Loading
If a module is already being loaded by another coroutine, require() will yield and wait for the loading to complete. This prevents duplicate loading of the same module.
The @self Alias
@self refers to the directory of the current script:
-- From: /project/src/main.luau
local utils = require("@self/utils/math") -- /project/src/utils/math.luau
This is especially useful in libraries where you want to require sibling modules without knowing the absolute path.
See Also
.luaurc Aliases
The .luaurc file allows you to define module aliases for cleaner require paths.
Creating a .luaurc File
Place a .luaurc file in your project root:
.luaurc:
{
"aliases": {
"@modules": "src/modules",
"@lib": "lib",
"@config": "config"
}
}
Using Aliases
-- Instead of:
local myModule = require("src/modules/myModule")
-- You can write:
local myModule = require("@modules/myModule")
Alias Resolution
Aliases are resolved relative to the directory containing the .luaurc file:
project/
├── .luaurc ← aliases defined here
├── src/
│ ├── modules/
│ │ └── myModule.luau
│ └── main.luau
└── lib/
└── utils.luau
main.luau:
local myModule = require("@modules/myModule") -- src/modules/myModule.luau
local utils = require("@lib/utils") -- lib/utils.luau
Directory Inheritance
.luaurc files are inherited up the directory tree. If a .luaurc is not found in the current directory, NicyRuntime searches parent directories.
project/
├── .luaurc ← global aliases
├── src/
│ ├── .luaurc ← src-specific aliases (merged with parent)
│ └── main.luau
project/.luaurc:
{
"aliases": {
"@lib": "lib"
}
}
project/src/.luaurc:
{
"aliases": {
"@components": "components"
}
}
project/src/main.luau:
-- Both aliases are available:
local utils = require("@lib/utils") -- from parent .luaurc
local comp = require("@components/button") -- from local .luaurc
Multiple .luaurc Files
When multiple .luaurc files exist in the directory tree:
- Child aliases take precedence over parent aliases
- Aliases are merged (not replaced)
JSON Format
The .luaurc file must be valid JSON. Only the aliases key is currently supported:
{
"aliases": {
"alias_name": "path/to/directory"
}
}
The @self Alias
@self is a built-in alias that always refers to the directory of the current script. It does not need to be defined in .luaurc.
See Also
Task Scheduler
NicyRuntime includes a built-in async task scheduler based on Luau coroutines.
Overview
The task global table provides these functions:
| Function | Description |
|---|---|
task.spawn(fn, ...) | Create and schedule a concurrent task |
task.defer(fn, ...) | Schedule for execution after current tasks |
task.delay(seconds, fn, ...) | Schedule execution after a delay |
task.wait(seconds?) | Pause the current coroutine |
task.cancel(thread_or_id) | Cancel a spawned task or delayed task |
task.spawn
Create a new coroutine and schedule it for immediate execution.
task.spawn(function()
print("Running in a separate task!")
end)
-- With arguments
task.spawn(function(name, count)
for i = 1, count do
print(name .. ": " .. i)
end
end, "Worker", 5)
task.defer
Schedule a function to run after all currently ready tasks yield or complete.
print("Before defer")
task.defer(function()
print("Deferred!")
end)
print("After defer")
-- Output:
-- Before defer
-- After defer
-- Deferred!
task.delay
Schedule a function to run after a delay (in seconds).
local id = task.delay(2.0, function()
print("2 seconds later!")
end)
task.wait(3.0)
Returns a numeric ID for cancellation:
local id = task.delay(5.0, function()
print("This won't fire")
end)
task.cancel(id)
task.wait
Pause the current coroutine for the specified duration (in seconds).
print("Waiting 1 second...")
local elapsed = task.wait(1.0)
print(string.format("Waited %.3f seconds", elapsed))
Returns: Actual elapsed time in seconds (float).
Main Thread vs Coroutine Behavior
| Context | Behavior | CPU Usage |
|---|---|---|
| Main thread (entry script) | Synchronous busy-wait loop with run_one_iteration() | ⚠️ High (calls std::thread::yield_now()) |
Spawned task (via task.spawn) | Async yield to scheduler; coroutine is suspended | ✅ None (coroutine is parked) |
Limitations
- Minimum precision: 1ms (internally rounded). Values < 0.001s are rounded up.
- Maximum timeout: 10 years (implementation limit). Values exceeding this are capped.
- Main thread busy-wait: When called from the main thread,
task.wait()consumes CPU even withyield_now(). Use only for short waits; prefertask.spawnfor long delays. - Non-finite values:
nil,NaN,Infinity, and negative values are treated as 0 (immediate yield).
Example: Main Thread vs Task
-- Main thread: busy-wait (high CPU)
print("Main thread waiting...")
task.wait(1.0) -- Blocks and consumes CPU
-- Spawned task: async yield (no CPU)
task.spawn(function()
print("Task waiting...")
task.wait(1.0) -- Yields to scheduler, zero CPU
end)
task.cancel
Cancel a running task or a scheduled delay.
local t = task.spawn(function()
while true do
print("Running...")
task.wait(0.5)
end
end)
task.wait(2.0)
task.cancel(t)
Returns true if cancelled, false if not found or already completed.
Cancellation by Type
| Argument Type | Behavior |
|---|---|
Thread (from task.spawn) | Removes from all scheduler queues, clears pending timers, unreferences registry entry |
Delay ID (number from task.delay) | Removes timer entry, prevents function from firing |
Limitations
- Delay ID precision: IDs are passed as
f64(Lua numbers). IDs exceeding2^53(9,007,199,254,740,992) are rejected due to IEEE 754 precision loss. A warning is logged:"task.cancel: id X exceeds safe integer range (2^53), ignoring". - Silent failure: Canceling a non-existent or already-completed task returns
falsewithout error. This is by design for safe cleanup. - Thread reference: The thread must have been registered with the scheduler (via
task.spawn,task.delay, or explicittask.wait). Coroutines created viacoroutine.createare not tracked.
Example: Delay ID Limit
-- This will fail silently with a warning
local huge_id = 9007199254740993.0 -- Exceeds 2^53
task.cancel(huge_id) -- Returns false, warns: "id exceeds safe integer range"
-- Normal usage (safe range)
local id = task.delay(5.0, function() end)
task.cancel(id) -- Works correctly
Scheduler Behavior
The scheduler processes tasks in this order:
- Ready queue (
task.spawn) - Yielded queue (
task.defer) - Timers (
task.delay,task.wait)
Cooperative Multitasking
Tasks must call task.wait() to yield. An infinite loop without yielding blocks all other tasks:
-- Bad: blocks scheduler
task.spawn(function()
while true do
-- No yield!
end
end)
-- Good: cooperative
task.spawn(function()
while true do
-- Do work
task.wait(0) -- Yield
end
end)
Main Thread Behavior
When using nicy run, the main thread:
- Executes the entry script
- Runs the scheduler until all tasks complete
- Exits
Error Handling
NicyRuntime provides a robust error handling system with concise output by default and verbose mode for debugging.
Concise Mode (Default)
Errors are displayed in a compact, readable format:
Error: module 'missing_module' not found
searched:
./missing_module.luauc
./missing_module.luau
./missing_module.lua
Verbose Mode
Enable verbose mode with the NICY_VERBOSE_ERRORS environment variable:
NICY_VERBOSE_ERRORS=1 nicy run broken.luau
Verbose output includes:
- Full stack trace
- Require chain (which modules required which)
- File paths and line numbers
- Exception details (PowerShell-style formatting)
Error Codes
NicyRuntime extends standard Luau error codes with custom codes for better error categorization.
Standard Luau Codes
| Code | Name | Description |
|---|---|---|
| 0 | LUA_OK | Success |
| 1 | LUA_YIELD | Coroutine yielded |
| 2 | LUA_ERRRUN | Runtime error |
| 3 | LUA_ERRSYNTAX | Syntax error |
| 4 | LUA_ERRMEM | Memory error |
| 5 | LUA_ERRERR | Error handler error |
| 6 | LUA_ERRFILE | File error |
Nicy-Specific Codes
| Code | Name | Description | Luau Equivalent |
|---|---|---|---|
| 100 | NICY_ERR_MODULE_NOT_FOUND | Require failed to resolve module | LUA_ERRFILE |
| 101 | NICY_ERR_MODULE_LOAD_FAILED | Module found but failed to load/compile | LUA_ERRSYNTAX |
| 102 | NICY_ERR_MODULE_INIT_FAILED | Module loaded but init function failed | LUA_ERRRUN |
| 103 | NICY_ERR_CYCLIC_REQUIRE | Cyclic dependency detected | LUA_ERRRUN |
| 104 | NICY_ERR_TASK_CRASH | Task/coroutine crashed | LUA_ERRRUN |
| 105 | NICY_ERR_NATIVE_CRASH | Native DLL crashed | LUA_ERRRUN |
| 106 | NICY_ERR_TIMEOUT | Operation timed out | LUA_ERRRUN |
| 107 | NICY_ERR_PERMISSION_DENIED | Access denied | LUA_ERRFILE |
Accessing Error Codes via FFI
When using NicyRuntime via FFI (C, C++, Rust, etc.), errors are returned as integer codes. The mapping is:
// In your C/C++ code:
int error_code = nicy_start("script.luau");
switch (error_code) {
case 0: // LUA_OK
printf("Success\n");
break;
case 100: // NICY_ERR_MODULE_NOT_FOUND
fprintf(stderr, "Module not found\n");
break;
case 103: // NICY_ERR_CYCLIC_REQUIRE
fprintf(stderr, "Cyclic require detected\n");
break;
// ... etc
}
🔧 Future: A
nicy_error_code()function will be exposed to convert error objects to codes. Currently, error codes are embedded in the error message returned bylua_tostring().
Error Colors
Errors are colorized by default using ANSI escape codes:
- Red — Error messages
- Yellow — Warnings
- Cyan — Info/context
Disabling Colors
NICY_NO_COLOR=1 nicy run script.luau
pcall and Error Suppression
Errors inside pcall are not reported to the console. The error reporter tracks pcall state and suppresses errors accordingly:
local success, err = pcall(function()
error("This error is caught")
end)
if not success then
print("Caught: " .. err)
end
-- No error output to console
Require Chain Tracking
When an error occurs inside a require() chain, NicyRuntime tracks the full chain:
RequireChain:
main.luau → a.luau → b.luau
SEH Crash Protection (Windows)
On Windows, runtime.loadlib() is wrapped in SEH (Structured Exception Handling) to catch access violations during library loading. This prevents the entire process from crashing when a library has bugs.
Bytecode Compilation
NicyRuntime can compile Luau source files to bytecode (.luauc) for faster loading and source obfuscation.
Compiling
Via CLI
# Basic compilation (respects --!native, --!optimize from source)
nicy compile myscript.luau
# Creates: myscript.luauc (same directory, same base name)
Via FFI
#include "NicyRuntime.h"
int main() {
nicy_compile("myscript.luau");
return 0;
}
Compiler Configuration
The compile command does not accept CLI flags like --native, --optimize, or --output. All configuration is done via in-source compiler directives:
-- myscript.luau
--!native
--!optimize 2
local function hot_function()
-- This will be compiled to native code with optimization level 2
end
Supported Directives
| Directive | Description |
|---|---|
--!native | Enable CodeGen/JIT for this file |
--!optimize N | Set optimization level (0-2, default: 1) |
--!coverage | Enable coverage tracking |
--!profile | Enable profiling |
--!typeinfo N | Enable type info generation (0-1) |
Running Bytecode
Bytecode files are executed the same way as source files:
# Compile
nicy compile game.luau
# Run the bytecode
nicy run game.luauc
The runtime automatically detects the .luauc extension and loads the bytecode directly.
Bytecode Priority
When requiring a module, NicyRuntime checks for bytecode first:
module.luauc— bytecode (fastest loading)module.luau— sourcemodule.lua— source
This means you can distribute bytecode alongside source, and the runtime will prefer the compiled version.
Portability
Source Files (.luau, .lua)
- ✅ Portable across platforms
- ✅ Portable across Luau versions
Bytecode Files (.luauc)
- ❌ Not portable across Luau versions
- ❌ May not be portable across architectures (especially with
--!native)
Always recompile bytecode when updating NicyRuntime.
Native Bytecode
When compiled with --!native, the bytecode includes native machine code for the target architecture.
Benefits
- ⚡ Faster execution (hot paths run as native code)
- 🔒 Source code not included
Drawbacks
- 📦 Larger file size
- 🏗️ Architecture-specific (x64 bytecode won’t work on ARM)
Native Modules (Bare Metal)
NicyRuntime supports loading native modules at runtime via runtime.loadlib(). Write performance-critical code in C, C++, Rust, Zig, or any language with C interop, and expose it directly to Luau scripts.
Overview
When you call runtime.loadlib("mylib.dll"), NicyRuntime:
- Loads the dynamic library (
.dll,.so,.dylib) - Looks for the symbol
nicydynamic_init(ornicydinamic_initas fallback) - Calls the init function, passing the current
lua_State* - The init function registers C functions, types, and data on the Lua stack
- Returns a table with the exported symbols
💡 Note: You can use either
NicyRuntime.h(which bundles everything) or standardlua.h+lauxlib.h. Both work identically.
C
Basic Example
mylib.c:
#include "NicyRuntime.h"
static int my_add(lua_State* L) {
double a = luaL_checknumber(L, 1);
double b = luaL_checknumber(L, 2);
lua_pushnumber(L, a + b);
return 1;
}
__declspec(dllexport) int nicydynamic_init(lua_State* L) {
lua_createtable(L, 0, 1);
lua_pushcfunction(L, my_add);
lua_setfield(L, -2, "add");
return 1;
}
Compiling
Windows (MSVC):
cl /LD mylib.c /Fe:mylib.dll
Linux/macOS (GCC/Clang):
gcc -shared -fPIC -o mylib.so mylib.c
C++
Example with std::string
string_ext.cpp:
#include "NicyRuntime.h"
#include <string>
#include <algorithm>
static int string_reverse(lua_State* L) {
const char* input = luaL_checkstring(L, 1);
std::string s(input);
std::reverse(s.begin(), s.end());
lua_pushstring(L, s.c_str());
return 1;
}
static int string_upper(lua_State* L) {
const char* input = luaL_checkstring(L, 1);
std::string s(input);
std::transform(s.begin(), s.end(), s.begin(), ::toupper);
lua_pushstring(L, s.c_str());
return 1;
}
static int string_repeat(lua_State* L) {
const char* input = luaL_checkstring(L, 1);
int count = (int)luaL_checkinteger(L, 2);
std::string result;
for (int i = 0; i < count; i++) result += input;
lua_pushstring(L, result.c_str());
return 1;
}
extern "C" __declspec(dllexport) int nicydynamic_init(lua_State* L) {
lua_createtable(L, 0, 3);
lua_pushcfunction(L, string_reverse);
lua_setfield(L, -2, "reverse");
lua_pushcfunction(L, string_upper);
lua_setfield(L, -2, "upper");
lua_pushcfunction(L, string_repeat);
lua_setfield(L, -2, "repeat");
return 1;
}
Compiling
Windows (MSVC):
cl /LD /EHsc string_ext.cpp /Fe:string_ext.dll
Linux/macOS (GCC/Clang):
g++ -shared -fPIC -o string_ext.so string_ext.cpp
Usage
local str = runtime.loadlib("@self/string_ext.dll")
print(str.reverse("hello")) -- "olleh"
print(str.upper("hello")) -- "HELLO"
print(str.repeat("ab", 3)) -- "ababab"
Rust
Example with JSON Parsing
Cargo.toml:
[package]
name = "json_ext"
version = "0.1.0"
edition = "2021"
[lib]
crate-type = ["cdylib"]
[dependencies]
serde_json = "1.0"
libc = "0.2"
src/lib.rs:
#![allow(unused)]
fn main() {
use std::ffi::{CStr, CString};
use std::os::raw::{c_char, c_int, c_void};
use serde_json::{Value, json};
// Type alias for the Lua state pointer
type LuaState = *mut c_void;
// Lua C API extern declarations
extern "C" {
fn luaL_checkstring(l: LuaState, narg: c_int) -> *const c_char;
fn luaL_checkinteger(l: LuaState, narg: c_int) -> i64;
fn lua_pushstring(l: LuaState, s: *const c_char);
fn lua_pushinteger(l: LuaState, n: i64);
fn lua_createtable(l: LuaState, narr: c_int, nrec: c_int);
fn lua_setfield(l: LuaState, idx: c_int, k: *const c_char);
fn lua_pushcfunction(l: LuaState, f: extern "C" fn(LuaState) -> c_int);
}
static JSON_PARSE_SYMBOL: &[u8] = b"json_parse\0";
static JSON_STRINGIFY_SYMBOL: &[u8] = b"json_stringify\0";
static JSON_VERSION_SYMBOL: &[u8] = b"version\0";
extern "C" fn json_parse(l: LuaState) -> c_int {
let input = unsafe { CStr::from_ptr(luaL_checkstring(l, 1)) };
let input_str = match input.to_str() {
Ok(s) => s,
Err(_) => return 0,
};
match serde_json::from_str::<Value>(input_str) {
Ok(value) => {
let result = serde_json::to_string(&value).unwrap_or_default();
let c_result = CString::new(result).unwrap();
unsafe { lua_pushstring(l, c_result.as_ptr()) };
1
}
Err(e) => {
let err = CString::new(format!("parse error: {}", e)).unwrap();
unsafe { lua_pushstring(l, err.as_ptr()) };
1
}
}
}
extern "C" fn json_stringify(l: LuaState) -> c_int {
let input = unsafe { CStr::from_ptr(luaL_checkstring(l, 1)) };
let input_str = match input.to_str() {
Ok(s) => s,
Err(_) => return 0,
};
match serde_json::from_str::<Value>(input_str) {
Ok(value) => {
let result = serde_json::to_string_pretty(&value).unwrap_or_default();
let c_result = CString::new(result).unwrap();
unsafe { lua_pushstring(l, c_result.as_ptr()) };
1
}
Err(_) => 0,
}
}
#[no_mangle]
pub extern "C" fn nicydynamic_init(l: LuaState) -> c_int {
unsafe {
lua_createtable(l, 0, 3);
lua_pushcfunction(l, json_parse);
lua_setfield(l, -2, JSON_PARSE_SYMBOL.as_ptr() as *const c_char);
lua_pushcfunction(l, json_stringify);
lua_setfield(l, -2, JSON_STRINGIFY_SYMBOL.as_ptr() as *const c_char);
let version = CString::new("1.0.0 (serde_json)").unwrap();
lua_pushstring(l, version.as_ptr());
lua_setfield(l, -2, JSON_VERSION_SYMBOL.as_ptr() as *const c_char);
}
1
}
}
Compiling
cargo build --release
# Output: target/release/json_ext.dll (or .so / .dylib)
Usage
local json = runtime.loadlib("@self/json_ext.dll")
-- Parse JSON
local parsed = json.parse('{"name": "Luau", "age": 5}')
print(parsed) -- {"age":5,"name":"Luau"}
-- Pretty print
local pretty = json.stringify('{"a":1,"b":2}')
print(pretty)
-- {
-- "a": 1,
-- "b": 2
-- }
Zig
Example with Hashing
hash_ext.zig:
const std = @import("std");
const c = @cImport({
@cInclude("lua.h");
@cInclude("lauxlib.h");
});
export fn nicydynamic_init(L: *c.lua_State) c_int {
c.lua_createtable(L, 0, 3);
c.lua_pushcfunction(L, hash_md5);
c.lua_setfield(L, -2, "md5");
c.lua_pushcfunction(L, hash_sha256);
c.lua_setfield(L, -2, "sha256");
c.lua_pushstring(L, "1.0.0 (Zig)");
c.lua_setfield(L, -2, "version");
return 1;
}
fn hash_md5(L: *c.lua_State) callconv(.C) c_int {
const input = c.luaL_checkstring(L, 1);
const input_slice = std.mem.span(@as([*:0]const u8, @ptrCast(input)));
var hash: [16]u8 = undefined;
// Note: In real code, use a proper crypto library
// This is a simplified example
std.mem.set(u8, &hash, 0);
const hex = std.fmt.bytesToHex(&hash, .lower);
const c_str = std.fmt.allocPrint(std.heap.c_allocator, "{s}", .{hex}) catch return 0;
c.lua_pushstring(L, c_str.ptr);
return 1;
}
fn hash_sha256(L: *c.lua_State) callconv(.C) c_int {
const input = c.luaL_checkstring(L, 1);
const input_slice = std.mem.span(@as([*:0]const u8, @ptrCast(input)));
var hash: [32]u8 = undefined;
std.mem.set(u8, &hash, 0);
const hex = std.fmt.bytesToHex(&hash, .lower);
const c_str = std.fmt.allocPrint(std.heap.c_allocator, "{s}", .{hex}) catch return 0;
c.lua_pushstring(L, c_str.ptr);
return 1;
}
Compiling
With Zig build system:
# Build as shared library
zig build-lib hash_ext.zig -dynamic -lc -llua -O ReleaseFast
# Output: hash_ext.dll (or .so / .dylib)
With build.zig:
const std = @import("std");
pub fn build(b: *std.Build) void {
const target = b.standardTargetOptions(.{});
const optimize = b.standardOptimizeOption(.{});
const lib = b.addSharedLibrary(.{
.name = "hash_ext",
.root_source_file = b.path("hash_ext.zig"),
.target = target,
.optimize = optimize,
});
lib.linkLibC();
lib.linkSystemLibrary("lua");
b.installArtifact(lib);
}
Usage
local hash = runtime.loadlib("@self/hash_ext.dll")
print(hash.md5("hello")) -- 5d41402abc4b2a76b9719d911017c592 (example)
print(hash.sha256("hello")) -- 2cf24dba5fb0a30e26e83b2ac5b9e29e... (example)
CPython (Embedding Python in Luau)
Example with Python Execution
python_ext.c:
#include "NicyRuntime.h"
#include <Python.h>
static int py_eval(lua_State* L) {
const char* code = luaL_checkstring(L, 1);
// Initialize Python (once)
if (!Py_IsInitialized()) {
Py_Initialize();
}
// Run the code
PyRun_SimpleString(code);
return 0;
}
static int py_exec(lua_State* L) {
const char* code = luaL_checkstring(L, 1);
if (!Py_IsInitialized()) {
Py_Initialize();
}
PyObject* result = PyRun_String(code, Py_file_input,
PyEval_GetGlobals(), PyEval_GetLocals());
if (result) {
lua_pushboolean(L, 1);
Py_DECREF(result);
} else {
PyErr_Print();
lua_pushboolean(L, 0);
}
return 1;
}
static int py_import(lua_State* L) {
const char* module_name = luaL_checkstring(L, 1);
if (!Py_IsInitialized()) {
Py_Initialize();
}
PyObject* module = PyImport_ImportModule(module_name);
if (module) {
lua_pushboolean(L, 1);
Py_DECREF(module);
} else {
PyErr_Print();
lua_pushboolean(L, 0);
}
return 1;
}
__declspec(dllexport) int nicydynamic_init(lua_State* L) {
lua_createtable(L, 0, 3);
lua_pushcfunction(L, py_eval);
lua_setfield(L, -2, "eval");
lua_pushcfunction(L, py_exec);
lua_setfield(L, -2, "exec");
lua_pushcfunction(L, py_import);
lua_setfield(L, -2, "import");
return 1;
}
Compiling
Windows (MSVC):
cl /LD python_ext.c /Fe:python_ext.dll /I"C:\Python312\include" /link /LIBPATH:"C:\Python312\libs" python312.lib
Linux (GCC):
gcc -shared -fPIC -o python_ext.so python_ext.c $(python3-config --cflags --ldflags)
Usage
local py = runtime.loadlib("@self/python_ext.dll")
-- Run Python code
py.eval("print('Hello from Python!')")
py.eval("import math; print(math.pi)")
-- Import modules
local success = py.import("requests")
if success then
print("requests module available")
end
Calling Convention Notes
Symbol Export
| Platform | Export Macro |
|---|---|
| Windows (MSVC) | __declspec(dllexport) |
| Windows (MinGW) | __declspec(dllexport) |
| Linux/macOS | (none needed, default visibility) |
Symbol Name
The runtime looks for:
nicydynamic_init(primary)nicydinamic_init(fallback, common typo)
Return Value
The init function must return 1 — the module table on the Lua stack. Returning anything else causes an error.
Error Handling
If your module fails to initialize (missing dependency, crash during init), runtime.loadlib returns nil + error message:
local lib, err = runtime.loadlib("@self/missing.dll")
if not lib then
print("Failed to load: " .. tostring(err))
end
SEH Crash Protection (Windows)
On Windows, runtime.loadlib() is wrapped in SEH (Structured Exception Handling). If the native library crashes during load, the error is caught and returned as a string instead of crashing the process.
Caching
Libraries are cached by their resolved path. Subsequent calls with the same path return the cached module table:
local a = runtime.loadlib("@self/mylib.dll")
local b = runtime.loadlib("@self/mylib.dll")
print(a == b) -- true (same cached instance)
Path Resolution
| Format | Description |
|---|---|
@self/lib.so | Relative to the entry script’s directory |
./relative/lib.so | Relative to current working directory |
/absolute/path/lib.so | Absolute path |
Unloading
Libraries are automatically unloaded when the runtime shuts down.
See Also
High-Resolution Timer
On Windows, the default timer resolution is ~15.6ms. NicyRuntime can enable a high-resolution timer for sub-millisecond precision.
Enabling
Set the NICY_HIRES_TIMER environment variable:
NICY_HIRES_TIMER=1 nicy run timing_test.luau
Implementation
When enabled, NicyRuntime calls:
timeBeginPeriod(1)at startuptimeEndPeriod(1)at shutdown
This sets the system timer resolution to 1ms, improving the precision of:
os.clock()os.sleep()task.wait()
⚠️ Important Warnings
System-Wide Effect
timeBeginPeriod affects the entire system, not just your process. While active:
- All applications benefit from (or are affected by) the higher timer resolution
- Power consumption may increase (CPU wakes up more frequently)
- Battery life may decrease on laptops
Only Use When Needed
Only enable the high-resolution timer when you actually need sub-millisecond precision:
- ✅ Performance profiling
- ✅ Real-time simulations
- ✅ Audio processing
- ❌ General scripting
Automatic Cleanup
NicyRuntime automatically calls timeEndPeriod when the runtime shuts down, even if an error occurs. This ensures the system timer resolution is restored.
Platform Support
| Platform | Supported |
|---|---|
| Windows | ✅ Yes |
| macOS | ❌ No effect (already high-resolution) |
| Linux | ❌ No effect (use clock_gettime) |
| Android | ❌ No effect |
Example
-- timing_test.luau
local start = os.clock()
-- Some computation
local sum = 0
for i = 1, 1000000 do
sum = sum + i
end
local elapsed = os.clock() - start
print(string.format("Sum: %d", sum))
print(string.format("Time: %.6f seconds", elapsed))
Run with high-resolution timer:
NICY_HIRES_TIMER=1 nicy run timing_test.luau
Alternatives
For cross-platform high-resolution timing, consider using os.clock() without the flag — on non-Windows platforms, it already uses high-resolution timers.
CodeGen / JIT
Luau CodeGen (Code Generation) compiles hot Luau functions to native machine code at runtime, providing near-native performance.
How It Works
- Luau code is initially interpreted by the VM
- The CodeGen profiler identifies “hot” functions (frequently executed)
- Hot functions are compiled to native machine code via LLVM
- Subsequent calls execute the native code directly
Enabling CodeGen
Via CLI
nicy compile myscript.luau --native
Via Compiler Directive
--!native
local function hot_function()
-- This will be compiled to native code
end
Via FFI
nicy_compile("myscript.luau", "myscript.luauc", 1, 1);
// native=1
Checking Availability
if runtime.hasJIT then
print("CodeGen is available!")
else
print("Running in interpreted mode only")
end
Platform Support
Feature Flags by Platform
NicyRuntime uses platform-specific feature flags to ensure ABI compatibility and stability. The following table shows which Luau features are enabled on each platform:
| Platform | Architecture | CodeGen/JIT | Vector4 | Notes |
|---|---|---|---|---|
| Windows | x64 | ✅ | ✅ | Full support |
| Windows | ARM64 | ✅ | ✅ | Full support |
| Windows | x86 | ✅ | ✅ | Full support |
| macOS | x64 | ✅ | ✅ | Full support |
| macOS | ARM64 | ✅ | ✅ | Full support |
| Linux | x64 | ✅ | ✅ | Full support |
| Linux | ARM64 | ✅ | ✅ | Full support |
| Linux | x86 (32-bit) | ✅ | ❌ | TValue ABI mismatch — lua_TValue size differs on i686 |
| Android | ARM64 | ❌ | ❌ | Disabled for stability — JIT can cause crashes on some devices |
| Android | ARMv7 | ❌ | ❌ | Disabled for stability |
Why These Differences?
Vector4 (Linux x86): The luau-vector4 feature is disabled on 32-bit Linux because of a static assertion failure: sizeof(lua_TValue) == 24. On i686, the TValue structure has a different size, causing ABI incompatibility with Luau bytecode compiled on other platforms.
CodeGen (Android): The luau-codegen feature is disabled on Android because LLVM JIT compilation can cause instability on certain Android devices, especially those with SELinux restrictions or limited memory.
What This Means for You
--!nativedirective: On platforms without CodeGen, this directive is silently ignored. Useruntime.hasJIT(path)to check if JIT is active for a specific file.- Cross-platform bytecode: Bytecode compiled with
--!nativeon x64 will NOT run on x86 or ARM due to architecture-specific machine code. - Vector4: If your code uses
vector4type, it will NOT work on Linux x86 (32-bit). Test on your target platform.
Performance Impact
CodeGen can provide 2-10x speedup for compute-intensive code:
--!native
--!optimize 2
local function fibonacci(n)
if n <= 1 then return n end
return fibonacci(n - 1) + fibonacci(n - 2)
end
local start = os.clock()
local result = fibonacci(35)
local elapsed = os.clock() - start
print(string.format("fibonacci(35) = %d", result))
print(string.format("Time: %.4f seconds", elapsed))
Typical results:
- Interpreted: ~2.5 seconds
- With CodeGen: ~0.3 seconds (~8x faster)
Limitations
- Not all Luau constructs can be compiled to native code
- Fallback to interpreter for unsupported operations
- Native code increases bytecode file size
- Native bytecode is architecture-specific
Optimization Levels
Combine CodeGen with optimization levels:
--!native
--!optimize 2 -- Aggressive optimization
| Level | Description |
|---|---|
| 0 | No optimization (debugging) |
| 1 | Default (balanced) |
| 2 | Aggressive (production) |
See Also
Memory Management
Understanding how NicyRuntime manages memory and how to avoid leaks.
Luau Garbage Collection
Luau uses incremental garbage collection. The collector runs automatically in the background, so you typically don’t need to manage memory manually.
Manual GC Control
-- Force a full collection
collectgarbage("collect")
-- Get memory usage (KB)
local mem = collectgarbage("count")
print("Memory: " .. mem .. " KB")
-- Stop the collector
collectgarbage("stop")
-- Restart the collector
collectgarbage("restart")
-- Perform a single step
collectgarbage("step")
GC via FFI
// Full collection
nicy_lua_gc(L, LUA_GCCOLLECT, 0);
// Get memory usage (KB)
int kb = nicy_lua_gc(L, LUA_GCCOUNT, 0);
// Stop/restart
nicy_lua_gc(L, LUA_GCSTOP, 0);
nicy_lua_gc(L, LUA_GCRESTART, 0);
NicyRuntime Memory Safety
Static State Cleanup
NicyRuntime cleans up all static state between calls to nicy_start() (FIX C-1). This prevents:
- Stale module caches from previous executions
- Dangling coroutine references
- Memory leaks from loaded libraries
Library Unloading
Libraries loaded via runtime.loadlib() are unloaded when the runtime shuts down (FIX C-5). The cleanup sequence:
- Stop the task scheduler
- Unref all registry references
- Unload all dynamically loaded libraries
- Destroy the Luau state
- Clear static state (module cache, require chain, etc.)
Avoiding Memory Leaks
In Luau Scripts
-- Good: Let GC collect unused data
local function processData()
local largeTable = {}
for i = 1, 1000000 do
largeTable[i] = i * 2
end
local result = compute(largeTable)
return result -- largeTable is now eligible for GC
end
-- Bad: Holding references indefinitely
local cache = {}
local function cachedProcess(key)
if not cache[key] then
cache[key] = loadData(key) -- Cache grows forever!
end
return cache[key]
end
In Host Applications
// Good: Clean up after each execution
for (int i = 0; i < 100; i++) {
nicy_start("script.luau", 0, NULL);
// Static state is cleaned up automatically
}
// Good: Use nicy_eval for isolated evaluations
for (int i = 0; i < 100; i++) {
char code[64];
sprintf(code, "return %d * 2", i);
const char* result = nicy_eval(code);
// Each call creates and destroys its own state
}
Memory Profiling
Enable verbose errors to see more details about memory-related issues:
NICY_VERBOSE_ERRORS=1 nicy run memory_test.luau
Monitor memory usage in your host application:
// Get Luau memory usage
int kb = nicy_lua_gc(L, LUA_GCCOUNT, 0);
printf("Luau memory: %d KB\n", kb);
Known Issues
| Issue | Status | Fix |
|---|---|---|
Stale module cache between nicy_start calls | ✅ Fixed (FIX C-1) | Static state cleanup |
| Loaded libraries not unloaded | ✅ Fixed (FIX C-5) | Library unload on shutdown |
| Coroutine references leaked | ✅ Fixed | Scheduler shutdown with unref |
Cross-Platform
NicyRuntime supports Windows, macOS, Linux, and Android with platform-specific considerations.
Supported Platforms
| Platform | Architecture | Status | Library Extension |
|---|---|---|---|
| Windows | x64 | ✅ Stable | .dll |
| Windows | x86 | ✅ Stable | .dll |
| Windows | ARM64 | ⚠️ Beta | .dll |
| macOS | x64 | ✅ Stable | .dylib |
| macOS | ARM64 | ✅ Stable | .dylib |
| Linux | x64 | ✅ Stable | .so |
| Linux | ARM64 | ✅ Stable | .so |
| Linux | x86 | ✅ Stable (no vector4) | .so |
| Android | ARM64 | ✅ Stable | .so |
| Android | ARMv7 | ✅ Stable | .so |
Platform-Specific Features
Windows
| Feature | Status |
|---|---|
| CodeGen/JIT | ✅ Supported |
| High-Resolution Timer | ✅ NICY_HIRES_TIMER=1 |
| SEH Crash Protection | ✅ runtime.loadlib() |
| luau-vector4 | ✅ Supported |
macOS
| Feature | Status |
|---|---|
| CodeGen/JIT | ✅ Supported |
| High-Resolution Timer | ❌ No effect (already high-res) |
| SEH Crash Protection | ❌ Not applicable |
| luau-vector4 | ✅ Supported |
Linux
| Feature | Status |
|---|---|
| CodeGen/JIT | ✅ Supported (x64, ARM64) |
| High-Resolution Timer | ❌ No effect |
| SEH Crash Protection | ❌ Not applicable |
| luau-vector4 | ✅ x64/ARM64 only |
Android
| Feature | Status |
|---|---|
| CodeGen/JIT | ❌ Disabled (stability) |
| High-Resolution Timer | ❌ Not applicable |
| SEH Crash Protection | ❌ Not applicable |
| luau-vector4 | ❌ Disabled |
Cross-Compilation
Building from Windows
# Build for your current platform
.\build.ps1 -target user
# Build for all platforms
.\build.ps1 -target all
Building from Linux/macOS
# Install zig and cargo-zigbuild
cargo install cargo-zigbuild --locked
# Build for Windows x64
cargo zigbuild --release --target x86_64-pc-windows-gnu -p nicyruntime
# Build for Linux ARM64
cargo zigbuild --release --target aarch64-unknown-linux-gnu -p nicyruntime
Building for Android
# Install Android NDK r26d
# Install cargo-ndk
cargo install cargo-ndk --locked
# Build for ARM64
cargo ndk -t arm64-v8a build --release -p nicyruntime
# Build for ARMv7
cargo ndk -t armeabi-v7a build --release -p nicyruntime
Path Handling
NicyRuntime handles path separators correctly on all platforms:
-- Works on all platforms
local mod = require("modules/myModule")
-- Platform-specific paths
local home = os.getenv("HOME") or os.getenv("USERPROFILE")
Line Endings
Luau source files should use LF (\n) line endings. CRLF (\r\n) files on Windows are handled correctly by the parser.
See Also
Performance Tips
Maximize the performance of your Luau scripts on NicyRuntime.
1. Enable CodeGen/JIT
The single biggest performance improvement:
--!native
--!optimize 2
-- Your compute-intensive code here
Or compile with native code generation:
nicy compile myscript.luau --native --optimize 2
See CodeGen/JIT for details.
2. Use Local Variables
Local variables are significantly faster than globals:
-- Slow: Global access
function compute()
math.sin(x) -- Global lookup each call
end
-- Fast: Local reference
local sin = math.sin
function compute()
sin(x) -- Local access
end
3. Avoid Table Allocations in Loops
-- Bad: Creates a new table every iteration
for i = 1, 1000000 do
local t = {x = i, y = i * 2}
process(t)
end
-- Good: Reuse a single table
local t = {}
for i = 1, 1000000 do
t.x = i
t.y = i * 2
process(t)
end
4. Use Integer Arithmetic When Possible
-- Slower: Floating point
local x = 10.0 + 20.0
-- Faster: Integer
local x = 10 + 20
Luau distinguishes between integers and floats internally. Integer operations are faster.
5. Minimize Function Call Overhead
-- Bad: Function call in tight loop
for i = 1, 1000000 do
result = add(result, i)
end
-- Good: Inline the operation
for i = 1, 1000000 do
result = result + i
end
6. Use rawget/rawset for Performance-Critical Access
-- With metamethod lookup
local value = table.key
-- Without metamethod lookup (faster)
local value = rawget(table, "key")
7. Pre-allocate Tables
-- Bad: Table grows dynamically
local t = {}
for i = 1, 1000000 do
t[i] = i
end
-- Good: Pre-allocate
local t = table.create(1000000)
for i = 1, 1000000 do
t[i] = i
end
8. Use task.defer for Non-Urgent Work
-- Spawn heavy work to run after current frame
task.defer(function()
heavyComputation()
end)
-- Continue with urgent work
urgentWork()
9. Profile Your Code
--!profile
local function slowFunction()
-- This will be profiled
end
Or use os.clock() for manual profiling:
local start = os.clock()
myFunction()
local elapsed = os.clock() - start
print(string.format("myFunction took %.6f seconds", elapsed))
10. Enable High-Resolution Timer (Windows)
For accurate profiling on Windows:
NICY_HIRES_TIMER=1 nicy run profile.luau
Benchmark Example
--!native
--!optimize 2
local function benchmark(name, fn, iterations)
local start = os.clock()
for i = 1, iterations do
fn()
end
local elapsed = os.clock() - start
print(string.format("%s: %.4f ms/op", name, elapsed / iterations * 1000))
end
-- Compare approaches
benchmark("global math.sin", function()
math.sin(1.5)
end, 1000000)
local sin = math.sin
benchmark("local sin", function()
sin(1.5)
end, 1000000)
See Also
Running Tests
NicyRuntime includes a comprehensive test suite for validating runtime behavior.
Quick Start
Run all tests:
nicy run Runtime/tests/run_all.luau
Run a single test file:
nicy run Runtime/tests/Task/spawn.luau
Test Categories
Tests are organized by category:
| Category | Files | Description |
|---|---|---|
| Core | 11 | Basic Luau functionality (stdlib, GC, metatables, vectors, etc.) |
| Require | 6 + fixtures | Module resolution, aliases, circular deps, bytecode loading |
| Runtime | 6 | Error handling, globals, debug, shutdown |
| Task | 7 | Scheduler functionality (spawn, defer, delay, wait, cancel, stress) |
Running Individual Tests
Each test is a standalone .luau file that can be executed with:
# From project root
nicy run Runtime/tests/Core/api.luau
nicy run Runtime/tests/Require/aliases.luau
nicy run Runtime/tests/Runtime/error_handler.luau
nicy run Runtime/tests/Task/precision.luau
Test Output Format
Tests use the built-in test helper (Runtime/tests/helpers/expect.luau) for assertions:
-- Example test
local result = some_function()
expect(result).to_equal("expected_value")
print("✓ Test name")
Output:
✓ Test name
✗ Failed test
Expected: "expected_value"
Got: "actual_value"
Test Fixtures
Some tests use fixture files in Runtime/tests/Require/fixtures/:
simple.luau— Basic module for require testingnested.luau— Nested module structure for path resolution testing
Continuous Integration
Tests are run automatically on every push via GitHub Actions. See .github/workflows/.
Adding New Tests
- Create a
.luaufile in the appropriate category directory - Use
expect()fromhelpers/expect.luaufor assertions - Print
✓ Test nameon success, descriptive error on failure - Add to
run_all.luauif not auto-discovered
Test Structure
NicyRuntime’s test suite is organized by functionality area.
Directory Layout
Runtime/tests/
├── Core/ # Basic Luau functionality
│ ├── api.luau # Lua C API wrapper testing
│ ├── bit32.luau # Bit32 library
│ ├── buffers.luau # Buffer handling
│ ├── edge_cases.luau
│ ├── gc.luau # Garbage collection
│ ├── io_files.luau # File I/O
│ ├── luaurc.luau # .luaurc parsing
│ ├── metatables.luau
│ ├── os_ext.luau # OS extensions
│ ├── stdlib.luau # Standard library
│ └── vectors.luau # Vector4 type
├── Require/ # Module system
│ ├── aliases.luau
│ ├── bytecode.luau
│ ├── circular.luau
│ ├── concurrent.luau
│ ├── relative.luau
│ ├── resolution.luau
│ └── fixtures/ # Test fixtures
│ ├── simple.luau
│ └── nested.luau
├── Runtime/ # Runtime behavior
│ ├── debug.luau
│ ├── error_handler.luau
│ ├── globals.luau
│ ├── loadlib_errors.luau
│ ├── shutdown.luau
│ └── traceback.luau
├── Task/ # Task scheduler
│ ├── cancel.luau
│ ├── defer.luau
│ ├── delay.luau
│ ├── precision.luau
│ ├── spawn.luau
│ ├── stress.luau
│ └── stress_extreme.luau
├── helpers/ # Test utilities
│ ├── expect.luau # Assertion library
│ ├── init.luau
│ └── report.luau # Test reporting
└── run_all.luau # Master test runner
Test Helper Files
helpers/expect.luau
Provides assertion-style testing:
local expect = require("helpers/expect")
expect(value).to_be(true)
expect(table).to_equal(expected)
expect(func).to_error("expected error message")
helpers/report.luau
Generates test summary reports:
Tests: 42 passed, 0 failed, 0 skipped
Time: 1.234s
Writing Tests
Basic Structure
-- Import helpers
local helpers = require("helpers/init")
-- Test 1
local result = my_function(1, 2)
assert(result == 3, "my_function should return 3")
print("✓ my_function basic test")
-- Test 2 with expect
expect(result).to_equal(3)
print("✓ my_function expect test")
Error Testing
-- Test that an error is thrown
local success, err = pcall(function()
error_function()
end)
assert(not success, "Should fail")
assert(err:find("expected error"), "Wrong error message: " .. err)
print("✓ error handling test")
Async Testing
-- Test task scheduler
task.spawn(function()
task.wait(0.1)
print("✓ async test completed")
end)
task.wait(0.5) -- Wait for task to complete
Code Coverage
Code coverage tracking for NicyRuntime’s Rust codebase.
Current Status
Target: 80% line coverage for Runtime/src/
📊 Coverage reports are not yet automated. This is a planned feature.
Manual Coverage with cargo-tarpaulin
Install tarpaulin:
cargo install cargo-tarpaulin
Run coverage:
cd Runtime
cargo tarpaulin --out Html --output-dir tarpaulin-report
This generates tarpaulin-report/tarpaulin-report.html with interactive coverage data.
Manual Coverage with grcov
For coverage of tests:
# Install grcov
cargo install grcov
# Build with coverage instrumentation
cd Runtime
RUSTFLAGS="-C instrument-coverage" cargo build
# Run tests
nicy run tests/run_all.luau
# Generate report
grcov target/ -s . --binary-path target/debug/ -t html --branch --ignore-not-existing -o grcov-report/
Coverage Goals
| Module | Current | Target |
|---|---|---|
lib.rs | TBD | 80% |
ffi_exports.rs | TBD | 90% |
require_resolver.rs | TBD | 85% |
task_scheduler.rs | TBD | 85% |
error.rs | TBD | 90% |
Future Plans
- GitHub Actions coverage check on every PR
- Coverage badge in README
- Coverage regression prevention (fail PR if coverage drops > 2%)
- Per-module coverage thresholds in CI
Contributing
When adding new features:
- Write tests that cover the new code paths
- Run
cargo tarpaulinlocally to verify coverage - Ensure new code is at least 80% covered
Troubleshooting
Common issues and their solutions.
Installation Issues
“nicy: command not found”
The CLI is not in your PATH. Add the directory containing nicy to your PATH:
# Windows
$env:PATH += ";C:\tools\nicy"
# Linux/macOS
export PATH="/usr/local/bin:$PATH"
“Library not found” (macOS)
macOS may block unsigned libraries. Allow the library:
xattr -d com.apple.quarantine libnicyruntime.dylib
“libnicyruntime.so: cannot open shared object file” (Linux)
Update the shared library cache:
sudo ldconfig
Or set LD_LIBRARY_PATH:
export LD_LIBRARY_PATH="/path/to/nicy:$LD_LIBRARY_PATH"
Runtime Issues
Module Not Found
Error: module 'mymodule' not found
Causes:
- File doesn’t exist at the expected path
- Typo in the module name
- Wrong file extension
Solutions:
- Check the searched paths in the error message
- Use
@selffor relative paths:require("@self/mymodule") - Verify the file exists:
ls mymodule.luau
Circular Require
Error: Cyclic require detected: a -> b -> a
Cause: Two modules require each other.
Solution: Restructure your code to break the cycle:
-- Before (circular):
-- a.luau: local B = require("b")
-- b.luau: local A = require("a")
-- After (no cycle):
-- common.luau: shared code
-- a.luau: local Common = require("common")
-- b.luau: local Common = require("common")
Task Scheduler Not Running
Tasks spawned with task.spawn don’t execute.
Cause: The scheduler only runs when using nicy_start(). It does not run with nicy_eval().
Solution: Use nicy_start() for scripts that use async tasks.
Native Code Not Available
print(runtime.hasJIT) -- false
Causes:
- Running on Android (CodeGen disabled)
- Built without
luau-codegenfeature - Using a pre-built binary for a platform without CodeGen support
Solution: Rebuild from source with CodeGen enabled (not applicable for Android).
Compilation Issues
“zig not found”
Install Zig 0.14.0:
# Download from https://ziglang.org/download/
# Or use a package manager
brew install zig # macOS
choco install zig # Windows
“NDK not found” (Android)
Set the ANDROID_NDK_HOME environment variable:
export ANDROID_NDK_HOME="/path/to/android-ndk-r26d"
Static Assertion Failed (Linux x86)
error: static assertion failed: size mismatch for value
This is a known issue with Luau on 32-bit Linux. The luau-vector4 feature is automatically disabled for linux-x86 targets. If you’re building from source, ensure you have the latest Cargo.toml configuration.
Performance Issues
Scripts Running Slowly
- Enable CodeGen: Add
--!nativeand--!optimize 2to your scripts - Use local variables: Avoid global lookups in hot loops
- Pre-allocate tables: Use
table.create()for known sizes - Profile your code: Use
os.clock()to find bottlenecks
See Performance Tips for more.
High Memory Usage
- Force garbage collection:
collectgarbage("collect") - Avoid global caches: Don’t store large data in global tables
- Clear references: Set variables to
nilwhen done
See Memory Management for more.
Error Reporting
Enable Verbose Errors
NICY_VERBOSE_ERRORS=1 nicy run script.luau
Disable Colors
NICY_NO_COLOR=1 nicy run script.luau
Getting Help
If you’re still stuck:
- Check the docs: Search this documentation site
- Check the issues: GitHub Issues
- Report a bug: New Issue
When reporting bugs, include:
- NicyRuntime version (
nicy --version) - Platform and architecture
- Error output (with
NICY_VERBOSE_ERRORS=1) - Minimal reproducible example