CX+AI

CX Automation & Rules

Entities, C-rules, events, and the invisible loop

GENERATED from DOCS/reference/manuals/CX_Automation_Manual.html by tests/forge.cx — edit the source, not this page.

CX Automation & Rules

Entity · C-Rule · Event · Invisible loop · AI as playwright

v3.137.1 · July 2026

Language Manual Reference Compiler Built-ins AI Graphics GameDev Automation

Overview

The CX automation system lets you attach behaviour to data without writing update loops. An entity is a JSON object (a bag of named values). Rules and functions are attached to entities on a schedule. The automation loop fires them — you write no per-entity update code. The event system routes input and time events to rule or function handlers, with a two-way JSON registry that can be loaded from disk or authored by an AI.

TIP — What changed (2026-07-05, v3.126–v3.137): rules are now authored in plain C, not a custom mini-language. A rule body is just { ... } — ordinary CX/C statements over a json e entity — compiled at runtime by the same compiler that builds cx.exe. See C-Rules below. The original string mini-DSL (Legacy DSL) still runs but is deprecated (compiler warning CX-W5001) — new rules should be written in C.

Entity = data

A JSON object is the unit of state. Fields are named values. The json type coerces to the right type at the point of use.

C-Rule = behaviour as data, in C current

A rule is a { } function body in plain C, compiled at runtime to fat bytecode and cached. Nothing to teach an AI — every LLM already knows C.

Func = compiled fast path

A funcAdd registers a real, build-time CX function. Same schedule/trigger as rules, full C speed, zero runtime-compile cost.

AI = playwright

The AI authors C-rules at init or checkpoints, validated by the compiler itself. The deterministic engine executes them; a learned rule can promote to compiled-in code.

The invariant: humans write the bounds, invariants, and compiled functions that must not be violated. AI writes rules that tune behaviour within those bounds. The trust boundary is the function/rule divide — enforced today at rule-compile time by a language subset (§C-Rules), not by a runtime cage.

Design Model

Function vs C-Rule vs Legacy DSL

Function (funcAdd)C-Rule (ruleAdd)AI-authored (airule/ruleAuthor)Legacy DSL
SpeedNative C — build-time compiled, optimisedFat bytecode — compiled at runtime, VM-executed near-nativeSame as C-rule once validatedThreaded code — fast, but not C
Who writes itHuman (you)Human or AIAI at runtime, compiler-validatedHuman or AI (deprecated)
LanguageCX/CCX/C (a language subset, §C-Rules)CX/C (same subset)Custom string mini-language
Editable at runtimeNoYes — replace the source textYes — re-prompt, re-validateYes — replace the string
Can promote to native— already nativeYes — rulePromote, literal text moveYes — same path once acceptedNo
Use forInvariants, physics, heavy computeTunable behaviour, balance, emergent AIEmergent NPC behaviour, dynamic difficultyExisting content only — do not write new rules this way
TIP — Use funcAdd for logic that must be correct and fast at build time. Use ruleAdd with a { } C-rule body for logic that should be tweakable or AI-generated at runtime. The same entity can have both: a function for physics, a C-rule for personality.

The entity model

#pragma rules c   // enable the C-rules compiler for this build

// A ship entity: JSON bag of state
json ship = {
    hull:    100,   hull_max:  100,
    shields: 50,    shield_max: 50,
    speed:   8.0,   speed_max:  12.0,
    damage:  10,    alive: 1,
    aggro:   0,     mood:  "idle"
};

// Attach a C-rule (behaviour = data, authored in plain C)
ruleAdd(ship, 0,  "alive",
    "{ e[\"shields\"] = clamp(e[\"shields\"] + 1, 0, e[\"shield_max\"]); }"); // shield regen

ruleAdd(ship, 10, "alive",
    "{ float hull = e[\"hull\"]; "
    "  if (hull < 20) { e[\"speed\"] = max(e[\"speed\"]-2, 3); e[\"mood\"] = \"retreating\"; } }");

// Attach a compiled function (fast path for physics)
function shipPhysics(json e) {
    x.f  = e["x"];  vx.f = e["vx"];  dt.f = e["dt"];
    e["x"] = x + vx * dt;
}
funcAdd(ship, 0, "alive", &shipPhysics);

