Guide

Text

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

Intro~19 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 a shared glyph atlas and hands the resulting quads to the text renderer, which merges every quad that shares a shader type and an atlas page into one draw call — across text nodes, not just within a single one.

Text is a Drawable (via AbstractText), not a Container: it carries the full transform, tint, blend mode, pixel-snap, filter, and mask surface of any other drawable, but it holds no children of its own. Nest it inside a Container when you need to group it with other nodes.

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, Text } from '@codexo/exojs';

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

label.style.fontSize = 32;            // re-lays out the glyphs on the next draw
label.style.fillColor = new Color(0xff6347); // shader-side colour only — no atlas work

Font loading

System fonts (Arial, Times New Roman, etc.) work immediately. For custom web fonts, load one via Asset.type('font', ...) in the scene’s load hook — fonts aren’t a seamless type, so a bare path isn’t enough; an explicit descriptor is required even for a literal path, and family is a required option:

examples/guides/text/custom-font.ts
override async load(): Promise<void> {
  await this.loader.load(Asset.type('font', 'font/MyFont.woff2', { family: 'MyFont' }));
}

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

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

You can also hand the loaded face to the node directly through the font style option. Text registers it with document.fonts itself and rebuilds once it is ready, which removes the ordering requirement between loading and construction:

examples/guides/text/custom-font.ts
override async load(): Promise<void> {
  const face = await this.loader.load(Asset.type('font', 'font/MyFont.woff2', { family: 'MyFont' }));

  this.title = new Text('Custom Font', { font: face, fontSize: 48 });
}

font takes precedence over fontFamily when both are set.

Style properties

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

Property Type Default Description
font FontFace — Pre-loaded font face; takes precedence over fontFamily
fontFamily string 'Arial' CSS font family name
fontWeight FontWeight 'normal' CSS font weight ('normal', 'bold', or '100'–'900')
fontStyle 'normal' | 'italic' | 'oblique' 'normal' Font style. 'oblique' asks for a fixed slant rather than the family’s own italic
fontVariant 'normal' | 'small-caps' 'normal' Caps variant. Small caps come from the family’s own glyphs where it has them, otherwise from the browser’s synthesis
fontSize number 20 Font size in pixels
fillColor Color Color.white Glyph fill colour
outlineColor Color Color.black SDF outline colour
outlineWidth number 0 Outline width in SDF units (0–0.5); 0 disables the outline
align TextAlignment — 'left' | 'center' | 'right' | 'justify' 'left' Horizontal alignment
textTransform 'none' | 'uppercase' | 'lowercase' | 'capitalize' 'none' Case mapping applied before layout; the node’s text is untouched
lineHeight number 1.2 Line-height multiplier on fontSize
leading number 0 Extra pixel gap between lines
shadowColor Color Color.black Drop-shadow colour
shadowOffsetX number 0 Horizontal shadow offset in pixels
shadowOffsetY number 0 Vertical shadow offset in pixels
shadowAlpha number 0 Shadow opacity (0–1); 0 disables the shadow
shadowBlur number 0 Shadow blur softness (0–1)
underline boolean false Draw a rule under each line
strikethrough boolean false Draw a rule through each line
decorationColor Color | null null Rule colour; null follows the fill, gradient included
decorationThickness number 0 Rule thickness in pixels; 0 derives it from the font size
decorationOffset number 0 Extra downward offset applied to both rules
gradient TextGradient | null null Multi-stop fill gradient; overrides fillColor

A gradient takes up to eight colour stops and an angle in degrees. The angle follows the CSS linear-gradient convention — 0 runs towards the top, 90 towards the right, increasing clockwise — and defaults to 180, top to bottom. The ramp spans the ink extent (getLocalBounds()) corner to corner along that direction, not the advance box, so the first and last stops always land on the box edges:

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

const banner = new Text('Level Up', {
    fontSize: 48,
    gradient: {
        stops: [
            { offset: 0, color: Color.white },
            { offset: 0.6, color: new Color(0xffd700) },
            { offset: 1, color: new Color(0xff6347) },
        ],
        angle: 200,
    },
});

Stop offsets are clamped to 0–1 and sorted on the way in, and the colours are cloned, so the object you pass is never aliased. Reading style.gradient back gives the normalized form. The ramp is evaluated in the fragment shader from the node’s own packed style, so changing it costs no atlas work — it is a 'tint' change like fillColor.

