You are browsing as a guest. Sign up (or log in) to start making projects!

RaidenScript

  • 4 Devlogs
  • 6 Total hours

An embeddable scripting language, written from scratch in C++20. RaidenScript (RS) runs standalone programs, but its real purpose is to go inside another application and make it programmable: mods for a game, plugins for a server, automation for a desktop app. Readability from Python, runtime model from JavaScript, optional static types and embeddability from C++, declarative UI blocks from HTML. Phase 1 is done and runs: lexer, parser, AST, resolver, tree-walking interpreter and REPL — about 5,150 lines of C++20, zero compiler warnings, and 15 example programs that double as the regression suite. Two decisions I would defend anywhere: only nil and false are falsy, so 0 and the empty string are truthy and the classic if-count bug dies at the root; and diagnostics were built before the lexer, so every error carries line, column, a source excerpt, a caret and a hint. The core stays small on purpose: 29 keywords, capped at 30 until v1.0.

Ship #1 Changes requested

RaidenScript v0.1 — an embeddable scripting language written from scratch in C++20.

WHAT IT IS
A small language (29 keywords) with Python's readability and JavaScript's runtime model, built to go inside another program and make it scriptable. The same interpreter runs in three hosts, and each one has a working demo in the repo:

- Terminal — native binary, "rai program.rai", plus a REPL
- Browser — C API compiled to WebAssembly, running an animated store page
- JVM — C API through JNI, running a Paper Minecraft plugin

The demo link is my shipped game STAR BREAKER: one of its weapons is not defined in the game at all, it is a RaidenScript file the game loads and runs through the WebAssembly build.

WHAT IS IN IT
Lexer, parser, resolver, tree-walking interpreter and REPL, all hand-written; no parser generator, no dependencies. 16 example programs that double as the regression suite, 20 C API checks, 7 WebAssembly checks, zero compiler warnings. A recursion-depth guard so a runaway script raises a catchable error instead of killing the host process — verified separately on native, wasm and the JVM.

The cover image was computed pixel by pixel by a RaidenScript program (demo/kapak/kapak-ascii.rai) — no graphics library, in the program or in the language.

HOW TO TRY IT
git clone, then "make" (w64devkit/MinGW on Windows, gcc elsewhere), then "rai examples/01-temeller.rai", or just "rai" for the REPL. The README is a full manual: language guide, standard library, embedding walkthrough. Known limits are written down there rather than hidden: an exception escaping a host function leaks stack, string += is quadratic, and a script returning a string to rs_call still yields 0.

AI SPLIT
The design of the language and its core — lexer, parser, resolver, interpreter, REPL, C API, string channel — is mine and was written by me. The host bindings, the demo apps, and this week's seven interpreter bug fixes are AI-written from my designs and my audit. Logged hours are my editor time only.

Source (MIT): github.com/RaidenTechnology/raidenscript

  • 4 devlogs
  • 6h
Try project → See source code →
Open comments for this post

22m 42s logged

Seven bugs, and the ones that were never reachable

Last devlog promised a depth counter for the crash that hit all three hosts. I
built it, then kept reading instead of building, and the audit found six more.

THE COUNTER

A script that recurses too deep does not raise an error. It kills the process.
On a Minecraft server that means every player drops and the log has not one line
about it - no Java exception, no hs_err file, exit code 127.

The tempting fix is a bigger stack, but that only moves the wall: with -Xss16m
the JVM dies at ~5000 frames instead of ~500, the same way. So the interpreter
counts instead. Every script call goes through one function, and past the limit
it raises an ordinary Error - catchable with try/catch, readable through
rs_last_error.

Picking the number mattered more than writing it. My notes said “4000”, my
measurements said that was useless: native and wasm hit the wall near 1000
frames, a default JVM thread at ~500, because each script frame costs about
1.7 KB of native stack. A limit above the wall protects nobody. So: 800, and the
JNI bridge drops it to 400 because it knows what stack it stands on. The counter
also has to be restored no matter HOW a call exits - return, return signal, or
exception. My restore lines covered two of those three paths; a destructor
covers all three and cannot be forgotten.

A CRASH ANY SCRIPT COULD TRIGGER