// Tick the engine
automationTick();
NOTE — A rule string that starts with { is sniffed as a C-rule and compiled by the embedded frontend; anything else falls through to the legacy DSL (with a one-time CX-W5001 deprecation notice). See C-Rules for the full subset and syntax.

Chapter 1

Entities

An entity is any JSON object. The automation engine treats its fields as named state slots.

Creating an entity

// Inline literal
json player = {hp: 100, mp: 50, speed: 5.0, alive: 1};

// Load from file (data stays in JSON, not in code)
json enemy = jsonload("data/enemy_scout.json");
json world = jsonload("data/world_state.json");

// Programmatic build
json npc = jsonObj();
jsonAdd(npc, "name",  "Guard");
jsonAdd(npc, "hp",    80);
jsonAdd(npc, "alert", 0);

_json — an entity written as itself, validated at compile time

For an entity whose shape you are writing by hand — a fixture, a template, a starting state — _json builds the document at compile time, so a malformed literal is an error on that .cx line rather than an invalid handle discovered when the automation loop first touches it:

_json guard {
  {
    "name":   "Guard",
    "hp":     80,
    "hp_max": 80,
    "alert":  0,
    "post":   { "x": 12, "y": 4 },
    "patrol": [ "north", "east", "south" ]
  }
}

ruleAdd(guard, 4, "alert", ALERT_RULE);

Two brace levels — the block's, then the document's own. Nested objects and arrays are ordinary JSON, and a top-level array is legal too.

Bare identifiers in value position are spliced from CX variables, and a spliced float enters as a number rather than going through text and back:

float startSpeed;
startSpeed = 5.5;

_json scout {
  {
    "speed": startSpeed,
    "hp":    60
  }
}
NOTE — _json is for program-shaped state: fixtures, templates, the starting values a rule then evolves. Game content — the enemies, the levels, the balance table — still belongs in data/*.json loaded with jsonload, exactly as above. The ergonomic literal is not meant to become the easy path to hardcoding content back into the source.

Reading and writing fields

// Context-typed read (sink type drives coercion)
hp.i    = player["hp"];       // → int
speed.f = player["speed"];    // → float
mood.s  = player["mood"];     // → string

// Write
player["hp"]   = 80;
player["mood"] = "low";

The same coercion rules apply inside rule strings — bare key names read as the natural type for the operation they appear in.

The trigger gate

Every ruleAdd / funcAdd has a trigger key: a field name in the entity. The engine checks entity[triggerKey] != 0 before firing. Use "" (empty string) for unconditional.

ruleAdd(ship, 0, "",       "...");   // always fires
ruleAdd(ship, 0, "alive",  "...");   // only fires when ship["alive"] != 0
ruleAdd(ship, 5, "aggro",  "...");   // every 5 ticks, only when aggro != 0

// Flip the gate to pause/resume a behaviour group
ship["aggro"] = 1;   // enable aggro rules
ship["aggro"] = 0;   // pause them

Chapter 2

Registering Behaviours

Three ways to attach behaviour to an entity: compiled function, rule string, AI-generated rule.

// ruleAdd(entity, schedule, triggerKey, ruleString)
//   schedule:   0 = every tick; N = every N ticks
//   triggerKey: "" = always; "key" = only when entity["key"] != 0
//   ruleString: a C-rule ("{ ... }" body) or a legacy DSL string (deprecated)

// Shield regeneration — every tick while alive
ruleAdd(ship, 0, "alive",
    "{ e[\"shields\"] = clamp(e[\"shields\"] + 1, 0, e[\"shield_max\"]); }");

// Retreat logic — checked every 5 ticks
ruleAdd(ship, 5, "alive",
    "{ float hull = e[\"hull\"];"
    "  if (hull < 20) { e[\"speed\"] = max(e[\"speed\"] - 2, 3); e[\"mood\"] = \"retreating\"; } }");

// Unconditional cleanup
ruleAdd(world, 1, "", "{ e[\"tick\"] = e[\"tick\"] + 1; }");
TIP — A rule body starting with { is compiled as C by the embedded frontend (needs #pragma rules c in the build). This is the current, recommended form — see C-Rules for the full language subset, loops, and calling registered functions.

_rules — the same rule, unescaped and checked at compile time

Look at what the escaping costs in the block above: every " in the rule body has to become \", and a multi-line rule becomes a run of concatenated string literals. That is the rule text fighting the string literal that carries it, and it is the single biggest source of mistakes in rule authoring.

_rules removes it. The body goes in braces, written as the C it is:

#pragma rules c

_rules SHIELD_REGEN {
  {
    e["shields"] = clamp(e["shields"] + 1, 0, e["shield_max"]);
  }
}

_rules RETREAT {
  {
    float hull = e["hull"];
    if (hull < 20) {
        e["speed"] = max(e["speed"] - 2, 3);
        e["mood"]  = "retreating";
    }
  }
}

ruleAdd(ship, 0, "alive", SHIELD_REGEN);
ruleAdd(ship, 5, "alive", RETREAT);

Two brace levels: the block's, then the rule body's own. Note the string "retreating" needs no escaping at all.

What it buys beyond readability — the rule is checked at BUILD time. The text is run through the rules-C frontend when the program is compiled, so a syntax error or an out-of-subset construct is an error on that .cx line. Compare that with what a bad rule does at run time: ruleExec prints a complaint and the program carries on with the rule silently never firing — which reads like a balance problem, not a compile error, and is exactly the failure that is hardest to attribute.

The rule is still DATA. _rules declares an ordinary string. ruleAdd, ruleExec and ruleAuthor take it unchanged, there is no new builtin, and the variable can be reassigned at run time from a config file or an AI — the compile-time check buys safety without taking away the thing rules are for.

Three fences, each loud at the .cx line: the body is C dialect only (it must open with {), no splices, builtins only.

NOTE — _rules is for rule text you write. Text an AI authors at run time still arrives as an ordinary string and is validated by ruleAuthor's compile-and-retry loop — see AI Authoring. The two paths meet at the same frontend.

funcAdd — compiled CX function

// Function signature: function name(json e) — entity passed by value
// Mutations to e persist (json is byref)
function shieldRegen(json e) {
    cur.i = e["shields"];
    max.i = e["shield_max"];
    e["shields"] = min(cur + 2, max);
}

// Same schedule/trigger semantics
funcAdd(ship, 0, "alive", &shieldRegen);

// Mix funcAdd and ruleAdd on the same entity — they queue and fire in order
ruleAdd(ship, 0, "alive", "hull = clamp(hull - damage, 0, hull_max)");
funcAdd(ship, 0, "alive", &shieldRegen);

airule — AI-generated rule

// airule(entity, schedule, triggerKey, prompt)
// Sends prompt to the default AI provider; receives a rule; registers it.
// Since v3.137.0 the AI is asked for a C-rule body ("{...}" over json e),
// validated by the compiler itself before it is attached — see
// AI Authoring in C (§aiC) for the underlying validate-retry loop.

airule(ship, 0, "alive",
    "Write a rule for a ship AI. When shields fall below 20, " +
    "route power away from weapons to shields. " +
    "Fields: shields 0-50, weapons_power 0-10, speed 0-12.");

// The AI now returns a C-rule body, e.g.:
// "{ float sh = e[\"shields\"];
//    if (sh < 20) { e[\"weapons_power\"] = max(e[\"weapons_power\"]-2, 0);
//                    e[\"shields\"] = sh + 3; } }"
TIP — Give the AI the field names and their ranges in the prompt. If the generated text doesn't compile inside the C-rule subset, the authoring loop retries with the compiler's own error appended — see AI Authoring in C.

Chapter 3

C-Rules

A rule is a function body written in plain C, compiled at runtime by the same compiler that builds cx.exe — into fat bytecode that runs on the register VM near-native. Nothing new to teach an AI; a client who knows C can read or write the same rules.

The idea in one example. A rule is the exact shape an event handler already takes — a function body over json e:

// rule "on_hit" — a C-rule body
{
    float dmg = e["dmg"];
    e["hull"] = e["hull"] - dmg;      // auto-fuses to a single RMW opcode
    if (e["hull"] < 30) { raise(51, e); }
}

The leading { is how ruleAdd/ruleExec tell a C-rule from the legacy DSL. Build with #pragma rules c to link the embedded frontend that compiles it (cx.exe itself always has this — it is the compiler).

Why plain C, not a mini-language

Nothing to teach

Every LLM is pre-trained on more C than any DSL corpus we could write. Authoring competence is maximal on day one, for every AI provider.

Two audiences, one file

The AI authors C; the client who knows C reads and edits the same rules text. No translation layer between them.

Promotion = text move

A C-rule is already valid CX. Promoting a learned rule to compiled-in behaviour is moving the same text into a .cx file — zero translation. See Checkpoint Promotion.

Auto-fusion transfers free

C-rules run through the same parser → AST → emit_risc as ordinary source, so every RMW/bulk-op recognizer that fires on hand-written CX fires identically on AI-written rules.

Entity access

Rules address the entity via e["key"] subscripts — not bare key names. This keeps ordinary C semantics intact (a bare name is a local, exactly as any C author expects) and is the shape the fused RMW recognizer already targets.

{
    float hull = e["hull"];        // typed local — read the entity once
    hull = hull - e["dmg"];
    e["hull"] = hull;              // write back
    string mood = e["mood"];       // string fields work the same way
}
WARN — Known compiler quirk (pre-existing, all CX code): a raw e["key"] subscript used directly in an expression/return/cast position yields the JSON node handle, not the scalar value. Always read into a typed local first (float h = e["hp"]; return h;) before using it in a value position — the idiom used throughout this manual.

The subset (the sandbox IS the language boundary)

In the subset

Declines loudly at compile

NOTE — Declines are compile-time CX-E#### errors at line:col — FP4, never silent. What cannot be expressed cannot escape the sandbox: the fat bytecode target has no expressible arbitrary address, so there is no runtime cage to maintain on top of the language subset.

Dialect dispatch

FormBehaviour
ruleExec(e, src)Sniffs the source: a leading { (or a // comment line before it) compiles as a C-rule; anything else runs the legacy DSL (with a one-time CX-W5001 notice).
ruleExecC(e, src)Explicit — always compiles as a C-rule; useful when disambiguation matters.
ruleExecDsl(e, src)Explicit — always runs the legacy DSL interpreter.

A rule compiles once and is cached by its source text (the same content-keyed cache the DSL always used) — re-registering identical rule text on many entities pays the compile cost once.

Signature & return

A rule body compiles as function <name>.f(json e). The value of a bare return expr; (or the rule's fall-through result) is the rule's float return — matching ruleExec's historic convention. Bare return; → 0.

Loops & Budget

while and for are legal inside a C-rule — safe not by forbidding them but by a loop budget on the register VM that runs the rule.

How the budget works

MechanismBehaviour
Back-edge chargingEvery taken, backward (negative-offset) branch decrements the budget by 1. Straight-line code costs nothing.
Loop-driver chargingInternal fused loop drivers (LOOPFOLD/LOOPREDUCE/LOOPFILL) charge their whole trip count up-front, so a fused fold can't out-run a tiny budget.
Default budget1,000,000 back-edges per rule invocation — generous for a tick, instant death for while(1).
#pragma rules budget NOverrides the default (baked into the build; 0/negative ignored — a rule may not opt out of the cap).
ExhaustionCX-E5026; ruleExec returns 0 and prints the offending rule snippet. The host tick survives — the entity keeps its pre-death state.

Example

#pragma rules c
#pragma rules budget 500000   // optional override, accumulates with "rules c"

// A C-rule with a bounded loop
{
    int sum = 0;
    for (int i = 0; i < 10; i = i + 1) { sum = sum + i; }
    e["sum10"] = sum;          // 45
}
TIP — Normal (non-rule) CX code and the legacy DSL pay nothing extra — the budget check is a single short-circuiting if that only rule VMs enable. A plain 10-million-iteration VM loop measures within noise of the pre-budget build.
NOTE — foreach-over-json (walking a JSON array/collection inside a rule) is covered next in ruleFunc & foreach — it needed its own emit_risc cursor-walk support beyond plain while/for.

ruleFunc & foreach

Calling registered user functions from a rule

A rule may call only a function the host explicitly registered — registration is the sole capability grant. Unregistered names, and names colliding with a builtin, are refused loudly.

// Register once at startup, BEFORE any rule compiles
function tag.s(json e) {
    string mood = e["mood"];
    return mood;
}
ruleFunc(&tag);

// Now callable from a C-rule:
{
    string t = tag(e);           // typed local — see the value-position quirk above
    if (instr(t, "hostile") > 0) { e["alert"] = 1; }
}
WARN — A registered fn's return used directly in a type-sensitive sink (e.g. e["name"] = tag(e);) can mis-store — always assign to a typed local first (string s = tag(e); e["name"] = s;), same class as the raw-subscript quirk above.

Signatures & budget

AspectRule
Parameter typesscalar (int/float/string) + json (byref); no struct params in v1.
Return typescalar or void.
Arity/type checkingChecked and coerced at rule-compile against the registered signature; mismatches decline loudly.
A native registered fnTrusted host code — not budget-charged (a rule can't author it).
A VM-hosted registered fnThe rule's remaining loop budget travels onto the call for its duration, so its loops charge the same budget as the calling rule.
Native-side reentryBounded by a reentry-depth cap (a named constant, loud CX-E on exceed) — the backstop for a cycle that never crosses a VM back-edge.

foreach over a JSON collection

{
    json hits = e["hits"];        // a json array field on the entity
    float dmg = 0;
    foreach hits {
        json it = jsonGet(hits);  // cursor-advance one element
        dmg = dmg + it;
    }
    e["hull"] = e["hull"] - dmg;
    if (e["hull"] < 30) { raise(50, e); }
}

The foreach xs { json it = jsonGet(xs); ... } shape is the actual CX surface (not the DSL's bare-key for(key) walk) — the walk's back-edge is bounded by the same loop budget as any other loop.

Chapter 4

Legacy Rule DSL

The original string mini-language rules were authored in before C-Rules shipped. It still executes — but emits a one-time compiler warning, and new rules should not be written this way.

WARN — CX-W5001 — deprecated. Any rule text that does not start with { falls through to this DSL and prints a once-per-process warning ("rule mini-DSL is deprecated; author rules as C function bodies"). The DSL still runs correctly — nothing below is broken — but it receives no new features and is scheduled for a versioned removal once existing game content migrates to C-Rules. This section is kept as reference for reading legacy rule text, not as a guide for writing new rules.

Rule Language

Syntax

// Statements separated by semicolons
"shields = clamp(shields + 1, 0, 50); tick = tick + 1"

// If / else if / else
"if (hull < 20) { speed = max(speed-2,3); mood = 'retreating' }
 else if (hull < 50) { mood = 'defensive' }
 else { mood = 'aggressive' }"

// Scratch variables ($prefix — live for one rule execution only)
"$incoming = damage * armor_factor; hull = clamp(hull - $incoming, 0, 100)"

// Probability
"if (roll(30)) { fire = 1 }"             // 30% chance per tick
"mood = pick('aggressive','idle','scout')"  // random from args

// String fields
"if (has(mood, 'retreat')) { speed = 2 }"  // substring test
"status = 'warning: hull at ' + str(hull)" // string concat + coerce

Grammar (EBNF)

rule    = stmt { ";" stmt }
stmt    = assign | cond
assign  = (bareKey | $temp) "=" expr
cond    = "if" "(" expr ")" stmt
          { "else" "if" "(" expr ")" stmt }
          [ "else" stmt ]
expr    = orexpr
orexpr  = andexpr { "or" andexpr }
andexpr = cmpexpr { "and" cmpexpr }
cmpexpr = sumexpr { ("==" | "!=" | "<" | ">" | "<=" | ">=") sumexpr }
sumexpr = product { ("+" | "-") product }
product = unary   { ("*" | "/" | "%") unary }
unary   = ["-"] term
term    = number | string | bareKey | $temp
        | builtinCall | "(" expr ")"

Types in rule expressions

TokenTypeNotes
bareKeyentity fieldReads back at natural type. Assignment persists to the entity JSON.
$tempscratch variableCreated on first use; discarded after the rule finishes. No quota.
numberint or float42 is int, 3.14 is float. Arithmetic promotes to float when mixed.
'string'string literalSingle-quoted. Supports concat with + and comparison with == / !=.
NOTE — String support shipped v3.59. Rules can assign, concatenate, compare, and has()-test string entity fields.

Compile process

A rule string is compiled once (on first ruleAdd or first tick) to an array of {fnptr, pre-bound operands} — threaded code. Key lookups are resolved to slot indices; literals are inlined. if/else become conditional-skip entries. Running a rule = walking the array and calling each entry. No re-parse, no map lookups, no decode.

NOTE — The compile cache (v3.96) means re-registering the same rule string on multiple entities pays the parse cost once. Subsequent entities sharing the same text get the pre-compiled form.

Rule Built-in Functions

DSL-only builtins (clamp/min/max/has/…). A C-rule uses the ordinary global CX builtins directly — no separate vocabulary to learn.

Math & logic

FunctionReturnsDescription
clamp(v, lo, hi)numberClamp v to [lo, hi]. The workhorse of balance rules.
min(a, b)numberMinimum of two values.
max(a, b)numberMaximum of two values.
abs(x)numberAbsolute value.
lerp(a, b, t)numberLinear interpolation. t=0 → a, t=1 → b.
between(x, lo, hi)0 / 11 if lo ≤ x ≤ hi.
str(x)stringConvert number to string (for concat in rule strings).

Probability (the AI sweet spot)

FunctionReturnsDescription
roll(pct)0 / 1Returns 1 with pct% probability each call. Natural fit for stochastic AI behaviour.
pick(a, b, …)anyReturn one argument chosen at random. Works with numbers or string literals.
"if (roll(15)) { fire = 1 }"                     // 15% chance to fire this tick
"tactic = pick('flank','charge','hold','retreat')"  // random tactic
"if (roll(5) and hp < 30) { flee = 1 }"           // rare flee when hurt

String operations

FunctionReturnsDescription
has(text, keyword)0 / 11 if text contains keyword (case-insensitive substring test).
"if (has(mood, 'retreat')) { speed = 3 }"
"if (has(status, 'critical')) { alert = 1 }"

Spatial (for AI with position)

FunctionReturnsDescription
dist2d(ax, ay, bx, by)float2D distance.
dist3d(ax, ay, az, bx, by, bz)float3D Euclidean distance.
len3d(x, y, z)floatVector magnitude.
dot3d(ax,ay,az, bx,by,bz)floatDot product.
headingto(ax, ay, bx, by)floatCompass heading (0–360) from A to B on XZ plane.
"$d = dist3d(x,y,z, px,py,pz); if ($d < 10) { aggro = 1 }"
"heading = headingto(x, z, px, pz)"

Events from rules

FunctionDescription
raise(eventType)Fire an event from inside a rule. Tags event["source"] with the entity that raised it. Links the rule engine to the event system.
"if (hull <= 0) { alive = 0; raise(5) }"   // type 5 = custom "ship_destroyed" event
// Register a handler for it:
OnEvent(5, "1", &onShipDestroyed);

Automation Loop

FunctionDescription
automationTick()Advance the clock by one tick. Fire all behaviours (rules + functions) whose schedule and trigger gate are satisfied. Call once per frame from your draw loop.
automationRun(entity, quitKey)Headless console driver. Ticks flat-out until entity[quitKey] becomes non-zero. Equivalent to the draw loop for simulation-only programs.
// In a graphics game loop
while (gpu_frame_begin(clearColor)) {
    automationTick();       // fire all entity behaviours
    // draw entities...
    gpu_frame_end();
}

// Headless simulation (no graphics)
json world = {tick: 0, done: 0, population: 1000};
ruleAdd(world, 1, "", "tick = tick + 1");
ruleAdd(world, 1, "", "{ e[\"population\"] = clamp(e[\"population\"] + random(5) - random(3), 0, 2000); }");
ruleAdd(world, 0, "", "if (tick >= 1000) { done = 1 }");

automationRun(world, "done");     // runs 1000 ticks, then exits
println("Final population: " + str(int(world["population"])));

Chapter 5

Event System

JSON-native event registry. Handlers are C-rule strings or CX functions. The registry is data — load, save, AI-author, live-edit.

OnEvent — registering handlers

A gate and a do handler are C-RULES: a { ... } body over json e. The legacy imperative mini-DSL these examples used to be written in was removed at v3.154, and since v3.311.0 a literal gate or handler is compiled when you BUILD — so a gate CX cannot run is an error at that line instead of a handler that silently never fires. "1" is the one gate that is not a rule: it means always, and it is the only always there is. An EMPTY gate is refused (CX-E5028) rather than read as "always", because a blank gate used to evaluate to a no-fire 0 and that is the quietest possible way for a handler to be dead.

#pragma rules c

// OnEvent(type, gate, handler)
//   type:    1=keyboard  2=mouse  3=timer  4=window  5..255=user-defined
//   gate:    a C-rule over the global event{}, or "1" = always
//            (on a TIMER, the gate is an interval in ms, not a condition)
//   handler: a C-rule string OR a function reference (&fn)

// Keyboard: fire on SPACE (key code 32)
OnEvent(1, "{ return e[\"key\"] == 32; }", "{ e[\"firing\"] = 1; }");

// Keyboard: ESC quits — function handler
function onEsc.v(json ev) { eventQuit(); }
OnEvent(1, "{ return e[\"key\"] == 27; }", &onEsc);

// Mouse: left click — the gate reads event fields
OnEvent(2, "{ return e[\"button\"] == 0; }", "{ e[\"clicked\"] = 1; }");

// Mouse wheel
OnEvent(2, "{ return e[\"wheel\"] != 0; }", "{ e[\"zoomed\"] = 1; }");

// Timer: every 3000 ms — the gate is the INTERVAL here
OnEvent(3, "3000", "{ e[\"spawnEnemy\"] = 1; }");

// Window resize
// your own code -- declared here so the examples are complete
function recalcLayout.v(int w, int h) { /* reposition your UI for w x h */ }
function showDialogBubble.v(json who) { /* draw a speech bubble over `who` */ }

