Imported from Daoductrung/PlayAural (
AGENTS.md). Install upstream withnpx skills add Daoductrung/PlayAural. Copyright stays with the author.
AGENTS.md - PlayAural AI Development Guide
Concise mandatory rules for AI agents working on PlayAural. CLAUDE.md is the
detailed source of truth; keep this file synchronized with it. If the two ever
conflict, follow CLAUDE.md and update AGENTS.md.
Project
PlayAural is an audio-first multiplayer gaming platform for screen reader users. It is GPL-licensed and has four first-party components:
server/: Python 3.11 asyncio WebSocket server, games, auth, tables, persistence, localization, ratings, voice authorization.client/: Python wxPython desktop client with keyboard/screen-reader UX.web_client/: modular vanilla JS PWA with ARIA live output, keyboard and touch menu navigation, capped history buffers, browser audio/Web Speech, and table voice chat.mobile_client/: Expo/React Native/TypeScript Android-first touch and self-voicing client.
All gameplay communication is WebSocket JSON packets. Table voice chat is
server-authorized but media flows through the separate LiveKit service. Never
merge voice media into gameplay WebSocket traffic. Voice membership is
runtime-only table state unless a future feature explicitly defines retention,
cleanup, and account-deletion behavior.
Focused menu help uses the semantic menu_description request with the current
menu and stable item ids. The server validates both against the visible menu;
never route this UI command through gameplay keybind dispatch.
Server-requested voice joins reuse voice_join_info with
server_requested=true; clients connect listen-only and must never enable the
microphone without a separate explicit user action. voice_context_closed
cancels both pending and active joins.
Live device handover carries confirmed table-voice listening intent through a
fresh context-bound server request without broadcasting a false leave/join
pair. Never transfer a microphone or device selection; the replacement client
starts listen-only, and only an unexpired continuation grant may survive a
rapid second handover. Keep confirmed presence during that bounded grant;
successful reconfirmation is silent, while client rejection, media-connection
failure, or grant expiry clears it and announces one real disconnect. Clients
must revoke failed join grants, and the server must close late confirmations.
Table voice controls are keyed only by immutable account UUID. Host microphone
moderation is server-authoritative and must update both the table policy and
LiveKit publish permission; client microphone locks are defense in depth, not
the authority. Personal mute and volume are listener-private mixer state. Both
are checkpoint-only properties of the durable table: preserve them across a
game switch and server restore, preserve settings about a departed target so
they apply if that account rejoins, clear preferences owned by a listener when
that listener leaves the table, and clear everything when the table is
destroyed. Never persist these controls in a manual saved game or key them by
username, display name, participant label, or seat.
clear_ui clears server-owned menus and inputs only; table context and unified
audio/voice lifecycle packets own runtime teardown.
User blocks are directional persistent records retained until explicit unblocking or either account is deleted. Any block between two accounts is a mutual direct-contact barrier: it blocks friend requests, private messages, table invites, ordinary text chat delivery, and presence notifications in both directions. Applying a block atomically removes their friendship, requests in either direction, and queued social notifications. It does not remove players from shared tables, prevent reserved-seat recovery, or mute table voice chat. It does prevent either account from newly entering a table hosted by the other or being brought together by manually restoring a saved table; later host transfer never evicts an existing participant. Optional global notifications attributable to a blocked account, such as table creation, are also suppressed. Enforce these boundaries in shared database/server handlers, not only through menu visibility. Manual saved-table restoration is owner-scoped and all-or-nothing: validate the complete game/member payload and current social admission before creating a table, preserve the save on every failure, and give actionable unblock guidance only for blocks the restorer controls.
Account identity has three distinct roles. The database UUID is the immutable,
globally unique account id and owns sessions, relationships, moderation targets,
statistics, and every other durable relation. The username is an immutable,
unique login/routing handle; it is currently also the public label, but must
never be rewritten as a display-name change. A future display_name is mutable,
non-unique presentation data only: resolve it through the UUID/username owner,
never authenticate, authorize, route, join, or persist a relation by it. Stored
chat, report, and result names are deliberate historical snapshots paired with
immutable ids, not live identity keys. Server-owned identity names are reserved
through the shared registry and cannot be registered by users. When display
names are introduced, identity-sensitive profiles, reports, moderation views,
and confirmations must expose the owning username, while every action id and
payload remains bound to the UUID.
Bot identity is likewise structural, never name-derived. Player.id and
Player.is_bot are authoritative; bot_name_base is serialized presentation
data used to preserve a bot's personality across save/load. Localized base
names may match usernames or other bot bases. Every production bot must expose
a collision-free table label: use the bare base while it is unique, add the
localized bot marker only while a human or another bot shares that base, and
add a stable ordinal when multiple bots share it. A replacement bot retains the
disconnected human's account UUID and reclaim metadata. Reconcile labels across
joins, leaves, replacements, reconnects, and save/load. Never reserve localized
bot bases from registration, infer bot status from text, or route an action by
an unqualified base name.
Commands
Run server tests from the repo root through uv:
uv run --project server --extra dev python -m pytest server/tests -q
uv run --project server --extra dev python -m pytest server/tests/test_file.py::test_name
Run focused tests while iterating. Run the full suite before landing
cross-subsystem changes or features. Tests are parallel-safe with -n auto;
keep new tests deterministic and avoid RNG-dependent exact assertions.
Other common commands:
cd server && python -m server
python client/client.py
python -m http.server 8080 --directory web_client
node web_client/scripts/generate-sound-manifest.mjs
npm --prefix web_client run test:history
cd mobile_client && cmd /c npm install && cmd /c npm run generate:sounds && cmd /c npm run generate:locales
cd mobile_client && cmd /c npm run typecheck && npx expo start
Core Architecture
server/games/currently registers 48 games. Categories arecards,dice,board,poker,arcade, andmisc; user-facing category labels must be localized. The Play menu uses dynamic counts, not hardcoded category counts.- Games are
@dataclassclasses registered with@register_game, inherit fromGame, and may add utility mixins such asGridGameMixinorTurnTimerMixin. - Persistent game state must be dataclass fields serialized safely by
Mashumaro. Runtime-only state belongs in
__post_init__. - Use the canonical
PlayerandActionContexttypes fromserver/game_utils/or theserver/games/base.pyre-exports. Do not create duplicate base player/context classes. - Every game implements metadata methods including
get_name,get_type,get_category, player bounds, andget_supported_leaderboards. - Use
get_active_players()for gameplay, results, and winner logic. Do not iterateself.playersfor active-player decisions unless spectators/bots are deliberately included. set_turn_players(players)resetsturn_indexto 0. Do not calladvance_turn()immediately afterward.on_tick()must callsuper().on_tick(), process scheduled sounds, process sequences, and gate bot logic when sequences pause bots.
Timed Flows
Use SequenceRunnerMixin for delayed gameplay/audio flows that must survive
save/load: movement animations, reveals, captures, roulette, cutscenes, and any
legacy event-queue style flow.
Rules:
- Build explicit
SequenceBeatlists withsound_op,localized_sound_op, andcallback_op. - Use
SequenceBeat.after_audio(duration_ticks, wait_ratio=...)when the next beat should overlap a measured asset; the ratio delays the following beat, so add a following beat when the sequence must remain active. Keep duration data-driven instead of hardcoding replacement-sensitive delays. - State changes happen in callbacks, not because a sound played.
- Payloads must be Mashumaro-safe primitives, lists, dicts, or safe dataclasses.
- Prefer
SEQUENCE_LOCK_GAMEPLAY; keep info/status actions available. - Use
pause_bots=Truewhen bots must wait. - Cancel stale tagged sequences when resetting a phase or round.
Grid Games
For GridGameMixin, serialized fields must use exact safe types:
grid_cursors: dict[str, GridCursor]grid_row_labels: list[str]grid_col_labels: list[str]
Do not store raw tuples or ad-hoc cursor dicts in serialized grid state.
Actions, Menus, and Focus
Action sets resolve in this order: turn -> lobby -> options -> standard.
- Turn-menu gameplay actions use
show_in_actions_menu=False. - Game-specific info/status actions belong in
create_standard_action_set, not the turn set. Action.include_spectatorsand matching keybindinclude_spectatorsmust agree.- Public information may use
include_spectators=True; private or mutating gameplay actions normally must not.
Game code does not paint turn menus directly. It records turn-menu intent with:
refresh_menus(player=None): mark one player or everyone dirty.request_menu_focus(player, action_id): one-shot focus jump for one player.
Per-client isolated rendering is supported and important for individualized
flows: use refresh_menus(player), request_menu_focus(player, action_id), or
framework-owned per-player input/status overlays when only that player should
see a menu replacement. This is a deliberate tool, not an always-on rule.
Before modifying or creating a game, understand the gameplay flow and decide
whether a change is private to the actor or reflects public table state. Use
strict per-player isolation for private/action-specific transitions such as
choosing a suit after a card play; use table-wide refreshes when other players'
visible choices or public information genuinely changed.
Status overlays are the sanctioned exception: use status_box(...) for static
snapshots and live_status_box(...) for dynamic state panels. Games still must
not call user.show_menu() / user.update_menu() directly.
Server navigation requested while a status box is open is deferred and replayed
after the box closes; active editbox/action-input states block navigation
without queuing it. In-game overlays such as Host Management must enter through
modal-aware server navigation, not direct show calls from game actions.
flush_menus() is sealed and framework-owned. Games must not override or call
menu flush internals except tests calling flush_menus() at production
boundaries after direct execute_action, _action_*, on_start, or on_tick.
Do not resurrect old rebuild_* / update_* patterns.
Customize painting through:
before_menu_build(player): idempotently sync dynamic action sets and order.build_menu_items(player, user) -> MenuBuild: custom item/grid layout.
Client focus doctrine:
- Same-menu repaint preserves focus by item id; if the focused item leaves the
menu, clients fall forward to the next surviving item from the old logical
order, then backward to the previous surviving item, and only then to a
clamped numeric fallback.
NetworkUserskips identical same-menu repaints with no focus directive. - Anchors reset only when the menu identity changes, no stable old item
survives, or
selection_idexplicitly jumps focus. - Keep disabled-but-visible persistent controls when they anchor touch or screen
reader focus. Use
request_menu_focusonly for deliberate action-driven jumps. - Informational server-menu rows use
MenuItem(read_only=True)when they need stable ids. Rows without action ids normalize to explicit read-only protocol data automatically; do not add no-op handler branches for them. Status-box rows are the intentional exception because activating any row closes the box. - Server-owned confirmation and consent menus use
server.ui.confirmation.show_confirmation_menu. It announces the localized prompt and renders that same prompt as the first stable read-only row, then places decision actions after it with the safe cancel/decline action last so Escape cancels. Do not hand-build Yes/No or Accept/Decline menus. - Open
MenuInputselectors repaint live through sealed flushes; use stable option ids and declare contextual non-actions throughread_only_options. PendingEditboxInputprompts do not repaint passively. If a selector must freeze public mutations, declarelocks_gameplay=Trueand use_gameplay_input_lock_owner()in the game's actor/permission checks rather than hardcoding its action id; information actions may remain available. Specialized selectors override the idempotent_build_action_menu_input_items(...)hook; one-time TTS or sound belongs in_on_action_menu_input_opened(...)so passive/stale-event repaints stay silent._on_action_input_cancelled(...)must only clean game-owned draft state because the framework also calls it when an input becomes stale or its human seat is removed/replaced. - The Escape/actions menu auto-refreshes in place through sealed
flush_menus(). Games must not repaint or block it manually. - Framework-owned exits restore focus to the opener when possible: actions-menu Back, actions selected from the actions menu, action-input Cancel/submit, leave-confirmation No, status-box close, and server menus that close after a selection. Use stable action ids so this works.
- Use
status_box(player, lines)for static snapshots/help/limited reveals. Uselive_status_box(player, box_id, builder, focus_id=None)for dynamic state panels such as boards, standings, clocks, rosters, and detailed scores. Live builders return strings orMenuItems, refresh only through the sealed flush afterrefresh_menus(), and must use stable semantic item ids whenever rows can reorder, appear, or disappear.
Touch Clients
Use server/game_utils/client_types.py helpers (is_touch_client,
is_touch_client_type, uses_self_voicing_settings) instead of raw
client_type checks. Touch clients include web and mobile; mobile is not web.
Rules:
- Time-critical reaction actions must be visible in the turn menu during active windows for touch clients.
- Utility actions normally reached by desktop keybinds should be touch-visible when useful.
- Turn order for touch menus: reactions, primary card/tile/play actions, multi-select confirmation, utilities.
- Touch standard-action order: game-specific info,
check_scoresif supported,whose_turn,whos_at_table. - Use
_order_touch_standard_actions(action_set, target_order); do not copy manual ordering loops. Keep desktop ordering separate. - In mobile self-voicing mode, a three-finger single tap requests the focused menu description. Defer it through the recognizer's shared multi-finger multi-tap window and cancel it when another chord starts so it never fires during the global three-finger triple-tap toggle.
Keybinds
setup_keybinds() must call super().setup_keybinds() first. Gameplay
keybinds use KeybindState.ACTIVE; lobby-only actions use IDLE; truly global
actions use ALWAYS.
Keybinds are scoped by state. The same physical key may be reused across
non-overlapping states when the actions cannot both be active. For example, the
base b Add bot binding is IDLE, so a game may safely bind b to an
ACTIVE gameplay action, as UNO does. Likewise, enter can start a lobby game
while idle and select a grid cell while active.
Base/client bindings to respect: enter, escape, b, shift+b, f3, t,
s, shift+s, ctrl+m, ctrl+q, ctrl+u, ctrl+s, ctrl+i,
f1, ctrl+f1. Plain F1 is the client-owned focused-menu-description
command and must send the semantic menu_description request, never a game
keybind; Ctrl+F1 remains How to Play. Do not reuse ALWAYS bindings or
same-state base/client bindings for unrelated game-specific actions unless
deliberately matching the standard behavior. When reusing a key across states,
keep the scope explicit and add coverage for the intended separation.
Options
Use declarative GameOptions with option_field().
- Every option must have working logic; no dead options.
- Every option needs a localized
change_msgin EN and VI. MenuOptionneedschoice_labelsfor every raw value.TeamModeOptionuses shared team-mode helpers.- The framework keeps
start_gamevisible throughout the waiting lobby.validate_start()owns player-count checks and combines them with the game'sprestart_validate()errors; never hide Start merely because setup is invalid. - Every start requires at least one active human-controlled player. Bots may satisfy a game's numeric player count, but spectators do not satisfy this human requirement. Reject bot-only starts in shared validation and recheck after disconnected lobby seats are converted to replacement bots; never enter gameplay and rely on abandoned-table cleanup to reject the match.
- Table ownership is independent from gameplay-seat role. A present host who is spectating retains host controls and may change waiting-lobby options. In an already-started game, that host may supervise bot-controlled seats and keeps the table alive; an ordinary spectator never does. This exception does not permit a bot-only start. If no active seat remains, retain the table but keep gameplay paused until the host restarts or closes it.
- Treat a table as the durable social and voice session and its
Gameas a replaceable activity. A host game switch must validate the complete live roster and target capacity before mutation, then keep the table id, host, privacy, bans, live human roles, dedicated bots, and voice context while creating a fresh target lobby. Do not carry match options, teams, readiness, timers, reclaim rights for disconnected seats, or activity-bound consent; stop and detach all old-game runtime state, cancel old-game invitations, and suppress the ordinary new-public-table notification. prestart_validate()must block impossible deals, unsupported option combinations, and team-mode conflicts with clear localized errors.
Audio and Accessibility
Audio-first is mandatory. Every important state change needs TTS and/or sound.
- Every
speak_l()andbroadcast_l()call must pass explicitbuffer=. - Buffers:
chatfor shared chat,privatefor private messages,gamefor gameplay,systemfor settings/connection/moderation, andmiscfor minor non-game informational output. - Use
history=Falseonly for transient UI chrome such as menu-open/close feedback and one-time selection prompts already represented by the current interface. Clients must still speak it subject to the selected buffer's mute state. Errors, gameplay results, and durable information stay in history. - Desktop, Web, and mobile share one buffer-mute contract. Muting
allmakes every buffer effectively muted and blocks individual mute changes until it is unmuted. A directly muted source retains its bounded runtime backlog but omits new items fromall; unmuting it merges that retained backlog intoallin original arrival order without duplicates. A global mute keeps the combined backlog. An effective Chat or Private Messages mute suppresses speech and its related notification sounds. Persist only canonical direct-mute names, never message history. - Use
play_sound,user.play_sound,play_music, ambience helpers, scheduled sounds, or sequences as appropriate. - All server-driven SFX, music, and ambience use the versioned
audiocommand contract inserver/audio.py. Do not add separate packet types or client-specific routing. Asset paths and command values must be validated. - Optional 3D positions use listener-relative
(x, y, z)coordinates with the listener at the origin facing+Y. The server is the positioning authority and derives ordinary pan from the same point for non-HRTF fallbacks. Desktop, Web, and native mobile playback consume the same spatial contract. Use a fixed position for a point event, atomicsegmentsfor a finite multi-stage sound, and stable-handleupdatecommands for a replayable moving source. - One-shot notification SFX may declare their related output
buffer; clients suppress those cues when that buffer is effectively muted. Do not attach a buffer to managed or looping audio. - Randomized numbered one-shot SFX use the validated
familyfield. Clients select from dynamically discovered<family><positive integer>assets; do not hardcode a variant count or use families for loops, music, or ambience. - Finite multi-stage SFX use one atomic
playcommand with completesegmentsand a stable handle. Clients validate and preload every asset before starting, then schedule each onset from decoded frame counts, pitch, and the validated per-segment next-onset ratio on a shared audio clock. A ratio of1is contiguous; a smaller ratio overlaps authored tails without altering pitch, and authored silence remains part of the asset. Segment motion spans that segment's decoded duration. Renderers keep every overlapping HRTF tail connected until it has finished rather than clipping it at the last decoded frame. Never approximate these chains with separate packets, server ticks, or replacement-sensitive duration constants. Any load or scheduling failure cancels the whole chain, and finite sequences remain runtime-only rather than enteringactive_audioreplay state. - Positioned
playcommands may carry a completeattenuationobject usingnone,linear,inverse, orexponential. Active curves always include reference/maximum distance, rolloff, and minimum/maximum gain; clients must reject partial or out-of-range objects. Omission and explicitnoneboth spatialize without volume falloff. Evaluate the versioned formulas in the shared mixer exactly once and keep renderer/backend distance gain neutral. - Replayable positioned sources move through a stable-handle
updatecommand carrying complete origin/destination positions, integer duration/elapsed milliseconds, and a named easing curve. The server advances and persists authoritative trajectory state; clients interpolate on their audio clocks. Reconnect resumes from persisted elapsed state, while stop and replacement generations cancel stale interpolation. Never persist process clock values. - Looping SFX use stable handles and explicit stop; music supports fade
pause/resume/stop; ambience uses independent
global,player, orcontextscope plus a stable layer. Switching music or one ambience layer crossfades without disturbing unrelated layers. - Stable handles are client-global. Server replay state must mirror handle and layer replacement exactly; never leave two overlapping sources that one recipient's client cannot own simultaneously. Reject a private operation that would partially replace public replay state.
playmay set an independent normalized source gain. Stable-handleupdatemay automate source gain, position, or both concurrently. Keep authored volume, source gain, attenuation, fade envelope, bus/duck gain, and user master volume as separate mixer stages. Persist current replayable gain and unfinished automation, never process-clock timestamps.- Environmental boundary blends keep independent ambience layers playing and phase coherent. Convert normalized zone weights to linear or equal-power source-gain targets; do not restart stems. Zero-gain layers remain active until explicit lifecycle teardown.
- Ambience stems may define any combination of intro, loop, and outro assets.
With seamless stem playback, intro-to-loop and loop-to-outro are contiguous
boundaries with no fade or crossfade; fades apply only to starting, replacing,
pausing, or forcibly stopping independent sources. An ambience stop uses an
immediate no-fade loop-to-outro splice by default so long loops cannot leak
across a game or table teardown. Use
outro_mode="boundary"only when the caller deliberately accepts finishing the current loop iteration at its authored seam. Waiting lobbies never own background music. Game completion and reset retire every replayable layer: ambience may finish through its authored outro, while already-playing one-shot result cues may complete. Table exit usesstop_allwithplay_outros=True. Reconnect replay joins the loop and never repeats an already-heard intro. - Named buses, priority/max-instance limits, and source-lifetime ducking are protocol data, not game/client hardcoding. User volume remains the master. Async loads and fades must be generation-guarded against stale resurrection.
- Cross-client attenuation and automation math must use the shared protocol-v3 conformance vectors at the repository root. Extend that corpus when formulas or easing curves change; do not copy independent expected values into each client suite.
- Ducking is a dormant, opt-in capability. Do not add
duckingto first-party gameplay commands until a future feature deliberately enables and tunes it; keep zero-duck defaults behaviorally identical to an engine without ducking. - Positioned native mobile playback uses the local Cosmos/miniaudio Expo module
and the complete official Steam Audio 4.8.1 artifact set. Treat its platform
libraries, headers, licenses/notices, and SHA-256 manifest as one update.
postinstallmust fail closed on version, hash, ABI, or Android 16 KiB alignment drift. Preserve partial frames and HRTF tails across arbitrary device callback sizes. Reserve bounded source capacity and collision-free IDs before async creation, and release them on every failure or lifecycle exit. iOS Simulator must use the explicit non-HRTF platform fallback. - Android playback must preserve the system-selected wired, Bluetooth, or
speaker route; game-audio setup must never force speakerphone routing. Keep
ExpoAV as the single audio-focus coordinator, and retain the guarded
expo-audionative routing patch applied by the mobilepostinstallscript. Android must buildexpo-audiofrom that guarded local source, not its otherwise-unpatched precompiled artifact. - Mobile LiveKit voice must remain media-quality audio. Android uses
MODE_NORMAL,STREAM_MUSIC,USAGE_MEDIA, no forced output selection, and no LiveKit focus lease even while publishing the microphone. iOS disables LiveKit's automatic call profile: listening usesplayback/default, while explicit microphone publishing usesplayAndRecord/default, nevervoiceChatorvideoChat; keep stereo Bluetooth A2DP available but exclude the mono hands-free profile. Retain the guarded LiveKit iOS native patch and its fail-closed postinstall tests. - Persist only replayable layers in
active_audiowith recipient and paused state. It follows the containing table/save retention and deletion lifecycle; explicit stops, resets, transfers, and replacements prune stale state. One-shots and mixer state are runtime-only. Gameplay WebSockets carry audio control JSON only; LiveKit remains the separate voice-media path. - Provide information actions for state queries such as hand, board/table, counts, status, scores, and whose turn.
Localization
All player-facing strings go through Fluent. No hardcoded English may reach players.
Use "user" / "người dùng" for account-level entities (presence, profiles, friends, blocks, and moderation). Reserve "player" / "người chơi" for game-table participants; do not reuse game-player error strings for account lookups.
- Use
speak_l,broadcast_l,broadcast_personal_l, localized option/pref helpers, and localized sequence helpers. - Pass raw data as kwargs and let Fluent format lists, plurals, and selects.
- Account gender uses the canonical
Gendermodel inserver/gender.py. Normalize values read from legacy or external data, reject unsupported mutation values, and default missing/deleted/unknown accounts to unspecified. Keep this mutable profile field on live users and in the account database; never duplicate it into serialized game state. - Games query
get_player_gender()/player_localization_kwargs()so disconnected and bot-controlled human seats resolve by immutable account ID. Localized identity references use validated sibling variables such as$player_genderwithGENDER_TERM(...); standard game broadcasters infer these variables fromPlayervalues or unique player names. Direct server messages must supply them with the shared gender-kwargs helper. - Grammar and game-specific forms belong in locale data, not language branches.
Use a supported shared
GENDER_TERMform and, only when a game needs its own vocabulary, an allowlisted context backed by<context>-gender-term-<form>. Unspecified and non-binary values use the locale's neutral fallback. - Maintain EN/VI parity: same keys, data-bearing variables, and plural/select
arms. A locale may omit a variable used only as a
GENDER_TERM(...)selector when its natural sentence does not need gender; if used, the selector name must still match the source key. - Agents author both EN and VI strings in this repo, but Vietnamese is provisional and should be flagged for native review when quality matters.
- Prefer writing locale keys before feature code so every announcement path is planned.
- Locale discovery and fallback must stay dynamic. Do not hardcode language branches in feature code; new languages should be added through locale files and metadata/registry layers. Missing translated strings or documentation must fall back to English rather than exposing raw keys to players.
- A live language change updates client-owned chrome before later localized packets, rebuilds that player's locale-bound action sets, and restores the semantic parent menu/focus. Browser clients must serialize their asynchronous locale-bundle load with later WebSocket packets so no stale language flashes.
- Validate server Fluent changes with
server/tools/compare_locales.py; it reports missing keys, obsolete keys, variables, select/plural arms, and attributes. Do not leave obsolete target keys behind after refactors.
String Localization & Contextual Broadcasting Standard
Whenever a new game is added or an existing game is modified with player-facing string changes, perform a complete string and broadcast-context audit.
- Every actor-attributable gameplay broadcast must have distinct personal
first-person and public third-person forms: the actor hears "You ...", while
other listeners hear " ...". Use
broadcast_personal_l(...)or an equivalent per-listener localized helper. Do not send the actor the same third-person message as everyone else. Genuinely global events with no actor may use one shared form. - Evaluate every message against the complete state and audience matrix, including actor versus observer, player versus team, success versus failure, active versus waiting/resolving state, option variants, spectators, bots, reconnect/save restoration, and relevant brief-announcement variants.
- Errors, warnings, disabled-action reasons, confirmations, and gameplay notifications must identify the attempted action, the specific blocking or resulting condition, and the state values needed to understand what happened and what the player can do next. Avoid generic messages such as "You cannot do that" when a contextual explanation is available.
- Keep EN/VI keys and variables structurally synchronized, localize listener-dependent values per recipient, and add tests for both actor and observer wording plus important contextual and error branches.
Documentation
When writing or updating documentation, first read existing polished project manuals for similar games and follow their standard structure, formatting, and player-facing tone.
- Manuals must be beginner-friendly, accessible, and focused on how to play: overview, goal, turn flow, special mechanics, scoring, options, information actions, and shortcuts where relevant.
- Do not write manuals like changelogs, patch notes, design notes, or developer justifications. Leave out unnecessary implementation details and rationale that do not help a player understand the game.
- Keep vocabulary plain and concrete. Explain game terms before relying on them, especially for custom games or mechanics that may be unfamiliar.
- Keep EN/VI documentation synchronized in structure, meaning, terminology, and
attribution. Vietnamese docs should be natural and friendly, with terminology
aligned to the matching
.ftlstrings. - Community translation work must follow
TRANSLATING.md, including variable preservation, perspective-key parity, documentation fallback, and contributor credit metadata.
Scores, Leaderboards, and Teams
- Only games with real leaderboard support should expose
get_supported_leaderboards()entries. - Every result for a wins or rating leaderboard must declare
winner_idsby immutable player/account id. An empty list means a draw and must not create wins or losses. Never infer a competitive result from a name. - A real team or a game that supplies more than winner/loser placement must
store canonical
RATING_COMPETITORS_KEYdata built withrating_competitors_from_scores(...). Each participant appears exactly once; equal ranks are ties, while ids grouped in one competitor are teammates. - Rating calculation is side-effect free. Persist the completed result, aggregate stats, and validated rating updates in one database transaction. Raw model parameters are internal; player-facing views use the conservative skill score so a future tier policy can be layered on without changing the rating model or durable identities.
- Build every
PlayerResultthroughPlayerResult.from_player(...). A disconnected replacement bot remains owned by the reserved account, so its final result, stats, win/loss, and rating count for that account and a disconnect cannot dodge a loss. A completed seat substitution transfers ownership to the incoming account. A reversible host kick removes current table membership but retains the account-owned replacement seat so the same UUID can reclaim its complete context and result attribution; private-table admission must recognize that reservation. A kick-and-ban, account deletion, or other permanent removal rekeys the retained active seat to a fresh dedicated-bot UUID immediately, so no later result is attributed to the departed account; dedicated bots never receive durable player stats or ratings. Keep an unclaimed reversible reservation visible as a human-owned roster row so it cannot be mistaken for or removed as an ordinary bot. - Startup garbage collection derives valid game types and persisted stat keys from the live registry. It removes unregistered-game data, unsupported derived leaderboard aggregates, and invalid rating rows; do not maintain a second hardcoded cleanup schema.
- Scoreless games should not claim score support; score buttons are hidden and
s/shift+sare ignored silently. - Games using default score actions must keep
TeamManagersynchronized. - Non-point units need a localized
score_unit_keyin sharedgames.ftlor the game locale when unique. Units are display only; stored stats remain numeric. - Brief score checks speak one line per player/team in the
gamebuffer. - Detailed score checks normally use
live_status_box(...)with one line per player/team. - Team games use shared team arrangement. Call
_setup_team_manager_for_start(...); use_get_team_turn_players(...)when seating affects turn order. Do not build per-game team selection UI.
Persistence and Data Lifecycle
Any persistent feature must define and test:
- what is stored and why
- lifespan/retention
- cleanup/pruning of stale data
- account-deletion behavior
- migration/backward compatibility when schemas or supported games change
Game.on_discard() is the idempotent lifecycle hook for match-scoped caches,
bot observations, and similar memory that must not outlive its game instance.
The framework calls it whenever an instance is abandoned, including table
destruction, restart, game switching, and failed replacement preparation.
Games that launch asynchronous work must cancel it cooperatively here and drop
every job reference; background work must operate on an isolated snapshot and
must never call back into a discarded game. This hook does not replace the
retention and cleanup rules required for genuinely persistent data.
Do not add database rows, tables, saved runtime state, notifications, chat logs, tokens, invites, moderation records, or similar data without this lifecycle.
Chat Anti-Spam and Reports
- Apply chat throttles by immutable account ID and channel scope. Global chat is deliberately stricter than table chat; direct messages are isolated from both. Reconnects and switching channels must not bypass capacity.
- Automated detection may reject the spam-shaped message, but it must not mute, ban, lower reputation, or otherwise punish an account. Escalation creates a review-only System report after multiple time-separated incidents.
- Coalesce rapid retries into one incident. Automatic reports use a persistent
per-account/per-scope cooldown so restarts cannot flood staff, and only a
newly created report sends the localized staff alert and
system-buffer notification sound. - Automatic reports store versioned structured evidence and one bounded sample. They use the existing report lifecycle: retained until manual review and explicit cleanup, retained across target-account deletion, and included in database backups. Runtime limiter state is discarded on account deletion.
Server Power Management
Server reboot and shutdown flows must go through the centralized server power manager and admin UI. Do not add chat-command reboot/shutdown paths or per-game shutdown hooks.
- Planned reboot preserves active tables through transient table checkpoints, freezes framework-owned mutation during finalization, skips normal bot substitution on disconnect, and lets clients auto-reconnect.
- Planned shutdown clears active table checkpoints and must warn players to save anything they want to keep before the server goes offline.
- Transient checkpoints need kind, creation time, expiration, pruning, and account-deletion cleanup. Keep the one-day checkpoint TTL unless a future migration explicitly changes the lifecycle.
- Post-reboot no-show handling belongs in shared table/game framework logic: restored tables receive a grace window, then missing active players are replaced with bots only when at least one human has returned; tables with no humans eventually close through abandoned-table cleanup.
Database Maintenance
Live backup, cleanup, and compaction use the centralized reversible maintenance manager, never ad-hoc database calls from an admin handler.
-
Database startup is fail-closed. Never move, quarantine, replace, or rebuild an existing database automatically after an integrity, identity, version, or schema failure. Preserve the main file and sidecars for operator recovery.
-
Schema changes require an incremented SQLite
user_version, the PlayAuralapplication_id, a full preflight integrity check, and a validated durable pre-migration backup before any existing schema is changed. Reject databases created by newer server versions. Opening SQLite must never run retention or compatibility cleanup; destructive cleanup is an explicit operation. -
Schema version 2 rebuilds legacy
users.username_keystorage so the canonical non-null identity contract is enforced; version-zero and version-one upgrades must remain atomic, backed up, and row-preserving. -
Schema version 3 compatibility-folds username lookup keys and makes the immutable account UUID index unique. Version-two upgrades must remain backed up and fail closed if invalid or duplicate account ids are found; never guess which account owns corrupted relational data.
-
Migration preflight must reserve the backup and transaction workspace together when they share a filesystem. A retry may reuse only a fully validated pre-migration backup whose schema, row counts, and logical-content digest exactly match the unchanged source; never create duplicate snapshots merely because a prior migration transaction rolled back.
-
Keep persistence SQL compatible with SQLite 3.25.0 and the SQLite 3.26.0 runtime shipped by AlmaLinux 8. Do not use newer catalog aliases or maintenance syntax without deliberately raising and testing the baseline.
-
Current-schema validation must fail closed on missing or unexpected tables, columns, triggers, and views, as well as malformed column definitions, primary/unique/check constraints, required index definitions, or foreign-key drift. Derive duplicated structural expectations from the canonical DDL when practical so schema creation and validation cannot silently diverge.
-
Connections use explicit autocommit plus
_transaction()for every multi-statement mutation. Never silently swallow unexpected SQLite failures; ordinary status/read paths must not perform retention cleanup as a side effect. -
Treat
SQLITE_IOERR_WRITEas an operating-system storage fault, not a retryable SQLite conflict. Preserve every database artifact and report the paths, required space, and SQLite extended code so operators can inspect filesystem/mount health, kernel logs, quota and file-size limits, ownership, and SELinux before retrying. Deployments may place validated backups on a separate healthy filesystem with--database-backup-dir. -
Maintenance capacity preflights must test space allocatable by the running service identity, not only filesystem-wide free bytes, so user, group, and project quotas fail before SQLite touches authoritative data.
-
Production deployment tooling must stop the game service before changing its environment, restore service-user ownership on database artifacts, and pass non-database fsync probes for both the live and backup directories before starting. Bound automatic restart loops so a persistent startup failure cannot repeatedly run migration or backup preparation unattended.
-
Transient table checkpoints remain durable until database validation, schema migration, table deserialization, network binding, and tick startup have all succeeded. A failed startup must close SQLite and leave checkpoints intact.
-
Stop authoritative ticks, reject new gameplay/account packets while leaving current menus visible, drain tracked in-flight work, and close the event-loop SQLite connection before starting maintenance on a worker-owned connection.
-
Broadcast localized start and terminal notices with a
system-buffer notify sound. Reject login, registration, and password-reset work before it reaches SQLite; authenticated attempts receive the active maintenance reason. -
Read-only storage analysis does not freeze gameplay, but it still owns the shared maintenance lock and storage-idle barrier. Shutdown and scheduled power operations must wait for its worker-owned SQLite connection to close.
-
Once a blocking maintenance worker starts, coroutine cancellation cannot stop it. Wait for the worker, report its actual success or failure, and preserve a post-publication failure as storage-uncertain rather than replacing it with a cancellation result.
-
Resume only after the live connection reopens and passes identity, schema, structural, and foreign-key validation. Reopen failure is fail-closed: keep gameplay frozen and require operator recovery.
-
Backups use SQLite's online-backup API, a unique partial file, exact source/destination snapshot metadata checks, full integrity validation, file and directory flushes, and atomic publication in
server/backups/by default. Never publish partial backups. -
Compaction creates a verified safety backup first, preflights space for both a staging copy and SQLite's temporary VACUUM database, VACUUMs a same-directory unpublished candidate in legacy DELETE-journal mode, and atomically replaces the live file only after full integrity, schema, and logical-content validation. Never run VACUUM against the authoritative file. Any failure after atomic replacement is fail-closed and requires operator recovery from the retained safety backup.
-
Backups contain persistent SQLite state only, not runtime-only active-table state. They are retained until an operator removes them, are excluded from source control, and are not rewritten when an account is later deleted. Protect and rotate them operationally and keep an off-host copy.
-
Storage cleanup is explicit, previewable, allowlisted maintenance. It creates a verified pre-cleanup backup, reevaluates candidates inside one atomic transaction, validates the result, and reports reusable pages without running compaction. Saved tables, game results, chat history, moderation reports, valid user blocks, compatibility data, valid backups, and logs are never cleanup candidates. Saved tables persist until their owner or a Developer explicitly deletes them.
-
Filesystem cleanup is limited to narrowly named regular unpublished PlayAural backup fragments in the configured backup directory and database-specific compaction candidates in the database directory, all older than the shared abandonment threshold. Never recursively scan or delete arbitrary server files, authoritative SQLite sidecars, active logs, caches, valid backups, or recovery artifacts from the live administration workflow.
Server, Web, Desktop, and Mobile Rules
- Server navigation uses
_nav_push,_nav_back,_nav_refresh, and_restore_frame; action handlers should not call_show_*()directly. The stack remembers the opener item and restores focus on Back/cancel and action completion. Server-owned menu selections are validated against the active menu before dispatch; stale client packets and forged item ids are ignored. - Use
_enter_input_state(...)/server.enter_input_state(...)for editbox input state instead of mutating_user_states. - Reconnect restoration and ghost cleanup must route through centralized restore code.
- Account handover is serialized per canonical username. Only the exact
connection owned by the current
NetworkUsermay dispatch packets or run disconnect cleanup; stale sockets and callbacks must be harmless. - Intentional application exit uses the generic authenticated
logoutpacket. The server runs its ordered session-activity teardown handlers (including table and voice departure) before retiring the session and sendingforce_exit; clients must not duplicate game- or room-specific cleanup. Forced process loss remains an ordinary disconnect because browsers and mobile operating systems cannot guarantee a final network callback. - Credential verification, password-reset eviction, moderation eviction, and account deletion must use that same account lock so a checked credential cannot install a session after its account or password changed.
- First-party releases update the server and all clients in lockstep. Only an
exact server/client version match may install an authenticated session.
Outdated native clients may receive the credential-verified update bootstrap
needed by their mandatory updater, but must never own a
NetworkUser, displace a current session, broadcast presence, or dispatch gameplay. Reject outdated Web clients before authentication. Do not add old packet-field aliases; retained account/config/table/save migrations remain required because persistent data survives releases. - Never transfer rendered menus or editboxes between sessions. Rebuild UI from authoritative server/game intent using the replacement client's capabilities; device-only frames fall back to a valid shared parent.
- A live device handover does not pause authoritative timers/sequences, trigger bot substitution, add reconnect grace, or reset rate limits. Replay active audio layers to the new session, retire old output queues, and keep transient resume state bounded with explicit cleanup.
- Web client must never use
innerHTMLwith server-controlled content. - Web client code is modular:
game.jsis only the version/bootstrap entry; runtime logic belongs inapp.js,store.js,network.js,audio.js,a11y.js,keybinds.js,ui/, andlocales/. - Web menus must preserve desktop-style keyboard behavior, touch activation,
focus anchoring, bottom-ordered ARIA live regions, and capped/coalesced
history rendering. Server editbox packets choose single-line versus
multiline inputs through the
multilineflag. - Desktop and Web visual history must follow newly rendered messages to the bottom without stealing the reader's caret or focus. Compact Web history remains collapsible, focus-safe, and opens at the newest rendered message. Web history and chat drafts clear when an authenticated session ends, but survive automatic reconnection for that same session.
- Web speech prefs are
speech_mode,speech_voice,speech_rate. - Web game audio uses a
playbackaudio session and switches toplay-and-recordonly while the user explicitly publishes a voice-chat microphone. Recover previously running contexts after foregrounding without consuming the initial user gesture, and retain the lazy Ogg Vorbis fallback for browsers without native container support. - Mobile speech prefs are
mobile_tts_engine,mobile_tts_voice,mobile_tts_rate; unavailable synced voices/engines must fall back safely. - Mobile native speech must serialize stop/start, await engine readiness, and
recover failed bindings with bounded, configurable retries. Invalidate stale
callbacks and voice discovery on engine replacement; do not estimate speech
completion from text length. Keep the guarded
expo-speechlifecycle patch and Android source-build requirement. Screen-reader events and foreground queries must not overwrite newer state or change the saved self-voicing preference. UI speech and game announcements retain separate ownership. - Web locale catalogs are loaded through
web_client/locales/manifest.jsandindex.js; mobile locale catalogs are loaded through the generatedmobile_client/src/i18n/localeCatalogs.tsregistry. Keep registry metadata synchronized when adding languages. - Server locale directories must include
metadata.jsonfor translator credit and official/community status. Keeplanguages.ftlfor localized language names; do not replace viewer-localized names with metadata-only labels. - Mobile connects as
client: "mobile", is treated as touch, uses SecureStore for credentials, AsyncStorage for local prefs, and must enforce mandatory APK updates on version/sound-pack mismatch. - Mobile Back resolves the visible dialog/input before local tabs or server menus. Preserve server escape behavior on same-menu updates. Boards must scroll on both axes without shrinking touch targets below the UI minimum; self-voicing focus must reveal the selected cell without delaying cursor or speech updates. Android boards use one native two-axis gesture owner and accessibility scroll surface; verify TalkBack two-finger panning and native directional scroll actions. Mobile message buffers are bounded runtime-only state, retain focus by message identity, and clear with chat drafts when returning to login; never persist them with account settings.
- Desktop passwords live only in OS keyring. Saved microphone devices must fall back to system default if unavailable.
New Game Requirements
Files normally required:
server/games/<type>/__init__.pyserver/games/<type>/game.py- optional
server/games/<type>/bot.py server/locales/en/<type>.ftlserver/locales/vi/<type>.ftlserver/documentation/content/en/games/<type>.mdserver/documentation/content/vi/games/<type>.mdserver/tests/test_<type>.py
Also register the game in server/games/__init__.py.
Documentation must follow the Documentation rules above and established game docs: escaped markdown bold, overview, gameplay, special mechanics, scoring, customizable options with defaults/ranges, and game-specific keyboard shortcuts.
Tests should cover registration/default options, pre-start validation, core mechanics, scoring/scoreless behavior, bot completion, touch visibility/order, keybind collisions, sound/TTS paths, transitions, and game completion.
Code Style
- Module-level imports only, except
main()inclient/client.pywhere CWD setup must happen first. - Prefer existing helpers and local patterns over new abstractions.
- Keep edits scoped; do not refactor unrelated code.
- Game classes:
<Name>Game; player classes:<Name>Player; options:<Name>Options; type ids lowercase without separators; action idssnake_case. - Handler names:
_action_<id>; visibility:_is_<id>_hidden; enabled:_is_<id>_enabled. - Use structured parsers/APIs where available; avoid ad-hoc string parsing.
- Update
CLAUDE.mdandREADME.mdwhen catalog counts or user-facing catalog data changes.
