2bpp Font
A font that renders Game Boy sprites from hex.
September 1, 2026
Every font is a mapping. On one side are characters: the letters, digits and symbols you type and store. On the other are glyphs: the shapes the font actually draws. Most of the time the two line up one to one, a single glyph for the letter A, but nothing requires that. A font can substitute one glyph for a run of several characters, choose between glyphs based on what came before, and move a glyph based on the glyphs around it.
I devised a font that uses all three. Its characters are hexadecimal digits (0 through 9 and A through F) and its glyphs are colored Game Boy pixels, so this mapping runs from bytes to pixels.
Type into , set it in my font, and the text stops behaving like text. Change the hexadecimal digits (“hex”) and the sprite follows.
There’s no canvas here and no image decoder. The only script behind this demo copies what you type into the element underneath it; the sprite itself is what the text shaper produces. Getting there takes two ideas: the Game Boy’s tile format is compact enough to write down as hex, and OpenType is expressive enough to decode it.
Two bits per pixel
The original Game Boy’s screen showed four shades, so one pixel needs two bits: 00, 01, 10 or 11. That’s where my font’s name, 2bpp, comes from: two bits per pixel.
Graphics are built from 8×8 tiles, and that fixes how big a tile is:
8 × 8 = 64 pixels
64 × 2 bits = 128 bits
128 bits = 16 bytes
Those 16 bytes are stored as eight pairs, one pair per row of the tile. So a row of eight pixels is exactly two bytes, and decoding a tile means decoding eight pairs. The rest of this section is about a single pair.
The sprite data at the top of this page comes from a real tile. It starts at memory address 0x3A97E in the Pokémon Blue game data, where it draws the Poké Ball that the game shows during battle sequences for each Pokémon in your party.
Reading two bytes at once
You might expect the first byte to hold the first four pixels and the second byte the next four. It doesn’t work that way: each byte holds one bit for every pixel in the row.
Take the row of the Poké Ball. Its two bytes are
51 and 6F,
and hex is just a compact way of writing binary: in base 2 those are
01010001 and
01101111.
Arrange the two bytes in columns and read down. Each column is one pixel, and its color is a two bit number: the first bit comes from the second byte, the second bit from the first byte. Those two are the high bit and the low bit, named for what each one is worth, and together they make a number from 0 to 3 that picks the shade:
| High bit (2nd byte) | Low bit (1st byte) | Value | Shade |
|---|---|---|---|
| 0 | 0 | 0 | White |
| 0 | 1 | 1 | Silver |
| 1 | 0 | 2 | Gray |
| 1 | 1 | 3 | Black |
Reading down the columns of each base 2 number:
51 = 0 1 0 1 0 0 0 1 (2nd bit)
6F = 0 1 1 0 1 1 1 1 (1st bit)
↓ ↓ ↓ ↓ ↓ ↓ ↓ ↓
value = 0 3 2 1 2 2 2 3
Click any row of the tile to decode that one instead. Repeat for all eight pairs and you’ve decoded the whole tile.
So far this is ordinary Game Boy tile decoding. The fun part is making a font do it.
A ligature is just pattern matching
In everyday typography, ligatures like fi and fl work by replacing a pair of typed characters with a single combined glyph. Nothing about f and i, or f and l, is special; the font is pattern-matching on a sequence of characters and substituting something else in its place. Any sequence will do, including a run of hex digits.
Separate glyphs and ligatures for f-i and f-l
Four hex digits, two glyphs
A row is two bytes, which is four hex digits, so in principle one rule could map those four characters straight to a glyph of the finished row. The problem is scale: that needs a glyph for every possible row, and there are 65,536 of them. You could make a version of the font with 65,536 glyphs, but that would bloat the size of the font and make it unwieldy in practice.
Instead, an earlier version of the font went the other way. I used four glyphs, one per shade, each a single colored pixel, and the font assembled 64 of them per 8×8 tile. The glyph count (4) was trivial, but the rendering wasn’t. Browsers re-applied every substitution and positioning rule on each frame, and the shaping cost added up fast.
What works better is to take advantage of the parallel-bit structure. Remember
that the digits 5 1 6 F arrive in byte order, but we need to match 5
and 6 together, and 1 and F together, since each pair maps to the same
four pixels. If we accidentally matched 5 with 1 and 6 with F, the
sprite would be visually incorrect.
The font performs the regrouping with OpenType contextual substitution: 5
and 6 become a named pixel56 glyph; 1 and F become a named pixel1F
glyph. Four input characters collapse into two half-row glyphs of four pixels
each. A half row is two hex digits, so the pixel-glyph set covers 256
combinations rather than 65,536.
The 6 and F that gave up their bits don’t vanish. They stay in the stream
as blank glyphs with no width, and the font finds a use for one of them in a
moment.
Positioning turns glyphs into a grid
Substitution has produced the right pieces, but they’re arranged horizontally in one long line, and that’s the problem. Text runs left to right; a tile is a grid. Sixteen half-row glyphs set normally would stretch sixteen glyphs wide instead of stacking into eight rows of two.
Positioning rules are the way out. Alongside substitution, OpenType lets a font shift where a glyph is drawn and change how far the pen advances after it, and it can pick the shift by looking at the glyphs around it. That’s enough to move every piece into its cell, provided each piece can tell which row it belongs to.
That’s what the leftover digits are for. Each row still has two blank glyphs trailing its pair of pixel glyphs, and the font swaps the first of them for one of eight invisible row markers, numbered 0 through 7. A row’s marker is chosen by looking back at the marker of the row before it: the row after marker 3 gets marker 4, and the row after marker 7, or after nothing at all, gets marker 0. The markers are never drawn. They exist so that every pixel glyph has its row number sitting one or two glyphs to its right.
With the row known, each piece has a fixed home. Every pixel is a 128-unit square on a 1024-unit grid, so eight pixels span exactly one full grid: the left half of a row starts at 0, the right half at 512, and each row sits 128 units below the last. The first piece of a tile is left alone. It keeps its normal 1024-unit advance, which moves the pen one em to the right, and every other piece is placed relative to that same pen position. Each one looks ahead to its row marker, cancels its own advance, and shifts itself into place:
piece sees ahead of it shift from the pen
1 (left alone) none, advances 1024
2 marker 0 −512 0
3 pixel, marker 1 −1024 −128
4 marker 1 −512 −128
...
15 pixel, marker 7 −1024 −896
16 marker 7 −512 −896
A piece followed directly by a marker is the right half of its row; a piece with another pixel glyph between it and the marker is the left half. No piece depends on where the previous one landed. Each is placed from the pen and its own row number, using at most two glyphs of lookahead, and after sixteen pieces the 8×8 tile is complete.
The markers also end the tile. They count to 7 and wrap around, so the row after the eighth is row 0 again, and its left piece is the one that keeps its advance. That’s what moves the pen on, so a longer string of hex renders as a row of separate tiles instead of one endless column.
An earlier version did this with marks instead, the OpenType mechanism that parks an accent over a letter. Fifteen of the sixteen pieces were marks, each attached to the piece before it, so the tile hung off its first glyph as one long chain. It worked, but every piece’s position depended on the previous one, and the font needed four variants of every pixel glyph just to keep track of its place in the chain. Row markers replace all of that with a lookup that never looks more than two glyphs away.
One trace of that version remains: the positioning rules live in the font’s
mark feature, the one meant for accents. Not because the pieces are marks
(they aren’t any more), but because browsers apply mark unconditionally,
and unlike kerning there’s no CSS property that switches it off.
The colors come from the font as well. The COLR build embeds the four shades
directly in the font file, so a sprite renders correctly with nothing but a
font-family.
Put end to end, this is the whole pipeline:
- The hex arrives as ordinary text:
00001C1C223E516F417F7F413E221C1C. - Contextual substitution regroups every four digits into two half-row pixel glyphs and turns a leftover digit of each row into a row marker.
- Contextual positioning reads each glyph’s row marker and moves it into its cell of an 8×8 grid.
- The COLR palette (a form of multicolor glyphs) paints them in the four Game Boy shades.
- The tile is rendered on-screen from the original text.
Why bother
I like this project because it makes a font feel less like a set of static letterforms and more like a tiny program. OpenType was never designed to render Game Boy sprites, but its substitution and positioning rules turned out to be just expressive enough to pull it off, which is exactly the kind of unnecessary, delightful nonsense I enjoy.