function onResize.v(json ev) {
    w.i = ev["w"]; h.i = ev["h"];
    recalcLayout(w, h);
}
OnEvent(4, "1", &onResize);

Event types

TypeConstantFields in event{}
Keyboard1event["key"] — key code (32=SPACE, 27=ESC, 65=A, 9=TAB, 13=ENTER…)
Mouse2event["button"] (0=left, 1=right, 2=middle), event["mx"], event["my"], event["wheel"]
Timer3event["ms"] — milliseconds elapsed since last timer event
Window4event["w"], event["h"] — new window dimensions after resize
User-defined5..255Filled manually by caller — eventFire(type) dispatches them

Event loop functions

FunctionDescription
eventPoll()Drain registered input sources, dispatch matching handlers. Returns 1 while running, 0 after eventQuit() or window close.
eventQuit()Signal the loop to exit. Next eventPoll() returns 0.
eventFire(type)Programmatically post an event. Walk gEvent[type], evaluate gates, fire matching handlers. Use for synthetic input, testing, and raise() from rules.
eventCtx()Returns the global event json. Read event fields from it; also write game state to it for gate conditions.
eventTable()Returns the full event registry as JSON. Inspect, save, or hot-reload it.
eventSetTable(tbl)Replace the event registry with a caller-supplied JSON table.
// The event loop sits INSIDE the frame loop -- gpu_frame_begin() is the
// per-frame gate, and it returns 0 when the window closes.
bg.i = rgb(18,18,28);
while (gpu_frame_begin(bg)) {
    while (eventPoll()) { /* drain this frame's events */ }
    automationTick();
    // draw...
    gpu_frame_end();
}

