WCA Codebase Style Guide
This guide captures the conventions that WCA maintainers actually apply in code review. It was derived by an LLM from ~1,800 review comments on this repository, so every rule here is something that has been asked for repeatedly on real pull requests. It is current through review comments up to 2026-08-24 and has been revised by Senior Members of WST.
Scope: This guide covers things a linter cannot catch. RuboCop (.rubocop.yml), ESLint (next-frontend/eslint.config.mjs) and Prettier are the source of truth for formatting and for mechanical rules — this guide won’t repeat them.
This guide covers the code itself. The process around changing code — how to scope a PR, what goes in the description, how to respond to review, which changes need sign-off outside the PR — lives in CONTRIBUTING.md.
You are expected to make yourself familiar with the contents of this file tree early on in your WST journey. We are a volunteer team, so we try our best to be nice and accommodating about enforcing rules. There won’t be a dedicated quiz that you have to “pass” like a university exam, and there won’t be any punishment if you don’t apply all rules absolutely correctly right from the start. In fact, we believe that learning by doing is the most healthy approach for writing good code!
Nonetheless, for such a big production codebase as ours, certain standards need to be enforced. So you should read the sections of the guide that concern you attentively, to get an overall feeling for how we like to write code. See it as an opportunity to grow into your role and hopefully learn something new!
- Sections §1 and §2 are pretty universal. You should read these no matter whether you’re working with frontend, backend, or somewhere in between.
- Sections §5 and §10 are also generally useful, but not as common-place as the first two. Read them when you dive deeper into the code.
- All other guides after that are for specific areas or use-cases. Glance over the table of contents and the section headings to get a feeling for what might apply to you. Every section opens with the condition(s) that makes it relevant. You are also free to read them just as a learning or out of interest :)
If you are using coding agents, it should support some form of “load this file when working on X” rules. Point it at these files but keep that config personal (.claude/ and .agents/ are gitignored) so we don’t have to agree on a specific tool team-wide. Claude Code users can install this guide as a skill with /plugin marketplace add thewca/web-dev-docs followed by /plugin install wca-style-guide@wca-web-dev-docs.
Table of contents
- Universal principles
- Naming
- Ruby and Rails
- Database and migrations
- API design
- Next.js frontend
- Chakra UI and styling
- Legacy React (Shakapacker / Semantic UI)
- Tests
- i18n and user-facing copy
1. Universal principles
1.1 Prefer immutable operations
In-place mutation is treated as a defect. Use immutable, functional-style data flow throughout. This applies to any area of the code, regardless of programming language or framework.
| Don’t | Do |
|---|---|
array.sort(...) | array.toSorted(...) |
map.set(k, v) in a forEach | build a new object via spread / Object.fromEntries |
array.pop(), arr.tap(&:pop) | take_while / drop_while / slicing |
let x = ... then reassign in a loop | build a new collection with map / reduce |
In JavaScript, let is itself the smell: it tells the reader “something below reassigns this”, and reviewers will ask for a const built from a map, a reduce, or a ternary.
In Ruby, variables are declared “on the fly” without designated keywords, so you need to be extra-careful. In-place mutation is hard to spot at first glance, and for this reason, you will find some places in our backend which still do it. It does not give you an excuse to copy that behavior, however.
If you genuinely must recompute values in place, produce new entries rather than mutating existing ones. Exception: The Ruby backend may mutate attributes of database entities in-place. Do not create a copy of a ScheduleActivity row just to change the name from “Lunch” to “Lunch break”.
Mutating an object you were handed is only the very last resort and has to be argued for very strongly in the PR description — assume your PR isn’t granted any exceptions.
1.2 No magic values
Every literal that isn’t self-evidently meaningful gets a name.
- Backend: a module-level or class-level constant. Even for values that are “obviously 2 today” because the current Regulations only support linking 2 rounds into a “Dual Round”: Derive them cleanly (
linked_round.rounds.size), so the code survives the 2031 Regulations change introducing Triple Rounds. - Frontend: a design token (
fontSize="2xl",w="full", see Chakra) or a namedconst. Raw44,1,#3B82F6will be questioned. - If a constant comes from an external protocol (an AnyCable message key, a keyboard code), document where it comes from in a comment next to the declaration.
1.3 Comment the why, not the what
If your comment needs to explain what the code is doing in the first place, then it’s a pretty strong indicator you should consider a refactor. We value hand-crafted code with variable names and control flow that is self-explanatory.
The core purpose of a comment should be to explain the Why or the How of your code: It is already clear what the code is doing, but it might not be clear why you’re doing it or how this self-explanatory code snippet solves the bigger problem and ties back into the bigger picture. In particular, code comments are required when:
- You worked around a library bug or quirk (
initialDatatype inference throughapi.queryOptions, RedoclyallOfhandling, theuse clientdirective needed for icon functions).- If you feel that the library quirk you just worked around clearly is a bug in the original library, report it to their issue tracker (mostly GitHub Issues) and add a code comment referencing your issue.
- If you feel that your workaround “should be useful” for the library, still consider reporting it as a feature suggestion or open-ended discussion. Most open source projects value constructive feedback.
- Your code depends on non-obvious ordering, indexing, or a business rule (e.g. filtering after a
mapbecause you need the original index). - You ported logic from another codebase (WCA Live, a Ruby equivalent) — say so and name the source.
Some LLMs have a particular tendency to be very verbose about the “Why”: They explain stuff in great detail, often down to “This works because in the third-party library code line 336, the fooBar() function does so-and-so, leading to this-and-that result, and the subsequent processor cycles run the calculations in exactly oh-so-many seconds thanks to the way modern NAND gates are structured, so it produces the desired outcome”. This level of detail is not helpful! Rather than a blanket “Why this works”, you should strive for:
- “Why this works for us”
- “Why this solution is adequate to the problem”
- “How this ties neatly into the conventions of our framework/this third-party library”
A note about future removal is welcome when it’s actionable. Mark it so it’s greppable (TODO, XXX) and name the condition that makes it removable — “TODO: drop once WCA Live is sunset” tells the next reader what to wait for. What doesn’t help is an unmarked aside saying the code is temporary without saying temporary until when. A TODO you’re leaving deliberately still needs to be called out in review; see CONTRIBUTING.md §4.
1.4 Don’t paper over errors
We generally do not catch errors (JS try/catch, Ruby rescue), and we also try to avoid throwing errors apart from some very specific use-cases.
try/catch(orrescue) around something that “sometimes explodes” is not acceptable. Find out what throws and prevent that input from reaching the call.- Don’t null-guard defensively (
?.,&.,!,?? 0) without knowing which case you’re guarding. If you can’t name the case, the guard is hiding a bug — or it’s dead code. - If you branch on format (“if it parses as JSON do X, else treat it as CSV”), validate the else branch too and react meaningfully when it’s neither.
- Don’t swallow failures silently. See also §3.5.
A notable exception where we do work with errors are Controllers in the Ruby on Rails framework (i.e. the HTTP layer of our API). The following rules there apply:
- A
rescueinside a method body is rejected. In a controller, catch the exception at the top withrescue_from(rescue_from JSON::Schema::ValidationError) — the happy path stays readable and every action gets the same handling. - You can
raisean error/exception when you know that it’s handled further up in the controller hierarchy. For example, one common pattern you will see israise WcaExceptions::NotPermittedto stipulate a 403.
1.5 Reuse existing code before writing new code
Before introducing a helper, search for one. Core results logic in particular must live in exactly one place so that a bug fix fixes every caller. Concretely, the codebase already has:
SolveTimeparsersScheduleActivity.parse_activity_codeRegistrations::Lanes::CompetingRegistrationChecker#apply_payloadCompetition.wcif_json_schemaCompetition.validate_wcif_schema!useInputStatefetchJsonOrErrorroutes.js.erblink helpers- the OpenAPI error component.
Ask around before re-implementing any of them — if you feel like it should be a common, extracted method then chances are that it already is one and your colleagues know where the code can be found.
This is particularly important for the libraries we depend on. Before hand-coding something commonplace like a character limit on an editor, a debounce, a flag icon — read that package’s documentation and check whether it’s already a prop or an option. Chances are, you can find a pre-packaged solution for whatever you’re trying to accomplish.
If you’re writing some new business logic, and you’re sure it’s not available elsewhere yet, consider moving it to a shared helper function that others can benefit from.
2. Naming
2.1 Names describe what something means, not how it was computed
Variable naming across all frameworks and all programming languages should always be meaningful. The flow of the code (to reach the variable declaration) already tells you how it was computed. So the name of the variable should instead tell you what its purpose is, and how it fits into the logic of the function or the component.
| Bad | Good |
|---|---|
hash | state_hash or round_checksum |
Errored | OpenapiError |
last_event that holds an ID | last_event_id, or change the value of the variable to actually be the full event |
2.2 Shape must match the name
- A
*ByXsuffix means it’s a map keyed by X. If it’s an array, rename it (or turn it into a map keyed by X). statMapshould actually be an object/map, not an array of objects.- Plural variable names imply an array of values/objects. Singular variable names imply that it’s just one object.
withResultsreads as “definitely has results”. If you mean “a tuple of competitor and result”, name it accordingly.
2.3 Use established WCA vocabulary
The WCA has established terminology for a lot of things relevant to cubing. For example: roundTypeId, wcif_id, registrant_id, competition_event, skipped.
- Don’t invent a synonym for a term which is already well-established within the codebase and the community.
- Conversely, don’t overload a term that already means something else
resultsas a general term for “something returned by an API call” should bequeryResults, because results means something very specific at the WCA.- Exception for React frontend:
Eventsubclasses foronClickand similar listeners can still be calledeventorevtas a callback parameter when writing event handlers.
2.4 Be internally consistent
Within one PR, one file, or one API payload: pick a convention and hold it.
- Not
competitors_xin one field andx_competitorsin the next. - Not
snake_casekeys in one method andcamelCasein a sibling method producing the same shape. - If a method is called
orderResults, the resulting variable is calledordered, and notsorted. - If you rename a concept, rename the related variables (
rolesLoading,rolesError,rolesSync), not just the one line you were looking at.
2.5 Booleans read as assertions, not commands
Boolean flags that indicate whether something is on or off, or whether something was enabled or not, should generally use the English language “he/she/it” case, including the -s grammar suffix:
useWcaRegistrationreads as an instruction — “use the WCA registration!”usesWcaRegistrationreads as the question to the answer “does it use WCA registration?”
Name the value behind a condition according to what makes it true, not after what you intend to do with the result:
- When a registration update is received, and the backend checks whether the request payload was sent by the same user that the registration is for (as opposed to an admin updating someone else’s registration), then the variable should be named based on
isSelfUpdating(or similar). - If you later decide whether an email notification about the update should be sent based on this self-updating flag, you can name the parameter to the email sendout method as
shouldSendEmail. But at the callsite, the variable you pass into that method should still be calledisSelfUpdatingas explained previously.
If one flag is quietly carrying two questions — “is this competition on the WCA registration system?” and “does it already have registrations stored?” — that’s two flags. Pass both and let the consumer branch on the combination, including to warn/error about the case where the truth values are conflicting.
3. Ruby and Rails
Read it when: you are working on the backend in any .rb files, or reviewing a diff that touches there. This is the case for many folder directories in our repo, because it was historically started as a Rails app. /app, /lib, /config and even a few top-level files all contain Ruby backend code.
4. Database and migrations
- Column naming: booleans use an
is_/has_prefix (MySQL can’t take Rails’?suffix). Match the conventions of sibling tables — iftotal_delegatedhas no suffix, don’t add one adjacent columns liketotal_organized. - Use
after:to place new columns sensibly.schema.rbsorts alphabetically in the developer dump, but the production table doesn’t, and humans read it in PMA. - Declare indexes inside
create_table(t.index %i[a b], unique: true) or withindex: trueon the column, rather than as a separate statement. - Let Rails infer foreign key columns and table names when they follow convention.
t.referencestakestype:andindex:; useforeign_key: { to_table: ... }only when inference fails. - Wrap necessary data backfills in
up_only do ... end. In general, data migrations should be Rake tasks (see Rails guide) but exceptions can be made if data must be there immediately upon executing the migration. - Don’t set arbitrary
limit:on strings without a reason. We should rather strive to remove existinglimits than arbitrarily mimicking them, because more often than not they are tech debt from ye olden days. - For a state machine with a natural order, use an integer-backed enum (
enum :lifecycle_state, [:pending, :open, :locked, :done]) — the numbering encodes the progression. - The
versionat the top ofschema.rbmust match the migration you’re adding. A mismatch means you committed a stale schema.- Exception: If you are merging/rebasing your PR to the tip of
main, somebody else might have merged another migration file with a newer timestamp - In case of doubt during merge conflicts: The
versionstamp should always represent the newest timestamp among all migration files in themigratefolder.
- Exception: If you are merging/rebasing your PR to the tip of
- When deleting columns (because you’ve established a newer format, or they are genuinely not needed anymore) always follow a two-step process in two separate deployments:
- Write a migration that adds the new column(s). Migrate the data (most likely via Rake task) and migrate the code to work with the new column(s). Treat the database as if the old column didn’t exist anymore, but DO NOT physically delete it
- After you have fully deployed the first migration, and you are confident that the code is working well and all legacy data has been fully migrated: Open a second, separate PR that migrates the physical deletion
5. API design
The OpenAPI YAML under next-frontend/openapi/ is the single source of truth for payload shapes.
Our API from v1 and onwards fully commits to snake_case. Even in frontend (TypeScript), access these fields in their snake case notation. This makes it very clear which fields/properties of an object are fetched data and which are genuinely newly computed within the frontend logic.
- Field naming rules from §2.4 apply doubly here. Pick one prefix/suffix convention across a schema family.
- Move shared fields up into the base schema instead of repeating them in every descendant.
- Model variants with a
discriminatoron a single enum rather than a bag of mutually-dependent booleans. Alifecycle_statestring beatsis_open+is_locked+is_clearable+is_openable. - Omitting a field from
requiredalready makes it nullable viaundefined— don’t also mark itnullable. - Don’t serialize fields “just in case”. Extra serialized properties are cheap to add, invisible to find, and expensive on the database. If you add one temporarily, comment that it’s temporary.
- Return arrays as arrays. Don’t join error messages with
", "on the backend — the frontend can do that if it really, really has to (but a bullet list by looping over the messages would be preferable). - An endpoint must return the same shape regardless of who calls it. “Admins get extra keys” is a documentation (and nullability) nightmare; make a separate endpoint.
- Error responses should carry a meaningful, specific body. A bare 401 tells the frontend nothing — return a distinguishable JSON payload so the client can react to this 401 rather than any 401.
- Keep per-competition data out of per-round endpoints and vice versa. Put a property at the level it logically belongs to. If you need to cross-reference a competition within results, use its ID and nothing else.
- Don’t silently drop parts of a payload. If a request carries fields the endpoint won’t apply, choose deliberately between rejecting it with a 4XX and documenting that the field is ignored. Answering
200 OKto a change you didn’t make is not an option. - Design for concurrency. At a large competition, requests interleave — prefer transactional payloads (“advance competitor #123, while verifying they are actually the next eligible one”) over stateful booleans (“advance the next competitor”).
6. Next.js frontend
Read it when: you are working on the new frontend under the /next-frontend folder.
If you are specifically working with UI layout / design components (as opposed to fixing the state or behavior of a component), you will most likely also need to read the guide on Chakra UI.
7. Chakra UI and styling
Read it when: you are working on design (UI and UX) of the new frontend under the /next-frontend folder.
This may also be worth a read if you’re going to work mainly in frontend, and you expect to submit many PRs on the Next website (even when it’s not about design in the stricter sense).
8. Legacy React (Shakapacker / Semantic UI)
Read it when: you are creating or editing any .jsx / .js file under app/webpacker/.
NOTE: You should not be creating any new files (let alone whole features) under the legacy Webpacker frontend. Touching existing files is acceptable when fixing bugs or augmenting existing features with functionality that is deemed immediately necessary.
Before you dive deep into your work in Shakapacker, double-check that you are really sure what you’re doing!!
9. Tests
Read it when: you are adding or changing a test or a factory — RSpec under spec/, Vitest in next-frontend/, Playwright in system-tests/.
10. i18n and user-facing copy
- All user-facing strings are translatable. This includes button labels, empty states, and error messages in new admin UIs.
- Interpolate variables, not words.
"Version %{n}"must include the word “Version” in the translatable string — otherwise translators can’t localise it. - Reuse existing keys where one fits. Skim before adding.
- Write for non-native English speakers. Dense, expert-written copy (especially in emails) is a recurring review rejection. If a WRT/WST insider wrote the text, simplify it.
- Status labels are not action labels. A backend status
locked_for_postingmeans it is locked; the button that gets you there says “Lock for posting”. Don’t reuse one string for both. - Don’t leak internal state names into copy. “Move to none” is the enum talking; the user reads “Remove from waitlist”. “0 spots” is a number; the user reads “no spots left”.
- Match the escaping conventions of the strings around you — we write quotes as
"inen.yml. - Watch singular/plural agreement between the API and the UI, and use proper punctuation characters (
…, not...).
Introducing new public vocabulary, or copy for an unreleased feature, needs agreement outside the PR — see CONTRIBUTING.md §5.