Code conventions
The house rules that keep the codebase uniform across contributors and
game packages. The linter and tool/verify.sh catch what they can; this
page covers the conventions automation cannot.
Naming
- Descriptive, not abbreviated. Names say what the value is:
playerNames,previous,repository,metadata— nevern,prev,repo,meta. Single-letter names are reserved for nothing; lambda and pattern parameters get real names too ((Player player) => …,(VoteEntry entry) => …), and loop counters are named for what they count (lineIndex,seatIndex), noti. - Common abbreviations are fine —
id,url,csv,json,ui,db, plus the domain's own (tts,l10n) and framework-required names (ref,context). If an abbreviation wouldn't be said aloud in conversation, don't write it. - Case follows the language. Dart uses
camelCasemembers andPascalCasetypes; shell variables aresnake_case; Kotlin follows Kotlin convention. - Check
terminology.mdfirst. The glossary maps user-facing terms to code identifiers — a name that fights the glossary is wrong even when it's descriptive.
Ordering
- Alphabetical when no stronger order exists — imports, barrel-file exports, pubspec dependencies, lint lists, enum members, and plain lists all sort A–Z.
- Keep semantic order where the order itself means something:
phase enums run in state-machine order,
TurnTimerLevel/SceneLengthrun low-to-high intensity,forbidden:clue lists run most-obvious-first, word pools group by grammar (alphabetical within a group), ARB keys group by feature. If the ordering rule isn't obvious from the code, a comment should say what it is.
Comments and documentation
- Every declaration gets a doc comment (
///) — public API and private classes, widgets, andStateclasses. Say what the thing is for and anything non-obvious about its lifecycle, not what the code already says. Generated files are exempt — they're regenerated anyway. - Use doc comments, not banner comments. A
// ------section header above a class duplicates what a doc comment does better; banners survive only for grouping free functions inside a long file. - Comment the why. Good comments explain constraints a reader can't see ("deferred past the build phase — the call mutates the controller"), not restate the next line.
Imports
lib/uses relative imports (prefer_relative_imports), in each package'slib/too.test/usespackage:imports (package:librebash/…,package:game_mechanics/…).
Layout and styling
- Never hardcode layout numbers. Spacing, radii, and recurring
sizes come from the tokens in
game_mechanics'app_spacing.dart(AppSpacing,AppRadius,AppSizes) — the same tokens serve the app and every game package. Missing a value? Add a token, don't inline a literal. Seedesign-system.md. - Use the shared widgets rather than re-building the same shells —
SessionScaffold,SessionTurnHeader,PassDeviceGate,AppIconButton, and friends are the conventions made concrete.
Strings
- App text comes from
lib/l10n/app_en.arb— never hardcode a user-facing string inlib/. Thehardcoded_ui_stringstest guards this. - Package text comes from
<Name>Stringsclasses with English defaults; the app injects localized values through the scope. A new user-facing package string means a new field plus its ARB wiring. - Template placeholders are content contracts. Tokens like
{n}or{p}inside authored strings and scene files are data formats — they aren't abbreviations and they don't get renamed.
Content
- Every generated thing/phrase pairs with a
CuratedPool. Any content a generator composes — titles, lines, headwords, survey questions, creature cards, review dishes and venues — also gets a hand-written list dealt throughdealCuratedOrElse(random, deck, pool, generate):pool.shareof deals serve an authored item, the rest run the generator. The flip is a mechanics roll onsessionRandomProvider; the authored draw goes through the sharedDrawDeckunder the pool'sdeckKeyso it can't repeat until the list wraps. - A curated pool holds at least
minCuratedPoolSize(50) entries. Per-package content tests assert the bar — a pool that ships under it fails its own test. sharedefaults todefaultCuratedShare(0.10) and is tunable per pool. A pool whose authored voice is the whole point runs a hot share (Acquired Taste's scales: 0.85); keep the default unless the design calls for authored-dominant dealing.- Curated pool keys are authored constants like template-bank keys
— stable, namespaced (
'acquired_taste_curated_dishes'), and the deck reseeds itself when the list's size changes. - Deals that only select (a word off
animals, a scene offmonkeyScenes) are already curated — the pool is the authored list. TheCuratedPoolmachinery is for pairing a generator with authored content, not for wrapping a pool that is one.
Generated files
*.g.dart,*.freezed.dart,lib/l10n/generated/**are never hand-edited. Change the source, rerun the generator, review the generated diff. See ARCHITECTURE.md.
Tests
- Code changes ship with tests. Pure logic gets unit tests; widgets
get
testWidgets; every package owns its owntest/mirroring itslib/. If a seam is awkward to reach — a platform channel, aSpeaker, aScreenPinningService— it's already behind an interface for exactly that reason: fake it, don't skip the test. - Session tests stand on
SessionTestRig(game_mechanics/lib/src/testing/): it builds theProviderContainerwith the session overrides (players/options/speaker/link/outcome), aFakeSessionLink, and the keep-alive listen — so a controller or bridge suite is aSessionTestRig(...)line plus assertions, not a rebuilt scaffold.RecordingSpeaker/RecordingSoundPlayer/FakeSessionLinklive ingame_core/lib/src/testing/. tool/verify.shis the gate — format check, generators, analysis, and every test suite, in one script. Run it before pushing;.githooks/automates the same.
Persisted names are contracts
Anything already written to storage, a wire format, or a shipped
artifact is frozen: GameMetadata.id values, SharedPreferences keys,
enum .name wire values, the screen-pinning channel name, package
names, BUILD_TIMESTAMP, and string-template placeholders. The full
list lives in terminology.md.
Renaming one requires a migration story, not a refactor.
Remote wire schemas are versioned. A remote game's projection
keys, intent kinds, and phase semantics are its wire contract — when
you change any of them in a breaking way (renaming or removing a key,
changing an intent's kind or payload shape, redefining a phase),
bump that game's remoteCompatVersion. Additive fields, new content,
and presentation tweaks stay compatible. Without the bump a peer on
an older build joins silently and stalls — the version check is what
turns schema drift into a legible "update to play" message.
Documentation
- Docs describe current behavior. Update the dev guide (and any affected doc) in the same commit as the behavior change. ADRs are historical — leave them as the record of the decision, never edit them to look current, and never describe unbuilt designs as existing.
- Update
CHANGELOG.mdfor every user-visible or packaging change under the topmost## 0.0.x (unreleased)heading. - Normal casing everywhere — no all-lowercase prose in docs, comments, or UI strings.
Commits
- Conventional Commit format —
feat(scope): …,fix: …,test: …,docs: …,refactor: …,build: …. Keep commits focused: a refactor and a behavior change are two commits.
Dependencies
- F-Droid-clean only. Nothing that pulls Google Play Services or
other proprietary blobs; a feature that truly needs one lands behind
a dedicated build flavor — see
ci-and-packaging.md. Thematerial_ui/cupertino_uidependency_overridesin the root pubspec are intentional (see the comment there) — don't remove them.