Chapter 6

gateOf & jsonBind

The production pattern for remappable, AI-authorable, and data-driven event bindings.

Hard-coding gate strings like "key == 32" works for prototypes. Production code uses three disjoint jsonBind views — one per trust layer — and gateOf to look up gates by name.

Three-view jsonBind pattern

// Three trust layers, each a separate JSON file view
json gRules;      // READONLY  — hard defaults the game ships with
json gBindings;   // LAZYWRITE — player remaps (written back to disk on change)
json gLearned;    // LAZYWRITE — AI-authored bindings (written back on checkpoint)

// Wire them up at startup
gRules    = jsonBind("data/bindings_default.json", JSON_READONLY);
gBindings = jsonBind("data/bindings_player.json",  JSON_LAZYWRITE);
gLearned  = jsonBind("data/bindings_ai.json",      JSON_LAZYWRITE);

// Register handlers using gateOf
function registerHandlers() {
    OnEvent(1, gateOf("keyboard", "evFire"),       &evFire);
    OnEvent(1, gateOf("keyboard", "evDash"),       &evDash);
    OnEvent(1, gateOf("keyboard", "evMenu"),       &evMenu);
    OnEvent(1, gateOf("keyboard", "evFullscreen"), &evFullscreen);
    OnEvent(4, gateOf("window",   "evResize"),     &evResize);
    OnEvent(1, "{ return e[\"capturing\"] == 1; }", &evCaptureKey);  // a literal gate is a C-rule
}

