Inspiration
Most emergency-response training tests recall — name the protocol, list the steps, pass the multiple choice. But the moment that decides whether someone walks away from a wreck isn't when you remember the protocol; it's the split second before the protocol fires, when instinct and procedure pull in opposite directions.
I wanted to build something that lived in that gap. Not a quiz — a scenario where the obvious move and the right move are different, and you only find out which is which after you commit. The thesis fits in one line: the fastest move isn't always the safest one, and judgment is the muscle that knows the difference.
Formally, the learner is solving a one-shot decision under uncertainty:
$$ a^{*} \;=\; \arg\max_{a \,\in\, \mathcal{A}} \; \mathbb{E}!\left[U(a)\right] \;=\; \arg\max_{a \,\in\, \mathcal{A}} \; \sum_{s \in \mathcal{S}} P(s \mid a)\, U(s), \qquad \mathcal{A} = {\,\text{rush},\,\text{stabilize}\,}. $$
The simulation never displays this expression — it lets the learner feel it.
How I built it
- Stack. Plain HTML, CSS, and vanilla JavaScript. No framework, no bundler, no build step. The whole experience is
index.html,styles.css,script.js, and four.mp4clips. Any static file server hosts it. - Scene graph. Every screen is data — phase, narration, clock, risk badge, choices, debrief — described as a record in a single
scenesobject. One function,showScene(key), renders the entire UI from that record. Adding a scene is a single object literal, not a refactor. - Branching. The
decisionscene exposes achoicesarray; clicking routes to that choice'snextkey. Outcome scenes carry adebriefblock (tone, heading, summary, bullet points) that renders on entry. - Cinematic media. The four scene clips were generated with Midjourney V1 Video using a two-step pipeline — an image prompt with
--ar 16:9 --rawfor the start frame, then a motion prompt at the Animate step. I mapped--motionto narrative tone:lowfor the calm beats,highfor the chaos beats. - Graceful degradation. Every scene also has a styled gradient fallback with its title, kicker, and description. If a clip is missing, the simulation stays fully usable.
The decision can be modeled more concretely as a tradeoff between hazard escalation and time-to-extraction. Treat ignition as a Poisson event with a hazard rate that depends on whether the scene is stabilized:
$$ P!\left(\text{ignition by time } t \,\middle|\, a\right) \;=\; 1 - e^{-\lambda_{a}\, t}, \qquad \lambda_{\text{stabilize}} \;\ll\; \lambda_{\text{rush}}. $$
If \(T_{a}\) is the time to free the patient under action \(a\), and \(C_{\text{ig}}\), \(C_{\text{delay}}\) are the costs of ignition and of delay, the expected harm under each branch is
$$ \mathbb{E}!\left[H \mid a\right] \;=\; C_{\text{ig}}!\left(1 - e^{-\lambda_{a}\, T_{a}}\right) \;+\; C_{\text{delay}}\, T_{a}. $$
Stabilizing increases \(T_{a}\) slightly but collapses \(\lambda_{a}\) — and because \(C_{\text{ig}} \gg C_{\text{delay}}\) in this scenario, the controlled branch wins on expected harm even though it feels slower in the moment.
What I learned
- Browser autoplay is a feature, not a bug. Browsers refuse to play video before a user gesture. I gated playback behind a
hasUserInteractedflag set on the first click. The briefing screen has no video on purpose, so by the time media is needed, the user has already qualified themselves. - Async media loading is a race-condition factory. Click through scenes faster than videos load and yesterday's
oncanplaycallback can fire after today'ssrcwas set, briefly showing the wrong clip. I solved it with a monotonicloadToken: every new load increments a counter, and a stale callback bails out when its captured token doesn't match. Formally, callback \(f_{k}\) for load \(k\) executes only when
$$ k \;=\; \texttt{loadToken}_{\text{now}}, $$
which guarantees at most one in-flight load is "live" at any time.
- Visual consistency across separately-generated clips is a prompt-engineering problem. I locked in shared phrases across every prompt — "red and blue light-bar reflections on wet asphalt," "anamorphic 35mm, shallow depth of field, volumetric haze, overcast night" — so the four clips read as one incident.
- Less framework, more clarity. Three files made the data flow obvious: data → render → DOM. No state library, no virtual DOM, no hidden lifecycle. Reading the source is the documentation.
Challenges
- Designing failure as a teacher, not a punishment. The "wrong" branch has to feel survivable enough that the learner reflects on it instead of dismissing it. The debrief copy went through several rewrites to land on "why this branch underperformed" rather than "you failed."
- Keeping the UI calm while the scenario isn't. The content depicts urgency, but the interface has to stay quiet — too much animation and the learner starts ignoring real signals. The risk badge gets three discrete tones — \(r \in {\,\text{medium},\,\text{high},\,\text{controlled}\,}\) — and that's it. No flashing, no countdowns. Tension comes from the writing, not the chrome.
- Making it work without the videos. Hosting four
.mp4s adds weight and a failure mode. Per-scene gradient fallbacks driven by adata-sceneattribute mean the experience reads cleanly on a flaky connection — or in a deploy with no assets at all. - Scope discipline. It was tempting to add a timer, a score, more branches, a leaderboard. The whole point is one decision under pressure — extra mechanics would have diluted that. The hardest engineering decision was the code I didn't write.
Log in or sign up for Devpost to join the conversation.