The problem
You want every student to get a slightly different version of the same problem, so that comparing answers doesn't work.
The obvious approach is a question bank: write twenty variants, assign each student one, store which one they got. It works. It also means someone has to write twenty variants of every question, and you now have a per-student assignment table that has to stay consistent forever.
We did it differently, and the difference is one idea: generate the variant from a deterministic seed instead of storing it.
The design
The author writes one template — a prompt with parameters, and a formula for the answer. At request time, the server derives a seed:
const seed = computeRandomTaskSeed(userId, lessonId, stepIndex);
const { resolvedPrompt } = resolveRandomTaskProblem(rawProblem, seed);
Same three inputs, same seed, same variant — forever, with nothing persisted.
What falls out of it
Different students get different problems. The whole point, and it comes from userId being in the seed.
The same student always gets the same problem. This one matters more than it looks. The naive implementation — randomize per request — creates a re-roll attack: refresh until you get an instance with friendlier numbers. Students find this on day one. Because our seed contains no clock and no request nonce, refreshing produces a byte-identical question.
No storage, no migrations, no drift. There's no variant table to keep in sync, nothing to backfill when an author edits a template, and no possibility of "the question shown" and "the answer graded" disagreeing because a row went stale. The seed regenerates the same variant on demand, every time.
Author edits are instant. Change the template and every student's variant re-derives from it on the next page load. With a stored bank you'd be reconciling old assignments against a changed question.
The part that's a security decision, not a design decision
The client must never receive the template, the parameter set, or the answer formula. Resolve on the server, send only the resolved prompt:
randomTaskProblem = canManageLessonAsAuthor
? { ...rawRandomTaskProblem, resolvedPrompt } // author is editing it
: {
resolvedPrompt, // student sees only their own variant
points,
tolerance,
explanation,
showCorrectAnswerOnFailure,
};
It is genuinely tempting to ship the template and resolve client-side — it's less server work and the interaction feels snappier. It also puts the answer generator in the page source, which converts your randomization from an anti-copying measure into an inconvenience for students who don't open devtools.
Note the shape of the branch: the author gets the raw template because editing requires it. Role-based field selection at the boundary, not a single response shape that leaks to whoever asks.
What it costs
Writing a good parameterized template is harder than writing a good fixed question. The author has to reason about parameter ranges: does every value in this range keep the problem meaningful, or does some combination produce a degenerate case — a divisor of one, a negative quantity where the story doesn't allow it, an answer that happens to be zero?
A question bank is dumber and more predictable, and for some material that's the right trade. This isn't strictly better; it's better when the question has a parametric shape at all.
The generalizable bit
Derive instead of store, when the derivation is deterministic and cheap. Anything you store is something that can go stale, needs migration, and can disagree with reality. Anything you can regenerate from stable inputs can't.
The trap is that the same property that makes it good — same inputs, same output — is exactly what you break the moment you add a timestamp or a random nonce to the seed "to make it more random". If you take one thing from this: whatever goes into that seed is the contract, and adding to it later is a breaking change for every student mid-course.
Top comments (0)