registerHandlers();

gateOf

gateOf(section, name) is a helper you write — it is not a builtin, and the whole of it is the eight lines below. It looks up a gate string from the binding registry, searching gBindings first (player override) and falling back to gRules (shipped defaults), so a player remap takes effect without any code change.

function gateOf.s(string section, string name) {
    json over = gBindings[section][name];      // player override, if any
    if (over->valid == 1) { return over["gate"]; }
    json def = gRules[section][name];          // shipped default
    if (def->valid == 1) { return def["gate"]; }
    return "";                                  // no binding -> never fires
}
// bindings_default.json (shipped with the game)
// {
//   "keyboard": {
//     "evFire":  { "gate": "key == 32 and phase == 1" },
//     "evDash":  { "gate": "key == 304" },
//     "evMenu":  { "gate": "key == 27" }
//   },
//   "window": {
//     "evResize": { "gate": "" }
//   }
// }

gate.s = gateOf("keyboard", "evFire");   // returns "key == 32 and phase == 1"
// Player remapped SPACE to X (key 88):
// gBindings["keyboard"]["evFire"]["gate"] = "key == 88 and phase == 1"
// gateOf now returns the override automatically

syncInput Pattern

Gate conditions can read any field in event{}. This lets gates test game state — "phase == 1" is only true in combat, "screen == 2" only on the settings screen. But those fields don't come from the OS; you push them into the event context before each poll.

