I build GamePrep, a free practice site for the "games" employers use in hiring: HireVue, Arctic Shores, Aon, Wonderlic-style tests. 39 browser games, each one ends with a score and a percentile.
Two lessons from building it, one about trust and one about norms:
- Never let the browser decide the score. Not because people cheat on a free practice site, but because the moment the score lives in JavaScript, it means nothing.
- Simulated norms are a guess. Mine were wrong by a mile, and real players told me so.
The stack is Laravel + vanilla JS, but the pattern works anywhere.
The contract: the browser sends what happened, the server decides what it means
Every game is a PHP class plus a JS module. The interface is small:
interface Game { public function getSlug(): string; public function getJsModule(): string; /** * Validate the raw client payload and compute the score server-side. * Never trust any score computed in the browser. * * @throws InvalidGamePayloadException when the payload is missing data, * malformed, or structurally impossible (manipulated). */ public function scoreResult(array $raw): GameScore; } The JS module records raw events (which option, how many milliseconds) and POSTs them. One controller handles all 39 games:
public function storeResult(Request $request, string $slug): JsonResponse { $game = $this->registry->get($slug) ?? abort(404); $validated = $request->validate([ 'payload' => ['required', 'array'], 'duration_ms' => ['required', 'integer', 'min:1', 'max:1800000'], ]); try { $score = $game->scoreResult($validated['payload']); } catch (InvalidGamePayloadException $e) { return response()->json(['message' => $e->getMessage()], 422); } // store score, percentile and the raw payload… } The raw payload is stored next to the score. It costs one JSON column, and it means any later change to a scoring rule can be checked against real runs.
Level 1: reject what a human can't do
The cheapest check is plausibility. A reaction-time game gets five trials, and each one has to fall between 120 and 2000 ms:
public const TRIALS = 5; public const MIN_RT_MS = 120; // faster than this is anticipation, not reaction public const MAX_RT_MS = 2000; if (count($trials) !== self::TRIALS) { throw new InvalidGamePayloadException('A session must contain exactly 5 trials.'); } // per trial: if (! is_int($rt) || $rt < self::MIN_RT_MS || $rt > self::MAX_RT_MS) { throw new InvalidGamePayloadException("Trial {$index}: 'rt_ms' outside plausible bounds."); } The multiple-choice tests do the same with a 400 ms floor per question, and a timed test rejects a run whose answers add up to more time than the clock allows. None of this stops a determined person, but it removes the garbage: double-submits, broken clients, someone posting {"score": 100} by hand.
Level 2: the answer key never reaches the browser
For tests built from a fixed question bank (verbal, deductive, situational judgement), the page gets the questions and nothing else:
interface ProvidesClientData { /** JSON-serialisable, with no answer key in it. */ public function clientData(): array; } The Blade view drops that into a data attribute, and the JS reads it from there:
<div id="game-root" @if ($game instanceof \App\Games\ProvidesClientData) data-game-data="{{ json_encode($game->clientData()) }}" @endif> The payload names item ids and the player's choice. The server looks the answer up itself, and it also checks that the ids make sense together:
protected function isCorrect(array $item, int $index): bool { $statement = $item['statement'] ?? null; $key = $this->key(); if (! is_string($statement) || ! isset($key[$statement])) { throw new InvalidGamePayloadException("Item {$index}: unknown statement."); } if ($key[$statement][0] !== ($item['passage'] ?? null)) { throw new InvalidGamePayloadException("Item {$index}: the statement does not belong to that passage."); } // …and the same statement can't be answered twice } View source on the page and you will find every question and not one answer.
Level 3: seeded items the server can rebuild
Some tests generate their questions procedurally. Take Aon's digit challenge: you see (_ × _) − _ = 55 and fill the blanks with digits 1–9, each once. Get one right and the next is harder; get one wrong and it gets easier. You have four minutes.
If the browser generated the equations, it could also generate easy ones. If the server generated them and sent the answers along, the answers would be in the page. So the server sends a seed and the items generated from it:
public function items(int $seed): array { $random = new Randomizer(new Mt19937($seed)); $items = []; for ($position = 0; $position < self::POSITIONS; $position++) { for ($level = 1; $level <= self::LEVELS; $level++) { $shapes = self::SHAPES[$level]; $shape = $shapes[$random->getInt(0, count($shapes) - 1)]; // Positive results only do { $digits = array_slice($random->shuffleArray(range(1, 9)), 0, self::slots($shape)); $target = self::evaluate($shape, $digits); } while ($target < 1); $items[$position][$level] = ['shape' => $shape, 'target' => $target]; } } return $items; } public function clientData(): array { $seed = random_int(1, 2_000_000_000); return ['seed' => $seed, 'items' => $this->items($seed), 'durationMs' => self::DURATION_MS]; } The page holds an item for every position and every difficulty level, because the browser doesn't know which level the player will be on. The payload returns only the seed, where the run started, and the digits typed with their timings. On submit, the server regenerates the same items from the seed and replays the adaptive staircase itself:
$items = $this->items($seed); $level = 1; foreach ($answers as $index => $answer) { $item = $items[($start + $index) % self::POSITIONS][$level]; // validate digits and rt_ms… if (self::evaluate($item['shape'], $answer['digits']) === $item['target']) { $points += $level; $level = min(self::LEVELS, $level + 1); } else { $level = max(1, $level - 1); } } Two details worth getting right:
- The seeded RNG has to be explicit. PHP's
Random\Randomizerwith anMt19937engine gives the same sequence for the same seed, every time and on every machine. The globalmt_rand()would have worked until some other code called it in between. - The client must walk the staircase the same way the server does. The JS has its own
nextLevel(Math.min(LEVELS, Math.max(1, level + (right ? 1 : -1)))) only to pick which item to show next. If the two ever disagreed, the player would see one equation and be scored on another, so the rule is one line on each side and nowhere else.
This also checks answers the honest way: an equation can have several right fillings, and the server accepts any filling that makes it true.
What this does not protect against
The server doesn't remember which seeds it issued, so a player could submit a seed of their choice, or solve the page's equations offline with a script. For a free practice tool that's fine: the person cheating is the only one fooled. If this were a real assessment, I would store issued seeds with a short expiry and bind each one to the session.
And a few older games still trust the client: procedural multiple-choice games where the browser builds the question and declares the right option next to the player's choice. That's a known gap, written down in the base class.
Lesson two: my norms were made up, and players noticed
A score only becomes useful with a percentile: "You're in the top 20%." But on day one there are no players to compare against. So I simulated them. For a timing game like Arctic Shores' Lock (press Stop while a moving shaded sector covers the highlighted digit), I modelled personal timing noise and bias, concluded that the median person is about 40 ms off target, and built the percentile table from that.
At the end of each game the result screen asks, "Did you know if this score was good?" Every answer was "not really". And two new users kept landing on the 5th percentile.
So I pulled the real score distributions. Nothing matched the simulations:
| Game | Real runs | What the simulated table did |
|---|---|---|
| Lock | 195 | put 62% of all runs at the 5th percentile or below |
| N-back | 375 | 35% at the 5th percentile or below |
| Predict | 380 | 34% at the 5th percentile or below |
| Numerical reasoning | 137 | put 55% of runs in the top 5% |
| Flanker | 614 | 31% in the top 5% |
Real Lock players have a median error of 91 ms, not 40. My best guess at what the simulation missed: people are still learning the game while they play it, and a practice run is often someone's first.
The fix is boring: a percentile table is just a threshold list, so I rebuilt each one from the real distribution and wrote the source into the comment:
/** * Calibrated on 195 real runs (Nov 2026): median 91 ms, p10 44 ms, p90 195 ms. * The first table came from a simulation (median person ~40 ms) * and put 62% of all runs at the 5th percentile or below. */ private const NORMS = [ 30 => 97, 37 => 95, 44 => 90, 56 => 80, 66 => 70, 80 => 60, 91 => 50, // … 244 => 5, ]; What I'd do differently: ship simulated norms labelled as provisional, log every run from day one (I did, and without it there would have been nothing to recalibrate from), and check the score distribution after the first hundred runs, not after a few hundred.
Takeaways
- The browser reports events, the server computes the score. Keep the raw payload.
- Plausibility bounds remove most junk for almost no code.
- A question bank stays on the server; the page gets questions, never keys.
- Seeded generation (
Randomizer+Mt19937) lets the server rebuild exactly what the player saw, without storing it. - Simulated norms are a placeholder. Real players will tell you how wrong they are, if you ask them on the result screen.
If you want to see it in action, the games are free to play without an account: GamePrep. The digit challenge from the example above is one of them, and so is the Lock game. Questions about the scoring or the norms are welcome in the comments.
