> ## Documentation Index
> Fetch the complete documentation index at: https://docs.summerengine.com/llms.txt
> Use this file to discover all available pages before exploring further.

# Headless Scripting

> Run Summer from a shell with no window: bake navmeshes, generate collision and LODs, author resources, import assets, and export builds from a plain GDScript file.

Summer's engine binary runs without a window. Point it at a project and a script, and it
will execute that script with the whole engine available — physics, resources, importing,
exporting, the lot. This is how you automate work that has no reason to involve a person
clicking through the editor.

```bash theme={null}
/Applications/Summer.app/Contents/MacOS/Summer --headless --path /path/to/project -s res://task.gd
```

<Warning>
  **`--headless` produces no pixels. None.**

  There is no rendering. `viewport.get_texture()` returns a healthy-looking `ViewportTexture`
  with a plausible size, and then `get_image()` on it returns **null**:

  ```
  ERROR: Parameter "t" is null.
     at: texture_2d_get (servers/rendering/dummy/storage/texture_storage.h:106)
  ```

  Every readback route fails the same way: the root viewport, a `SubViewport` set to
  `UPDATE_ALWAYS`, calling `RenderingServer.force_draw()` first, and
  `RenderingServer.texture_2d_get()`. `--write-movie` does not rescue you either — under
  `--headless` it **crashes with signal 11**.

  If you do not null-check, `save_png()` is never reached, no file is written, and nothing
  reports a failure. Your script exits 0 and you believe it worked.

  **Any plan of the form "run headless and take a screenshot" is dead on arrival.** Headless
  can simulate. It cannot see.
</Warning>

## The script template

Copy this. It is the exact file used to verify the behaviour on this page.

```gdscript theme={null}
extends SceneTree

# Entry point for: Summer --headless --path <proj> -s res://task.gd
# _init() is the entry point. _run() is NEVER called and will hang the process.

func _init() -> void:
	var exit_code := 0
	print("[task] start")

	# Nodes added here are NOT in the tree until at least one frame passes.
	var n := Node3D.new()
	get_root().add_child(n)
	await process_frame          # required before physics / in-tree APIs
	print("[task] node in tree: ", n.is_inside_tree())

	# ... do work ...
	var ok := true
	if not ok:
		push_error("[task] FAILED: reason here")
		exit_code = 1

	print("[task] done")
	quit(exit_code)   # REQUIRED. Without it the process hangs forever.
```

<Warning>
  **`_init()` is the entry point, not `_run()`.**

  `_run()` belongs to `EditorScript`, which cannot be instantiated outside the editor. A
  script using `_run()` with `-s` **hangs forever and prints absolutely nothing** — no error,
  no warning, no output at all. `--quit-after` does not rescue it.

  This is the single easiest way to lose an afternoon. If your headless script produces no
  output and never returns, check this first.
</Warning>

Only two base classes work. Everything else hangs silently:

| Script shape                                                        | Result                                            |
| ------------------------------------------------------------------- | ------------------------------------------------- |
| `extends SceneTree` + `_init()`                                     | **Works**                                         |
| `extends MainLoop` + `_initialize()` / `_process()` / `_finalize()` | **Works** — return `true` from `_process` to quit |
| `extends SceneTree` + `_run()` only                                 | Hangs, zero output                                |
| `extends Node`, `extends Object`, `extends RefCounted`              | Hangs forever, zero output                        |

`@tool` on a `-s` script is a no-op — `Engine.is_editor_hint()` is `false` here.

## Exit codes lie

<Warning>
  **Only an explicit `quit(N)` produces a non-zero exit code — and a runtime error produces no
  exit code at all.**

  | What happened                         | Result                                           |
  | ------------------------------------- | ------------------------------------------------ |
  | `quit(0)`                             | exits 0                                          |
  | `quit(42)`                            | exits 42                                         |
  | GDScript **parse** error              | exits **0**                                      |
  | **Script file does not exist at all** | exits **0**                                      |
  | `push_error()`                        | exits **0**                                      |
  | GDScript **runtime** error            | **hangs forever. No exit, no crash, no timeout** |

  Two separate failure modes, and you need a defence against each.

  A typo in your script path exits 0, so CI reports success on a script that never ran. Guard
  against that by reading stderr.

  A runtime error — a null dereference, a division by zero, an out-of-bounds index — prints its
  error and then **sits there indefinitely**, because `quit()` was never reached. Reading stderr
  does not save you, because the process never returns to be inspected. **Every script you run
  unattended needs an external wall-clock timeout.**
</Warning>

