Sparrow WebGL Devlog 21: Text Formatting
Bold, Italic, and Colored Text with Icons, Auto Line Breaks, and UI Element Resizing
09.02.2025 - 20:34Iconography is a common feature of games and their interfaces. For example, Civilization VI uses gear icons for production and corn cobs for food. Whenever a building, a technology, or the built-in Civilopedia mentions a resource, it also shows its icon, which greatly improves the clarity of the explanations.
Recently, I’ve been working on a game prototype that would benefit from iconography. However, my Sparrow WebGL engine didn’t have an easy way to mix icons and text.
In this blog post, I’ll talk about how I added advanced text formatting to my custom WebGL engine. In the process, I also unified the text rendering and improved auto line breaks and UI element resizing.
Text Parsing
One of my main goals was to avoid adding a new specialized class for formatted texts and make it fully compatible with normal text rendering and the UI system. Therefore styled text has to indicate that it needs to be parsed.
Adding a parseText option and pushing it through the call stack turned out to be too complicated, so I went with a very simple but effective solution instead: If you want the text to be parsed, it has to start with “@@”. I don’t think any reasonable strings would start with two @ characters, so it should be fine.
I also had to decide how to format the text and as someone who has been on the internet for a long time, I used BBCodes. Here is an example of how an input string could look like:
“@@This is a text with [b ]bold[/b ] and [i ]italic[/i ] words and icons [icon=duck].”
// without the extra spaces in the tags, my blog uses BBCodes too :D
I wrote a parser that I wasn’t very happy with, so I asked ChatGPT instead. It gave me this very confusing-looking regex: "/[([bi])\]([^\[]+?)[/\1]|[icon=([^]]+)]|([^[]+)/g;”. It worked but didn’t handle some edge cases gracefully and ChatGPT couldn’t improve its solution, so I proceeded with my parser and kept thinking about a better solution.
While working on other stuff, I came up with a simpler and better way to implement the parser. Rather than looking for beginning and end tags, I use the individual tags to toggle the state. A bold tag marks the following text bold until a closing bold tag is found and the same applies to italic tags. This made the parsing code much cleaner and also able to deal with nested bold and italic tags. In fact, it made the parser so simple that I could add color formatting too, which I had previously marked as an optional feature. Now, the text can also be styled like this: “@@This is [#ff0000]red[/#] text”.
Formatted text with its input string and the result:

Icons
The next problem was the icons. My WebGL engine handles text rendering and therefore icon rendering with an OffscreenCanvas that’s turned into a texture. However, all resources have to be downloaded from the server, which happens asynchronously and takes some time. This means that when the text renderer comes across an icon, there’s a chance it hasn’t finished loading yet.
Async await might have been a solution, but this part of the engine wasn’t designed for this, so it would have required major changes to prevent race conditions. A much easier solution was to return false when the text renderer comes across an unloaded icon and try again a few frames later. Once an icon is loaded, it stays loaded (icons are small, so this won’t take up a lot of memory).
There are two ways to load icons: You can load them explicitly and give them a unique name [icon=duck], or you can use filenames [icon=path/to/icon.png], in which case the loader will try to load the specified URL. If a file does not exist, a default icon is returned that looks similar to a missing image icon you might come across in a browser.
I also added icon loading to the asset loading system of my engine, so you can mark certain icons as required resources and the engine will show a loading screen until all required assets are loaded.
Text Measuring
Before we can render any text, we need to know how big the text is to resize the OffscreenCanvas correctly. The text parsing returns an array with the different sections of the string, which looks something like this:
[
{ str: “Hello “ , type: “normal” , color: “#000000” },
{ str: “World! ” , type: “bold,” color: “#00ff00” },
{ str: “earthIcon” , type: “icon” }
]
To find the dimensions of formatted text, we just measure each individual section with its style applied (bold text can be wider than normal text). Icons are a bit more complicated because they can be drawn in different ways. The simplest option is to always resize the icon to the line height of the text and use the scaled width.
However, scaling images can sometimes look bad, so there are two options for the icons: scaleDownToLineHeight, which controls whether taller icons should be scaled down, and scaleUpToLineHeight for smaller icons. By default, tall icons are scaled down while smaller icons are not scaled up.
Text Rendering
The text measuring step returns an object with the total dimensions of the text and a list of the sections with x and y coordinates. This can be used to perform additional text alignment by shifting the coordinates relative to a specified box size.
Because everything is precalculated, the text rendering itself is very simple: Just go through the sections and draw them at their locations.
Text formatting works with UI elements too:

Auto Line Breaks
Most of the descriptions above only deal with the simplest case for formatted text, which is just normal text. However, my WebGL engine has two additional types of text: UI elements and UI elements with auto line breaks enabled.
I’ve never been happy with my old implementation of automatic line breaks. It was only possible to use with labels and the text rendering was completely independent of the method used for all other types of text. Since I was working on the text rendering anyway, I decided to have another look at it.
The basic idea of automatic line breaks isn’t that complicated. Split the text at its space characters, calculate the width of every word, check whether the word still fits in the current line, and if not start the next line. However, the actual implementation is a bit more cumbersome, especially when also considering the formatting and icons.
In the end, I was able to add automatic line breaks to the text measuring function which can be controlled with an option. The nice thing about this is that all UI elements can have automatic line breaks, so it’s possible to create big buttons with a lot of text or bigger edit fields similar to text areas in HTML.
A big label with auto line breaks enabled. It's also possible to center each individual line:

Resizing UI Elements
The next feature that required an update was resizing the UI elements. As mentioned above, only labels had the option for auto line breaks and if it was enabled, the label would always resize itself to the dimensions of the text. While this is certainly the most common situation for long texts, there can be others too, especially now that it can be enabled on other UI elements.
Since I moved all of the text measuring and text rendering into the same function, I couldn’t resize the UI elements directly anymore and I didn’t want to add a callback function into the process. However, then I realized that there is an even better and simpler way of doing it: The texture that’s returned from the canvas renderer still has its dimensions attached, so I can just resize the element to that size.
I also added resize as a style option for the UI elements. It can be either “vertical”, “horizontal”, “both”, or “none”, which hopefully is self-explanatory.
Text formatting works in edit fields too, although I should probably add an option to disable it in some cases:

I’m very happy with the new text formatting features. Not only will icons and bold, italic, and colored text enable more freedom in interface designs, but the under-the-hood changes make the code cleaner, more maintainable, and easier to expand.
There are many different cases when you consider the countless variations of text with different styles and potentially weirdly-sized icons, auto-line breaking on or off, and the additional resizing options. It took a while to find and fix the most obvious bugs, but I’m sure I missed a few edge cases, which I will hopefully find and fix when I come across them during game development.
by Christian - 09.02.2025 - 20:34
Comments
Comments are disabled