shiping
An interview assistant and an exam assistant that uses AI to provide solution ideas in real time during 「面试」 or 「在线考试」.
AI translation
Practice Science Bowl questions solo or with friends using real tournament packets
AI summary · website content
EducationJavaScript · CSSGitHub @UnweidlyKhan349OnlineOwner unconfirmed
Have you tried it? One tap is enough. No sign-in needed.
No updates detected yet. GitHub releases will appear here.
Owners of public repositories under a personal GitHub account can sign in to verify ownership and manage the introduction themselves. No administrator approval is needed.
Sign in, generate a verification file, and commit it to this repository’s default branch within 30 minutes. Files on other branches or forks do not count. Then check the file below to manage the project without administrator approval. Keep the file to retain your access.
If needed, you will go to sign-in. After signing in, return here and press your chosen verification button again.
Run /nomorevibe verify in your project folder to verify ownership and manage the introduction yourself.
Verify in one minuteAsk about other verification options
The maker's introduction consists of the line above. Read the repository README for more details. Repository README ↗
Loading the full README.
Read the full README ↗A static Science Bowl practice site built from real tournament packets: a searchable catalog, a solo buzzer trainer, a peer-to-peer multiplayer mode, and per-device bookmarks. It's plain HTML/CSS/JS, with no backend, build step, framework, or server-side code, so it can be deployed straight to GitHub Pages or any static host.
Current question bank: 84,104 questions from 108 tournaments (44,026 tossups / 40,078 bonuses; 51,776 short answer / 32,328 multiple choice), including 535 visual questions whose figures (541 images) are shown with the question.
| Subject | Questions |
|---|---|
| Biology | 16,986 |
| Earth & Space | 16,768 |
| Physics | 16,824 |
| Math | 14,457 |
| Chemistry | 15,391 |
| Energy | 3,678 |
Visual bonuses are implemented, and every scrape must collect them. A question that comes with a figure (a visual bonus, or the occasional visual tossup) is stored with its image and shown in the catalog, solo and multiplayer modes, with its own Visual filter. When packets are scraped, their visual questions are scraped too, figure and all, from the packet itself or from the event's separate visual-bonus deck. They are never skipped as "visual"; one is left out only when its image exists nowhere that can be reached. See Visual questions.
Every question from every tournament and packet is included. Questions with similar wording, or that test the same thing as another question, are allowed and kept, in the same tournament or across tournaments. A question an event prints more than once (in two rounds, twice in one round, or in both its HS and MS divisions) stays every time. The only questions dropped as duplicates are mass copies: the word-for-word copies inside a packet that is itself mostly copies of another packet (a packet re-posted under another name, or the same file ingested twice), and the same question parsed twice from one slot. When in doubt, keep. Apart from that, a question is left out only when it can't be used: no answer, text garbled past repair, a figure that exists nowhere, or a fact that is no longer true. See Inclusion and duplicate policy.
Every question is stored the way the site shows it. Pronunciation guides, moderator prompts and writer notes are removed, answer keys hold only the answer (alternates go to the accept and reject lists), spoken math is written in notation, and math uses
^/_markup. The same rules apply to every packet, old or newly scraped. See Text and notation conventions.
This README covers what the site does, how it's built, the data format, and how to run and test it. Where the questions came from and how they were cleaned is in docs/data-history.md, the per-tournament counts are in docs/tournaments.md, and site changes are in CHANGELOG.md.
index.html (Home)A landing page with cards linking to Catalog, Solo Practice, and Multiplayer. The tagline shows the live question and tournament counts, read from meta.json alone. Once the page has loaded and the browser is idle, it prefetches the question bank's files into the browser cache at low priority, so the page you open next doesn't wait for the download (skipped when the browser reports Save-Data or a 2G connection).
catalog.html (Catalog)chloride sodium finds "sodium chloride", and aves photosynthesis finds AVES questions about photosynthesis). Each question's lowercased search text is built once, in small batches while the browser is idle after the page loads, and reused, so a keystroke doesn't lowercase the whole bank again. Matches are highlighted in the results, and exponent markup is ignored, so 7100 finds 7^100.date in meta.json), each in packet order; picking tournaments lists them A–Z. Packet order is: by tournament, then round (unlabeled rounds last), then question number, each tossup before its bonus. Filtering to MIT 2023 shows Round 1 from question 1, then Round 2, and so on. NSB Regs, whose rounds are numbered within each set, shows Round 1 of every set before Round 2 (SBData.sortByPacketOrder).solo.html (Solo Practice)js/reveal.js) at an adjustable speed, from 0.5× to 2.5×. At 1.0× that's 21 characters per second. The reveal position is computed from wall-clock time rather than tick counts, so it doesn't fall behind in a backgrounded browser tab. Your speed is saved in localStorage (sb_rate).Esc or a click closes it.SBData.stratifiedQueue, a round-robin across subjects that keeps cycling. Subjects stay evenly mixed for the whole session even when one subject has far fewer matching questions.S): change reading speed and every filter mid-session. Changes apply live and only rebuild the not-yet-asked part of the queue, so you won't see a question twice. If you turn off every subject and close the panel, it resets to all subjects, so the next time you open it you get a working selection instead of zero matches.solo.html?bookmarked=1, which skips setup and drills only your bookmarks, ignoring subject/format/round filters. An inline script hides the setup screen before the first paint so it never flashes.multiplayer.html (Multiplayer)sbowl-XXXXX, but players only ever see and type XXXXX. Codes are uppercased before connecting, so typing xfts2 joins XFTS2, and a pasted sbowl- prefix is handled whatever its case.S) can be edited by anyone in the room, not just the host. Changes go to the host and are then broadcast to everyone. An echo-suppression guard stops programmatic repaints from bouncing settings back and forth between clients. The "all subjects off → reset to all on close" rule applies here too, and the reset is pushed to the room like any other settings change.bookmarks.html (Bookmarked questions)Lists every bookmarked question as a card, with a "Practice these" button that starts solo bookmarked mode. Bookmarks are stored per device in localStorage under sb_bookmarks_v1. The page downloads only the tournaments that hold a bookmarked question (SBData.loadBookmarked(), using data/bank/index.json to find them), not the whole bank.
Shortcuts are ignored while you're typing in a text field (except Enter to submit) and when modifier keys are held.
While an enlarged figure is open, it takes the keyboard: Esc closes it and no game shortcut reaches the page underneath.
Solo (in session): Space buzz · Enter submit · N next/skip · Q override grading · B bookmark · P pause/resume · S open settings (not in bookmarked mode) · Esc close settings
Multiplayer (in game): Space buzz · Enter submit · N next (after reveal) / skip (while reading) · P pause · Q override your own grade · B bookmark · S room settings · / focus chat · Esc close settings
All grading lives in js/answer-check.js (exposed as window.SBAnswer). The core is checkAnswer(question, userInput), which follows Science Bowl conventions.
Normalization (normalize()): uppercases, unifies apostrophes, and strips punctuation. It also maps symbols to words so either form matches the other:
√ ↔ SQRT, ∛ ↔ CBRT, π/Π ↔ PISQRT X, and "cube root(s) of X" → CBRT XSo 128 π = 128 pi, cube root of 35 = cbrt 35 = cbrt(35), and square root of 16 = sqrt 16.
Bug caught during testing:
.toUpperCase()runs first and turnsπinto capitalΠ, so the first version's/π/pattern never matched and the symbol was silently deleted. The fix matches[πΠ]. This was verified by running the code in Node, not just by reading it.
Other rules:
a.ac, a.rj) are honored.1) … 2) … markers, and the answer is canonicalized, so 1 and 3, 13, and 1, 3 all match. Item names are resolved to their numbers (e.g. "diatoms radiolarians"). "All but X" phrasing is resolved by excluding the named or numbered item(s).132, 1, 3, 2, 1 and 3 and 2). A ranking is recognised from "order/rank/arrange/sort … the following", "order them", "in increasing/chronological order", "in order of increasing …", "by increasing/decreasing …", "chronologically" or "from earliest … to …", but never in an "identify/select all" question, and "second-order" or "in order to" don't count. The ordering can come from the key or an accept (2/5; 0.402; 42% with accept 3, 2, 1). Before this, a ranking stem that didn't put "the following" right after the verb was graded as a set, and even recognised rankings accepted any order typed with commas or spaces (3, 2, 1 passed for 1, 3, 2); only a bare digit run like 321 was checked. A typed ranking has to be item numbers alone, so 128/3 or vertices = 12, edges = 30 no longer reads as 1, 2, 3, and an extra item (1, 3, 2, 4) is wrong.8 FACTORS. Leading-number extraction and set comparison handle numeric answers.SiO2 matches an answer key of SI O2 and MgCl2 matches MgCl 2 (PDF extraction scattered spaces through formulas). Punctuation is kept, so 1.5 never meets 15, and bare numbers are excluded because spacing can matter there. The same rule applies to reject lists. Checked against the whole corpus: grading every short-answer question with another question's answer gives exactly as many acceptances as before (no new false positives).; parts, e.g. FIRST QUARTER; FULL): every part must be given, and each is matched on its own. The typed answer can be split with , , , , or nothing at all (), and a part can carry one extra word ( for ). A part's label or number can be left off ( for , for ). Order is enforced for "respectively", ranking, or sequence questions and for labelled parts. Otherwise any order is accepted, as it is when the packet says "either order". A single-part accept is only an alternate for that one part ( for ), and gives an alternate for each part. Before this change, a single part often passed as a whole answer through the fuzzy fallback ( alone) or through a lone per-part accept (, ), while was marked wrong. Checked across the corpus: a main answer, a whole-answer accept, or the parts joined by or now always pass (531 multi-part questions). The only inputs that no longer pass are single parts and out-of-order answers to ordered questions.npm test checks these rules and grades every answer key and every whole-answer accept in the corpus against its own question (see Local development and tests).
index.html catalog.html solo.html multiplayer.html bookmarks.html
css/style.css
img/logo.svg the logo mark (nav brand); img/icon-*.png are its PWA/home-screen sizes
favicon.svg favicon.ico apple-touch-icon.png site.webmanifest tab and home-screen icons
js/data.js shared loader, text cleanup, math rendering, question cards, filters, UI widgets, bookmarks
js/answer-check.js grading (SBAnswer)
js/mp-rules.js multiplayer host rules with no DOM code: message validation, who may override (SBMpRules)
js/reveal.js letter-by-letter reveal engine (replaced text-to-speech)
js/catalog.js js/solo.js js/multiplayer.js js/bookmarks.js page logic
js/peer.js PeerJS wrapper: makeHost / makeClient / friendly error messages
js/vendor/peerjs.min.js vendored PeerJS (no CDN dependency)
data/meta.json tournaments, counts, filter values, id aliases
data/bank/<slug>.json the question bank, one file per tournament (see Data format)
data/bank/index.json which tournament each question id is in
data/source-subjects.json the subject each question's packet prints (checked by npm test, not loaded by the site)
data/subject-overrides.json questions deliberately filed under a different subject than their packet prints
tools/bank.js converts the bank to and from one flat list of records, for editing
tools/subject-audit.js checks each question's subject, type and number against its packet; records the printed subject in data/source-subjects.json
data/img/<tournament-slug>/<id>.webp figures for visual questions (537 files, about 24 MB, loaded only when shown)
tests/ node --test suites: grader, multiplayer rules, data shape
.github/workflows/ci.yml runs the syntax check and tests on every push and pull request
js/data.js (window.SBData)SBData.loadMeta() fetches only meta.json; SBData.load() fetches it and then every tournament file in parallel, expanding each into the app's question shape (expandTournament) as it arrives, so that work overlaps the downloads still running. SBData.loadBookmarked() does the same for just the tournaments holding a bookmark. The catalog, solo and multiplayer pages include data.js in <head> and call SBData.load() there, so the downloads start before the rest of the page has loaded; meta.json is preloaded with <link rel="preload">. Expansion skips the text-cleanup regexes for the roughly 95% of strings that contain none of the characters they act on. normalizeMeta fixes up meta in one place so every page benefits:
meta.subjects, a flat array of keys, is expanded to {key, label} objects. This fixed a long-standing bug where subject chips rendered with no text on every page.meta.json stores them alphabetically, a leftover of how the Python scripts built it.)cleanText and friends). This is non-destructive, and each regex's scope was checked against the whole corpus:
stripAuthorTag: trailing Discord-style author tags (name#1234)stripTrailingWatermark: trailing 2019 DSB ®-style watermarksstripPronunciation: bracketed phonetic hyphen-chainsfixSpacedHyphens / fixSpacedApostrophes: PDF-extraction spacing damagerenderMathHTML turns real ^(...), ^token, _(...), and _token notation into <sup>/<sub>. Parenthesized groups may nest (2^(2^(2^2)), n^(1/ln(n))). By design it does not guess at lost notation; see limitations.Peer-to-peer over WebRTC via PeerJS. The free public PeerJS broker is used only for the connection handshake; no game data goes through a server. The host's browser holds the authoritative game state (queue, timers, buzzes, scoring) and broadcasts it. Clients send intents (join, buzz, submitAnswer, next, skip, togglePause, override, typing, updateSettings, chat).
Anyone with the room code can connect, so the host passes every message through SBMpRules.cleanHostMessage first. Unknown types are dropped, strings are cut to length (name 24, answer 200, chat 300), filter values must exist in meta.json (the visual filter only takes visual/text), and the reading rate is clamped to 0.5–2.5. Peers that haven't sent join are ignored. Messages aren't rate-limited.
Connections use Google's public STUN servers, with no TURN relay.
data/bank/ (the question bank)One JSON file per tournament, data/bank/<slug>.json (108 files, about 24 MB raw and 7.2 MB gzipped in all; GitHub Pages serves them gzipped and over HTTP/2, so they download in parallel). The catalog, solo and multiplayer pages load every file, because their filters and search span the whole bank; the bookmarks page loads only the ones it needs.
Each file is an array of packets. A packet holds the fields its questions share, and the questions themselves under q:
[
{"rl":"AVES DE1","r":6,"u":"https://lebot.djiang.xyz/packets/AVES-2025/AVES%20DE1.pdf","q":[
{"i":0,"s":"math","f":"SA","qt":"tossup","n":1,"q":"Rohan is driving and …","a":{"t":"36"}},
{"i":1,"s":"math","f":"MC","qt":"bonus","n":1,"q":"How many positive integers …","a":{"t":"93","l":"Z"},"c":{"W":"50","X":"90","Y":"92","Z":"93"}}]},
…
]
Packet fields: rl (raw round label), r (round number, null if unlabeled) and u (packet URL, left out when there isn't one). A packet is a run of consecutive questions that share all three, so a tournament's questions keep their order. The tournament's name and slug aren't repeated on every question: they come from meta.json. Leaving those five repeated fields off each question is what makes the bank about a third smaller than the old single data/questions.json. Each line holds one question, so a change to a question shows up as a one-line diff.
data/bank/index.json is {"ids": [[firstId, slug], …]}: runs of consecutive ids, in id order, that belong to the same tournament (about 430 runs). SBData.slugForId finds a question's tournament with a binary search.
Editing the bank. tools/bank.js converts between these files and the flat form, one record per question carrying every field (the table below), which is what the old data/questions.json held:
node tools/bank.js unpack /tmp/questions.json # bank -> flat list, in meta.json's tournament order
# ...edit or audit /tmp/questions.json...
node tools/bank.js pack /tmp/questions.json # flat list -> data/bank/ (rewrites every file and index.json)
npm test
pack groups consecutive questions of a tournament into packets, writes one file per tournament slug, deletes the file of any tournament that no longer has questions, and rebuilds index.json. It doesn't touch meta.json, so update its counts (and add a new tournament's entry, with its level) when they change; npm test fails until the two agree, and also checks that every file is exactly what pack would write. Small edits can also be made to a tournament's file directly.
Checking new questions against their packets. After adding questions, put their packets' text in a folder (pdftotext, a Google Doc's ?format=txt export, a .docx's text), with a urls.json that maps each packet's URL to its file, and run:
node tools/subject-audit.js /tmp/packets --write
It finds each new question's stem in the packets and reads the header printed before it. It prints every question filed under a different subject than the label there, and every question whose type or number differs from the TOSS-UP 5) / BONUS 5) marker; it records the printed subjects in data/source-subjects.json (null when it can't find the stem or its label). Read each difference. For a subject, fix the question when the packet is right, or add "<id>": "printed→filed" to data/subject-overrides.json when the packet mislabels it. For a type or number, fix the question when the packet is right; packets misprint headers too (TOSS-UP 19 twice, BONUS 8 right after tossup 18), so check the questions around it first. Type and number are read only from a question's own packet (listed in urls.json), since a copy elsewhere can sit in another slot. --all re-checks questions that already have an entry, without changing them.
A flat record (a question plus its packet's fields and its tournament):
| Key | Meaning |
|---|---|
i | unique integer id (the only hard uniqueness constraint) |
t / ts | tournament name / slug |
r | round number, or null for unlabeled |
rl | raw round label (e.g. RR 5, Set 3 · Round 12) |
s | subject key: math, phys, bio, chem, ess, energy |
f | format: SA or MC |
qt | tossup or bonus |
n | question number within the round |
q | question text |
c | MC choices {W, X, Y, Z} |
a | answer: t text, l MC letter, ac also-accept list, rj do-not-accept list |
u | source packet URL, always https:// (on 73,725 records) |
im | figures for a visual question, in order: [{f, w, h}], f a path under data/img/ (mit-2025/17415.webp), w/h its pixel size. Absent on text-only questions. |
(rl, n, qt) is not guaranteed unique. Some packets really do contain spare or replacement questions that reuse a number (e.g. NWI 2025 DE5, CLASH "21A/21B", FE!M 2025).
data/meta.jsontotalQuestions, tournaments[{slug, name, count, level}] (sorted case-insensitively by name), subjects, rounds, formats, qtypes, levels, idAliases, generatedAt. It's rebuilt from the bank after every data change, and totalQuestions always equals both the sum of tournament counts and the number of questions in the bank. Every tournament listed has a file in data/bank/, and vice versa.
Each tournament also has a date (YYYY-MM-DD, approximate where the event's day isn't known) used for the catalog's newest-first order; a new tournament needs one.
level is hs or ms and belongs to the tournament, not the record: every question inherits its tournament's level at load time (SBData.load()), which drives the Level filter chips (the level isn't shown as a tag on questions). The middle-school tournaments are NSB MS Regs, CLASH 2026 MS, Bay Ultimate MS Scibowl, BUMS 2026, Dasoni Standard1, Deadbird Invitational, LOST 3 MS, MIT MS Study Guides and MOOSE 2021 (14,249 questions); everything else is hs (69,866). A rebuild of meta.json has to carry each tournament's level over, and a new tournament needs one assigned (a missing level is treated as hs).
idAliases maps the id of every question removed as a duplicate to the id of the copy that was kept; SBData.load() uses it to move saved bookmarks onto the kept copy, so merging duplicates never silently deletes someone's bookmark. Keep it when rebuilding meta.json. One known blur: "Random Stuff" is a grab-bag tournament that includes one MS earth-science packet but is tagged hs as a whole.
Some bonuses (and a few tossups) are about a picture: a diagram, graph, micrograph, spectrum, structure or photo. Visual questions are fully implemented: the record carries its figure, every page shows it, and a filter selects them. Very early ingests skipped them because there was nowhere to put an image; that is no longer a reason to skip anything.
Scraping rule: always scrape visual bonuses. When a packet is ingested, its visual questions go in with their figures like any other question. Look for the figure in the packet first (embedded images; vector drawings rendered from the page), then in the event's visual-bonus deck (slides, a separate PDF, a Google Slides export, speaker notes), then in the structured source (isobowl's imageUrl). Only when the image exists nowhere reachable is the question left out, or kept as text if it can be answered without the picture, and the gap goes under Known limitations.
Visual sweep (October 2026): all 1,976 packet files were searched for visual markers ("Visual Bonus" headers, NSBA1's "VISHWAL BONUS", "shown in the image", "pictured", "depicted", "the following figure/graph/pedigree", VB slide titles) and every hit was matched against the bank. All but a handful already carry their figure. Added: ICSBT 2's DE7 Brassicaceae bonus (its floral diagram is embedded in the .docx) and its ext4 "visual" bonus, which has no picture and is answerable as text. Two text-only copies that sat in a tossup slot beside the real visual bonus (NSBA1 Playoffs 2.2's Vish Fish, Finals 3's Eulerian path) and one in LOST 1 Round 13 (Bullet Cluster) were removed, with idAliases pointing at the visual record. Still without a figure, because none was published: Texas 2026's three visual bonuses (Round 12's sandstones and Round 13's beach profiles are left out; Round 13's 7-segment display is in the bank as text, since the display is standard), National Chemistry Bowl DE6's three (the Google Doc says "shown above" but holds no image), Fall 2023 Round 14's pipe-flow bonus (its .docx image is an empty placeholder), and SBST's and SBST (Spring)'s visual rounds (their packets never included the figures).
Which questions: 535 records in 31 tournaments carry an im list (541 images; NSBA1's and Bash 2025's two-part figures have two images each, and one WSBT bonus has three). The largest sets are LOST 2 (46), SBL (44), MIT 2025 (36), LOST 1 (34), NSBA1 (31), ESBOT 2 and CLASH 2026 HS (30 each), DASONI 2 and HSBT 2026 (27 each), Bash 2025 and MIT 2024 (25 each), WSBT (23) and Fall 2023 (22). 99 of them were already in the bank as text and got their figure back; the rest were added. Where an existing record's text had been cut short, it was replaced with the packet's full text. Per-tournament counts are in docs/tournaments.md, and how they were found and checked is in docs/data-history.md.
Where the images come from: images embedded in the PDF and .docx packets of the user's packet archive (matched to the bank by question text and cropped to the figure; where a packet's image runs off the page, the embedded picture itself was taken instead of a page render); the separate visual-bonus slide decks (MIT 2024/2025, Fall 2023, DAST, CLASH, FE!M, …), rendered slide by slide with LibreOffice and trimmed to the figure; LOST 1's and LOST 2's slide decks from scibowl.live; HSBT 2026's public visual-bonus deck (Google Slides, exported as PDF); figures drawn inline in SBL's packets and in ICSBT 3's public PDFs; the public SMH 2026 Google Docs; and isobowl's structured Trio Online Math Bowl rounds. Figures drawn as vector graphics (LOST 1's reaction schemes and circuit, SBL's geometry diagrams) have no embedded image to extract, so the page region holding the drawing is rendered at 200 dpi instead, which captures vector strokes, text labels and any pictures layered on top together.
The bank includes every question from every tournament and packet. This is the rule for every scrape and every cleanup pass:
meta.json's idAliases, so bookmarks move to the kept copy.In October 2026 the whole bank was re-checked against this rule. The earlier duplicate audit had also removed reworded questions and lone reuses, and some ingests had skipped questions that only loosely resembled one already in the bank. Those were restored (see docs/data-history.md). On October 25 the rule was loosened again so that repeats inside one event stay too, and the 43 that earlier passes and ingests had dropped were put back (see Lenient duplicate policy).
Questions are read on screen, not heard, so the bank stores each one the way a player should see it. These rules were set by earlier cleanup passes (the history is in docs/data-history.md). They apply to every packet, and every new scrape has to follow them too.
Remove text meant for the moderator or the writer
[my-OH-sis], (pron: …), Voigt (voyt)) and spoken readings of math or formulas (0.3939... [zero point three nine three nine repeating], (read as: …)) are removed. Where a reading was the only copy of the math, the math is written out instead.[JR], name#1234, "hi please write some phys or chem"), packet section headers glued to stems ("(Mech) – Multiple Choice"), page footers and watermarks, and template slots ("Your bonus question goes here.").Answer keys hold the answer and nothing else
(ACCEPT: …), ACCEPT … and (DO NOT ACCEPT: …) notes are moved into the accept (ac) and reject (rj) lists. Moderator notes such as "(must give units)" or "(order irrelevant)" are dropped. A note that changes grading is kept as grading data: "do not accept" parts go to the reject list, "accept either" parts to the accept list, and "must give both" keys join their parts with AND.;, an accept entry may be an alternate for one part (DIAMONDOIDS for 1) ADAMANTANES; …), and the grader matches it to that part. On any other key, every accept entry is graded as a whole answer. An alternate for one item of a list such as OXYGEN AND WATER is therefore written as a whole answer; a lone O2 would pass by itself. A list of substances gets one accept in formulas, in key order (O2 AND H2O), and the grader mixes names and formulas from it. Gases also get the bare symbols (CO2 AND N, so co2 n passes), and ions both the charged and bare forms (Na^+ AND K^+, Na AND K). A question that asks for elements gets just their symbols (Fe AND Ni). For the same reason, an accept list that a scrape split at its commas (S0, SBa, Sc for a ranking keyed 1, 2, 3) is joined back into one entry (S0, SBa, Sc).1, 3, 2, not 132.Write math in notation
8√14, "4PI" becomes 4π, "left parenthesis x + 1 right parenthesis" becomes (x + 1), sqrt(…) becomes √…, and * or x used as a times sign before 10^ becomes ×. In answer keys, the spoken form stays as an accepted answer. A spoken key whose grouping is ambiguous ("1 + ROOT 2 OVER 2") is left as printed.^ and _ markup, never Unicode super- or subscript characters: x^2, cm^-1, ^(13)C, log_2 x, ∫_0^1. A bare exponent ends where its leading digits end (x^3y), so a longer exponent needs parentheses (e^(-2t)). Chemical formulas are written with plain digits (H2SO4, [Fe(CN)6]^(3-)), and the site renders the subscripts.Ca^(2+), NO3^-, H^+. So are hybridizations (sp^3d^2) and electron configurations (1s^2 2s^2 2p^6).-2, –4). The display joins X - Y into X-Y, so "is - 2" would show as "is-2".?, , or a closing :, and none inside √3, 10^-3 or σ/3. Compound names split by a spaced dash ("MICHAELIS – MENTEN") are hyphenated. Math-italic letters (𝑥, 𝜋) are plain letters.Fix extraction damage, not content
Placement
data/source-subjects.json records the printed subject of every question ("other" for Energy, General Science, Physical Science and the like, which the Energy rule places; null where no packet could be checked), and npm test fails when a question's subject differs from it without an entry in data/subject-overrides.json. An override is written "printed→filed", so changing that question's subject again makes the test fail until someone re-reads it. A packet's label is overridden only after reading the question, when it belongs to the other subject at least as much (an enzyme question printed under Chemistry, a Kepler's-laws question under Physics); a question that plainly fits the printed subject keeps it. Every new scrape records the printed subjects with tools/subject-audit.js (see Editing the bank) and settles each difference it lists. A single-subject event's questions are all recorded as its subject.level and an approximate date in meta.json (see Data format).x2 looks the same as a subscript x₂, a formula like H2O, or an ordinary number), trig powers like sin2 x (indistinguishable from sin 2x given the corpus's spacing), and stacked fractions flattened onto one line in packets that aren't in any archive (e.g. 3 √13 13 for 3√13/13 in some answer choices). The examples this list used to name (NWI 2024's ellipse, MHS Rounds' Laplace transform of t², CSUB's integrals, NSB Regs Set 6's area under y = √x) have been repaired from the packet archive.main, and pick / (root).https://<you>.github.io/<repo>/.There's no build step, npm install, or environment variables. package.json only defines npm test and has no dependencies.
python3 -m http.server 8000
# open http://localhost:8000/
npm test # or: node --test tests/*.test.js (Node 18+, no install needed)
The tests, which CI also runs on every push and pull request:
tests/answer-check.test.js: grading rules, the known false positives, and every answer key and whole-answer accept in the corpus graded against its own question.tests/mp-rules.test.js: multiplayer message validation and override rules.tests/data.test.js: every record has the fields and values the site expects, ids are unique, meta.json counts match the bank, data/bank/ has one file per tournament and each is byte for byte what tools/bank.js pack writes, index.json maps every id (and every alias target) to its own tournament, every question's subject matches the subject its packet prints or is a listed override (see Placement), every image a record names exists under data/img/ and every file there is used, the Visual filter splits the bank into visual and text-only questions with nothing left over, packet order matches a direct collator sort, and search returns the same matches as checking each word against each field separately. Run it after any data change.answer-check.js, data.js and mp-rules.js attach to globalThis outside the browser, so tests require them directly.
<head>;,andthenfirst quarter full moonfull moonFULL25MEDIAN = 25magnons1) MAGNONSLHLUTEINIZING HORMONE1ST QUARTER | FULL MOONfull moonLHCOPPERfirst quarter and full moon,andnormalize() strips -, so -2 used to equal 2. Now, when both answers start with a number, that leading number's sign must match. So 195 degrees fails -195 degrees, 2+sqrt6 fails -2+sqrt6, and negative 2, minus 2, and −2/–2 all equal -2. When both answers contain the same numbers in any order, each number's sign must also match: 3 + 5i no longer passes 5i - 3, and 5, 7 no longer passes -5, 7. A dash after a digit (20 – 21, 2 - 3i) or attached to a letter (G-2, A-1) might be a range, subtraction, or label, so it isn't read as a sign. Leading zeros are kept (001 ≠ 1). Checked on the corpus: of 674 answer keys that start with a negative number, 659 used to accept the answer with the sign dropped. Now 18 do, and in each of those the main key or an alternate is written without a sign. The bare-number set comparison also skips multi-part keys, so a reversed answer to a "respectively" question (18; 16 for 16; 18) no longer passes.0.5 = 1/2, 0.75 = 3/4, 5.5 = 11/2, also with a trailing unit or symbol (0.5 pi for 1/2 PI, a bare 0.5 for 1/2 METERS). Nothing else is compared by value: two fractions, two decimals, or a decimal and an integer are still compared as written, because packets reject unreduced fractions (15/60 for 1/4) and dropped significant figures (1 × 10^-8 for 1.0 × 10^-8). The fraction must be in lowest terms, and 0.33 is not 1/3. Checked on the corpus: no main answer stops passing, and the only new cross-answer matches are true fraction/decimal pairs.ER, RER and SER read as endoplasmic reticulum, rough and smooth endoplasmic reticulum (so rough ER matches ROUGH ENDOPLASMIC RETICULUM). The table is in normalize() in js/answer-check.js.x^2 = x2 and e^(-x) = e-x, so answer keys that gained ^ in the superscript repair still accept what students type.ITCZ OR INTERTROPICAL CONVERGENCE ZONE, GREEN ACCEPT: EMERALD) accepts each one, and a trailing note in parentheses can be left off (TORRICELLI'S THEOREM (OR LAW)). An OR is not split when the key is an equation or inequality, or says both answers are required (x = 0 or x = 9 (MUST GIVE BOTH ANSWERS)).+6) passes even when a reject entry only differs in punctuation (6).jupiter saturn = SATURN AND JUPITER, the troposphere = TROPOSPHERE, mitochondrion = MITOCHONDRIA.AND, e.g. CARBON DIOXIDE AND MOLECULAR NITROGEN): each typed item is matched to a key item, in any order. An accept with the same number of items lends its items to the key items in the same places when it is written all in formulas (CO2 AND N2) or renames just one item (CARBON DIOXIDE AND NITROGEN), so names, formulas and alternates mix: nitrogen and carbon dioxide, CO2 and nitrogen and co2 n all pass. Formulas are read case-sensitively (Fe, CO2, Na^+), so an all-caps second answer such as ALUMINUM AND SILICON never mixes with the key. A list on an ordered question has to be given in its order: ranking wording, "respectively", a matrix written in rows, an ordered pair, or a point's coordinates. magnesium, iron passes "the only metal in forsterite and the only metal in fayalite, respectively" and iron, magnesium doesn't. Before this, only ; keys were held to their order, so 419 comma or AND lists on ordered questions passed when reversed (water, aluminum, lead for "order … from largest to smallest increase in temperature": LEAD, ALUMINUM, WATER). Checked across the corpus: every key and accept still passes in its own order, and the only answers that now pass where they didn't are formula and mixed forms of substance lists and number sets with signs in another order (5, -2 for eigenvalues -2, 5).deuterosomes for DEUTEROSTOMES), or the whole key plus more words (sodium chloride for SODIUM), is marked wrong with close: true. Solo and multiplayer then show "Close", and the player overrides with Q if they meant it. A swap that names a different term never counts as close: hypertonic/hypotonic, alkane/alkene, nitrate/nitrite, triphosphate/diphosphate, aerobic/anaerobic and the other pairs in CONTRAST_SWAPS.carbon for CARBON DIOXIDE, dwarf for WHITE DWARF) gets no credit.subscriptFormulas writes the counts in formulas such as H2SO4, Ca(OH)2 and C6H12O6 as subscripts. A run counts as a formula only when every symbol is an element, no count is 1 or starts with 0, no element repeats back to back, and it isn't a lone element (except a short list of real molecules: O2, N2, Cl2, S8, C60, …) or one of the bank's look-alikes (SN2, sp3, HSP70, IC50, H3K27, P700, …). An ion written with its charge as a superscript (H3^+, S2^(2-)) gets its subscript even as a lone element. The stored text isn't changed, and grading ignores the subscripts.answerDisplayText(q): for MC questions the choice text wins over a.t, so a damaged answer field still displays correctly.answerLineHTML(): one shared "Answer: X (also accept: Y)" renderer.questionMetaRow, appendQuestionBody, answerBlock and bookmarkButton build the cards in the catalog, the bookmarks page and both session logs. escapeHtml, QTYPE_LABELS and FORMAT_LABELS are shared from here too.questionFigures(q, opts) builds the figure strip (lazy-loaded <img> with its width/height set so the layout doesn't shift, eager in the live question panel), openLightbox(src, alt) shows one full-size, and preloadImages(q) warms the next question's images. expandQuestions turns a record's im into q.images ({src, w, h}) and sets q.visual, which the filter and the tags read: qtypeLabel(q) / qtypeTagClass(q) give a visual bonus its "Visual Bonus" type tag in the cards and in the solo and multiplayer question panels.cleanRoundLabel() tidies round labels for display: it strips "Copy of", strips a repeated tournament name, and spaces letters from digits (DE1 → DE 1).filterQuestions, including the visuals set: visual/text), shuffles (shuffle, stratifiedShuffle, stratifiedQueue), the dual round slider (wireRoundRangeSlider), the searchable multi-select (enhanceMultiSelect), range-fill styling, and bookmark storage (kept in memory, re-read when another tab changes it).Files: data/img/<tournament-slug>/<question id>.webp (<id>-1.webp, <id>-2.webp … when a question has more than one figure), referenced from the record as {f, w, h}. Images are WebP at quality 86, at most 1,400 px on the long side, with transparency flattened onto white so they read in dark mode too; five animated figures stay GIF. Total about 24 MB. They're downloaded only when a question that uses them is shown (lazy-loaded in lists, preloaded one question ahead in solo and multiplayer), so the bank's load time is unchanged.
Display: figures sit between the question text and the choices, on a white card, capped at 420 px tall (300 px in catalog cards, min(52vh, 460px) in the live question panel), and open full-size in a lightbox on click. The card's width/height attributes reserve the space before the image arrives.
Filter: a "Visual questions" chip row (Visual / Text only) in the catalog, solo setup, solo settings and multiplayer room settings; selecting neither or both means no filter. It's carried in the multiplayer settings messages like every other filter.
Grading is unchanged: the answer still comes from the record's key and accept list.
(26, 15) for x = 26, y = 15, ±1 for 1 and –1, RGB, ELECTROWEAK FORCE, HELIUM NUCLEUS for "2 protons and 2 neutrons". "Voyager" for the two Voyager probes is the packet's own accept.(ACCEPT TWO) CHEMICAL, MECHANICAL, …, ANY TWO OF: …, about 20) list more items than the question asks for, and the grader can't grade "any two of these". They stay as printed.I), II) (HSBT and ICSBT 3 visual bonuses) can't be answered without the labels, and a part keyed as a letter plus its choice text (3) W ((R)-METHYL 3-HYDROXYBUTANOATE)) doesn't accept the letter alone. The bank's other keys label parts 1), 2), which the grader lets you leave off.5 root 5, the square root of 3 to the square root of 2, about 30 choice sets) are left as printed. The radical rule was applied to answer keys, and changing a choice also means changing its key.rround1-9/rround10-17 sit on rounds 1 and 10, because the source doesn't say which specific round each belongs to.