scope has one central idea: an instance is created inside a Scope, and that
scope defines what it can see. Everything else — dependency injection,
lifecycles, hooks — follows from this.
A Scope reads like a lexical block in JavaScript, Rust or Java. A value defined
in an outer block is visible to inner blocks, unless an inner block defines a
nearer value under the same key — in which case the nearer one shadows it.
// RootScope
{
x = value;
// PlayerScope (nested inside RootScope)
{
player = value;
classes = ...;
// An object created here sees:
// - player and classes locally;
// - x from the parent;
// - any local definition that shadows a parent definition.
}
// GameScope
{
game = value;
players = List<Scope<Player>>;
// Player1Scope
{ player = player1; }
// Player2Scope
{ player = player2; }
}
}
This classic view is a tree: a child inherits the providers visible from its
parent. Resolution uses the nearest definition by default (the NEAREST
strategy). Exactly like a local variable, a local value shadows a parent's value.
The difference with a compiler's lexical scope: here the blocks are objects. They are opened, nested, shadowed and closed at runtime — and a scope may even have several parents, forming a directed acyclic graph rather than a strict tree.
The same code, the lexical reading, and the runtime objects side by side:
root.seed(Config.class, new Config("root"));
player.ownedBy(root);
player.seed(Config.class, new Config("player"));
player.get(Config.class); // -> "player"As lexical blocks, player's Config hides root's, exactly like a local variable
hides an outer one:
{ // root
Config = "root";
{ // player (nested in root)
Config = "player"; // ← redeclares Config: shadows root's
use Config; // resolves to "player"
}
}
As runtime scope objects, lookup walks outward and stops at the first match
(NEAREST):
┌─ root ────────────────────────────┐
│ │
│ Config = Config("root") │ ✗ never reached — shadowed
│ │
└───────────────────────────────────┘
▲
│ visible (ownedBy)
│
┌─ player ──────────────────────────┐
│ │
│ Config = Config("player") │ ✔ found here → search stops
│ │
└───────────────────────────────────┘
▲
player.get(Config.class)
Remove the local Config from player and the very same lookup falls through to
root and returns "root". Shadowing is just "a nearer definition exists"; nothing
is deleted.
A scope is an atomic unit of lifetime: every object created or registered in a scope lives exactly as long as that scope stays open. There is no API to close or remove a single object inside a scope.
Concretely:
- Each provider in a scope returns a singleton of that scope.
get(...)can be called dynamically, but the object it returns belongs to the scope untilscope.close().close()tears the whole scope down: owned children first, then cleanup hooks, then local providers (see Scopes & lifecycle).- For a resource that must be released earlier, create a dedicated child scope for it, use it from there, and close that child when done.
A parent is visible from a child, but not the other way around. An object allocated in a child scope never becomes a dependency of its parent. This asymmetry forces a clear boundary of visibility, state and lifecycle.
In practice: "1 scope = 1 container lifetime" — dynamic allocation is allowed, partial deallocation is not. If you find yourself wanting to free one object early, that object wants its own scope.
Systems that juggle many overlapping lifetimes map cleanly onto nested scopes. On a game server, the same value can live at the scale of the plugin, a player, a running game, or a single action — each with its own visibility boundary and its own deterministic teardown:
root → plugin → game → player-in-game → action
Model each as a scope, register what belongs there, and let resolution + close()
handle the wiring and the cleanup.
Next: Getting started.