Underline and strikethrough

Both rules are quads the layout emits per line, so they follow the alignment, the wrap and the letter spacing without any further work:

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

const link = new Text('Read the manual', { fontSize: 18, underline: true });
const removed = new Text('Old price', { fontSize: 18, strikethrough: true, decorationColor: Color.red });

Their position and thickness come from the font’s own metrics rather than from a fraction of the font size: the underline sits inside the descender space and the strikethrough is centred on half the x-height, which is what keeps a rule in the same relation to the letters across typefaces. decorationThickness and decorationOffset override the two numbers when a design needs a specific one.

A rule samples an opaque block in the glyph atlas, so by default it takes exactly the fill the glyphs take — including a gradient. decorationColor replaces that for the rules only.

A BitmapText draws from an offline atlas that has no opaque block reserved in it, so it renders no rules.

Style and caps variants

fontStyle and fontVariant go into the CSS font shorthand the glyph rasterizer hands to Canvas 2D, so what you get is whatever the browser gets: a family’s real italic or small-cap glyphs where it ships them, and the browser’s synthesis where it does not.

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

const chapter = new Text('Chapter One', { fontSize: 28, fontVariant: 'small-caps' });
const aside = new Text('a passing thought', { fontSize: 16, fontStyle: 'oblique' });

Both are part of a glyph atlas’s identity, so a small-cap a and an ordinary a never share a cache entry, and 'oblique' gets its own atlas rather than borrowing the italic one. That costs a second set of pages for a node that mixes variants — worth knowing before styling every label differently.

A BitmapText draws from an atlas somebody else baked, which contains exactly the glyphs it contains. Neither property has anything to act on there.

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

Property Type Default Description
maxWidth number — Word-wrap boundary in pixels; longer lines break at word boundaries
maxHeight number — Vertical boundary in pixels; only whole lines that fit are kept. No effect on its own — pair it with overflow
maxLines number — Hard cap on the laid-out line count, applied after wrapping. Clips on its own; pair with overflow: 'ellipsis' for a marker
overflow 'visible' | 'clip' | 'ellipsis' 'visible' What happens to lines that do not fit maxHeight or maxLines: keep them all, drop them, or drop them and mark the last visible line
ellipsis string '…' Marker appended under overflow: 'ellipsis'. '...' for the three-period spelling, '' to truncate silently
letterSpacing number 0 Extra pixel gap between glyphs
tabSize number 8 Tab stop spacing in space widths, matching the CSS tab-size initial value. Only preserved tabs reach it — see whiteSpace
direction 'ltr' | 'rtl' 'ltr' 'rtl' reverses each line’s glyphs after wrapping; no full bidi or Arabic shaping, and align stays literal
breakWords boolean false Break words wider than maxWidth at character boundaries
whiteSpace 'normal' | 'pre' | 'pre-line' 'pre-line' Whitespace handling

Under whiteSpace: 'pre' a tab advances the pen to the next tabSize stop measured from the start of its line, so a run of tab-separated values lines up as columns instead of drifting. The collapsing modes turn a tab into a single space before layout runs, exactly as CSS does, so tabSize has nothing to act on there. A browser-shaped line is laid out whole by the platform’s text engine and ignores the setting.

Layout options stay reachable as text.layout. Unlike style, this is a plain options object with no change tracking, and the node holds its own copy of it: assign a new one to re-flow the text — mutating the object you passed in, or the one the getter hands back, changes nothing.

Glyphs are rasterized white into the shared SDF atlas and coloured at draw time, so fillColor, outlineColor, the shadow, and the gradient are shader work only — changing them never touches the atlas.

Case without rewriting the string

textTransform maps case at layout time, exactly as CSS text-transform does. The node’s text is untouched, so a label reads back the string you assigned and an editable widget keeps a caret that lands where the reader clicked:

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

const heading = new Text('level complete', { fontSize: 32, textTransform: 'uppercase' });

heading.text; // 'level complete' — the mapping lives in the layout, not in the string

The mapping runs per grapheme cluster and is Unicode-aware, so a cluster that changes length under it — German ß uppercasing to SS — still traces back to the character it came from. 'capitalize' raises the first cluster of each word and leaves the rest of the word alone, and word boundaries come from the platform segmenter rather than from splitting on spaces.

