Kawa is the C scripting runtime used by the engine's current scripting adapter. This page is the language surface: syntax, types, and the stdlib binds the compiler/VM actually expose. Engine attach (entity and component .script, tick hooks, natives) lives in Scripting with Kawa.
Editor highlighting: tools/vscode-extensions/kawa (VS Code / Cursor); tools/nvim-plugins/kawa (Neovim / Vim). Concrete syntax: src/kawa/syntax.ebnf (W3C EBNF aligned with the C parser/scanner). Do not invent forms that are not in that grammar.
let hp: i32 = 100;
let name: str = "Player";
fn add(a: i32, b: i32) -> i32 { return a + b; }
if (hp > 0) { print(name); } else { print("dead"); }
let values: i32[] = [1, 2, 3];
for (value: i32 in values) { print(value); }Lexical
The scanner strips whitespace and // line comments. There are no block comments. Strings may contain newlines.
Keywords (not identifiers): and break continue elif else false fn for if in is let nil or return true while.
Identifiers: [A-Za-z_][A-Za-z0-9_]*. Type names (i32, str, …) are ordinary identifiers in expression position unless they parse as a cast.
Numbers are decimal only: [0-9]+ or [0-9]+ "." [0-9]+. A trailing dot is a number then . (1. is not a float). A leading dot is . (.5 is not a number). No sign, hex, or exponent; unary minus is a separate token.
Strings are double-quoted. \" does not terminate the token. Unescapes: \n \t \r \\ \".
No bitwise & |. Those characters are only valid as && and ||. A lone & or | is a scanner error (Unexpected character).
No trailing commas in argument lists, parameter lists, type lists, array literals, or record field lists.
Types
Closed set of base names, then zero or more [], then an optional function-type suffix.
| Family | Names |
|---|---|
| Signed int | i8 i16 i32 i64 |
| Unsigned | u8 u16 u32 u64 |
| Float | f32 f64 |
| Numeric (unspecified width) | number |
| Other scalars | bool str any void |
Arrays: i32[], i32[][], … Function types write the return type first: i64(i32, i32) is a function returning i64 and taking two i32 parameters. void is a type (often a return annotation); the nil literal is the empty value.
Unknown type names are a compile error. number, any, and void are types but not cast prefixes.
Runtime values are still tagged coarsely (number / bool / str / array / record / function / nil). Width names (i32, f32, …) are declaration and cast annotations, not distinct runtime tags.
Strict mode
KAWA_COMPILE_ENFORCE_TYPE_NOTATIONS requires type annotations on:
letbindings- function parameters
- function return types (
-> type) - record fields
for-loop variables
Without the flag, those annotations are optional. Archives may set the flag so load matches compile-time behavior.
Program and statements
A program is a sequence of statements. { at the start of a statement is a block. In expression position { is a record literal.
statement ::= ; | block | let | if | while | for | fn
| return | break | continue | assignment | expression ;fn is allowed in any statement position, including inside blocks. Nested fn inside another function is rejected. return is only valid inside a function. break / continue are only valid inside a loop.
A function that falls off the end returns nil. return; also yields nil.
Declarations
let hp: i32 = 100;
let name = "Player"; // annotation optional unless strict mode
fn add(a: i32, b: i32) -> i32 {
return a + b;
}
fn log_only(msg: str) -> void {
print(msg);
return;
}letalways takes=and a terminating;. There is no declaration without an initializer.- Functions are named only.
fnhas no prefix (expression) rule, sofn (...) { }is not an expression. - Parameter and return annotations are optional unless strict mode.
- Function names must not already exist in the current scope.
Control flow
Conditions and loop headers take parentheses. Bodies are blocks ({ … }).
if / elif / else
if (hp > 0) {
print("alive");
} elif (hp == 0) {
print("down");
} else {
print("dead");
}else if is rejected. After else the parser requires {. Chain extra conditions with elif.
while
while (hp > 0) {
hp -= 1;
}for (in)
for (value: i32 in values) {
print(value);
}The iterated expression is an array. The loop variable may carry a type annotation (required in strict mode). There is no C-style for (init; cond; step).
break / continue
break;
continue;Both require a trailing ;. Illegal outside a loop. Nesting is capped (KAWA_PARSER_MAX_LOOP_DEPTH is 64).
Assignment
Assignment is a statement, not an expression. = / += / -= / *= / /= have no infix expression rule. You cannot write a = b = 1 or if ((x = 1)).
Compound operators are only += -= *= /=. There is no %= (or bitwise assigns).
Legal lvalues:
| Form | Example |
|---|---|
| identifier | hp = 10; |
ident.field | player.name = "Bob"; |
ident[i] (one or more indices) | grid[0] = 1; tilemap[1][0] = 4; |
Not assignment forms: chained fields (a.b.c = …) and mixed lvalues (a.b[i] = …).
A leading ident.field that is not followed by an assign operator is rewound and parsed as an expression statement. A leading ident[…] that is not followed by an assign operator is a compile error (it is not rewritten as an expression statement). Index reads belong in expressions: let x = values[1]; or print(values[1]);.
hp = 50;
hp += 10;
player.name = "Ada";
values[1] = 4;
tilemap[0][1] *= 2;Expressions
Precedence, weakest to strongest (Pratt table in kawa_parser_state.c):
| Prec | Operators |
|---|---|
| ternary | ?: |
| or | or || |
| and | and && |
| equality | == != is type |
| comparison | < > <= >= |
| term | + - |
| factor | * / % |
| unary | ! - (no unary +) |
| postfix | call f(…) index [i] field .name |
and / && and or / || short-circuit and coerce the result to bool. Falsy values are nil and false only (a 0 number is truthy).
+ adds numbers, or concatenates when either operand is a str (the other side is stringified).
Ternary: cond ? then_expr : else_expr.
expr is type
let typed: i32 = 1;
print(typed is i32); // true — declared type of a simple identifier
print(typed is number); // true — any numeric declaration matches `number`
print(typed is any); // true
print((1 + 2) is i32); // false — width names are not runtime tags
print(nil is void); // true
print("x" is str); // trueis binds at equality precedence. The right-hand side is a type, not an expression (x is i32[], cb is i64(i32, i32)).
- Simple identifier: compares the variable's declared type when present (
anyalways matches;numbermatches any numeric declaration; otherwise the type strings must be equal). Untyped identifiers fall back to the runtime tag. - Other expressions: runtime tag only —
any(always),void(nil),bool,str,number, array types, function types. Specific numeric names (i32,f32, …) do not match a bare number value.
Casts
Cast prefixes are i8 i16 i32 i64 u8 u16 u32 u64 f32 f64 bool str. Rank suffixes select array casts.
let n: i32 = i32(wide); // safe: error if the conversion would lose data
let t: i32 = i32!(wide); // forced: may truncate
let row: i32[] = i32[](raw);number, any, and void are not cast prefixes.
Literals
true; false; nil;
42; 3.5;
"line\n";
[1, 2, 3];
{name: str = "Ada", age: i32 = 1};Array and record literals are expressions. Empty [] and {} are legal. Field type annotations on records are optional unless strict mode.
Records
let player = {name: str = "Alice", age: i32 = 25};
print(player.name);
player.name = "Bob";Fields are identifier (":" type)? "=" expression, comma-separated, no trailing comma. There is no struct keyword; the literal is the record.
Reading a missing field yields nil unless the compile flag KAWA_COMPILE_ERROR_ON_STRUCT_READ_UNDEFINED is set. Writing a missing field creates it unless KAWA_COMPILE_ERROR_ON_STRUCT_WRITE_UNDEFINED is set.
Arrays
let values: i32[] = [1, 2, 3];
print(values[1]);
values[1] = 4;
print(values.len);
print(values.empty);
print(values.first);
print(values.last);Index and .len / .empty / .first / .last are postfix on the value. Array.* (and Matrix.* for nested arrays) are the mutating/query binds below. Strings expose the same .len / .empty / .first / .last properties and s[i] (character as a one-char string).
Functions as values
Named functions are values. Function types appear in annotations and is:
fn double(value: i32) -> i32 { return value * 2; }
fn map(input: i32[], callback: i32(i32)) -> i32[] {
let output: i32[] = [];
for (item: i32 in input) {
Array.push(output, callback(item));
}
return output;
}
let cb: i32(i32) = double;Argument, parameter, and field counts are capped at 255 (KAWA_PARSER_MAX_ARG_COUNT).
Swizzles
Arrays and records support .x .y .z .w and .r .g .b .a, one to four components long (v.xy, v.rgba, v.xwzw). A one-component swizzle is a number; two or more build a record with x/y/z/w fields. Exact record fields take precedence over a swizzle of the same name.
Standard library
Binds registered by kawa_init_std_lib. Each row is an actual bind: namespace name and the free-function alias (when one exists). Wrong arity or type typically returns nil rather than throwing.
Console / runtime
| Call | Alias | Args |
|---|---|---|
Console.print | print | any number of values; writes a line |
halt | optional number exit code |
There is no Console.halt.
Math
Numeric helpers take number values. min / max take one or more numbers. random is () → [0,1), (max), or (min, max). vec is 2–4 numbers, or one existing vec. mat is 16 numbers, four vec4s, or one existing mat4. quat is four numbers or one existing quat.
Math.* | Alias |
|---|---|
add | math_add |
subtract | math_subtract |
multiply | math_multiply |
divide | math_divide |
modulo | math_modulo |
power | math_power |
sqrt | math_sqrt |
abs | math_abs |
floor | math_floor |
ceil | math_ceil |
round | math_round |
sin | math_sin |
cos | math_cos |
tan | math_tan |
min | math_min |
max | math_max |
random | random (not math_random) |
vec | vec |
mat | mat |
quat | quat |
Math.quat is registered with the quaternion module; it is still Math.quat / quat.
Vec
Vectors are records {x, y, …} (2–4 components) or numeric arrays of length 2–4. Color fields r/g/b/a are accepted when reading. Operations require matching length; cross is 3-wide only. clamp / lerp accept a scalar or a same-length vector for the extra arguments.
Vec.* | Alias | Args |
|---|---|---|
add | vec_add | (a, b) |
sub | vec_sub | (a, b) |
dot | vec_dot | (a, b) → number |
distance | vec_distance | (a, b) → number |
len | vec_len | (v) → number |
normalize | vec_normalize | (v) |
mul_scalar | vec_mul_scalar | (v, s) |
cross | vec_cross | (a, b) 3-wide |
clamp | vec_clamp | (v, min, max) |
lerp | vec_lerp | (a, b, t) |
reflect | vec_reflect | (v, n) |
project | vec_project | (v, n) |
Mat
4×4 matrices are row-major arrays of four rows (each a 4-wide array or vec4), or a flat 16-number array. Builders return the 4×4 row form.
Mat.* | Alias | Args |
|---|---|---|
mul_vec | mat_mul_vec | (m, v4) |
mul | mat_mul | (a, b) |
transpose | mat_transpose | (m) |
inverse_affine | mat_inverse_affine | (m) |
make_translation | mat_make_translation | (x, y, z) or (vec3) |
make_scale | mat_make_scale | (x, y, z) or (vec3) |
make_rotation_x | mat_make_rotation_x | (radians) |
make_rotation_y | mat_make_rotation_y | (radians) |
make_rotation_z | mat_make_rotation_z | (radians) |
lookAt | mat_lookAt | (eye, target, up) |
perspective | mat_perspective | (fovy, aspect, znear, zfar) |
ortho | mat_ortho | (left, right, bottom, top, znear, zfar) |
lookAt is camelCase in the bind list.
Matrix (array namespace)
Same implementations as Array.*, bound for nested arrays (i32[][] and the like):
Matrix.* | Alias |
|---|---|
len | matrix_len |
is_empty | matrix_is_empty |
push | matrix_push |
pop | matrix_pop |
clone | matrix_clone |
clear | matrix_clear |
compact | matrix_compact |
reserve | matrix_reserve |
Quat
Quaternions are {x, y, z, w} records (a 4-wide numeric array is also accepted when reading).
Quat.* | Alias | Args |
|---|---|---|
mul | quat_mul | (a, b) Hamilton product |
normalize | quat_normalize | (q) |
slerp | quat_slerp | (a, b, t) |
from_axis_angle | quat_from_axis_angle | (axis, angle) — axis is a vec, array, or {x,y,z} |
from_euler | quat_from_euler | (rx, ry, rz) XYZ intrinsic, radians |
to_mat4 | quat_to_mat4 | (q) → 4×4 row array |
Array
Array.* | Alias | Args / notes |
|---|---|---|
len | array_len | (arr) → count |
is_empty | array_is_empty | (arr) → bool |
push | array_push | (arr, value) — typed arrays reject a mismatched value (nil) |
pop | array_pop | (arr) → last element, or nil if empty |
clone | array_clone | (arr) |
clear | array_clear | (arr) — count to 0 |
compact | array_compact | (arr) — drop nil holes |
reserve | array_reserve | (arr, n) — grow count to n, filling new slots with nil |
push / clear / compact / reserve return the array. pop returns the element.
Events
Events.* | Alias | Args |
|---|---|---|
on | events_on | (name, fn) or (name, fn, owner) |
once | events_once | same; listener removes itself after one delivery |
off | events_off | (name, fn) — first matching listener |
post | events_post | (name, arg) — deliver now |
post_deferred | events_post_deferred | (name, arg) — queue until flush |
flush | events_flush | () — deliver deferred events |
remove_by_script | events_remove_by_script | (script_name: str) |
remove_by_owner | events_remove_by_owner | (owner) |
clear | events_clear | (name) — drop every listener on that event |
count | events_count | (name) → listener count |
Events are enabled by default in standalone Kawa and shared across scripts in the same VM. Hikari disables this standard-library bus at VM creation, so neither Events.* nor the events_* aliases are registered there. Hikari gameplay uses Actors.send / Actors.emit through the World dispatcher.
Event names are strings. owner is a string or a number. One-shot listeners remove themselves inline. Deferred events run when flushed (Events.flush or the host kawa_events_flush). Destroying a script removes listeners whose user-functions came from that script; Events.remove_by_script does the same by source name.
fn on_hit(payload: any) { hp -= payload.damage; }
Events.on("Enemy.hit", on_hit, "player");
Events.once("Enemy.alert", on_alert);
Events.post("Enemy.hit", {damage: i32 = 10});
Events.post_deferred("tick", 1);
Events.flush();
Events.off("Enemy.hit", on_hit);
Events.remove_by_owner("player");
Events.remove_by_script("player.kawa");C embedding
#include "kawa.h"
kawa_handle_t vm = kawa_create(NULL);
kawa_init_std_lib(vm);
kawa_compilation_options_t options = {0};
options.emit_symbols = true;
kawa_script_t script = kawa_script_compile(vm, source, "main", options);
kawa_scope_t scope = kawa_scope_create(vm, NULL);
kawa_result_t result = kawa_script_run(script, scope);
kawa_events_flush(vm, scope);
kawa_scope_destroy(scope);
kawa_script_destroy(script);
kawa_destroy(vm);kawa_create(NULL) selects default VM options, including enable_events = true.
To customize them, start from the defaults:
kawa_vm_options_t vm_options = kawa_vm_options_default();
vm_options.enable_events = false;
kawa_handle_t vm = kawa_create(&vm_options);
kawa_init_std_lib(vm);Options are copied at creation and stay fixed for that VM's lifetime. Disabling
Events omits both its namespace and free-function aliases; math, arrays, and the
other standard libraries remain available. Repeating kawa_init_std_lib cannot
re-enable Events. This is an embedding option, separate from script compilation
flags. Hikari explicitly disables Events on every VM creation, including resets.
Bind native callbacks with kawa_bind_function or kawa_bind_namespace_function. Engine code should use src/hikari/src/scripting/kawa/public.zig, not this raw C layer.
Offline compiler (kawac)
Shinra and CI cook scripts outside the game process:
src/kawa/tools/kawac → bin/kawa/<platform-arch>/kawac → bin/game/tools/kawac (product stage)
kawac input.kawa output.kawabcCompile flags must match engine scene scripts (emit_symbols, KAWA_COMPILE_ENABLE_SIMD_TIER1; archives may set KAWA_COMPILE_ENFORCE_TYPE_NOTATIONS for strict annotations).
VM script slots (embedding)
- Each distinct compiled script occupies one of
KAWA_MAX_SCRIPTS(256) VM slots until destroyed. - The engine caches compiled bytecode across scene unload; actor instances drop, session scripts stay.
- Destroying a compiled script while live user-function meta strings still borrow its chunk table can poison the VM — the engine promotes meta strings on destroy when refs remain; avoid ad hoc destroy/reload loops in embedders.
- Editor Stop may rebuild the VM; game
registerScriptNativesbindings are re-applied on the fresh VM.