"a,b".split(5)

That was enough to kill the host. split assumed its argument was a string,
dereferenced a checked cast without checking it, and got a null pointer. Eight
builtins had the same line copied into them. In a standalone language that is a
segfault. In an embedded one, any script author can take down the server with a
typo. It is now a type error naming the method.

OPERATORS THAT WERE NEVER REACHABLE

The lexer produced tokens for & | ^ « ». The interpreter had the arithmetic
for all five. Every test passed. But no rule in the parser ever read those
tokens, so “flags & 4” got “expression expected, found ‘&’”, and the code in the
other two layers had never once run. Three layers agreeing on a feature is not
the same as three layers connected by it. The fix was four precedence levels,
ordered like Python and C - no new keywords, so the language stays at 29 of 30.

THE SMALL ONES

throw Error(“amount missing”) reached the host as “”: the CLI path pulled
out the message field, the embedding path did not. 2 ** 64 wrapped silently to
0. 1 « 64 was undefined behaviour - on x86 the CPU takes the shift count modulo
64, so it quietly evaluates to 1. And an exception thrown from a
single-expression lambda left the interpreter in the lambda’s scope, so the
catch block ran against the wrong variables.

WHERE IT STANDS

The C API suite is 20 checks now, five written for these bugs; the wasm suite 7;
11 examples still run; zero warnings. The guard is verified on native, wasm and
JNI separately, and in each one the process was still alive after the limit was
hit. Three problems stay open, in the README rather than hidden: an exception
escaping a host function leaks stack, string += is quadratic, and a script
returning a string to rs_call still yields 0.

ON THE AI SPLIT

This changes what I said last time, so I am saying it plainly. Until now AI had
not touched src/. This round it did: I asked it to audit the interpreter and fix
what it found, and the C++ in these seven fixes is AI-written from that audit.
The language itself - lexer, parser, resolver, interpreter, REPL, C API, string
channel - is still my design and was written by me. Host bindings and demo apps
were already AI-written from my designs, as I said last devlog. Logged hours are
my editor time only.

0
0
20
Open comments for this post

2h 45m 26s logged

One interpreter, three hosts

Last devlog my language could define a weapon in my shipped game. Since then it
left the browser too, and the way out taught me more than the way in.

STRINGS WITHOUT LYING ABOUT NUMBERS

The C boundary only carried doubles. That was a deliberate choice: a number is
copied, so nobody has to ask who owns it. Then I tried to write a bank UI and
stopped in the first ten minutes. An IBAN is text. An error message is text.

The tempting fix is to pass a handle: “this double is really an index into a
string table”. I did not do it. A contract like that is invisible, and the first
time somebody misreads it the program moves the wrong money without a warning.

So strings travel BESIDE the numbers instead of inside them. args[] did not
change by a single byte, rs_host_fn kept its signature, and my already-shipped
game’s bridge kept working untouched. Text is pulled by the script, not pushed
by the host:

script:  iban = ui.input("iban")
host  :  reads the field name, returns the value

THE 64 KILOBYTE FLOOR

Then I asked whether the language could drive an animated web page, and measured
instead of guessing. 490,000 host calls per second. 200 elements updated twice
each = 1.45 ms, nine percent of a frame. Fine.

What was not fine: recursion died at depth 130. Not with an error - with memory
corruption. After it, the whole WebAssembly module was dead; I could not even
open a fresh VM on it. Emscripten’s default stack is 64 KB, and a tree-walking
interpreter spends several C++ frames per script frame. Adding -sSTACK_SIZE=8MB
moved the safe depth past 1000 and turned overflow into a catchable error. Cost:
the .wasm grew by three bytes.

Then a worse one. If a host function throws, the exception unwinds through the
interpreter’s frames without restoring the stack pointer, and that space never
comes back. I measured the drift: 5,000 escaping exceptions took the safe depth
from 1000 to 937. At 50,000 the module died. Fix: never let an exception cross
the boundary - catch it in the bridge.

THE THIRD HOST

Then JNI, and the language went into a Minecraft server. Same C header, a
different bridge, and the design copied deliberately: numbers in the array,
strings in the channel beside it.

