<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/">
  <channel>
    <title>DEV Community: Chris Xhangrei</title>
    <description>The latest articles on DEV Community by Chris Xhangrei (@chris_xhangrei_72df64ab94).</description>
    <link>https://dev.to/chris_xhangrei_72df64ab94</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F3988998%2F5f538e06-d1b4-4a12-a3dc-e186b730f820.png</url>
      <title>DEV Community: Chris Xhangrei</title>
      <link>https://dev.to/chris_xhangrei_72df64ab94</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/chris_xhangrei_72df64ab94"/>
    <language>en</language>
    <item>
      <title>How to add game feel to a static site without hiding a word from Google</title>
      <dc:creator>Chris Xhangrei</dc:creator>
      <pubDate>Tue, 15 Sep 2026 13:38:27 +0000</pubDate>
      <link>https://dev.to/chris_xhangrei_72df64ab94/how-to-add-game-feel-to-a-static-site-without-hiding-a-word-from-google-1pd3</link>
      <guid>https://dev.to/chris_xhangrei_72df64ab94/how-to-add-game-feel-to-a-static-site-without-hiding-a-word-from-google-1pd3</guid>
      <description>&lt;p&gt;You tap an answer tile and nothing pushes back. The next question slides in like a spreadsheet cell. The result card appears fully formed with no weight, no flash, no sense that anything happened. Or you pour 3 weeks into press feedback and idle drift, then discover the text that mattered never existed in the HTML the crawler received. Most builders treat those two outcomes as a forced choice. You were handed the wrong method.&lt;/p&gt;

&lt;h2&gt;
  
  
  Your ranking fails because the crawler never met the part you built for humans
&lt;/h2&gt;

&lt;p&gt;Most people assume ranking problems on a new domain are content problems. But the real failure is quieter. You spent the time on the part that feels alive: the weighted scoring and the 12-question flow, plus the card that appears face-down showing the winning palette as diagonal bands before it flips. Those pieces live in the script. When JavaScript is the only place the result descriptions exist, a crawler with scripts switched off receives an empty shell. The article text, the FAQ, every one of the 44 result descriptions never reached the HTML that ships. The page still "works" for a human with a modern browser. It does not exist for the bot.&lt;/p&gt;

&lt;p&gt;That gap is easy to miss because the live site looks finished. You open the page, answer questions, see the flip, hear the tone if the toggle is on. Everything appears to work. The missing piece only shows up when you view-source or disable JavaScript and notice the result text is gone.&lt;/p&gt;

&lt;p&gt;The tension is real. A five-quiz entertainment site needs to feel like a small game. Tactile answer tiles and an animated result reveal. At the same time every word of the article and FAQ content must remain readable when JavaScript is disabled. Those goals fight each other if you build the wrong way. Most stacks resolve the fight by moving the content into the runtime layer and hoping the crawler will execute the script. Hope is not a strategy. The moment the content moves into a script that only runs after the page loads, the crawler is already looking at a different document from the one the user sees.&lt;/p&gt;

&lt;p&gt;I found the first silent failure by reading my own build script, not by testing. The archetype system expected a &lt;code&gt;traits&lt;/code&gt; field at runtime. The whitelist in the build script had already filtered that field out of the data it injected into the page. Every lookup returned an empty array. Nothing threw. Nothing logged. Every single archetype fell back to the default without a single console warning. The content layer looked complete. The game layer was blind.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The crawler only indexes what you put in the HTML at build time.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;If the part you care about most never leaves the script, the ranking problem is not mysterious. It is the predictable result of treating the content as an afterthought to the interaction.&lt;/p&gt;

&lt;h2&gt;
  
  
  Your animation drains phones because you reached for requestAnimationFrame first
&lt;/h2&gt;

&lt;p&gt;Most people reach for requestAnimationFrame the moment they want continuous motion. But continuous motion on a quiz page is almost never what the user asked for. Idle drift that runs forever is a tax. Every frame is battery and heat that gets a background tab killed earlier than it should.&lt;/p&gt;