Set locale and both the case mapping and the word boundaries follow it, which is the difference between a Turkish I lowercasing to ı and to i.

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,
});

Clamping to a line count

maxLines caps how many lines survive, counted after wrapping. It clips on its own, so it needs no overflow policy; add overflow: 'ellipsis' when the truncation should be visible:

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

const body = 'A long article body that runs well past three lines of the layout width.';

const summary = new Text(body, {
    fontSize: 16,
    maxWidth: 320,
    maxLines: 3,
    overflow: 'ellipsis',
});

The marker defaults to '…' and ellipsis replaces it — '...' for the three-period spelling, '' to truncate with no marker at all. Whatever it is, it is measured in the same font as the text, and the line gives up whole grapheme clusters until the two fit maxWidth together.

Under a line cap the marker also reaches a line that no wrap could shorten. maxLines: 1 on a single unbreakable word drops no line at all, yet the word still overflows maxWidth — and that is the case a one-line label wanted the marker for:

const label = new Text('Supercalifragilistic', {
    maxWidth: 120,
    maxLines: 1,
    overflow: 'ellipsis',
});

maxHeight expresses the same clamp in pixels and still requires an overflow policy. Set both and the tighter one wins.

International text

Four separate concerns decide how a string ends up on screen. They are easy to confuse, and only the first two are about typography:

  • Segmentation — where the text may safely be split.
  • Shaping — whether a glyph’s appearance depends on the glyphs around it.
  • Raster density — how much glyph information is stored (pixelRatio).
  • Reconstruction — how that information is turned back into an edge on screen.

Segmentation: the unit is a cluster, not a character

Layout counts grapheme clusters, not code points. A combining sequence (e + acute), an emoji with a skin-tone modifier, a ZWJ sequence like 👨‍👩‍👧 and a regional-indicator flag pair are each one cluster made of several code points, and each is placed as one glyph. Wrapping, breakWords and the 'ellipsis' overflow all cut between clusters, so truncation never leaves a dangling mark or half a flag behind.

Word boundaries come from the platform’s own segmentation rather than from splitting on spaces, so a language written without inter-word spaces wraps at its own boundaries instead of overflowing as one token:

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

const jp = new Text('日本語のテキストは自動的に折り返されます', {
    fontSize: 18,
    maxWidth: 200,
    locale: 'ja',
});

locale selects the segmentation locale. It picks no font and loads nothing; leave it out and the platform default applies.

Shaping: when a glyph needs its neighbours

Most text renders one glyph at a time out of a shared atlas — the same S serves every label on screen, which is what makes thousands of them cheap. Some scripts cannot work that way. An Arabic letter changes shape depending on what it joins to, and a line mixing Latin and Hebrew has a visual order that follows from the whole line, not from any character in it.

For those, ExoJS hands the complete laid-out line to the browser’s own text engine, which resolves the bidirectional order and the contextual forms, and renders the result. shaping selects between the two:

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

// 'auto' (the default): plain Latin takes the shared-glyph path.
const score = new Text('Score: 1234', { fillColor: Color.white, fontSize: 20 });

// 'auto' again: Arabic needs its neighbours, so the browser shapes the line.
const heading = new Text('الإصدار 12', { fillColor: Color.white, fontSize: 20 });

// Forced, for controlled content and for benchmarks.
const forced = new Text('Score: 1234', { fillColor: Color.white, fontSize: 20, shaping: 'simple' });

text.shapingMode reports which one settled, 'simple' or 'browser'.

direction sets the base direction a mixed line is resolved against. It is explicit — the engine does not guess it from the text or from the locale, because neither implies the other:

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

const rtl = new Text('Build 42 — الإصدار 12 (Beta)', { fontSize: 20, direction: 'rtl' });

What this costs, and what it does not

The shared-glyph path is unchanged. A HUD counter going from 1234 to 1235 still reuses every glyph it already has and rasterizes only what is new, and static text does no segmentation, no measurement and no rasterization on a frame where nothing changed.

Browser-shaped text has a different cost model on purpose. Its unit is a line, so a line whose text changes is rasterized again, and the raster belongs to the node rather than to the shared atlas — which is why it is worth forcing shaping: 'simple' for content you know is simple, and worth not putting contextual text in a per-frame readout.

