Iurii RoguliaIurii Rogulia
AboutServicesPricingProjectsStackReviewsPhrasesBlog
Contact
Iurii ships.

Iurii Rogulia, IT partner for business & fractional CTO. Professionally building software since 2001.

Think of a number
PricingQuality checklistPrivacy PolicyCookie Policy

Business

TMI Iurii Rogulia
VAT ID: FI29845875
DUNS: 368664211
Lappeenranta, Finland 🇫🇮

[email protected]
Back to projects

Iurii Miscalculates: Wrongulator — The Calculator That Is Confidently Incorrect

May 31, 2026

A joke calculator that returns a deterministically wrong answer with a straight-faced reason — built as a share machine. Same expression, same wrong answer, forever, in 17 languages, with link previews that unfurl into the joke.

Live demo

Stack

JavaScriptNode.jsHTMLCSS

Libraries

Express@napi-rs/canvasioredis

Databases

Redis

Topics

Viral ProductProgrammatic SEOi18nOpen GraphDocker

Key Results

  • Built as a share machine: same wrong answer for everyone, so it becomes a shareable inside joke
  • Links unfurl into the joke on social — the wrong answer and share image render before any JS runs
  • Ranks in search across 17 languages via programmatic SEO on the exact sums people type
  • Runs cheap and scales for free — the funny part is a pure client-side function, cached forever
  • Ships as one Docker container with zero required infrastructure
Wrongulator — The Calculator That Is Confidently Incorrect

The Problem

Most web toys die in ten seconds because they are random. A calculator that says 2+2=7 for you and 2+2=5 for your friend gives nobody anything to talk about. The whole product is one design decision: the wrongness has to be reproducible. Only then does "Wrongulator says 2+2 is 5" become a shared fact people can link to, screenshot, and argue about — which is the entire growth loop.

That single rule cascades into hard engineering constraints:

  1. The answer must be a pure function of the input — no database read, no per-user state, so a permalink like /2+2 reproduces the exact result anywhere, instantly, for free.
  2. Links must unfurl into the joke — social crawlers don't run JavaScript, so the wrong answer, the reason, and a 1080×1080 share image all have to exist in the server's HTML response before any JS executes.
  3. The share image and the in-app card must be pixel-identical — if the card a user sees differs from the card that unfurls on Twitter, the joke breaks. That means one drawing routine running in two completely different environments.
  4. It has to be funny in any language — but a translated meme isn't funny. The localization layer has to know which text carries the joke (translate it) and which text is the joke (leave it alone).

The Solution

Wrongulator is built as a share machine. A vanilla HTML/CSS/JS frontend sits over a deliberately thin Express server that exists for only three things that genuinely need a server — the share image, per-link social preview data, and the global share counter. Everything that can be a pure client-side function is one, which keeps the whole toy cheap to run and effortless to scale.

The core is a "Wrong Engine" that maps any expression to one reproducible wrong answer plus a deadpan reason. Because it's deterministic, 2+2 returns the same wrong answer for everyone, forever — which is exactly what turns it into a shareable inside joke rather than a forgettable random gag. And because that engine runs entirely in the browser, it's free, works offline, and scales without limit.

For sharing to actually work, a link has to unfurl into the joke. Social crawlers don't run JavaScript, so the server bakes the real wrong answer, its reason, and a matching 1080×1080 share image into the page before any JavaScript executes. The share image is drawn by a single routine that runs identically in the browser and on the server, so the card a user sees is pixel-for-pixel the card that unfurls on social — and because the output is deterministic, it's cached forever without re-rendering.

The same server-side rendering gives it real SEO reach: a curated set of the arithmetic people most often search (2+2, 9+10, 7*8, …) becomes an indexable, programmatic-SEO surface with localized metadata across all 17 languages. The localization is careful about humor — it translates the prose that merely carries the joke, but leaves universal number-memes untranslated because they're funnier as-is, and even adds locale-only memes that fire in a single language. All 17 languages, including RTL Arabic, got a native-speaker review pass.

Finally, a "Hall of Fame" leaderboard tracks the most-shared wrong answers. The client only ever sends the expression, never the answer — the server recomputes the result itself before counting, so the leaderboard can't be gamed. It ships as one Docker container and runs end-to-end with zero infrastructure (an in-memory store stands in when Redis isn't configured).

Results

