Skip to content

Latest commit

 

History

64 Commits

Folders and files

NameName
Last commit message
Last commit date
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

RaidenScript

RaidenScript, or RS, is a small embeddable scripting language I wrote from scratch in C++20. You can write standalone programs with it, but that is not what it is for. It is meant to go inside another application and make that application programmable: the weapons in a game, the rules on a game server, the logic behind a web page.

The same interpreter runs in three hosts today. In a terminal it is a native binary, so you type rai program.rai. In a browser it goes through the C API into WebAssembly with emscripten, and demo/site is an animated store built that way. On the JVM it goes through the same C API into JNI, and demo/plugin is a Minecraft plugin built that way.

Phase 1 is complete and phases 4 to 6, the embedding work, are working. The lexer, the parser, the resolver, the tree-walking interpreter and the REPL all run. Static types, which are phase 2, and a bytecode VM, which is phase 3, are skipped for now and I explain why at the bottom. Expect things to break before v1.0.

Table of contents

  1. Install and build
  2. Your first program
  3. Command line
  4. Language guide
  5. Standard library
  6. Embedding, the point of the language
  7. Demos
  8. Known limits
  9. Design goals and non-goals

1. Install and build

You need a C++20 compiler and make. On Windows I develop this with w64devkit, gcc 16 and make 4.4, which is one folder and no installer.

git clone https://github.com/RaidenTechnology/raidenscript
cd raidenscript
make                 # -> build/rs.exe
make test            # C API test suite
make install         # optional: installs the `rai` command

On Windows you can also run build.ps1. It finds the toolchain itself and sets PATH for that session only.

There are two optional targets

make wasm                          # -> dist/raidenscript.js + .wasm   (needs emsdk)
make jni JDK_HOME=/path/to/jdk21   # -> dist/raidenscript.dll

One thing that will catch you: if w64devkit\bin is not on PATH, g++ cannot find its own assembler and fails with cannot execute 'as'. Use build.ps1, or put the folder on PATH for the session.

2. Your first program

# hello.rai
fn greet(name: str) -> str:
    return f"Hello {name}"

print(greet("world"))
rai hello.rai
# Hello world

Run rai with no arguments and you get a REPL.

Errors are meant to be read

I treat diagnostics as part of the language, not as something bolted on at the end. Given this:

fn f(x):
    return x!

you get this :

error:unexpected '!'
 --> hello.rai:2:13
  |
2 |     return x!
  |             ^
  |
  =  Use 'not' for negation.

1 error

3. Command line

rai <file.rai>     >     run a file
rai run <file.rai> >    the same, explicit
rai                >    start the REPL
rai --version      >
rai --help         >

DEVELOPMENT:
rai tokens <file.rai>  >  token dump
rai ast <file.rai>     > syntax tree dump
rai coz <file.rai>     >  name resolution only — a fast syntax/name check
rai tani <file.rai>    >  show the diagnostics engine on a file

rai coz is the one worth remembering. It parses and resolves without running, so it is the fastest way to check a script that needs a host before it can execute at all.

4. Language guide

4.1 Values and truthiness

The types are int, f64, str, bool, nil, list, map, function and class instance.

Only nil and false are falsy. 0 and "" are both truthy, which is the Lua and Ruby model. I chose it on purpose, because it kills the old if count: bug where empty and zero stop being distinguishable.

if 0:
    print("this runs")        

4.2 Variables and scope

Assignment creates a local. If you want to assign to a variable from an enclosing scope, module level included, you have to say outer. Shadowing is never silent.

total = 0

fn add(n):
    outer total = total + n     

without outer this would create a local

4.3 Strings

name = "Raiden"
n = 3
print(f"{name} has {n} items")      
print(f"{n * 2}", f"{name.upper()}")

s = "a,b,c"
s.split(",")            
s.replaceAll(",", "-")  
s.contains("b")        
s.indexOf("b")         
s[0]                    — 
len(s)                    — 

4.4 Collections

l = [3, 1, 2]
l.push(9)
l.pop()                       
l.map((x) => x * 2)           
l.filter((x) => x > 1)       
l.reduce((a, b) => a + b, 0)  
["a", "b"].join("-")          

m = { "hp": 100, "name": "Raiden" }
m["hp"] = m["hp"] - 10
m.keys()                      
for k in m.keys():
    print(k, m[k])

Lambda parameters have to be parenthesised. Write (x) => x * 2, not x => x * 2.

4.5 Control flow

if hp <= 0:
    print("dead")
elif hp < 20:
    print("hurt")
else:
    print("fine")

while i < 10:
    i = i + 1

for item in items:
    print(item)

for i in range(0, 10, 2):
    print(i)

for i in 0 .. 5:      
    print(i)

There is a conditional expression too, and it is right-associative:

label = "big" if n > 20 else "small"

4.6 Functions

fn add(a: int, b: int) -> int:
    return a + b

# multi-line lambda
handler = (event) => {
    log(event.name)
    return event.damage * 2
}

Type annotations are optional everywhere, which is what gradual typing means here. Today they are documentation plus a few resolver checks. A real type checker is phase 2.

4.7 Classes and traits

trait Drawable:
    fn draw(self, painter)

class Entity:
    hp: int = 100
    fn init(self, name):
        self.name = name
    fn hello(self) -> str:
        return f"I am {self.name}"

class Ship(Entity):
    fn init(self, name, hp):
        super.init(name)
        self.hp = hp
    fn hello(self) -> str:
        return f"{super.hello()} ({self.hp} hp)"

print(Ship("Raiden", 100).hello())    # I am Raiden (100 hp)

A class body cannot be just pass. It needs at least one field or one method.

4.8 Errors

Everything you throw has to derive from Error, and Error is a real class you can subclass.

class NotEnoughCredit(Error):
    code: int = 7

try:
    throw NotEnoughCredit("30 credits short")
catch e:
    print("caught:", e.message)
finally:
    print("always runs")

4.9 import and include

There are two keywords for pulling code in, and the difference between them is when they are resolved. I picked that as the dividing line because a compiler can actually enforce it, which a style guide cannot.

import is resolved at runtime. It can pull from std.*, from a git repository, and it can carry a version tag like @ "v0.3.1". Quoted paths are allowed.

include is resolved when the host builds you in, so it can only name static bindings the host compiled in. Quoted paths are rejected, and so are version tags.

include serial          
import std.math        
import std.json as j

On an ESP32 there is no filesystem and nothing to fetch, so a dependency that needs the network at load time simply cannot be an include. Hardware and software end up separated as a side effect of a rule about resolution time, and that is the part I like about it.

A file is allowed to use both, and that is the intended pattern. The file that bridges hardware to application reads include serial and import std.json at the top, and within five lines you know this code touches hardware and also goes to the network.

5. Standard library

The prelude is narrow on purpose. Anything not on this list needs an import:

print(...)       >       output
len(x)           >       length
type(x)          >       type name as a string
int(x) float(x)  >       conversion
str(x) bool(x)   >       conversion
range(a, b [, step])  >  range object
assert(cond, message) >  check
Error            >       the built-in error class (subclassable, has .message)

Maths is not in there, so a bare sqrt() does not work:

import std.math
print(math.sqrt(16), math.abs(-3), math.floor(2.7), math.max(2, 9))

Everything else lives as methods on the values themselves. Lists carry push, pop, map, filter, reduce, forEach, join and copy. Strings carry split, trim, upper, lower, contains, startsWith, endsWith, indexOf, replace and replaceAll. Maps carry keys, values and items.

6. Embedding, the point of the language

I keep the core small deliberately: 29 keywords, with a ceiling of 30. The power is supposed to come from host bindings. The host exposes a handful of primitives and the script becomes the rules layer on top of them.

6.1 The C API

src/capi.h is pure C. No C++ type appears in it, so emscripten, JNI or any other bridge can use it directly.

rs_vm* vm = rs_new();
rs_set_host(vm, my_callback, user_data);
rs_register(vm, "game", "spawnBullet");   
rs_eval(vm, source, "weapon.rai");
double out;
rs_call(vm, "fire", args, 2, &out);
rs_free(vm);

There are four rules at that boundary.

  1. One VM runs one script. rs_eval is called once. If you need a second script, open a second VM, because a VM is cheap.
  1. Numbers cross as double. Strings travel beside them in a separate channel and are never packed inside a number. I refused the "this double is really a handle" contract, because a contract like that quietly moves the wrong money the first time somebody misreads it. Three calls carry strings, and all three are additive to the original signatures: rs_arg_str goes script to host inside a callback, rs_return_str goes host to script inside a callback, and rs_result_str gives the host the string a rs_call returned.
  1. A host callback is allowed to throw. An exception escaping rs_host_fn is caught at the boundary and handed to the script as an ordinary Error, so catch can see it and an uncaught one reaches rs_last_error. It never crosses back into your C code.
  1. Register before you call rs_eval, because include game is resolved during eval.

6.2 Browser, through WebAssembly

make wasm      
<script src="dist/raidenscript.js"></script>
<script src="bindings/js/rs-host.js"></script>
const RS = await RaidenScriptHost.create();
const vm = RS.open({
  game: {
    spawnBullet: (x, y, angle) => scene.spawn(x, y, angle),
    playerName:  ()            => player.name,     
  },
});
vm.eval(source, "weapon.rai");
vm.call("fire", [player.x, player.y]);
vm.close();

Host functions take ordinary JS values and return either numbers or strings. The string channel is invisible from up here.

