Instruction file imported from dvoyni/cog (
.github/instructions/architecture.instructions.md). Copyright stays with the author.
Plugin Kinds
Every plugin in cog is exactly one kind. Its kind fixes its directory, and every
plugin has the same package shape. docs/adr/0002-slots-extensions-and-bundles-as-declaration-roots.md
records why; CONTEXT.md defines each term. The kinds, the
shape and the import rules are enforced by kernel/archtest, not by review.
Everything here is the rule for every plugin, new ones included.
The Kinds
| Kind | Where | What it is |
|---|---|---|
| the kernel package | kernel |
The Engine, Registrar and scheduler every Plugin is built on. |
| Library | libs/<name> |
Code that is not a Plugin and defines none (libs/m). |
| Slot | slots/<name> |
A Plugin whose root declares at least one required Port (app, gfx, storage). Composition fails until an Adapter fills it. |
| Extension | extensions/<name> |
A Plugin that provides Adapters, at least one of them for a Slot's required Port, and declares no API (gogpu, diskstorage, jsstorage). It may also contribute to a collected Port. |
| Bundle | bundles/<name> |
Every other Plugin (input, anim, canvas, scene, ui, ecs, ecsscene, mcp). Its root declares no required Port, and it may collect Adapters or contribute them. |
A plugin that would need both a Slot's Adapters and an API of its own is two plugins: an Extension and a Bundle.
The Package Shape
A plugin X is four places, and nothing else under X may hold Go code:
-
The root,
X/, holds declarations only: what the plugin offers others. -
X/internal/types/holds the concrete types the root aliases, their methods, and the plain functions the implementation needs to read their unexported state. A declaration goes there for one of two reasons only:- performance: a concrete type where an interface in the root would cost it, as the recording queues and hot state do;
internal/typescode names it:internal/typesnever imports its own root, so a type or command its code needs, a forwarder's body included, is declared there and aliased in the root. input declaresSynthesizeCmdandActionthere becausetypes.Play, whichinput.Playforwards to, dispatches the command.
Anything else such code refers to comes from another plugin's root.
-
X/internal/holds the implementation: the unexported plugin, itsNew, handlers, subscriptions, Adapter values and mcp provider. No file layout is enforced inside it. -
The constructor package,
X/Xplugin/(appplugin,canvasplugin), is one file whose only export isfunc New() kernel.Plugin, returninginternal.New().
A plugin uses another plugin through its root only.
What A Root Holds
Its non-test Go files come from its kind's allowlist. Non-Go files and docs/
stay where they are.
| Kind | Allowed files |
|---|---|
| Slot, Bundle | doc.go, id.go, commands.go, events.go, resources.go, ports.go, adapters.go, types.go, config.go, err.go, utils.go |
| Extension | doc.go, id.go, config.go, adapters.go, err.go |
-
Data-driven declarations. Types with exported fields, and no getters or setters.
-
Aliases go in the file matching what the aliased type is: a resource alias in
resources.go, a value type intypes.go(type OpQueue = types.OpQueue). -
Configis plain data whose zero value is the default. It may have builder methods (WithValuesPath), the only logic a root holds outsideutils.gobesides an inline anchor. There is noDefaultConfig. -
Functions appear only in
utils.go, and each one is a pure forwarder: a single call into the plugin's owninternal/types, returned when the forwarder has results, with its parameters passed through in order. A generic forwarder passes its type parameters through the same way:func GetValue[T any](key string, defaultValue T, outValue *T) AccessValuesRequest { return types.GetValue[T](key, defaultValue, outValue) } -
An inline anchor is the one code exception a root may hold outside
Config's builders andutils.go: an unexported function nothing calls, returning nothing, whose parameters are typed with the root's own aliases ofinternal/typestypes and whose every statement calls an argument-free method on one of them, discarding the results. It exists for the compiler. Go inlines a method of a package the caller does not import only when a package it does import references that method, and nothing outside a plugin imports itsinternal/types, so an accessor called per instance through a root alias stops inlining in importers unless the root references it. Anchor exactly the accessors a hot importer calls, say why in the function's comment, and confirm with-gcflags=-mbefore and after:// inlineAnchor is never called; see architecture.instructions.md. func inlineAnchor(parameter ParameterDescr, format TextureFormat) { _ = parameter.Name() _, _ = parameter.ColorValue() _ = format.Resolve() } -
A Slot's forwarders name only the Slot's own types, predeclared types, the standard library, Libraries and the kernel in their parameters and results, never another plugin's types. A Slot's API stays interface-like, so what fills it can change without its users changing.
-
No Plugin. A root declares no type implementing
kernel.Plugin, meaning no type withName,DependenciesandRegistermethods. -
No dispatch helpers. A caller dispatches a command itself and handles its answer.
-
An Extension's root declares only
Name,Config, its Adapter types andErr…errors: no commands, events, resources, ports, types or forwarders. ItsConfigarrives, as every plugin's does, throughkernel.New's config map underName, since its constructor takes nothing. An Extension may be the engine'skernel.PluginHost, as gogpu is: the host is the plugin value its constructor returns, found by the kernel, so nothing in the root names it. The platform main loop it runs is still an Adapter like any other: gogpu provides it asAppMainLoop, for app'sMainLoopPort. An Extension built for one platform only (diskstorage is!js, jsstorage isjs) tags itsinternal/implementation and its constructor package, and leaves its root untagged so the declarations build everywhere. -
An Extension's name takes the Slot it fills as its suffix when it fills Adapters for exactly one Slot:
diskstorageandjsstorageboth fill storage. An Extension that fills more than one Slot has no naming rule: gogpu, named for the library it wraps, fills both app and gfx. The tier test does not check names.
Ports And Adapters
A Port is a type in the declaring plugin's ports.go; an Adapter is a type in
the providing plugin's adapters.go. kernel.instructions.md
§ Ports and Adapters has the spelling.
- A Slot's root declares at least one required Port. A Bundle's or an Extension's declares none, though a Bundle may declare a collected Port.
- Every
ProvideAdapter[A]in a plugin names anAdeclared in that plugin'sadapters.go, and every type declared there is provided. A call in a file only another platform builds counts.
Placing New Code
Take the first answer that fits:
- It defines no Plugin → a Library in
libs/. - It cannot work until another plugin supplies an implementation → a Slot
in
slots/, whoseports.godeclares that required Port. - It supplies implementations of Slots' Ports and offers nothing else → an
Extension in
extensions/. - Otherwise it is a Bundle in
bundles/.
Within the plugin, a declaration another plugin uses goes in the root, in the
file its allowlist names for what it is. A type the root must alias, for
performance or because internal/types code names it, goes in
internal/types. Everything else goes in internal/.
Import Rules
| package | may import (plus std and third-party) |
|---|---|
kernel |
nothing else in cog |
libs/* |
libs, kernel |
root X |
libs, kernel, other plugins' roots, its own internal/types |
X/internal/types/… |
libs, kernel, other plugins' roots |
X/internal/… |
libs, kernel, any root, its own internal/… and internal/types |
constructor X/Xplugin |
kernel, its own internal/ |
- Nothing in cog imports a constructor package or another plugin's
internal/, except from_test.gofiles. A test composing an engine imports constructor packages; every other row holds for tests too.
Ordering Identities
An ordering identity is the subscription identity type a subscriber is ordered
against with Before/After. Name it verb plus event, for what the handler does
on which event:
type PresentOnUpdate kernel.Subscription[app.UpdateEvent]
Declare an identity another package orders against in the root's id.go, next
to Name, so ordering against it imports a root and nothing else
(gfx.PresentOnUpdate, canvas.FlushOnUpdate). An identity nothing outside its
plugin orders against is unexported, and stays in internal/.
Composition Roots Are Exempt
Games and examples (cog-examples, feuds-26, nox) are composition roots. They pick
the Plugins an engine is built from, so they import whatever they compose,
constructor packages included. These rules apply to the cog repo only. Inside
cog, kernel/archtest/** and docs/research/** are outside the tiers.
The Tier Test
go test ./kernel/archtest checks:
- every cog-internal import edge in every Go file, whatever its build tags;
- every root's files against its kind's allowlist;
- every root's functions against the forwarder and inline-anchor rules;
- every root for a Plugin type;
- every Slot for a required Port, and every Bundle and Extension for none;
- every Extension's declarations;
- every constructor package's exports;
- every plugin's
ProvideAdaptercalls against itsadapters.go.
A failure names the file, the declaration or edge, and the rule it breaks, and
every violation fails the test: fix the code to fit the rules. A change to the
rules themselves changes this file and kernel/archtest together.