Limits worth knowing

  • Segmentation uses the platform’s Intl.Segmenter. Where a browser does not provide it, clusters degrade to code points: surrogate pairs still survive, but a combining sequence, a ZWJ sequence or a flag may be split by wrapping or truncation, and word boundaries fall back to blank runs. No polyfill and no Unicode tables ship with the engine.
  • ExoJS does not implement the Unicode bidirectional algorithm itself. It supplies the base direction and lets the browser resolve the line, which is enough to render mixed text correctly but not to own a logical-to-visual mapping — so caret navigation, selection and hit testing inside contextual text are not supported yet.
  • Word segmentation is not full Unicode line breaking. Hyphenation points and the finer break rules of some scripts are not modelled.
  • Font fallback for a character the chosen family lacks is the browser’s, not the engine’s. There is no engine-owned fallback ordering.
  • BitmapText is unaffected: a prebuilt font holds pre-generated glyphs and usually no contextual data, so it stays on the per-glyph path.

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.

'justify' is the fourth mode: it spreads the extra space of a line evenly across its inter-word gaps so every line reaches the width of the widest line. The last line and any line with a single word are left alone, so a justified block never stretches a trailing fragment.

Text in the scene graph

As a Drawable, Text carries position, rotation, scale, origin, and anchor like any other scene node. You can rotate text, tint the whole node, apply filters, and add it to a container — but not add children to it:

examples/guides/text/custom-font.ts
this.hud = new Container();
this.scoreLabel = new Text('Score: 0', { fillColor: Color.white, fontSize: 24 });
this.scoreLabel.setPosition(10, 10);
this.hud.addChild(this.scoreLabel);
this.addChild(this.hud);

The glyph geometry is not a child node — it lives on the Text itself as per-atlas-page quad data the text renderer consumes. Nothing you assign lays the text out on the spot: assigning text.text, text.layout or a whole new text.style, and mutating style fields (e.g. text.style.fillColor = ...), all just mark the geometry stale. Any number of changes in one frame coalesce into at most one layout pass, which runs the moment something reads the node — its bounds, its measurements, or the renderer’s collect phase, whichever comes first. Assigning the same string is a no-op, so changing the text every frame only costs a pass on the frames it actually changes. text.syncDirty() forces the pass early, but you rarely need it: every read resolves on its own.

The node’s local bounds track the laid-out text, so an anchored label re-derives its origin whenever the string changes width — a centred score readout stays centred as it grows.

Measuring text

A laid-out string has two honest sizes, and picking the wrong one is the usual source of a panel that fits badly:

  • text.textBounds is the advance — where the cursor ends up. This is the layout measure: how much room the string takes up in the flow, and where a caret or a following element belongs. It is a TextSize ({ width, height }).
  • text.getLocalBounds() is the ink — the rectangle the glyph quads actually cover. It includes the SDF padding, and therefore the reach of any outline or shadow. In SDF mode it starts at a slightly negative x/y and is a little larger than the advance on both axes. This is what culling, hit-testing and the gradient ramp use.

Use the advance to size a panel around a label or to position something next to it:

examples/guides/text/custom-font.ts
this.label = new Text('Ready', { fontSize: 24 });

this.backdrop = new Panel({
  width: this.label.textBounds.width + 16,
  height: this.label.textBounds.height + 8,
});

Both reads resolve a pending layout pass first, so the number you get always reflects the changes you have made — including a style mutation on the line above.

To measure without a node at all — sizing a button before you have anything to put in it, deciding whether a caption fits — use the static counterpart. It takes the same options as the constructor and runs the same layout pass over the same shared font metrics, so it answers exactly what the node’s textBounds would:

import { BitmapText, Text, type BmFont } from '@codexo/exojs';

declare const font: BmFont;

const { width } = Text.measure('Continue', { fontSize: 24 });
const { height } = BitmapText.measure('Continue', font, { scale: 2 });

Text.measure costs one canvas measurement per glyph it has not seen before and nothing else: it creates no atlas, rasterizes no glyph and claims no atlas space. Its answer is therefore also independent of pixelRatio — see below. BitmapText.measure reads a pre-built atlas and costs nothing beyond the layout.

HiDPI: glyph raster density

Glyphs are rasterized onto a real pixel grid, so on a high-density display they need more of them. A text node therefore rasterizes at the pixelRatio of the Application that draws it — set canvas.pixelRatio: 2 and its glyphs are rendered from a 2x font onto a 2x atlas tile. You do not have to do anything for that; it is the default, and it is deterministic: nothing in the text stack consults window.devicePixelRatio, so an application pinned at 2 renders text at 2 on a device that reports 3.