6.3 JVM, through JNI

make jni JDK_HOME=/path/to/jdk21     
RaidenScript.yukle("/abs/path/raidenscript.dll");
try (RaidenScript vm = RaidenScript.ac()) {
    vm.kaydet("mc", "message", a -> {
        player.sendMessage(RaidenScript.metin(a, 1));
        return 0.0;
    });
    vm.eval(source, "rules.rai");
    vm.cagir("onCommand");
}

Host functions receive an Object[] where each element is a Double or a String, and they return a Double or a String.

A native library can only be loaded by one class loader per JVM. If two plugins need the interpreter, either extract the DLL under two different file names, or ship them as a single plugin.

6.4 Getting data into a call

rs_call only carries doubles. The pattern that works is the other way around: the script pulls its own context.

fn onCommand():
    player = mc.commandPlayer()      
    arg    = mc.commandArg(0)

The host stores the context just before the call, and the script asks for whatever it needs. Both the browser demo and the JVM demo in this repo use exactly that.

6.5 How to write a good binding

  1. Keep application logic out of the host. The host opens a window, writes a node, plays a sound. Whether the player is allowed to, what it costs, what the message says, all of that belongs in the script.
  1. The test I use for that line: replace your primitives with versions that just print to a terminal. If the script still runs unchanged, the line is in the right place.
  1. Never let a host exception cross the boundary. Catch it in the bridge. A JS or Java exception unwinding through interpreter frames does not restore the native stack pointer, and the leak that leaves behind is permanent. Section 8 has the measurements.
  1. Narrow your numbers at the entry point. Everything arrives as a double, and list[i] needs an int.

7. Demos

  1. examples/ holds 16 programs, and they double as the regression suite.
  1. demo/banka is a bank interface in the browser. IBAN mod-97 validation, money formatting, transfer limits, statements and interest projection all live in banka.rai. The JavaScript only touches the DOM.
  1. demo/site is an animated computer-parts store. The catalogue, the filtering, the cart, VAT, a PC-build compatibility checker and the animation timings themselves all live in magaza.rai. It builds 424 nodes in 83 ms.
  1. demo/plugin is a Minecraft plugin for Paper 1.21, with an ender-chest command and a custom enchanting table carrying 16 enchants, rarity tiers, slot limits and conflicts. There is one sistem.rai and two Java classes that know no game rules at all.
  1. demo/kapak is the cover image at the top of this file, computed pixel by pixel.

8. Known limits

I measured all of these. None of them is a guess. Read them before you ship something on top of this.

  1. Recursion is capped by a counter, because a native stack overflow is not graceful. The interpreter walks the tree, so every script frame costs several C++ frames, and past the limit the process dies silently with no exception and no crash log. So the interpreter stops at 800 nested calls and raises an ordinary catchable Error instead. Hosts can move that with rs_set_max_depth.
  1. The numbers behind that cap differ per host. Native has an 8 MB stack, the measured wall is around 1000 frames, and the cap is 800. WebAssembly has to be built with -sSTACK_SIZE=8MB, because the 64 KB default dies at about 130 frames; with 8 MB the wall is again around 1000 frames and the cap is 800. The JVM defaults to 1 MB per thread and reaches about 500 frames, or roughly 5000 with -Xss16m, so the bridge lowers the cap to 400 when it opens a VM.
  1. A host exception escaping into the interpreter used to leak the stack permanently. Fixed on 28 July 2026. It was real and I measured it in the browser: 5,000 escaping exceptions dropped the safe recursion depth from 1000 to 937, and 50,000 killed the module outright. The exception was unwinding through interpreter frames whose destructors a JS-thrown exception never runs, and crossing an extern "C" boundary that way is undefined behaviour to begin with. The throw is now caught in the bridge, at the call site, before it enters a single interpreter frame, and the script sees a normal Error. Catching it in your own bridge is still good manners, but it is no longer load bearing.
  1. A script could not catch a host error. Partly fixed on 28 July 2026. A host function that throws now surfaces in the script as an ordinary Error, so try and catch see it. A host that wants to signal failure without throwing still has no dedicated call, because rs_host_fail is not built yet.
  1. s = s + x in a loop used to be quadratic. Fixed on 28 July 2026. Every step copied the whole accumulated text, so n pieces cost O(n²). At 60,000 pieces, += took 1879 ms and s = s + x took 2382 ms against a 54 ms baseline of list.push plus join, which is 35 times and 44 times slower. Both now run in about 25 ms, which makes direct accumulation faster than building a list and joining it. So I am withdrawing the advice that used to be here: write the loop the obvious way.
  1. Strings are still immutable, as SPEC section 2 says. The interpreter only grows one in place when the value being overwritten is provably the sole owner, which no program can observe. Any alias falls back to copying, and that includes t = s, a copy sitting in a list, s += s, and a value passed into a function.
  1. d["k"] += x and list[i] += x stayed quadratic after that. Fixed on 28 July 2026. The plain variable case was fixed first, but accumulating into a map key or a list element kept copying, because the read-modify-write path handed back a copy of the value and had no slot to grow. At 60,000 pieces against an 82 ms list.push plus join baseline, d["k"] += x took 3058 ms, list[i] += x took 5199 ms and d.k += x took 4838 ms, which is 36, 61 and 57 times slower. Compound assignment now takes a pointer to the slot itself when one genuinely exists, and all three run in about 60 ms. The sole-owner rule still applies, so aliases still copy, and a missing key or an out-of-range index still raises rather than quietly creating a slot.
  1. A script returning a string to rs_call used to silently yield 0. Fixed on 28 July 2026. out is still 0 for a string result, because rs_call carries a double in its signature and shipped hosts depend on that, but the text is now readable next to it with rs_result_str(vm). That returns NULL when the last call returned something other than a string. Every rs_call rebuilds the channel, so there is no stale text left to misread.
  1. A quoted string inside an f-string interpolation used to be a syntax error. Fixed on 28 July 2026. f"credit: {player["credit"]}" did not parse, even though rule 3 of SPEC section 9 has declared it valid since day one. The rule was written and never implemented. The parser was already right; the lexer was cutting the f-string body at the first quote it saw without counting {...} depth. It counts now. At depth zero a quote closes the f-string, and inside an interpolation a quote opens an inner string where braces stop counting, so f"{d["}"]}" works and a backslash escapes the next character, so f"{g("a\"b")}" works too. Nesting an f-string inside an f-string works. Triple-quoted f-strings turned out to be fine already.
  1. One edge is still there: a single-quoted f-string has to close on its own line, even in the middle of an interpolation. Break a long expression up, or use f"""...""".
  1. Host numbers are doubles. items[u] with u = 0.0 is an error, so call int(...) at every event entry point.