The plugin is a custom enchanting table. Sixteen enchants, seven rarity tiers,
slot limits per rarity, conflicts, an XP cost curve, and the block of text drawn
on the item - all of it in one .rai file. The Java side is two classes that do
not contain a single enchant name. Stats are written into the schema my existing
combat plugin already reads, so enchants change real damage without one line
changing in the plugins that were already there.

Because there is no compile step, /rai reload changes the rules while players
are online.

A live server found a bug I had not: NamespacedKey only accepts [a-z0-9/._-] and
my enchant ids are camelCase. The bridge’s catch turned what would have been a
crash into a log line, and the log line named the key.

THE SAME BUG THREE TIMES

Native: depth 1000, then a silent death, exit code 127. WebAssembly: 130, then
corruption. JVM: 500, then the whole server dies with no Java exception and no
crash log. One cause - I recurse on the native stack and never count the depth.
Three hosts made it obvious in a way one host never would have. That counter is
next.

ON THE AI SPLIT

The core is mine: lexer, parser, resolver, interpreter, REPL, C API, the string
channel. AI has not touched src/ and I checked the history before writing this.
The host bindings and the demos are AI-written from my designs - the browser
binding, the JNI bridge, and three demo apps. Logged hours are my editor time
only.

0
0
12
Open comments for this post

2h 9m 1s logged

My language now defines a weapon in my shipped game

Phase 1 gave RaidenScript a lexer, parser, resolver, interpreter and REPL. Today it
got a design change of its own, and a way out of the terminal.

TWO KEYWORDS, AND WHY THE FIRST VERSION WAS WEAK

I had one keyword for pulling code in: use. I wanted hardware code to look different
from application code, so I proposed include for boards and import for everything
else. That rule was weak and I want to be honest about why. If include serial and
import math do exactly the same thing, nothing stops anyone writing include math. A
guide the compiler cannot check is not a guide, it is a convention with a syntax bill.

So the difference moved onto something the compiler can enforce:

import   resolved at runtime: std library, a git repo, a pinned version
include  resolved when the host builds you in

include now refuses quoted paths and @ “v0.3.1” version tags outright. On an ESP32
there is no filesystem and nothing to fetch, so a dependency needing the network at
load time simply cannot be an include. Hardware and software separate as a side
effect of a rule about resolution time — that is what earns two keywords instead of
one. A file may use both, and that is the point: that is what a bridge looks like.

29 keywords now, hard-capped at 30 until v1.0. One slot left, and I would rather
leave it empty than spend it on a synonym.

A BUG ONLY TURKISH COULD PRODUCE

Renaming use to import across 20 files, a bulk find-and-replace uppercased an i into
U+0130 — the Turkish dotted capital, because in Turkish that is what the uppercase of
i is. My editor did the case change in my own locale and handed C++ a character it
cannot compile.

The irony is sharp. RaidenScript deliberately accepts UTF-8 identifiers, so a Turkish
variable name is legal in the language I am building. The language I am building it
in has no such patience.

OUT OF THE TERMINAL

Then the embedding work: a C API, a WASM build, and one weapon.

The C API was not the hard part. Lifetime was. Function values hold RAW pointers into
the AST, and a host calls a script function long after loading it — so the VM has to
own the source, the tree and the interpreter, and destroy them in the right order. One
VM, one script; numbers across the boundary, not JSON.

Then WASM aborted on the first return statement. Emscripten disables C++ exceptions by
default, and this interpreter carries return, break and thrown errors on C++
exceptions. -fwasm-exceptions fixes it. The JS-based alternative would mean a trip to
JavaScript on every single function return — unusable for a tree-walker.

And the payoff: STAR BREAKER, the game I shipped last week, now has a weapon whose
definition is not in the game. scripts/plazma.rai holds its damage, fire rate, spread
and the shape of its shot. The engine calls fire(angle), the script calls back with
game.spawnBullet, and the engine makes the bullets. The script only says where.

Rarity, levels, reforges, evolution — all of it still works, untouched. The weapon is
an ordinary table entry that happens to be filled from a language I wrote.

I added a weapon rather than moving one: the game is shipped and its balance measured,
so adding risks nothing. If the WASM fails to load, the weapon simply is not there.

