Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

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)
  • .luaurc alias 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:

PlatformArchitectureStatus
Windowsx64, x86, ARM64✅ Stable
macOSx64, ARM64 (Apple Silicon)✅ Stable
Linuxx64, ARM64✅ Stable
AndroidARM64, 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

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:

PlatformArchitectureArchiveContents
Windowsx64nicy-win-x64.zipnicy.exe, nicyruntime.dll
Windowsx86nicy-win-x86.zipnicy.exe, nicyruntime.dll
WindowsARM64nicy-win-arm.zipnicy.exe, nicyruntime.dll
macOSx64nicy-mac-x64.zipnicy, libnicyruntime.dylib
macOSARM64nicy-mac-arm.zipnicy, libnicyruntime.dylib
Linuxx64nicy-linux-x64.zipnicy, libnicyruntime.so
LinuxARM64nicy-linux-arm.zipnicy, libnicyruntime.so
AndroidARM64nicy-android-arm.zipnicy, libnicyruntime.so
AndroidARMv7nicy-android-v7.zipnicy, libnicyruntime.so

Windows

  1. Download nicy-win-x64.zip
  2. Extract to a folder (e.g., C:\tools\nicy\)
  3. 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"
)
  1. Verify installation:
nicy --version

macOS

  1. Download nicy-mac-arm.zip (Apple Silicon) or nicy-mac-x64.zip (Intel)
  2. 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/
  1. Verify:
nicy --version

Linux

  1. Download the appropriate archive for your architecture
  2. 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
  1. 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 Luau version number and with CodeGen indicator 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?

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

FileLinesPurpose
lib.rs~1,250Main entry, Luau state init, FFI functions, OS extensions
require_resolver.rs~1,205Custom require() with caching, aliases, circular detection
task_scheduler.rs~782Cooperative async scheduler with coroutines
ffi_exports.rs~52270+ Lua C API wrappers with stable C-ABI
error.rs~1,997Error reporting (concise/verbose modes)

Key Features

  • nicy_start() — Initialize runtime and execute a script file
  • nicy_eval() — Evaluate inline code in an isolated state
  • nicy_compile() — Compile source to .luauc bytecode
  • nicy_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:

  1. Dynamically loads nicyruntime via libloading
  2. Routes commands (run, eval, compile)
  3. 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:

CategoryFilesTests
Core API11stdlib, bit32, buffers, GC, IO, metatables, vectors, etc.
Require System6 + fixturesaliases, bytecode, circular deps, concurrent loading
Runtime6debug, error handler, globals, shutdown, traceback
Task Scheduler7spawn, 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

VariablePurpose
NICY_VERBOSE_ERRORS=1Enable verbose error output
NICY_NO_COLOR=1Disable ANSI colors in error messages
NICY_HIRES_TIMER=1Enable 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

