Quick answer: Yes. Use get_viewport().get_texture().get_image() to grab the current frame, then save it as PNG bytes for upload or local storage.
Learning how to add bug reporter to Godot game is a common challenge for game developers. Players who hit a bug rarely leave the game to fill out a web form. If you want quality reports, the reporter needs to live inside the game itself. This guide walks through building a complete in-game bug reporter for Godot 4 using GDScript, from the UI layer to screenshot capture to offline queuing.
Why an In-Game Reporter Matters
External feedback forms lose context. By the time a player tabs out, opens a browser, and describes the issue, they have forgotten half the details. An in-game reporter captures the moment: the exact frame as a screenshot, the hardware the player is running, and the game state at the time of the bug. You get better data, and the player stays in your game.
Godot 4 makes this straightforward. The engine exposes viewport textures, OS-level system queries, and a built-in HTTP client. We do not need any third-party plugins to get a fully functional reporter running.
Step 1: Create the CanvasLayer UI
The bug reporter needs to render on top of everything else, including pause menus. A CanvasLayer with a high layer value ensures it always stays visible. Inside it, we build a simple form: a text area for the description, a dropdown for category, and a submit button.
Create a new scene called BugReporter.tscn with this node structure:
# Scene tree
CanvasLayer (layer = 100)
└── PanelContainer "ReporterPanel"
└── VBoxContainer
├── Label "Report a Bug"
├── TextEdit "DescriptionField"
├── OptionButton "CategoryDropdown"
├── HBoxContainer
│ ├── Button "CancelBtn"
│ └── Button "SubmitBtn"
└── Label "StatusLabel"
Set the PanelContainer anchors to center it on screen and give it a fixed size around 500 by 400 pixels. The TextEdit should have a minimum height of 150 pixels so players have room to type. Add categories to the OptionButton that match your project: Crash, Gameplay, Visual, Audio, and Other are a solid starting set.
Step 2: Attach the Reporter Script
Create bug_reporter.gd and attach it to the CanvasLayer root. This script handles toggling visibility, collecting data, and dispatching the report.
extends CanvasLayer
@onready var panel = $ReporterPanel
@onready var description_field = $ReporterPanel/VBoxContainer/DescriptionField
@onready var category_dropdown = $ReporterPanel/VBoxContainer/CategoryDropdown
@onready var status_label = $ReporterPanel/VBoxContainer/StatusLabel
@onready var submit_btn = $ReporterPanel/VBoxContainer/HBoxContainer/SubmitBtn
@onready var http_request = HTTPRequest.new()
const API_URL = "https://your-project.bugnet.dev/api/v1/bugs"
const API_KEY = "your-sdk-key-here"
var screenshot_bytes: PackedByteArray
func _ready():
add_child(http_request)
http_request.request_completed.connect(_on_request_completed)
submit_btn.pressed.connect(_on_submit)
$ReporterPanel/VBoxContainer/HBoxContainer/CancelBtn.pressed.connect(hide_reporter)
panel.visible = false
func _input(event):
if event.is_action_pressed("toggle_bug_reporter"):
if panel.visible:
hide_reporter()
else:
show_reporter()
Map the toggle_bug_reporter input action to F8 or whatever key you prefer in Project Settings. The reporter starts hidden and toggles on keypress.
Step 3: Capture a Screenshot
Godot gives us direct access to the viewport texture. We grab it the frame before showing the panel so the screenshot reflects what the player actually saw, not the reporter UI itself.
func show_reporter():
# Capture before showing the panel
var image = get_viewport().get_texture().get_image()
screenshot_bytes = image.save_png_to_buffer()
panel.visible = true
description_field.text = ""
status_label.text = ""
description_field.grab_focus()
The save_png_to_buffer() method returns a PackedByteArray containing the PNG data. We hold onto it until submission. This typically weighs between 200KB and 2MB depending on resolution, which is reasonable for upload.
Step 4: Collect System Information
The most valuable part of any bug report is context. Godot exposes several OS and rendering queries that let us build a system profile automatically, so the player never has to guess what GPU they have.
func get_system_info() -> Dictionary:
return {
"os": OS.get_name(),
"gpu": RenderingServer.get_video_adapter_name(),
"gpu_vendor": RenderingServer.get_video_adapter_vendor(),
"screen_size": str(DisplayServer.screen_get_size()),
"window_size": str(get_viewport().get_visible_rect().size),
"locale": OS.get_locale(),
"game_version": ProjectSettings.get_setting("application/config/version"),
"godot_version": Engine.get_version_info().string
}
Store your game version in project.godot under application/config/version so it is always available at runtime. This single field saves you hours of back-and-forth when a player reports something that was already fixed in a newer build.
Step 5: Submit the Report via HTTP
With the description, category, screenshot, and system info collected, we build a JSON payload and send it to the API. Godot's HTTPRequest node handles this cleanly.
func _on_submit():
var desc = description_field.text.strip_edges()
if desc.length() < 10:
status_label.text = "Please describe the issue in at least 10 characters."
return
submit_btn.disabled = true
status_label.text = "Submitting..."
var payload = {
"description": desc,
"category": category_dropdown.get_item_text(category_dropdown.selected),
"screenshot_base64": Marshalls.raw_to_base64(screenshot_bytes),
"system_info": get_system_info()
}
var json_body = JSON.stringify(payload)
var headers = [
"Content-Type: application/json",
"Authorization: Bearer " + API_KEY
]
var err = http_request.request(API_URL, headers, HTTPClient.METHOD_POST, json_body)
if err != OK:
status_label.text = "Network error. Saving locally."
save_report_locally(payload)
submit_btn.disabled = false
Handle the response to confirm success or fall back to the local queue:
func _on_request_completed(result, code, headers, body):
submit_btn.disabled = false
if result == HTTPRequest.RESULT_SUCCESS and code == 201:
status_label.text = "Bug report submitted. Thank you!"
await get_tree().create_timer(2.0).timeout
hide_reporter()
else:
status_label.text = "Upload failed. Report saved locally."
var payload = JSON.parse_string(body.get_string_from_utf8())
save_report_locally(payload)
Step 6: Offline Queue with Local Save
Players do not always have a stable connection, especially during playtests or at conventions. A local queue ensures nothing gets lost. We write each report as a separate JSON file in the user:// directory and retry on the next session.
const QUEUE_DIR = "user://bug_reports/"
func save_report_locally(payload: Dictionary):
DirAccess.make_dir_recursive_absolute(QUEUE_DIR)
var filename = QUEUE_DIR + "report_" + str(Time.get_unix_time_from_system()) + ".json"
var file = FileAccess.open(filename, FileAccess.WRITE)
file.store_string(JSON.stringify(payload))
func flush_local_queue():
var dir = DirAccess.open(QUEUE_DIR)
if dir == null:
return
dir.list_dir_begin()
var fname = dir.get_next()
while fname != "":
if fname.ends_with(".json"):
var file = FileAccess.open(QUEUE_DIR + fname, FileAccess.READ)
var json_body = file.get_as_text()
# Re-submit via HTTP; delete file on 201 response
_submit_queued_report(QUEUE_DIR + fname, json_body)
fname = dir.get_next()
Call flush_local_queue() in _ready() so queued reports get sent automatically when the game starts with a working connection. Delete each file only after receiving a 201 response to avoid data loss.
Step 7: Polish and Ship
A few finishing touches make the reporter feel professional rather than bolted on. Add a short cooldown after submission so players cannot accidentally spam reports. Include a character counter on the text field. Let the panel fade in with a tween rather than snapping into view.
func hide_reporter():
var tween = create_tween()
tween.tween_property(panel, "modulate:a", 0.0, 0.2)
await tween.finished
panel.visible = false
panel.modulate.a = 1.0
Finally, autoload the BugReporter scene in Project Settings so it persists across scene changes. Players can press F8 from anywhere in the game and the reporter is ready.
Ship the reporter in your beta builds from day one. The earlier you collect structured reports, the fewer hours you spend guessing what went wrong.