You just loaded your carefully crafted 16x16 sprite into LÖVE, scaled it up, and... it's a blurry mess. Every edge is smoothed out, every pixel bleeds into its neighbor, and your retro game looks like someone smeared vaseline on the screen.
I've watched this exact moment of frustration play out in every LÖVE Discord channel and forum for years. It's probably the single most common gotcha for developers picking up the framework. The good news: it's a simple fix once you understand why it happens.
The Root Cause: Texture Filtering
LÖVE uses linear filtering by default when rendering images. Linear filtering interpolates between neighboring pixels when an image is scaled, which produces smooth results for photographs and high-resolution textures. For pixel art? It's a disaster.
When you scale a tiny sprite to 4x its original size, linear filtering averages the color values between adjacent pixels, creating that characteristic blur. Your crisp one-pixel border becomes a gradient. Your hard-edged character turns into mush.
Here's what's happening under the hood. When LÖVE draws a scaled image, OpenGL (or whichever backend you're using) samples the texture. With linear filtering, it grabs the four nearest texels and blends them together based on distance. That's great for realistic textures. It's terrible for art where every single pixel is intentional.
Step 1: Set the Default Filter to Nearest
The most important fix is one line at the top of your conf.lua or early in love.load:
function love.load()
-- "nearest" tells the GPU to snap to the closest pixel
-- instead of blending between neighbors
love.graphics.setDefaultFilter("nearest", "nearest")
-- Load your sprites AFTER setting the filter
playerSprite = love.graphics.newImage("assets/player.png")
endThe two arguments are the magnification filter and the minification filter. Setting both to "nearest" ensures your pixel art stays crisp whether you're scaling up or down.
If you already loaded an image and don't want to restructure your code, you can set the filter on individual images:
-- Fix filtering on an already-loaded image
playerSprite:setFilter("nearest", "nearest")Step 2: Handle Window Scaling Properly
Setting the filter fixes blurry textures, but most pixel art games need to scale the entire game to fill a modern monitor. If your game runs at 320x180 and you're drawing to a 1920x1080 window, you need a scaling strategy.
The naive approach — just multiplying your draw coordinates — works but gets messy fast. A cleaner solution is rendering to a canvas at your game's native resolution, then drawing that canvas scaled up:
local GAME_WIDTH = 320
local GAME_HEIGHT = 180
local canvas
function love.load()
love.graphics.setDefaultFilter("nearest", "nearest")
-- Create a canvas at the game's native resolution
canvas = love.graphics.newCanvas(GAME_WIDTH, GAME_HEIGHT)
love.window.setMode(1280, 720, {resizable = true})
end
function love.draw()
-- Draw everything to the small canvas first
love.graphics.setCanvas(canvas)
love.graphics.clear()
-- All your normal drawing code goes here
love.graphics.draw(playerSprite, playerX, playerY)
love.graphics.draw(tileMap, 0, 0)
love.graphics.setCanvas() -- reset to the main screen
-- Calculate the scale to fill the window
local scaleX = love.graphics.getWidth() / GAME_WIDTH
local scaleY = love.graphics.getHeight() / GAME_HEIGHT
local scale = math.min(scaleX, scaleY) -- maintain aspect ratio
-- Center the scaled canvas
local offsetX = (love.graphics.getWidth() - GAME_WIDTH * scale) / 2
local offsetY = (love.graphics.getHeight() - GAME_HEIGHT * scale) / 2
love.graphics.draw(canvas, offsetX, offsetY, 0, scale, scale)
endThis approach gives you integer-like scaling with letterboxing. Your 320x180 game renders at native resolution on the canvas, and the canvas itself gets scaled up with nearest-neighbor filtering because we set that default filter earlier.
Step 3: Watch Out for Non-Integer Positions
Even with the right filter mode, you can still get visual artifacts if you draw sprites at fractional pixel positions. When your player is at position (10.7, 23.3) on a pixel-art canvas, the renderer has to figure out what to do with that sub-pixel offset. The result is usually subtle shimmer or inconsistent pixel sizes.
The fix is straightforward — round your draw positions:
function love.draw()
love.graphics.setCanvas(canvas)
love.graphics.clear()
-- Round positions to avoid sub-pixel rendering artifacts
local drawX = math.floor(playerX + 0.5)
local drawY = math.floor(playerY + 0.5)
love.graphics.draw(playerSprite, drawX, drawY)
love.graphics.setCanvas()
-- ... scale and draw canvas as before
endKeep your actual position values as floats for smooth movement calculations. Only round when drawing. This gives you smooth physics with crisp rendering.
Step 4: Fonts Need Love Too
Another place where blurriness sneaks in: fonts. If you're using LÖVE's default font or loading a TTF at a small size and scaling it, you'll get the same blur problem.
For pixel art games, use a bitmap font (BMFont format) or load an image font. LÖVE supports both natively:
-- Load a BMFont
local font = love.graphics.newFont("assets/pixelfont.fnt")
-- Or use an image font with a specific glyph string
local font = love.graphics.newImageFont(
"assets/font.png",
" abcdefghijklmnopqrstuvwxyz0123456789"
)
love.graphics.setFont(font)Bitmap fonts respect the nearest-neighbor filter and look exactly how you designed them at any scale.
Prevention Checklist
Here's the quick reference I keep in my project template:
- Set
love.graphics.setDefaultFilter("nearest", "nearest")before loading ANY assets. This is the single most impactful fix. - Render to a native-resolution canvas and scale the canvas to the window. Don't scale individual sprites by hand.
- Round draw positions to integers on your game canvas. Keep float positions for logic, integers for rendering.
- Use bitmap fonts instead of TTF fonts for pixel art projects.
- Set the filter in
conf.luaif you want to be absolutely safe about load order:
-- conf.lua
function love.conf(t)
t.window.title = "My Pixel Game"
t.window.width = 1280
t.window.height = 720
endThen make setDefaultFilter the very first call in love.load.
Why LÖVE Defaults to Linear
You might wonder why the framework doesn't just default to nearest-neighbor. Fair question. LÖVE is a general-purpose 2D framework, not strictly a pixel art engine. Plenty of LÖVE games use high-resolution assets, smooth gradients, and rotated sprites where linear filtering is clearly the right choice. The maintainers made a reasonable default — it just happens to be the wrong one for an extremely common use case.
There have been discussions about this in the LÖVE community for ages, but changing a default in an established framework is always a hard sell. Breaking existing projects that rely on the current behavior isn't worth it when the fix is a single function call.
Wrapping Up
The blurry pixel art problem in LÖVE comes down to texture filtering, and 90% of the time, one line of code fixes it. The remaining 10% is making sure your scaling pipeline and draw positions don't reintroduce artifacts.
If you're starting a new pixel art project in LÖVE, set that filter first, set up a canvas-based scaling system early, and you'll never have to debug blurry sprites again. The LÖVE wiki at love2d.org/wiki has solid documentation on FilterMode and canvas rendering if you want to dig deeper into the specifics.
