Defold can record the game window to an H.264 video in an MP4 file through the recorder Lua module or the engine HTTP service. Both interfaces share one recorder, so only one recording can start, run or finalize at a time.
Recording is included in desktop debug engines. It is omitted from release, headless, mobile and web builds. Enabling Exclude Record in an App Manifest also removes the Lua module and HTTP routes.
| Platform | Video requirements | Application audio |
|---|---|---|
| macOS | macOS 15 or later, using ScreenCaptureKit | Supported |
| Windows | Windows 10 version 1903 or later, using Windows Graphics Capture and Media Foundation | Supported on Windows build 20348 or later |
| Linux | OpenGL, OpenGL ES or Vulkan, using framebuffer capture | Not supported |
Video and audio support are checked separately. HTTP clients can inspect /recording/capabilities; Lua callers receive an error if the requested capture is unsupported. Operating-system permissions and capture-device failures can also cause an asynchronous error after a start request is accepted.
The recorder captures the game window’s content at its native size in physical pixels. It may crop one row or column to produce even video dimensions. Resizing the window or changing its pixel density stops and finalizes the recording. Capture rate is 1–60 frames per second, with a default of 30.
Use recorder.start() with a writable output path and a callback. The parent directory must exist; sys.get_save_file() supplies a suitable application save path. An existing output file may be overwritten. Lua recordings belong to the caller and remain on disk after the engine exits.
Paths use the Windows ANSI code page on Windows and UTF-8 elsewhere, as with Lua file I/O. The callback’s result.path preserves the path passed to recorder.start().
This .script example records for five seconds after capture starts:
local function on_recording_event(self, event, result)
if event == recorder.EVENT_STARTED then
self.recording = true
timer.delay(5, false, function(self)
if self.recording then
local ok, err = recorder.stop()
if not ok then
print("Could not stop recording:", err)
end
end
end)
elseif event == recorder.EVENT_STOPPED then
self.recording = false
print("MP4 ready:", result.path)
elseif event == recorder.EVENT_ERROR then
self.recording = false
print("Recording failed:", result.error)
end
end
function init(self)
self.recording = false
if not recorder then
print("Recording is not included in this engine build")
return
end
local ok, err = recorder.start({
path = sys.get_save_file("recording_example", "capture.mp4"),
fps = 30,
audio = false,
}, on_recording_event)
if not ok then
print("Could not start recording:", err)
end
end
recorder.start() and recorder.stop() return true when accepted, or nil, error when rejected. Acceptance does not mean capture or file finalization has finished. Invalid options raise Lua errors.
Callbacks run during engine updates:
| Event | Meaning |
|---|---|
recorder.EVENT_STARTED |
Capture is active; it can now be stopped. |
recorder.EVENT_STOPPED |
The MP4 has been finalized and is ready to read. This also occurs after an automatic stop on resize. |
recorder.EVENT_ERROR |
Capture failed; inspect result.error. Startup failure can produce this event without EVENT_STARTED. |
The result also contains path, width, height, fps and audio. Read or upload the output only after EVENT_STOPPED. Keep the initiating script alive to receive the callback: deleting it discards callbacks without stopping capture. Engine shutdown finalizes capture without invoking Lua callbacks.
Audio is disabled by default in Lua. Set audio = true to request application audio on a supported platform. Requesting unsupported audio returns an error.
recorder.stop() targets the current shared recording, including one started through HTTP. It returns an error while an operation is pending or when no recording is active. If an HTTP client stops a Lua recording, the original Lua callback still receives its completion event. Starting through HTTP does not create a Lua callback; use HTTP status to observe its completion.
HTTP status can describe a Lua recording, but its file field is null: the service only prepares downloads for recordings started through HTTP. Read a Lua recording from result.path after EVENT_STOPPED.
The recording routes use the engine service port printed in the game log. Discover them through /openapi.json as described in the engine service manual. A headless or release engine will not expose them.
The following commands use a POSIX shell, curl and jq; the download verification step also uses Python 3. Run them in the same shell and replace <port> with the running engine’s port. First query capabilities and retain the service identity:
set -eu
ENGINE_URL="http://127.0.0.1:<port>"
CAPABILITIES=$(curl -fsS "$ENGINE_URL/recording/capabilities")
printf '%s\n' "$CAPABILITIES" | jq
INSTANCE_ID=$(printf '%s\n' "$CAPABILITIES" | jq -er 'select(.supported) | .instance_id')
Proceed only if supported is true; otherwise inspect unsupported_reason and minimum_platform_version. The audio field reports audio support. Unlike Lua, HTTP recording enables audio by default when the backend supports it. The example below explicitly disables audio.
Start with an optional JSON object containing fps and/or audio. The service chooses the output file; it does not accept a path, width, height or scale.
START=$(curl -fsS -X POST \
-H 'Content-Type: application/json' \
-H "X-Recording-Instance: $INSTANCE_ID" \
--data '{"fps":30,"audio":false}' \
"$ENGINE_URL/recording/start")
OPERATION_ID=$(printf '%s\n' "$START" | jq -er '.operation_id')
printf '%s\n' "$START" | jq
HTTP 202 means the request was accepted. Re-run the following status command until pending is false and active is true. The check rejects a response for another operation and reports capture or file-preparation failures. If an automatic stop has already finalized the recording, continue with finalization before downloading.
STATUS=$(curl -fsS \
-H "X-Recording-Instance: $INSTANCE_ID" \
"$ENGINE_URL/recording/status")
printf '%s\n' "$STATUS" | jq -e --argjson id "$OPERATION_ID" '
if .operation_id != $id then error("Recording operation changed")
elif .state == "failed" then error(.failure)
elif .file.state == "failed" then error("Recording file preparation failed")
else . end'
Let the game run for the duration you want to capture, then request a stop. If the window resized and the recording already completed, skip the stop request and continue with finalization below.
Stop the operation whose ID you retained from the start response:
STOP_BODY=$(jq -n --argjson id "$OPERATION_ID" '{operation_id: $id}')
STOP=$(curl -fsS -X POST \
-H 'Content-Type: application/json' \
-H "X-Recording-Instance: $INSTANCE_ID" \
--data "$STOP_BODY" \
"$ENGINE_URL/recording/stop")
printf '%s\n' "$STOP" | jq
HTTP 202 only accepts the stop request. Repeat the status command until pending is false, finalized is true, and file.state is complete. The service prepares the file’s size and SHA-256 after native capture finishes, so state: "complete" and finalized: true alone do not mean the download is ready. Stop waiting if state or file.state becomes failed; do not download a file whose state is pending or failed.
An automated client should poll with a delay and a timeout, checking the retained operation ID on every response. Allow the engine to keep updating while waiting for capture and finalization.
Use the completed file’s relative URL from the status response. The check below also ensures that the response still belongs to your operation:
FILE_URL=$(printf '%s\n' "$STATUS" | jq -er --argjson id "$OPERATION_ID" \
'select(.operation_id == $id and .pending == false and .finalized == true and .file.state == "complete") | .file.url')
curl -fsS -H "X-Recording-Instance: $INSTANCE_ID" \
"$ENGINE_URL$FILE_URL" -o capture.mp4
printf '%s\n' "$STATUS" > capture.json
Keep the completed status receipt in capture.json with the MP4. /recording/status describes the current operation, so a later recording replaces the status response even while earlier HTTP files remain available.
Verify the downloaded file’s byte size and SHA-256 before using it or deleting the service’s copy:
python3 - <<'PY'
import hashlib
import json
from pathlib import Path
receipt = json.loads(Path("capture.json").read_text(encoding="utf-8"))
capture = Path("capture.mp4")
if capture.stat().st_size != receipt["file"]["size"]:
raise SystemExit("Recording download size mismatch")
digest = hashlib.sha256()
with capture.open("rb") as stream:
for chunk in iter(lambda: stream.read(65536), b""):
digest.update(chunk)
if digest.hexdigest() != receipt["file"]["sha256"]:
raise SystemExit("Recording download SHA-256 mismatch")
print("Verified capture.mp4")
PY
A GET without a Range header downloads the whole MP4. The service also supports closed byte ranges, such as Range: bytes=0-1023, if a client needs partial downloads. Verify the assembled file against the same receipt.
After downloading and verifying the file, you can release the service’s retained copy:
curl -fsS -X DELETE -H "X-Recording-Instance: $INSTANCE_ID" \
"$ENGINE_URL$FILE_URL"
HTTP recording files remain available until explicitly deleted or the engine shuts down or reboots. Download them before closing the engine. The service retains at most 128 files; delete completed files you have saved to make room for further recordings.
Every POST and DELETE requires X-Recording-Instance. The examples also send it on status and download requests to reject responses from a replacement engine. Keep instance_id, operation_id and the file information together: the service identity changes on restart or reboot, and a new process can reuse operation numbers.
A missing identity returns HTTP 400. A stale identity, stale stop operation or deleted file returns HTTP 410. Do not fetch a new identity and reuse it to retry an old operation. Discover the new service and start a new recording instead.
Rejected requests return JSON with error.code and error.message. For troubleshooting, run the individual curl request with -i and without -f to see the HTTP status and error body.
| HTTP status | What to check |
|---|---|
| 409 | Another recording is active or pending, the current recording is not ready to stop, or the file is still being prepared. Read status before retrying. |
| 500 | Recording startup, storage initialization or file preparation/read failed. Inspect the error message; a failed capture also reports failure in status. |
| 501 | Video or the requested audio is unsupported. Inspect capabilities; use audio: false if only audio is unavailable. |
| 507 | The service’s retained-file limit was reached. Download and delete completed files before starting another recording. |