Optional (for cross-compilation)

  • Zig 0.14.0 — For cross-compiling Linux/macOS binaries
  • Android NDK r26d — For Android builds
  • cargo-zigbuildcargo install cargo-zigbuild --locked
  • cargo-ndkcargo 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 (or nicy.exe on 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

TargetPlatformArchitectureToolchain
win-x64Windowsx86_64MSVC (native)
win-x86Windowsx86MSVC (native)
win-armWindowsARM64MSVC (native)
mac-x64macOSx86_64Zig
mac-armmacOSARM64Zig
linux-x64Linuxx86_64Zig
linux-armLinuxARM64Zig
linux-x86Linuxx86Zig
android-armAndroidARM64cargo-ndk
android-v7AndroidARMv7cargo-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

ArgumentRequiredDescription
<file>YesPath 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

ArgumentRequiredDescription
<code>YesLuau 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

ArgumentRequiredDescription
<file>YesPath 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

VariableEffect
NICY_VERBOSE_ERRORS=1Enable verbose error output
NICY_NO_COLOR=1Disable ANSI colors in errors
NICY_HIRES_TIMER=1Enable 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

ParameterTypeDescription
file_pathconst char*Path to the .luau, .lua, or .luauc file to execute

Description

nicy_start is the main entry point for executing Luau scripts. It:

  1. Loads the dynamic runtime library
  2. Creates a new Luau state with standard libraries
  3. Installs the error handler and runtime/task globals
  4. Loads, compiles (with optional CodeGen), and executes the script
  5. Runs the task scheduler until idle (processes all task.spawn, task.delay, etc.)
  6. 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

PropertyTypeDescription
runtime.versionstringNicyRuntime version
runtime.loadlibfunctionDynamically load a native library
runtime.hasJIT(spec?)functionCheck if JIT is available for a spec
runtime.entry_filestringPath of the executed script
runtime.entry_dirstringDirectory of the executed script

task table

FunctionDescription
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

FunctionDescription
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

VariableEffect
NICY_VERBOSE_ERRORS=1Enable verbose error output
NICY_NO_COLOR=1Disable ANSI colors in errors
NICY_HIRES_TIMER=1Enable 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

ParameterTypeDescription
codeconst 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:

FunctionDescription
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 true if the task was found and cancelled
  • Returns false if 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:

  1. Ready queue (task.spawn) — Tasks that are ready to run
  2. Yielded queue (task.defer) — Tasks that yielded and are ready to resume
  3. 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:

  1. Executes the entry script
  2. Runs the scheduler until all tasks complete
  3. 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

FunctionReturns
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

FormatDescription
@self/pathRelative to the entry script’s directory
./relative/pathRelative to the current working directory
/absolute/pathAbsolute 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:

LevelDescription
0No optimization (fastest compilation, slowest execution)
1Default optimization (balanced)
2Aggressive 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 info
  • 1 — Basic type info
  • 2 — 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

  1. CLI flags (highest priority) — Flags passed to nicy compile or nicy_compile() override source directives
  2. Source directives — Directives in the source file
  3. 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:

  1. Reads lines starting with --!
  2. Extracts the directive name and optional value
  3. Strips the directive lines from the source (they are not executed)
  4. 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

FunctionSignature
nicy_lua_gettopc_int(*l)
nicy_lua_settopvoid(*l, idx)
nicy_lua_pushvaluevoid(*l, idx)
nicy_lua_removevoid(*l, idx)
nicy_lua_insertvoid(*l, idx)
nicy_lua_absindexc_int(*l, idx)
nicy_lua_checkstackc_int(*l, extra)
nicy_lua_popvoid(*l, n) (macro: settop(l, -n-1))

Push Operations

FunctionSignature
nicy_lua_pushnilvoid(*l)
nicy_lua_pushbooleanvoid(*l, b: c_int)
nicy_lua_pushnumbervoid(*l, n: lua_Number)
nicy_lua_pushintegervoid(*l, n: lua_Integer)
nicy_lua_pushstringvoid(*l, s: *const c_char)
nicy_lua_pushlstringvoid(*l, s: *const c_char, len: usize)
nicy_lua_pushcfunctionvoid(*l, f: lua_CFunction)
nicy_lua_pushcclosurevoid(*l, f: lua_CFunction, n: c_int)
nicy_lua_pushlightuserdatavoid(*l, p: *mut c_void)
nicy_lua_newuserdata*mut c_void(*l, sz: usize)
nicy_lua_newthread*mut LuauState(*l)

Type Checking

FunctionSignature
nicy_lua_typec_int(*l, idx)
nicy_lua_typename*const c_char(*l, tp: c_int)
nicy_lua_isnilc_int(*l, idx)
nicy_lua_isbooleanc_int(*l, idx)
nicy_lua_isnumberc_int(*l, idx)
nicy_lua_isstringc_int(*l, idx)
nicy_lua_istablec_int(*l, idx)
nicy_lua_isfunctionc_int(*l, idx)
nicy_lua_isuserdatac_int(*l, idx)
nicy_lua_isthreadc_int(*l, idx)
nicy_lua_iscfunctionc_int(*l, idx)
nicy_lua_isintegerc_int(*l, idx)

Get & Conversion

FunctionSignature
nicy_lua_tostring*const c_char(*l, idx)
nicy_lua_tolstring*const c_char(*l, idx, len: *mut usize)
nicy_lua_tobooleanc_int(*l, idx)
nicy_lua_tonumberlua_Number(*l, idx)
nicy_lua_tointegerlua_Integer(*l, idx)
nicy_lua_touserdata*mut c_void(*l, idx)

Table Access

FunctionSignature
nicy_lua_getfieldvoid(*l, idx, k: *const c_char)
nicy_lua_getglobalvoid(*l, k: *const c_char)
nicy_lua_setglobalvoid(*l, k: *const c_char)
nicy_lua_gettablevoid(*l, idx)
nicy_lua_settablevoid(*l, idx)
nicy_lua_rawgetvoid(*l, idx)
nicy_lua_rawgetivoid(*l, idx, n: lua_Integer)
nicy_lua_rawsetvoid(*l, idx)
nicy_lua_rawsetivoid(*l, idx, n: lua_Integer)
nicy_lua_getmetatablec_int(*l, idx)
nicy_lua_setmetatablec_int(*l, idx)
nicy_lua_createtablevoid(*l, narr: c_int, nrec: c_int)
nicy_lua_nextc_int(*l, idx)

Call & Execution

FunctionSignature
nicy_lua_callvoid(*l, nargs: c_int, nresults: c_int)
nicy_lua_pcallc_int(*l, nargs, nresults, errfunc: c_int)
nicy_lua_errorc_int(*l)
nicy_lua_resumec_int(*l, from: *mut LuauState, nargs, nres: *mut c_int)
nicy_lua_yieldc_int(*l, nresults: c_int)

Comparison & Other

FunctionSignature
nicy_lua_equalc_int(*l, idx1, idx2)
nicy_lua_lessthanc_int(*l, idx1, idx2)
nicy_lua_rawequalc_int(*l, idx1, idx2)
nicy_lua_concatvoid(*l, n: c_int)
nicy_lua_gcc_int(*l, what: c_int, data: c_int)
nicy_lua_rawlenusize(*l, idx)

Lua 5.1 Compatibility

FunctionSignature
nicy_lua_getfenvvoid(*l, idx)
nicy_lua_setfenvc_int(*l, idx)

Auxiliary Library (lauxlib)

FunctionSignature
nicy_luaL_checkstring*const c_char(*l, narg: c_int)
nicy_luaL_checklstring*const c_char(*l, narg, len: *mut usize)
nicy_luaL_checknumberlua_Number(*l, narg)
nicy_luaL_checkbooleanc_int(*l, narg)
nicy_luaL_checkintegerlua_Integer(*l, narg)
nicy_luaL_checktypevoid(*l, narg, t: c_int)
nicy_luaL_checkanyvoid(*l, narg)
nicy_luaL_optstring*const c_char(*l, narg, d: *const c_char)
nicy_luaL_optintegerlua_Integer(*l, narg, d: lua_Integer)
nicy_luaL_optnumberlua_Number(*l, narg, d: lua_Number)
nicy_luaL_argerrorc_int(*l, narg, extramsg: *const c_char)
nicy_luaL_wherevoid(*l, lvl: c_int)
nicy_luaL_tracebackvoid(*l, l1: *mut LuauState, msg: *const c_char, level: c_int)
nicy_luaL_refc_int(*l, t: c_int)
nicy_luaL_unrefvoid(*l, t, r: c_int)
nicy_luaL_lenlua_Integer(*l, idx)
nicy_luaL_newmetatablec_int(*l, tname: *const c_char)
nicy_luaL_getmetatablec_int(*l, tname: *const c_char)
nicy_luaL_errorc_int(*l, msg: *const c_char)

Error Code Utilities

These functions help FFI integrators work with NicyRuntime’s error codes:

FunctionSignatureDescription
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_errorc_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.h header file
  • nicyruntime shared 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

Embedding in Rust

Embed NicyRuntime in a Rust application using FFI.

⚠️ Important: nicyruntime is a cdylib, NOT an rlib. You have two options:

  1. Dynamic loading (recommended): Use libloading to load the library at runtime
  2. Static linking: Use #[link(name = "nicyruntime")] with the library in your library path

Do NOT add nicyruntime as a Cargo dependency — this will fail with a crate type error.

Prerequisites

  • Rust 1.75+
  • nicyruntime shared 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:

  1. module.luauc (compiled bytecode — fastest)
  2. module.luau (Luau source)
  3. module.lua (Lua source)
  4. module/init.luauc
  5. module/init.luau
  6. module/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:

  1. If a file is edited and reverted to different content with the same size
  2. AND the filesystem preserves the original mtime (e.g., via touch -t on Linux, or certain backup/restore tools)
  3. 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 touch commands 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:

  1. Manual cache clear: Restart the runtime (clears all cached modules)
  2. Future feature: robust-cache feature flag using SHA-256 content hashing (planned)
  3. 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:

FunctionDescription
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

ContextBehaviorCPU 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 with yield_now(). Use only for short waits; prefer task.spawn for 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 TypeBehavior
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 exceeding 2^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 false without 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 explicit task.wait). Coroutines created via coroutine.create are 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:

  1. Ready queue (task.spawn)
  2. Yielded queue (task.defer)
  3. 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:

  1. Executes the entry script
  2. Runs the scheduler until all tasks complete
  3. 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