ON THE AI SPLIT

The design is mine — the two keywords, the bridge pattern, the decision to add rather
than move. I wrote the first pass of the rename; AI reviewed it, found what was
missing, and wrote most of the embedding layer with me directing. That is more AI than
my last devlog claimed, and I would rather correct it than let it stand.

Repo (MIT): github.com/RaidenTechnology/raidenscript

0
0
11
Open comments for this post

44m 11s logged

I wrote a programming language. It runs.

RaidenScript is an embeddable scripting language — the kind that goes inside
another program and makes it programmable. It borrows readability from Python,
the runtime model from JavaScript, optional types from C++, and declarative UI
from HTML.

Phase 1 is done: lexer, AST, parser, resolver, tree-walking interpreter, REPL.
~5,150 lines of C++20, zero warnings under -Wall -Wextra -Wpedantic -Wshadow
-Wconversion.

WHAT I DID FIRST WAS NOT WRITE CODE

Phase 0 was pure design: a spec, a grammar, and 15 example programs — and no
compiler at all. Writing those examples found 16 defects in the spec before a
single line of the implementation existed. Two of them would have forced a
rewrite later:

  1. Conditional expressions (a if cond else b) were missing from the grammar
    entirely — I typed one by reflex in example 4 and it couldn’t parse.
  2. => followed by { was genuinely ambiguous: block, or map literal? I took
    JavaScript’s answer — after =>, { is always a block, and returning a map
    needs ({…}). Finding this while writing the parser would have cost days.

Then the parser found a third: my rule that brace-blocks suppress indentation was
just wrong. It makes this impossible to write:

register("hit", (e) => {
    if e.critical:
        damage = damage * 2
})

Parentheses hold one expression; brace blocks hold statements, and statements
need block structure.

DECISIONS I’M HAPPY WITH

  • Only nil and false are falsy. 0 and “” are truthy, which kills the classic
    if-count bug at the root.

  • / always returns a float; // is integer division. C’s silent truncation is a
    bug factory.

  • No package registry, ever. Modules resolve straight from a git repo, Go-style.

  • I built the diagnostics engine before the lexer, because error messages bolted
    on afterwards always stay bolted on. Columns count UTF-8 characters, not bytes
    — so the caret lands correctly when a variable is named sayaç. Real output,
    Turkish for now (English messages are on the list):

    hata: beklenmeyen ‘!’
    –> test.rai:3:7
    |
    3 | c = 5 ! 3
    | ^
    |
    = ipucu: olumsuzlama için ‘not’ kullan

THE PART THAT MADE ME GRIN

Example 15 is a calculator — a lexer, a Pratt parser and an evaluator, written in
RaidenScript. I wrote it in Phase 0 as a rehearsal for the C++ I hadn’t written
yet. Now it runs on the interpreter it was a rehearsal for: 2 ^ 3 ^ 2 gives 512,
right-associative, not 64.

The REPL I wrote by hand, and it immediately earned its keep by finding a bug
nothing else could: Source::fromFile stripped the UTF-8 BOM, but the constructor
didn’t. The REPL was the first code path to build a Source from an in-memory
string instead of a file, so piped input carried a BOM into the lexer, which
glued it onto the first identifier. A new entry point finds bugs no amount of
staring finds.

ON THE AI SPLIT

The design is mine — spec, grammar, naming, and every decision above. Phases 0-6
of the implementation were written with AI assistance (Claude); the REPL, step 7,
I wrote myself, and that is the mode from here on: I write the code, AI reviews
it.

WHAT’S NEXT

Not phase 2. I’m skipping types and the bytecode VM and going straight to
embedding — because a tree-walking interpreter is plenty fast for mod scripts,
which run on events, not in the render loop. The goal: compile to WASM, bind a
game.* API, and define one weapon in my published game STAR BREAKER using a
language I wrote.

A language nobody uses is a toy. A language shipping in a real product is not.

Repo (MIT): github.com/RaidenTechnology/raidenscript

0
0
5

Delete project?

Are you sure you want to permanently delete this project? This action cannot be undone.

All devlogs, followers, and associated data will be removed.

Followers

Loading…