Quick answer: Stop scaling rasterized text. Render the glyphs at the target pixel size every time using pygame.freetype.Font.render(text, size=N), and cache results by (text, size). Never call pygame.transform.scale on a text surface.

You write a HUD in Pygame, render the score with font.render, and it looks great at 800×600. You add a fullscreen mode that scales the rendering surface, and the text turns to mud. The number 7 looks like a 1, the letters bleed into each other, and your beautiful retro game suddenly has subway-bathroom typography. The fix is to stop letting transform.scale touch your text at all.

The Symptom

Text rendered with pygame.font.SysFont(...).render(...) looks fine at the original render resolution. The moment you do anything that resizes the resulting surface — pygame.transform.scale, blitting onto a smaller surface and then up-blitting, switching to a larger window mode — the glyph edges go fuzzy.

Distinctive symptoms:

What Is Actually Happening

pygame.transform.scale performs bilinear interpolation on raw pixel data. For photographs and most game art, that is fine — bilinear interpolation hides aliasing. For text, it is the wrong tool. A glyph is a 2D shape with sharp boundaries, and those boundaries depend on knowing where the original vector outline lives, not where the pixels happen to fall after rasterization.

When you ask FreeType (or SDL_ttf) to render a glyph at 16 pixels, it computes the precise outline at that size and antialiases the result. When you then scale that 16-pixel raster up to 32 pixels, you are working with the already-antialiased pixels, not the original outline. The result is a fuzzy, blurry version of what was originally a crisp shape.

The Fix

Step 1: Switch to pygame.freetype.

The legacy pygame.font module is built on SDL_ttf and locks you into one render size per font instance. pygame.freetype supports rendering the same font at arbitrary sizes on demand, with better kerning and subpixel positioning.

import pygame
import pygame.freetype

pygame.init()
pygame.freetype.init()

font = pygame.freetype.Font("assets/font.ttf")
font.antialiased = True

Step 2: Render at the target pixel size every time.

def render_hud(surface, score, scale_factor):
    text_size = int(24 * scale_factor)
    text_surf, text_rect = font.render(
        f"Score: {score}",
        fgcolor=(255, 255, 255),
        size=text_size)
    surface.blit(text_surf, (16, 16))

Notice that we pass size=text_size to font.render. The font object is created once and reused; the size is passed at render time. FreeType rasterizes the glyph fresh at exactly the requested size, so the result is always crisp.

Step 3: Cache rendered surfaces by (text, size).

Rasterizing every glyph every frame is wasteful. For static or rarely changing strings, cache the result:

_text_cache = {}

def cached_render(text, size, color=(255, 255, 255)):
    key = (text, size, color)
    if key not in _text_cache:
        surf, _ = font.render(text, fgcolor=color, size=size)
        _text_cache[key] = surf
    return _text_cache[key]

def on_resize(new_size):
    # Clear the cache so text re-renders at the new size
    _text_cache.clear()

Cap the cache size if you render dynamic strings (e.g. enemy hit numbers). A simple LRU eviction is enough for most games.

The Right Way to Use a Render Layer

If your architecture requires drawing into a smaller logical surface and then scaling the whole thing up to fill the window (a common pattern for retro pixel games), draw everything except text into the small surface, scale it up, and then render text directly to the final window surface at the high resolution.

logical_surface = pygame.Surface((320, 180))
window = pygame.display.set_mode((1280, 720))
scale = 4

while running:
    logical_surface.fill((20, 20, 30))
    draw_world(logical_surface)  # sprites, tiles, etc

    # Scale the world up
    pygame.transform.scale(logical_surface, window.get_size(), window)

    # Render text directly to the high-res window surface
    text_surf, _ = font.render("HP: 100", (255, 255, 255), size=32)
    window.blit(text_surf, (32, 32))

    pygame.display.flip()

This gives you the chunky pixel art look for the world and crisp modern text for the HUD. Best of both worlds.

Verifying the Fix

Render the same string at three sizes — 16, 24, and 48 — and blit them stacked vertically. With the bug (scaling pre-rendered text), the larger sizes are visibly blurrier than the smaller ones. With the fix, all three are equally sharp because each was rasterized fresh at its target size.

“A pixel-perfect game can have crisp UI text. The trick is keeping the two render paths separate — one for the world that gets nearest-neighbor scaled, and one for text that goes straight to the high-res surface.”

Related Issues

For broader Pygame performance work, see Pygame performance tips for indie developers. If your sprites are blurry as well as your text, that is a separate issue tied to convert_alpha — covered in the same article. For sprite collision problems unrelated to rendering, see Pygame sprite collision not detected between groups.

If you must use pygame.font for legacy reasons, instantiate a new Font object for each pixel size you need. Different sizes mean different objects.