CodeNameDescription
0LUA_OKSuccess
1LUA_YIELDCoroutine yielded
2LUA_ERRRUNRuntime error
3LUA_ERRSYNTAXSyntax error
4LUA_ERRMEMMemory error
5LUA_ERRERRError handler error
6LUA_ERRFILEFile error

Nicy-Specific Codes

CodeNameDescriptionLuau Equivalent
100NICY_ERR_MODULE_NOT_FOUNDRequire failed to resolve moduleLUA_ERRFILE
101NICY_ERR_MODULE_LOAD_FAILEDModule found but failed to load/compileLUA_ERRSYNTAX
102NICY_ERR_MODULE_INIT_FAILEDModule loaded but init function failedLUA_ERRRUN
103NICY_ERR_CYCLIC_REQUIRECyclic dependency detectedLUA_ERRRUN
104NICY_ERR_TASK_CRASHTask/coroutine crashedLUA_ERRRUN
105NICY_ERR_NATIVE_CRASHNative DLL crashedLUA_ERRRUN
106NICY_ERR_TIMEOUTOperation timed outLUA_ERRRUN
107NICY_ERR_PERMISSION_DENIEDAccess deniedLUA_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 by lua_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

DirectiveDescription
--!nativeEnable CodeGen/JIT for this file
--!optimize NSet optimization level (0-2, default: 1)
--!coverageEnable coverage tracking
--!profileEnable profiling
--!typeinfo NEnable 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:

  1. module.luauc — bytecode (fastest loading)
  2. module.luau — source
  3. module.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:

  1. Loads the dynamic library (.dll, .so, .dylib)
  2. Looks for the symbol nicydynamic_init (or nicydinamic_init as fallback)
  3. Calls the init function, passing the current lua_State*
  4. The init function registers C functions, types, and data on the Lua stack
  5. Returns a table with the exported symbols