MetricValue
Codebase~4,350 lines (vanilla JS/HTML, no frontend framework)
Wrong EnginePure function, FNV-1a hash + mulberry32 PRNG, fully client-side
Meme catalogue18 entries with magnet-snap radius + 1-in-a-million "accidentally right"
Languages17, including RTL Arabic, with native meme-slang review
Share cardOne Canvas routine, rendered identically in browser and on the server
OG image cacheImmutable, 1-year — deterministic output never re-renders
SEOServer-side meta + hreflang × 17 + JSON-LD FAQ per expression
Hall of FameRedis ZSET leaderboard, server-recomputed (spoof-proof), in-memory fallback
Runtime deps3 (Express, @napi-rs/canvas, ioredis)
DeploySingle Docker container on Coolify, /healthz healthcheck

Wrongulator is a study in disciplined scope: the funny part is one pure function, and everything around it — sharing, SEO, localization, the leaderboard — is distribution engineered to put that function in front of as many people as possible, as cheaply as possible.

Under the Hood

For the technically curious, here is how the core pieces are built.

Deterministic Wrongness — a Seeded PRNG, Not Math.random()

The engine never calls Math.random(). It hashes the normalized expression with FNV-1a, seeds a mulberry32 PRNG, and draws every decision — which wrongness mode fires, which meme it snaps to, which justification line it picks — from that one deterministic stream. Same input, same wrong answer, same reason, forever, on every device.

// public/engine.js — deterministic seeding
function hashStr(s) {
  let h = 2166136261 >>> 0; // FNV-1a
  for (let i = 0; i < s.length; i++) {
    h ^= s.charCodeAt(i);
    h = Math.imul(h, 16777619);
  }
  return h >>> 0;
}
function mulberry32(a) {
  return function () {
    a |= 0;
    a = (a + 0x6d2b79f5) | 0;
    let t = Math.imul(a ^ (a >>> 15), 1 | a);
    t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t;
    return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
  };
}

The wrong answer isn't arbitrary — it's legibly wrong. The engine has 18 meme entries with a magnet radius: when the real result lands near a meme number, it snaps to it so the joke feels earned (64+5 → 67, "the only correct number"). It also hides a one-in-a-million "accidentally right" easter egg — a rare collectible card that returns the correct answer with a MALFUNCTION reason.

// MAGNET — if the real answer is near a meme, snap to it (feels earned)
let best = null,
  bestD = Infinity;
for (const m of MEMES) {
  if (m.n == null || !m.magnet || m.n === realRounded || !inLocale(m)) continue;
  const d = Math.abs(realRounded - m.n);
  if (d > 0 && d <= m.magnet && d < bestD) {
    best = m;
    bestD = d;
  }
}

One Canvas Routine, Two Runtimes

The share card is the unit of virality, so the picture a user shares must be byte-for-byte the picture a crawler unfurls. Rather than maintain two renderers, card-core.js is written against the bare Canvas 2D API and exported as a UMD module — the browser drives it with a real <canvas>, and the server drives the same function with @napi-rs/canvas.

// public/card-core.js — environment-agnostic UMD; same draw() in both runtimes
(function (root, factory) {
  if (typeof module === "object" && module.exports) module.exports = factory();
  else root.WrongCardCore = factory();
})(typeof self !== "undefined" ? self : this, function () {
  // ... drawCard(ctx, { expr, answer, reason, headline, emoji })
});
// server.js — the OG endpoint feeds the identical core a node canvas
app.get('/api/og', async (req, res) => {
  const canvas = createCanvas(core.W, core.H);
  const r = wrongulate(expr, tone, lang);
  core.drawCard(canvas.getContext('2d'), { expr, answer: r.answer, reason: r.reason, ... });
  res.setHeader('Cache-Control', 'public, immutable, max-age=31536000'); // output is deterministic
  res.end(canvas.toBuffer('image/png'));
});

Because the output is a pure function of the query, the image is cached immutable for a year — the CDN serves the same wrong card forever without ever re-rendering.

SEO a Crawler Can Read Before JS Runs

A link to /9+10 is worthless if the unfurl shows a generic splash. So the server computes everything a crawler needs — localized <title>, description, canonical URL, hreflang alternates for every language, OpenGraph/Twitter cards, and JSON-LD — and injects the actual wrong answer into the HTML body. The page has real, indexable content the moment it arrives.

// server.js — JSON-LD FAQ with the real (wrong) answer, per expression
const jsonld = {
  "@context": "https://schema.org",
  "@type": "FAQPage",
  mainEntity: [
    {
      "@type": "Question",
      name: `What is ${disp}?`,
      acceptedAnswer: {
        "@type": "Answer",
        text: `Wrongulator says ${disp} = ${r.answer}. ${r.reason}`,
      },
    },
  ],
};