Text.pixelRatio decouples one node’s glyph raster from the surface it is drawn on:

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

const app = new Application({ canvas: { pixelRatio: 2 } });

// Inherits the application's ratio: rasterized at 2.
const label = new Text('Ammo 24/60', { fontSize: 9 });

// Overridden: rasterized at 3 while the surface stays at 2.
const crisp = new Text('Ammo 24/60', { fontSize: 9, pixelRatio: 3 });

Both nodes lay out identically. The logical font size, the advances, kerning, wrapping, line breaks, alignment, the reach of an outline or shadow, and everything textBounds and Text.measure report are the same at every ratio — only sharpness, atlas tile size and memory change. text.pixelRatio reports the override and is undefined when there is none; assign undefined to go back to inheriting. text.rasterPixelRatio reports the density actually in force.

The value to want is usually the inherited one. Lowering it lowers the raster resolution the distance field is built from, so the quality floor it eventually hits depends on the glyph — its size, the thinnest stroke in the typeface, the SDF radius, an outline — rather than on the ratio alone. Small text reaches that floor first: on an iPhone 13 Pro, 9 and 11px rows pinned to ratio 1 lost their stems while the 16px row held.

Raising it costs roughly the square of the ratio. Measured over an ASCII set at 9, 11 and 16px: 146k atlas texels at ratio 1, 582k at ratio 2 and 1.29M at ratio 3, enough to push that set from one 1024x1024 page to two. For unscaled screen text at those sizes, the same device showed no visible gain from going above the inherited ratio — which is what makes inherit the right default rather than a compromise.

The case for raising it is content whose on-screen density exceeds the surface ratio: a label scaled up at runtime, or one drawn through a zoomed camera, samples its atlas over more device pixels than the surface ratio suggests. The case for lowering it is trading sharpness for atlas memory — safe on large text, harmful on small.

Antialiasing is not part of this decision. The shader derives its edge width from how many device pixels the node covers on screen, so a glyph fades over about one device pixel however its atlas density, the surface ratio, its own scale and the camera’s zoom combined to put it there. The three concerns stay separate: pixelRatio decides how much glyph information is stored, the atlas sampler decides how it is reconstructed between texels, and the shader decides how wide the reconstructed edge lands on the screen.

Text constraints

  • One glyph atlas is shared per font variant (family, style, weight, colour mode, SDF radius, raster pixel ratio), so nodes that differ only in size or colour reuse the same atlas — and nodes that differ in family, weight or pixel ratio do not. The atlas grows by adding 1024×1024 pages as needed; large sets of unique glyph/size combinations cost VRAM rather than failing, but a single glyph larger than one page throws.
  • Browser-shaped lines are owned by the node that laid them out rather than pooled, so identical contextual strings in different nodes each hold their own raster. There is no shared shaped-line cache.
  • Text does not expose per-character styling. Use separate Text instances for mixed-style strings.
  • Text is a leaf drawable, not a container — it takes no children.
  • Loader-based FontAsset 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 TextOpen in PlaygroundView source

Preview is paused until you click Play.

import { Application, Asset, Color, FixedResolutionCanvasSizing, type RenderingContext, Scene, type Seconds, Text } from '@codexo/exojs';

class BasicTextScene extends Scene {
  private elapsed = 0;
  private text!: Text;

  override async load(): Promise<void> {
    const app = this.app;
    await this.loader.load(Asset.type('font', 'font/Kenney Future.ttf', { family: 'Kenney Future' }));

    const { width, height } = app;

    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: Seconds): void {
    this.elapsed += delta;
    this.text.text = `Hello World! ${this.elapsed | 0}`;
    this.text.rotate(delta * 36);
  }

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

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

await app.start(BasicTextScene);

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

Multiline and WrapOpen in PlaygroundView source

Preview is paused until you click Play.

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

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 app = this.app;
    const { width, height } = app;

    // 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('Word wrap @ 360px — at word boundaries', { fillColor: titleColor, fontSize: 16 });
    this.titleA.setPosition(colX(0), titleY);
    this.textA = new Text(paragraph, { fillColor: Color.white, fontSize: 22, maxWidth: 360 });
    this.textA.setPosition(colX(0), bodyY);

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