💡 Note: You can use either NicyRuntime.h (which bundles everything) or standard lua.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

PlatformExport Macro
Windows (MSVC)__declspec(dllexport)
Windows (MinGW)__declspec(dllexport)
Linux/macOS(none needed, default visibility)

Symbol Name

The runtime looks for:

  1. nicydynamic_init (primary)
  2. 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

FormatDescription
@self/lib.soRelative to the entry script’s directory
./relative/lib.soRelative to current working directory
/absolute/path/lib.soAbsolute 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 startup
  • timeEndPeriod(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

PlatformSupported
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

  1. Luau code is initially interpreted by the VM
  2. The CodeGen profiler identifies “hot” functions (frequently executed)
  3. Hot functions are compiled to native machine code via LLVM
  4. 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:

PlatformArchitectureCodeGen/JITVector4Notes
Windowsx64Full support
WindowsARM64Full support
Windowsx86Full support
macOSx64Full support
macOSARM64Full support
Linuxx64Full support
LinuxARM64Full support
Linuxx86 (32-bit)TValue ABI mismatch — lua_TValue size differs on i686
AndroidARM64Disabled for stability — JIT can cause crashes on some devices
AndroidARMv7Disabled 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

  • --!native directive: On platforms without CodeGen, this directive is silently ignored. Use runtime.hasJIT(path) to check if JIT is active for a specific file.
  • Cross-platform bytecode: Bytecode compiled with --!native on x64 will NOT run on x86 or ARM due to architecture-specific machine code.
  • Vector4: If your code uses vector4 type, 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
LevelDescription
0No optimization (debugging)
1Default (balanced)
2Aggressive (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:

  1. Stop the task scheduler
  2. Unref all registry references
  3. Unload all dynamically loaded libraries
  4. Destroy the Luau state
  5. 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

IssueStatusFix
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✅ FixedScheduler shutdown with unref

Cross-Platform

NicyRuntime supports Windows, macOS, Linux, and Android with platform-specific considerations.

Supported Platforms

PlatformArchitectureStatusLibrary Extension
Windowsx64✅ Stable.dll
Windowsx86✅ Stable.dll
WindowsARM64⚠️ Beta.dll
macOSx64✅ Stable.dylib
macOSARM64✅ Stable.dylib
Linuxx64✅ Stable.so
LinuxARM64✅ Stable.so
Linuxx86✅ Stable (no vector4).so
AndroidARM64✅ Stable.so
AndroidARMv7✅ Stable.so

Platform-Specific Features

Windows

FeatureStatus
CodeGen/JIT✅ Supported
High-Resolution TimerNICY_HIRES_TIMER=1
SEH Crash Protectionruntime.loadlib()
luau-vector4✅ Supported

macOS

FeatureStatus
CodeGen/JIT✅ Supported
High-Resolution Timer❌ No effect (already high-res)
SEH Crash Protection❌ Not applicable
luau-vector4✅ Supported

Linux

FeatureStatus
CodeGen/JIT✅ Supported (x64, ARM64)
High-Resolution Timer❌ No effect
SEH Crash Protection❌ Not applicable
luau-vector4✅ x64/ARM64 only

Android

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

CategoryFilesDescription
Core11Basic Luau functionality (stdlib, GC, metatables, vectors, etc.)
Require6 + fixturesModule resolution, aliases, circular deps, bytecode loading
Runtime6Error handling, globals, debug, shutdown
Task7Scheduler 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 testing
  • nested.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

  1. Create a .luau file in the appropriate category directory
  2. Use expect() from helpers/expect.luau for assertions
  3. Print ✓ Test name on success, descriptive error on failure
  4. Add to run_all.luau if 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

ModuleCurrentTarget
lib.rsTBD80%
ffi_exports.rsTBD90%
require_resolver.rsTBD85%
task_scheduler.rsTBD85%
error.rsTBD90%

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:

  1. Write tests that cover the new code paths
  2. Run cargo tarpaulin locally to verify coverage
  3. 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 @self for 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-codegen feature
  • 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

  1. Enable CodeGen: Add --!native and --!optimize 2 to your scripts
  2. Use local variables: Avoid global lookups in hot loops
  3. Pre-allocate tables: Use table.create() for known sizes
  4. Profile your code: Use os.clock() to find bottlenecks

See Performance Tips for more.

High Memory Usage

  1. Force garbage collection: collectgarbage("collect")
  2. Avoid global caches: Don’t store large data in global tables
  3. Clear references: Set variables to nil when 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:

  1. Check the docs: Search this documentation site
  2. Check the issues: GitHub Issues
  3. 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