From 0826426120dbda65d3de919e91488bd29eb0bc1c Mon Sep 17 00:00:00 2001 From: Erik Thorelli Date: Thu, 8 Oct 2026 15:12:58 -0700 Subject: [PATCH 1/6] feat(booth): add a kiosk mode that returns idle games to the attract screen [Agent: Claude Code | Model: Opus 5.5 | Thinking: High | Acting on behalf of: Erik | Skill: none] At a booth, players walk away mid-game, and a visitor can press Quit Game and leave a dead screen. `-- --booth` makes the build a kiosk: it starts fullscreen without touching saved settings, returns any route to the hub after 90 seconds without input (the hub's rotating previews are the attract screen), and ignores the menu's Quit while staff can still close the window. `--route=` opens a route at launch, so a booth can boot straight into Heroes vs. Villains; an unknown name logs an error and stays on the hub. Both stay off unless passed. tests/booth_mode.gd covers the default-off state, launch-route parsing, input keeping a game alive, the idle return to the hub, and the ignored Quit. docs/builds.md documents booth use. --- docs/builds.md | 15 +++++ hoppys-adventure-arcade/app/app_router.gd | 59 ++++++++++++++++-- hoppys-adventure-arcade/tests/booth_mode.gd | 67 +++++++++++++++++++++ 3 files changed, 137 insertions(+), 4 deletions(-) create mode 100644 hoppys-adventure-arcade/tests/booth_mode.gd diff --git a/docs/builds.md b/docs/builds.md index e81f714b..f07a3251 100644 --- a/docs/builds.md +++ b/docs/builds.md @@ -21,6 +21,21 @@ Each platform is its own artifact: `hoppys-adventure-arcade-macos`, `-windows` a - **Windows** (x86_64): unzip and run `HoppysAdventureArcade.exe`. SmartScreen may ask you to confirm (**More info → Run anyway**), because the build isn't code-signed. - **Linux** (x86_64): unzip and run `./HoppysAdventureArcade.x86_64`. +## Run it at a booth + +Launch with `--booth` to run an unattended kiosk. Arguments go after `--`; for a macOS app, use `open -a … --args …`. + +```sh +./HoppysAdventureArcade.x86_64 -- --booth +./HoppysAdventureArcade.x86_64 -- --booth --route=heroes_vs_villains +open -a "Hoppy’s Adventure Arcade.app" --args -- --booth --route=heroes_vs_villains +``` + +- **Fullscreen.** The game starts fullscreen without changing the saved settings. +- **Idle reset.** After 90 seconds with no keyboard, mouse, touch or controller input, any game returns to the hub, whose rotating previews act as the attract screen. +- **No quitting from the menu.** The menu's **Quit Game** does nothing in booth mode; staff close the window (Cmd+Q, Alt+F4). +- **Launch route.** `--route=` opens a route at launch (`heroes_vs_villains`, `code_city_brawl`, `bay_area_blitz`, `orbital_ops`, `enchanted_repo`, `runner`, `platformer`, `beatem_up`, `hub`), with or without `--booth`. An unknown name logs an error and opens the hub. + ## Build locally ```sh diff --git a/hoppys-adventure-arcade/app/app_router.gd b/hoppys-adventure-arcade/app/app_router.gd index ecba0b17..0d7d215f 100644 --- a/hoppys-adventure-arcade/app/app_router.gd +++ b/hoppys-adventure-arcade/app/app_router.gd @@ -15,8 +15,19 @@ const ROUTES := { ## nonzero on the first failure. tools/build/export_game.py runs it against each export. const SMOKE_FLAG := "--smoke-routes" const SMOKE_FRAMES := 30 +## Booth mode (` -- --booth`) is for unattended kiosks: the game runs fullscreen, the menu's +## Quit does nothing, and after IDLE_SECONDS without input any route returns to the hub, whose +## rotating previews are the attract screen. `--route=` opens a route at launch, with or +## without --booth. Both are off unless passed. +const BOOTH_FLAG := "--booth" +const ROUTE_PREFIX := "--route=" +const IDLE_SECONDS := 90.0 +const STICK_DEADZONE := 0.4 var busy := false var veil: ColorRect +var booth := false +var idle_limit := IDLE_SECONDS +var idle_seconds := 0.0 func _ready() -> void: # The veil tween must keep running while a microgame has the tree paused. process_mode = Node.PROCESS_MODE_ALWAYS @@ -28,8 +39,14 @@ func _ready() -> void: veil.mouse_filter = Control.MOUSE_FILTER_IGNORE veil.modulate.a = 0 add_child(veil) - if SMOKE_FLAG in OS.get_cmdline_user_args(): + var args := OS.get_cmdline_user_args() + if SMOKE_FLAG in args: smoke_routes.call_deferred() + return + set_booth(BOOTH_FLAG in args) + var start := launch_route(args) + if start != "": + go.call_deferred(start) func go(route: String) -> void: if busy or not ROUTES.has(route) or not ResourceLoader.exists(ROUTES[route]): return @@ -48,8 +65,9 @@ func go(route: String) -> void: await create_tween().tween_property(veil, "modulate:a", 0.0, 0.25).finished veil.mouse_filter = Control.MOUSE_FILTER_IGNORE busy = false -func quit_game() -> void: - if busy: +## Visitors can't quit a booth from the menu; staff close the window instead (force = true). +func quit_game(force := false) -> void: + if busy or (booth and not force): return busy = true veil.mouse_filter = Control.MOUSE_FILTER_STOP @@ -59,7 +77,40 @@ func quit_game() -> void: func _notification(what: int) -> void: if what == NOTIFICATION_WM_CLOSE_REQUEST: - quit_game() + quit_game(true) + +## The route named by `--route=`, or "" when absent or unknown (with an error for typos). +static func launch_route(args: PackedStringArray) -> String: + for arg in args: + if arg.begins_with(ROUTE_PREFIX): + var route := arg.trim_prefix(ROUTE_PREFIX) + if ROUTES.has(route): + return route + push_error("Unknown --route %s; expected one of %s" % [route, ", ".join(ROUTES.keys())]) + return "" + +func set_booth(enabled: bool) -> void: + booth = enabled + idle_seconds = 0.0 + set_process(enabled) + set_process_input(enabled) + if enabled: + DisplayServer.window_set_mode(DisplayServer.WINDOW_MODE_FULLSCREEN) + +func _input(event: InputEvent) -> void: + if event is InputEventKey or event is InputEventMouseButton or event is InputEventJoypadButton \ + or event is InputEventScreenTouch \ + or (event is InputEventJoypadMotion and absf(event.axis_value) > STICK_DEADZONE): + idle_seconds = 0.0 + +func _process(delta: float) -> void: + idle_seconds += delta + if idle_seconds < idle_limit or busy: + return + idle_seconds = 0.0 + var scene := get_tree().current_scene + if scene == null or scene.scene_file_path != ROUTES["hub"]: + go("hub") func smoke_routes() -> void: # Let the launch scene finish its opening frames, as a player would, before switching. diff --git a/hoppys-adventure-arcade/tests/booth_mode.gd b/hoppys-adventure-arcade/tests/booth_mode.gd new file mode 100644 index 00000000..b1f1be4e --- /dev/null +++ b/hoppys-adventure-arcade/tests/booth_mode.gd @@ -0,0 +1,67 @@ +extends SceneTree +## Booth mode: idle routes return to the hub, input keeps a game alive, the menu can't quit the +## kiosk, and --route= picks a launch route. Run headless with --script res://tests/booth_mode.gd. +var failures := 0 +var router: Node + +func _initialize() -> void: + call_deferred("run") + +func check(ok: bool, message: String) -> void: + print("PASS: " if ok else "FAIL: ", message) + if not ok: + failures += 1 + +func wait(seconds: float) -> void: + await create_timer(seconds, true).timeout + +func settle_route() -> void: + for i in 300: + if not router.busy: + break + await process_frame + await process_frame + +func on_hub() -> bool: + return current_scene != null and current_scene.scene_file_path == router.ROUTES["hub"] + +func run() -> void: + router = root.get_node("AppRouter") + await wait(0.3) + check(not router.booth and not router.is_processing(), "Booth mode is off without --booth") + + check(router.launch_route(PackedStringArray(["--route=heroes_vs_villains"])) == "heroes_vs_villains", + "--route names a launch route") + check(router.launch_route(PackedStringArray(["--booth"])) == "", "No --route means the default scene") + + router.go("platformer") + await settle_route() + await wait(0.5) + check(not on_hub(), "Without booth mode an idle game stays put") + + router.set_booth(true) + router.idle_limit = 0.6 + check(router.booth and router.is_processing(), "set_booth enables the idle timer") + for i in 4: + await wait(0.25) + var key := InputEventKey.new() + key.physical_keycode = KEY_RIGHT + key.pressed = true + Input.parse_input_event(key) + Input.flush_buffered_events() + check(not on_hub(), "Input inside the idle window keeps the game running") + + await wait(1.2) + await settle_route() + check(on_hub(), "An idle game returns to the hub's attract screen") + + var before: int = router.get_child_count() + router.quit_game() + check(not router.busy and router.get_child_count() == before, "The menu's Quit is ignored in booth mode") + + router.set_booth(false) + router.idle_limit = router.IDLE_SECONDS + root.get_node("AudioService").shutdown() + await wait(0.4) + print("Booth mode failures: ", failures) + quit(1 if failures else 0) From ffb638190077eaa32c25b4c9d1f480fb11e11bbd Mon Sep 17 00:00:00 2001 From: Erik Thorelli Date: Thu, 8 Oct 2026 15:13:21 -0700 Subject: [PATCH 2/6] style(booth): keep the booth test within the line limit --- hoppys-adventure-arcade/tests/booth_mode.gd | 15 +++++++++------ 1 file changed, 9 insertions(+), 6 deletions(-) diff --git a/hoppys-adventure-arcade/tests/booth_mode.gd b/hoppys-adventure-arcade/tests/booth_mode.gd index b1f1be4e..c52188c0 100644 --- a/hoppys-adventure-arcade/tests/booth_mode.gd +++ b/hoppys-adventure-arcade/tests/booth_mode.gd @@ -1,6 +1,7 @@ extends SceneTree -## Booth mode: idle routes return to the hub, input keeps a game alive, the menu can't quit the -## kiosk, and --route= picks a launch route. Run headless with --script res://tests/booth_mode.gd. +## Booth mode: idle routes return to the hub, input keeps a game alive, the menu can't quit +## the kiosk, and --route= picks a launch route. +## Run headless with --script res://tests/booth_mode.gd. var failures := 0 var router: Node @@ -30,9 +31,10 @@ func run() -> void: await wait(0.3) check(not router.booth and not router.is_processing(), "Booth mode is off without --booth") - check(router.launch_route(PackedStringArray(["--route=heroes_vs_villains"])) == "heroes_vs_villains", - "--route names a launch route") - check(router.launch_route(PackedStringArray(["--booth"])) == "", "No --route means the default scene") + var launch: String = router.launch_route(PackedStringArray(["--route=heroes_vs_villains"])) + check(launch == "heroes_vs_villains", "--route names a launch route") + launch = router.launch_route(PackedStringArray(["--booth"])) + check(launch == "", "No --route means the default scene") router.go("platformer") await settle_route() @@ -57,7 +59,8 @@ func run() -> void: var before: int = router.get_child_count() router.quit_game() - check(not router.busy and router.get_child_count() == before, "The menu's Quit is ignored in booth mode") + check(not router.busy and router.get_child_count() == before, + "The menu's Quit is ignored in booth mode") router.set_booth(false) router.idle_limit = router.IDLE_SECONDS From 62970a77a6de334c912719399ceeba94b2f5f1fc Mon Sep 17 00:00:00 2001 From: Erik Thorelli Date: Thu, 8 Oct 2026 15:25:11 -0700 Subject: [PATCH 3/6] fix(router): let a window close quit even during a route transition [Agent: Claude Code | Model: Opus 5.5 | Thinking: High | Acting on behalf of: Erik | Skill: none] auto_accept_quit is off, so a close request that arrived while go() held busy was dropped and the window stayed open. A forced quit now stops audio and quits immediately mid-transition, without starting a second veil fade. Addresses CodeRabbit review on #43. --- hoppys-adventure-arcade/app/app_router.gd | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/hoppys-adventure-arcade/app/app_router.gd b/hoppys-adventure-arcade/app/app_router.gd index 0d7d215f..4d053e0d 100644 --- a/hoppys-adventure-arcade/app/app_router.gd +++ b/hoppys-adventure-arcade/app/app_router.gd @@ -66,8 +66,14 @@ func go(route: String) -> void: veil.mouse_filter = Control.MOUSE_FILTER_IGNORE busy = false ## Visitors can't quit a booth from the menu; staff close the window instead (force = true). +## A forced quit wins even mid-transition: auto_accept_quit is off, so nothing else would close. func quit_game(force := false) -> void: - if busy or (booth and not force): + if booth and not force: + return + if busy: + if force: + AudioService.shutdown() + get_tree().quit() return busy = true veil.mouse_filter = Control.MOUSE_FILTER_STOP From 250e93716238b0018b6053edad793c9b0f8e32c1 Mon Sep 17 00:00:00 2001 From: Erik Thorelli Date: Thu, 8 Oct 2026 15:25:28 -0700 Subject: [PATCH 4/6] chore(booth): commit the booth test's Godot UID like the other suites [Agent: Claude Code | Model: Opus 5.5 | Thinking: High | Acting on behalf of: Erik | Skill: none] --- hoppys-adventure-arcade/tests/booth_mode.gd.uid | 1 + 1 file changed, 1 insertion(+) create mode 100644 hoppys-adventure-arcade/tests/booth_mode.gd.uid diff --git a/hoppys-adventure-arcade/tests/booth_mode.gd.uid b/hoppys-adventure-arcade/tests/booth_mode.gd.uid new file mode 100644 index 00000000..f72ff152 --- /dev/null +++ b/hoppys-adventure-arcade/tests/booth_mode.gd.uid @@ -0,0 +1 @@ +uid://c7s3s7spaejbm From fcaed4c538d211ce7ffa37b753233247b5f4769f Mon Sep 17 00:00:00 2001 From: Erik Thorelli Date: Thu, 8 Oct 2026 15:35:34 -0700 Subject: [PATCH 5/6] test(booth): cover --booth and --route through a real launch [Agent: Claude Code | Model: Opus 5.5 | Thinking: High | Acting on behalf of: Erik | Skill: none] Addresses CodeRabbit review on #43: the suite set booth mode directly, so a regression in _ready()'s argument handling would pass. It now launches the game as a child process, plain and with -- --booth --route=heroes_vs_villains, and checks the startup lines AppRouter now prints (which staff can also read in a kiosk log). Removing set_booth() from _ready() fails the suite. --- hoppys-adventure-arcade/app/app_router.gd | 2 ++ hoppys-adventure-arcade/tests/booth_mode.gd | 20 +++++++++++++++++++- 2 files changed, 21 insertions(+), 1 deletion(-) diff --git a/hoppys-adventure-arcade/app/app_router.gd b/hoppys-adventure-arcade/app/app_router.gd index 4d053e0d..8caf4e68 100644 --- a/hoppys-adventure-arcade/app/app_router.gd +++ b/hoppys-adventure-arcade/app/app_router.gd @@ -46,6 +46,7 @@ func _ready() -> void: set_booth(BOOTH_FLAG in args) var start := launch_route(args) if start != "": + print("AppRouter: launching route ", start) go.call_deferred(start) func go(route: String) -> void: if busy or not ROUTES.has(route) or not ResourceLoader.exists(ROUTES[route]): @@ -102,6 +103,7 @@ func set_booth(enabled: bool) -> void: set_process_input(enabled) if enabled: DisplayServer.window_set_mode(DisplayServer.WINDOW_MODE_FULLSCREEN) + print("AppRouter: booth mode on (idle return after %d s)" % int(idle_limit)) func _input(event: InputEvent) -> void: if event is InputEventKey or event is InputEventMouseButton or event is InputEventJoypadButton \ diff --git a/hoppys-adventure-arcade/tests/booth_mode.gd b/hoppys-adventure-arcade/tests/booth_mode.gd index c52188c0..f8e4ed16 100644 --- a/hoppys-adventure-arcade/tests/booth_mode.gd +++ b/hoppys-adventure-arcade/tests/booth_mode.gd @@ -1,6 +1,7 @@ extends SceneTree ## Booth mode: idle routes return to the hub, input keeps a game alive, the menu can't quit -## the kiosk, and --route= picks a launch route. +## the kiosk, and --route= picks a launch route. The startup flags are checked by launching +## the game as a child process, so _ready()'s own argument handling is covered. ## Run headless with --script res://tests/booth_mode.gd. var failures := 0 var router: Node @@ -23,6 +24,15 @@ func settle_route() -> void: await process_frame await process_frame +## Launch the game headless with user args and return its combined output. +func launch(user_args: Array[String]) -> String: + var args: Array[String] = ["--headless", "--path", ProjectSettings.globalize_path("res://"), + "--quit-after", "30", "--"] + args.append_array(user_args) + var output: Array = [] + OS.execute(OS.get_executable_path(), args, output, true) + return "\n".join(output) + func on_hub() -> bool: return current_scene != null and current_scene.scene_file_path == router.ROUTES["hub"] @@ -36,6 +46,14 @@ func run() -> void: launch = router.launch_route(PackedStringArray(["--booth"])) check(launch == "", "No --route means the default scene") + var plain := launch([]) + check(not plain.contains("booth mode on") and not plain.contains("launching route"), + "A plain launch starts without booth mode or a launch route") + var kiosk := launch(["--booth", "--route=heroes_vs_villains"]) + check(kiosk.contains("AppRouter: booth mode on"), "Launching with --booth turns booth mode on") + check(kiosk.contains("AppRouter: launching route heroes_vs_villains"), + "Launching with --route opens that route") + router.go("platformer") await settle_route() await wait(0.5) From 118436172b2a7172e1f9b1069b497cdc89fc8afd Mon Sep 17 00:00:00 2001 From: Erik Thorelli Date: Thu, 8 Oct 2026 15:43:00 -0700 Subject: [PATCH 6/6] test(booth): assert the launched scene and child exit codes [Agent: Claude Code | Model: Opus 5.5 | Thinking: High | Acting on behalf of: Erik | Skill: none] Addresses CodeRabbit review on #43: AppRouter logs the launch route after the transition completes, with the scene that actually loaded. The suite's child launches run at a fixed 60 fps for two simulated seconds, so the fade always finishes, and assert exit code 0. --- hoppys-adventure-arcade/app/app_router.gd | 9 +++++++-- hoppys-adventure-arcade/tests/booth_mode.gd | 22 ++++++++++++--------- 2 files changed, 20 insertions(+), 11 deletions(-) diff --git a/hoppys-adventure-arcade/app/app_router.gd b/hoppys-adventure-arcade/app/app_router.gd index 8caf4e68..9f9a7eb2 100644 --- a/hoppys-adventure-arcade/app/app_router.gd +++ b/hoppys-adventure-arcade/app/app_router.gd @@ -46,8 +46,7 @@ func _ready() -> void: set_booth(BOOTH_FLAG in args) var start := launch_route(args) if start != "": - print("AppRouter: launching route ", start) - go.call_deferred(start) + _launch.call_deferred(start) func go(route: String) -> void: if busy or not ROUTES.has(route) or not ResourceLoader.exists(ROUTES[route]): return @@ -96,6 +95,12 @@ static func launch_route(args: PackedStringArray) -> String: push_error("Unknown --route %s; expected one of %s" % [route, ", ".join(ROUTES.keys())]) return "" +func _launch(route: String) -> void: + await go(route) + var scene := get_tree().current_scene + var path := scene.scene_file_path if scene else "no scene" + print("AppRouter: launched route %s (%s)" % [route, path]) + func set_booth(enabled: bool) -> void: booth = enabled idle_seconds = 0.0 diff --git a/hoppys-adventure-arcade/tests/booth_mode.gd b/hoppys-adventure-arcade/tests/booth_mode.gd index f8e4ed16..74c0ea34 100644 --- a/hoppys-adventure-arcade/tests/booth_mode.gd +++ b/hoppys-adventure-arcade/tests/booth_mode.gd @@ -24,14 +24,15 @@ func settle_route() -> void: await process_frame await process_frame -## Launch the game headless with user args and return its combined output. -func launch(user_args: Array[String]) -> String: +## Launch the game headless with user args for two simulated seconds (fixed 60 fps, so the +## route fade always completes) and return its exit code and combined output. +func launch(user_args: Array[String]) -> Dictionary: var args: Array[String] = ["--headless", "--path", ProjectSettings.globalize_path("res://"), - "--quit-after", "30", "--"] + "--fixed-fps", "60", "--quit-after", "120", "--"] args.append_array(user_args) var output: Array = [] - OS.execute(OS.get_executable_path(), args, output, true) - return "\n".join(output) + var code := OS.execute(OS.get_executable_path(), args, output, true) + return {"code": code, "log": "\n".join(output)} func on_hub() -> bool: return current_scene != null and current_scene.scene_file_path == router.ROUTES["hub"] @@ -47,12 +48,15 @@ func run() -> void: check(launch == "", "No --route means the default scene") var plain := launch([]) - check(not plain.contains("booth mode on") and not plain.contains("launching route"), + check(plain.code == 0, "A plain launch exits cleanly") + check(not plain.log.contains("booth mode on") and not plain.log.contains("launched route"), "A plain launch starts without booth mode or a launch route") var kiosk := launch(["--booth", "--route=heroes_vs_villains"]) - check(kiosk.contains("AppRouter: booth mode on"), "Launching with --booth turns booth mode on") - check(kiosk.contains("AppRouter: launching route heroes_vs_villains"), - "Launching with --route opens that route") + check(kiosk.code == 0, "A booth launch exits cleanly") + check(kiosk.log.contains("AppRouter: booth mode on"), "Launching with --booth turns booth mode on") + var opened: String = "AppRouter: launched route heroes_vs_villains (%s)" \ + % router.ROUTES["heroes_vs_villains"] + check(kiosk.log.contains(opened), "Launching with --route opens that route's scene") router.go("platformer") await settle_route()