How this is builtGroundworkHow this is built
16 / 26 written chapters

I10 · Part 2 · How it runs

The mock interview engine

A loop planner, a pure session reducer and a hiring committee, all in the browser.

8 min read1 diagram10 sections
10On this page
  1. The pieces
  2. The bank stays on the server
  3. Planning a loop
  4. Building a session
  5. The reducer
  6. The adaptive interviewer
  7. Scoring
  8. Pacing
  9. The design-round whiteboard
  10. Storage and readiness

The pieces

/mock runs a whole interview loop: a recruiter screen, coding graded by real tests, the technical deep dives, system design, behaviour and the offer conversation. The logic is in twelve modules under lib/mock, and none of them import React. The UI in app/mock is a lobby, a room, a debrief and a readiness dashboard. The engine is pure functions over plain data, so the 66 test cases in the four tests/mock*.test.ts files can drive a whole loop without rendering anything.

SERVER, AT BUILD TIME BROWSER lib/mock/bank.ts every question /mock/bank/[stage] 12 static JSON files fetchStage only planned stages buildSession seeded picks reduce() brief, answer, review decideLoop the committee history localStorage, last 50 readiness 14-day half-life
The server's only job is to split the question bank by stage. Everything after that, from the plan to the verdict, runs in the reader's browser.

The bank stays on the server

lib/mock/bank.ts assembles the questions. For talk rounds it reads the interview rounds in content/interview-data.ts and the JavaScript and React question banks, and it parses each answer's seniority ladder out of its HTML. For coding rounds it wraps the site's exercises, with function exercises for the coding stage and component exercises for machine coding. That is a lot of content, and the lobby needs almost none of it.

So only two app files import the bank's functions. app/mock/page.tsx is a server component that calls mockCatalog() and passes the lobby a summary with titles, who runs each stage, and a count of questions. app/mock/bank/[stage]/route.ts is a route handler with dynamic = "force-static", dynamicParams = false and generateStaticParams over the 12 stages, so the build writes one JSON file per stage and any other stage name is a 404. The client files import only types from the bank. There is no server-only guard package; the import graph is what keeps the bank off the client.

On the client, fetchStage keeps one promise per stage and forgets it if the fetch fails, so a retry works. The lobby starts fetching the plan's stages when the reader hovers or focuses Start, and loads the room's code in idle time. The commit that set this up measured the lobby going from 628 KB of script and 462 KB of question fetches to 237 KB and 12 KB.

Planning a loop

A loop is configured by role (frontend, full-stack, backend), seniority (junior, mid, senior), company type (service, product, SaaS, agency) and intensity (quick, standard, full). planLoop walks the 12 stages in order and applies each stage's rule from STAGE_RULES: which roles and seniorities it applies to, a weight (essential, standard or full) compared against the intensity, and whether that kind of company actually runs the round. A plan with no coding round gets one inserted.

IntensityTalk questions per stageCoding problems per stage
Quick21
Standard32
Full42

Machine coding gets one problem, or two on a full loop. Time slots are 3, 4 or 5 minutes per spoken answer by seniority, 15 minutes per coding function and 20 per component.

lib/mock/styles.ts adds four company styles on top: Amazon-style, big tech, product startup and service company. A style can force stages in or out, add questions to a stage, mark stages as core, prefer certain source rounds, ask for harder coding, and name stages where a lean-no is a veto. Amazon-style, for example, forces a behavioural stage, adds two questions to it, prefers the Leadership Principles round, and puts it under a veto.

Building a session

buildSession takes the plan, the fetched banks and a seed. The random source is a 32-bit xorshift, so one seed always gives the same loop, which is what makes the tests deterministic. pickItems shuffles each pool and ranks items by fit. A talk question scores higher when its level matches the reader's seniority or when the style prefers its source, and is dropped when it is marked only for other seniorities or companies. A coding problem scores on level and on topic: JavaScript and React for a frontend loop, data structures otherwise. No question is asked twice in a loop. Each talk question that has follow-ups gets one, drawn at random from its list.

The reducer

