Dev.to WebDev ๐Ÿ›  Dev ๐Ÿ‘ 0 ๐Ÿ“– 6 min read

I built three football party games, each one HTML file with no build step

Every football night with friends follows the same shape. People show up early and argue about the score. The match itself is ninety minutes of half-watching and talking. Then it ends and nobody wants to go home yet. I

Every football night with friends follows the same shape. People show up early and argue about the score. The match itself is ninety minutes of half-watching and talking. Then it ends and nobody wants to go home yet.

I built one small game for each of those moments. Three projects, three weekends, and a rule I set myself: every game has to be a single HTML file you can open by double-clicking it. No npm install, no bundler, no server. If it can't survive being emailed to a friend, it doesn't ship.

Here's what came out of it, and the parts where I got things wrong.

1. Presing โ€” a quiz in the format of a TV show

Play it ยท source

Presing is a football quiz show on Setanta Sports in Ukraine where three contestants compete across several rounds. I rebuilt the format as a web app: one host drives everything from a single screen, reads questions out loud, and taps to award points.

Four rounds:

  • A league table. Four tables on offer, players veto three. Then they name positions in the surviving table โ€” and the points you get equal the position you guessed. Naming the champion is worth 1. Naming whoever finished 8th is worth 8. The obvious answers are the cheap ones.
  • Categories at 10/20/30, with steals on a wrong answer.
  • A hidden theme. Six questions whose answers share a secret connection. The round has a deliberately vague title as the only hint.
  • A final with three riddles, five clues each, decreasing payout.

The interesting problem here was answer matching. A host shouldn't have to adjudicate "did he say it close enough" fifty times a night, but Ukrainian spelling has real traps: apostrophes come in four different Unicode flavours, and half the audience will type Russian spellings of the same club name.

So every answer goes through a normaliser before comparison:

function norm(s){
  return s.toLowerCase()
    .replace(/['โ€™สผ`ยด-]/g,"")   // apostrophes and hyphens, all variants
    .replace(/ั”/g,"ะต")
    .replace(/ั–/g,"ะธ")
    .replace(/ั—/g,"ะธ")          // collapse Ukrainian-only letters
    .replace(/\s+/g," ")
    .trim();
}

On top of that, each entry carries a list of aliases, and anything longer than four characters is compared with a Levenshtein distance of 1. The result: "ะœะฎ", "ะผะฐะฝ ัŽะฝะฐะนั‚ะตะด", "Manchester United" spelled with a typo โ€” all resolve to the same row. I tested this on eighteen input variants before I trusted it.

2. Match bingo โ€” the thing you play during the match

Play it ยท source

A 5ร—5 grid of things that happen in football broadcasts. A penalty. A red card. The commentator bringing up 2006 again. Tap a cell when it happens, complete a line, shout.

Everyone opens the same link on their own phone and hits "new card" โ€” the pool has 44 events, so no two cards match.

This is where the no-build rule got interesting. Artifacts of this kind usually reach for localStorage, but I wanted a card to survive being reopened on a different device. So the entire game state lives in the URL.

The card is generated from a four-character code via a seeded PRNG (mulberry32), so the same code always produces the same card:

function rng(seed){
  return function(){
    seed|=0; seed=seed+0x6D2B79F5|0;
    var t=Math.imul(seed^seed>>>15,1|seed);
    t=t+Math.imul(t^t>>>7,61|t)^t;
    return ((t^t>>>14)>>>0)/4294967296;
  };
}

function seedOf(code){
  var s=0;
  for(var i=0;i<code.length;i++) s=(s*31+code.charCodeAt(i))|0;
  return s||1;
}

And the 25 marked cells are just a bitmask, base36-encoded into the hash:

function writeHash(){
  location.replace("#"+code+"-"+marks.toString(36));
}

function readHash(){
  var m=/^#([A-Z0-9]{4})-([0-9a-z]+)$/.exec(location.hash||"");
  if(!m) return false;
  code=m[1];
  marks=parseInt(m[2],36)|0;
  return true;
}

A full game state is about fifteen characters. Refresh survives it, airplane mode survives it, and you can text the URL to yourself and pick the card back up on a laptop. location.replace instead of assignment keeps the back button from filling up with one entry per tap.

3. Match predictions โ€” the pre-kickoff ritual

Play it ยท source

Before kickoff, the phone goes around the room. Each person enters a scoreline, the first goalscorer, how many yellow cards, whether there'll be a penalty or a red. A "pass the phone" screen sits between each turn so nobody sees anyone else's guesses.

After the final whistle the host enters what actually happened, and the app ranks everyone.

Scoring is the whole app, really:

function pointsFor(pr,R,fsOk){
  var d={score:0,fs:0,yc:0,pen:0,red:0};

  if(pr.ga===R.ga && pr.gb===R.gb) d.score=5;              // exact scoreline
  else if(sign(pr.ga-pr.gb)===sign(R.ga-R.gb)){
    d.score=(pr.ga-pr.gb===R.ga-R.gb) ? 3 : 2;             // goal difference, or just the result
  }

  if(fsOk) d.fs=3;                                          // first goalscorer

  var dy=Math.abs(pr.yc-R.yc);
  d.yc = dy===0 ? 2 : (dy===1 ? 1 : 0);                     // yellow cards, off-by-one still scores

  if(pr.pen===R.pen) d.pen=1;
  if(pr.red===R.red) d.red=1;

  d.total=d.score+d.fs+d.yc+d.pen+d.red;
  return d;
}

Twelve points maximum. The off-by-one credit on cards matters more than it looks โ€” without it that field is almost always a zero and people stop caring about it.

The first goalscorer is the one field a computer can't judge cleanly. Someone writes a surname, someone writes a full name, someone writes "no goals". So the host taps a button and the app pre-fills a verdict for everyone by fuzzy-matching surnames โ€” then any verdict can be flipped by tapping it. Automate the boring 90%, leave the judgement call to a human.

Here too, the whole session lives in the URL, because ninety minutes is a long time to trust a browser tab.

Three things that bit me

iOS inflates your font sizes. My bingo grid looked perfect in a desktop browser at phone width and completely broken on an actual iPhone โ€” text much bigger than specified, the fifth column pushed off screen. Safari applies text autosizing to some layouts. One line fixes it:

html {
  -webkit-text-size-adjust: 100%;
  text-size-adjust: 100%;
}

1fr is not "one fifth". My grid was repeat(5, 1fr), and grid items default to min-width: auto, which means a column refuses to shrink below its longest unbreakable word. One long Ukrainian compound word was blowing out the entire grid:

.grid {
  grid-template-columns: repeat(5, minmax(0, 1fr));
}

.cell {
  min-width: 0;
  overflow-wrap: anywhere;
  font-size: clamp(7.5px, 2.25vw, 11px);
}

GitHub's mobile site doesn't render the About box. I set the GitHub Pages link on each repo, confirmed it showed in the sidebar on desktop, then couldn't find it anywhere on my phone. Scrolled to the footer, checked every menu โ€” it simply isn't in the mobile layout. If you want a visitor on a phone to find your live demo, put the link in the README. That's the only block mobile reliably shows.

Would I do it this way again

Yes, with one caveat. The single-file constraint made these finishable โ€” there was no afternoon lost to tooling, and each project went from idea to something we actually played within a weekend. Keeping state in the URL turned out to be a genuinely good pattern for small tools, not just a workaround.

The caveat: because everything ships to the client, the quiz answers are sitting in the page source. For a host-driven game that's fine โ€” I run it off my own screen and don't hand out the link. But it's the ceiling on this approach. The moment you need secrets, you need a server.

All three are sitting here if you want to fork one and swap in your own questions:

The quiz is an unofficial fan project and isn't affiliated with Setanta Sports or the show.

๐Ÿ“ฐ Read the original article on Dev.to WebDev

Originally published by Dev.to WebDev. Aggregated on AIWithGhost for educational purposes โ€” full credit and traffic to the original publisher.