&lt;p&gt;The idle motion on the tiles is pure CSS keyframes. Amplitude stays under 3px and 0.5 degrees of rotation. Each element receives its own duration and a negative animation-delay so the cycles start mid-way and stay out of phase. Randomness reads as chaos. Phase offset reads as alive. requestAnimationFrame is permitted only between pointerdown and pointerup, and it is cancelled the instant the finger lifts. Outside that window the main thread is free.&lt;/p&gt;

&lt;p&gt;The press sequence itself is a short, hard-coded chain of transforms and opacity changes. 0ms: scale to 0.96 on pointerdown with a 70ms ease-out. 70ms: scale to 1.04 on pointerup, then settle. 250ms: sibling tiles fade and drop 6px. 350ms: the selected tile expands while the question card begins its exit. 500ms: the next question enters from 12px below. 760ms: settled. The easing curve is cubic-bezier(0.2, 1.4, 0.3, 1). The 1.4 control point pushes past the endpoint and comes back. That overshoot is the difference between an element that moves and one that has weight.&lt;/p&gt;

&lt;p&gt;Adding four rotating question layouts cost 58 bytes. One class assignment. The rest was CSS. The entire quiz engine, weighted scoring, 12-question flow, card-flip reveal, procedural audio, localStorage progression, sits at 23.7 KB minified against a self-imposed 25KB budget. One quiz page totals 333KB, of which roughly 149KB is the Google Tag Manager script. The interaction layer is not the weight problem.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;If the animation runs when nothing is happening, it is already too expensive.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;The second silent failure lived in the theme selectors. Per-quiz styles scoped by a data attribute carried higher specificity than the &lt;code&gt;.is-selected&lt;/code&gt; class on the answer tiles. Left alone, the resting style would have overridden the selected state. Tapping a tile would have produced no visual feedback at all. The fix was a single &lt;code&gt;:not(.is-selected)&lt;/code&gt; gate. Specificity bugs do not announce themselves. They simply make the interaction feel broken while every automated test still passes.&lt;/p&gt;

&lt;h2&gt;
  
  
  Your interaction feels cheap because you deleted the delay instead of tuning it
&lt;/h2&gt;

&lt;p&gt;Most people treat latency as the enemy. But zero delay is what makes a digital control feel like a spreadsheet. The body expects a small, consistent interval between the moment of contact and the consequence. Delete that interval and the tile feels like a checkbox. Tune it and the same tile feels like a physical object.&lt;/p&gt;

&lt;p&gt;The card-flip result reveal runs about 1.9s end to end. The card appears face-down, showing the winning result's color palette as diagonal bands. It wobbles, then flips. A haptic pulse and a white flash fire at the midpoint of the flip. The sequence is long enough to register as an event and short enough that the user never feels held hostage.&lt;/p&gt;

&lt;p&gt;Sound is procedural Web Audio oscillators. No audio files. Default state is off with a visible toggle, because the audience browses on phones in public places and unexpected audio closes tabs. The AudioContext is created inside a user-gesture handler. Create it any earlier and it is born suspended and stays silent forever.&lt;/p&gt;

&lt;p&gt;Answer tiles are real button elements, never divs. Minimum height 52px. prefers-reduced-motion collapses the entire timed sequence so the quiz remains fully completable. navigator.vibrate(8) on selection is feature-checked and wrapped so it never throws on unsupported devices.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The delay is not lag. The delay is the signal that something happened.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;The third silent failure was in the build script itself. It HTML-escaped the article body text. Hand-authored anchor tags became literal visible text on the page. The content was still present for any crawler that looked. The links simply stopped being links. The page would have passed a naive "is my content crawlable" check. It would have failed any human who tried to follow a reference.&lt;/p&gt;

&lt;h2&gt;
  
  
  Two layers, one page: what the crawler reads and what the thumb touches
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;┌─────────────────────────────────────┐
│  CONTENT LAYER (build time)         │
│  headings · article · 44 results    │
│  FAQ · every word the crawler needs │
│  written into HTML, never moved     │
└─────────────────────────────────────┘
                  │
                  ▼
┌─────────────────────────────────────┐
│  GAME LAYER (runtime)               │
│  drift · press · flip · sound       │
│  progression · enhancement only     │
│  nothing depends on it              │
└─────────────────────────────────────┘
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The content layer is written into the HTML by the build script at build time and never moves into JavaScript. The game layer is assembled at runtime. Nothing in the content depends on the game layer. JavaScript is enhancement, never a dependency. A crawler with scripts disabled still receives every heading and every result description, plus every FAQ answer. A human with a modern browser receives the full tactile sequence.&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Game feel&lt;/em&gt; is the tuned delay between a thumb contacting a tile and the interface answering with weight and overshoot. It is not the animation. It is the calibrated interval that turns a digital control into something the body recognizes as real.&lt;/p&gt;

&lt;p&gt;The part you spent 3 weeks on can live entirely in the game layer. The crawler still meets every word because those words were never placed in the script in the first place. The architecture removes the forced choice. The content stays where the crawler can reach it. The interaction stays where the thumb can feel it. Neither layer has to compromise the other.&lt;/p&gt;

&lt;h2&gt;
  
  
  How to ship game feel in one afternoon without touching your content layer
&lt;/h2&gt;

&lt;ol&gt;
&lt;li&gt;Keep every heading, article paragraph, result description, and FAQ answer inside the HTML that the build script emits. Do not move a single string into a JavaScript object that only exists after the page loads.&lt;/li&gt;
&lt;li&gt;Build the interaction layer so that every visual and audio response is additive. If the script fails to load, the quiz is still readable and completable.&lt;/li&gt;
&lt;li&gt;Implement the press feedback as a fixed timing chain rather than a continuous loop. Pointerdown scales to 0.96. Pointerup overshoots to 1.04 then settles. Sibling tiles fade. The next question enters from below. Cancel any requestAnimationFrame the instant the pointer lifts.&lt;/li&gt;
&lt;li&gt;Drive idle motion with CSS keyframes only. Give each drifting element a unique duration and a negative delay so the cycles stay out of phase.&lt;/li&gt;
&lt;li&gt;Create the AudioContext inside the first user-gesture handler. Default the sound toggle to off.&lt;/li&gt;
&lt;li&gt;Use real button elements for answer tiles. Gate any theme styles that would override the selected state with &lt;code&gt;:not(.is-selected)&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;Test the full sequence with JavaScript disabled. Confirm every result description and FAQ answer is still present in the DOM.&lt;/li&gt;
&lt;li&gt;Open &lt;a href="https://vibequiz.online/aesthetic-quiz/?r=dark_academia" rel="noopener noreferrer"&gt;https://vibequiz.online/aesthetic-quiz/?r=dark_academia&lt;/a&gt; on a phone, because the timings were tuned for a thumb.
&lt;/li&gt;
&lt;/ol&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight css"&gt;&lt;code&gt;&lt;span class="k"&gt;@keyframes&lt;/span&gt; &lt;span class="n"&gt;drift&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="err"&gt;0&lt;/span&gt;&lt;span class="o"&gt;%,&lt;/span&gt; &lt;span class="err"&gt;100&lt;/span&gt;&lt;span class="o"&gt;%&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nl"&gt;transform&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;translateY&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="m"&gt;0&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="n"&gt;rotate&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="m"&gt;0deg&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
  &lt;span class="err"&gt;50&lt;/span&gt;&lt;span class="o"&gt;%&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nl"&gt;transform&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;translateY&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="m"&gt;-3px&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="n"&gt;rotate&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="m"&gt;0.4deg&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="nc"&gt;.tile&lt;/span&gt;&lt;span class="nd"&gt;:nth-child&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="err"&gt;1&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nl"&gt;animation&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;drift&lt;/span&gt; &lt;span class="m"&gt;4.2s&lt;/span&gt; &lt;span class="n"&gt;ease-in-out&lt;/span&gt; &lt;span class="n"&gt;infinite&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nl"&gt;animation-delay&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="m"&gt;-1.1s&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="nc"&gt;.tile&lt;/span&gt;&lt;span class="nd"&gt;:nth-child&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="err"&gt;2&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nl"&gt;animation&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;drift&lt;/span&gt; &lt;span class="m"&gt;5.1s&lt;/span&gt; &lt;span class="n"&gt;ease-in-out&lt;/span&gt; &lt;span class="n"&gt;infinite&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nl"&gt;animation-delay&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="m"&gt;-2.7s&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="nc"&gt;.tile&lt;/span&gt;&lt;span class="nd"&gt;:nth-child&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="err"&gt;3&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nl"&gt;animation&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;drift&lt;/span&gt; &lt;span class="m"&gt;3.8s&lt;/span&gt; &lt;span class="n"&gt;ease-in-out&lt;/span&gt; &lt;span class="n"&gt;infinite&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nl"&gt;animation-delay&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="m"&gt;-0.4s&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight css"&gt;&lt;code&gt;&lt;span class="nc"&gt;.tile&lt;/span&gt;&lt;span class="nd"&gt;:active&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nl"&gt;transform&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;scale&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="m"&gt;0.96&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="nl"&gt;transition&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;transform&lt;/span&gt; &lt;span class="m"&gt;70ms&lt;/span&gt; &lt;span class="n"&gt;ease-out&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="nc"&gt;.tile&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nl"&gt;transition&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;transform&lt;/span&gt; &lt;span class="m"&gt;180ms&lt;/span&gt; &lt;span class="n"&gt;cubic-bezier&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="m"&gt;0.2&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="m"&gt;1.4&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="m"&gt;0.3&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="m"&gt;1&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// 0ms   pointerdown → scale(0.96)&lt;/span&gt;
&lt;span class="c1"&gt;// 70ms  pointerup   → scale(1.04) → 1&lt;/span&gt;
&lt;span class="c1"&gt;// 250ms siblings fade + translateY(6px)&lt;/span&gt;
&lt;span class="c1"&gt;// 350ms selected expands, card exits&lt;/span&gt;
&lt;span class="c1"&gt;// 500ms next question enters from translateY(12px)&lt;/span&gt;
&lt;span class="c1"&gt;// 760ms settled&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Treat the page like a stage, not a machine
&lt;/h2&gt;

&lt;p&gt;A machine tries to eliminate every delay. A stage uses delay as the moment the audience leans forward. The content layer is the set. It is always present, fully lit, readable by anyone who walks into the room with the lights off. The game layer is the performance. It only begins when a human steps onto the stage and presses a tile.&lt;/p&gt;

&lt;p&gt;The same architecture that keeps the crawler honest also keeps the phone cool. Idle motion costs almost nothing because it never touches the main thread. The press sequence costs a few hundred milliseconds of carefully ordered transforms and then stops. Sound stays silent until the user asks for it. Accessibility is not an afterthought; the reduced-motion preference simply removes the performance and leaves the set intact.&lt;/p&gt;

&lt;p&gt;Your site does not rank because the crawler never met the part you built for humans. That problem disappears the moment the part built for humans is no longer the only place the content lives. Two layers, one page. The crawler reads the set. The thumb touches the performance. Neither has to wait for the other.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Christopher Shangrei&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Portfolio: &lt;a href="https://xhangreichris-max.github.io" rel="noopener noreferrer"&gt;xhangreichris-max.github.io&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Site from this post: &lt;a href="https://vibequiz.online" rel="noopener noreferrer"&gt;vibequiz.online&lt;/a&gt;&lt;/p&gt;

</description>
      <category>webdev</category>
      <category>javascript</category>
      <category>css</category>
      <category>performance</category>
    </item>
    <item>
      <title>Nobody Told Me Unicode Was This Weird — So I Built a Font Generator</title>
      <dc:creator>Chris Xhangrei</dc:creator>
      <pubDate>Wed, 17 Jun 2026 11:55:14 +0000</pubDate>
      <link>https://dev.to/chris_xhangrei_72df64ab94/nobody-told-me-unicode-was-this-weird-so-i-built-a-font-generator-2e4b</link>
      <guid>https://dev.to/chris_xhangrei_72df64ab94/nobody-told-me-unicode-was-this-weird-so-i-built-a-font-generator-2e4b</guid>
      <description>&lt;p&gt;Someone in a Discord server changed their username to something that looked like a different alphabet, except it wasn't. I copied it into a random site, pasted my own name, and it came back looking like 𝔠𝔥𝔯𝔦𝔰. Sent it to a friend. He asked how I did that. I had no idea.&lt;/p&gt;

&lt;p&gt;That should have been the end of it. I pasted the text, it worked, move on. But the question stayed: Discord doesn't support custom fonts. Nobody downloaded a plugin. The text just showed up different on everyone's screen. How?&lt;/p&gt;

&lt;p&gt;The answer broke my brain a little. These are not fonts. 𝔠 is not the letter c. It looks like c. It has the same phonetic role in that word. But to the computer, it is a completely separate character — its own entry in a database of every symbol that computers are supposed to represent. The font generator doesn't load a different typeface. It finds a character that looks like the letter you typed and swaps it in. Your device's default font renders that character. That's it. The "font" was always your own system.&lt;/p&gt;

&lt;p&gt;I had to sit with that for a minute.&lt;/p&gt;

&lt;p&gt;Unicode is the system that assigns every character a code point — a number that tells the computer what to display. There are over 149,000 characters in the standard. One organization, the Unicode Consortium, maintains the whole thing. They review proposals, vote on additions, publish a new version roughly every year. Emoji go through this same process. The face-with-tears-of-joy emoji has an official code point, a proposal document, and a ratification date. So does the cuneiform sign for ox.&lt;/p&gt;

&lt;p&gt;Where the "font styles" actually come from is the weird part. That bold text people use on Instagram and LinkedIn — 𝐁𝐨𝐥𝐝 𝐥𝐢𝐤𝐞 𝐭𝐡𝐢𝐬 — those characters live in a block called Mathematical Alphanumeric Symbols. They were added so that math papers could distinguish between variable types in digital documents. Researchers needed a way to write a vector 𝒗 differently from a scalar v in the same document, without relying on a renderer to apply formatting. So they got their own code points. The internet found those code points and decided they looked cool for bios.&lt;/p&gt;

&lt;p&gt;The rune situation is even better. Elder Futhark runes are in Unicode. The proposal came from historical linguists doing serious work on early Germanic inscriptions. The Consortium approved them. They are now used to write PUBG usernames. Nobody in those proposal meetings saw that coming.&lt;/p&gt;

&lt;p&gt;There's a whole separate problem with characters that don't show up. Unicode has 149,000+ entries, but your device's font only has so many of them mapped. If a character exists in the standard but your font doesn't have a glyph for it, you get a box. Or nothing. What renders on iOS is not guaranteed to render on Android, and neither is guaranteed to work in a specific game client. Discord has broader coverage than WhatsApp. A PUBG client has whatever the developer bundled. No published list exists of what works where. Players figure it out by pasting things in and checking, which is honestly the only way to know.&lt;/p&gt;

&lt;p&gt;I started building the tool because I couldn't find one that didn't look like it was made in 2008. Static site, no backend, all the conversion runs in the browser. 168 styles total. I thought mapping the character ranges would take an afternoon. It did not. Some ranges have gaps — code points that exist but don't have a clean visual equivalent, so a character drops out mid-word, which looks broken. Other ranges work fine in isolation but behave strangely when mixed. I spent more time on edge cases than on anything else in the build.&lt;/p&gt;

&lt;p&gt;If you want to see what 168 Unicode styles looks like in one place, it's at &lt;a href="https://aifontsgenerator.com" rel="noopener noreferrer"&gt;aifontsgenerator.com&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;The thing I keep circling back to is the Consortium itself. These are people who built a standard for encoding the world's languages — historical scripts, mathematical notation, scientific symbols. Serious work, dense documentation. And somewhere in there, the internet turned their character database into an aesthetic toolkit. Zalgo text — that corrupted glitchy horror thing from creepypasta — uses combining characters that were added for languages where diacritical marks stack above and below letters. A legitimate linguistic feature, used for stacking fifty marks on a single e until it looks haunted.&lt;/p&gt;

&lt;p&gt;That's a real sentence. I'm still not sure what to do with it.&lt;/p&gt;

</description>
      <category>webdev</category>
      <category>javascript</category>
      <category>beginners</category>
      <category>ai</category>
    </item>
  </channel>
</rss>
