Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -40,7 +40,7 @@ HoppysAdventureArcade.exe -- --booth --route=heroes_vs_villains
./HoppysAdventureArcade.x86_64 -- --booth --route=heroes_vs_villains # Linux
```

Booth mode runs fullscreen and ignores the menu's **Quit Game**, so staff close the window instead. After 90 seconds without input, any game returns to the hub's attract screen. [Run it at a booth](docs/builds.md#run-it-at-a-booth) lists every route and flag.
Booth mode runs fullscreen and ignores the menu's **Quit Game**, so staff close the window instead. After 90 seconds without input, any game returns to the hub's attract screen, and a lost game sends the player there after a thank-you card instead of offering endless retries. [Run it at a booth](docs/builds.md#run-it-at-a-booth) lists every route and flag.

For a machine that runs unattended all day, follow the [booth runbook](docs/booth-runbook.md). It sets up a watchdog that restarts the game if it closes or freezes, along with the Mac's kiosk settings, and covers the staff checklist.

Expand Down
5 changes: 3 additions & 2 deletions docs/booth-runbook.md
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@ This runbook is for the staff who run Hoppy’s Adventure Arcade unattended at a
```sh
tools/booth/macos/setup.sh --app "/Applications/Hoppy’s Adventure Arcade.app" --route heroes_vs_villains
```
Nothing changes yet. To apply it, run the same command with `--apply` and enter the administrator password when asked. The game starts.
Nothing changes yet. To apply it, run the same command with `--apply` and enter the administrator password when asked. The game starts. To give each player a retry or change the attract screen, add game flags after `--`, for example `-- --retries=1 --attract=beatem_up` ([booth flags](builds.md#run-it-at-a-booth)).
4. Do the manual steps the script lists at the end: automatic login, Do Not Disturb, the display, the volume and the controllers.
5. Restart the Mac. The game should start on its own within a minute.

Expand All @@ -27,7 +27,7 @@ The setup does the following:
- It keeps the Mac awake and turns it back on after a power cut.
- It turns off the screen saver, automatic updates and crash dialogs.

Booth mode itself runs fullscreen, ignores the menu's **Quit Game**, and sends a game nobody has touched for 90 seconds back to the hub's attract screen ([details](builds.md#run-it-at-a-booth)).
Booth mode itself runs fullscreen and ignores the menu's **Quit Game**. A game nobody has touched for 90 seconds goes back to the hub's attract screen, and so does a lost game, after a thank-you card for the player ([details](builds.md#run-it-at-a-booth)).

## Every morning

Expand All @@ -40,6 +40,7 @@ Booth mode itself runs fullscreen, ignores the menu's **Quit Game**, and sends a
| What happens | What you'll see |
|---|---|
| A player walks away mid-game | After 90 seconds the game returns to the hub. |
| A player loses | Once they have used any retries you set (none by default), a **GAME OVER** card thanks them and shows their score. After six seconds the attract screen (the hub unless you set another) opens for the next player. |
| The game crashes or closes | The watchdog opens it again within a few seconds. |
| The game freezes | The watchdog restarts it within about a minute. |
| Someone presses Cmd+Q | The game closes, and the watchdog opens it again. |
Expand Down
5 changes: 3 additions & 2 deletions docs/builds.md
Original file line number Diff line number Diff line change
Expand Up @@ -33,11 +33,12 @@ open -a "Hoppy’s Adventure Arcade.app" --args -- --booth --route=heroes_vs_vil
```