// Write game state into the event context each frame
function syncInput.v() {
    json ev    = eventCtx();
    ev["screen"]    = gScreen;      // which screen is active (0=title, 1=game, 2=settings)
    ev["phase"]     = gPhase;       // game phase (0=menu, 1=combat, 2=cutscene)
    ev["shipview"]  = gShipView;    // which ship panel is shown
    ev["capturing"] = gCapturing;   // 1 while the capture-key dialog is open
}

// Main loop
while (eventPoll()) {
    syncInput();        // push state INTO event context BEFORE dispatch
    automationTick();
    gpu_frame_begin(bg);
    // draw...
    gpu_frame_end();
}
TIP — Why this works: eventPoll() reads event{} when evaluating gates. By writing game state into the context before the poll, gate strings like "screen == 1 and key == 32" correctly scope actions to the right game mode — without any if/else in the handler.

Gate design guidelines

PatternExample gateWhen to use
Key only"key == 32"Global hotkey, works in any screen
Key + screen"key == 32 and screen == 1"Action only valid on game screen
Key + phase"key == 32 and phase == 1"Combat only
Key + !capture"key == 65 and capturing == 0"Suppress during key-rebind dialog
Unconditional"" or "1"Window resize, always-active handlers

Rebindable Key Bindings

// Write a new binding for a named action
function setBinding.v(action.s, keycode.i) {
    json entry;
    jsonAddStr(entry, "gate", "key == " + str(keycode) + " and capturing == 0");
    jsonAddStr(entry, "fn",   action);

    json kb = gBindings["keyboard"];
    jsonArrAdd(kb, entry);            // appends to the player's bindings array

    // gBindings is LAZYWRITE — it will be flushed to disk at checkpoint/exit
    // Re-register to pick up the new gate immediately:
    registerHandlers();
}

// Player presses a new key for "Fire" in the settings screen:
setBinding("evFire", 88);    // X = fire from now on

Persisting bindings

// LAZYWRITE views flush automatically at checkpoint + program exit.
// Force a flush at any time:
jsonFlush(gBindings);

// Or save the whole event table as a JSON snapshot:
tbl.s = jsonExport(eventTable());
fwrite("bindings_snapshot.json", tbl);

// Load it back (e.g. after a crash or on a new device):
json tbl = jsonload("bindings_snapshot.json");
eventSetTable(tbl);
registerHandlers();    // re-wire handlers with the loaded gates

Chapter 7

AI Authoring & Checkpoint Promotion

The AI writes C-rules; the compiler validates them before they attach. A learned rule can later be promoted — literal text moved — into compiled-in code.

AI Authoring

The model: humans write the compiled functions that enforce invariants. The AI writes rules that tune behaviour within those invariants. The trust boundary is the function/rule divide, enforced at rule-compile time by the C-Rules subset — an AI-authored rule cannot exceed the whitelist regardless of what it generates.

airule usage

// Effective prompt: give field names, ranges, and the desired behaviour
airule(enemy, 0, "alive",
    "Write a rule for a ship AI. " +
    "Fields: hull 0-100, shields 0-50, speed 0-12, weapons_power 0-10, mood is a string. " +
    "When shields are low, reduce weapons_power and increase shields. " +
    "When hull is critical, set mood to 'retreating' and lower speed.");

// The AI is prompted for a C-rule body and validated before it attaches —
// typical output:
// "{ float sh = e[\"shields\"]; float hull = e[\"hull\"];
//    if (sh < 15) { e[\"weapons_power\"] = max(e[\"weapons_power\"]-2,0); e[\"shields\"]=sh+3; }
//    if (hull < 20) { e[\"mood\"] = \"retreating\"; e[\"speed\"] = max(e[\"speed\"]-2,3); } }"

AI council pattern (ensemble)

// Multiple AI personalities author different rule sets for the same entity
// Each runs in parallel on a background thread (up to 8 concurrent)
ai_call_async("Author an aggressive combat rule for: hull, shields, weapons_power, damage", "anthropic", 1);
ai_call_async("Author a defensive shield-regen rule for: shields, shield_max, speed", "openai",     2);
ai_call_async("Author a morale/mood rule for: hull, mood", "anthropic",                             3);

while (!ai_async_ready(1) || !ai_async_ready(2) || !ai_async_ready(3)) { sleep(10); }

ruleAdd(enemy, 0, "aggro",  ai_result(1));
ruleAdd(enemy, 0, "alive",  ai_result(2));
ruleAdd(enemy, 0, "alive",  ai_result(3));   // multiple rules fine on same gate

gLearned — AI-persistent bindings

// The gLearned jsonBind view is where AI-authored bindings live
// It is LAZYWRITE — persists across sessions automatically

// At a checkpoint, ask the AI to author a context-aware binding:
prompt.s = "The player uses the keyboard binding 'evDodge' often in phase 1 " +
           "but rarely in phase 2. Author a CX gate string that activates " +
           "evDodge only in phase 1 with key 304 (L-Shift).";
gate.s = ai_call(prompt, "anthropic");