Gate on stderr, not on `$?`. Streams are cleanly separated: `print()` goes to stdout;
`printerr()`, `push_error()`, `push_warning()` and all engine `ERROR:` / `WARNING:` lines
go to stderr. Grep for `SCRIPT ERROR`, `Parse Error`, `Can't load script`, and `ERROR:`.

**Check syntax before you run anything.** `--check-only` parses a script without executing
it, in well under a second — the cheapest possible gate, and it catches the parse errors that
would otherwise exit 0 and look like success:

```bash theme={null}
Summer --headless --path . --check-only -s res://task.gd
```

The exit code is still 0, so read stderr. But it costs almost nothing and it runs no code, so
it cannot hang.

<Warning>
  **Never pass `--quiet`.** It swallows your script's own `print()` output, so a script that
  ran correctly looks like a script that produced nothing.
</Warning>

One benign exception: `WARNING: ObjectDB instances leaked at exit` prints on **every** run,
including fully successful ones. Do not gate on it.

## Finding the binary

<Warning>
  The binary is **not** called `godot`. `godot --headless` fails on every user's machine
  because no such binary is installed.
</Warning>

On macOS the engine is at `/Applications/Summer.app/Contents/MacOS/Summer`. From inside a
running script, ask the engine directly rather than guessing:

```gdscript theme={null}
print(OS.get_executable_path())
# /Applications/Summer.app/Contents/MacOS/Summer
```

That path is the real executable, not the `.app` wrapper, so it is safe to re-invoke
directly for self-spawning work.

## What works

Verified by running each against the shipped binary.

<AccordionGroup>
  <Accordion title="Collision shapes from a mesh" icon="box">
    `create_trimesh_shape()` and `create_convex_shape()` both work and round-trip through
    `.tres` losslessly. A `BoxMesh` produces 36 face vec3s (12 triangles) trimesh and an
    8-point convex hull.

    Simplification is effective: a 32x16 sphere gives a 514-point convex hull, or **32 points**
    with `create_convex_shape(true, true)`.
  </Accordion>

  <Accordion title="Navmesh baking" icon="route">
    Use `NavigationServer3D.bake_from_source_geometry_data(nav_mesh, source_geometry)`.
    `NavigationServer2D` has the same method.

    Prefer building `NavigationMeshSourceGeometryData3D` procedurally with `add_faces()` /
    `add_mesh()`. Parsing a live scene tree also works, but only **after** a frame has passed —
    before that you get `The root node needs to be inside the SceneTree` and a silent zero-poly
    bake. Parsing visual meshes also triggers a real performance warning, because it reads
    mesh data back from the RenderingServer.

    Bakes save to `.tres` and reload with identical vertex and polygon counts.
  </Accordion>

  <Accordion title="LOD generation" icon="layers">
    `ImporterMesh` is instantiable outside the editor. In Summer the signature takes **three**
    arguments:

    ```gdscript theme={null}
    importer_mesh.generate_lods(normal_merge_angle, normal_split_angle, bone_transform_array)
    ```

    The two-argument form is a parse error. A 4,224-triangle sphere produced **9 LOD levels**
    down to 8 triangles in 3ms. `get_mesh()` returns an `ArrayMesh` that saves and reloads
    cleanly. Note `generate_shadow_mesh` is not exposed.
  </Accordion>

  <Accordion title="Animation authoring" icon="film">
    Value, bezier, method, position\_3d and rotation\_3d tracks can all be created
    programmatically, saved to `.tres`, and reloaded intact. Bezier handles, method names **and
    their argument arrays**, loop mode, step and length all survive exactly.
    `AnimationLibrary` round-trips too.

    Track type integers: `0` value, `1` position\_3d, `2` rotation\_3d, `5` method, `6` bezier.
  </Accordion>

  <Accordion title="TileSet, Theme, Shader, AudioBusLayout" icon="palette">
    All four author and round-trip through `.tres`.

    `TileSet` keeps atlas sources, physics layers, terrain sets, navigation layers, custom data
    layers and per-tile collision polygons. One gotcha: `add_physics_layer()`,
    `add_terrain_set()`, `add_navigation_layer()` and `add_custom_data_layer()` return **void**,
    not the new index, so `var i := ts.add_physics_layer()` is a parse error.

    `Shader` source survives byte-identical and uniforms introspect correctly — but this is
    **parsing, not GPU compilation**. The dummy renderer never compiles the shader, so headless
    will not catch driver-level compile errors.

    `AudioBusLayout` works with the Dummy audio driver; bus volumes, mutes and effects all
    persist.
  </Accordion>

  <Accordion title="Physics and raycasting" icon="crosshair">
    This is the real story: **headless can simulate, it just cannot see.** Physics is entirely
    CPU-side and completely unaffected by the dummy renderer.

    3D and 2D `intersect_ray`, `intersect_point` and `intersect_shape` all work, returning full
    hit dictionaries with position, normal, collider and RID. Rigid bodies actually fall under
    gravity.

    Prerequisite: `await physics_frame` after `add_child()`, or `direct_space_state` returns
    nothing.
  </Accordion>
