11 KiB
Kernel, modules and extension points
What belongs to the kernel, what belongs to a module, and what depcheck reports when the line is crossed. The rules are in ARCHITECTURE §4; the only place to waive one is tools/depcheck/exceptions.txt (D212).
What is kernel
The kernel (schema, scene, render, asset, engine, script, ui, project, contracts) and the editor core know concepts every module can use, never a particular module:
| Kernel concept | Examples |
|---|---|
| Frame phases and scheduling | PhasePhysics, PhasePostPhysics, SystemDesc.Before/After inside one module |
| Scene data | presentation pose (SetPresentationLocal), component defaults, OnAdd |
| Script host | host structs, component methods, script events (module.Registry.ScriptEvent), bind.Ctx |
| Contracts | contracts/scriptevents, contracts/physics2d, contracts/camera2d: interfaces between modules and scripts |
| Editor core | documents, history, selection, assetops, fuzzy, gizmos, vectors, play, services (Frame and the panel services) |
| Editor extension points | editor/ext: SettingsPage, Problems, Profiler, GameOverlay, Vectors, Overlay, ext/inspect custom inspectors |
What is a violation
| Code in the kernel or the editor that… | depcheck rule | Do instead |
|---|---|---|
imports modules/X |
kernel-imports-module, editor-imports-module |
register through module.Registry from the module; compose in app (the catalog) |
imports another module from modules/X |
module-imports-module |
depend through a contract in contracts/, declare Info.Requires |
| names a module's system in an ordering constraint | caught by Build (unknown dependency) |
order by phases; keep Before/After inside the module (D204) |
writes a module ID, <ID>.*, a module component name or a module diagnostic code as a literal |
module-literal |
let the module pass the string through its registration (category, page, section) |
| imports one panel from another | panel-imports-panel |
move the shared code to editor/core or editor/ext |
imports a panel from editor/ext or editor/core |
editor-ext-imports-panel, editor-core-imports-panel |
move the type to editor/ext |
makes modules/X import modules/X/editor |
module-imports-own-editor |
the editor side imports the module, never the reverse |
| lets a runtime package reach dev-only code | runtime-reaches-dev |
put the dev part behind !aego_runtime or in a dev package |
| lets the playground reach native or pipeline code | wasm-reaches-forbidden |
keep it out of the playground profile |
holds or receives *context.Editor in editor/panels/* |
tests/modulecheck/panels_test.go |
take the services the panel needs in its Deps (D216) |
A module can tune another module's behaviour without importing it by providing a value under a contract kind: r.Provide(camera2d.AccessibilityKind, camera2d.Accessibility{ScreenShake: "...", ReducedMotion: "..."}) hands the camera module the IDs of settings it reads every frame. The consumer reads ctx.Provided(kind) in Init.
Proof with a fake module
A rule holds when a test module that is not the real one can use the same extension point. tests/modulecheck/fakephys registers a project section, inspector hints, a script event, component hooks, a settings page, a panel, a custom inspector, a gizmo, bounds, overlays, a vector layer and a profiler section without importing modules/physics; the guard test there checks that the kernel and the editor do not import or name any module.
Writing a module
A project module is written against aego/sdk only. examples/native-settings is a complete one: a project section, a component with a validator and a game-view overlay. tests/modulecheck/fakephys uses every extension point and runs in the editor in place of physics; it is the working reference for the full list. The engine-side names behind each facade are in D205–D211.
Lifecycle
Register → schema freeze → Init(ctx) → AttachWorld(ctx) / DetachWorld(ctx) per edit or play world → Shutdown(). Shutdown runs in reverse Init order; a failing Init shuts down the modules initialized before it. DetachWorld is called for every module whose AttachWorld was called, including a failing one, so it must tolerate a partial attach. Per-world state lives in world services, not in the module struct (D206).
Project settings sections
A module owns one block of project.aego, named after the module ID or <id>.<name>:
sdk.ProjectSection(r, sdk.SectionDesc[Settings]{
ID: "weather",
Title: "Weather",
Order: 400,
Default: func() Settings { return Settings{Strength: 1} },
Validate: func(s *Settings) error { ... },
})
- With
Decode/Encodeleft empty and the type registered withsdk.RegisterStruct, the schema codec reads the block and Project Settings draws a page from the fields. VersionandMigrate(node, from, to)upgrade old blocks; migration works on a clone, the file is rewritten only on save.Formerlists old block heads (render2dreadsrender {}).Names(v, list)returns named lists for inspector hints (below).sdk.Section[T](ctx, id)gives the current value. Project Settings edits a draft and replaces the value afterproject.aegois written, so a module reads the section when it needs it instead of caching the pointer fromInit.
Blocks without a registered section stay in the file untouched. aego check validates the sections of the built-in modules; sections of native modules are checked when the project runs.
Inspector hints
Schema options on the component registration, never code in the Inspector:
| Option | Effect |
|---|---|
sdk.ChoicesFrom(field, section, list) |
integer field picked from a named list of a section (Names) |
sdk.Bitset(field, section, list) |
integer field edited as a mask over that list; all listed bits set is stored as -1 |
sdk.Runtime(fields...) |
shown only during Play, read-only |
Component groups in the Inspector follow module order from the manifest, then registration order (D208).
Component hooks
sdk.Component[Wind](r, sdk.Name("weather.Wind")).
OnAdd(func(ctx sdk.AddContext, w *Wind) { ... }).
OnRemoving(func(ctx sdk.AddContext, w *Wind) { ... }).
OnChanged(func(cs *sdk.ChangeSet) { ... }).
Validate(func(ctx sdk.ValidateContext, w *Wind) []sdk.Diagnostic { ... })
OnRemovinggets the old value before the store frees it (editor remove and undo of add).OnChangedruns after every committed transaction, Undo and Redo with the deduplicatedChangeSet; add and remove of one component in a batch cancel out.Validatereports problems of one component; the editor runs it over open edit scenes.- Hooks make no structural changes outside pending. Runtime code keeps comparing values itself (D185, D209).
Diagnostics
engine/problems collects sdk.Diagnostic{Owner, Code, Severity, Message, Subject, BlocksPlay} from sources with a revision; the service rebuilds the list only when a revision changes. sdk.Diagnostics(ctx) in Init returns the service; a module adds runtime diagnostics as a sdk.ProblemSource from AttachWorld and removes it in DetachWorld. The Problems panel shows them with the owner as category; an error with BlocksPlay stops Play (D210).
Resources
Module icons are named <owner>.* or cat.<owner>; a clash with the engine atlas or another module is a build error. Built-in modules list their icon sources through IconDir(). Docs registered with Registry.Docs carry the module as owner and are left out of runtime builds (D211).
Profiling
Every system already shows up in the Profiler flame graph and in exported traces under its system ID. For finer zones inside a system, register a zone once in Init and open it around the work:
var gust sdk.TraceZone
func (m *Weather) Init(ctx *sdk.InitContext) error {
gust = ctx.Engine.Trace().Zone("weather.gust")
return nil
}
func (s *windSystem) Run(ctx *sdk.Context) {
z := sdk.TracerOf(ctx).Begin(gust)
s.applyGusts(ctx)
z.End()
}
Zones nest, cost no allocations and are no-ops while tracing is off; every method is safe on a nil tracer (D215).
Editor extensions
Editor code lives in files tagged aego_editor and is attached with sdk.EditorHook. The hook runs once when the editor starts; everything it registers is removed together when one of its callbacks panics (D72).
//go:build aego_editor
func registerEditor(r *sdk.Registry) {
sdk.EditorHook(r, func(e *sdk.EditorRegistry) error {
ed := e.Editor()
if err := e.Panel(sdk.PanelDesc{Panel: &questPanel{ed: ed}, Near: sdk.PanelInspector}); err != nil {
return err
}
return e.Command(sdk.CommandDesc{ID: "game.heal_all", Name: "Heal All", Run: func() error { return heal(ed) }})
})
}
type questPanel struct{ ed *sdk.Editor }
func (*questPanel) ID() string { return "game.quest" }
func (*questPanel) Title() string { return "Quest" }
func (q *questPanel) Draw(f *sdk.Frame, r mathx.Rect) {
ui := f.UI
if q.ed.Doc() == nil {
widgets.LabelDim(ui, "No scene open")
return
}
...
}
| Extension point | Registration |
|---|---|
| Panel | Panel(sdk.PanelDesc{Panel, Slot, Near, Order, Hidden}); draws with Draw(*sdk.Frame, r) |
| Custom inspector | Inspector("game.Health", sdk.InspectorFunc(fn)); fn gets the editor, the selected nodes and the default field drawer |
| Command | Command(sdk.CommandDesc{...}); appears in its category menu and in Search Everywhere |
| Gizmo, bounds | Gizmo(type, g), Bounds(type, fn) for picking and framing |
| Overlays | Overlay(fn) in the Scene View, GameOverlay(fn) also in the Game View |
| Settings page, profiler section, vector layer | SettingsPage, Profiler, Vectors |
A panel gets a fresh Frame every draw (UI, drag and drop, time, focus). Anything else it needs it captures at registration through e.Editor(). Built-in panels follow a stricter rule and never see the editor (D216). PanelDesc.Slot puts a panel into a column of the default layout; Near opens it next to a panel on demand; without Order extension panels follow the built-ins in the Window menu (D217).
Try it scenes
A module can give any of its components a live example on the documentation site: put a scene in res/docs/try/<Component>.aescene, named after the last part of the component ID (try/Body2D.aescene for physics.Body2D). The page of that component gets a Try it block that runs the scene in the browser with the Inspector, selecting the first node that has the component; a guide page can show a scene with try: try/name.aescene and try_select: module.Component in its front matter.
Try scenes run on the sandbox catalog (core, input, render2d, scripting, physics, debug) with builtin assets only and no scripts. aego docs check loads each one, runs it for 60 frames and fails on a load error, a warning or a fault, and on a scene of a module outside the sandbox. Keep the scene to what the component needs, put the documented node first, and add a camera.