Guide

Guide Rendering Text

Text

Control layout, styling, and visual effects for runtime text.

Intro ~4 min read

What you'll learn

  • render and style runtime text
  • lay out multiline and wrapped text

Text

Text renders GPU-accelerated text strings as nodes in the scene graph. Each Text instance rasterizes its glyphs into the engine’s shared glyph atlas and draws them as a single Mesh — one draw call per text node, regardless of string length. The node extends Container and inherits full transform, filter, blend, and mask support.

A minimal text node

import { Color, Text } from '@codexo/exojs';

const label = new Text('Hello', {
    fillColor: Color.white,
    fontSize: 24,
    fontFamily: 'Arial',
});

The second argument is a TextOptions object — a flat merge of TextStyleOptions (visual appearance) and LayoutOptions (flow and overflow). All properties are optional and have defaults. The live TextStyle is stored as text.style; mutating its fields is cheap and is applied automatically before the next render pass — no manual rebuild call is needed:

import { Color } from '@codexo/exojs';

label.style.fontSize = 32;            // rebuilds the glyph mesh on the next draw
label.style.fillColor = Color.tomato; // updates only the mesh tint — no atlas work

Font loading

System fonts (Arial, Times New Roman, etc.) work immediately. For custom web fonts, load a FontFace in the scene’s load hook:

async load(loader) {
    await loader.load(FontFace, {
        myFont: 'font/MyFont.woff2',
    }, { family: 'MyFont' });
}

init(loader) {
    this.title = new Text('Custom Font', {
        fontFamily: 'MyFont',
        fontSize: 48,
        fillColor: Color.white,
    });
}

The FontFace asset is registered with the document’s document.fonts set. Once loaded, it becomes available to all Text instances via the fontFamily style property.

Style properties

The visual appearance comes from TextStyleOptions. All properties are optional:

PropertyTypeDefaultDescription
fontFamilystring'Arial'CSS font family name
fontWeightFontWeight'normal'CSS font weight ('normal', 'bold', or '100''900')
fontStyle'normal' | 'italic''normal'Font style
fontSizenumber20Font size in pixels
fillColorColorColor.whiteGlyph fill colour
outlineColorColorColor.blackSDF outline colour
outlineWidthnumber0Outline width in SDF units (00.5); 0 disables the outline
align'left' | 'center' | 'right''left'Horizontal alignment
lineHeightnumber1.2Line-height multiplier on fontSize
leadingnumber0Extra pixel gap between lines
shadowColorColorColor.blackDrop-shadow colour
shadowOffsetXnumber0Horizontal shadow offset in pixels
shadowOffsetYnumber0Vertical shadow offset in pixels
shadowAlphanumber0Shadow opacity (01); 0 disables the shadow
shadowBlurnumber0Shadow blur softness (01)
gradientColors[Color, Color] | nullnullTwo-stop fill gradient [top, bottom]; overrides fillColor
gradientAxis'vertical' | 'horizontal''vertical'Gradient orientation

Text flow and overflow come from LayoutOptions, merged into the same options object:

PropertyTypeDefaultDescription
maxWidthnumberWord-wrap boundary in pixels; longer lines break at word boundaries
maxHeightnumberClip boundary in pixels
overflow'visible' | 'clip' | 'ellipsis''visible'Behaviour when text exceeds maxHeight
letterSpacingnumber0Extra pixel gap between glyphs
breakWordsbooleanfalseBreak words wider than maxWidth at character boundaries
whiteSpace'normal' | 'pre' | 'pre-line''pre-line'Whitespace handling

Glyphs are rasterized into the shared SDF atlas; the mesh tint is initialized from fillColor when the mesh is built.

Multiline text and wrap settings

Multiline text works by embedding \n characters in the string:

import { Color, Text } from '@codexo/exojs';

const dialog = new Text('Line one\nLine two\nLine three', {
    fillColor: Color.white,
    fontSize: 18,
    lineHeight: 1.5,
});

To wrap long runs automatically, set maxWidth (in pixels) — lines that exceed it break at word boundaries. Add breakWords: true to also split individual words that are wider than maxWidth:

import { Color, Text } from '@codexo/exojs';

const longString = 'A long run of text that wraps automatically once it exceeds the layout width.';

const wrapped = new Text(longString, {
    fillColor: Color.white,
    fontSize: 16,
    maxWidth: 400,
    breakWords: true,
});

Alignment

The align property controls horizontal positioning relative to the Text node’s local origin:

import { Color, Text } from '@codexo/exojs';

const centered = new Text('Centered Title', {
    align: 'center',
    fillColor: Color.white,
    fontSize: 32,
});
centered.setPosition(400, 20);

A center-aligned text node at (400, 20) centers its glyphs horizontally around x=400 in local space.

Text in the scene graph

Text extends Container, so it carries position, rotation, scale, origin, and anchor. You can rotate text, tint the whole node, apply filters, and nest it inside other containers:

this.ui = new Container();
this.scoreLabel = new Text('Score: 0', { fillColor: Color.white, fontSize: 24 });
this.scoreLabel.setPosition(10, 10);
this.ui.addChild(this.scoreLabel);
this.addChild(this.ui);

The text node’s internal mesh is a Container child. Assigning to text.text rebuilds the glyph mesh. This means:

  • Changing text every frame is fine — the mesh is rebuilt on demand, at most once per frame.
  • Mutating style fields (e.g. text.style.fillColor = ...) is also picked up automatically before the next draw — no manual rebuild needed.

Text constraints

  • The glyph atlas is shared across all Text instances and has a fixed default size. Large sets of unique glyph/style combinations can exhaust atlas space.
  • Text does not expose per-character styling. Use separate Text instances for mixed-style strings.
  • Text does not measure or report its pixel dimensions through the public API — the mesh bounds reflect the glyph quad size but measured width/height in pixels is not exposed as a public getter.
  • Loader-based FontFace loading is the built-in path for custom fonts; any font family available to the browser’s canvas text engine can be used once loaded.

Examples

Basic Text Open in Playground View source

Preview is paused until you click Play.

import { Application, Color, FontAsset, Scene, Text, Time } from '@codexo/exojs';

const app = new Application({
    canvas: {
        width: 1280,
        height: 720,
        mount: document.body,
        sizingMode: 'fit',
    },
    clearColor: Color.black,
    loader: {
        basePath: 'assets/',
    },
});

class BasicTextScene extends Scene {
    private time!: Time;
    private text!: Text;

    override async load(loader): Promise<void> {
        await loader.load(FontAsset, { example: 'font/Kenney Future.ttf' }, { family: 'Kenney Future' });
    }

    override init(): void {
        const { width, height } = this.app.canvas;

        this.time = new Time();

        this.text = new Text('Hello World!', {
            align: 'left',
            fillColor: Color.white,
            outlineColor: Color.black,
            outlineWidth: 0.2,
            fontSize: 25,
            fontFamily: 'Kenney Future',
        });

        this.text.setPosition(width / 2, height / 2);
        this.text.setAnchor(0.5, 0.5);
    }

    override update(delta): void {
        this.text.text = `Hello World! ${this.time.addTime(delta).seconds | 0}`;
        this.text.rotate(delta.seconds * 36);
    }

    override draw(context): void {
        context.backend.clear();
        context.render(this.text);
    }
}

app.start(new BasicTextScene());

A text node with a custom web font, updating its string each frame.

Multiline and Wrap Open in Playground View source

Preview is paused until you click Play.

import { Application, Color, Scene, Text } from '@codexo/exojs';

const app = new Application({
    canvas: {
        width: 1280,
        height: 720,
        mount: document.body,
        sizingMode: 'fit',
    },
    clearColor: Color.black,
});

const paragraph = 'ExoJS text layout can render multiline content with configurable wrapping behavior and style.';
const longToken = 'ExoJStextlayoutrendersaverylongunbrokentokenwithoutanyspacestobreakon';

const titleColor = new Color(140, 170, 210);

class MultilineAndWrapScene extends Scene {
    private titleA!: Text;
    private textA!: Text;
    private titleB!: Text;
    private textB!: Text;
    private titleC!: Text;
    private textC!: Text;

    override init(): void {
        const { width, height } = this.app.canvas;

        // Three wrap modes side by side across the 16:9 canvas: one column each.
        const colWidth = width / 3;
        const titleY = height * 0.16;
        const bodyY = height * 0.16 + 36;
        const colX = (index: number): number => colWidth * index + (colWidth - 360) / 2;

        this.titleA = new Text('No wrap — single line overflows', { fillColor: titleColor, fontSize: 16 });
        this.titleA.setPosition(colX(0), titleY);
        this.textA = new Text(paragraph, { fillColor: Color.white, fontSize: 22 });
        this.textA.setPosition(colX(0), bodyY);

        this.titleB = new Text('Word wrap @ 360px — at word boundaries', { fillColor: titleColor, fontSize: 16 });
        this.titleB.setPosition(colX(1), titleY);
        this.textB = new Text(paragraph, { fillColor: Color.white, fontSize: 22, maxWidth: 360 });
        this.textB.setPosition(colX(1), bodyY);

        this.titleC = new Text('Break words @ 280px — splits a token', { fillColor: titleColor, fontSize: 16 });
        this.titleC.setPosition(colX(2), titleY);
        this.textC = new Text(longToken, { fillColor: Color.white, fontSize: 22, maxWidth: 280, breakWords: true });
        this.textC.setPosition(colX(2), bodyY);
    }

    override draw(context): void {
        context.backend.clear();
        context.render(this.titleA);
        context.render(this.titleB);
        context.render(this.titleC);
        context.render(this.textA);
        context.render(this.textB);
        context.render(this.textC);
    }
}

app.start(new MultilineAndWrapScene());

Multiline text with alignment and line-height control.

Where to go next

The next chapter, Animation, covers frame-based sprite animation, tweens for interpolated motion, and how to combine manual frame-loop updates with automated tween-driven timing.