53 KiB
id, icon, group, title, order
| id | icon | group | title | order |
|---|---|---|---|---|
| language | file.script | script | Aego Script | 1 |
Aego Script
Aego Script is the gameplay language of the Aego engine. It is statically typed, uses Go-like syntax, compiles to a register bytecode and runs inside the engine's VM. Scripts attach to scene nodes, expose fields to the Inspector, read and write engine components, call functions exposed by the engine or by a project's native Go code, and talk to each other through signals and script references.
This document is the normative reference for language version 2 (Stages 5–6). Language version 1 modules are no longer loaded; projects are recompiled on open.
Run it:
package demo
import "aego/log"
script Hello {
@export var Greeting string = "hello from Aego Script"
var frames int
func Ready() {
log.Info(Greeting + " on node " + node.Name())
}
func Update(dt float) {
frames++
if frames == 60 {
log.Info("one second of frames: " + string(frames))
}
}
}
id: language/overview icon: help.book parent: language order: 1 title: Overview
Overview
package player
import (
"aego/input"
"aego/log"
"aego/math"
"mygame/combat"
)
enum State {
Idle
Run
}
type Stats struct {
Hp int
Armor float
}
const MaxSpeed float = 600
script PlayerController {
@export(min=0, max=600, unit="px/s")
var Speed float = 220
@export
var Target NodeRef
var state State
var stats Stats
var hits []int
func Ready() {
stats = Stats{Hp: 10, Armor: 0.5}
log.Info("ready")
}
func Update(dt float) {
x := input.Axis("Move")
if x != 0 {
state = State.Run
} else {
state = State.Idle
}
t := node.Transform2D()
t.Position.X += math.Clamp(x*Speed, -MaxSpeed, MaxSpeed) * dt
node.SetTransform2D(t)
sr := node.Get<SpriteRenderer>()
if sr != nil {
sr.FlipX = x < 0
}
if Target != nil && input.KeyPressed(input.KeySpace) {
combat.Damage(Target, 5)
}
}
}
Attach PlayerController to a node in the editor, set Speed and Target in the Inspector, press Play.
Run it: the same controller without the mygame/combat import and the Target field, which need a project. The sandbox has no input devices, so the script pushes the axis to 1 on one frame.
package player
import (
"aego/input"
"aego/log"
"aego/math"
)
enum State {
Idle
Run
}
type Stats struct {
Hp int
Armor float
}
const MaxSpeed float = 600
script PlayerController {
@export(min=0, max=600, unit="px/s")
var Speed float = 220
var state State
var stats Stats
var frames int
func Ready() {
stats = Stats{Hp: 10, Armor: 0.5}
log.Info("ready with " + string(stats.Hp) + " hp")
}
func Update(dt float) {
x := input.Axis("Move")
if frames == 30 {
x = 1
}
if x != 0 {
state = State.Run
} else {
state = State.Idle
}
t := node.Transform2D()
t.Position.X += math.Clamp(x*Speed, -MaxSpeed, MaxSpeed) * dt
node.SetTransform2D(t)
sr := node.Get<SpriteRenderer>()
if sr != nil {
sr.FlipX = x < 0
}
frames++
if frames % 30 == 1 {
log.Info("frame " + string(frames) + ": state " + string(int(state)) + ", x " + string(node.Position().X))
}
}
}
id: language/files-and-packages icon: file.folder parent: language order: 2 title: Files and packages
Files and packages
- Source files use the
.aesextension and live underscripts/in the project. Any directory underscripts/is a package; files in one directory belong to one package. - The package path is
project.module + "/" + directory(mygame/player). Thepackageclause must equal the directory's base name (package player); a mismatch isAES4001. - A module path uses
[a-z0-9_/-]only, no dots: qualified names (mygame/player.State) are split on the last dot. importworks as in Go; there are no import aliases. Importing a package that is not underscripts/and not a host module isAES4002; cycles areAES4005. Unused imports are warnings.- Host modules (
aego/input,mygame/combat) and script packages share one namespace. A script directory whose package path equals a host module path isAES4010. Theaego/prefix is reserved for the standard library;module = "aego"is a project error. - Visibility follows Go: identifiers starting with an uppercase letter are exported from the package; everything else is package-private.
@exportis unrelated to visibility (see §8). *_test.aesfiles are compiled only byaego test.
A package that fails to compile keeps its last good build; its files are marked invalid and Play is blocked until they compile again.
Run it: one package with exported and package-private declarations. The sandbox compiles a single file, so imports between script packages need a project.
package inventory
import (
"aego/log"
"aego/math"
)
// MaxSlots is exported: other packages read it as inventory.MaxSlots.
const MaxSlots int = 8
// stackSize is package-private.
const stackSize int = 20
// Fit is exported and can be called from other packages.
func Fit(count int) int {
return math.MinInt(count, MaxSlots * stackSize)
}
script Bag {
@export var Count int = 200
func Ready() {
log.Info("package inventory keeps " + string(Fit(Count)) + " of " + string(Count) + " items")
}
}
id: language/lexical-structure icon: code.hints parent: language order: 3 title: Lexical structure
Lexical structure
- Identifiers are ASCII only:
[A-Za-z_][A-Za-z0-9_]*. A non-ASCII letter where an identifier could start isAES1001. Non-ASCII text is fully supported inside string literals. - Keywords:
package import script type struct enum var const func return if else for switch case default break continue true false nil yield signal coroutine. - Contextual:
startis a keyword only when it is followed by a name and(; elsewhere it is an ordinary identifier.wait,framesandfixedare special only right afteryield. - Reserved for future versions (using them is
AES1004):await select go chan defer interface map. - Literals: integers
10,0x1f,1_000; floats1.5,.5,1e3,1_000.5; strings"..."with escapes\n \t \" \\ \uXXXX;true,false,nil. Underscores must separate digits. - Comments:
// lineand/* block */. A//comment group directly above a declaration is its documentation and is shown on hover and in completion. - Semicolons are inserted at a line end after an identifier, literal,
),],},return,break,continue,++,--, or after a>that closes type arguments. Write code line by line as in Go;}followed byelsemust be on the same line. - Operators:
+ - * / % == != < <= > >= && || ! = := += -= *= /= %= ++ --. There are no bitwise operators; a lone&or|isAES1001.
Deliberately unsupported lexical forms (each has a diagnostic that names the intended form):
| Input | Why | Write instead |
|---|---|---|
'a' |
no char type separate from string |
"a" |
1. / 1.e3 |
a float needs digits after the point | 1.0 / 1.0e3 |
имя := 1 |
identifiers are ASCII | ASCII names |
a & b |
no bitwise arithmetic | && / || |
Run it:
package demo
import "aego/log"
/* Literals and operators from this page. */
script Lexer {
func Ready() {
hex := 0x1f
big := 1_000_000
half := .5
sci := 1e3
log.Info("ints " + string(hex) + " " + string(big) + ", floats " + string(half) + " " + string(sci))
log.Info("escapes: tab[\t] quote[\"] backslash[\\] unicode[\u00e9]")
log.Info("bytes in \"héllo\": " + string(len("héllo")))
a := 7
a += 3
a %= 4
a++
log.Info("(7 + 3) % 4 + 1 = " + string(a) + ", both: " + string(a > 2 && !(a == 0)))
}
}
id: language/grammar icon: code.implementation parent: language order: 4 title: Grammar
Grammar
File = "package" ident ";" { Import ";" } { TopDecl ";" } .
Import = "import" ( string | "(" { string ";" } ")" ) .
TopDecl = ConstDecl | TypeDecl | EnumDecl | FuncDecl | ScriptDecl .
ConstDecl = "const" ident [ Type ] "=" Expr .
TypeDecl = "type" ident "struct" "{" { ident Type ";" } "}" .
EnumDecl = "enum" ident "{" { ident ";" } "}" .
FuncDecl = { Attr } "func" ident "(" [ Params ] ")" [ Type | "coroutine" ] Block .
Params = ident Type { "," ident Type } .
ScriptDecl = { Attr } "script" ident "{" { ScriptMember ";" } "}" .
ScriptMember= { Attr } "var" ident Type [ "=" Expr ] | FuncDecl | SignalDecl .
SignalDecl = "signal" ident "(" [ Params ] ")" .
Attr = "@" ident [ "(" [ AttrArg { "," AttrArg } ] ")" ] .
AttrArg = [ ident "=" ] ( Literal | "-" Literal ) .
Type = ident [ "." ident ] [ "<" Type { "," Type } ">" ] | "[" "]" Type .
Block = "{" { Stmt ";" } "}" .
Stmt = "var" ident Type [ "=" Expr ]
| ident ":=" Expr
| LValue ( "=" | "+=" | "-=" | "*=" | "/=" | "%=" ) Expr
| LValue ( "++" | "--" )
| Expr | If | For | Switch
| "return" [ Expr ] | "break" | "continue" | Block
| "yield" [ YieldArg ] .
YieldArg = "wait" "(" Expr ")" | "frames" "(" Expr ")" | "fixed" "(" Expr ")" | Expr .
If = "if" Expr Block [ "else" ( If | Block ) ] .
For = "for" [ Expr | [ Simple ] ";" [ Expr ] ";" [ Simple ] ] Block .
Switch = "switch" Expr "{" { ( "case" Expr { "," Expr } | "default" ) ":" { Stmt ";" } } "}" .
Expr = Unary { BinOp Unary } .
Unary = [ "-" | "!" ] Primary | "start" ident "(" [ Expr { "," Expr } ] ")" .
Primary = Operand { "." ident | "[" Expr "]" | [ "<" Type ">" ] "(" [ Expr { "," Expr } ] ")" } .
Operand = Literal | ident | "(" Expr ")" | Type "{" [ Elems ] "}" .
Precedence, high to low: unary - !; * / %; + -; == != < <= > >=; &&; ||. Type arguments in a call (Get<T>()) are accepted by the parser anywhere, but only intrinsics may use them (§9).
Composite literals are not allowed directly in the head of if, for or switch; parenthesize them (if x == (Stats{}) {).
Run it:
package demo
import "aego/log"
const Limit int = 3
type Point struct {
X int
Y int
}
enum Dir {
Left
Right
}
func flip(d Dir) Dir {
switch d {
case Dir.Left:
return Dir.Right
default:
return Dir.Left
}
}
script Grammar {
var steps []Point
func Ready() {
d := Dir.Left
for i := 0; i < Limit; i++ {
d = flip(d)
steps = append(steps, Point{X: i, Y: int(d)})
}
n := 0
for n < len(steps) {
log.Info("step " + string(n) + ": " + string(steps[n].X) + ", " + string(steps[n].Y))
n++
}
if steps[0] == (Point{0, 1}) {
log.Info("a composite literal in an if head needs parentheses")
}
}
}
id: language/types icon: code.block parent: language order: 5 title: Types
Types
| Type | Description |
|---|---|
bool |
true / false |
int |
signed 64-bit; wraps on overflow |
float |
IEEE float32 |
string |
immutable UTF-8; + concatenation, comparison, len in bytes; no indexing |
Vec2 Vec3 Color Quat Rect |
engine value types; lanes .X .Y .Z .W, .R .G .B .A, .X .Y .W .H |
Transform2D{Position Vec2, Rotation float, Scale Vec2} |
value struct |
Transform3D{Position Vec3, Rotation Quat, Scale Vec3} |
value struct |
NodeRef |
reference to a node; may be nil |
AssetRef<Texture | Scene | Prefab | Mesh> |
reference to an asset; may be nil |
ComponentRef<T> |
reference to a component on a node; may be nil |
ScriptRef<T> |
reference to an instance of script T; may be nil; fields, plain methods and signals of T |
Coroutine |
handle returned by start; may be nil; methods Stop(), Running() bool |
Connection |
handle returned by Connect; may be nil; methods Disconnect(), Connected() bool |
Curve |
read-only view of a curve field of a component (fader.Alpha); evaluated with aego/curve |
tween.Tween, timer.Timer, … |
host handles returned by engine and native modules; may be nil; methods come from the module |
struct |
package-level, value semantics, copied on assignment |
enum |
package-level, int-backed, members State.Idle |
[]T |
growable slice on the VM heap |
Rules:
- No implicit numeric conversion. Use
int(x),float(x),string(x)(frombool,int,float,string),int(e)for an enum andState(i)for int → enum. Untyped constants adapt to the context (var f float = 1is fine,var i int = 1.5is not). floatarithmetic is float32 everywhere, including constant folding.==/!=work on scalars, strings, enums, references, handles, vectors, structs with comparable fields, andTransform*;Quat,Curveand slices are not comparable.< <= > >=work on numbers and strings.- Vector arithmetic:
v + w,v - w,v * s,s * v,v / s,-v; vectors are constructed only with composite literals (Vec2{x, y},Color{1, 0, 0, 1}), never with call syntax. - A struct is a value:
a = bcopies; passing to a function copies. Nested structs are allowed, including structs from other packages; a struct that contains itself by value isAES2017. - A slice element may occupy at most 32 value slots (
AES2032); scalars, vectors, refs and small structs are far below it. nilis valid forNodeRef,AssetRef,ComponentRef,ScriptRef,Coroutine,Connectionand host handles. Reading or writing through a nil reference is a runtime fault (AES6004); calling a method on a nilCoroutine,Connectionor host handle is not:Running()andConnected()returnfalse,Done()returnstrue,Stop(),Cancel()andDisconnect()do nothing.
Run it:
package demo
import "aego/log"
type Stats struct {
Hp int
Armor float
}
enum Mood {
Calm
Angry
}
script Types {
var conn Connection
func Ready() {
a := Stats{Hp: 10, Armor: 0.5}
b := a
b.Hp = 3
log.Info("structs copy: a.Hp " + string(a.Hp) + ", b.Hp " + string(b.Hp))
f := 7.9
log.Info("int(7.9) = " + string(int(f)) + ", float(7) / 2 = " + string(float(7) / 2) + ", 7 / 2 = " + string(7 / 2))
m := Mood(1)
log.Info("Mood(1) is Angry: " + string(m == Mood.Angry) + ", int " + string(int(m)))
v := Vec2{3, 4} * 2 - Vec2{1, 1}
c := Color{1, 0.5, 0, 1}
log.Info("vector " + string(v.X) + ", " + string(v.Y) + ", color green " + string(c.G))
var hits []int
hits = append(hits, 5)
hits = append(hits, 7)
log.Info("slice of " + string(len(hits)) + ", last " + string(hits[len(hits) - 1]))
log.Info("nil connection connected: " + string(conn.Connected()))
}
}
id: language/declarations-and-statements icon: code.brackets parent: language order: 6 title: Declarations and statements
Declarations and statements
var x T,var x T = e— explicit type;x := e— inferred frome(untyped constants default toint/float);nilcannot initialise:=.- Package-level
constonly; package-levelvarisAES1105. State lives in script fields and components. - Functions have fixed arity and zero or one result. A function that declares a result must end in a terminating statement (
AES2013). Recursion is allowed up to depth 256. if,if … else if … else;for cond {},for init; cond; post {},for {};break,continue.switch x { case a, b: … default: … }— no fallthrough; aswitchon an enum must cover every member or have adefault(AES2015); duplicate cases are errors.- Compound assignment
+= -= *= /= %=and++/--onint/floatlvalues; lvalues are variables, fields, lanes, slice elements, component fields and fields reached through aScriptRef. - A statement-level expression must be a call; an unused value is an error. Discarding the result of
len,append, a conversion,Get<T>,GetScript<T>orConnectis an error. Discarding the result ofstartis allowed. - Unused local variables are warnings.
- There are no closures, lambdas, defer, goroutines, pointers or maps.
Run it:
package demo
import "aego/log"
enum Weapon {
Sword
Bow
Staff
}
func damage(w Weapon) int {
switch w {
case Weapon.Sword:
return 12
case Weapon.Bow, Weapon.Staff:
return 8
}
return 0
}
func fib(n int) int {
if n < 2 {
return n
}
return fib(n - 1) + fib(n - 2)
}
script Statements {
func Ready() {
var total int
var scale float = 1.5
for w := 0; w < 3; w++ {
total += damage(Weapon(w))
}
log.Info("total damage " + string(total) + ", scaled " + string(float(total) * scale))
i := 0
for {
i++
if i % 2 == 0 {
continue
}
if i > 7 {
break
}
log.Info("odd " + string(i))
}
log.Info("fib(15) = " + string(fib(15)))
}
}
id: language/scripts-and-lifecycle icon: panel.history parent: language order: 7 title: Scripts and lifecycle
Scripts and lifecycle
script Enemy {
@export var Speed float = 120
var elapsed float
signal Died()
func Ready() {}
func FixedUpdate(dt float) {}
func Update(dt float) {}
func Reloaded() {}
func helper() int { return 1 }
}
- A
scriptis a stateful type that attaches to a node. A node holds at most one instance of each script type; different script types may share a node. - Every script has an implicit field
node NodeRef. Naming a field, method or signalnodeis an error. - Methods are declared inside the script and called unqualified from other methods. Package-level functions see no script state and cannot use
node,start,yieldor signals. - Fields declared with
varare runtime state: they are created when Play starts and discarded on Stop. Only@exportfields are saved in the scene and shown in the Inspector. Play never writes back to the edit scene. - Field initialisers must be constants (or
nilfor references and handles).
Lifecycle methods are recognised by name and must have exactly these signatures:
| Method | When |
|---|---|
func Ready() |
once, after every instance in the scene exists and references are resolved, in scene pre-order |
func FixedUpdate(dt float) |
every fixed step, before physics, in instance creation order |
func Update(dt float) |
every frame, in instance creation order |
func Reloaded() |
after a hot reload, for every instance whose script or a dependency's interface changed (§19) |
Instances that do not declare a method are not visited for it; a scene with ten thousand instances that never declare Update costs nothing in the update phase. Lifecycle methods cannot be coroutines (AES2056), cannot be signal handlers (AES2055) and cannot be called through a ScriptRef (AES2059).
Run it: the sandbox attaches each script to its own node.
package demo
import "aego/log"
script Enemy {
@export var Speed float = 2
var fixedSteps int
var frames int
var elapsed float
func Ready() {
log.Info("Ready on " + node.Name() + ", speed " + string(Speed))
}
func FixedUpdate(dt float) {
fixedSteps++
}
func Update(dt float) {
frames++
elapsed += dt
node.SetPosition(node.Position() + Vec2{Speed * dt, 0})
if frames % 40 == 0 {
log.Info("frame " + string(frames) + ", fixed steps " + string(fixedSteps) + ", elapsed " + string(elapsed) + ", x " + string(node.Position().X))
}
}
func helper() int {
return frames
}
}
script Spawner {
func Ready() {
log.Info("Ready on " + node.Name() + "; every Ready runs before the first Update")
}
}
id: language/attributes icon: code.hints parent: language order: 8 title: Attributes
Attributes
| Attribute | Target | Meaning |
|---|---|---|
@export |
field | saved in the scene, shown in the Inspector |
@export(min=0, max=100, step=1, unit="px/s", display="Speed", tip="…", category="Movement") |
field | same, with Inspector hints; min/max/step only on numeric fields |
@former("OldName") |
script or field | the old name still loads from scenes; the scene is rewritten with the new name on save; also used to match fields across a hot reload |
@hide |
script | not offered in Add Script |
@persist |
field | kept by save games |
Exportable field types: bool int float string Vec2 Vec3 Color Quat Rect, enums, NodeRef, AssetRef<T>, ComponentRef<T>. Slices, structs, ScriptRef, Coroutine, Connection, Curve and host handles cannot be exported (AES3001). An exported ComponentRef<T> is stored as a node reference; the Inspector only offers nodes that have T, and a node without T starts the field as nil.
A field default must be a constant. Composite literals of Vec2, Vec3, Color, Quat and Rect whose components are constants count as constants: var Jump Vec2 = Vec2{0, -5}, var Tint Color = Color{R: 1, A: 1}. The Inspector shows them as the default, and a scene that omits the property starts with them.
Attribute arguments are literals (optionally negative). Attributes before func are parsed for forward compatibility but rejected (AES3005).
Unknown properties found in a scene (a field that was removed or renamed without @former) are preserved and re-saved with a warning; they are not applied at runtime.
Persistent fields
@persist marks a script field that save games keep:
@persist
var Coins int
Persistent fields may be bool, int, float, string, a vector or color, an enum, NodeRef or AssetRef. Other types are AES3007; save what they point at in OnSave instead. @persist and @export may be combined.
OnSave() runs while a game is saved and writes values with save.PutInt, PutFloat, PutBool, PutString and PutVec2; OnLoad() runs after Ready when a game is loaded and reads them back with save.GetInt(key, def) and the others. Keys belong to the node, so two scripts on one node share them.
Run it: attributes change what the Inspector, scenes and saves do with a field; in the sandbox every field starts with its default.
package demo
import "aego/log"
enum Team {
Red
Blue
}
@former("Mover")
script Walker {
@export(min=0, max=10, step=0.5, unit="u/s", display="Walk speed", tip="Units per second", category="Movement")
var Speed float = 2.5
@export
@former("Side")
var Team Team = Team.Blue
@export var Jump Vec2 = Vec2{0, -5}
@export var Tint Color = Color{R: 1, A: 1}
@persist
var Coins int
func Ready() {
log.Info("speed " + string(Speed) + ", team " + string(int(Team)) + ", jump " + string(Jump.Y) + ", tint red " + string(Tint.R))
Coins += 5
log.Info("coins " + string(Coins))
}
}
@hide
script Helper {
func Ready() {
log.Info("Helper is attached here but not offered in Add Script")
}
}
id: language/nodes-components-and-references icon: node.empty parent: language order: 9 title: Nodes, components and references
Nodes, components and references
NodeRef
| Method | |
|---|---|
IsAlive() bool, Name() string, Parent() NodeRef, Child(name string) NodeRef, Find(path string) NodeRef, Destroy() |
hierarchy; Find accepts Weapon/Gun, ../Sibling, /Root/Player |
Enabled() bool, SetEnabled(bool), Space() int |
state; Space is 0 for 2D, 1 for 3D |
Transform2D() Transform2D, SetTransform2D(t), Position() Vec2, SetPosition(Vec2), WorldPosition() Vec2, Rotation() float, SetRotation(float), Scale() Vec2, SetScale(Vec2) |
2D nodes |
Transform3D() Transform3D, SetTransform3D(t), Position3() Vec3, SetPosition3(Vec3), Rotation3() Quat, SetRotation3(Quat) |
3D nodes |
Get<T>() ComponentRef<T>, Has<T>() bool |
components |
GetScript<T>() ScriptRef<T> (nil when the node has no T) |
scripts (§18) |
Transforms are read and written by value: t := node.Transform2D(); t.Position.X += 1; node.SetTransform2D(t). A 2D method on a 3D node (and the reverse) faults. Destroy takes effect at the end of the phase; IsAlive is false immediately. Get<T>() and GetScript<T>() on a destroyed node return nil; on a nil reference they fault with AES6004.
Components
node.Get<SpriteRenderer>() returns a ComponentRef<SpriteRenderer> or nil. Field names are the schema names with an upper-cased first letter (sr.FlipX, hp.Current); read-only fields cannot be assigned. A short component name works when it is unique among registered components; otherwise write the full name (render2d.SpriteRenderer). An enum field of a Go component is exposed as int.
Components may also expose methods: hp.Damage(5), body.ApplyImpulse(Vec2{0, -300}). Which fields and methods are visible is decided by the component's owner when it registers the component (§11); the generated library stubs (Ctrl+Click on a component name in the editor) show exactly what is available.
A ComponentRef is safe to keep in a field: it is re-validated on every access and faults with AES6004 as soon as the component or its node is destroyed, even before the end of the phase.
AssetRef
IsNil() bool, Name() string. Assets are assigned in the Inspector; scripts pass them to engine functions (scene.Instantiate).
Run it: the sandbox puts each script on its own top-level node with a Sprite Renderer.
package demo
import "aego/log"
script Gun {
var shots int
func Fire() {
shots++
}
}
script Player {
var sprite ComponentRef<SpriteRenderer>
var frames int
func Ready() {
sprite = node.Get<SpriteRenderer>()
log.Info("has sprite: " + string(node.Has<SpriteRenderer>()))
gun := node.Find("../Gun")
if gun != nil && gun.IsAlive() {
log.Info("found " + gun.Name() + " next to " + node.Name())
gun.GetScript<Gun>().Fire()
}
t := node.Transform2D()
t.Position.X += 1
node.SetTransform2D(t)
log.Info("position " + string(node.Position().X) + ", world " + string(node.WorldPosition().X))
}
func Update(dt float) {
frames++
if frames == 30 {
sprite.FlipX = true
sprite.Tint = Color{1, 0, 0, 1}
log.Info("flipped: " + string(sprite.FlipX) + ", tint red " + string(sprite.Tint.R) + " green " + string(sprite.Tint.G))
}
if frames == 60 {
gun := node.Find("/Gun")
log.Info("gun has a sprite: " + string(gun.Has<SpriteRenderer>()))
log.Info("Gun fired " + string(gun.GetScript<Gun>().shots) + " time")
}
}
}
id: language/native-bindings icon: cat.scripts parent: language order: 11 title: Native bindings
Native bindings
Project Go code in native/ exposes functions, constants and component members through the SDK.
Functions
m := r.ScriptModule("mygame/combat")
sdk.Proc2(m, "Damage", func(c *sdk.ScriptCtx, target sdk.NodeHandle, amount int64) {
if h, ok := sdk.Get[Health](c.Scene, target); ok {
h.Current -= float32(amount)
}
}, sdk.ScriptDoc("deals damage to a node with Health"))
Scripts import "mygame/combat" and call combat.Damage(target, 5). Use Func0..Func5 for functions with a result, Proc0..Proc3 without, Const and Enum for constants and host enums. Allowed parameter and result types: bool, Go integer types, float32/float64, string, mathx.Vec2/Vec3/Color/Quat/Rect, scene.Transform2D/Transform3D, scene.NodeHandle (a NodeRef), asset.Ref[…], sdk.ScriptCurve (a Curve parameter, resolved with Resolve()), integer types registered with Enum and Go types registered as handles. Structs and slices cannot cross the boundary. c.Fail(msg) stops the calling instance with AES6010; a Go panic becomes AES6002.
Component fields and methods
A component registered through sdk.Component[T] exposes every field marked with the script schema flag by default. The builder narrows or extends that:
b := sdk.Component[Health](r, schema.Category("Gameplay"))
b.Script("Current", "Max")
sdk.Method1[Health, int64, bool](b, "Damage", func(c *sdk.ScriptCtx, h *Health, amount int64) bool {
h.Current -= float32(amount)
return h.Current <= 0
})
sdk.Proc0[Health](b, "Heal", func(c *sdk.ScriptCtx, h *Health) { h.Current = h.Max })
Script(names...) lists the visible fields, ScriptNone() hides the component from scripts entirely. Method0..Method3 and Proc0..Proc3 on a builder take the same parameter types as module functions plus the component pointer; they are compiled to typed adapters and allocate nothing per call. Scripts call them on the component reference: hp := node.Get<Health>() then hp.Damage(5). The methods are not a module and cannot be imported. A call through a nil reference, a destroyed node or a removed component faults with AES6004. ScriptMethod(name, fn any) accepts an arbitrary Go function through reflection: it is for prototyping, logs a warning at registration and is rejected in aego_runtime builds.
The binding registry's hash covers every module, name and signature and is part of the compile cache key, so changing a native signature recompiles the scripts that use it. Changes in native/ are never swapped in-process (§19).
Handles
A module that owns long-lived objects hands them to scripts as handles:
m := r.ScriptModule("mygame/net")
sdk.ScriptHandle(m, "Request", true, RequestOf, Request.Key, sdk.ScriptDoc("Request is a pending HTTP call."))
hm := m.Registry().HandleMethods("mygame/net.Request")
sdk.Func1(hm, "Done", func(c *sdk.ScriptCtx, r Request) bool { return svc.Done(r) })
The Go type must be distinct for every handle type and convert to and from a non-zero uint64 key; key 0 is nil. Scripts see the type as net.Request and call its methods. A handle registered as awaitable must have Done() bool; yield req then parks the coroutine until the module calls Complete(abi.HandleID("mygame/net.Request"), key) on the world service scriptwait.ServiceID. Call it when the object ends, fires or is cancelled.
Run it: the sandbox has no native/ code, but the standard library and component fields are bound through the same SDK, so scripts call them the same way they call your own module.
package demo
import (
"aego/log"
"aego/math"
"aego/rand"
)
script Bindings {
func Ready() {
log.Info("math.Clamp from Go: " + string(math.Clamp(14, 0, 10)) + ", math.Pi " + string(math.Pi))
log.Info("rand.Int from Go: " + string(rand.Int(1, 7)))
sr := node.Get<SpriteRenderer>()
sr.FlipY = true
log.Info("SpriteRenderer.FlipY set through a component binding: " + string(sr.FlipY))
}
}
id: language/runtime-behaviour icon: panel.profiler parent: language order: 12 title: Runtime behaviour
Runtime behaviour
- A fault stops only the instance that caused it (its methods, coroutines and handlers are no longer called); the rest of the scene keeps running. The Console shows the script stack; double-click opens the file.
- Each callback has an instruction budget (default 1 000 000); an infinite loop ends with
AES6001. A coroutine resume and a signal handler each have their own budget. - Integer division by zero faults;
MinInt / -1yieldsMinInt,MinInt % -1yields0.float → intconversion saturates and maps NaN to0, identically on every platform. - Strings and slices are garbage-collected between callbacks; scripts that do not allocate produce no garbage. A suspended coroutine keeps its strings and slices alive.
| Code | Meaning |
|---|---|
| AES6001 | instruction budget exceeded |
| AES6002 | native binding panicked |
| AES6003 | call depth over 256, or a signal that re-emits itself without end |
| AES6004 | nil or dead reference (NodeRef, ComponentRef, ScriptRef, Curve) |
| AES6005 | index out of range |
| AES6006 | integer division by zero |
| AES6007 | invalid string or slice handle |
| AES6008 | missing return reached |
| AES6009 | unresolved extern or binding |
| AES6010 | host function reported an error (c.Fail) |
Faults raised by the engine itself (a dead reference inside a component accessor, for instance) carry their exact code; only c.Fail from native code maps to AES6010.
Run it:
package demo
import (
"aego/log"
"aego/math"
)
script Runtime {
var names []string
var frames int
func Ready() {
log.Info("int(1e20) saturates to " + string(int(1e20)))
log.Info("int(NaN) is " + string(int(math.Sqrt(-1))))
minInt := -9223372036854775807
minInt--
log.Info("MinInt / -1 = " + string(minInt / -1) + ", MinInt % -1 = " + string(minInt % -1))
log.Info("-7 / 2 = " + string(-7 / 2) + ", -7 % 2 = " + string(-7 % 2))
}
func Update(dt float) {
frames++
names = append(names, "frame " + string(frames))
if frames % 40 == 0 {
log.Info(string(len(names)) + " strings kept in a field, last \"" + names[len(names) - 1] + "\"")
}
}
}
id: language/limits icon: status.warning parent: language order: 13 title: Limits
Limits
255 registers per function · 32 767 instructions per function · 32 value slots per slice element · 255 script-visible fields per component (checked when the component is registered) · call depth 256 · 1 000 000 instructions per callback (configurable).
Run it:
package demo
import "aego/log"
func depth(n int) int {
if n == 0 {
return 0
}
return 1 + depth(n - 1)
}
script Limits {
var frames int
var sum int
func Ready() {
log.Info("recursion " + string(depth(200)) + " deep, below the limit of 256")
}
func Update(dt float) {
for i := 0; i < 20_000; i++ {
sum += i % 7
}
frames++
if frames % 60 == 0 {
log.Info("frame " + string(frames) + ": 20 000 iterations per frame fit the budget, sum " + string(sum))
}
}
}
id: language/diagnostics icon: panel.problems parent: language order: 14 title: Diagnostics
Diagnostics
Compiler diagnostics have a code, file, line and column, and often a note:
scripts/player/player.aes:17:21 AES2003 cannot use value of type NodeRef as float in assignment
note: field Speed has type float
AES1xxx lexical/syntax · AES2xxx types · AES3xxx attributes · AES4xxx packages and imports · AES5xxx bindings · AES6xxx runtime faults. The parser recovers after an error, so one run reports every problem in a file. aego check --explain AES2052 prints the long description of a code; the editor shows it on hover in the Problems panel.
Codes specific to coroutines, signals and script references:
| Code | Meaning |
|---|---|
| AES2050 | yield outside a coroutine |
| AES2051 | coroutine called without start |
| AES2052 | start target is not a coroutine of this script |
| AES2053 | coroutine is not a script method without a result |
| AES2054 | handler parameters do not match the signal |
| AES2055 | bad Connect handler, or Emit on another script's signal |
| AES2056 | lifecycle method declared as a coroutine |
| AES2057 | wrong yield argument type |
| AES2058 | ScriptRef / GetScript target is not a script type, or is ambiguous |
| AES2059 | lifecycle method or coroutine called through a ScriptRef |
Run it: the checks below avoid the runtime faults AES6004, AES6005 and AES6006.
package demo
import "aego/log"
script Guards {
@export var Target NodeRef
var scores []int
func Ready() {
scores = append(scores, 40)
scores = append(scores, 15)
var none []int
log.Info("average " + string(average(scores)) + ", of none " + string(average(none)))
log.Info("score 5: " + string(at(5)))
if Target == nil {
log.Warn("Target is not set; Target.Name() would stop the script with AES6004")
}
}
func average(list []int) int {
if len(list) == 0 {
return 0
}
total := 0
for i := 0; i < len(list); i++ {
total += list[i]
}
return total / len(list)
}
func at(i int) int {
if i < 0 || i >= len(scores) {
return -1
}
return scores[i]
}
}
id: language/cli icon: panel.console parent: language order: 15 title: CLI
CLI
| Command | |
|---|---|
aego check [dir] [--manifest file] [--explain code] |
type-check every script package against engine and native bindings |
aego test [dir] [-run name] [-v] [-budget n] [-seed n] |
run TestXxx() functions from *_test.aes headless |
aego test [dir] --determinism [--frames n] [--every n] [--seed s] [--scene file] [-v] |
run the game twice headless and compare state hashes |
aego dump script <pkg|Type> [--func name] [--project dir] |
bytecode with ; file:line source comments; the header lists imports, externs, enums, signals, script refs, cross-script fields and methods, and connections |
aego run [dir] |
refuses to start while a script package has errors |
All of them use the same workspace as the editor and hot reload. check and dump use the bindings manifest. For a project with native/, a small helper that only calls the module's Register writes it to .aego/cache/script-bindings; it is rebuilt when native/*.go, go.mod/go.sum or the engine change. A native compile error or a failing Register is exit code 2. test on a project with native/ builds the game binary and runs the tests inside it, because tests call the real native functions.
Test functions are package functions; they have no scene, so coroutines, signals and script references are tested in play, not in _test.aes. _test.aes files are compiled only by aego test; they never enter Play or exported builds. A test that loops forever ends with AES6001 and the next test runs.
aego test --determinism checks that the game itself is deterministic. It runs the main scene (or --scene file) twice headless with the same seed (--seed, default 1) and a fixed 1/60 s frame, for --frames frames (default 600), and every --every frames (default 60) hashes the world time, the random source and the state of every service that supports rewind keyframes. If the hashes differ, it prints the first checkpoint where they do and exits with code 1. -v lists which services the hash covers and which it does not (engine services such as the asset loader and the renderer hold no game state). A difference usually means a script or module reads the wall clock, iterates a Go map, or keeps state outside the world.
Run it: the sandbox has no test runner, so a script calls the test function from Ready. In a project, put TestCombo in a _test.aes file and run aego test.
package demo
import (
"aego/log"
"aego/testing"
)
func combo(hits int) int {
if hits < 3 {
return 1
}
return hits / 3 + 1
}
func TestCombo() {
testing.Equal(combo(0), 1)
testing.Equal(combo(3), 2)
testing.Equal(combo(9), 4)
}
script Runner {
func Ready() {
TestCombo()
log.Info("ok TestCombo")
}
}
id: language/coroutines icon: status.busy parent: language order: 16 title: Coroutines
Coroutines
A coroutine is a script method that can pause and continue on a later frame.
script Player {
@export var HitTint Color
var base Color
var blink Coroutine
func Hurt() {
if !blink.Running() {
blink = start Blink(6)
}
}
func Blink(times int) coroutine {
sr := node.Get<SpriteRenderer>()
for i := 0; i < times; i++ {
if i%2 == 0 {
sr.Tint = HitTint
} else {
sr.Tint = base
}
yield wait(0.1)
}
sr.Tint = base
}
}
- Declaration.
coroutinefollows the signature:func Name(params) coroutine { … }. Only script methods without a result can be coroutines (AES2053).Ready,Update,FixedUpdateandReloadedcannot be coroutines (AES2056); start one from them instead. - Starting.
start Name(args)runs the coroutine at once, up to its firstyield, and returns aCoroutine. Only coroutines of the same script can be started (AES2052). Calling a coroutine like a plain function isAES2051. Starting the same coroutine twice gives two independent runs. - yield is allowed only inside a coroutine (
AES2050):
| Form | Resumes |
|---|---|
yield |
on the next frame |
yield wait(s) |
after s seconds (float) of game time, at the earliest on the next frame; paused Play does not advance it |
yield frames(n) |
after n frames (int) |
yield fixed(n) |
after n fixed steps (int), before physics |
yield c |
when coroutine c ends; it resumes in the same frame; a nil, finished or cancelled c resumes on the next frame |
yield h |
when the tween or timer h (any awaitable host handle) ends, fires or is cancelled, on the next frame; a nil or finished h resumes on the next frame; it waits while the game is paused |
A wrong argument type, or a handle that cannot be awaited, is AES2057.
- Order. Coroutines that are due in one frame resume in the order they were started. They run in
script.coroutines, right afterscript.update;fixedwaits run afterscript.fixed. Only coroutines that are due are visited; ten thousand sleeping coroutines cost nothing per frame. - Ownership. A coroutine belongs to the instance that started it. It is cancelled when the instance's node is destroyed, the script is removed or disabled, the instance faults, or its script changes in a hot reload. A fault inside a coroutine stops the owning instance, like a fault in
Update. A cancelled coroutine releases its wait slot immediately; it does not linger until its timer would have fired. - Handles.
c.Stop()cancels;c.Running()is false once the coroutine finished, was cancelled or faulted. The zeroCoroutineisniland never running. A handle from before a hot reload stays valid as a value but is never running again. - Budget. Each resume has its own instruction budget (§12). A loop without
yieldinside a coroutine still ends withAES6001.
Run it:
package demo
import "aego/log"
script Blink {
var blink Coroutine
func Ready() {
blink = start Run(3)
}
func Run(times int) coroutine {
for i := 0; i < times; i++ {
log.Info("blink " + string(i))
yield wait(0.1)
}
log.Info("done")
}
}
id: language/signals icon: panel.events parent: language order: 17 title: Signals
Signals
script EnemyAI {
signal Died()
func Kill() {
Died.Emit()
node.Destroy()
}
}
script PlayerController {
@export var Enemy NodeRef
var kills int
var conn Connection
func Ready() {
e := Enemy.GetScript<EnemyAI>()
if e != nil {
conn = e.Died.Connect(OnEnemyDied)
}
}
func OnEnemyDied() {
kills++
}
}
- Declaration.
signal Name(params)inside a script. Signals have no result. An uppercase name is visible from other packages. - Emit.
Name.Emit(args)is allowed only inside the script that declares the signal (AES2055). Handlers run synchronously, in connection order, beforeEmitreturns. Each handler has its own instruction budget; a fault in a handler stops the handler's owner, not the emitter, and the remaining handlers still run. - Connect.
Name.Connect(Method)orref.Name.Connect(Method)registers a plain method of this script, orref.Methodof another instance, and returns aConnection. The handler must take exactly the signal's parameter types and return nothing (AES2054). Coroutines and lifecycle methods cannot be handlers (AES2055). Connecting the same method twice calls it twice. - Changes during Emit. Connections added or removed by a handler take effect after the current
Emitreturns; the runningEmituses the list it started with. - Lifetime. When the source instance stops, all its connections are removed. When a handler's owner stops, its connections are skipped and removed.
Connected()becomes false in both cases.c.Disconnect()removes one connection; on anilhandle it does nothing. - Recursion. A handler that emits again goes one call deeper; endless re-emitting ends with
AES6003on the emitting instance. - Cost.
Emitallocates nothing; a scene with ten thousand connections and a thousand emits per frame produces no garbage.
Run it:
package demo
import "aego/log"
script Counter {
signal Ticked(n int)
var frames int
var conn Connection
func Ready() {
conn = Ticked.Connect(OnTicked)
}
func Update(dt float) {
frames++
if frames%30 == 0 {
Ticked.Emit(frames)
}
}
func OnTicked(n int) {
log.Info("tick at frame " + string(n))
}
}
Engine signals
Components and engine handles have signals too. Scripts connect to them and never emit them:
hp := node.Get<Health>()
conn = hp.Died.Connect(OnDied)
fade = tween.ToFloat(node, "SpriteRenderer.Tint.a", 0, 0.3, curve.Linear).Completed.Connect(OnFaded)
The same rules apply as for script signals. A component signal's connections end when its node is destroyed or the component removed; a handle signal's connections end after the handle's last emit (a tween's Completed, the last Fired of a one-shot timer). Connecting to a finished handle returns a nil Connection. Emit on an engine signal is AES2055. Parameters of engine signals are numbers, vectors, colors, enums and references, never strings or slices.
Go code subscribes with sdk.Connect1[A](ctx, node, "Health.Died", fn) or "script/Enemy.Died". A Go handler that panics is disconnected and reported; it never stops a script.
id: language/scriptref icon: panel.references parent: language order: 18 title: ScriptRef
ScriptRef
node.GetScript<T>() returns the ScriptRef<T> of script T on that node, or nil when the node has none. On a nil or destroyed node it faults with AES6004. Use pkg.Name when the short name is ambiguous (AES2058).
Through a ScriptRef you can read and write fields (exported ones from other packages), call plain methods and connect to signals. Lifecycle methods and coroutines are not reachable (AES2059); call a plain method that does the work, or connect to a signal. A ScriptRef is re-validated on every access; once the target instance stops, access faults with AES6004. ScriptRef fields cannot be exported. A ScriptRef survives a hot reload of either side: fields and methods are re-resolved by name.
Run it:
package demo
import "aego/log"
script Health {
@export var Max int = 10
var Current int
signal Died()
func Ready() {
Current = Max
}
func Damage(n int) {
Current -= n
if Current <= 0 {
Current = 0
Died.Emit()
}
}
}
script Attacker {
var target ScriptRef<Health>
var conn Connection
var frames int
func Ready() {
other := node.Find("/Health")
target = other.GetScript<Health>()
conn = target.Died.Connect(OnTargetDied)
}
func Update(dt float) {
frames++
if frames % 20 == 0 && target.Current > 0 {
target.Damage(4)
log.Info("hit, target has " + string(target.Current) + " of " + string(target.Max))
}
}
func OnTargetDied() {
log.Info("target died on frame " + string(frames))
conn.Disconnect()
}
}
id: language/hot-reload icon: build.ok parent: language order: 19 title: Hot reload
Hot reload
Saving a script while the game runs (aego run, Play in the editor) swaps the code without restarting. The workspace builds a new generation from the saved files. At the start of the next frame (script.swap, PhaseFrameBegin, the VM stack is empty), every module of that generation is loaded into the same VM. Every live instance is then moved into the new layout, and the dynamic schema is replaced in the same transaction. If anything fails, nothing changes and the previous generation keeps running. A save that does not compile never produces a generation.
Instances keep their identity: InstanceID, node, heap strings and slices survive. Fields are matched by name (or @former), never by slot:
| Change | Result |
|---|---|
| same type | value kept |
| new field | its default |
| removed field | dropped |
incompatible type (float → string) |
default + warning, counted as reset |
| enum | by member name; a removed member resets the field |
| struct | field by field, recursively |
[]T |
element by element; incompatible element type resets the slice |
NodeRef, AssetRef, ComponentRef, ScriptRef |
kept |
Coroutine |
kept as a value; see below for whether it is still running |
Connection |
kept; rebound or invalidated, see below |
host handles, Curve |
kept as values |
After the swap, for every script whose package (or a dependency's interface) changed:
- its coroutines are cancelled: a coroutine's suspended state belongs to the old code; a
Coroutinefield keeps the old, no longer running handle; - signal connections are rebound by signal name, parameter types and handler name; a connection whose signal or handler is gone, or whose signature changed, is removed and its
ConnectionreportsConnected() == false; func Reloaded()runs. Use it to restart coroutines:
func Reloaded() {
if flash > 0 {
blink = start Blink(6)
}
}
Ready does not run again. Instances that faulted in a changed script resume running. Instances of a removed script stop. Coroutines and connections of unchanged scripts are untouched. The Console prints one line per reload: Hot reload N → N+1: X migrated, Y reset, Z coroutines canceled, W handlers disconnected, … ms; the same numbers appear in the Profiler's Scripts section. Changes in native/ are never swapped in-process: the supervisor rebuilds and restarts.
Run it: the sandbox never reloads, so Reloaded does not run here. In aego run or Play, change Beat and save; the coroutine is cancelled and Reloaded starts it again.
package demo
import "aego/log"
script Pulse {
@export var Period float = 0.5
var pulse Coroutine
var beats int
func Ready() {
pulse = start Beat()
}
func Reloaded() {
if !pulse.Running() {
pulse = start Beat()
}
}
func Beat() coroutine {
for {
beats++
log.Info("beat " + string(beats))
yield wait(Period)
}
}
}
id: language/editor icon: panel.inspector parent: language order: 20 title: Editor
Editor
The editor's code editor, navigation, completion, hover, library stubs, Problems panel and Profiler sections for scripts are described in docs/editor.md (§ Scripts). Unsaved edits are analysed but never run; autosave writes them one second after the last keystroke, on focus loss and before Play.
Run it: in the code editor, hover over Turret, Range or Rate to see the comments above them; the Inspector groups the fields by category.
package demo
import "aego/log"
// Turret aims at the nearest target. This comment is shown on hover.
script Turret {
// Range is how far the turret sees, in world units.
@export(min=0, max=20, unit="u", category="Aim")
var Range float = 8
// Rate is shots per second.
@export(min=0.1, max=10, category="Fire")
var Rate float = 2
var cooldown float
var shots int
func Update(dt float) {
cooldown -= dt
if cooldown <= 0 {
cooldown = 1 / Rate
shots++
if shots % 2 == 0 {
log.Info("shot " + string(shots) + " at range " + string(Range))
}
}
}
}
id: language/data-assets icon: file.data parent: language order: 21 title: Data assets
Data assets
A data type registered by a native module is available to scripts by its short name.
@export
var Stats AssetRef<EnemyStats>
@export
var Loot TableRef<LootEntry>
Stats.Speed reads a field of the record the reference points to; every read sees the current record, so live tuning in the editor reaches the script on the next read. Fields cannot be assigned through a reference (AES2007). Stats.Copy() returns the record as a struct value of type data.EnemyStats that the script may change freely.
A table reference has Row(id string), Has(id string) bool and Len() int. Row returns a copy of the row.
Reading through a nil reference or asking for a missing row stops the script with AES6004.
Run it: Stats.Copy() returns a struct like EnemyStats below. The sandbox registers no data types, so the program builds the struct itself.
package demo
import "aego/log"
type EnemyStats struct {
Speed float
Hp int
}
script Enemy {
var stats EnemyStats
func Ready() {
stats = EnemyStats{Speed: 3, Hp: 20}
boss := stats
boss.Hp *= 5
log.Info("enemy " + string(stats.Hp) + " hp, boss copy " + string(boss.Hp) + " hp")
}
func Update(dt float) {
node.SetPosition(node.Position() + Vec2{stats.Speed * dt, 0})
if node.Position().X >= 3 && stats.Speed > 0 {
stats.Speed = 0
log.Info("stopped at x " + string(node.Position().X))
}
}
}