Volume 6
Scripting Engine & Logic Framework
Section titled “Scripting Engine & Logic Framework”Janino in-memory compilation, zero-allocation callbacks, and the
SimpleEntityScriptDSL. Volume 1 explained how a script gets compiled and ticked. This volume covers what you write inside one: the framework that turns a bag of ECS components into gameplay, without giving up the engine’s no-garbage rule.
Design Philosophy
Section titled “Design Philosophy”RenderWare never shipped a gameplay language. It gave studios a solid core and left the top layer to them, and nearly every studio ended up writing the same helpers: “move this thing”, “wait two seconds”, “tell the HUD”. Gamebryo and Creation Engine went the other way. Papyrus made the top layer a first-class part of the engine, and modders built a decade of content on it. Minecraft showed that this top layer can be plain Java, as long as the engine keeps it fast.
The scripting framework (com.retro.engine.scripting.framework) is RetroEngine’s version of that top layer. It sits
between the low-level ECS systems (Volume 1) and the people writing game logic, who are often modders who have never
opened PhysicsSystem.java.
Friction in the Raw ECS API
Section titled “Friction in the Raw ECS API”EntityScript gives you everything, but in its rawest form. Under Janino (§1.2) it also makes you spell out every
type yourself:
// Raw EntityScript: correct, but every line is bookkeepingTransform t = (Transform) entity.getComponent(Transform.class); // cast required under JaninoRigidBodyComponent rb = (RigidBodyComponent) entity.getComponent(RigidBodyComponent.class);if (t != null && rb != null) { ... }That’s a component lookup and a cast on every call. Most new script authors end up doing it every frame, because nothing tells them not to.
SimpleEntityScript swaps that for cached, concretely typed getters: transform(), rigidbody(), light().
The first call does the lookup and later calls return a field. No casts, no repeated lookups, and the cache notices
when components are added or removed.
The Rule That Doesn’t Bend
Section titled “The Rule That Doesn’t Bend”Anything a script calls every frame or every physics step must not allocate.
The physics solver steps at a fixed 60 Hz (Volume 4) and the frame loop targets a flat 16.67 ms line (Volume 5).
Every service in this volume (timers, tweens, events, input, debug drawing, camera shake) runs on preallocated arrays
and int handles. The few calls that do allocate (spawning prefabs, writing save data, scanning the scene by name)
say so in their Javadoc and in the tables below. Call them on events, not in onUpdate.
1. The Janino Runtime & Compilation Constraints
Section titled “1. The Janino Runtime & Compilation Constraints”1.1 How a Script Becomes a Class
Section titled “1.1 How a Script Becomes a Class”RetroEngine compiles scripts with Janino 3.1.12 (org.codehaus.janino:janino). Janino is a Java compiler
written in Java that runs inside the engine process. It doesn’t start javac and doesn’t write .class files to
disk.
SimpleCompiler compiler = new SimpleCompiler();ClassLoader parent = scriptParentLoader(); // mod class-loader chain, else the engine's own loadercompiler.setParentClassLoader(parent);thread.setContextClassLoader(parent); // Janino resolves some types through the context loadercompiler.cook(scriptFile.getAbsolutePath(), reader); // parse + compile to bytecode, in memoryClass<?> c = compiler.getClassLoader().loadClass(name);ScriptEngine.compileAndLoadJavaFile runs this once per script source:
- Locate the source. The lookup order is: explicit
scriptAssetPath, the raw name,<project>/Assets/Scripts/, anywhere under<project>/Assets/, then the working directory’sAssets/Scripts/. - Read the
packagedeclaration so the right fully qualified name can be loaded. - Cook the source under an explicit parent class loader.
- Cache the class in
compiledClassCache. A failed compile goes intofailedScriptCache, so a broken script isn’t recompiled and re-logged every frame. The component is disabled until Play stops.
War story: the context class loader. By default Janino resolves types through the thread’s context class loader. In
gradle runthat loader can see every engine class. In a packaged app it may not, and a script that compiles in the editor can then fail to resolveEntityScriptin the shipped game. The fix is the two explicit lines above. The engine never relies on Janino’s defaults.
1.2 What Janino Doesn’t Support
Section titled “1.2 What Janino Doesn’t Support”Janino covers most of Java, but not all of it. These are the gaps that affect gameplay code:
| Feature | Janino 3.1.12 | What to write instead |
|---|---|---|
Lambdas () -> ... |
✗ | Anonymous class: new ScriptTask() { public void run() { ... } } |
Method references this::foo |
✗ | Anonymous class or named inner class |
| Generic type inference | ✗ (erased) | Cast explicitly: (BoxCollider) e.getComponent(BoxCollider.class) |
For-each over List<Entity> |
✗ | Index loop with a cast: Entity e = (Entity) list.get(i); |
| Varargs call sugar | Avoid | Pass arrays explicitly, as the bundled scripts do: new Class[] { int.class } |
| Anonymous & inner classes | ✓ | The main tool for callbacks and FSM states (§3) |
| Referencing another script’s class | ✗ (isolated, §1.3) | ScriptEvents (§1.4) |
War story: the javac false positive. Janino reads generic signatures and then drops the type arguments.
getComponent(X.class)returns plainComponent, andfor (Entity e : list)won’t compile.javacaccepts both, so a “does every template script compile?” check built onjavacreported the Starter Game template scripts as clean while Janino rejected them. Check scripts by cooking them withorg.codehaus.janino.SimpleCompileragainst the real runtime classpath, never withjavac.
The framework is designed with these gaps in mind. Every SimpleEntityScript getter returns a concrete type, every
callback is a single-method interface (ScriptTask, ScriptEventListener), and every state is a plain abstract
class (ScriptState).
1.3 Class-Loader Isolation
Section titled “1.3 Class-Loader Isolation”Each script source gets its own SimpleCompiler, so each script class lives in its own class loader.
Bootstrap / platform └── Engine class loader EntityScript, SimpleEntityScript, ScriptEvents, Transform, … └── Mod class-loader chain mod components & ModScripts (Volume 4), when --mods is active ├── Janino loader A PlayerCombatScript ├── Janino loader B GameManagerScript └── Janino loader C CollectibleScriptHere’s what follows from that:
- Every script can see the engine and loaded mods. Mod component classes can be used directly.
- Scripts can’t see each other. Loader A has no idea
GameManagerScriptexists, so((GameManagerScript) other).addScore(1)doesn’t compile. This is deliberate: one broken script never stops another from loading. - Framework statics are shared.
ScriptEvents,ScriptInput,ScriptSaveDataandScriptDebuglive in the engine loader, so all scripts see the same instance. That shared layer is how scripts talk to each other.
1.4 ScriptEvents: Talking Across the Boundary
Section titled “1.4 ScriptEvents: Talking Across the Boundary”Before the framework existed, scripts talked to each other through reflection. This is the old CollectibleScript:
// BEFORE: brittle, allocating, and silently breaks when GameManager is renamedObject inst = sc.activeScriptInstance;inst.getClass().getMethod("addScore", new Class[] { int.class }) .invoke(inst, new Object[] { Integer.valueOf(points) }); // boxing + Object[] on every pickupNow it publishes an event, and anything that cares subscribes to it:
// Publisher (CollectibleScript)publishEvent("collectible_collected", null, points); // source = this entity, text = null, number = points
// Subscriber (GameManagerScript)public class GameManagerScript extends SimpleEntityScript implements ScriptEventListener { public void onStart() { subscribeEvents(this); } // auto-unsubscribed when this script is destroyed
public void onScriptEvent(String eventName, Entity source, String text, float number) { if ("collectible_collected".equals(eventName)) addScore((int) number); }}ScriptEventListener is an engine interface, so every Janino loader sees the same type. The payload is
(eventName, source, text, number) as plain arguments, and there is no event object to allocate.
| Behavior | Detail |
|---|---|
| Delivery | Synchronous, main thread, subscription order. Every listener gets every event and filters by name. |
| Allocation | None per publish. The listener array grows (by doubling) only when you subscribe more listeners than it holds. |
| Re-entrancy | Subscribe, unsubscribe and publish are all safe inside a callback. Unsubscribing nulls the slot, and the list is compacted when the outermost publish returns. Listeners added during a publish start with the next event. |
| Fault isolation | A listener that throws is logged and skipped. The other listeners still get the event. |
| Lifetime | subscribeEvents(listener) ties the subscription to the script, which unsubscribes on destroy or hot reload. All subscriptions are dropped when Play stops. |
Use
public static final Stringconstants for event names (CollectibleScript.EVENT_COLLECTED), but remember that another script can’t import them (§1.3). Copy the literal and keep a single list in your project notes.
1.5 Hot Reload During Play
Section titled “1.5 Hot Reload During Play”Volume 1 §3.3 covers how the reload is queued. These are the parts that matter for the framework:
file saved → asset-folder-watcher → ScriptEngine.invalidateScript(path) (queued, never mid-frame)next script tick → recompile once → for each live instance: old.onDestroy() → old.onFrameworkDestroy() → new.init() → overrides re-bound → new.onStart()onFrameworkDestroy()cancels the old instance’s timers and tweens and unsubscribes its listeners. A reload never leaves a dead class’sScriptTaskfiring, or a stale listener receiving events.- Instance state resets. Fields go back to their initializers and Inspector overrides are re-applied. If a value
has to survive a reload, keep it in a component, in
ScriptSaveData, or recompute it inonStart. - Static fields of the script class reset too, because the reloaded class is a new class in a new loader.
- A failed compile keeps the old version running. The error goes to the console.
2. SimpleEntityScript Architecture & Spatial DSL
Section titled “2. SimpleEntityScript Architecture & Spatial DSL”2.1 Lifecycle Wiring
Section titled “2.1 Lifecycle Wiring”SimpleEntityScript extends EntityScript and adds two engine hooks that ScriptEngine calls on every script:
| Hook | When | What the framework does |
|---|---|---|
init(entity, world) |
Instantiation | Binds the ComponentCache before onAwake, so the getters work there |
onFrameworkUpdate(dt) |
Every frame, just before onUpdate |
Ticks tasks and tweens |
onFrameworkDestroy() |
Right after onDestroy (and on hot reload) |
Cancels all tasks and tweens, unsubscribes owned listeners |
You don’t need to call super.onUpdate(). Override onUpdate and the framework still ticks.
Ordering trap: Inspector property overrides are injected after
init(), which means afteronAwake(). InonAwake, yourpublic float speedstill holds its initializer value. Read Inspector-tuned fields inonStart.
2.2 The ComponentCache Model
Section titled “2.2 The ComponentCache Model”public Transform transform() { if (!validate()) return null; // unbound → null if (transform == null) transform = entity.getTransform(); return transform; // every later call: one field read}
private boolean validate() { int n = entity.getAllComponents().size(); if (n != componentCount) { invalidate(); componentCount = n; } // component added/removed → drop refs return true;}| Accessor | Returns | Notes |
|---|---|---|
transform() |
Transform |
|
rigidbody() |
RigidBodyComponent |
The component (authoring data). The live simulation body is reached through setVelocity etc. |
collider() |
Component |
First of box → sphere → capsule. Use the typed accessors below for fields. |
boxCollider() / sphereCollider() / capsuleCollider() |
concrete type | |
animator() |
AnimatorComponent |
|
light() |
Light |
|
camera() |
Camera |
Also changes lookAt / turnTowards conventions (§2.4) |
audio() |
AudioSource |
|
material() / meshFilter() |
concrete type | |
characterController() / vehicleController() |
concrete type |
How the cache behaves:
- Lazy. Nothing is looked up until you ask for it.
- Misses aren’t cached. If a component is missing, the getter returns
nulland checks again on the next call, so a component added later is found. - Self-invalidating. If the entity’s component count changes, every cached reference is dropped.
- Manual escape hatch. If you remove one component and add another in the same frame (same count, different
object), call
invalidateComponentCache().
2.3 Side by Side: Verbose vs. Clean
Section titled “2.3 Side by Side: Verbose vs. Clean”The same behavior written both ways: bob up and down, face the player, and push away from them on contact.
// ---- EntityScript ----------------------------------------------------------------------------public class BobberScript extends EntityScript { public float bobHeight = 0.25f; private float time, baseY; private Entity player;
public void onStart() { baseY = getPositionY(); java.util.List all = sceneWorld.getSceneEntities(); for (int i = 0; i < all.size(); i++) { // no for-each under Janino Entity e = (Entity) all.get(i); if (e.getComponent(CharacterControllerComponent.class) != null) { player = e; break; } } }
public void onUpdate(float dt) { time += dt; Transform t = (Transform) entity.getComponent(Transform.class); // lookup + cast, every frame t.posY = baseY + (float) Math.sin(time * 6.28f) * bobHeight; if (player != null) { Transform pt = (Transform) player.getComponent(Transform.class); float dx = pt.posX - t.posX, dz = pt.posZ - t.posZ; // local, not world, position t.rotY = (float) Math.toDegrees(Math.atan2(-dx, dz)); // snaps; no turn speed } }}
// ---- SimpleEntityScript ----------------------------------------------------------------------public class BobberScript extends SimpleEntityScript { public float bobHeight = 0.25f; public float turnSpeed = 180.0f; private float time, bobOffset; private Entity player;
public void onStart() { player = findPlayer("Player"); } // scan once, cache
public void onUpdate(float dt) { time += dt; float offset = (float) Math.sin(time * 6.28f) * bobHeight; move(0.0f, offset - bobOffset, 0.0f); // dy = world up bobOffset = offset; if (player != null) lookAtFlat(player); // world-space, parent-aware }}The second version is about half as long. It also uses world positions (so it still works when the entity is parented under a moving platform), it’s correct for cameras, and it does no per-frame lookups.
2.4 Spatial Helpers
Section titled “2.4 Spatial Helpers”| Method | Space | Behavior |
|---|---|---|
translate(dx, dy, dz) |
World axes | Inherited from EntityScript. Adds to the local position. |
rotate(drx, dry, drz) |
Euler degrees | Inherited. Adds to the local rotation. |
move(dx, dy, dz) |
Facing-relative | dx along the right vector, dy along world up, dz along forward (for cameras, the view direction). |
moveInPlace(Vector3) |
Facing-relative | move taking a reusable vector (x = right, y = up, z = forward). |
lookAt(Entity) / lookAt(x, y, z) |
World | Sets pitch and yaw so forward points at the target. Keeps roll. |
lookAtFlat(Entity) |
World, yaw only | For characters that must stay upright. |
turnTowards(x, z, maxDeg) |
World, yaw only | Turns at most maxDeg this call and returns the remaining angle. Pass speed * dt. |
getWorldPosition(out) / getWorldX/Y/Z() |
World | Includes parents, read from SceneWorld’s computed world transforms. |
getDistanceTo(e) / getFlatDistanceTo(e) |
World | Float.MAX_VALUE for null. The flat version ignores height. |
findPlayer(name) |
Scene scan | Entity named name, else the first entity with a CharacterController. Scans the scene, so cache the result. |
isPlayer(other, name) |
No scan | The same test for one entity. Use it in trigger callbacks. |
Forward conventions. Models face +Z. Cameras look down −Z. lookAt and turnTowards check
camera() and pick the right formula, so the same call aims a turret mesh or a security camera. Both subtract the
parent’s world pitch and yaw, which makes them exact for root entities and for children whose parents only yaw or
pitch.
Physics shortcuts. move and translate write the Transform directly. That’s correct for kinematic props
and wrong for anything the solver owns:
applyImpulse(0, 6.0f, 0); // velocity += impulse / mass on a DYNAMIC body; false if there isn't onesetVelocity(vx, vy, vz); // overwrite linear velocity (m/s); keeps gravity & collisions workinggetVelocity(scratch); // read into a reusable Vector3raycastInPlace(20f, hit); // from world position along forward, ignoring self, into a reused RaycastHitPlayer movement belongs to
CharacterController. Don’tmove()a player orsetVelocity()a character. Feed the controller from input and let its ground probe, step snapping and friction (Volume 4 §1.3) do the work. The same goes for cars andVehicleController.
2.5 Entity Lifecycle & Spawning
Section titled “2.5 Entity Lifecycle & Spawning”Deferred destruction
Section titled “Deferred destruction”destroySelf(); // this entity + all descendants, at end of framedestroy(otherEntity);if (isBeingDestroyed()) return; // queued this frame and not yet removedSceneWorld.requestEntityDestroy queues the entity. Nothing is removed while scripts or physics are
iterating. The queue is flushed after the simulation step:
ScriptRuntime.beginFrame → strip last frame's camera shake, age debug linesphysics + scripts → destroy() calls land in pendingDestroysflushPendingDestroys() → breadth-first collect descendants → onDestroy + onFrameworkDestroy for each script → PhysicsSystem.removeRuntimeEntity (bodies list + BVH leaf) → deleteEntity, children before parentsScriptRuntime.endFrame → apply this frame's camera shakerender- Safe from any callback, including
onTriggerEnterin the middle of a contact pass. - No orphans. Destroying a parent takes its whole subtree with it.
- Stop restores it. Destruction is a Play-mode change like any other, so the Play snapshot brings the entity back.
- Outside Play,
requestEntityDestroydeletes immediately. No frame is running, so there’s nothing to defer.
Mid-play prefab instantiation
Section titled “Mid-play prefab instantiation”Entity coin = spawnPrefab("Prefabs/Coin.rprefab", x, y, z); // returns the root, or nullSceneWorld.instantiatePrefabRuntime was written for spawning during gameplay, not for the editor’s “drag prefab
into scene”:
| Step | Behavior |
|---|---|
| Path | Resolved through the project AssetDatabase / mod VFS (Volume 4 §2.1), so mods can override the prefab. |
| Selection | The editor selection is saved and restored, and the editor spawn sound doesn’t play. |
| Mods | ModLoader.onPrefabSpawned runs, so EntitySpawnEvent veto handlers still apply. |
| Physics | Each spawned physics entity is passed to PhysicsSystem.registerRuntimeEntity. The body is created at the start of the next physics step, so no solver loop sees the body list change while it iterates. |
| Scripts | Scripts on the spawned entities are instantiated by the script tick (in the same pass if the new entity was appended after the spawner, otherwise on the next frame). |
Cost: this reads the prefab from disk and allocates entities and components. It’s fine for a pickup dropping or a wave spawning. It isn’t fine 60 times a second. For bullets, pool a fixed set of entities and re-position them.
3. Allocation-Free Timers, Tweens & State Machines
Section titled “3. Allocation-Free Timers, Tweens & State Machines”3.1 Why Not java.util.Timer, Threads, or Closures?
Section titled “3.1 Why Not java.util.Timer, Threads, or Closures?”| Tempting tool | What goes wrong in a game loop |
|---|---|
java.util.Timer / ScheduledExecutorService |
Fires on another thread. Touching ECS state from it races the frame (Volume 1 §1.5: workers produce, the main thread consumes). It also runs on wall-clock time, so it keeps going while the game is paused. |
Thread.sleep in a script |
Blocks the frame thread. Every entity freezes. |
CompletableFuture.delayedExecutor |
Same threading problem, plus a task object and a future per call. |
| A new anonymous class per call | One allocation every time you schedule. If that happens in onUpdate, it’s garbage every frame. |
float timer; timer -= dt; fields |
Works, but becomes unmanageable at five timers, and nothing cleans them up on destroy. |
The framework’s answer: game-time schedulers on the main thread, using pooled slots and int handles, ticked by the framework, and cleaned up automatically.
3.2 ScriptTaskQueue
Section titled “3.2 ScriptTaskQueue”private ScriptTask closeDoor; // create once…private int closeHandle;
public void onStart() { closeDoor = new ScriptTask() { public void run() { close(); } };}
public void onTriggerEnter(Entity other) { open(); cancelTask(closeHandle); // stale/invalid handles are ignored safely closeHandle = runAfter(3.0f, closeDoor); // …reuse forever}| API | Behavior |
|---|---|
runAfter(delay, task) |
Runs once after delay seconds of game time (0 = next tick). |
runEvery(interval, task) |
Repeats. The first run is one interval from now. Fires at most once per tick. |
tasks.runEvery(interval, initialDelay, task) |
Repeats, with its own first delay. Useful for staggering many enemies. |
cancelTask(handle) / tasks.cancel(h) |
Returns false if the handle is stale. |
tasks.isScheduled(h), getTimeRemaining(h), restart(h, delay) |
Inspect or reset a pending timer. |
Internals.
- Parallel arrays instead of entry objects:
ScriptTask[],float[] remaining,float[] interval,boolean[] repeating,boolean[] active,int[] generation. There’s one allocation per array and none per timer. - Generational handles:
handle = (generation << 16) | slot. A slot’s generation increases when the slot is released, so an old handle can never cancel the timer that reuses its slot. This is the same idea asEntityHandle(Volume 1 §2.2).0is never a valid handle. - Repeat drift: a repeating timer carries its overshoot forward, so the average rate holds. It never queues catch-up fires after a hitch.
- Scheduling from inside a task is safe. The new timer starts counting on the next tick.
- Capacity:
SimpleEntityScriptcreates its queue with 8 slots. The pool doubles when it’s full, and that growth is the only allocation. The hard cap is 65,536 slots. - Exceptions thrown by a task propagate, and
ScriptEnginedisables that one script, as with any other callback.
3.3 ScriptTweener
Section titled “3.3 ScriptTweener”int h = tweenPositionTo(0, 3, 0, 1.5f, ScriptTweener.EASE_OUT_BACK);tweens.setDelay(h, 0.25f);tweens.setOnComplete(h, openLidTask);
int glow = tweens.tweenLightColor(light(), 1.0f, 0.4f, 0.1f, 3.0f, 0.8f, ScriptTweener.EASE_IN_OUT_SINE);tweens.setPingPong(glow, true); // forever; onComplete never fires| Channel | Start method | Target |
|---|---|---|
| Position | tweenPositionTo / tweens.tweenPosition(t, …) |
Transform local position |
| Rotation | tweenRotationTo / tweens.tweenRotation |
Local Euler degrees, shortest way around on each axis |
| Scale | tweenScaleTo / tweens.tweenScale |
Local scale |
| Light color | tweens.tweenLightColor(light, r, g, b, intensity, …) |
Light color + intensity |
| UI color | tweens.tweenUIColor(element, r, g, b, alpha, …) |
UIElement color + alpha |
| Value | tweens.tweenValue(from, to, …) |
Not bound to anything. Read it with tweens.getValue(h). |
The 14 easing curves (ScriptTweener.*):
| Family | Constants |
|---|---|
| Linear | LINEAR, SMOOTHSTEP |
| Quad | EASE_IN_QUAD, EASE_OUT_QUAD, EASE_IN_OUT_QUAD |
| Cubic | EASE_IN_CUBIC, EASE_OUT_CUBIC, EASE_IN_OUT_CUBIC |
| Sine | EASE_IN_SINE, EASE_OUT_SINE, EASE_IN_OUT_SINE |
| Character | EASE_OUT_BACK (overshoot), EASE_OUT_ELASTIC (spring), EASE_OUT_BOUNCE |
Rules the tweener enforces:
- One tween per target and channel. Starting a position tween on a
Transformthat already has one replaces the old tween. Two tweens never fight over the same property. - The start value is sampled when the tween begins, after its delay, not when you call the method. A delayed tween picks up from wherever the object is by then.
onCompletefires once, after the final value is applied. It doesn’t fire on cancel or replace, and never for ping-pong tweens.- A tween started from inside a completion callback begins on the next tick.
- Storage is
float[4]per slot forfrom,toandvalue, using the same generational handles and doubling pool as the task queue.SimpleEntityScriptstarts with 4 slots.
The easing math is also available as static helpers, which you can use in your own update code:
ScriptTweener.ease(EASE_OUT_CUBIC, t); // shape a 0..1 parameterScriptTweener.lerpAngle(a, b, t); // shortest arc, degreesScriptTweener.deltaAngle(a, b); // signed shortest difference, -180..180ScriptTweener.damp(current, target, 8f, dt); // frame-rate-independent smoothing (use instead of lerp(…, 0.1f))3.4 ScriptStateMachine
Section titled “3.4 ScriptStateMachine”Enemy AI is where gameplay code usually turns into a mess of if (state == 2 && !wasAttacking). The FSM gives each
state its own class. Under Janino those are non-static inner classes, so they can read the script’s fields
directly:
public class EnemyAIStateScript extends SimpleEntityScript { final ScriptStateMachine fsm = new ScriptStateMachine(); ScriptState idle, patrol, chase, attack; // package-private, not private: see the note below
class ChaseState extends ScriptState { ChaseState() { super("Chase"); } public void onEnter() { playSpatialSound(alertSound, 1.0f); } public void onUpdate(float dt) { if (getFlatDistanceTo(player) <= attackRange) { fsm.changeState(attack); return; } getWorldPositionOf(player, targetPos); moveTowards(targetPos.x, targetPos.z, chaseSpeed, dt); } }
public void onStart() { idle = new IdleState(); patrol = new PatrolState(); // each state created ONCE chase = new ChaseState(); attack = new AttackState(); fsm.changeState(idle); }
public void onUpdate(float dt) { fsm.update(dt); } // the owner drives the machine public void onPhysicsUpdate(float dt) { fsm.physicsUpdate(dt); } // only if states use physics}| API | Behavior |
|---|---|
changeState(next) |
current.onExit(), then next.onEnter(). Changing to the current state does nothing. |
restartState() |
Runs exit and enter again on the current state and resets its timer. |
revertToPrevious() |
Goes back to the previous state (“stunned → whatever I was doing”). |
getTimeInState() |
Game seconds in the current state. Replaces hand-written stateTimer fields. |
getCurrentStateName() / isInState(s) |
For HUDs, debug overlays and conditions. Doesn’t allocate. |
getTransitionCount() |
If this climbs quickly, two states are flickering between each other. |
Queued transitions. If changeState is called during a transition (for example from onEnter), the
request is queued and applied once the current transition finishes. onExit and onEnter calls always come in
pairs. Chains of transitions are capped at 16. If two states keep handing control back and forth, the machine
logs the problem and stops in the last state instead of hanging the frame.
The bundled example keeps shared fields package-private instead of
private, so the inner state classes can access them reliably under Janino. Do the same in your own FSM scripts.
The full working example is in scripting/framework/examples/EnemyAIStateScript.java. It runs perception on a 5 Hz
staggered timer instead of every frame, checks line of sight with multi-pass raycasts that skip trigger volumes, and
draws its state with ScriptDebug.
4. Input, Audio, Debugging & Persistence
Section titled “4. Input, Audio, Debugging & Persistence”4.1 ScriptInput: Actions, Not Key Codes
Section titled “4.1 ScriptInput: Actions, Not Key Codes”if (ScriptInput.isActionPressed("Jump")) jump(); // went down this frameboolean sprint = ScriptInput.isActionDown("Sprint"); // heldfloat h = ScriptInput.getAxis("Horizontal"); // -1..1, keys and stick mergedScriptInput.getMouseDelta(lookScratch); // into a reused Vector3 (or float[2])Gameplay code asks for named actions and axes. Each name can be bound to up to six keys, mouse buttons and gamepad buttons, so one line of code covers keyboard, mouse and controller.
| Default action | Keyboard / mouse | Gamepad |
|---|---|---|
Jump |
Space | A |
Interact |
E | X |
Fire / AltFire |
Left / right mouse | Right / left trigger |
Sprint |
Left Shift | Left stick click |
Crouch |
Left Ctrl | B |
Reload |
R | Y |
Pause |
Escape | Start |
| Default axis | Keys | Gamepad |
|---|---|---|
Horizontal |
A/D, ←/→ | Left stick X |
Vertical |
S/W, ↓/↑ (up = +1) | Left stick Y (inverted, since GLFW reports up as −1) |
LookX / LookY |
n/a | Right stick |
- Axis merging: keys contribute −1, 0 or +1, the stick contributes its dead-zoned value, and the larger magnitude wins. Holding W while pushing the stick doesn’t add up to double speed.
getAxisRawsnaps the value to −1, 0 or +1 for menus and grid movement.- Mouse delta is 0 while the cursor isn’t captured, so editor clicks don’t spin the player camera.
setMouseSensitivityscales the delta. - Rebinding (
bindKey,bindMouseButton,bindGamepadButton,bindAxisKeys,bindAxisGamepad,unbind,resetToDefaults) may allocate. Do it once, for example in a game manager’sonStartor from a settings menu. - Queries don’t allocate. Lookups are
HashMap.geton string literals, which cache their hash. An unknown name returnsfalseor0and logs a warning once, so a typo like"jump"shows up in the console without spamming it every frame.
4.2 Audio & Camera Feedback
Section titled “4.2 Audio & Camera Feedback”playSpatialSound("Assets/Audio/pickup.wav", 0.8f); // 3D-attenuated, from this entity's positionplayGlobalSound("Assets/Audio/stinger.wav"); // non-spatial, full volume (UI, music stingers)shakeCamera(0.35f, 0.25f); // intensity ~0..1, secondsCamera shake keeps the logical pose and the rendered pose separate. This matters more than it looks. A
naive shake writes an offset into the camera transform, and then the mouse-look script reads that offset back,
adds to it, and the camera slowly drifts off. ScriptCameraShake avoids that by only touching the transform
between script ticks:
beginFrame: remove last frame's offset ← scripts & physics now see the clean pose[physics + every script's onUpdate]endFrame: add this frame's offset to Main Camera ← only the renderer sees the shaken pose| Property | Value |
|---|---|
| Max translation | MAX_OFFSET = 0.25 m at intensity 1 |
| Max rotation | MAX_ANGLE = 4° pitch and roll (yaw is half) |
| Fade | Quadratic over the duration |
| Overlap | A stronger request replaces the current shake. A weaker one during a strong shake is ignored. |
| Noise | Sums of sines at incommensurate frequencies. Looks smooth and non-repeating, uses no RNG state and allocates nothing. |
| Play stop | reset() removes any applied offset before the Play snapshot is restored |
4.3 ScriptDebug: Immediate-Mode Gizmos
Section titled “4.3 ScriptDebug: Immediate-Mode Gizmos”ScriptDebug.drawRay(ox, oy, oz, fx, fy, fz, 10f, ScriptDebug.CYAN);ScriptDebug.drawRaycast(ox, oy, oz, fx, fy, fz, 10f, hit); // green to hit + yellow normal, or red missScriptDebug.drawSphere(x, y, z, detectRadius, ScriptDebug.ORANGE);ScriptDebug.drawCircleXZ(x, y, z, patrolRadius, ScriptDebug.BLUE); // ground-plane ringScriptDebug.drawBox(cx, cy, cz, 2f, 1f, 2f, ScriptDebug.GREEN); // full edge lengths, like BoxColliderScriptDebug.drawLine(a, b, ScriptDebug.WHITE);ScriptDebug.drawCross(x, y, z, 0.3f, ScriptDebug.YELLOW); // point marker (always one frame)ScriptDebug.drawRay(ox, oy, oz, fx, fy, fz, 10f, ScriptDebug.RED, 2.0f); // duration overload: stays up 2 sThis is immediate mode: call it every frame you want the shape visible. With no duration, a shape lasts exactly
one frame. With the durationSeconds overloads (drawLine, drawRay, drawSphere, drawBox) set to > 0, it stays up for that much game time, which is handy for “where did that
bullet hit?”.
| Detail | Value |
|---|---|
| Storage | Fixed float[MAX_LINES * 6], int[] colors, float[] lifetimes. MAX_LINES = 8,192. |
| Cost per shape | Line/ray = 1 line, box = 12, cross = 3, sphere = 3 great circles × 16 segments = 48 |
| Overflow | Extra lines are dropped and one warning is logged |
| Colors | 0xRRGGBB ints: RED, GREEN, BLUE, YELLOW, CYAN, MAGENTA, ORANGE, WHITE, GRAY |
| Where it shows | The editor’s Scene viewport only, while playing or paused. ScriptDebugRenderer projects the lines with the editor camera into the UI batch. The standalone runtime ignores them. |
| Aging | beginFrame drops expired lines and compacts the rest in place. No allocation. |
Candid note: segments are clipped against the camera’s near plane, but a segment with an endpoint outside the Scene tile is currently dropped rather than clipped to the viewport edge. Long rays that leave the screen disappear completely. Shorten them, or split them with
drawLine, if you need to see them.
Use ScriptDebug.setEnabled(false) to switch off a noisy overlay without touching the scripts that draw it.
4.4 ScriptSaveData: Persistence
Section titled “4.4 ScriptSaveData: Persistence”setSaveData("best_lap", lapTime); // formats to text; event-time onlyfloat best = getSaveDataFloat("best_lap", 999f); // parse on each call; cache it in a fieldif (!getSaveDataBool("intro_seen", false)) playIntro();saveGame(); // force a write now (e.g. at a checkpoint)| Aspect | Behavior |
|---|---|
| File | ~/.retroengine/saves/<project>.properties. <project> is the folder containing Assets/, sanitized to [A-Za-z0-9-_.]. |
| Format | java.util.Properties: plain text that’s easy to diff. Values are stored as strings and read back with the typed getters (getBool accepts true/false/1/0). |
| When it writes | saveGame(), when Play stops in the editor, and from a JVM shutdown hook when the game exits. It writes only when something changed. |
| Crash safety | Write-then-rename: the data goes to <file>.tmp first and then replaces the save, so a crash mid-write never leaves a truncated file. |
| Project switch | The previous project’s pending changes are saved before the new project’s file is loaded. |
| Threading | All methods are synchronized (the shutdown hook runs on its own thread). |
Candid note: Play-mode writes are real writes. Stop restores the scene snapshot, not your save file. Testing “first launch” behavior in the editor means calling
ScriptSaveData.deleteAll()(thensaveGame()) or deleting the file. This is intentional: high-score and unlock logic behaves the same in the editor as in the shipped game.
5. Engine Runtime Fixes & Zero-Allocation Case Studies
Section titled “5. Engine Runtime Fixes & Zero-Allocation Case Studies”Building the framework meant pushing real gameplay scripts through engine paths that had seen little use before: runtime spawning, runtime destruction, and long-running Play sessions. Reviewing those paths turned up two engine bugs worth writing down. Neither one crashes. One wastes memory and the other corrupts state silently, which is why they’re easy to miss.
5.1 Case Study: The HierarchySystem Per-Frame Map Rebuild
Section titled “5.1 Case Study: The HierarchySystem Per-Frame Map Rebuild”Symptom. In a steady-state scene with nothing spawning or despawning, the hierarchy pass still produced a
steady stream of java.util.HashMap$Node garbage. That’s the heap sawtooth Volume 5 §3.2 counts as a regression,
and JFR’s Allocation by Class view is where it shows up.
Cause. HierarchySystem keeps an ID → Entity index (entityMapScratch) for resolving parents. SceneWorld.update
correctly calls invalidateEntityIndex() once per frame, because entities can be added, removed or re-ID’d at any
time. But “invalid” was treated as “rebuild”:
// BEFORE: every frame, for every entityentityMapScratch.clear(); // drops every nodefor (...) entityMapScratch.put(e.id, e); // allocates a fresh HashMap.Node per entityA HashMap.Node takes 32 bytes on a 64-bit JVM with compressed oops (12-byte header + hash + key + value +
next, padded to 8). The map was pre-sized, so the table itself never grew, but the nodes were rebuilt anyway:
| Entities | Garbage / frame | Garbage / second @ 60 FPS |
|---|---|---|
| 250 | 8 KB | 480 KB |
| 1,000 | 32 KB | 1.9 MB |
| 5,000 | 160 KB | 9.4 MB |
Fix. Treat the per-frame invalidation as a request to check the index, and rebuild only if the check fails:
private void ensureEntityIndex(List<Entity> sceneEntities) { if (!indexDirty && indexedList == sceneEntities && indexedSize == sceneEntities.size()) return; if (!indexMatches(sceneEntities)) { /* clear + put: only on a real change */ } ...}
private boolean indexMatches(List<Entity> sceneEntities) { int indexed = 0; for (int i = 0; i < sceneEntities.size(); i++) { Entity e = sceneEntities.get(i); if (e == null || e.id == null) continue; if (entityMapScratch.get(e.id) != e) return false; // lookups only: zero allocation indexed++; } return indexed == entityMapScratch.size(); // equal counts ⇒ no stale keys left behind}The check is a pass of get() calls, and String caches its hash code, so this costs pointer comparisons rather
than re-hashing. Matching counts plus “every live ID resolves to this exact entity” proves there are no stale keys
from removed or renamed entities. In steady state, the hierarchy pass now allocates nothing.
The lesson: “invalidate every frame” is a fine safety policy, as long as invalidating doesn’t automatically mean rebuilding. Make the cheap check first.
5.2 Case Study: BroadphaseSolver.updateLeaf and the Stale Node ID
Section titled “5.2 Case Study: BroadphaseSolver.updateLeaf and the Stale Node ID”Background. Each PhysicsBody stores treeNodeId, its leaf in the dynamic AABB tree (Volume 4 §1.3). When a
body moves outside its 20 cm fat AABB, updateLeaf removes the leaf and inserts it again with the new bounds.
The bug. updateLeaf threw away the ID returned by insertLeaf:
// BEFOREremoveLeaf(leafId);insertLeaf(body, minX, minY, minZ, maxX, maxY, maxZ); // return value ignoredThe code assumed the re-inserted leaf would land in the same node slot, so body.treeNodeId would still be correct.
Nothing in the API promised that. Whether it holds depends on the order removeLeaf returns nodes to the
LIFO free list and the order insertLeaf takes them back out (leaf first, then a new parent). If either changes,
the body’s handle points at an internal node. After that:
- The next
updateLeafreads an internal node’s AABB, decides the body “moved out”, and removes the wrong subtree. removeRuntimeEntity(new in this release, §2.5) frees the wrong node, which corrupts the tree.- Pair queries silently miss the body. Objects fall through floors or ignore triggers, and nothing gets logged.
Runtime spawning and destruction (§2.5) made this much more likely to happen: until now, bodies were only ever
inserted once in initWorld, and the free list rarely saw mixed traffic.
Fix. Use the ID that insertLeaf actually returns:
// AFTERint newLeafId = insertLeaf(body, minX, minY, minZ, maxX, maxY, maxZ);if (body != null) body.treeNodeId = newLeafId;Candid note: with today’s free-list order,
removeLeaffrees the parent and then the leaf, andinsertLeaftakes the leaf slot first, so the slot is usually reused and the old code usually worked. That’s exactly why it got past testing. The rule isn’t specific to this tree: if a function returns a handle, store it. Never rely on the allocator’s ordering.
Related physics changes in this release:
PhysicsSystem.getBody(entity)exposes the live body, whichSimpleEntityScript.applyImpulseandsetVelocityuse.registerRuntimeEntity/flushPendingBodiesdelay body creation until the start of the next step, so no solver loop seesactiveBodieschange during iteration.isPhysicsEntityis now the single definition of “the solver cares about this entity”, shared byinitWorldand runtime spawning.
6. Content Creator Rules & Best Practices
Section titled “6. Content Creator Rules & Best Practices”6.1 Do’s and Don’ts
Section titled “6.1 Do’s and Don’ts”| ✓ Do | ✗ Don’t | Why |
|---|---|---|
Extend SimpleEntityScript |
Extend raw EntityScript for gameplay |
Cached getters, auto-ticked timers and tweens, auto-cleanup |
Read Inspector fields in onStart |
Read them in onAwake |
Overrides are injected after onAwake (§2.1) |
Cache findPlayer / findEntity in onStart |
Call them in onUpdate |
Both scan the whole scene |
Create ScriptTasks and states once, as fields |
Write new ScriptTask() {…} in onUpdate |
Every anonymous-class instance is an allocation |
Use runAfter / runEvery |
Use Thread, Timer, sleep, executors |
Off-thread ECS writes race the frame. sleep freezes everything. |
Talk to other scripts with ScriptEvents |
Use reflection or casts to another script’s class | Scripts are class-isolated (§1.3) |
Use subscribeEvents(this) |
Use raw ScriptEvents.subscribe without unsubscribing |
The owned version cleans up on destroy and reload |
Use destroySelf() / destroy(e) |
Hide entities by parking them at y = −10000 | Deferred destroy removes scripts, bodies and children correctly |
Use setVelocity / applyImpulse on dynamic bodies |
Use move() on a dynamic rigidbody |
Writing the transform fights the solver |
Feed CharacterController from ScriptInput |
Hand-roll player movement | The controller handles ground, steps and friction |
Use ScriptInput action names |
Use raw GLFW_KEY_* checks in gameplay |
One binding covers keyboard, mouse and gamepad, and can be rebound |
Put forces in onPhysicsUpdate |
Put forces in onUpdate |
Physics runs at a fixed 60 Hz (Volume 4 §1.1) |
Use raycast(..., outHit) / raycastInPlace |
Use the convenience raycast that returns a new hit |
One RaycastHit field per script, reused |
| Write save data on events | Call setSaveData every frame |
It formats strings and marks the file dirty |
Use ScriptTweener.damp for smoothing |
Use lerp(a, b, 0.1f) per frame |
The fixed-factor lerp behaves differently at 30 FPS and 144 FPS |
| Check scripts by cooking them with Janino | Check scripts with javac |
javac accepts generics that Janino erases (§1.2) |
| Pool frequently spawned objects | Call spawnPrefab 60× a second |
Spawning reads from disk and allocates entities |
6.2 Scratch Objects vs. Allocation in Update Loops
Section titled “6.2 Scratch Objects vs. Allocation in Update Loops”Vector3 is mutable on purpose. Every operation writes into this (or an explicit out) and returns it for
chaining. Use it the same way the engine uses RaycastHit: declare a few as fields and reuse them.
public class TurretScript extends SimpleEntityScript { // ✓ Scratch objects: allocated once with the script private final Vector3 myPos = new Vector3(); private final Vector3 toTarget = new Vector3(); private final RaycastHit hit = new RaycastHit(); private Entity target;
public void onStart() { target = findPlayer("Player"); }
public void onUpdate(float dt) { if (target == null) return; getWorldPosition(myPos); getWorldPositionOf(target, toTarget).sub(myPos); // toTarget = target - me, in place if (toTarget.lengthSquared() > 400f) return; // 20 m; no sqrt needed for a range check toTarget.normalize(); if (raycast(myPos.x, myPos.y, myPos.z, toTarget.x, toTarget.y, toTarget.z, 20f, hit) && hit.entity == target) { turnTowards(hit.pointX, hit.pointZ, 120f * dt); } }}// ✗ The same logic, allocating every framepublic void onUpdate(float dt) { Vector3 me = new Vector3(getWorldX(), getWorldY(), getWorldZ()); // garbage Vector3 dir = new Vector3(...).sub(me); // garbage RaycastHit h = raycast(me.x, me.y, me.z, dir.x, dir.y, dir.z, 20f); // garbage (convenience overload) logInfo("dist " + dir.length()); // string concat: garbage runAfter(1f, new ScriptTask() { public void run() { fire(); } }); // garbage + a new timer every frame}Rules of thumb:
newbelongs in field initializers andonStart. If you seenewinsideonUpdate,onPhysicsUpdateor a callback that runs every frame, move it out.- Prefer
outparameters.getWorldPosition(out),getVelocity(out),getForward(float[] out),ScriptInput.getMouseDelta(out). Overloads without anoutparameter exist for convenience and allocate. - Compare squared lengths (
lengthSquared,distanceSquaredTo) for range checks. - Strings are allocations. Concatenation in
logInfoorVector3.toString()is fine for one-off logs, not every frame. Comparing event names with"name".equals(eventName)is free. - Size the pools if you need to.
tasksstarts at 8 slots andtweensat 4. If a script keeps dozens of timers in flight, the first burst grows the arrays once. Schedule the worst case duringonStartto get that growth out of the way before gameplay.
The check: run your scene with
-Xlog:gcas described in Volume 5 §3.2. If a young-generation GC shows up during steady-state gameplay, something is allocating every frame. JFR’s Allocation by Class will point you to the exact script line.