json entry;
jsonAddStr(entry, "gate", gate);
jsonAddStr(entry, "fn", "evDodge");
jsonArrAdd(gLearned["keyboard"], entry);   // stored in gLearned, flushed on exit

AI Authoring in C

Under the hood, airule (and any direct caller) drives a validate-retry loop: build a prompt → fetch text (live AI or an injected mock) → validate it through the embedded compiler → on a decline, retry once with the compiler's own error appended → on final failure, attach nothing and fail loudly (FP4 — never a silently-broken rule).

Surface

FunctionDescription
ruleAuthor(task)stringRuns the full author→validate→retry loop for a described task; returns the validated C-rule text on success. Bounded by CX_RULE_AUTHOR_MAX_RETRIES (default 2, so up to 3 attempts); a final decline is loud and returns empty.
ruleAuthMock(text)Injects a fixed response into the fetch step instead of calling a live AI provider — for testing prompts and decline handling without network access.
ruleAuthStats()jsonEvidence feed: {attempts, declines, finalOk, lastClass, lastClassCode, lastReason, records:[{class, classCode, retry, reason}, ...]} across all authoring attempts so far.
ruleAuthReset()Clears stats, the mock queue, and the once-per-warning set.

Example

#pragma rules c

task.s = "When hull drops below 30, raise event 50 with the entity as source.";
text.s = ruleAuthor(task);
if (strlen(text) > 0) {
    ruleAdd(ship, 0, "alive", text);
} else {
    println("AI authoring declined after retries — see ruleAuthStats()");
}

json stats = ruleAuthStats();
println("attempts=" + str(int(stats["attempts"])) +
        " declines=" + str(int(stats["declines"])));

Decline classes

Every attempt+decline is recorded with a classification, so repeated failure modes can be diagnosed or fed back into prompt design:

ClassTypical cause
pointeraddress-of/deref usage in the generated text
call-denieda non-whitelisted builtin OR an unregistered user function (both share the same compiler error text, hence one class)
aritywrong argument count on a registered/builtin call
structa struct/typedef declaration (not in the v1 subset)
nonscalara non-scalar local declaration (includes bare pointer decls)
foreach-colonthe AI tried CX's non-existent foreach(x:c) colon form — use foreach xs { json it = jsonGet(xs); ... }
syntaxgeneric parse failure
empty / otherblank response / uncategorised

Checkpoint Promotion

A C-rule is already valid CX. Promotion takes rule text that has proven itself at runtime and moves it — literally, unchanged — into a .cxi source file as a real compiled function, closing the loop from "AI learned this behaviour" to "this behaviour now ships compiled-in."

Two surfaces, one core

SurfaceUse
rulePromote(rulesArr, path)Builtin. Host-only — deliberately absent from the rule whitelist (a rule that could promote itself would be a capability escalation). Call it from your own game code / tooling, never from a rule.
cx --promote-rules <rules.json> [-o <out.cxi>]CLI. A thin #pragma rules c bootstrap over the same builtin (cx.exe has no JSON parser of its own, so this reuses the real pipeline). Nonzero exit on any failure — safe to gate a build/CI step on.

rules.json format

An array of {name, body} objects — the input to promotion:

[
  { "name": "shieldRegen", "body": "{ e[\"shields\"] = clamp(e[\"shields\"]+1,0,e[\"shield_max\"]); }" },
  { "name": "retreatCheck", "body": "{ float h=e[\"hull\"]; if (h<20){ e[\"mood\"]=\"retreating\"; } }" }
]

After promotion, each entry gains "promoted": true in place, and the output .cxi contains one function <name>.f(json e) { ... } per rule plus a register_promoted_rules.v() block of ruleFunc(&<name>); calls, behind a provenance header.

Safety guarantees

GuaranteeMechanism
No accidental overwriteGenerated files carry a marker (CX-GENERATED-RULES-V1); promotion refuses to write over any file lacking it.
Nothing broken shipsEvery rule body is compile-checked through the real frontend first — a single failing rule aborts the whole promotion; nothing partial is written.
No name collisionsA promoted name colliding with a builtin, or duplicated within the same set, fails named (not silently overwritten).
Atomic writeTemp-file-then-rename — a crash mid-write can't leave a half-written .cxi.
Re-promotableRunning promotion again on a changed set regenerates a correctly marked file.

ruleFire — the dispatch handshake

// ruleFire(entity, entry) — call instead of ruleExec once promotion may have happened
result.f = ruleFire(ship, ruleEntry);

ruleFire checks the entry's promoted mark: if promoted and the name is registered (via ruleFunc), it calls the compiled function directly — no recompile, provably flat via ruleCacheCount(). If the mark says promoted but no matching function is registered (a stale exe running newer rule data), it falls back to interpreting the text and warns once per rule — visible to the developer, harmless to the player.

TIP — ruleCacheCount() returns the rule-compile cache's entry count — use it in tests to prove a promoted path never triggers a recompile.

The one promotion caveat

WARN — The rule-only 1-argument raise(type) sugar (rulec silently rewrites it to raise(type, e), tagging the entity as the event source) does not survive promotion verbatim — a promoted plain function is compiled without that rewrite. Rules intended for promotion should use the explicit 2-argument form, raise(type, e), from the start. Everything else is literal text movement with zero semantic drift.

End-to-end flow

// 1. Author + validate + attach (interpreted, live)
text.s = ruleAuthor("Shield regen: e[\"shields\"] += 1 up to e[\"shield_max\"] each tick, using raise(52,e) at max.");
ruleAdd(ship, 0, "alive", text);

// 2. ...runs for a while, proves itself in play...

// 3. Promote the accepted rule into compiled-in code (host tooling, not a rule)
json entries = jsonArr();
json entry;
jsonAddStr(entry, "name", "shieldRegen");
jsonAddStr(entry, "body", text);
jsonArrAdd(entries, entry);
rulePromote(entries, "data/promoted_rules.cxi");

// 4. Next build: #include "data/promoted_rules.cxi"; register_promoted_rules();
//    ruleFire() now calls the compiled function — zero recompiles, same behaviour.

Chapter 8