9. Design goals and non-goals

What I am aiming for is a small core, 29 keywords with a ceiling of 30, where the power lives in the library and in host bindings. It should embed everywhere: browser through WASM, desktop as a native binary, JVM through the bridge, and embedded on an ESP32. Diagnostics count as a feature, because the quality of a language is mostly the quality of its error messages. And typing should be gradual, so you start without types and add them where they earn their place.

The non-goals are written down because they are the only real defence against "let us add this too". This is not a replacement for C++ and not a systems language. It aims at fast enough, not at C. It will not grow an academic type system, so no dependent types and no higher-kinded types. There will be no package registry of ours, because modules resolve from repository addresses the way Go does it and I do not want to run a server. And there are no backwards-compatibility promises before v1.0.

The areas where I plan to use this language are: integrating it into the avionics control of our UAV in the SUAS 2026 competition, preparing plugins and games for a Minecraft server, and, since it is a common language that combines 5 languages ​​(Python, HTML, Java, JavaScript, C++), I aim to use it in interface design, web design, electronic microcontroller control, phone and computer application development, and many other fields.

Where the syntax comes from

Indentation blocks and the general push toward readability come from Python. Arrow functions and the object and array literals come from JavaScript. Explicit types where they matter come from C++. The view blocks, declarative UI written inside the language, come from HTML, and those are planned rather than implemented.

Roadmap

Phase 0 was the language spec plus example programs, and it is done. Phase 1 was the lexer, parser, AST, resolver, interpreter and REPL, and it is done. Phase 2, static types and nil-safety, is skipped for now. So is phase 3, the bytecode VM and GC. Phase 4 was the C API, WASM and the string channel, and it is done. Phase 5 was the web binding, the DOM and declarative animation, and it is done. Phase 6 was the JVM bridge through JNI, and it is done. Phase 5b, the view blocks, is not started. Neither is phase 7, which is the LSP, the formatter and the package resolver.

Repository layout

src/ is the implementation in C++20. bindings/ holds the host bridges, js/ for the browser and jvm/ for JNI.

examples/ holds the 16 example programs that also serve as the regression suite, and demo/ holds four working applications.

Naming

The full name is RaidenScript, the short name is RS, the command is rai and the extension is .rai. It comes from Raiden, and also from 雷, thunder.

.rs belongs to Rust, .rds to R, .rsc to MikroTik RouterOS and .ra to RealAudio. .rai was free.

License

MIT, see LICENSE. The RaidenScript name and the Raiden Technology brand are not covered by it — see NOTICE.md.

Built by Raiden Technology.

About

RaidenScript - an embeddable scripting language. Python's readability, JavaScript's runtime model, C++'s optional types, HTML's declarative UI.

Topics

Resources

Stars

1 star

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages