Text rendering in web games
Text is everywhere in games—scores, dialogue, menus, damage numbers. This tutorial covers different approaches and when to use each.
1) Canvas text basics
The simplest approach:
js
ctx.font = '24px Arial'
ctx.fillStyle = '#ffffff'
ctx.textAlign = 'center'
ctx.textBaseline = 'middle'
ctx.fillText('Score: 1000', canvas.width / 2, 50)2) Text with outline
js
function drawTextWithOutline(ctx, text, x, y, fillColor, outlineColor, outlineWidth = 2) {
ctx.font = '24px Arial'
ctx.textAlign = 'center'
ctx.textBaseline = 'middle'
ctx.strokeStyle = outlineColor
ctx.lineWidth = outlineWidth
ctx.strokeText(text, x, y)
ctx.fillStyle = fillColor
ctx.fillText(text, x, y)
}
drawTextWithOutline(ctx, 'GAME OVER', 400, 300, '#fff', '#000', 3)3) Text with shadow
js
function drawTextWithShadow(ctx, text, x, y, color, shadowOffset = 2) {
ctx.font = '32px sans-serif'
ctx.textAlign = 'left'
ctx.textBaseline = 'top'
// Shadow
ctx.fillStyle = 'rgba(0, 0, 0, 0.5)'
ctx.fillText(text, x + shadowOffset, y + shadowOffset)
// Main text
ctx.fillStyle = color
ctx.fillText(text, x, y)
}4) Bitmap font rendering
For pixel art games or consistent cross-platform look:
js
class BitmapFont {
constructor(image, charWidth, charHeight, chars) {
this.image = image
this.charWidth = charWidth
this.charHeight = charHeight
this.chars = chars
}
draw(ctx, text, x, y, scale = 1) {
const w = this.charWidth * scale
const h = this.charHeight * scale
for (let i = 0; i < text.length; i++) {
const charIndex = this.chars.indexOf(text[i].toUpperCase())
if (charIndex === -1) continue
const sx = (charIndex % 16) * this.charWidth
const sy = Math.floor(charIndex / 16) * this.charHeight
ctx.drawImage(
this.image,
sx, sy, this.charWidth, this.charHeight,
Math.round(x + i * w), Math.round(y), w, h
)
}
}
measureText(text, scale = 1) {
return text.length * this.charWidth * scale
}
}
// Usage
const font = new BitmapFont(fontImage, 8, 8,
'ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789.,!?-+:; '
)
font.draw(ctx, 'SCORE: 1000', 10, 10, 2)5) Pre-rendered text for performance
For static text, render once and reuse:
js
function createTextImage(text, font, color) {
const tempCanvas = document.createElement('canvas')
const tempCtx = tempCanvas.getContext('2d')
tempCtx.font = font
const metrics = tempCtx.measureText(text)
tempCanvas.width = Math.ceil(metrics.width)
tempCanvas.height = parseInt(font) * 1.5
tempCtx.font = font
tempCtx.fillStyle = color
tempCtx.textBaseline = 'top'
tempCtx.fillText(text, 0, 0)
return tempCanvas
}
// Create once
const titleImage = createTextImage('MY GAME', '48px Impact', '#fff')
// Draw many times (fast)
ctx.drawImage(titleImage, x, y)6) Typewriter effect
js
class TypewriterText {
constructor(text, x, y, charsPerSecond = 20) {
this.fullText = text
this.x = x
this.y = y
this.speed = 1 / charsPerSecond
this.timer = 0
this.charIndex = 0
}
update(dt) {
this.timer += dt
while (this.timer >= this.speed && this.charIndex < this.fullText.length) {
this.charIndex++
this.timer -= this.speed
}
}
draw(ctx) {
ctx.font = '18px monospace'
ctx.fillStyle = '#fff'
ctx.fillText(this.fullText.slice(0, this.charIndex), this.x, this.y)
}
isComplete() {
return this.charIndex >= this.fullText.length
}
skip() {
this.charIndex = this.fullText.length
}
}7) Word-wrapped text
js
function wrapText(ctx, text, maxWidth) {
const words = text.split(' ')
const lines = []
let currentLine = ''
for (const word of words) {
const testLine = currentLine ? currentLine + ' ' + word : word
const metrics = ctx.measureText(testLine)
if (metrics.width > maxWidth && currentLine) {
lines.push(currentLine)
currentLine = word
} else {
currentLine = testLine
}
}
if (currentLine) lines.push(currentLine)
return lines
}
function drawWrappedText(ctx, text, x, y, maxWidth, lineHeight) {
const lines = wrapText(ctx, text, maxWidth)
for (let i = 0; i < lines.length; i++) {
ctx.fillText(lines[i], x, y + i * lineHeight)
}
}8) Dialogue box system
js
class DialogueBox {
constructor(x, y, width, height) {
this.x = x
this.y = y
this.width = width
this.height = height
this.padding = 16
this.lineHeight = 24
this.text = ''
this.displayedChars = 0
this.charsPerSecond = 30
this.timer = 0
}
show(text) {
this.text = text
this.displayedChars = 0
this.timer = 0
}
update(dt) {
if (this.displayedChars < this.text.length) {
this.timer += dt
const charsToAdd = Math.floor(this.timer * this.charsPerSecond)
this.displayedChars = Math.min(this.displayedChars + charsToAdd, this.text.length)
this.timer = this.timer % (1 / this.charsPerSecond)
}
}
skip() {
this.displayedChars = this.text.length
}
isComplete() {
return this.displayedChars >= this.text.length
}
draw(ctx) {
// Box background
ctx.fillStyle = 'rgba(0, 0, 0, 0.8)'
ctx.fillRect(this.x, this.y, this.width, this.height)
// Border
ctx.strokeStyle = '#fff'
ctx.lineWidth = 2
ctx.strokeRect(this.x, this.y, this.width, this.height)
// Text
ctx.font = '18px sans-serif'
ctx.fillStyle = '#fff'
ctx.textBaseline = 'top'
const displayText = this.text.slice(0, this.displayedChars)
const maxWidth = this.width - this.padding * 2
const lines = wrapText(ctx, displayText, maxWidth)
for (let i = 0; i < lines.length; i++) {
ctx.fillText(
lines[i],
this.x + this.padding,
this.y + this.padding + i * this.lineHeight
)
}
// Continue indicator
if (this.isComplete()) {
ctx.fillText('▼', this.x + this.width - 24, this.y + this.height - 24)
}
}
}9) Floating damage numbers
js
class FloatingText {
constructor(text, x, y, color = '#fff') {
this.text = text
this.x = x
this.y = y
this.color = color
this.vy = -60
this.lifetime = 1
this.age = 0
}
update(dt) {
this.y += this.vy * dt
this.vy *= 0.95
this.age += dt
}
draw(ctx) {
const alpha = 1 - (this.age / this.lifetime)
ctx.font = 'bold 20px sans-serif'
ctx.textAlign = 'center'
ctx.fillStyle = this.color
ctx.globalAlpha = alpha
ctx.fillText(this.text, Math.round(this.x), Math.round(this.y))
ctx.globalAlpha = 1
}
isExpired() {
return this.age >= this.lifetime
}
}
// Manager
const floatingTexts = []
function spawnDamageNumber(damage, x, y) {
const color = damage > 50 ? '#ff4444' : '#ffff44'
floatingTexts.push(new FloatingText(damage.toString(), x, y, color))
}
function updateFloatingTexts(dt) {
for (let i = floatingTexts.length - 1; i >= 0; i--) {
floatingTexts[i].update(dt)
if (floatingTexts[i].isExpired()) {
floatingTexts.splice(i, 1)
}
}
}10) WebGL text (SDF fonts)
For WebGL games, Signed Distance Field fonts scale cleanly:
js
// SDF shader fragment
const sdfFragmentShader = `
precision mediump float;
uniform sampler2D u_texture;
uniform vec4 u_color;
varying vec2 v_texCoord;
void main() {
float distance = texture2D(u_texture, v_texCoord).a;
float alpha = smoothstep(0.4, 0.6, distance);
gl_FragColor = vec4(u_color.rgb, u_color.a * alpha);
}
`
// Libraries like msdf-bmfont-xml can generate SDF font atlasesFor most 2D Canvas games, bitmap fonts or pre-rendered Canvas text work well.