Core concept
DominScript is built on three pillars. Understanding them up front explains nearly every design decision in the language.
A minimal interpreter
The interpreter deliberately knows very little on its own. There is no built-in networking, file, graphics, or operating-system call. All of that arrives from plugins — platform-specific shared libraries (.so on Linux, .dll on Windows). A script has exactly the capabilities the loaded plugins provide, and no more.
Early error catching
Code passes through three phases before it runs:
- Parser — syntactic correctness.
- Binder — types, variable scope, definite assignment, and plugin ABI agreement.
- Runtime — dynamic range and type checks during execution.
This makes it impossible to, for example, read an uninitialized variable or pass a wrongly-typed argument to a plugin function — those are binder errors, raised before the program starts.
C-translatability as a design rule
DominScript is intentionally C-like. A hot script function should be mechanically and conceptually easy to move into a C plugin. The #define directive, brace blocks, typed signatures, and explicit casts all serve that goal.
This does not mean full C compatibility. DominScript deliberately sits between a flexible scripting language and C — it borrows C's shape and discipline where they pay off, but stays smaller and more script-like elsewhere. The #define directive, for instance, is not (yet) a complete macro language. Treat the C resemblance as a guiding principle, not a compatibility promise.
Your first program
plugin "../plugins/print/PrintPlugin"; void main() { i32 $Counter = 0; while ($Counter < 5) { printf("Count: %d\n", $Counter); $Counter += 1; } return; }
domin first.dom
Count: 0
Count: 1
Count: 2
Count: 3
Count: 4
File structure
DominScript source files use the .dom extension. Every statement ends with a semicolon (;).
The plugin directive
Plugin directives may appear only at the top of the file, before any other statement.
plugin "builtin:blob"; plugin "builtin:string"; plugin "builtin:struct"; plugin "builtin:os"; plugin "../plugins/print/PrintPlugin"; plugin "../plugins/file_io/FileIoPlugin"; plugin "../plugins/path/PathPlugin";
- The extension is optional — the runtime appends the platform-appropriate one (
.so/.dll) automatically. - The
builtin:xxxform loads internal plugins written in C and linked into the interpreter. - Two plugins may not export the same
namespace.functionpair — that is a binder error. - There is no in-script plugin unload; everything is released automatically when the program ends.
The include directive
include "utils/helpers.dom"; include "../common/config.dom";
Textual inclusion, like C's #include. It works recursively, with cycle detection. Note that include paths are resolved relative to the including file.
Comments
// Single-line comment /* Multi-line comment */
The # character is not a comment prefix — it belongs to the preprocessor.
Preprocessor — #define
The #define directive performs simple single-token text substitution — to a literal value, a keyword, or a previously defined name.
#define MAX_SIZE 256 #define WAIT_FOREVER -1 #define PI_APPROX 3.14159 #define STATUS_OK 0 void main() { blob $B[MAX_SIZE]; i32 $Status = STATUS_OK; double $Pi = PI_APPROX; printf("Size: %d, Pi: %f\n", MAX_SIZE, $Pi); return; }
It is especially handy for naming switch/case labels, keeping branch logic readable.
This is plain token substitution, not a full C preprocessor. There are no function-like macros, conditional compilation, or token-pasting tricks — #define maps a name to a value, and that is intentionally all it does for now.
Types
Scalar types
| Type | Description | Size | Range |
|---|---|---|---|
| bool | boolean value | 1 byte | true / false |
| i8 | signed integer | 1 byte | −128 .. 127 |
| u8 | unsigned integer | 1 byte | 0 .. 255 |
| i16 | signed integer | 2 bytes | −32 768 .. 32 767 |
| u16 | unsigned integer | 2 bytes | 0 .. 65 535 |
| i32 | signed integer | 4 bytes | −2.15×10⁹ .. 2.15×10⁹ |
| u32 | unsigned integer | 4 bytes | 0 .. 4.29×10⁹ |
| i64 | signed integer | 8 bytes | −9.2×10¹⁸ .. 9.2×10¹⁸ |
| u64 | unsigned integer | 8 bytes | 0 .. 1.8×10¹⁹ |
| float | single-precision float | 4 bytes | ~±3.4×10³⁸, ~7 digits |
| double | double-precision float | 8 bytes | ~±1.8×10³⁰⁸, ~15 digits |
Compound types
| Type | Description |
|---|---|
| string | text with a declared capacity — string $S[N] |
| blob | raw byte array — blob $B[N] |
| void | valid only as a return type |
Type rules that matter
- A
voidvariable cannot be declared. stringandblobdo not participate in implicit numeric conversion.boolis not compatible with integer types at the plugin ABI level.- Scalar assignment does not allow implicit narrowing such as
float → i32— an explicit cast is required.
Variables
Variable names are prefixed with $. The compiler distinguishes a declaration (which fixes the type) from later assignments.
Declaration and initialization
// Scalar types i32 $Number = 42; double $Pi = 3.14159; bool $Done = true; u8 $Bitmask = 0xFF; // Declaration without initialization i64 $Result; // String — capacity is mandatory string $Name[32]; string $City[64] = "Budapest"; // Blob — size is mandatory blob $Data[128]; blob $Empty[0];
Scope rules
void main() { i32 $X = 10; // outer scope { i32 $Y = 20; // inner scope printf("%d\n", $X + $Y); } // $Y is no longer reachable here // FORBIDDEN: redeclaring $X in the same scope -> binder error // FORBIDDEN: shadowing an outer $X in an inner scope -> binder error return; }
Definite-assignment check
The binder verifies that every variable is definitely assigned before it is read. If a value is set on only some branches, reading it afterward is a binder error.
i32 $Result; if (GetSomeValue() > 0) { $Result = 10; } else { $Result = 0; // assigned on every path } printf("%d\n", $Result); // OK
Literals
Integer literals
// Decimal i32 $A = 42; i32 $B = -17; // Hexadecimal (0x / 0X prefix) u8 $H1 = 0xFF; // 255 i32 $H2 = 0x1A2B; // 6699 // Binary (0b / 0B prefix) u8 $B1 = 0b10101010; // 170 i32 $B3 = 0b11110000; // 240 // Negative hex / binary (unary minus) i32 $Neg = -0x10; // -16
Floating-point, string, boolean
float $F = 3.14; double $D = 3.14159265358979; string $S[32] = "Hello, world!"; string $Lines[64] = "First line\nSecond line"; string $Quote[32] = "she said: \"hi\""; string $Path[16] = "C:\\user"; bool $Yes = true; bool $No = false;
Supported escape sequences in string literals include \n, \t, \", \\, and \0 (NUL terminator).
Operators and expressions
Arithmetic
i32 $A = 10; i32 $B = 3; printf("%d\n", $A + $B); // 13 printf("%d\n", $A - $B); // 7 printf("%d\n", $A * $B); // 30 printf("%d\n", $A / $B); // 3 (integer division) printf("%d\n", $A % $B); // 1 (remainder)
Bitwise (integer types only)
i32 $A = 0b00001100; // 12 i32 $B = 0b00001010; // 10 printf("%d\n", $A & $B); // 8 AND printf("%d\n", $A | $B); // 14 OR printf("%d\n", $A ^ $B); // 6 XOR printf("%d\n", ~$A); // -13 NOT (sign-extending) printf("%d\n", 1 << 4); // 16 left shift printf("%d\n", -8 >> 1); // -4 arithmetic right shift
Shift counts must fall between 0 and 63.
Comparison & logical
bool $R1 = ($A == $B); // equal bool $R2 = ($A != $B); // not equal bool $R3 = ($A <= $B); // less-or-equal bool $L1 = ($X && $Y); // logical AND bool $L2 = ($X || $Y); // logical OR bool $L3 = !$X; // logical NOT
String and blob concatenation (+)
string $First[32] = "Hello"; string $Both[64]; $Both = $First + ", world!"; // Hello, world! blob $A[3]; blob $B[3]; blob $C[0]; $C = $A + $B; // 6-byte blob: A then B
Operator precedence (high → low)
| Level | Operators |
|---|---|
| 1 (highest) | ! ~ unary - |
| 2 | * / % |
| 3 | + - |
| 4 | << >> |
| 5 | < <= > >= |
| 6 | == != |
| 7 – 9 | & then ^ then | |
| 10 | && |
| 11 (lowest) | || |
Assignment & compound operators
Compound operators are desugared by the parser: $X op= Y becomes $X = $X op Y.
i32 $X = 100; $X += 5; // 105 $X -= 10; // 95 $X *= 2; // 190 $X /= 4; // 47 $X %= 10; // 7 $X &= 0xFF; $X |= 0x01; $X ^= 0x0F; $X <<= 2; $X >>= 1;
A compound operator may be applied only to a simple variable. Index, range, or member targets such as $Array[0] += 5 or $S.x += 1 are binder errors.
Capacity-bounded assignment: = vs :=
Strings and blobs have a second assignment operator, :=. The two differ in exactly one thing: := never grows the target's allocated capacity, whereas = grows it as needed.
= (plain) | := (capacity-bounded) | |
|---|---|---|
| Upper bound | none — capacity grows if needed | the target's declared capacity |
| Source too long | capacity grows to fit | truncated at capacity; Overflow becomes 1 |
| Shorter source | the remainder is dropped (new length = source length) | the existing tail beyond the source is kept |
| Typical use | general assignment | fixed-size fields — e.g. a string member of a packed struct |
string $S[4]; $S = "abcdefgh"; // '=' grows the capacity // $S = "abcdefgh", Length 8, Capacity 8, Overflow 0 string $T[4]; $T := "abcdefgh"; // ':=' truncates at the capacity // $T = "abcd", Length 4, Capacity 4, Overflow 1 string $A[16] = "Hello"; $A := "AB"; // shorter source: the existing tail is kept // $A = "ABllo", Length 5, Overflow 0
Blobs behave identically. Writing into a fixed-size blob with := — including at an offset, e.g. $Cut[1] := $Src[0..3] — will not grow it: it truncates at the capacity and flags Overflow, whereas = grows the blob to fit.
Explicit casts
An explicit cast is required between scalar types whenever the conversion is narrowing or non-trivial. Many casts are also validated at runtime — the value must actually fit.
// float/double -> integer (only if the value is whole) float $F = 3.0; i32 $I = (i32)$F; // OK: 3.0 is whole i32 $J = (i32)3.7; // RUNTIME ERROR: 3.7 not whole // narrowing integer cast i8 $Ok = (i8)127; // OK i8 $Bad = (i8)300; // RUNTIME ERROR: 300 does not fit i8 // unsigned / signed u32 $U = (u32)-1; // RUNTIME ERROR: -1 is negative // chainable double $Val = (double)(i16)7; // casts to/from string, blob, or void are binder errors
Control flow
if / else if / else
if ($Value > 100) { printf("large\n"); } else if ($Value > 0) { printf("small\n"); } else { printf("zero or negative\n"); }
The elseif and else if spellings are equivalent.
while, for, do/while, do/until
i32 $I = 0; while ($I < 10) { $I += 1; } for ($I = 0; $I < 5; $I += 1) { printf("%d\n", $I); } // runs at least once, while condition is TRUE do { $I += 1; } while ($I < 3); // runs at least once, until condition is TRUE do { $I -= 1; } until ($I <= 0);
switch / case
switch ($Err) { case ERR_NONE: printf("none\n"); break; case ERR_IO: printf("i/o\n"); break; // fallthrough: a case without break falls into the next case 6: case 7: printf("weekend\n"); break; default: printf("unknown\n"); break; }
break, continue, goto, return
for ($I = 0; $I < 10; $I += 1) { if ($I == 3) { continue; } // skip 3 if ($I == 7) { break; } // stop at 7 printf("%d\n", $I); } // prints: 0 1 2 4 5 6 start: if ($I >= 5) { goto done; } $I += 1; goto start; done: return;
Functions
Basic syntax
// return type + name + parameters i32 Sum(i32 $A, i32 $B) { return $A + $B; } void PrintNumber(i32 $N) { printf("Number: %d\n", $N); return; }
Value vs ref parameters
Value parameters receive a copy; ref parameters receive a reference, so changes are visible on the original. Type matching on ref parameters is strict — an i64 argument will not bind to a ref i32 parameter.
void Increment(i32 $X) { $X += 1; } // copy void IncrementRef(ref i32 $X) { $X += 1; } // reference void main() { i32 $A = 10; Increment($A); printf("%d\n", $A); // 10 IncrementRef($A); printf("%d\n", $A); // 11 return; }
Blob / string ref parameters & recursion
void FillBlob(ref blob $B, i32 $Value) { i32 $I; for ($I = 0; $I < $B.Length; $I += 1) { $B[$I] = $Value; } } i32 Factorial(i32 $N) { if ($N <= 1) { return 1; } return $N * Factorial($N - 1); // 5! = 120 }
Calling plugin namespaces
Plugin functions are called in namespace.Function(...) form. (printf is an exception: the print plugin exports it directly, without a namespace.)
plugin "../plugins/print/PrintPlugin"; plugin "../plugins/path/PathPlugin"; plugin "../plugins/file_io/FileIoPlugin"; void main() { string $Norm[64] = path.Normalize("a/./b/../c"); printf("%s\n", $Norm); // a/c fileio.WriteFileText("out.txt", "content"); bool $Exists = fileio.Exists("out.txt"); printf("exists: %d\n", $Exists); return; }
That covers the core language. The section below walks through how plugins and callbacks actually move data through the runtime; the per-plugin reference lives one click away.
Plugins & callbacks: how data flows
DominScript plugins fall into four shapes. Adapter plugins are thin synchronous wrappers around an existing C library — every script call is one library call (e.g. json over cJSON, sqlite over libsqlite3, crypto over OpenSSL). Bridge plugins wrap a library too, but additionally mediate callbacks: sdl, gl, ffmpeg and image expose synchronous calls and deliver events to script callbacks through the host's callback queue. Callback-driven plugins like callbacktcp or callbackworker exist primarily to deliver events — their own OS threads enqueue work onto the same central queue. (Plus a fourth, plain group: native plugins such as str, map or csv — pure C implementations, wrapping nothing.) The two interesting data paths are below.
The callback model
Every callback in DominScript flows through the same machinery, regardless of which plugin produced the event (TCP/UDP listener, HTTP listener, worker pool, timer, callback listener). One single dispatcher thread drains a central FIFO queue and runs callbacks strictly one at a time. The plugin's own thread blocks until its callback finishes. This is a load-bearing invariant — two script-side callbacks never run simultaneously.
- ① Event arrives. A plugin OS thread reads bytes from a socket, fires a timer, or finishes a worker job. The bytes sit in the plugin's own buffer.
- ② InvokeBorrowedCallback. The plugin calls the host's
InvokeBorrowedCallbackwith the pointer, size, timeout, and an optionalReleasefunction pointer. No deep copy is made. - ③ Wraps a borrowed blob. The host builds a lightweight
DominBlobwhose.Dataaliases the plugin's bytes. The flagsREADONLY | EXTERNALguard against script-side writes and tell the host that the bytes are owned externally. - ④ Enqueue. The host queues a
DominCallbackEventon the central FIFO callback queue. - ⏸ Plugin blocks. The plugin's own thread sleeps on the event's completion condition variable. It does not run the script callback itself.
- ⑤ One dispatcher. A single, long-lived dispatcher thread drains the queue. Two script callbacks never run at the same time, so shared script state seen by callbacks is not subject to a data race.
- ⑥–⑦ Script callback runs. The dispatcher invokes the registered script function with
$Requestas a read-only view aliasing the borrowed buffer. - ⑧ Return. The script returns a response
blob, or (for ref-style callbacks) ani32plus aref blob. - ⑨ Signal completion. The host stores the response on the event and broadcasts the cond var.
- ⑩–⑪ Plugin resumes. The plugin wakes, takes the
ResponseBlob, sends or stores the bytes, and releases it.
Three lifetimes are at play:
- The plugin's own buffer (the bytes
buf). After the script callback returns, the host frees the borrowed wrapper, which fires the plugin-suppliedReleasefunction onbuf. This is the moment the borrowed bytes are reclaimed. If the plugin passesNULLforRelease(the skeleton's path), the synchronous call simply held the bytes alive for its duration and the plugin keeps ownership afterwards. - The borrowed-blob wrapper (the small
DominBlobstruct that the host built in step ③). Freed automatically when the script callback returns; the script never sees it again. - The response blob (built by the script in step ⑦). Owned by the host; refcounted. The plugin must call
host->ReleaseBlobwhen done — that is when the response bytes are freed.
Adapter plugins — the contrasting model
Pure adapters (json, sqlite, crypto) have no threads, no queue, no callbacks. Every script-side call is a thin layer over exactly one call into an underlying C library (cJSON, libsqlite3, OpenSSL). They are stateless from the runtime's perspective; control flow is plain, synchronous, and lives on the same thread as the caller. Blobs do cross the boundary — crypto.Sha256($Data) takes a blob in and returns a blob out — but they don't travel the queue/dispatcher path: the plugin reads the script's bytes directly while the synchronous call holds them alive.
sdl, gl, ffmpeg, image wrap an external library and deliver events via callback. sdl.SetEventCallback and gl.PumpEvents use the same RegisterCallback + InvokeBorrowedCallback path shown in the first diagram — events from the underlying library funnel through the central queue and the single dispatcher, exactly as a TCP packet does.
Adapter plugins are synchronous: the script call returns on the same thread, no event queue, no dispatcher involvement; blobs cross the boundary by direct pointer while the call holds. Callback-driven and bridge plugins are queue-merged: every event funnels through one dispatcher, and the payload crosses the boundary as a borrowed (or refcounted) DominBlob.