charset.school

active · since 2026

A site for learning to encode and decode characters by hand: a sandbox that breaks the conversion down as you type, and exercises where you do the work yourself, bit by bit.

stack
  • ▸Elixir 1.20 (OTP 29)
  • ▸Phoenix 1.8, LiveView 1.2
  • ▸SQLite (WAL mode), Ecto
  • ▸Tailwind v4 through Vite
  • ▸Gettext (EN at the root, FR under /fr)
  • ▸Docker multi-stage, Bandit
links

What it does

Two modes. The sandbox first: ten pages, one per encoding and direction (UTF-8, UTF-16, UTF-32, Latin-1, Windows-1252). Type a character or a run of bytes, and the conversion breaks apart as you type: the code point, the useful bits, the bit groups, the bytes in hex, endianness where it matters. Nothing to submit, you just watch.

The exercises do the opposite: six modules (encode and decode in UTF-8, UTF-16, UTF-32) where you do the converting. Each exercise is cut into steps, one small question each: which format, which binary, which bit groups, which bytes, how many useful bits, which offset. The server validates as you go, the hint depends on the kind of mistake you made, and the answer shows up after three tries.

Difficulty climbs on its own: five correct answers in a row move you up a level. UTF-8 runs from 1 to 4 bytes, UTF-16 and UTF-32 from the BMP to the supplementary planes, so to surrogate pairs.

No account. Everything hangs off an anonymous id dropped in a cookie, and the site is bilingual: English at the root, French under /fr.

How it's built

Phoenix and LiveView on a SQLite database, and nothing else: no separate API, no separate frontend. Every answer goes to the server, comes back validated, and the page updates.

The solution never reaches the browser. The exercise is generated server-side, each step keeps its expected answer, and the client only receives what it needs to render the field. Without that, the answer would be readable in the network tab.

A level doesn't replace the previous ones, it mixes them in: at level 4 in UTF-8, generation still draws 10% one-byte characters, 30% two-byte, 30% three-byte and 30% four-byte. That keeps the easy cases in rotation, and above all it stops the "which format" step from becoming an automatic answer.

Why

Everyone has seen an é show up as é without knowing where exactly it went wrong. Understanding that means being able to cut a code point into bits, and that sticks better after doing it by hand a few dozen times than after reading a table.

The site already existed, in Kotlin and Spring Boot for the API and Nuxt for the frontend, around 18,000 lines. I rewrote it in Phoenix with an LLM, working from specs I wrote in detail, reading and correcting as it went: the same method as Jellyfin BetaSeries .

It came out much shorter, because Elixir lands right on the domain: an encoding format is a run of bits at fixed positions, and that is exactly what bitstrings write.

# Two-byte UTF-8: the format is the pattern
<<0b110::3, high::5, 0b10::2, low::6>>

No masks, no hand-rolled shifts, no comment needed to explain what the line does: the code looks like the table in the RFC. Decoding reads the same thing backwards, as pattern matching. The old version is still on a branch of the repo, for comparison.

← back to projects