Skip to main content
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.
--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:
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.

The script template

Copy this. It is the exact file used to verify the behaviour on this page.
_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.
Only two base classes work. Everything else hangs silently: @tool on a -s script is a no-op — Engine.is_editor_hint() is false here.

Exit codes lie

Only an explicit quit(N) produces a non-zero exit code — and a runtime error produces no exit code at all.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.
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:
The exit code is still 0, so read stderr. But it costs almost nothing and it runs no code, so it cannot hang.
Never pass --quiet. It swallows your script’s own print() output, so a script that ran correctly looks like a script that produced nothing.
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

The binary is not called godot. godot --headless fails on every user’s machine because no such binary is installed.
On macOS the engine is at /Applications/Summer.app/Contents/MacOS/Summer. From inside a running script, ask the engine directly rather than guessing:
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.
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).
ImporterMesh is instantiable outside the editor. In Summer the signature takes three arguments:
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.
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.
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.
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.

Importing assets

A file written directly to disk cannot be loaded until an import pass runs.
This is the most common way agent-written assets fail. ResourceLoader.exists() returns false and load() returns null.
The best fix is to need no import at all. Two routes avoid this entirely, and both are described under shipping assets 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:
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.
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 below for the full mechanism. Plain --headless -s script.gd is unaffected and completely safe.
-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.
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 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.

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).
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.
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.
--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:
Always verify a pack from a directory that has no project.godot in it.
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.
.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.

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:
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.
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.
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.
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

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().
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:
--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: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.
See 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.

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.