- **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.
- **Idle reset.** After 90 seconds with no keyboard, mouse, touch or controller input, any game returns to the attract route. That is the hub, whose rotating previews act as the attract screen, unless `--attract=` names `beatem_up` or `runner`, the other menus that start their own music.
- **Game over.** A lost game doesn't offer unlimited retries. A player gets `--retries=<n>` retries (default 0); after that, the game shows no retry screen. Instead a **GAME OVER** card thanks the player, shows their score and counts down six seconds, then the attract route opens for the next player. The count starts again whenever the attract route opens. Every game takes part: Heroes vs. Villains, Code City Brawl, the platformer levels and the Endless Runner. Without `--booth`, every game keeps its own retry 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=<name>` 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.
- **Keep it running.** `tools/booth/watchdog.sh` relaunches the game whenever it exits and restarts it when it hangs. The watchdog passes `--heartbeat=<path>`, which makes the game rewrite that file with the current time every two seconds; a file that stops changing means the game is stuck. On a Mac, `tools/booth/macos/setup.sh` installs the watchdog as a login item and applies the kiosk settings. The [booth runbook](booth-runbook.md) walks staff through setup, the show days and recovery.
- **Session log.** Booth mode records each game session's start and end in `user://booth/`, one file per run of the game (`sessions-<date and time>-<process id>.jsonl`). Each entry records the game, the local start and end times, the duration, how the session ended (`menu`, `idle` or `quit`) and how many times the player retried. It records nothing about players and never uses the network. A start with no end means the game stopped without quitting: a crash, a power cut or a forced kill. The game prints its file's full path at startup. `--session-log=<folder>` writes to another folder and works without `--booth`, for playtests.
- **Session log.** Booth mode records each game session's start and end in `user://booth/`, one file per run of the game (`sessions-<date and time>-<process id>.jsonl`). Each entry records the game, the local start and end times, the duration, how the session ended (`menu`, `idle`, `game_over` or `quit`), how many times the player re-entered the game, and how many times they lost (`game_overs`, stage retries included). It records nothing about players and never uses the network. A start with no end means the game stopped without quitting: a crash, a power cut or a forced kill. The game prints its file's full path at startup. `--session-log=<folder>` writes to another folder and works without `--booth`, for playtests.

