Key takeaways
- As of 29 October 2026, Chakri’s first visit is 23 files and 85 KB gzipped.
- The game picks the player before the wheel moves, so no swipe or animation can change the odds.
- The rules live in DOM-free modules that take a random source, so Node’s test runner checks them.
- One set of SVG functions draws the screens, the share cards and the link preview.
- Cache-Control: no-cache on HTML, JavaScript and CSS gets a fix to every phone on its next load.
Chakri, our Diwali party game, is a web game without a framework. It’s plain HTML, one stylesheet and 20 JavaScript modules, with no build step and no dependencies, and as of 29 October 2026 a first visit downloads 23 files and 85 KB gzipped. The rules sit in modules that never touch the DOM, so Node’s built-in test runner checks them. The art is SVG drawn in code, every sound is synthesised with Web Audio, and the result cards are drawn on a canvas and handed to the phone’s share sheet.
Chakri went live at chakri.9io.ai on 29 October 2026, and the launch post introduces the game. It runs in the phone’s browser, on one phone passed round the room. In Jodi Check two people guess each other’s answers, and in Chakri Truth or Dare 2 to 12 players take turns at a spinning chakri. Below, we follow the code from the spin to the card players post at the end, then cover caching.
Why we built a web game without a framework
Chakri matters for about one week a year. It has to open quickly on whatever phone is at the party, and next Diwali it has to run again without anyone first upgrading a toolchain. Plain JavaScript modules do both. Every modern browser loads them natively, so the files in the repository are the files the server sends. package.json lists no dependencies, only a script that runs the tests and one that serves the folder, because browsers won’t load modules from a file:// URL.
Escaping is the part of a framework Chakri had to replace with care. A party game prints players’ names everywhere, and a name is user input. src/html.js is a 38-line tagged template that escapes every value unless it’s wrapped in raw(), so a player called <b>Dadi</b> appears exactly as typed. Each screen is a view() function that returns markup, plus an optional mount() that attaches listeners such as the wheel’s. The shell redraws the current screen with innerHTML whenever something changes, and one click listener on the document routes every tap through data-action attributes. That routing, drawing and event handling fit in app.js and nav.js, 270 lines between them.
Without a bundler nothing is minified, and the browser makes one request per file. This is what the live site sent for a first visit, as of 29 October 2026.
| What | Files | Raw | Gzipped |
|---|---|---|---|
index.html |
1 | 10.6 KB | 4.0 KB |
style.css |
1 | 29.7 KB | 8.2 KB |
| Game code | 18 | 105.6 KB | 39.7 KB |
| Questions and cards, both languages | 2 | 94.4 KB | 31.0 KB |
icon.svg |
1 | 4.4 KB | 1.8 KB |
| First visit | 23 | 244.7 KB | 84.8 KB |
og.jpg, fetched by link previews only |
1 | 135.8 KB | 135.8 KB |
The decks are more than a third of the bytes, and most of index.html is the plain-HTML section described below. All the art (src/art.js) is 5.0 KB gzipped and all the sound (src/sound.js) is 2.1 KB. The page loads no web fonts, the only outside service it calls is a cookieless visit counter, and nothing a player types leaves the phone.
Rules and content in DOM-free modules, checked by Node’s test runner
The rules of both modes live in src/dares.js and src/jodi.js. Neither touches the DOM, and every function that needs randomness takes the random source as an argument. That’s Math.random in the browser and a small seeded generator in the tests, so a failure repeats exactly.
That means Node’s built-in test runner, stable since Node 20, can check the rules with nothing else installed. npm test runs node --test test/*.test.mjs over three files and 17 tests, and the deploy script stops if any fail. One test plays 500 turns with five players and checks that the chakri never picks the same person twice running and that nobody’s turn count drifts more than two from anyone else’s.
The same runner reads every line of content. Chakri has 82 Jodi Check questions and 176 truth-or-dare cards, each in English and Hinglish (Hindi in Latin letters, mixed with English), and test/decks.test.mjs checks both languages, matching placeholders and a length cap on every one.
Two tests hold the content rules. No dare involves fire, firecrackers, diyas, drink, smoke, going outside or messaging anyone outside the room. A word list catches the obvious slips in the English text of every dare, from candle and matches to beer and kiss. The Hinglish also has to work for any player. Many Hindi verb forms change with the gender of the person they’re about (“karoge” or “karogi” for “you’ll do”), so a regular expression rejects 29 of them in every Hinglish line, the UI included. “Ab toh karna padega!” (“You have to do it!”) works whoever is holding the phone. No test can tell whether a dare is funny, or whether the Couple deck stays flirty without getting explicit. That takes people reading the cards, and 60 of them are in our Diwali truth or dare questions.
A fair spin: the player is picked before the wheel moves
In Chakri Truth or Dare you swipe the chakri, a spinning firework drawn in the middle of a wheel of names. It stops on someone, who then picks a truth or a dare. We wanted turns to come round evenly, which pure chance doesn’t do over one evening. So the game decides who’s next as the spin starts, and the wheel animates to that person.

// Who the chakri lands on, decided before it moves.
function pickPlayer(players, last, rng = Math.random) {
const fewest = Math.min(...players.map((p) => p.turns));
const weights = players.map((p, i) =>
i === last ? 0 : 1 / (1 + 2 * (p.turns - fewest)));
let r = rng() * weights.reduce((sum, w) => sum + w, 0);
for (let i = 0; i < weights.length; i++) {
r -= weights[i];
if (weights[i] > 0 && r < 0) return i;
}
return weights.findLastIndex((w) => w > 0); // floating-point leftovers
}
The player who just went gets weight 0, so nobody goes twice in a row, and two players alternate. Everyone else gets 1 / (1 + 2d), where d is how many more turns they’ve had than whoever has had the fewest. Say Arjun has just gone, Riya, Kabir and Dadi have had as many turns as him, and Meera and Sam are one behind. Meera and Sam get weight 1 and the other three get 1/3, so Meera and Sam each have a 1 in 3 chance and Riya, Kabir and Dadi a 1 in 9.
The wheel then only has to arrive. It picks a stopping angle inside the chosen name’s slice, offset at random by up to 30% of the slice so it doesn’t always stop dead centre, adds whole turns and eases out along a cubic curve. The swipe sets only the direction and the length of the spin, from four turns in 3.3 seconds for a gentle one to eight in 5.2 for a hard one. A careful swipe can’t aim the chakri at a friend. The pointer ticks as it passes each name, and the faster it turns, the more sparks spray from its tip.
SVG art in code, shared by the screens and the share cards
The screens load no image files. src/art.js has a function for each piece of art, from the diya (a clay lamp) and the rangoli to the four firecrackers and the string lights, and each returns an SVG string. The rangoli takes the state of all 12 Jodi Check questions and draws a coloured petal and a lit diya for each match, a dashed outline for each miss and a pulsing ring round the question in play. The chakri’s spiral is a 160-segment path built in a loop.
The screens drop these strings into their templates, and the result cards draw them onto a canvas. Two small pages in the repository’s tools/ folder render the link preview and the Home Screen icon from the same functions with headless Chrome, so a change to a diya shows up everywhere a diya is drawn.
Gradients need one extra step, because SVG refers to them by ID. All the inline SVGs in a page share the document’s IDs, so the page inserts the six gradients once, in a hidden <svg>. An SVG drawn as a separate image, like the art on a result card, is its own document and can’t see them. Those calls pass withDefs: true and carry their own copy.
Sharing the result card with the Web Share API
At the end of a game Chakri makes a 1080 × 1920 result card, sized for a story. src/card.js paints a gradient sky and 28 soft bokeh lights, placed by a seeded generator so every card gets the same ones, then the string lights, the chakri or the rangoli, the scores and a footer with the game’s address.

The art goes in as images. Each SVG string gets a width and height at the size it will be drawn, becomes a data: URL and is loaded into an Image for drawImage(), so it’s rasterised at the card’s resolution. Text uses the system font and shrinks two pixels at a time until a long name fits, and toBlob() turns the canvas into a PNG.
The Web Share API can hand that PNG to the phone’s share sheet, with two conditions. Not every browser can share files, and canShare() answers that without side effects. And the spec makes share() reject with NotAllowedError unless the page has transient activation, which in practice means calling it inside the user’s tap. Drawing a card takes a moment, so Chakri draws it as soon as the share sheet opens, behind a spinner, and shows the buttons only when the file is ready.
// Draw first, so share() runs inside the tap that asks for it.
const blob = await drawCard(); // canvas.toBlob(resolve, 'image/png')
const file = new File([blob], 'chakri.png', { type: 'image/png' });
const withImage = navigator.canShare?.({ files: [file] }) ?? false;
shareButton.hidden = !withImage && !navigator.share;
shareButton.addEventListener('click', () => {
navigator.share(withImage ? { files: [file], text } : { text })
.catch(() => {}); // closed without sharing
});
Where files can’t be shared, Share sends the text and the link. Send on WhatsApp, Save image and Copy text are always offered. Save image is an <a download> pointing at the card’s blob URL, and Copy text wraps navigator.clipboard.writeText() in a try, so a refused clipboard doesn’t break the sheet.
WhatsApp link preview image size
Send on WhatsApp carries text only. It opens WhatsApp’s click-to-chat link with a pre-filled message, and the sender picks a chat. The message ends with the game’s address, so the picture in the chat is the link preview, built from the page’s Open Graph og:image, a 1200 × 630 image rendered from the same art.
The first preview image was a 346 KB PNG. We replaced it with a 136 KB JPEG, saved at quality 82, because WhatsApp is widely reported to drop previews whose image is over about 300 KB. We didn’t test whether WhatsApp would have shown the PNG. A Stack Overflow answer on WhatsApp link sharing lists an image under 300 KB among its steps, and Branch’s help centre says WhatsApp generally shows thumbnails for images up to 300 KB and calls its size limits undocumented.
We found no WhatsApp or Meta page that gives 300 KB for the image. Meta’s developer page on WhatsApp link previews, updated 21 May 2026, asks for an og:image under 600 KB and at least 300 px wide, and warns that previews for pages outside its rules are a best effort that isn’t guaranteed. Our JPEG is under both figures. The one 300 KB rule on Meta’s page is a different limit: the page’s <head> has to fall within the first 300 KB of the HTML.
Web Audio: synthesised sound that starts on the first tap
Browsers won’t let a page start sound on its own. An AudioContext created before the user has interacted with the page starts suspended, and MDN’s Web Audio best practices sum up the rule as “Create or resume context from inside a user gesture”. The Web Audio spec lets a browser refuse to start a context until the page has had a user activation. Chakri creates its context inside the first tap. One click listener on the document handles every button, and it wakes the audio before running the button’s action. Later sounds resume the context if the browser has suspended it, and while the sound switch is off, the game makes no Web Audio calls.
Nothing is sampled, so the game ships no audio files. All sounds run through one gain node into a compressor set up as a limiter (threshold −12 dB, ratio 6). The bells are additive. When a bell is struck, its partials aren’t whole-number multiples of the fundamental, and they die away quickly. Chakri’s bell is four sine waves at 1, 2.76, 5.4 and 8.93 times the fundamental, the first modes of a bar that’s free at both ends, rounded. A physics-of-music handout from the University of Rochester gives the first three as 1, 2.756 and 5.404.
// A struck bell: four sine partials at inharmonic ratios, the high ones dying first.
function bell(ctx, out, freq, when, gain = 0.16, length = 1.2) {
const partials = [[1, 1], [2.76, 0.32], [5.4, 0.14], [8.93, 0.05]]; // [ratio, level]
partials.forEach(([ratio, level], i) => {
const end = when + length / (1 + 0.7 * i);
const osc = ctx.createOscillator();
const env = ctx.createGain();
osc.frequency.value = freq * ratio;
env.gain.setValueAtTime(0.0001, when);
env.gain.exponentialRampToValueAtTime(gain * level, when + 0.002); // the strike
env.gain.exponentialRampToValueAtTime(0.0001, end); // a ramp to 0 would throw
osc.connect(env).connect(out);
osc.start(when);
osc.stop(end + 0.02);
});
}
The envelopes start and end at 0.0001 because the spec makes an exponential ramp to 0 throw a RangeError. A lit diya or a finished dare plays two of these bells a fifth apart, and a result plays six rising through a pentatonic scale.
The fizz and crackle are filtered noise. One second of white noise sits in a buffer, and each sound loops it from a random point through a biquad filter with its own envelope. While the chakri spins, a band-passed hiss sweeps from 5.2 kHz down to 2.6 kHz under 28 short high-passed crackles per second. The crackles are placed at seconds × (i / n)², so they crowd the start of the spin and thin out as the wheel slows. A firework is a sine thump sliding from 130 Hz to 38 Hz, a low-passed burst of noise and a dozen crackles.
Reduced motion, private windows and other fallbacks
The prefers-reduced-motion media feature tells a page that someone has asked their device for less motion, a setting phones keep under Accessibility (MDN). In CSS, one rule cuts every animation and transition in Chakri to 0.01 ms and a single run, which stills the twinkling lights, the flickering diya flames and the turning chakri on the home screen. In JavaScript, the same query makes the wheel turn once in 1.4 seconds before landing on the player it has already picked. It also stops the sparks from the wheel’s tip, cuts each firework burst to a fifth of its sparks and plays one burst where a result would set off several.
Local storage can fail too. Chakri keeps the language, the sound setting and the last players’ names and decks in localStorage, along with the last 48 Jodi Check questions played, so the next game can start with fresh ones. MDN lists a SecurityError when the user has set the browser to stop sites storing data, which blocking cookies will probably trigger, and setItem() throws QuotaExceededError when storage is full. In a private window the data goes when the last private tab closes. So src/store.js wraps every read and write in try/catch, and a failure falls back to the defaults.
Each browser feature the game depends on has a fallback.
| Part of the game | Browser feature | If it’s missing or fails |
|---|---|---|
| Sound | Web Audio, started on the first tap | Silence, and play carries on |
| A buzz when the chakri lands | navigator.vibrate() |
No buzz |
| Posting the result card | Web Share API with files | Shares the text and link, or hides Share if there’s no Web Share at all |
| Drawing the card | Canvas and toBlob() |
Send on WhatsApp and Copy text |
| Copying the result | navigator.clipboard.writeText() |
Nothing happens, and the other buttons still work |
| Names, decks, language, sound setting | localStorage |
Defaults on every visit |
| Motion | prefers-reduced-motion |
Full animation |
| Everything | JavaScript | A <noscript> line, and the plain-HTML section on how both games work |
Cache-Control: no-cache, so a fix reaches phones in Diwali week
Without a build step, file names carry no content hashes. src/dares.js has the same URL in every version, so a long cache lifetime would leave phones on an old copy, perhaps running yesterday’s app.js against today’s decks.
Chakri’s HTML, JavaScript and CSS are served with Cache-Control: no-cache. MDN explains that a browser may store such a response but must check with the server before each reuse, as RFC 9111 defines it. An unchanged file costs a 304 Not Modified with an empty body, and a changed one arrives in full, so a fix deployed during Diwali week reaches each phone the next time the game loads. PNG, SVG and ICO files are cached for a day (max-age=86400, public), which covers the two icons. The link preview, og.jpg, falls outside that rule and goes out with no-cache too.
The cost of no-cache is round trips. A repeat visit still asks about all 23 files, and the browser only learns a module’s imports once it has the module. Chakri’s imports go four deep (app.js imports play-tod.js, which imports dares.js, which imports the decks), so even when every answer is a 304, the page makes four rounds of module requests after the HTML before the game can start. We haven’t measured what that costs on a phone network. If it shows up, a <link rel="modulepreload"> in index.html for each module would let the browser ask for them all at once, still without a build step. MDN notes that listing each module is the only way to make every browser preload them.
A page that search engines can read
A game drawn entirely by JavaScript gives anything that doesn’t run it a title and nothing else. Chakri’s first index.html was 1.6 KB of shell. A few hours after launch we added plain HTML under the home screen: how both games work, the patakha points, the decks, a short FAQ and links to our Diwali posts. app.js writes the current screen’s name onto <body>, and one CSS rule hides the section during a game, so players only meet it if they scroll past the two games on the home screen.
The head now carries a canonical link and JSON-LD for the game, the site and the FAQ, with every answer word for word the same as the visible one. robots.txt, sitemap.xml, an llms.txt for AI assistants and an IndexNow key file go out with every deploy, because the deploy script deletes anything on the server it didn’t copy. The section took index.html from 1.6 KB to 10.6 KB, or 4.0 KB gzipped.
How we measured
The sizes are what the live server sent on 29 October 2026, measured with curl 8.9.1 at 19:38 UTC, after the plain-HTML section went live. For each file, one request without Accept-Encoding gave the raw size and one with Accept-Encoding: gzip gave the bytes on the wire. The text files came back gzipped and the JPEG and PNG unchanged. The first-visit total counts the page, the stylesheet, the icon and the 20 modules the page imports. A KB here is 1,000 bytes. All 29 files the site serves matched the repository by SHA-256 hash, and sending one module’s ETag back in If-None-Match returned a 304 with an empty body.
The weights, turn counts, durations and sound settings come from reading the source, and so do the 17 tests, which we read but didn’t run for this post. We haven’t measured load times, the module round trips on a real network, frame rates or audio latency, and we haven’t checked which phones can share files or whether WhatsApp would have shown the old PNG.
What we’d do again
- Use plain JavaScript modules for a small seasonal game, so there’s nothing to upgrade next year.
- Put the rules and a FAQ in plain HTML from the first deploy, so the page says what the game is before any script runs.
- Write an escaping template helper before the first screen.
- Keep the rules free of the DOM, and pass the random source in so tests can seed it.
- Decide outcomes in code and animate to them, so fairness is tested and the animation can shrink for reduced motion.
- Draw the art as SVG strings, so one function feeds the screens, the share image and the link preview.
- Draw the share image before showing the Share button, check
canShare()with the real file, and keep WhatsApp, save and copy as fallbacks. - Keep the link preview image well under 300 KB.
- Create the
AudioContextinside the first tap, and wrap everylocalStoragecall intry/catch. - Turn content rules into tests that read every line, from placeholders to banned words and gendered verbs.
- With no hashed file names, serve HTML, JavaScript and CSS with
Cache-Control: no-cache, and trymodulepreloadbefore a bundler if the round trips start to hurt.
Frequently asked questions
Can you build a web game without a framework?
Yes. Chakri is a vanilla JavaScript game, written as plain HTML, CSS and 20 modules that browsers load natively, with no build step and no dependencies. A 38-line tagged template escapes players’ names, and one click listener routes every button.
How do you share an image with the Web Share API?
Wrap the image in a File, check navigator.canShare({ files: [file] }), then call navigator.share({ files: [file], text }) from a tap or click, because share() needs user activation. Where files can’t be shared, share the text and offer a download.
What size should a WhatsApp link preview image be?
Meta’s developer page on WhatsApp link previews asks for an og:image under 600 KB and at least 300 px wide. Developers widely report previews failing above about 300 KB, a figure WhatsApp doesn’t document, so we keep ours at 136 KB.
Why is my Web Audio AudioContext suspended?
Autoplay rules keep an AudioContext suspended until the user has interacted with the page. Create the context, or call resume() on it, inside a click or tap handler.
What does Cache-Control: no-cache do?
It lets the browser keep a copy but makes it check with the server before each reuse. An unchanged file comes back as a 304 with no body, and a changed one is downloaded again.
How do you synthesise a bell sound with Web Audio?
Mix a few sine oscillators at inharmonic multiples of the fundamental, such as 1, 2.76, 5.4 and 8.93, each with a very short attack and an exponential decay that is shorter for the higher partials.
Building something like this?
9io is a small team of senior engineers with a fractional CTO. We work by the hour, and plans start at $5,000. Send us a note about your product. The reply comes from the person who'd do the work.