Started as a dumb experiment: can I query live game state from a browser tab without touching the game window? Turns out yes, and it's weirdly useful.
The setup: a TCPServer autoload that listens on a localhost port, parses incoming HTTP GET requests (just the first line, really), routes to registered handler callables, and returns JSON. No plugin, no third-party dep, just Godot 4's built-in networking primitives.
extends Node
var _server := TCPServer.new()
var _handlers: Dictionary = {}
func start(port: int = 9876) -> void:
_server.listen(port)
func register(path: String, callable: Callable) -> void:
_handlers[path] = callable
func _process(_delta: float) -> void:
if not _server.is_connection_available():
return
var peer: StreamPeerTCP = _server.take_connection()
await get_tree().process_frame # let the buffer fill
var raw := peer.get_utf8_string(peer.get_available_bytes())
var path := _parse_path(raw)
var result := _handlers[path].call() if _handlers.has(path) else {"error": "not found"}
var response := "HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nAccess-Control-Allow-Origin: *\r\n\r\n" + JSON.stringify(result)
peer.put_data(response.to_utf8_buffer())
peer.disconnect_from_host()
func _parse_path(raw: String) -> String:
var parts := raw.split("\r\n")[0].split(" ")
return parts[1] if parts.size() > 1 else "/"
Then from game code:
DebugServer.register("/player", func():
return {
"pos": str(player.global_position),
"hp": player.health,
"state": player.state_machine.current_state,
})
DebugServer.start()
Hit localhost:9876/player in a browser and you get a live JSON snapshot. I threw together a tiny vanilla JS page that polls every 500ms and renders it as a table. No more print() chains scattered everywhere just to read state mid-session.
I've also wired up write endpoints by parsing POST bodies, so I can fire curl -X POST localhost:9876/spawn -d '{"enemy":"goblin"}' at the running game. Toggle god mode, force-load a level, inject quest state. All from the terminal without touching the game window.
Gated behind OS.is_debug_build() so it doesn't ship. Known issue: get_available_bytes() sometimes returns 0 if you check too fast after accepting the connection, hence the await process_frame. Not bulletproof for large POST bodies but works fine for localhost GET queries.
Anyone else doing external tooling like this? Curious if there's a cleaner pattern, or if someone's taken it further. The obvious next step is a little browser devtools panel that auto-refreshes registered endpoints.