On this page
Agent runbooks
Harness-neutral procedures for driving defold-typescript from an automated
agent. defold-typescript is a CLI published to npm (run with bunx) plus a
types package; it ships no
harness-specific skill or command assets, so the durable interface for an agent
is the CLI verbs themselves and their machine-readable --json output. Every
runbook below works from any harness: run the command, read the JSON envelope on
stdout, gate on ok.
Each one-shot command (init, build, resolve) prints a single JSON object
when given --json. The envelope is always one of two shapes:
{ "command": "<verb>", "ok": true, "written": ["<path>", "..."] }
{ "command": "<verb>", "ok": false, "error": "<message>" }
The agent branches on ok: on true, read written for the paths the command
created or updated; on false, read error for the failure reason. Some verbs
add fields to the success envelope (noted per runbook), but command, ok, and
either written or error are always present.
Machine-readable output
Every command accepts --json. The one-shot commands (init, build,
setup-debug, defold) print a single JSON object to stdout, terminated by a
newline; watch streams newline-delimited JSON (one object per
line):
bunx @defold-typescript/cli build --json
# {"command":"build","ok":true,"written":["src/main.ts.script", "src/util.lua", ...],"warnings":[]}
A failure flips ok to false and carries an error string instead of
written. On build, warnings carries the sourceless-orphan
lines and scene-resource-mismatch lines (empty when there are none). Optional fields (defoldVersion,
defoldChannel, apiSurface, materializedSurface, …) appear only when they
apply.
watch is long-running, so --json streams newline-delimited JSON (NDJSON) —
one object per line, one line per event. The full lifecycle reads
start → build → rebuild* → resolve* → stop:
bunx @defold-typescript/cli watch --json
# {"command":"watch","event":"start","ok":true,"written":[]}
# {"command":"watch","event":"build","ok":true,"written":[...],"warnings":[]}
# {"command":"watch","event":"rebuild","ok":true,"written":[...],"changed":["src/main.ts"],"removed":[]}
# {"command":"watch","event":"rebuild","ok":false,"error":"..."}
# {"command":"watch","event":"resolve","ok":true,"written":[]}
# {"command":"watch","event":"stop","ok":true,"written":[]}
A resolve event is emitted whenever a game.project save re-resolves the
extension surface (re-materializing .defold-types/extensions/ from the declared
[dependencies] URLs). start arrives once, before the initial full build — the
process is up and listening. stop arrives once on graceful shutdown. A failed
startup (missing tsconfig.json, etc.) emits start then exits non-zero with
no stop line; a rebuild that fails emits an ok: false line to stdout
too, so a line-reader sees one uninterrupted stream — failures never split off
to stderr. Read each line as it arrives and react per event. Without --json,
stdout stays the human wrote N files: … output and rebuild errors stay on
stderr.
resolve --json reports materializedSurface (the written
directory, or null when nothing was materialized) and, per extension, the url,
generated namespaces, scriptApiCount, provenance (cache or download),
whether it was assetOnly, the resolvedVersion (sha256 digest of the resolved
archive bytes), — when the project pins that url — the pinnedVersion, and the
pinStatus (unpinned / match / drift). A separate libraries array reports
each asset-only dependency that matched a vendored
library — its url, source (the vendored
source identity), materialized modules, provenance (vendored), and verified.
A verified: false entry (with an empty modules) is a repo-name match the
downloaded archive did not confirm; it is reported but never materialized:
{
"command": "resolve",
"ok": true,
"written": [],
"materializedSurface": ".defold-types/extensions",
"extensions": [
{
"url": "https://github.com/defold/extension-iap/archive/main.zip",
"namespaces": ["iap"],
"scriptApiCount": 1,
"provenance": "download",
"assetOnly": false,
"resolvedVersion": "sha256:ab12…",
"pinnedVersion": "sha256:ab12…",
"pinStatus": "match"
}
],
"libraries": [
{
"url": "https://github.com/paulomrpp/dicebag/archive/main.zip",
"source": "dicebag",
"modules": ["dicebag.dicebag"],
"provenance": "vendored",
"verified": true
}
]
}
A pinnedVersion field is only present when the project's package.json records
a pin for that url. See Resolve for
what pinStatus drives.
Offline knowledge pack
@defold-typescript/docs ships two generated, never-hand-edited files at
node_modules/@defold-typescript/docs/:
llms.txt— a curated index (per llmstxt.org): one link per guide page and per API namespace.llms-full.txt— the whole guide inlined plus a compactnamespace.function(signature)index of the entire API surface.
llms-full.txt is self-contained: an agent in a consumer project that has only
node_modules and the in-project source can read every mechanic and signature
from this one file — no network, no monorepo checkout. The docs site serves the
same pair at /llms.txt and /llms-full.txt. Both are regenerated from the
guide and the typed API on every docs build and drift-gated, so they never go
stale against the shipped types.
This pack documents this toolchain's TypeScript surface. Defold's own engine
docs are a separate, Lua/C++-first set served at defold.com/llms-*.txt — reach
for those (and cross-reference the types) via
Reach upstream Lua docs and convert.
Do not confuse the two llms-full.txt files: this one is the port; defold.com's
is the engine.
Helper and codegen scripts (Bun, /scripts)
When a task needs a build tool, a codegen pass, or a one-off maintenance script,
do not write it into src/ and do not run it with node. Two rules
keep such scripts off the game's Lua build path:
- Location: put the script in a project-root
/scriptsfolder. The Defold build selects source bytsconfig.include(defaultsrc/**/*.ts) and ignoresexclude, so any.tsundersrc/**is transpiled to Lua and shipped —/scriptsis outside that include, and outside the Defold-pinned maintsconfig.json. - Runner and typing: run with
bun scripts/foo.ts(nevernode);node:*builtins work under Bun and are typed by@types/bun. Add ascripts/tsconfig.jsonwith"noEmit": true,"types": ["bun"], and"include": ["**/*.ts"]— no Defold pin. Script dependencies go in the rootpackage.jsondevDependencies.
See the Helper scripts guide for the full rationale and a copy-pasteable config.
Verify against the real API surface
This section governs every runbook below it. Before answering any "how do I…"
question about a Defold symbol, confirm the symbol against the installed
surface — that it exists, its namespace, and its exact signature. An agent's
default instinct is wrong here: the Lua-first manuals at defold.com and the
ts-defold (@ts-defold/types) lore baked into training both describe surfaces
that diverge from this toolchain's. Never emit a signature from training,
from ts-defold, or from a raw Lua doc without first checking it exists in the
materialized .defold-types/ surface or the installed @defold-typescript/types
package. When recollection and the on-disk surface disagree, the surface wins.
Where the truth lives (reachable-surface map)
In a consumer project an agent can read only these; everything else (the generation pipeline, fixtures, monorepo planning docs) is contributor-only and absent from an install:
| Location | What it holds | When it exists |
|---|---|---|
.defold-types/<surfaceId>/*.d.ts |
the pinned ambient engine surface (e.g. .defold-types/defold-1.12.4/) |
after build; gitignored but on disk |
node_modules/@defold-typescript/types/generated/*.d.ts |
namespace signatures (go, vmath, factory, …) |
always, once installed |
node_modules/@defold-typescript/types/src/ |
the three factories (lifecycle.ts) and the narrowing guards (message-guard.d.ts, message-dispatch.d.ts, window-event-guard.d.ts) |
always, once installed |
node_modules/@defold-typescript/docs/guide/*.md |
this guide, refreshed on every install | always, once installed |
packages/types/generated, packages/types/fixtures, packages/types/scripts |
generator inputs and outputs — contributor-only | only in a clone of this monorepo |
The pointers the init-agents managed block writes are the layered pack:
node_modules/@defold-typescript/docs/llms.txt (the map) and
node_modules/@defold-typescript/docs/llms-full.txt (the grep corpus), with the
individual guide/<page>.md pages and the typed API in
@defold-typescript/types/generated/*.d.ts alongside them.
Confirm a signature
Read the signature from the namespace declaration instead of recalling it. Grep
the generated .d.ts for the function:
grep -rn "function create" node_modules/@defold-typescript/types/generated/
Quote what the surface declares as authoritative — for factory.create it is
factory.create(url, position?, rotation?, properties?, scale?): Hash. If the
grep returns nothing, the symbol does not exist on this surface; do not invent
it.
Reach upstream Lua docs and convert
Defold's own manuals and API reference are Lua-first. When the mechanic you need
is only documented there, run the conversion loop: locate the mechanic in
the Lua docs -> find its namespace in the generated .d.ts -> confirm the
signature as above -> translate the idioms to the TypeScript surface.
Defold serves its whole documentation set as agent-ready text — reach for these to locate the mechanic, never to copy a call signature:
https://defold.com/llms-full.txt— manuals, API reference, and examples combined.https://defold.com/llms-apis.txt— the C++/Lua/extension API reference alone.https://defold.com/llms-manuals.txt— the conceptual manuals alone.https://defold.com/llms-examples.txt— worked examples alone.
These describe the engine's Lua and C++ surface, not this toolchain's
TypeScript one: argument order, optionality, and even whether a symbol exists can
differ, so treat them as a concept-and-mechanic reference only. Once you know
what to call, switch to the generated .d.ts
(Confirm a signature) for the actual TypeScript
signature you author against; when the two disagree, the .d.ts wins. A
namespace in the Lua docs can be absent from TypeScript because it is not a
scriptable runtime module at all — Defold has no input namespace, for example;
input arrives through the on_input hook, not a polling API.
| Lua idiom | TypeScript surface |
|---|---|
function init(self) … end |
defineScript({ init() { … } }) |
obj:method(a) |
obj.method(a) (method form) |
local x, y = f() (multi-return) |
const [x, y] = f() / LuaMultiReturn |
hash("player") |
hash("player"), or a pre-hashed Hash id |
msg.post("#comp", "msg", {}) |
msg.post("#comp", "msg", {}) |
Fetch upstream on demand (gitignored, not a submodule)
When you need the engine's own API data or source, pull it on demand into a
gitignored path — do not add a defold/defold git submodule. A submodule is
a multi-GB tree pinned to one engine SHA that every clone then carries; the API
data you actually need is the few-hundred-KB ref-doc.zip the type generator
already consumes, cached at ~/.cache/defold-typescript/ref-doc/<version>/
(DEFOLD_TYPESCRIPT_CACHE overrides the cache root). Read the cached
ref-doc.zip there, or for the rare source-reading case shallow-clone
defold/defold into a gitignored dir. This is a cached download, not a
submodule — the on-demand fetch keeps the repo small.
The verification loop
Once converted, prove it compiles before handing it back: write the snippet, run
build --json, and on ok: false read the error span, fix the source, and
rebuild. This is the same loop as Fix the Lua output; the
script lifecycle page covers which hooks and which
self typing each script kind exposes. On ok: true the build envelope adds a
warnings array. It lists two kinds of issue, empty when clean, and the build
never fixes either for you:
- Sourceless outputs — a generated
.lua/.ts.*left without a TypeScript source (a deleted or renamed source), each naming the stale file and the source to restore. - Scene-resource mismatches — a
.go/.collectionwhosecomponent:points at a mesh source asset (.gltf/.glb/.dae) instead of a.modelcomponent. Bob builds this silently, but the game object fails at runtime, so the CLI surfaces at build time what only the editor would otherwise catch. Wrap the mesh in a.model(with amaterialsblock) and point the component at the.model.
Scaffold a project
Goal: create a new TypeScript surface — either a fresh project, or the TypeScript layer added to an existing Defold project.
Command (fresh project, new folder):
bunx @defold-typescript/cli@latest init my-game --json
Command (existing Defold project — run inside the folder that holds
game.project):
bunx @defold-typescript/cli@latest init . --json
init requires an explicit destination folder — pass a path to create it, or .
for the current folder. There is no implicit current-folder default, so an
invocation with no path fails fast (ok: false) rather than scaffolding into the
working directory by accident. init then detects whether a game.project is
already present at the destination and either scaffolds a whole new project or
adds the TypeScript surface alongside the existing one.
Returns:
{
"command": "init",
"ok": true,
"written": ["tsconfig.json", "src/main.ts", "..."],
"operations": [
{ "target": "tsconfig.json", "status": "merged" },
{ "target": "src/main.ts", "status": "skipped", "detail": "existing project sources present" }
]
}
On failure:
{ "command": "init", "ok": false, "error": "<message>" }
Reading ok: if ok is true, the scaffold succeeded and written lists
every file created or modified — use it to know what to open next. If ok is
false, stop and surface error; nothing was scaffolded.
Reading operations: each entry pairs a target with a status of
written (freshly created), merged (an existing tsconfig.json was preserved
and refreshed in place), or skipped (left untouched, with a detail reason —
e.g. your own src/main.ts or engine sources already present). Branch on
status when you need to distinguish a merge from a clobber.
Install the agent contract
Goal: drop an agent contract at the project root so any harness (or human) opening the repo finds the conventions and a pointer to the installed guide.
Command:
bunx @defold-typescript/cli@latest init-agents . --json
Like init, init-agents requires an explicit destination — pass a path or .
for the current folder; a missing path fails fast (ok: false). This writes two files. AGENTS.md carries a managed block delimited by HTML
comment markers; CLAUDE.md is the single line @AGENTS.md, re-exporting it.
Only the content between the markers is ever rewritten, so any notes you add
above or below the block survive re-runs untouched. If AGENTS.md already exists
without the markers, the block is appended after one blank line and your prior
content is left intact; a CLAUDE.md that already equals @AGENTS.md is left
byte-for-byte unchanged. The block is versionless — its pointers resolve to
node_modules/@defold-typescript/docs/llms.txt and llms-full.txt, which the
install swaps under the same paths — so the verb is safe to re-run any time.
init writes the same contract as part of its scaffold: a fresh
project gets AGENTS.md + CLAUDE.md created, but a plain re-init leaves an
existing contract untouched. Re-syncing the managed block on a project that
already has one is the --force path — init . --force (or the standalone
init-agents verb) refreshes the block after an upgrade.
Returns:
{ "command": "init-agents", "ok": true, "written": ["AGENTS.md", "CLAUDE.md"] }
On failure:
{ "command": "init-agents", "ok": false, "error": "<message>" }
Reading ok: if ok is true, written lists the files touched in order;
a re-run that changes nothing omits the untouched file. If ok is false, stop
and surface error; nothing was written.
Regenerate extension types
Goal: refresh the ambient TypeScript surface for native extensions after a
game.project [dependencies] change, so extension namespaces stay in sync with
the declared archives. This automates the workflow described in
Typing native extensions.
Command (run from the project root, after editing [dependencies]):
bunx @defold-typescript/cli resolve --json
resolve reads each declared extension's .script_api, regenerates the
gitignored .defold-types/extensions/ ambient surface, and rewrites its index
and package.json to exactly the declared set.
Returns the same one-shot envelope keyed command: "resolve", plus an
extensions array reporting provenance for each resolved dependency:
{
"command": "resolve",
"ok": true,
"written": [],
"materializedSurface": ".defold-types/extensions",
"extensions": [
{
"url": "<archive url>",
"provenance": "<cache | download>",
"namespaces": ["<namespace>"],
"scriptApiCount": 1,
"assetOnly": false,
"resolvedVersion": "<version>",
"pinStatus": "<unpinned | match | drift>"
}
]
}
On failure:
{ "command": "resolve", "ok": false, "error": "<message>" }
Reading ok: if ok is true, the extension surface is current —
materializedSurface is the regenerated surface directory
(.defold-types/extensions), and extensions records where each dependency
came from (provenance), how many .script_api files it contributed
(scriptApiCount; an assetOnly dependency contributes no types), and its pin
state (resolvedVersion/pinStatus). The written array is always empty for
resolve. If ok is false, surface error; the existing surface is left
untouched.
Add a script
Goal: add a new gameplay script to a TypeScript Defold project and attach it
so it runs. There is no add verb — the workflow composes ordinary file
creation with the shipped build verb, then a scene-file edit to wire the
compiled component.
1. Write the source. One Defold script per file under src/, exporting a
single lifecycle factory as default (never two in one file). The factory
decides the compiled kind:
| Source factory | Compiled artifact | Referenced by |
|---|---|---|
defineScript |
<name>.ts.script |
a game object (.go / .collection) as a component |
defineGuiScript |
<name>.ts.gui_script |
a GUI scene (.gui), as its Script property |
defineRenderScript |
<name>.ts.render_script |
the render pipeline (a .render file, set via game.project) |
A source that calls no factory emits a plain <name>.lua module to import
instead. Which hooks to export (init, update, fixed_update, on_message,
on_input, final, …) and how self is typed are covered in
Script lifecycle.
2. Build (from the project root):
bunx @defold-typescript/cli build --json
Or, if a watch --json is already running, just save the
file and read its rebuild event instead of invoking build.
Returns the one-shot envelope keyed command: "build", plus the build
context fields:
{
"command": "build",
"ok": true,
"written": ["src/<name>.ts.script", "..."],
"defoldVersion": "<version>",
"defoldChannel": "<stable | beta | alpha>",
"apiSurface": "<surface id>",
"materializedSurface": "<path | null>"
}
On failure:
{ "command": "build", "ok": false, "error": "<message>" }
Reading ok: if ok is true, the script transpiled — written lists the
emitted artifact (.ts.script, .ts.gui_script, or .ts.render_script) to
attach next; defoldVersion and apiSurface record which API surface it was
built against; defoldChannel records the resolved release channel (stable
unless pinned or passed via --channel; it does not yet change which surface is
fetched). If ok is false, the build failed — surface error and follow
Fix the Lua output.
What build writes. Each .ts source under src/ produces exactly one
output in the Defold project tree — a script component or a <name>.lua module,
per the table above. import is rewritten to require("<module>") resolving
against the emitted .lua, so a shared module must be built for its require to
resolve at runtime. Two runtime modules are synthesized at the output root on
demand: lualib_bundle.lua (when a source uses a TS runtime helper like
Object.keys or spread) and defold_typescript_timers.lua (when timers are
used).
Who creates these: TypeScriptToLua (TSTL)
produces the Lua content in memory; the CLI writes the files, choosing the
.ts.script / .ts.gui_script / .ts.render_script / .lua name and location.
TSTL never touches disk — that is why the outputs carry Defold-correct extensions
instead of plain .lua. Treat the --json written array as the authoritative
list of what landed; do not infer paths.
3. Attach the compiled script. Building only produces the artifact; nothing
runs until a scene references it as a component. Scene files (.go,
.collection, .gui, .render) reference the compiled artifact, never the
.ts source. A game object references a .ts.script through a components { … }
entry naming its project-root-absolute path:
components {
id: "player"
component: "/src/player.ts.script"
}
In a standalone .go the block appears verbatim. When the game object is
embedded in a .collection, Defold stores the same block as an escaped string
inside an embedded_instances { data: "…" } entry — the form to write when
editing the file directly. The platformer example shows this in
game/player.collection:
embedded_instances {
id: "player"
data: "components {\n"
" id: \"player\"\n"
" component: \"/src/player.ts.script\"\n"
...
"}\n"
...
}
A .ts.gui_script is referenced by a .gui scene's Script property and a
.ts.render_script by the render pipeline, the same compiled-artifact rule.
4. Verify the attachment. Two checks confirm the script is wired in:
-
Grep the scene files for the compiled name:
grep -rl "player.ts.script" --include="*.go" --include="*.collection" --include="*.gui" .A hit means a scene references it; no hit means it is orphaned and will not run.
-
Defold compiles only reachable resources, so after a Defold build (the CLI's
defold buildsubcommand) an attached script producesbuild/default/src/player.ts.scriptc— the path mirrors the source — while an orphaned script never appears underbuild/default/at all. (The_generated_*artifacts there are inlined or asset-derived resources — embedded game objects, components, textures — never referenced script files, which keep their source path.)
Narrow engine callback payloads
Goal: type an engine callback's untyped payload without hand-rolling typeof/in/as.
Rule for clankers: when an engine callback hands back an untyped or unknown
payload that is discriminated by a separate id/event argument, reach for the
provided declaration-only guard before reaching for typeof/in/as.
TypeScript cannot correlate two independent parameters, and Defold's ids/event
constants are pre-hashed Hash values or branded numbers — neither works as a
discriminated-union tag. Each guard below re-introduces the discriminant at the
use site. They are all declaration-only: the transpiler lowers each call to
its runtime comparison, so there is no runtime Lua symbol and no import to
add.
on_message — narrow one message with isMessage, or route many with onMessage:
export default defineScript({
on_message(self, message_id, message) {
if (isMessage(message_id, message, "contact_point_response")) {
// message narrowed to the contact_point_response payload — no cast.
print(message.distance);
}
},
});
window.set_listener — narrow the callback's data with isWindowEvent:
window.set_listener((self, event, data) => {
if (isWindowEvent(event, data, window.WINDOW_EVENT_RESIZED)) {
// data narrowed to { width: number; height: number } — no cast.
print(data.width, data.height);
}
});
An unknown id/event constant is a compile error, so the guard also catches typos.
The full narrowing reference, including onMessage's multi-message dispatcher,
lives in Typed messages
and the window.set_listener gotcha.
Fix the Lua output
Goal: recover from a transpile failure reported by build or watch.
Command (re-run after each source fix):
bunx @defold-typescript/cli build --json
On a transpile failure the one-shot build --json envelope carries the message:
{ "command": "build", "ok": false, "error": "<message>" }
Under a long-lived watch --json, the same failure arrives as an NDJSON event on
stdout (one JSON object per line) keyed command: "watch":
{ "command": "watch", "event": "rebuild", "ok": false, "error": "<message>" }
The first build emits event: "build"; each later rebuild emits
event: "rebuild". build and the transpile-diagnostics pass share one
diagnostic run, so the error names the offending source span.
Reading ok: while ok is false, read error for the failing span,
then fix the source and rebuild. Two pages route the fix:
TypeScript gotchas for the runtime-semantics traps
that compile clean but surprise under Lua, and
Transpile diagnostics for what the diagnostic pass
surfaces. Repeat until ok is true, then read written as in
Add a script.
Drive the engine build
Goal: once the TypeScript surface transpiles clean, run the actual Defold
engine build headlessly — resolve native dependencies, compile the project, or
produce a platform bundle — without opening the editor. This is the autonomous
phase after the type and
transpile loop: the .ts sources are already emitted as
Defold artifacts, and now bob (the engine's
command-line builder) turns the project tree into engine output.
Commands (run from the project root):
bunx @defold-typescript/cli defold resolve --json # fetch native extension deps
bunx @defold-typescript/cli defold build --json # compile to build/default
bunx @defold-typescript/cli defold bundle --json # produce a platform bundle
Each defold <sub> verb shells out to bob and, under --json, keeps stdout to
exactly one JSON object — bob's own chatter is captured, not streamed, so a
line-reader can JSON.parse stdout deterministically. The envelope is:
{ "command": "defold", "subcommand": "build", "ok": true, "exitCode": 0, "output": "<bob tail>" }
On a non-zero bob exit:
{ "command": "defold", "subcommand": "build", "ok": false, "exitCode": 17, "error": "bob build exited with code 17", "output": "<bob tail>" }
output is a trimmed tail of bob's combined stdout/stderr, present for
diagnostics on both outcomes. Without --json, bob's output streams live to the
terminal instead and no JSON is written.
Reading ok / exitCode: branch on ok first — true means bob succeeded
and the CLI's exit code is 0. On ok: false, the CLI exit code is bob's
own exitCode (not a flat 1), so a caller can distinguish bob failures by
code; read output for the tail of what bob printed and error for the summary.
Preconditions — now satisfied autonomously: two setup steps that used to
require a human are handled for you. bob needs a Java runtime, and defold
resolves one in order (--java/DEFOLD_JAVA override → java on PATH → the
installed editor's bundled JDK), so a machine with only the Defold editor
installed builds with no separate JDK. bob itself (bob.jar) is auto-downloaded
to the cache on first use and reused thereafter. The one precondition still
required is network egress: the first defold resolve/build fetches
bob.jar and any native extension archives, so an air-gapped run fails until the
cache is warmed.
Combine components on a game object
Goal: put several components — scripts, a sprite, a collision object — on one game object and let them cooperate.
One .go (or an embedded_instances block inside a .collection) lists each
component under its own id. A script reaches a sibling component on the same
object by that #-prefixed id — no path is needed because they share the object.
The platformer's game/player.collection
embeds the player script next to its sprite and collision object exactly this
way; the Add a script runbook shows the embedded_instances
form to write when editing the scene file directly.
import { defineScript } from "@defold-typescript/types";
export default defineScript({
on_input(self, action_id) {
if (action_id == hash("jump")) {
// "#" addresses a sibling component on this same game object.
msg.post("#animator", "play_animation", { id: hash("jump") });
}
},
});
Confirm each id names a real component and the snippet builds — see Verify against the real API surface.
Spawn objects with a factory
Goal: create new game objects at runtime from a prototype.
Add a .factory component to a game object and point it at the prototype .go.
At runtime, factory.create(url, position?, rotation?, properties?, scale?)
returns the spawned object's id as a Hash. The fourth argument, properties,
seeds the new script's self before its init runs, so each spawned instance
can start with its own state.
import { defineScript } from "@defold-typescript/types";
export default defineScript({
init() {
// properties (4th arg) seed the spawned script's self before its init runs.
const enemy = factory.create("#enemyfactory", undefined, undefined, {
health: 100,
});
return { lastSpawned: enemy };
},
});
Confirm factory.create against
node_modules/@defold-typescript/types/generated/factory.d.ts and build the
snippet — see Verify against the real API surface.
Spawn a hierarchy with a collection factory
Goal: spawn a whole tree of game objects in one call.
A .collectionfactory component points at a .collection. Unlike factory,
collectionfactory.create(url, position?, rotation?, properties?, scale?) returns
a LuaMap<Hash, Hash> mapping each prototype id in the collection to the runtime
id it was spawned as. Read one spawned object out of the map with
.get(prototypeId), keyed by the /-prefixed path the collection gives it.
import { defineScript } from "@defold-typescript/types";
export default defineScript({
init() {
const ids = collectionfactory.create("#levelfactory");
// Read one spawned object out of the map by its prototype id.
const player = ids.get(hash("/player"));
return { player };
},
});
Confirm collectionfactory.create against
node_modules/@defold-typescript/types/generated/collectionfactory.d.ts and build
the snippet — see Verify against the real API surface.
Pass messages between components
Goal: send a message from one component to another and receive it typed.
msg.post(receiver, message_id, message?) addresses the receiver by a URL
string. Two forms cover most cases: "#component" reaches a sibling component on
the same game object, and "/object#component" reaches a named component on a
different object. The receiver handles it in on_message; to narrow the
untyped payload by message_id without typeof/as, reuse the guard from
Narrow engine callback payloads rather than
re-deriving it here.
import { defineScript } from "@defold-typescript/types";
export default defineScript({
update(self) {
// "#…" → sibling on this object; "/path#…" → a component on another object.
msg.post("#health", "damage", { amount: 10 });
msg.post("/enemy#ai", "alert");
},
});
Confirm each address resolves and the snippet builds — see Verify against the real API surface.
Address an object by URL
Goal: pick the right URL form for every msg.post / go.get / go.set /
msg.url call, and know when the socket: prefix is required.
A Defold URL is [socket:][path][#fragment] — the first part is optional. Inside
a single world (everything in your bootstrap collection) address by relative id,
absolute path, or sibling component, with no socket::
"camera"— a sibling instance by id, resolved relative to the current component."/camera"— the same instance by id, absolute from the current world's root."#health"— a sibling component on the same game object."/enemy#ai"— a named component on a different object in the same world.
The socket: segment is reserved for crossing into a collection-proxy-loaded
world: msg.post("level_a:/door#sensor", "open") reaches door#sensor in the
collection whose Name property is level_a. That value is the target
collection's Name, not the proxy component's id — and a collection's Name is
a field distinct from its folder, so a bare "main:…" copied from a Lua example
is fragile (it assumes your bootstrap collection happens to be named main).
The two-arg msg.url(socket, path, fragment) call is a runtime error — only
msg.url(), msg.url(urlstring), and the all-required three-arg form are
supported. The tightened overloads in msg-overloads.d.ts reject the two-arg
form at compile time, so the typings are the warning.
Confirm every address resolves and the snippet builds — see Verify against the real API surface. For the full address grammar and the cross-world proxy case, follow up with the TypeScript gotchas URL addressing entry.
Where script state lives
Goal: decide whether a piece of mutable state belongs on self or in a
module.
Per-instance state belongs on the typed self: init's returned object is
copied onto self for that component instance, so two objects running the same
script keep independent values. A module-level let/const behaves differently,
and the build shows how — this source:
import { defineScript } from "@defold-typescript/types";
// Module-level: one value, not per-instance.
let spawnCount = 0;
export default defineScript({
init() {
// Per-instance: copied onto this component's self.
return { health: 100 };
},
update(self) {
self.health -= 1;
spawnCount += 1;
},
});
compiles spawnCount to a single Lua module local evaluated once, while
health is assigned onto each instance's self:
local spawnCount = 0
function init(self)
-- self.health is set per instance
end
function update(____self)
____self.health = ____self.health - 1
spawnCount = spawnCount + 1
end
Because Defold loads each module once via require and caches it, that module
local is shared by every component instance that requires the module — it is
not per-instance state. Reach for a module local only for constants or a
deliberately-shared singleton; keep anything each instance must own its own copy
of on self. The full self typing model — how init infers it and how
properties seed it — is in Script lifecycle.
Above a module local sits one wider tier: a VM-global declared with declare global. The declaration emits no Lua; a use site lowers to a bare Lua global
shared across the entire VM — no require, no module scope:
function ____exports.bump()
FOO = FOO + 1
return FOO
end
No local FOO and no ____exports. prefix — FOO is raw VM-wide state, broader
than a cached module local. Reach for it only for genuine engine/Lua globals;
prefer a module singleton for app state. The full four-tier treatment — per-instance
self, shared module local, cross-script module singleton, and VM-global — is in
Where script state lives.