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, anddemo/siteis an animated store built that way. On the JVM it goes through the same C API into JNI, anddemo/pluginis 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.
- Install and build
- Your first program
- Command line
- Language guide
- Standard library
- Embedding, the point of the language
- Demos
- Known limits
- Design goals and non-goals
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` commandOn Windows you can also run
build.ps1. It finds the toolchain itself and setsPATHfor 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.dllOne thing that will catch you: if
w64devkit\binis not onPATH, g++ cannot find its own assembler and fails withcannot execute 'as'. Usebuild.ps1, or put the folder onPATHfor the session.
# hello.rai
fn greet(name: str) -> str:
return f"Hello {name}"
print(greet("world"))rai hello.rai
# Hello worldRun
raiwith no arguments and you get a REPL.
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
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 cozis 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.
The types are
int,f64,str,bool,nil, list, map, function and class instance.
Only
nilandfalseare falsy.0and""are both truthy, which is the Lua and Ruby model. I chose it on purpose, because it kills the oldif count:bug where empty and zero stop being distinguishable.
if 0:
print("this runs") 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
outerthis would create a local
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) — 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, notx => x * 2.
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"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.
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.
Everything you
throwhas to derive fromError, andErroris 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")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.
importis resolved at runtime. It can pull fromstd.*, from a git repository, and it can carry a version tag like@ "v0.3.1". Quoted paths are allowed.
includeis 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 jOn 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 serialandimport std.jsonat the top, and within five lines you know this code touches hardware and also goes to the network.
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,joinandcopy. Strings carrysplit,trim,upper,lower,contains,startsWith,endsWith,indexOf,replaceandreplaceAll. Maps carrykeys,valuesanditems.
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.
src/capi.his 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.
- One VM runs one script.
rs_evalis called once. If you need a second script, open a second VM, because a VM is cheap.
- 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_strgoes script to host inside a callback,rs_return_strgoes host to script inside a callback, andrs_result_strgives the host the string ars_callreturned.
- A host callback is allowed to throw. An exception escaping
rs_host_fnis caught at the boundary and handed to the script as an ordinaryError, socatchcan see it and an uncaught one reachesrs_last_error. It never crosses back into your C code.
- Register before you call
rs_eval, becauseinclude gameis resolved duringeval.
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.
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 aDoubleor aString, and they return aDoubleor aString.
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.
rs_callonly 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.
- 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.
- 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.
- 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.
- Narrow your numbers at the entry point. Everything arrives as a double, and
list[i]needs an int.
examples/holds 16 programs, and they double as the regression suite.
demo/bankais a bank interface in the browser. IBAN mod-97 validation, money formatting, transfer limits, statements and interest projection all live inbanka.rai. The JavaScript only touches the DOM.
demo/siteis an animated computer-parts store. The catalogue, the filtering, the cart, VAT, a PC-build compatibility checker and the animation timings themselves all live inmagaza.rai. It builds 424 nodes in 83 ms.
demo/pluginis 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 onesistem.raiand two Java classes that know no game rules at all.
demo/kapakis the cover image at the top of this file, computed pixel by pixel.
I measured all of these. None of them is a guess. Read them before you ship something on top of this.
- 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
Errorinstead. Hosts can move that withrs_set_max_depth.
- 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.
- 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 normalError. Catching it in your own bridge is still good manners, but it is no longer load bearing.
- 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, sotryandcatchsee it. A host that wants to signal failure without throwing still has no dedicated call, becausers_host_failis not built yet.
s = s + xin 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 ands = s + xtook 2382 ms against a 54 ms baseline oflist.pushplusjoin, 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.
- 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.
d["k"] += xandlist[i] += xstayed 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 mslist.pushplusjoinbaseline,d["k"] += xtook 3058 ms,list[i] += xtook 5199 ms andd.k += xtook 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.
- A script returning a string to
rs_callused to silently yield 0. Fixed on 28 July 2026.outis still0for a string result, becausers_callcarries adoublein its signature and shipped hosts depend on that, but the text is now readable next to it withrs_result_str(vm). That returnsNULLwhen the last call returned something other than a string. Everyrs_callrebuilds the channel, so there is no stale text left to misread.
- 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, sof"{d["}"]}"works and a backslash escapes the next character, sof"{g("a\"b")}"works too. Nesting an f-string inside an f-string works. Triple-quoted f-strings turned out to be fine already.
- 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"""...""".
- Host numbers are doubles.
items[u]withu = 0.0is an error, so callint(...)at every event entry point.
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.
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
viewblocks, declarative UI written inside the language, come from HTML, and those are planned rather than implemented.
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
viewblocks, is not started. Neither is phase 7, which is the LSP, the formatter and the package resolver.
src/is the implementation in C++20.bindings/holds the host bridges,js/for the browser andjvm/for JNI.
examples/holds the 16 example programs that also serve as the regression suite, anddemo/holds four working applications.
The full name is RaidenScript, the short name is RS, the command is
raiand the extension is.rai. It comes from Raiden, and also from 雷, thunder.
.rsbelongs to Rust,.rdsto R,.rscto MikroTik RouterOS and.rato RealAudio..raiwas free.
MIT, see LICENSE. The RaidenScript name and the Raiden Technology brand are not covered by it — see NOTICE.md.
Built by Raiden Technology.