A session moves through four steps per question: brief (the stage introduction), answer, followup and review. reduce(session, action) handles twelve action types, and every case checks the step first, so a stray click in the wrong step is ignored. MockApp keeps the session in React state, dispatches into this pure function, and writes the session to groundwork:mock:current 250 ms after each change. A reload offers to resume, and resuming restarts the clock on the current question.

The adaptive interviewer

While building, every question after the first in a stage gets two alternates: one harder and one easier, chosen from the same pool and reserved so nothing else takes them. When the reader moves on, shiftFor reads the score just earned. At 0.8 or above the next question is swapped for the harder alternate, and below 0.45 for the easier one. Those two thresholds live in lib/adaptiveThresholds.ts, which the DSA placement shares, so raising and lowering mean the same thing in both. It only happens within a stage, and never in a retry round.

persona.ts gives each of the 12 stages an interviewer with a name, a role and a tone (warm, sharp or calm). Each tone has three opening lines and its own way of saying the next question is harder or more basic. An Amazon-style loop replaces the behavioural interviewer with a Bar Raiser.

Scoring

Coding is scored by the tests: the fraction passing, minus 0.05 per hint up to 0.15, and capped at 0.3 if the reader opened the solution. Spoken answers are scored by the reader against a rubric built for that question, each criterion marked 0, a half or 1.

CriterionWeightIncluded when
Answered what they were really testing2The question says what it tests
Had the substance of the model answer3Always
Stayed clear of the trap2The question names a trap
Held up when pushed2A follow-up was asked
Structured and concise1Always

A score maps to a verdict: strong hire from 0.8, hire from 0.65, lean no from 0.45, and no hire below that. decideLoop then acts as the hiring committee. It weights core stages twice as heavily as the others, then applies its rules in order:

  • A lean-no in a stage the style vetoes is a no hire. This is the Bar Raiser rule.
  • A no-hire in any core stage is a no hire, whatever the average says.
  • Two or more lean-nos anywhere make a lean no.
  • Otherwise the weighted average decides.

A hire can still come with a level call. Above junior, a design round under 0.65 turns the offer into one at the level below. Every round at strong hire, plus a design score of at least 0.8 above junior, makes the case for the level above.

Pacing

pacing.ts reads the timestamps. A spoken answer is marked rushed if it took less than 45 seconds or a fifth of its slot, whichever is shorter. Two or more rushed answers earn a note in the debrief, and so does any answer that ran past the clock. The debrief can also be copied as plain text with the verdict, each round, the reasons, the pacing and the five weakest questions.

The design-round whiteboard

The system design stage has a small whiteboard of its own in app/mock/Whiteboard.tsx, not the full board from /whiteboard. Its model in lib/mock/board.ts has four shape kinds (box, database, queue, text), six stencils (client, load balancer, service, database, cache, queue), arrows between shape ids, and a fixed 800 × 420 canvas that shapes are clamped inside. The board is stored on the question itself through a board action, so it is saved and resumed with the session.

Storage and readiness

When a session finishes, mockStore.finish runs the committee once more and appends a summary to groundwork:mock:history, which keeps the last 50. Questions scoring under 0.6 join a retry queue at groundwork:mock:retry, which holds up to 60, and questions scoring 0.8 or more leave it. The store exposes subscribe and a cached snapshot for useSyncExternalStore, with an empty snapshot on the server.

The readiness dashboard is computed from that history. Each competency's score is an average in which a session's weight halves every 14 days, so a session four weeks old counts a quarter as much as one from today. The overall score falls in one of four bands (not yet, getting there, interview-ready, strong) at the same cut-offs as the verdicts. The dashboard also draws trends, a heatmap of the last 10 sessions, and a streak, and suggests what to do next: the weakest competency's weakest stage, or a coding or design round never tried.

Why the reader marks their own answers

Nothing here sends an answer to a model or a server. The rubric puts the model answer, what the question tests and the trap next to the reader's notes, and asks for an honest mark. That keeps the feature free and private and makes it work offline. The cost is that the score is only as honest as the person marking.