A curated sitemap.xml seeds the highest-search expressions (2+2, 9+10, 7*8, …) as a programmatic-SEO surface, each with hreflang alternates across all 17 languages — so the toy ranks for the exact arithmetic people actually type into search.

Localizing a Joke Without Killing It

Naïvely translating everything would flatten the humor. The i18n layer splits the copy: it translates the prose that merely carries the joke (the deadpan reasons, the UI), but leaves the universal memes untranslated (skibidi, Ohio, 6-7, 300, 9+10=21) because they travel as-is and are funnier left alone. It even adds locale-gated memes that only fire in their own language — Thai 555 (= "hahaha"), Japanese 39 (= "thank you"), Finnish 666 (perkele) — each written natively, never translated.

// public/i18n.js — split by what the text DOES
// translate: deadpan/ai reasons, void/malfunction lines, UI prompts
// leave alone: universal number-memes; brand labels ("100% WRONG", "HALL OF FAME")
const MEMES = {
  meme555: ['ห้า ห้า ห้า. ตลกจนเครื่องคิดเลขขำ.', ...], // th — only ever shown for th
  meme39:  ['サンキュー。3-9。電卓からの感謝です。', ...],     // ja
  meme666: ['PERKELE. Tämä on ainoa oikea vastaus.', ...],  // fi
};

All 17 languages — including RTL Arabic — got a native-speaker, meme-forward review pass. The OG renderer carries a per-glyph font fallback chain (Russo One for Latin/Cyrillic, Noto faces for Thai/Arabic/Japanese/Korean, Noto Color Emoji) so a mixed string like "サンキュー 🙏" renders fully on the server-drawn card.

A Hall of Fame the Client Can't Lie To

The global "most-shared wrong answers" leaderboard lives in a Redis sorted set, with a companion hash holding each entry's { expr, answer, reason }. The trick: the client never sends the answer — it sends only the expression, and the server runs the engine itself before incrementing. You can't inflate a fake wrong answer into the leaderboard.

// server.js — server recomputes the answer, so counts can't be spoofed
async share(expr) {
  const k = norm(expr);
  await redis.hset(H, k, JSON.stringify(metaFor(expr))); // metaFor() re-runs the engine
  return redis.zincrby(Z, 1, k);
}

If REDIS_URL is unset, an in-memory implementation with the identical interface takes over — so the app runs end-to-end with npm run dev and zero infrastructure.

Iurii RoguliaAvailable

Need something similar?

I build custom solutions — from APIs to full products. Let's talk about your project.

View all projects

Related projects

YouTube Home Blocker — Manifest V3 Chrome Extension
YouTube Home Blocker — Manifest V3 Chrome Extension
June 5, 2026
YouTube Home Blocker — Manifest V3 Chrome Extension

A Manifest V3 Chrome extension that redirects the YouTube homepage to any URL you choose — so you skip the recommendations feed without losing search, videos,

Stack

JavaScriptHTMLCSS

Topics

Chrome ExtensionManifest V3Browser APIProductivity
vatnode — EU VAT Validation API
vatnode — EU VAT Validation API
January 19, 2026
vatnode — EU VAT Validation API

Developer-first SaaS API for EU VAT validation via VIES with Redis caching, change monitoring, and webhook notifications.

Stack

Next.jsReactTypeScriptHonoNode.jsTurborepoDockerCaddy

Libraries

Drizzle ORMBullMQZodBetter AuthTanStack QueryZustand

Databases

PostgreSQLRedis

Services

StripeResendGoogle Analytics

Topics

SaaSAPIAuthFintechTax/VATMonorepoWebhooksSEOSchema.org

Related posts

Programmatic SEO with hreflang: One Joke, 17 Languages, Server-Rendered
July 29, 2026· 7 min
Programmatic SEO with hreflang: One Joke, 17 Languages, Server-Rendered

Programmatic SEO with hreflang: how each wrong answer in a viral toy is a server-rendered page with localized meta and JSON-LD, across 17 languages.

Stack

JavaScriptNode.js

Libraries

Express

Topics

Programmatic SEOi18nOpen GraphViral Product
Isomorphic Canvas Rendering: One draw() in Browser and Node
July 10, 2026· 7 min
Isomorphic Canvas Rendering: One draw() in Browser and Node

Isomorphic canvas rendering: one drawCard() runs in the browser and on the server via @napi-rs/canvas, so the share image and in-app card never drift.

Stack

JavaScriptNode.js

Libraries

@napi-rs/canvasExpress

Topics

Open GraphViral ProductWeb Development