Complete Examples

End-to-end patterns showing the system working together.

Examples

C-rule with a registered function, a loop, and a raise current

#pragma rules c
#pragma AIProvider anthropic

_json ship {
  { "hull": 100, "hull_max": 100, "shields": 50, "shield_max": 50,
    "hits": [], "alive": 1 }
}

// A function rules are allowed to call — grant the capability once at startup
function tag.s(json e) {
    string mood = e["mood"];
    return mood;
}
ruleFunc(&tag);

function onShipDestroyed(json ev) { println("ship destroyed"); }
OnEvent(50, "1", &onShipDestroyed);

// A C-rule: sums a batch of incoming hits (foreach), applies them (RMW-fused),
// calls the registered function, and raises an event on death
ruleAdd(ship, 0, "alive",
    "{ json hits = e[\"hits\"]; float dmg = 0;"
    "  foreach hits { json it = jsonGet(hits); dmg = dmg + it; }"
    "  e[\"hull\"] = e[\"hull\"] - dmg;"
    "  string m = tag(e);"
    "  if (e[\"hull\"] <= 0) { e[\"alive\"] = 0; raise(50, e); } }");

// Shield regen, another C-rule, every tick
ruleAdd(ship, 0, "alive",
    "{ e[\"shields\"] = clamp(e[\"shields\"] + 1, 0, e[\"shield_max\"]); }");

automationTick();
NOTE — The remaining examples below predate the C-Rules migration and still use the legacy DSL string form to illustrate the automation loop, event system, and gateOf/jsonBind patterns — those patterns (schedule/trigger, OnEvent, gateOf, syncInput) are unchanged by C-Rules. Wherever you see a bare-key DSL string below (e.g. "hp = hp - 10"), the equivalent C-rule form is "{ e[\"hp\"] = e[\"hp\"] - 10; }".

Combat entity with rules + events

#pragma AIProvider anthropic

// Entity
json player = {hp: 100, mp: 50, speed: 5.0, alive: 1, firing: 0};
json enemy  = jsonload("data/scout.json");   // {"hp":60,"shields":20,"speed":4,"alive":1,"aggro":0}

// Compiled function — physics (fast path)
function enemyMove(json e) {
    dt.f = e["dt"]; dx.f = e["dx"]; dz.f = e["dz"];
    e["x"] = e["x"] + dx * dt;
    e["z"] = e["z"] + dz * dt;
}
funcAdd(enemy, 0, "alive", &enemyMove);

// Rule string — AI behaviour (tweakable)
ruleAdd(enemy, 0,  "alive",  "shields = clamp(shields + 1, 0, 20)");
ruleAdd(enemy, 5,  "alive",  "if (hp < 20) { speed = 2; aggro = 0 }");
ruleAdd(enemy, 0,  "aggro",  "if (roll(40)) { fire = 1 }");

// Event handlers
OnEvent(1, "{ return e[\"key\"] == 32; }", "{ e[\"player_firing\"] = 1; }");   // SPACE = shoot
function onPlayerHit(json ev) {
    player["hp"] = max(int(player["hp"]) - 10, 0);
    if (int(player["hp"]) <= 0) { player["alive"] = 0; eventQuit(); }
}
OnEvent(5, "1", &onPlayerHit);    // type 5 = hit event

// range check in rules fires the event
ruleAdd(enemy, 0, "alive",
    "{ float d = dist3d(e[\"x\"],e[\"y\"],e[\"z\"], px,py,pz);
       if (d < 5 && random(100) < 20) { raise(5); } }");

// Main loop
while (eventPoll()) {
    syncInput();
    automationTick();
    // draw...
}

NPC dialogue state machine

// NPC state lives in a JSON entity
json guard = {
    state: "idle",   // idle / alert / hostile / talking
    seen_player: 0,
    trust: 50,
    hp: 80,  alive: 1
};

// State transitions as rules
ruleAdd(guard, 1, "alive",
    "if (seen_player and trust < 30) { state = 'hostile' } " +
    "else if (seen_player and trust >= 30) { state = 'talking' } " +
    "else { state = 'idle' }");

// AI-authored dialogue behaviour
airule(guard, 0, "alive",
    "Write a rule for NPC guard. If state is 'talking' and trust > 60, " +
    "increase trust by 2. If state is 'hostile', decrease trust by 5. " +
    "Fields: state is a string, trust is 0-100. You may use has and clamp.");

// Compiled handler for the 'talking' visual
function onTalking(json ev) {
    json src = ev["source"];
    state.s  = src["state"];
    if (state == "talking") { showDialogBubble(src); }   // your own UI call
}
OnEvent(6, "1", &onTalking);     // type 6 = npc_state_changed

// Trigger it from the rule:
ruleAdd(guard, 0, "alive", "raise(6)");

Data-driven input (production pattern)

// Startup
gRules    = jsonBind("data/bindings_default.json", JSON_READONLY);
gBindings = jsonBind("data/bindings_player.json",  JSON_LAZYWRITE);
gLearned  = jsonBind("data/bindings_ai.json",      JSON_LAZYWRITE);

function registerHandlers.v() {
    OnEvent(1, gateOf("keyboard", "evFire"),       &evFire);
    OnEvent(1, gateOf("keyboard", "evDodge"),      &evDodge);
    OnEvent(1, gateOf("keyboard", "evMenu"),       &evMenu);
    OnEvent(1, gateOf("keyboard", "evFullscreen"), &evFullscreen);
    OnEvent(1, "{ return e[\"capturing\"] == 1; }", &evCaptureKey);
    OnEvent(4, gateOf("window",   "evResize"),     &evResize);
}

registerHandlers();

// Game state pushed into event context each frame
function syncInput.v() {
    json ev     = eventCtx();
    ev["screen"]    = gScreen;
    ev["phase"]     = gPhase;
    ev["capturing"] = gCapturing;
}

// Main loop
while (eventPoll()) {
    syncInput();            // state into context BEFORE poll evaluates gates
    automationTick();
    gpu_frame_begin(bg);
    // draw game...
    gpu_frame_end();
}