`user://` is `~/Library/Application Support/Godot/app_userdata/Hoppy’s Adventure Arcade/` on macOS, `%APPDATA%\Godot\app_userdata\Hoppy’s Adventure Arcade\` on Windows and `~/.local/share/godot/app_userdata/Hoppy’s Adventure Arcade/` on Linux. To summarize the logs, copy each machine's `booth` folder and run:

Expand Down
98 changes: 93 additions & 5 deletions hoppys-adventure-arcade/app/app_router.gd
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
extends CanvasLayer
const BoothLog := preload("res://app/booth_log.gd")
const BoothHeartbeat := preload("res://app/booth_heartbeat.gd")
const NextPlayerCard := preload("res://app/next_player_card.gd")
const ROUTES := {
"heroes_vs_villains": "res://scenes/beatem_up/reinvent/heroes_vs_villains.tscn",
"code_city_brawl": "res://scenes/beatem_up/code_city_brawl.tscn",
Expand All @@ -18,9 +19,9 @@ const ROUTES := {
const SMOKE_FLAG := "--smoke-routes"
const SMOKE_FRAMES := 30
## Booth mode (`<game> -- --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=<name>` opens a route at launch, with or
## without --booth. Both are off unless passed.
## Quit does nothing, and after IDLE_SECONDS without input any route returns to the attract route
## (the hub, whose rotating previews are the attract screen, unless --attract names another).
## `--route=<name>` 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
Expand All @@ -35,13 +36,31 @@ const SESSION_LOG_DEFAULT := "user://booth"
## time every two seconds, so tools/booth/watchdog.sh can restart a game that hangs. Off unless
## passed; the watchdog passes it.
const HEARTBEAT_PREFIX := "--heartbeat="
## Booth game over: games call booth_game_over() when the player loses. A booth player gets
## `--retries=<n>` retries (default 0); after that the router lays NextPlayerCard over the game
## and, NEXT_PLAYER_SECONDS later, opens the attract route for the next player. `--attract=<name>`
## picks the attract route for this and the idle return, from ATTRACT_ROUTES. Outside booth mode
## games are unchanged.
const ATTRACT_PREFIX := "--attract="
## Menus that can wait for the next player: each starts its own music (the hub through
## AudioService). Game routes would start playing by themselves, and the platformer menu would be
## silent after a beat 'em up stops the shared music.
const ATTRACT_ROUTES: Array[String] = ["hub", "beatem_up", "runner"]
const RETRIES_PREFIX := "--retries="
const NEXT_PLAYER_SECONDS := 6.0
var busy := false
var veil: ColorRect
var booth := false
var idle_limit := IDLE_SECONDS
var idle_seconds := 0.0
var session_log: BoothLog
var heartbeat: BoothHeartbeat
var attract := "hub"
var retry_limit := 0
## Game overs since the attract route last opened: one player's, as far as a booth can tell.
var game_overs := 0
var next_player: NextPlayerCard
var next_player_left := 0.0
func _ready() -> void:
# The veil tween must keep running while a microgame has the tree paused.
process_mode = Node.PROCESS_MODE_ALWAYS
Expand All @@ -57,7 +76,11 @@ func _ready() -> void:
if SMOKE_FLAG in args:
smoke_routes.call_deferred()
return
attract = attract_route(args)
retry_limit = retry_count(args)
set_booth(BOOTH_FLAG in args)
if booth:
print("AppRouter: booth attract route %s, %d retries per player" % [attract, retry_limit])
var log_path := session_log_path(args, booth)
if log_path != "":
start_session_log(log_path)
Expand Down Expand Up @@ -86,6 +109,10 @@ func go(route: String, reason := "menu") -> void:
get_tree().paused = false
if session_log:
session_log.route_changed(route, reason)
if route == attract:
game_overs = 0
Comment thread
coderabbitai[bot] marked this conversation as resolved.
if next_player:
next_player.hide()
if route == "hub":
AudioService.play_default_music()
await create_tween().tween_property(veil, "modulate:a", 0.0, 0.25).finished
Expand Down Expand Up @@ -143,16 +170,67 @@ static func heartbeat_path(args: PackedStringArray) -> String:
return arg.trim_prefix(HEARTBEAT_PREFIX)
return ""

## The booth's attract route from `--attract=<name>`: "hub" when absent or not one of
## ATTRACT_ROUTES (with a warning).
static func attract_route(args: PackedStringArray) -> String:
for arg in args:
if arg.begins_with(ATTRACT_PREFIX):
var route := arg.trim_prefix(ATTRACT_PREFIX)
if route in ATTRACT_ROUTES:
return route
push_warning("--attract %s is not one of %s; the booth returns to the hub"
% [route, ", ".join(ATTRACT_ROUTES)])
return "hub"

## Retries a booth player gets, from `--retries=<n>`: 0 when absent or not a whole number.
static func retry_count(args: PackedStringArray) -> int:
for arg in args:
if arg.begins_with(RETRIES_PREFIX):
var value := arg.trim_prefix(RETRIES_PREFIX)
if value.is_valid_int() and value.to_int() >= 0:
return value.to_int()
push_warning("--retries needs a whole number, not %s; using 0" % value)
return 0

## Games call this when the player loses, before showing their own retry screen. Returns true when
## the booth takes over: the player has used their retries, so the game shows nothing and the
## router thanks them and sends the booth to the attract route. Always false outside booth mode,
## and while a route change is already under way (it takes the player elsewhere anyway). The
## session log counts every game over, booth or not.
## `score_line` is shown on the card, in the game's own style ("SCORE 01234").
func booth_game_over(score_line := "") -> bool:
if session_log:
session_log.count_game_over()
if not booth or busy:
return false
game_overs += 1
if game_overs <= retry_limit:
return false
if next_player == null:
next_player = NextPlayerCard.new()
add_child(next_player)
move_child(next_player, 0) # Under the veil, so the route fade covers it.
next_player_left = NEXT_PLAYER_SECONDS
next_player.present(score_line, next_player_left)
return true

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:
# A card counting down over a paused, finished game would strand it: hand off right away.
var finishing := not enabled and next_player != null and next_player.visible
booth = enabled
idle_seconds = 0.0
AudioService.set_attract(enabled)
game_overs = 0
if finishing:
go(attract, "game_over") # The route change hides the card.
elif next_player:
next_player.hide()
set_process(enabled)
set_process_input(enabled)
if enabled:
Expand All @@ -166,13 +244,23 @@ func _input(event: InputEvent) -> void:
idle_seconds = 0.0

func _process(delta: float) -> void:
# The game-over card owns the screen until its countdown sends the booth to the attract route.
if next_player and next_player.visible:
next_player_left -= delta
next_player.set_seconds(next_player_left)
if next_player_left <= 0.0 and not busy:
# The route change hides the card; if the attract scene fails to load, try again after
# another countdown rather than every frame.
next_player_left = NEXT_PLAYER_SECONDS
go(attract, "game_over")
Comment thread
coderabbitai[bot] marked this conversation as resolved.
return
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", "idle")
if scene == null or scene.scene_file_path != ROUTES[attract]:
go(attract, "idle")
Comment thread
coderabbitai[bot] marked this conversation as resolved.

func smoke_routes() -> void:
# Let the launch scene finish its opening frames, as a player would, before switching.
Expand Down
18 changes: 14 additions & 4 deletions hoppys-adventure-arcade/app/booth_log.gd
Original file line number Diff line number Diff line change
Expand Up @@ -9,10 +9,13 @@ extends RefCounted
## {"event":"start","launch":"2026-11-30T09:58:12.345","session":1,"game":"heroes_vs_villains",
## "time":"2026-11-30T10:02:03"}
## {"event":"end","launch":"2026-11-30T09:58:12.345","session":1,"game":"heroes_vs_villains",
## "time":"2026-11-30T10:04:26","duration_s":143.2,"ended_by":"idle","retries":0}
## "time":"2026-11-30T10:04:26","duration_s":143.2,"ended_by":"idle","retries":0,
## "game_overs":1}
## A session is one visit to a game route. Re-entering the same game (the runner's or a platformer
## level's Retry) counts as a retry within it. `ended_by` is "menu" (the player left through the
## game's own menu), "idle" (booth mode's idle return) or "quit". A start with no end means the game
## level's Retry) counts as a retry within it. `game_overs` counts the session's losses, stage
## retries that stay in the game included. `ended_by` is "menu" (the player left through the
## game's own menu), "idle" (booth mode's idle return), "game_over" (booth mode sent a player who
## had used their retries back to the attract route) or "quit". A start with no end means the game
## stopped without quitting: a crash, a power cut or a forced kill.
## tools/booth/sessions_report.py summarizes a folder of these files.

Expand All @@ -28,6 +31,7 @@ var launch := ""
var game := ""
var session := 0
var retries := 0
var game_overs := 0
var started_msec := 0
## Set after the first failed write: the game keeps running, unlogged, rather than retrying.
var failed := false
Expand Down Expand Up @@ -57,6 +61,7 @@ func route_changed(route: String, reason: String) -> void:
game = route
session += 1
retries = 0
game_overs = 0
started_msec = Time.get_ticks_msec()
_write({"event": "start", "session": session, "game": game,
"time": Time.get_datetime_string_from_system()})
Expand All @@ -69,9 +74,14 @@ func end(reason: String) -> void:
var seconds := (Time.get_ticks_msec() - started_msec) / 1000.0
_write({"event": "end", "session": session, "game": game,
"time": Time.get_datetime_string_from_system(), "duration_s": snappedf(seconds, 0.1),
"ended_by": reason, "retries": retries})
"ended_by": reason, "retries": retries, "game_overs": game_overs})
game = ""

## AppRouter.booth_game_over() calls this for every loss in the session in progress.
func count_game_over() -> void:
if game != "":
game_overs += 1

## Open, append and close per event, so a crash loses at most the line being written.
func _write(fields: Dictionary) -> void:
if failed:
Expand Down
44 changes: 44 additions & 0 deletions hoppys-adventure-arcade/app/next_player_card.gd
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
extends Control
## The booth's game-over card. AppRouter lays it over a finished game when the booth player has
## used their retries: GAME OVER, the game's score line, a thank-you, and a countdown to the next
## player. It takes no input; the router opens the attract route when the countdown ends.
const Style = preload("res://scenes/main_menu/menu_style.gd")
var summary: Label
var countdown: Label

func _init() -> void:
name = "NextPlayerCard"
set_anchors_and_offsets_preset(Control.PRESET_FULL_RECT)
# Stop clicks reaching the game underneath.
mouse_filter = Control.MOUSE_FILTER_STOP
theme = Style.theme()
var dim := ColorRect.new()
dim.color = Color(0.01, 0.02, 0.04, 0.84)
dim.set_anchors_and_offsets_preset(Control.PRESET_FULL_RECT)
dim.mouse_filter = Control.MOUSE_FILTER_IGNORE
add_child(dim)
var center := CenterContainer.new()
center.set_anchors_and_offsets_preset(Control.PRESET_FULL_RECT)
center.mouse_filter = Control.MOUSE_FILTER_IGNORE
add_child(center)
var column := VBoxContainer.new()
column.add_theme_constant_override("separation", 16)
center.add_child(column)
var title := Style.label("GAME OVER", 48)
summary = Style.label("", 24, Style.MINT)
var thanks := Style.label("Thanks for playing!", 26)
countdown = Style.label("", 18, Color("afbdc9"))
for label in [title, summary, thanks, countdown]:
label.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER
column.add_child(label)
hide()

## Show the card with the game's score line ("" for none) and the seconds left.
func present(score_line: String, seconds: float) -> void:
summary.text = score_line
summary.visible = score_line != ""
set_seconds(seconds)
show()

func set_seconds(seconds: float) -> void:
countdown.text = "Next player in %d" % ceili(maxf(seconds, 0.0))
1 change: 1 addition & 0 deletions hoppys-adventure-arcade/app/next_player_card.gd.uid
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
uid://c5a3iwsssrkab
1 change: 1 addition & 0 deletions hoppys-adventure-arcade/modes/beatem_up/game.gd
Original file line number Diff line number Diff line change
Expand Up @@ -603,6 +603,7 @@ func game_over() -> void:
playing = false
state = "game_over"
_update_hud()
if AppRouter.booth_game_over("SCORE %05d" % score): return # Booth: the next player's turn.
show_overlay("REVIEW FAILED", "Retry %s with 3 HP and fresh equipment." % Catalog.TITLES[stage_index],"Retry current stage",retry_stage)

func boss_defeated() -> void:
Expand Down
1 change: 1 addition & 0 deletions hoppys-adventure-arcade/modes/beatem_up/reinvent/game.gd
Original file line number Diff line number Diff line change
Expand Up @@ -576,6 +576,7 @@ func game_over() -> void:
playing = false
state = "game_over"
_update_hud()
if AppRouter.booth_game_over("SCORE %05d" % score): return # Booth: the next player's turn.
show_overlay("REVIEW FAILED", "Retry %s with 3 HP and fresh equipment." % Catalog.TITLES[stage_index],"Retry current stage",retry_stage)

func boss_defeated() -> void:
Expand Down
1 change: 1 addition & 0 deletions hoppys-adventure-arcade/modes/platformer/level.gd
Original file line number Diff line number Diff line change
Expand Up @@ -297,6 +297,7 @@ func _cancel_knockout() -> void:

## The cutaway holds its final pose behind the existing results fade.
func _reveal_results(delay: float) -> void:
if not won_run and AppRouter.booth_game_over("%06d POINTS" % score): return # Booth: next player.
if not won_run: knockout.callout.hide()
overlay.color = RESULTS_SHADE
overlay.modulate.a = 0.0
Expand Down
Loading
Loading