SKILL DETAIL
defold-core
gamedev-skills/awesome-gamedev-agent-skills/defold-core
Build and debug the core of a Defold game in Lua: game.project settings, collections, game objects and components, script lifecycle and self state, go.property, input focus and bindings, URLs and message passing, factories, collection factories and collection proxies, plus building and smoke-running with Bob. Use when a project has game.project or .collection, .go, .script, .gui_script or .input_binding files, or the request mentions Defold, msg.post, on_message, on_input, acquire_input_focus, factory.create, collection proxy, bob.jar, or a Defold build error.
Installation
npx skills add https://github.com/gamedev-skills/awesome-gamedev-agent-skills --skill defold-core
Skill-Dateien
SKILL.md
Zuletzt synchronisiert · 25.10.2026
agents/openai.yaml›
interface:
display_name: "Defold Core"
short_description: "Build and debug Defold games in Lua with Bob checks"
default_prompt: "Use $defold-core to add or fix this feature in my Defold game and verify it builds and runs."
references/build-and-run.md›
# Build and run with Bob (Defold 1.14.0)
Bob (`bob.jar`) is Defold's command-line builder. Everything here was run with Bob 1.14.0
(sha1 `d295ed4edff2e49072719835713e625f409849f1`) on Linux x86_64; commands, flags and the
error messages quoted are from those runs or from the official
[Bob manual](https://defold.com/manuals/bob/) and
[automated testing manual](https://defold.com/manuals/automated-testing/).
## Get the tools
- **Java:** Bob 1.14.0 needs OpenJDK 25. On Java 21 it fails at once with
`UnsupportedClassVersionError … class file version 69.0`.
- **bob.jar:** use the release the project is built with. Current stable release info is at
[d.defold.com/stable/info.json](https://d.defold.com/stable/info.json) (version and sha1).
Download by version from the GitHub release assets
(`https://github.com/defold/defold/releases/download/1.14.0/bob.jar`) or by sha1
(`https://d.defold.com/archive/stable/<sha1>/bob/bob.jar`).
- **Check it:** `java -jar bob.jar --version` prints the version, sha1 and channel.
## Commands
Bob runs the listed commands in order: `resolve` (fetch library dependencies into
`.internal/lib/`), `clean`, `distclean`, `build`, `bundle`.
```sh
# Build only: compiles everything reachable from game.project into build/default
java -jar bob.jar --root . --variant headless --archive resolve build
# Desktop bundle (release)
java -jar bob.jar --root . --archive --platform x86_64-linux --variant release \
--bundle-output ../dist resolve distclean build bundle
# Machine-readable report of what was built
java -jar bob.jar --root . --archive --build-report-json ../report.json resolve build
```
- Platforms: `x86_64-linux`, `arm64-linux`, `x86_64-win32`, `x86_64-macos`, `arm64-macos`,
`arm64-ios`, `arm64_sim-ios`, `armv7-android`, `wasm-web`.
- Variants: `debug` (default), `release`, `headless` (null graphics device).
- `bundle` needs `--archive` and `--platform`. `--bundle-output` must be **outside** the
project's `build/` folder, or Bob stops with `Folder '…/build' in the project folder can't
be used for bundling as this folder is reserved for Defold build system`.
- `--settings file.settings` overlays `game.project` keys (several files apply left to
right). Use it for test bootstraps instead of editing `game.project`.
- The bundle lands in a folder named after `project.title`, with the executable named after
the title without spaces (`Defold Core Example/DefoldCoreExample.x86_64`).
- Bob exits 1 on a build error. The useful line starts with `ERROR`; Java stack traces follow
as `SEVERE` lines. See `file-formats.md` for tracing errors that name a generated file.
## Smoke test a running game
A build proves the resources compile, not that scripts run. Give the project a test bootstrap
that loads the real main collection through a proxy, runs it for a while, prints a marker
and exits. These three files and the commands below were run as shown.
`test/smoke.settings`:
```ini
[bootstrap]
main_collection = /test/smoke.collectionc
```
`test/smoke.collection`:
```text
name: "smoke"
embedded_instances {
id: "smoke"
data: "components {\n"
" id: \"smoke\"\n"
" component: \"/test/smoke.script\"\n"
"}\n"
"embedded_components {\n"
" id: \"game\"\n"
" type: \"collectionproxy\"\n"
" data: \"collection: \\\"/main/main.collection\\\"\\n\"\n"
" \"\"\n"
"}\n"
""
}
scale_along_z: 0
```
`test/smoke.script`:
```lua
-- Smoke-test bootstrap: loads the real main collection, lets it run, then exits.
go.property("seconds", 2)
local PROXY_LOADED = hash("proxy_loaded")
function init(self)
msg.post(".", "acquire_input_focus")
msg.post("#game", "load")
self.time = 0
end
function update(self, dt)
self.time = self.time + dt
if self.time >= self.seconds then
print("SMOKE done")
sys.exit(0)
end
end
function on_message(self, message_id, message, sender)
if message_id == PROXY_LOADED then
msg.post(sender, "init")
msg.post(sender, "enable")
end
end
```
The main collection's `name` must differ from `smoke`, since both worlds are loaded at once.
```sh
java -jar bob.jar --root . --settings test/smoke.settings --platform x86_64-linux \
--variant headless --archive --bundle-output ../smoke-bundle resolve build bundle
timeout 30 "../smoke-bundle/Defold Core Example/DefoldCoreExample.x86_64" > run.log 2>&1
grep -q "SMOKE done" run.log && ! grep -q "^ERROR:" run.log && echo PASS
```
Judge the run by the log, not only the exit status:
- A Lua error in a callback prints `ERROR:SCRIPT: main/x.script:8: attempt to index …` with a
traceback and the engine **keeps running**: without a timeout the test hangs, and with
one it ends with the timeout's status (124 for GNU `timeout`).
- A main collection that cannot be created (for example a `require` that fails at load)
exits 1 after `ERROR:GAMEOBJECT: Could not instantiate game object from prototype …`.
- Bad message targets and proxy clashes log `ERROR:GAMEOBJECT:` lines but do not stop the
game, so check for them explicitly.
- For many assertions, print one structured line per result (the automated testing manual
suggests a prefix such as `TEST {json}`) and fail the run on a missing final line.
Without a bundle, the headless engine binary can run a plain build from the project root
(it loads `build/default/game.dmanifest`). The official manuals do not document this binary;
it is published next to `bob.jar` as
`https://d.defold.com/archive/stable/<sha1>/engine/x86_64-linux/dmengine_headless`. Prefer
the headless bundle above when you need a documented path.
## Ports and parallel runs
A debug or headless engine starts an engine service on port 8001, a log server on a random
port and the Remotery profiler (`ws://127.0.0.1:17815`). A second engine started at the same
time logs `ERROR:PROFILER: Failed to initialize Remotery`. Run one engine per test job, always
under a timeout, and make sure it has exited before the next run.
## Beyond the core
The headless engine has no input devices and draws nothing, so check input, rendering and
layout in the editor (Project ▸ Build) or a desktop bundle. For the next subsystems, read the
official manuals:
- GUI scenes and `.gui_script`: [GUI](https://defold.com/manuals/gui/) — a GUI script uses
`gui.*` functions instead of `go.*` and acquires input focus the same way.
- Sprites and flipbooks: [Sprite](https://defold.com/manuals/sprite/) and
[Atlas](https://defold.com/manuals/atlas/); play an animation with
`sprite.play_flipbook("#sprite", "run")`.
- Tilemaps: [Tilemap](https://defold.com/manuals/tilemap/) and
[Tile source](https://defold.com/manuals/tilesource/).
- Collision objects and physics messages: [Physics](https://defold.com/manuals/physics/);
with a fixed timestep, act on physics in `fixed_update`.
- Engine-agnostic design for these topics: the related skills listed in `SKILL.md`.
references/file-formats.md›
# Defold file formats (1.14.0)
Every file below comes from one small project that Bob 1.14.0 built (`--variant headless`) and
the 1.14.0 headless engine ran: a player that moves and fires bullets from a factory, a loader
that spawns a wave with a collection factory and loads a level through a collection proxy.
Copy these shapes when you must write editor files by hand; when the editor is available,
let it write them.
The editor files are protobuf text format. Fields not shown keep their defaults, `#` starts a
comment, and the field order the editor writes does not matter to Bob. The schemas are the
`*_ddf.proto` files in the Defold engine source.
```text
game.project
input/game.input_binding
main/main.collection main/player.go main/player.script
main/main.atlas main/bullet.go main/bullet.script
main/loader.go main/loader.script main/check.script
main/wave.collection main/enemy.go
levels/level1.collection levels/level.script
```
## game.project
INI file, required, in the project root, named exactly `game.project`. Paths name the compiled
file (trailing `c`). Without the `[bootstrap]` key Defold looks for `/logic/main.collectionc`;
without `[input]` it uses `/input/game.input_bindingc`.
```ini
[project]
title = Defold Core Example
[bootstrap]
main_collection = /main/main.collectionc
[input]
game_binding = /input/game.input_bindingc
[physics]
use_fixed_timestep = 1
[engine]
fixed_update_frequency = 60
```
Library dependencies are listed as `dependencies#0 = https://…/archive.zip`, `dependencies#1 =
…`; Bob's `resolve` command fetches them into `.internal/lib/`. Read any setting at run time
with `sys.get_config_string("project.title")`.
## .input_binding
```text
key_trigger {
input: KEY_LEFT
action: "left"
}
key_trigger {
input: KEY_RIGHT
action: "right"
}
key_trigger {
input: KEY_SPACE
action: "fire"
}
mouse_trigger {
input: MOUSE_BUTTON_LEFT
action: "touch"
}
```
`/builtins/input/all.input_binding` exists for quick tests; a game should own its bindings.
## .collection
Instances of game object files, an embedded instance, a nested collection and a per-instance
script property override. `name` becomes the socket when the collection is loaded through a
proxy, so every proxied collection needs a unique name.
```text
name: "main"
instances {
id: "player"
prototype: "/main/player.go"
position {
x: 480.0
y: 120.0
}
component_properties {
id: "player" # id of the script component inside player.go
properties { id: "speed" value: "250.0" type: PROPERTY_TYPE_NUMBER }
}
}
instances {
id: "loader"
prototype: "/main/loader.go"
}
embedded_instances {
id: "check" # a game object defined inline; here it holds one script
data: "components {\n id: \"check\"\n component: \"/main/check.script\"\n}\n"
}
collection_instances {
id: "decor"
collection: "/main/wave.collection"
position {
x: 100.0
y: 400.0
}
}
scale_along_z: 0
```
Ids inside a nested collection are prefixed with its id: the `leader` object in `decor` is
`/decor/leader`. A child is attached with `children: "child_id"` on the parent instance:
```text
name: "wave"
instances {
id: "leader"
prototype: "/main/enemy.go"
children: "wingman"
}
instances {
id: "wingman"
prototype: "/main/enemy.go"
position {
x: 64.0
}
}
scale_along_z: 0
```
A collection that only holds embedded instances, as loaded by a proxy:
```text
name: "level1"
embedded_instances {
id: "level"
data: "components {\n"
" id: \"level\"\n"
" component: \"/levels/level.script\"\n"
"}\n"
""
}
scale_along_z: 0
```
## .go
`components` reference component files (a script here); `embedded_components` carry the
component's own file as an escaped string, split across adjacent quoted lines.
```text
components {
id: "player"
component: "/main/player.script"
}
embedded_components {
id: "sprite"
type: "sprite"
data: "default_animation: \"blob\"\n"
"material: \"/builtins/materials/sprite.material\"\n"
"textures {\n"
" sampler: \"texture_sampler\"\n"
" texture: \"/main/main.atlas\"\n"
"}\n"
""
}
embedded_components {
id: "bulletfactory"
type: "factory"
data: "prototype: \"/main/bullet.go\"\n"
""
}
```
The loader object holds the other two spawning components:
```text
components {
id: "loader"
component: "/main/loader.script"
}
embedded_components {
id: "levelproxy"
type: "collectionproxy"
data: "collection: \"/levels/level1.collection\"\n"
""
}
embedded_components {
id: "wavefactory"
type: "collectionfactory"
data: "prototype: \"/main/wave.collection\"\n"
""
}
```
Older sprite data uses `tile_set: "/main/main.atlas"` instead of the `textures` block; keep
whichever form the project already uses.
## .atlas
```text
images {
image: "/builtins/graphics/particle_blob.png"
}
animations {
id: "blob"
images {
image: "/builtins/graphics/particle_blob.png"
}
playback: PLAYBACK_LOOP_FORWARD
fps: 8
}
```
A sprite's `default_animation` names an image (file name without extension) or an animation
id in its atlas.
## Scripts
`bullet.script`, spawned by the player's factory with a `velocity` override:
```lua
go.property("velocity", vmath.vector3(0, 400, 0))
go.property("lifetime", 1.5)
function update(self, dt)
go.set_position(go.get_position() + self.velocity * dt)
self.lifetime = self.lifetime - dt
if self.lifetime <= 0 then
go.delete() -- deletes this game object at the end of the frame
end
end
```
`level.script`, inside the proxy-loaded world, reporting back to the main world:
```lua
local LEVEL_DONE = hash("level_done")
function init(self)
self.time = 0
print("level init", msg.url()) -- url: [level1:/level#level]
end
function update(self, dt)
self.time = self.time + dt
if self.time >= 0.5 and not self.reported then
self.reported = true
msg.post("main:/loader#loader", LEVEL_DONE, { seconds = self.time })
end
end
```
Shared code goes in `.lua` modules, loaded with a dotted path from the project root:
`local util = require "modules.util"` loads `/modules/util.lua`.
The run printed, in order: `wave leader hash: [/collection0/leader]`,
`level init url: [level1:/level#level]`, `level done after 0.5…`, `level unloaded`.
## Where build errors point
Bob names the source file and line 0 for most reference errors:
```text
ERROR: game.project:0: '…the bootstrap.main_collection '/main/main.collectio' can't be found'
ERROR: main/probe.script:0: '…the Lua module '/modules/nothere.lua' can't be found'
ERROR main/main.atlas:0 Resource 'main/images/missing.png' is not created
```
For an embedded component, Bob reports a generated file instead of the `.go` that holds it:
```text
ERROR: build/default/_generated_c1d664c47a440ab4.factory:0: '…the prototype '/main/missing.go' can't be found'
```
The generated name does not exist in the sources. Search them for the quoted path in the
message (`"/main/missing.go"`, written as `\"/main/missing.go\"` inside embedded data) or run
`scripts/check_defold_project.py`, which reports the `.go` file and line.
A missing resource property (`go.property("art", resource.atlas("/main/nope.atlas"))`) appears
only in the `SEVERE` stack trace lines: `Resource '/main/nope.atlas' referenced from script
resource property 'art' does not exist`.
scripts/check_defold_project.py›
#!/usr/bin/env python3
"""Check a Defold project for broken resource references. Read-only.
Run it after creating, moving or renaming collections, game objects, scripts,
atlases or other resources. It reads the project's text files and reports
references that Bob (the Defold build tool) will fail to resolve. It never
edits anything and needs no Defold editor, Java or bob.jar.
What it checks:
* ``game.project``: ``bootstrap.main_collection`` and ``input.game_binding``
must name an existing file and carry the compiled suffix (``.collectionc``,
``.input_bindingc``). When a key is absent, Defold's default
(``/logic/main.collectionc``, ``/input/game.input_bindingc``) is checked
instead. Other settings whose value is a root-absolute path, and the
``custom_resources``, ``bundle_resources`` and ``include_dirs`` lists, are
checked as warnings.
* Every quoted root-absolute resource path (``"/main/player.go"``, also inside
the escaped ``data:`` strings of embedded components) in collections, game
objects, atlases, GUI scenes, tile sources and the other Defold text resource
files. A string is treated as a path only when its last part has a file
extension and it contains no ``#`` or ``:``, so game object ids and URLs
are left alone.
* In ``.script``, ``.gui_script``, ``.render_script`` and ``.lua`` files:
literal ``require "a.b"`` modules (``/a/b.lua``) and literal
``resource.*("/path")`` properties; ``sys.load_resource("/path")`` is a
warning because it reads custom resources at run time. Comments are ignored.
* Paths whose letter case differs from the file on disk. This can work on
Windows and macOS and break on Linux and in CI.
Bob only builds files reachable from ``game.project``, so a broken reference
in a file nothing uses is reported as a warning, not an error.
Paths under ``/builtins/`` are skipped. Paths provided by library
dependencies are checked against the archives Bob fetched into
``.internal/lib/``; when a dependency has not been fetched, an unresolved path
is only a warning. Skipped while scanning: the ``build/`` folder, hidden files
and folders (``.internal/``, ``.git/``, ``.editor_settings``) and anything
listed in ``.defignore``.
Exit codes: 0 no errors, 1 errors (or warnings with --strict),
2 usage problem or no game.project found.
"""
from __future__ import annotations
import argparse
import hashlib
import json
import os
import re
import sys
import zipfile
from dataclasses import dataclass
from pathlib import Path
TOOL = "check_defold_project"
SCHEMA = 1
# Defold resource files stored as protobuf text; any quoted "/path.ext" in them is a resource.
RESOURCE_EXTS = {
".animationset", ".atlas", ".buffer", ".camera", ".collection", ".collectionfactory",
".collectionproxy", ".collisionobject", ".compute", ".convexshape", ".display_profiles",
".factory", ".font", ".gamepads", ".go", ".gui", ".input_binding", ".label", ".material",
".mesh", ".model", ".particlefx", ".render", ".render_target", ".sound", ".sprite",
".texture_profiles", ".tilemap", ".tilesource",
}
SCRIPT_EXTS = {".script", ".gui_script", ".render_script", ".lua"}
# Names Bob accepts in require() without a project file (engine-provided modules).
ENGINE_MODULES = {"socket", "string", "table", "math", "debug", "os", "io", "coroutine", "bit", "package"}
# Defaults from the Defold project settings manual and Bob's behaviour when a key is absent.
DEFAULT_MAIN_COLLECTION = "/logic/main.collectionc"
DEFAULT_GAME_BINDING = "/input/game.input_bindingc"
QUOTED_PATH_RE = re.compile(r'\\*"(/[^"\\\n]+)\\*"')
# Matched against masked code (string contents blanked); each ends at a string's opening quote.
REQUIRE_RE = re.compile(r"""(?<![\w.:])require\s*\(?\s*["']""")
RESOURCE_CALL_RE = re.compile(r"""\bresource\.[a-z_]+\s*\(\s*["']""")
LOAD_RESOURCE_RE = re.compile(r"""\bsys\.load_resource\s*\(\s*["']""")
EXT_RE = re.compile(r"\.[A-Za-z0-9_]+$")
@dataclass
class Issue:
severity: str # "error" or "warning"
code: str
file: str
line: int | None
message: str
fix: str
def as_dict(self) -> dict:
return {"code": self.code, "file": self.file, "line": self.line, "message": self.message, "fix": self.fix}
@dataclass
class Ref:
file: str # project-relative file that holds the reference
line: int
path: str # root-absolute path as written, e.g. "/main/player.go"
kind: str # resource, require, resource-property, load-resource, setting
severity: str # severity when the target is missing and the file is reachable
# ---------------------------------------------------------------- text helpers
def read_text(path: Path) -> str:
"""Read a text file, tolerating a UTF-8 BOM, CRLF line endings and bad bytes."""
text = path.read_bytes().decode("utf-8", errors="replace")
if text.startswith("\ufeff"):
text = text[1:]
return text.replace("\r\n", "\n").replace("\r", "\n")
def line_of(text: str, offset: int) -> int:
return text.count("\n", 0, offset) + 1
def parse_ini(text: str) -> dict[str, tuple[str, int]]:
"""Parse game.project into ``{"section.key": (value, line)}``."""
values: dict[str, tuple[str, int]] = {}
section = ""
for lineno, raw in enumerate(text.split("\n"), 1):
line = raw.strip()
if not line or line[0] in "#;":
continue
if line.startswith("[") and line.endswith("]"):
section = line[1:-1].strip()
continue
if "=" in line:
key, value = line.split("=", 1)
values[f"{section}.{key.strip()}"] = (value.strip(), lineno)
return values
def mask_lua(code: str) -> tuple[str, dict[int, str]]:
"""Blank out Lua comments and the contents of string literals.
Returns the masked code (same length and line breaks as the input) and a map
from the offset of each short string's opening quote to its contents, so a
call such as ``require "a.b"`` is found in code but never inside a string.
"""
out: list[str] = []
strings: dict[int, str] = {}
i, n = 0, len(code)
long_open = re.compile(r"\[(=*)\[")
def blank(text: str) -> str:
return re.sub(r"[^\n]", " ", text)
while i < n:
ch = code[i]
if code.startswith("--", i):
m = long_open.match(code, i + 2)
if m:
close = "]" + m.group(1) + "]"
end = code.find(close, m.end())
end = n if end < 0 else end + len(close)
else:
end = code.find("\n", i)
end = n if end < 0 else end
out.append(blank(code[i:end]))
i = end
elif ch in "\"'":
j = i + 1
while j < n and code[j] != ch and code[j] != "\n":
j += 2 if code[j] == "\\" else 1
strings[i] = code[i + 1 : j]
out.append(ch + blank(code[i + 1 : j]) + (ch if j < n and code[j] == ch else ""))
i = j + 1
elif ch == "[" and long_open.match(code, i):
m = long_open.match(code, i)
close = "]" + m.group(1) + "]"
end = code.find(close, m.end())
end = n if end < 0 else end + len(close)
out.append(blank(code[i:end]))
i = end
else:
out.append(ch)
i += 1
return "".join(out), strings
def defignore_regex(pattern: str) -> re.Pattern[str] | None:
"""Translate one .defignore line into a regex over project paths ("/a/b")."""
pattern = pattern.strip()
if not pattern.startswith("/"):
return None
parts = []
i = 0
while i < len(pattern):
if pattern.startswith("/**/", i):
parts.append("/(?:.*/)?")
i += 4
elif pattern.startswith("/**", i) and i + 3 == len(pattern):
parts.append("(?:/.*)?")
i += 3
elif pattern[i] == "*":
parts.append("[^/]*")
i += 1
elif pattern[i] == "?":
parts.append("[^/]")
i += 1
else:
parts.append(re.escape(pattern[i]))
i += 1
# A pattern matches the path itself or any of its parent folders.
return re.compile("".join(parts) + "(?:/.*)?$")
# ------------------------------------------------------------------- project
class Project:
def __init__(self, root: Path) -> None:
self.root = root
self.files: list[str] = [] # project-relative posix paths ("main/player.go")
self.file_set: set[str] = set()
self.dir_set: set[str] = set()
self.by_casefold: dict[str, str] = {}
self.ignore: list[re.Pattern[str]] = []
self.ignored_files: set[str] = set()
self.lib_files: set[str] = set() # files provided by fetched library archives
self.lib_dirs: set[str] = set() # top folders those archives provide
self.unfetched: list[str] = [] # dependency URLs with no fetched archive
self.settings: dict[str, tuple[str, int]] = {}
self.refs: list[Ref] = []
self.issues: list[Issue] = []
self.files_checked = 0
def add(self, severity: str, code: str, file: str, line: int | None, message: str, fix: str) -> None:
self.issues.append(Issue(severity, code, file, line, message, fix))
# -- scanning
def ignored(self, rel: str) -> bool:
return any(p.match("/" + rel) for p in self.ignore)
def read_defignore(self) -> None:
path = self.root / ".defignore"
if path.is_file():
for line in read_text(path).split("\n"):
regex = defignore_regex(line)
if regex:
self.ignore.append(regex)
def scan(self) -> None:
stack = [""]
seen: set[tuple[int, int]] = set()
while stack:
rel_dir = stack.pop()
full = self.root / rel_dir if rel_dir else self.root
try:
info = os.stat(full)
if info.st_ino:
key = (info.st_dev, info.st_ino)
if key in seen:
continue # symlink loop
seen.add(key)
entries = sorted(os.scandir(full), key=lambda e: e.name)
except OSError:
self.add("warning", "dir-unreadable", rel_dir or ".", None,
"Folder could not be read, so files in it were not checked.",
"Check the folder's permissions.")
continue
for entry in entries:
rel = f"{rel_dir}/{entry.name}" if rel_dir else entry.name
if entry.name.startswith(".") or (not rel_dir and entry.name == "build"):
continue
try:
is_dir = entry.is_dir()
is_file = entry.is_file()
except OSError:
continue
if is_dir:
if self.ignored(rel):
continue
self.dir_set.add(rel)
stack.append(rel)
elif is_file:
if self.ignored(rel):
self.ignored_files.add(rel)
continue
self.files.append(rel)
self.files.sort()
self.file_set = set(self.files)
for rel in self.files:
self.by_casefold.setdefault(rel.casefold(), rel)
# -- library dependencies
def dependency_urls(self) -> list[tuple[str, int]]:
urls: list[tuple[str, int]] = []
for key, (value, line) in self.settings.items():
if key == "project.dependencies" or key.startswith("project.dependencies#"):
urls.extend((u.strip(), line) for u in value.split(",") if u.strip())
return urls
def load_libraries(self) -> None:
lib_dir = self.root / ".internal" / "lib"
try:
archives = sorted(os.listdir(lib_dir))
except OSError:
archives = []
for url, _line in self.dependency_urls():
# Bob stores each fetched library as .internal/lib/<sha1 of the URL>-<etag>.zip.
prefix = hashlib.sha1(url.encode("utf-8")).hexdigest() + "-"
match = next((a for a in archives if a.startswith(prefix) and a.endswith(".zip")), None)
if not match or not self.read_library(lib_dir / match):
self.unfetched.append(url)
def read_library(self, archive: Path) -> bool:
try:
with zipfile.ZipFile(archive) as zf:
names = zf.namelist()
project = next((n for n in names if n.count("/") <= 1 and n.endswith("game.project")), None)
if project is None:
return False
base = project[: -len("game.project")]
text = zf.read(project).decode("utf-8", errors="replace")
except (OSError, zipfile.BadZipFile, KeyError):
return False
include = parse_ini(text.replace("\r\n", "\n")).get("library.include_dirs", ("", 0))[0]
dirs = [d.strip("/") for d in include.split() if d.strip("/")]
for name in names:
if not name.startswith(base) or name.endswith("/"):
continue
rel = name[len(base):]
if rel.split("/", 1)[0] in dirs:
self.lib_files.add(rel)
self.lib_dirs.update(dirs)
return True
# -- path lookup
def lookup(self, path: str) -> tuple[str, str | None]:
"""Return (status, actual) for a root-absolute path.
status is ok, builtin, case, ignored, maybe-library or missing.
"""
rel = path.lstrip("/")
if rel.startswith("builtins/"):
return "builtin", None
if rel in self.file_set or rel in self.lib_files:
return "ok", rel
if rel in self.ignored_files or self.ignored(rel):
return "ignored", None
actual = self.by_casefold.get(rel.casefold())
if actual:
return "case", actual
if self.unfetched and rel.split("/", 1)[0] not in self.dir_set:
return "maybe-library", None
return "missing", None
def similar(self, path: str) -> list[str]:
name = path.rsplit("/", 1)[-1].casefold()
return ["/" + f for f in self.files if f.rsplit("/", 1)[-1].casefold() == name][:3]
# -- reference collection
def collect_resource_refs(self, rel: str, text: str) -> None:
for m in QUOTED_PATH_RE.finditer(text):
path = m.group(1)
last = path.rsplit("/", 1)[-1]
if "#" in path or ":" in path or not EXT_RE.search(last):
continue
self.refs.append(Ref(rel, line_of(text, m.start()), path, "resource", "error"))
def collect_script_refs(self, rel: str, text: str) -> None:
code, strings = mask_lua(text)
def literal_args(regex: re.Pattern[str]):
for m in regex.finditer(code):
value = strings.get(m.end() - 1)
if value is not None:
yield m, value
for m, module in literal_args(REQUIRE_RE):
module = module.strip()
if module.startswith("builtins."):
continue
if "." not in module and "/" not in module:
if module in ENGINE_MODULES:
continue
severity = "warning" # a single name might be provided by the engine or an extension
else:
severity = "error"
path = "/" + module.replace(".", "/") + ".lua"
self.refs.append(Ref(rel, line_of(code, m.start()), path, "require", severity))
for m, path in literal_args(RESOURCE_CALL_RE):
if path.startswith("/"):
self.refs.append(Ref(rel, line_of(code, m.start()), path, "resource-property", "error"))
for m, path in literal_args(LOAD_RESOURCE_RE):
if path.startswith("/"):
self.refs.append(Ref(rel, line_of(code, m.start()), path, "load-resource", "warning"))
# -- game.project
def check_settings(self) -> list[str]:
"""Check game.project and return the root resources Bob builds from."""
roots: list[str] = []
for key, default, suffix, what in (
("bootstrap.main_collection", DEFAULT_MAIN_COLLECTION, ".collectionc", "main collection"),
("input.game_binding", DEFAULT_GAME_BINDING, ".input_bindingc", "input binding"),
):
value, line = self.settings.get(key, ("", 0))
written = bool(value)
value = value or default
note = "" if written else f" ({key} is not set, so Defold uses {default})"
if not value.endswith(suffix):
if value.endswith(suffix[:-1]):
fix = f"Write {value}c: game.project names the compiled file."
else:
fix = f"Point {key} at a {suffix[1:-1]} file, written with a trailing 'c' ({suffix})."
self.add("error", "setting-suffix", "game.project", line or None,
f"{key} = {value} must use the compiled suffix '{suffix}'.", fix)
source = value
else:
source = value[:-1]
status, actual = self.lookup(source)
if status in ("ok", "builtin"):
if actual:
roots.append(actual)
elif status == "case":
self.add("error", "path-case", "game.project", line or None,
f"{key} points to {source} but the file on disk is /{actual}{note}.",
"Match the letter case exactly; Linux builds are case-sensitive.")
elif status == "maybe-library":
self.add("warning", "path-unverified", "game.project", line or None,
f"{key} points to {source}, which is not in the project and may come "
f"from a library dependency that is not fetched{note}.",
"Fetch libraries (Project > Fetch Libraries, or bob 'resolve') and re-run.")
else:
self.add("error", "setting-missing", "game.project", line or None,
f"The {what} {source} does not exist{note}.",
self.missing_fix(source, f"Point {key} at an existing file"))
for key, (value, line) in sorted(self.settings.items()):
if key in ("bootstrap.main_collection", "input.game_binding"):
continue
if key in ("project.custom_resources", "project.bundle_resources"):
for item in (v.strip() for v in value.split(",")):
rel = item.strip("/")
if rel and rel not in self.file_set and rel not in self.dir_set:
self.add("warning", "setting-missing", "game.project", line,
f"{key} lists '{item}', which is not a file or folder in the project.",
"Fix the path (relative to the project root) or remove it from the list.")
continue
if key == "library.include_dirs":
for item in value.split():
if item.strip("/") not in self.dir_set:
self.add("warning", "setting-missing", "game.project", line,
f"library.include_dirs lists '{item}', which is not a folder in the project.",
"Name a top-level folder that this library shares, or remove it.")
continue
if not value.startswith("/") or "," in value or " " in value or not EXT_RE.search(value):
continue
candidates = [value[:-1], value] if value.endswith("c") else [value]
found = None
for candidate in candidates:
status, actual = self.lookup(candidate)
if status in ("ok", "builtin"):
found = actual or ""
break
if found is None:
status, actual = self.lookup(candidates[0])
if status == "case":
message = f"{key} = {value} differs in letter case from /{actual}."
elif status == "maybe-library":
continue
else:
message = f"{key} = {value} points to a file that does not exist."
self.add("warning", "setting-missing", "game.project", line, message,
self.missing_fix(candidates[0], f"Point {key} at an existing file"))
elif found:
roots.append(found)
return roots
def missing_fix(self, path: str, action: str) -> str:
similar = [s for s in self.similar(path) if s != path]
if similar:
return f"{action}; a file with that name exists at {', '.join(similar)}."
return f"{action}, or restore the file."
# -- run
def run(self) -> None:
self.settings = parse_ini(read_text(self.root / "game.project"))
self.read_defignore()
self.scan()
self.load_libraries()
self.files_checked = 1 # game.project
for rel in self.files:
suffix = Path(rel).suffix.lower()
if suffix not in RESOURCE_EXTS and suffix not in SCRIPT_EXTS:
continue
try:
text = read_text(self.root / rel)
except OSError:
self.add("warning", "file-unreadable", rel, None, "File could not be read, so it was not checked.",
"Check the file's permissions.")
continue
self.files_checked += 1
if suffix in RESOURCE_EXTS:
self.collect_resource_refs(rel, text)
else:
self.collect_script_refs(rel, text)
roots = self.check_settings()
reachable = self.reachable(roots)
for ref in self.refs:
self.report_ref(ref, ref.file in reachable)
if self.unfetched:
line = self.dependency_urls()[0][1]
self.add("warning", "dependencies-unfetched", "game.project", line,
f"{len(self.unfetched)} library dependenc{'y is' if len(self.unfetched) == 1 else 'ies are'} "
"not fetched, so paths they provide could not be checked.",
"Fetch libraries (Project > Fetch Libraries in the editor, or "
"'java -jar bob.jar resolve') and re-run this check.")
def reachable(self, roots: list[str]) -> set[str]:
edges: dict[str, list[str]] = {}
for ref in self.refs:
status, actual = self.lookup(ref.path)
if status == "ok" and actual in self.file_set:
edges.setdefault(ref.file, []).append(actual)
seen: set[str] = set()
stack = [r for r in roots if r in self.file_set]
while stack:
rel = stack.pop()
if rel in seen:
continue
seen.add(rel)
stack.extend(edges.get(rel, []))
return seen
def report_ref(self, ref: Ref, reachable: bool) -> None:
status, actual = self.lookup(ref.path)
if status in ("ok", "builtin"):
return
what = {
"resource": "Resource",
"require": "Lua module file",
"resource-property": "Resource property",
"load-resource": "sys.load_resource() file",
}[ref.kind]
severity = ref.severity if reachable else "warning"
unused = "" if reachable else " This file is not used by the build yet, so Bob does not report it."
if status == "case":
self.add(severity, "path-case", ref.file, ref.line,
f"{what} {ref.path} differs in letter case from /{actual}.{unused}",
"Match the letter case exactly; Linux builds and CI are case-sensitive.")
elif status == "ignored":
self.add(severity, "path-ignored", ref.file, ref.line,
f"{what} {ref.path} is excluded by .defignore, so Bob cannot find it.{unused}",
"Remove the matching line from .defignore or stop referencing the file.")
elif status == "maybe-library":
self.add("warning", "path-unverified", ref.file, ref.line,
f"{what} {ref.path} is not in the project and may come from a library "
"dependency that is not fetched.",
"Fetch libraries and re-run; if no library provides it, fix the path.")
elif ref.kind == "load-resource":
self.add("warning", "path-missing", ref.file, ref.line,
f"{what} {ref.path} does not exist.",
"Fix the path and list the file or its folder in project.custom_resources.")
else:
if ref.kind == "require" and ref.severity == "warning":
fix = (f"Add {ref.path}, or ignore this if the module is provided by the engine "
"or a native extension.")
elif ref.kind == "require":
fix = self.missing_fix(ref.path, "Fix the dotted module name (a.b means /a/b.lua)")
else:
fix = self.missing_fix(ref.path, "Fix the path")
self.add(severity, "path-missing", ref.file, ref.line,
f"{what} {ref.path} does not exist.{unused}", fix)
# ----------------------------------------------------------------------- CLI
def build_parser() -> argparse.ArgumentParser:
parser = argparse.ArgumentParser(
prog="check_defold_project.py",
description=(
"Read-only check of a Defold project for broken references: game.project main "
"collection and input binding (with the compiled 'c' suffix), quoted /paths in "
"collections, game objects, atlases, GUI and other resources, require() modules "
"and resource properties in scripts, letter-case mismatches, .defignore'd files "
"and paths from fetched library dependencies."
),
epilog=(
"example:\n"
" python scripts/check_defold_project.py path/to/my_game\n"
" python scripts/check_defold_project.py . --json --max-issues 20\n\n"
"exit codes: 0 no errors, 1 errors found (or warnings with --strict), "
"2 usage problem or no game.project"
),
formatter_class=argparse.RawDescriptionHelpFormatter,
)
parser.add_argument("project_dir", nargs="?", default=".", metavar="PROJECT_DIR",
help="folder that contains game.project (default: current folder)")
parser.add_argument("--json", action="store_true", help="print one JSON object instead of text")
parser.add_argument("--strict", action="store_true", help="exit 1 when there are warnings too")
parser.add_argument("--max-issues", type=int, default=50, metavar="N",
help="print at most N issues, errors first (default: 50; 0 = no limit)")
return parser
def main(argv: list[str] | None = None) -> int:
# A console or pipe that cannot encode a file name (Windows code pages) must not crash the report.
for stream in (sys.stdout, sys.stderr):
if hasattr(stream, "reconfigure"):
stream.reconfigure(errors="backslashreplace")
parser = build_parser()
args = parser.parse_args(argv)
if args.max_issues < 0:
parser.error("--max-issues must be 0 or more")
root = Path(args.project_dir).expanduser()
if not root.is_dir():
print(f"error: {args.project_dir} is not a folder", file=sys.stderr)
return 2
if not (root / "game.project").is_file():
print(f"error: {args.project_dir} is not a Defold project (no game.project found there)",
file=sys.stderr)
return 2
root = root.resolve()
project = Project(root)
project.run()
def sort_key(issue: Issue) -> tuple:
return (issue.file, issue.line or 0, issue.code, issue.message)
errors = sorted((i for i in project.issues if i.severity == "error"), key=sort_key)
warnings = sorted((i for i in project.issues if i.severity == "warning"), key=sort_key)
ordered = errors + warnings
shown = ordered[: args.max_issues] if args.max_issues else ordered
truncated = len(ordered) > len(shown)
if args.json:
payload = {
"tool": TOOL,
"schema": SCHEMA,
"root": root.as_posix(),
"files_checked": project.files_checked,
"error_count": len(errors),
"warning_count": len(warnings),
"errors": [i.as_dict() for i in shown if i.severity == "error"],
"warnings": [i.as_dict() for i in shown if i.severity == "warning"],
"truncated": truncated,
}
print(json.dumps(payload, indent=2))
else:
for issue in shown:
where = f"{issue.file}:{issue.line}" if issue.line else issue.file
print(f"{issue.severity.upper()} {where}: {issue.message}")
print(f" fix: {issue.fix}")
if truncated:
print(f"... and {len(ordered) - len(shown)} more")
print(f"Summary: {len(errors)} errors, {len(warnings)} warnings in {project.files_checked} files checked")
if errors or (args.strict and warnings):
return 1
return 0
if __name__ == "__main__":
sys.exit(main())
SKILL.md›
---
name: defold-core
description: >
Build and debug the core of a Defold game in Lua: game.project settings, collections, game
objects and components, script lifecycle and self state, go.property, input focus and
bindings, URLs and message passing, factories, collection factories and collection
proxies, plus building and smoke-running with Bob. Use when a project has game.project or
.collection, .go, .script, .gui_script or .input_binding files, or the request mentions
Defold, msg.post, on_message, on_input, acquire_input_focus, factory.create, collection
proxy, bob.jar, or a Defold build error.
---
# Defold Core
Structure, wire and verify a Defold game: editor resource files, the Lua script lifecycle,
addressing and messages, runtime spawning and world loading, and a Bob build-and-run check.
Examples target **Defold 1.14.0**; they were built with Bob 1.14.0 and run on the 1.14.0
headless engine. An existing project keeps its Defold version (see [Version](#version)).
## When to use
- Use when adding or fixing game objects, collections, scripts, input, messages, spawning or
level loading in a Defold project (`game.project` in the root, `.collection`, `.go`,
`.script`, `.gui_script` files), or when Bob reports a missing resource.
**When *not* to use:** plain Lua or LÖVE projects (`love2d-core`); GUI layout, sprites,
tilemaps and physics depth beyond the pointers in the references; engine-agnostic design
handled by the related skills below.
## Workflow
1. **Inspect first.** Read `game.project` (`[bootstrap] main_collection`, `[input]
game_binding`, `[project] dependencies`), the main collection, and the scripts near the
request. Follow the project's folder layout, id naming and message names; do not
reorganize. Note which Defold/Bob version CI or the team uses.
2. **Edit the right file type.** Game objects (`.go`) hold components; collections
(`.collection`) place game objects and other collections; logic goes in `.script`
(game objects), `.gui_script` (GUI) or shared `.lua` modules. Editor files are
protobuf text: keep their structure, and copy shapes from
`references/file-formats.md` instead of guessing fields.
3. **Check references.** Run `python scripts/check_defold_project.py <project-dir>` (path
relative to this skill folder; Python 3.10+, no Defold install needed). Fix every `ERROR`
(missing or wrong-case `/paths`, a `game.project` path without the compiled `c` suffix,
a `require` whose file does not exist). Warnings cover files nothing uses yet, unfetched
library dependencies and runtime-loaded files. Re-run until it exits 0.
4. **Build with Bob.** `java -jar bob.jar --root . --variant headless --archive resolve
build` needs JDK 25 and a `bob.jar` matching the project's Defold version. Exit 1 plus
an `ERROR:` line means a broken reference or a Lua syntax error (with file and line);
`references/file-formats.md` shows how to trace an error that names a generated file.
5. **Run it.** A build does not prove the game works. Run a headless bundle under a timeout
(`references/build-and-run.md`) and read the log: `ERROR:SCRIPT:` lines are Lua runtime
errors, `ERROR:GAMEOBJECT:` lines bad addresses or socket clashes. Require a line your
test prints when it finishes; the exit code alone proves nothing. The headless engine has
no keyboard or mouse, so check input changes in the editor or a desktop build, and say
what you could not verify.
## Patterns
### Project and resource references
`game.project` is an INI file in the project root. Resource paths everywhere are
root-absolute (`/main/player.go`). In `game.project` they name the **compiled** file, so the
source path gets a trailing `c`:
```ini
[bootstrap]
main_collection = /main/main.collectionc
[input]
game_binding = /input/game.input_bindingc
```
Bob builds only what is reachable from `game.project`; `/builtins/...` ships with the engine.
### Collections, game objects, components
A collection places instances and can override script properties per instance; a game object
lists file components and embedded components (escaped `data:` strings). Full verified
files are in `references/file-formats.md`.
```text
# main.collection (excerpt): place player.go and override its script's speed
instances {
id: "player"
prototype: "/main/player.go"
component_properties {
id: "player"
properties { id: "speed" value: "250.0" type: PROPERTY_TYPE_NUMBER }
}
}
```
### Script lifecycle, `self` and properties
```lua
go.property("speed", 200) -- top level only, literal default; editable per instance
local LEFT = hash("left") -- hash ids once, not every frame
local RIGHT = hash("right")
local FIRE = hash("fire")
function init(self)
msg.post(".", "acquire_input_focus") -- no focus, no on_input calls
self.held = {} -- per-instance state lives on self
end
function update(self, dt)
local dir = (self.held[RIGHT] and 1 or 0) - (self.held[LEFT] and 1 or 0)
if dir ~= 0 then
local pos = go.get_position()
pos.x = pos.x + dir * self.speed * dt -- dt-scaled: same speed at any frame rate
go.set_position(pos)
end
end
function on_input(self, action_id, action)
if action_id == LEFT or action_id == RIGHT then
if action.pressed then self.held[action_id] = true
elseif action.released then self.held[action_id] = nil end
return true -- consumed: listeners lower on the stack skip it
elseif action_id == FIRE and action.pressed then
factory.create("#bulletfactory", go.get_position(), nil,
{ velocity = vmath.vector3(0, 600, 0) })
return true
end
end
function final(self)
msg.post(".", "release_input_focus")
end
```
Callbacks: `init`, `final`, `fixed_update`, `update`, `late_update` (after every `update`,
before render), `on_message`, `on_input`, `on_reload`. Update order between game
objects is unspecified. `fixed_update` runs 0..N times per frame at `[engine]
fixed_update_frequency`; set `[physics] use_fixed_timestep = 1` too so physics steps at the
same rate, and put physics-driven code there. `self` is userdata: store
fields on it, but `pairs(self)` and `pprint(self)` do not work. Property types: number,
boolean, hash, `msg.url`, `vmath.vector3`/`vector4`, `vmath.quat`, resources such as
`resource.atlas("/main/main.atlas")`, and (since 1.13.2) strings.
### Input bindings and the input stack
`.input_binding` maps devices to action names (`key_trigger`, `mouse_trigger`, plus
gamepad, touch and text triggers). Every component that acquired focus sits on an input
stack; the last to acquire is on top and gets input first, and returning `true` from
`on_input` stops it there, which is how pause menus block gameplay input. Key actions report
`pressed` and `released`; track held keys yourself as above.
### Addressing and messages
A URL is `[socket:][path][#fragment]`: `"."` is this game object, `"#"` this component,
`"#sprite"` a sibling component, `"enemy#ai"` a relative object, `"/manager#controller"` an
absolute one, `"main:/loader#loader"` an object in another world. Collections create naming
scopes, so a string id is resolved relative to the sender's collection; a pre-hashed id is
always absolute.
```lua
msg.post("/manager#controller", "enemy_spotted", { id = go.get_id() })
function on_message(self, message_id, message, sender)
if message_id == hash("enemy_spotted") then
print("from", sender, "id", message.id)
end
end
```
Messages are queued and dispatched later in the frame; the table is capped at 2 KB. A bad
address logs `ERROR:GAMEOBJECT: Instance '/x' could not be found when dispatching message`.
### Factories and collection factories
```lua
-- factory component "#bulletfactory" -> one game object; properties override go.property values
local id = factory.create("#bulletfactory", pos, nil, { velocity = vmath.vector3(0, 600, 0) })
-- collectionfactory component "#wavefactory" -> table of collection ids -> new instance ids
local ids = collectionfactory.create("#wavefactory", vmath.vector3(480, 500, 0))
local leader = ids[hash("/leader")] -- e.g. hash: [/collection0/leader]
go.delete(ids, true) -- later: delete them and their children
```
Position and rotation default to the spawning object's. `go.delete()` takes effect at the
end of the frame. Per-instance properties for a collection factory are keyed by id:
`{ [hash("/leader")] = { hp = 5 } }`.
### Collection proxies (load another world)
```lua
-- excerpt from the loader script; the collection's "#levelproxy" component is a collectionproxy
function init(self)
msg.post(".", "acquire_input_focus") -- else the loaded world gets no input
msg.post("#levelproxy", "async_load") -- or "load"
end
function on_message(self, message_id, message, sender)
if message_id == hash("proxy_loaded") then
msg.post(sender, "init")
msg.post(sender, "enable")
elseif message_id == hash("level_done") then
msg.post("#levelproxy", "unload") -- disables and finalizes first
elseif message_id == hash("proxy_unloaded") then
-- safe to load the next level here
end
end
```
The loaded world's socket is the **`name:` of the loaded collection file**, so scripts in it
reply with `msg.post("main:/loader#loader", ...)` and its own URLs read
`level1:/level#level`. `set_time_step { factor = 0, mode = 0 }` pauses a proxy world.
## Version
`game.project` has no engine version key. Use the Defold editor or `bob.jar` version the
project is built with (CI often pins a release sha1), and `library.defold_min_version` when the
project is a library. `java -jar bob.jar --version` prints the version and sha1. Keep that
version; do not migrate unless asked.
## Pitfalls
- **Bob: `the bootstrap.main_collection '/main/main.collectio' can't be found`** → the
setting lacks the compiled suffix and Bob stripped the last letter. Write
`/main/main.collectionc` (same for `input.game_binding` → `.input_bindingc`).
- **Bob: `ERROR: build/default/_generated_….factory:0: the prototype '/main/x.go' can't be
found`** → an embedded component in some `.go` points at a missing file. Search the
sources for the quoted path (`/main/x.go`), not the generated name, or run the checker.
- **`on_input` never fires** → the object never sent `acquire_input_focus`, the action name
differs from the binding, `input.game_binding` points elsewhere, or a listener higher on
the stack returns `true`.
- **Input dead inside a proxy-loaded level** → the game object holding the collection proxy
did not acquire input focus.
- **`already a socket with the same name` and the proxy never sends `proxy_loaded`** → the
loaded collection's `name:` equals an already loaded world (often both are `"main"` or
`"default"`). Give every proxied collection a unique name.
- **`fixed_update` never runs** → `[engine] fixed_update_frequency` is 0 (default 60). For
physics also set `[physics] use_fixed_timestep = 1`, as the manual requires both.
- **`module 'modules.util' not found` at run time although Bob succeeded** → a `require` used
slashes (`require "modules/util"`). Use the dotted path from the project root,
`require "modules.util"` for `/modules/util.lua`.
- **`tostring(id)` prints `hash: [<unknown:…>]`** → hashes keep their source text only in
debug builds. Compare hashes, never their strings.
- **A second engine logs `Failed to initialize Remotery`, or a test run never ends** → the
debug engine opens an engine service (port 8001), a log server and a profiler port, and
waits forever without `sys.exit`. Run one engine at a time and always use a timeout.
## References
- `references/file-formats.md` — verified minimal `game.project`, `.collection`, `.go`,
`.atlas`, `.input_binding` and script files, and how to trace build errors back to a
source file; read before writing editor files by hand.
- `references/build-and-run.md` — JDK 25 and `bob.jar`, Bob commands, platforms and
variants, a headless smoke test with timeout and log checks, and pointers for GUI,
sprites, tilemaps and collision objects.
- `scripts/check_defold_project.py` — read-only reference checker; `--help` lists every check.
## Related skills
- `input-systems` — rebinding, gamepad support and buffering on top of Defold bindings.
- `game-ui-ux` — HUD and menu design to build with GUI scenes and `.gui_script`.
- `physics-tuning` — fixed-timestep and collision tuning for collision objects.
- `camera-systems` — follow/deadzone logic, typically applied in `late_update`.
- `save-systems` — save formats and slots; pair with Defold's `sys.save`/`sys.load`.
- `game-feel` — juice built from `go.animate` and timers.
- `platformer`, `level-design` — genre and layout guidance that binds to this skill.
- `love2d-core` — the other Lua engine in this catalog, for LÖVE projects.