    // No-wrap goes in the LAST column on purpose: its single line overflows
    // off the right canvas edge, demonstrating the overflow without running
    // across (and visually breaking) the other two columns.
    this.titleC = new Text('No wrap — single line overflows', { fillColor: titleColor, fontSize: 16 });
    this.titleC.setPosition(colX(2), titleY);
    this.textC = new Text(paragraph, { fillColor: Color.white, fontSize: 22 });
    this.textC.setPosition(colX(2), bodyY);
  }

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

const app = new Application({
  scenes: { MultilineAndWrapScene },
  canvas: {
    width: 1280,
    height: 720,
    mount: document.body,
    sizing: new FixedResolutionCanvasSizing(),
  },
  clearColor: Color.black,
});

await app.start(MultilineAndWrapScene);

Multiline text with alignment and line-height control.

Typographic StylingOpen in PlaygroundView source

Preview is paused until you click Play.

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

const ink = new Color(232, 238, 250);
const margin = 64;

class TypographicStylingScene extends Scene {
  private readonly labels: Text[] = [];

  override init(): void {
    const { width } = this.app;
    const column = (width - margin * 2) / 2;

    // A multi-stop gradient with a CSS-style angle: 0 runs towards the top,
    // 90 towards the right, and the ramp spans the ink box corner to corner.
    this.place(
      new Text('LEVEL UP', {
        fontSize: 76,
        gradient: {
          stops: [
            { offset: 0, color: new Color(255, 255, 255) },
            { offset: 0.55, color: new Color(255, 214, 92) },
            { offset: 1, color: new Color(255, 96, 84) },
          ],
          angle: 200,
        },
      }),
      margin,
      margin,
    );

    // Small caps and oblique come from the CSS font shorthand, so a family's
    // own glyphs are used where it ships them.
    this.place(new Text('Chapter One', { fillColor: ink, fontSize: 34, fontVariant: 'small-caps' }), margin, 170);
    this.place(new Text('a passing thought', { fillColor: ink, fontSize: 26, fontStyle: 'oblique' }), margin, 220);

    // Rules are quads the layout emits per line, so they follow alignment and
    // wrapping. Without decorationColor they take the fill.
    this.place(new Text('Read the manual', { fillColor: ink, fontSize: 26, underline: true }), margin, 272);
    this.place(new Text('999 gold', { fillColor: ink, fontSize: 26, strikethrough: true, decorationColor: new Color(255, 96, 84) }), margin + column, 272);

    // A case mapping applied at layout time - the node's own text is untouched.
    this.place(new Text('quest complete', { fillColor: ink, fontSize: 26, textTransform: 'uppercase' }), margin, 326);
    this.place(new Text('the sunken keep', { fillColor: ink, fontSize: 26, textTransform: 'capitalize' }), margin + column, 326);

    // maxLines clips the line count; overflow marks the cut.
    this.place(
      new Text('The keep has stood since long before the first maps were drawn, and nobody living remembers who raised it.', {
        fillColor: ink,
        fontSize: 20,
        maxWidth: column - 24,
        maxLines: 2,
        overflow: 'ellipsis',
      }),
      margin,
      388,
    );

    // Preserved tabs advance to the next stop, so the values line up as a column.
    this.place(
      new Text('Sword\t120\nShield\t80\nPotion\t15', {
        fillColor: ink,
        fontSize: 20,
        lineHeight: 1.4,
        whiteSpace: 'pre',
        tabSize: 4,
      }),
      margin + column,
      388,
    );
  }

  override draw(context: RenderingContext): void {
    for (const label of this.labels) {
      context.render(label);
    }
  }

  private place(label: Text, x: number, y: number): void {
    label.setPosition(x, y);
    this.labels.push(label);
  }
}

const app = new Application({
  scenes: { TypographicStylingScene },
  canvas: {
    width: 1280,
    height: 720,
    mount: document.body,
    sizing: new FixedResolutionCanvasSizing(),
  },
  clearColor: new Color(18, 22, 34),
  loader: {
    basePath: 'assets/',
  },
});

await app.start(TypographicStylingScene);

The style sweep in one scene: an angled multi-stop gradient, small caps and oblique, underline and strikethrough, textTransform, a clamped paragraph with an ellipsis, and tab-aligned columns.

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.