</AccordionGroup>

## Importing assets

<Warning>
  **A file written directly to disk cannot be loaded until an import pass runs.**

  ```
  ERROR: No loader found for resource: res://raw.png (expected type: unknown)
     at: _load (core/io/resource_loader.cpp:358)
  ```

  This is the most common way agent-written assets fail. `ResourceLoader.exists()` returns
  `false` and `load()` returns null.
</Warning>

**The best fix is to need no import at all.** Two routes avoid this entirely, and both are
described under [shipping assets](#do-not-ship-a-game-that-depends-on-raw-source-files)
below:

* **`ResourceSaver.save()` to `.tres` or `.res`** — no import pass, no editor, no `.godot/`.
* **Write a `.dds` or a `.po`** — the formats that load raw and ship as-is.

Prefer those. They are simpler, faster, and free of the hazard below.

When you do need the importer — it is the only shell route that generates `.import`
sidecars — run the import pass:

```bash theme={null}
/Applications/Summer.app/Contents/MacOS/Summer --headless --path /path/to/project --import
```

This writes the `.import` file next to your asset and the compiled `.ctex` into
`.godot/imported/`. Afterwards `load()` returns a `CompressedTexture2D` and everything
behaves normally.

<Warning>
  **Never run `--import` against a project whose editor is open.**

  `--import` is a full editor boot. It starts the local API server, which takes the machine's
  engine binding and mints a fresh auth token — so a client that believes it is talking to the
  open project can have its writes **land in the project you imported instead**, with no error.
  When the import exits, it leaves the binding pointing at a dead port, and the user's editor
  stays unreachable until something restarts.

  See [the binding hazard](#what-does-not-work) below for the full mechanism. Plain
  `--headless -s script.gd` is unaffected and completely safe.
</Warning>

<Note>
  **`-s` script runs never create `.godot/` at all.** Only `--import` (or a normal editor or
  game run) builds the import database. If you have only ever run scripts, the directory does
  not exist yet.

  `--import` costs roughly 5 seconds even with nothing to do, about 15x a trivial script run.
  It is a **full editor boot** — it loads the editor layout and starts the local API server,
  with the binding consequences described above. Do not run it speculatively.
</Note>

<Warning>
  **A long-lived headless editor does not notice files created after it booted.**

  `--headless --editor` is not a filesystem watcher. Its rescan is driven by the application
  receiving focus, and a headless process never gets a focus event. Assets added twelve seconds
  after boot were still unimported forty-five seconds later — none of them loadable.

  So a headless editor kept alive as a worker serves a **boot-time snapshot of the project,
  silently, for as long as it runs.** Either keep the process short-lived and start a fresh one
  per task, or call `EditorInterface.get_resource_filesystem()` and rescan explicitly. See
  [Editor Plugins](/extending/editor-plugins) for the working rescan sequence — the naive one
  is a silent no-op while a scan is already in flight.

  This is also the deeper cause of the familiar "the AI wrote my asset but the editor never
  registered it" problem.
</Warning>

### Do not ship a game that depends on raw source files

There is a tempting shortcut: `Image.load_from_file("res://raw.png")` reads the file
directly and needs no import pass at all. Equivalents exist for audio
(`AudioStreamOggVorbis.load_from_file`), fonts (`FontFile.load_dynamic_font`) and models
(`GLTFDocument.append_from_file`).

<Warning>
  **Exporting strips your raw source files. Every one of these loaders returns null in the
  shipped game.**

  The importer's *product* ships — the `.ctex`, the `.translation`, the imported scene — and
  ordinary `load()` finds it. The original `.png`, `.ogg`, `.ttf` or `.glb` does not ship at
  all, so anything reading the raw file fails.

  ```
  # inside the exported pack
  res://assets/raw.png  file_exists=false
      Image.load_from_file  =>  <null>
      ResourceLoader.load   =>  CompressedTexture2D    # the .ctex, works fine
  ```

  `FontFile.load_dynamic_font` is the worst case: when the file is missing it returns
  **error code 0 with zero faces**. Success, and no font. Nothing fails at authoring time.

  Use these loaders for tooling and one-off scripts. Do not build a shipping game on them.
</Warning>

<Warning>
  **`--main-pack` is silently ignored if the current directory contains a `project.godot`.**

  The local project wins, the pack is never mounted, and nothing warns you. So the obvious way
  to check whether your exported game still works — run it against the pack from your project
  folder — **reports a false pass**, because it is reading your development files the whole
  time.

  Identical command, only the working directory differs:

  ```bash theme={null}
  cd my-project && Summer --headless --main-pack build/game.pck -s res://probe.gd
  #   file_exists=true    Image.load_from_file => Image      <- reading the DEV project

  cd /tmp/empty && Summer --headless --main-pack ~/my-project/build/game.pck -s res://probe.gd
  #   file_exists=false   Image.load_from_file => <null>     <- the truth
  ```

  **Always verify a pack from a directory that has no `project.godot` in it.**
</Warning>

Three routes that survive export:

1. **Author a real resource and save it.** `ResourceSaver.save()` to `.tres` or `.res` needs
   no import pass, no editor, and no `.godot/` at all, and the result ships correctly.
   Verified for `ImageTexture`, `Image`, `AudioStreamWAV`, `AudioStreamOggVorbis`,
   `AudioStreamMP3`, `FontFile` (which embeds the whole TTF), `ArrayMesh`, `Translation`
   and `AudioBusLayout`. One trap: `PortableCompressedTexture2D` saves with error 0 and
   reloads **empty** — use `ImageTexture`.
2. **Import properly**, then use ordinary `load()`. The compiled `.ctex` always ships.
3. **Write a `.dds` directly.** It is the one image format that needs no import pass, loads
   raw, decodes to real pixels, and **ships as-is inside the pack** — a 128-byte header plus
   pixel data, emittable from a few lines of script. Load it with `ResourceLoader.load()`,
   which returns an `ImageTexture`; note `Image.load_from_file()` does *not* read DDS and
   returns null for it.

<Note>
  `.godot/imported/` is **load-bearing, not a disposable cache.** Delete it and every imported
  asset returns null, even with all the `.import` files still present. Ignore any advice that
  tells you to clean it.
</Note>

## Exporting a build

Export works headless from a hand-written `export_presets.cfg`. Summer bundles macOS export
templates inside the app, so no download is needed for a Mac build:

```bash theme={null}
mkdir -p build
/Applications/Summer.app/Contents/MacOS/Summer --headless --path . \
  --export-release "macOS" build/game.zip
```

The resulting artifact genuinely runs, as a real template build with
`OS.has_feature("template") == true` and `Engine.is_editor_hint() == false`.
`--export-pack` produces a `.pck` instead.

<Warning>
  **Only the macOS template ships with the app.** Windows, Linux, Android and iOS exports all
  fail with "No export template found" until you supply templates yourself.

  Templates are searched for in `Summer.app/Contents/Resources/export_templates/` and then in
  `~/Library/Application Support/Godot/export_templates/` — note **`Godot`**, not `Summer`. A
  `~/Library/Application Support/Summer/export_templates/` directory may exist on your machine;
  the engine never reads it. Putting templates there does nothing.
</Warning>

<Note>
  Web export is not available on this build at all. Summer ships as a Mono (C#/.NET) build, and
  Godot 4 does not support exporting to Web from a Mono build. This is an architectural
  property of the binary, not a missing template.
</Note>

Use `export_filter="all_resources"`. The dependency-based `resources` filter exits non-zero
and produces an empty 112-byte pack for a project whose main scene has no resource
dependencies. Be aware `all_resources` packs everything, including every stray `.gd` file in
the project.

Two errors print at the start of every export and are harmless:
`Parent node is busy setting up children, add_child() failed` and
`Condition "!is_inside_tree()" is true` from `http_request.cpp`.

## What does not work

| Capability                                                            | Status                                                                                                                                                                                                                                                             |
| --------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
| Rendering, screenshots, any pixel readback                            | **Impossible headless.** See the warning at the top                                                                                                                                                                                                                |
| `--write-movie`                                                       | **Crashes** with signal 11 under `--headless`                                                                                                                                                                                                                      |
| Performance measurement                                               | Meaningless. Draw calls read `0.0`, FPS reads `1.0`, video adapter name is empty                                                                                                                                                                                   |
| UI and Control layout measurement                                     | **Wrong.** The headless viewport is not your configured size — it reports a small placeholder that `--resolution` does not override, while `ProjectSettings` still reports the real one. Every anchor, margin and global position measured headless is meaningless |
| `LightmapGI.bake()`                                                   | **Does not exist** in the scripting API. The node instantiates and every property setter is exposed, but `bake` is absent from the method list — calling it is a *parse-time* failure                                                                              |
| `OccluderInstance3D.bake_single_node()`                               | Same. Not exposed                                                                                                                                                                                                                                                  |
| `EditorScript`, `EditorPlugin`, `EditorInterface`, `EditorFileSystem` | `can_instantiate()` is false for all of them outside the editor                                                                                                                                                                                                    |

<Warning>
  `Engine.has_singleton("EditorInterface")` returns **true** while
  `Engine.get_singleton("EditorInterface")` fails with
  `Can't retrieve singleton 'EditorInterface' outside of editor`. Do not use `has_singleton`
  as your guard — use `Engine.is_editor_hint()`.
</Warning>

Anything needing editor context has a supported route. Adding `--editor` gives you a live
`EditorNode` and the full `EditorInterface` — 70 bound methods including `save_scene`,
`open_scene_from_path` and `get_resource_filesystem()` — even under `--headless`, and even
with no plugin installed:

```bash theme={null}
Summer --headless --editor --path /path/to/project -s res://tool.gd
```

<Warning>
  **`--editor` is not safe to run casually. Plain `--headless` is.**

  On current builds, adding `--editor` starts Summer's local API server, which **overwrites
  `~/.summer/api-port` and regenerates `~/.summer/api-token`**. That pair is how the CLI and
  your agent find a running editor. A headless editor takes the binding for itself, rotates
  the token underneath whatever was already running, and on exit leaves the file pointing at a
  dead port.

  **This is not only a broken lookup — a client can write into the wrong project.** The
  identity check rejects a request that declares which project it means, but **accepts one
  that does not declare anything**, and applies it to whichever project currently holds the
  binding. There is no error and no warning. Work silently lands in someone else's project.

  `--import` and `--export-*` do the same thing. They are full editor boots, not lightweight
  passes. Running `--import` on one project was measured rotating the token while another
  project held the binding.

  Which invocations are affected, measured:

  | Invocation                           | Takes the binding |
  | ------------------------------------ | ----------------- |
  | `--headless -s script.gd`            | **No — safe**     |
  | `--headless --editor …`              | Yes               |
  | `--headless --import`                | Yes               |
  | `--export-release` / `--export-pack` | Yes               |

  Plain `-s` headless scripting — the bulk of this page — is unaffected. The hazard is
  specific to the invocations that boot the editor.

  If your agent reports the engine as disconnected while it is plainly running, this is the
  first thing to check. Restarting the real editor republishes the binding.

  A later build suppresses this for batch-mode instances; until you have confirmed your build
  does, assume it does not.

  Plain `--headless -s script.gd` never starts that server and has none of these effects.
</Warning>

See [Editor Plugins](/extending/editor-plugins) for the full editor-context story, including
the enable routes and what `EditorInterface` unlocks.

Draw calls and the video adapter name are useful liveness probes. If
`Performance.get_monitor(Performance.RENDER_TOTAL_DRAW_CALLS_IN_FRAME)` reads `0.0` or
`RenderingServer.get_video_adapter_name()` is empty, you are not rendering and never will be.

## Performance

Startup overhead is roughly 0.3 to 0.4 seconds, low enough to call the binary in a tight
loop.

| Command                           | Typical |
| --------------------------------- | ------- |
| Trivial `-s` script               | 0.41s   |
| Script doing collision generation | 0.63s   |
| `--import` with nothing to do     | 5.6s    |
| `--export-release`, 50MB zip      | 6.4s    |

## For AI agents

If you are an agent automating Summer, the things that will silently burn you — note that
every one of them fails by *appearing to succeed*:

1. **`--headless` renders nothing.** `get_texture()` looks fine; `get_image()` returns null.
   Never plan around a headless screenshot.
2. **Use `_init()`, never `_run()`.** `_run()` hangs with zero output.
3. **Exit code 0 means nothing, and a runtime error means no exit code at all.** Parse errors
   and missing script files exit 0, so grep stderr. Runtime errors hang the process forever,
   so also impose an external wall-clock timeout — stderr cannot help you if the process
   never returns.
4. **`await process_frame` after `add_child()`**, or in-tree and physics APIs fail before
   the node is really in the tree.
5. **Write a file, then `--import` it**, or `load()` will not find it.
6. **Never read a raw source file at runtime.** Exporting strips them. `Image.load_from_file`
   and friends return null in the shipped game, and `FontFile.load_dynamic_font` returns
   error 0 with zero faces.
7. **Verify a pack from an empty directory.** `--main-pack` is silently ignored when the
   working directory holds a `project.godot`, so checking your export from the project folder
   reports a false pass.

Find your own binary with `OS.get_executable_path()` rather than assuming a path, and never
assume it is called `godot`.
