Last month I was prototyping a 2D roguelike in Phaser. I had about 180 sprites — 16x16 tiles, a few 32x64 characters, UI buttons, particle effects. I loaded them individually because "I'll optimize later."
Later came fast. My laptop fan spun up like a jet engine. Chrome DevTools showed 174 draw calls per frame. Each sprite meant a texture swap on the GPU. Each swap meant the rendering pipeline stalled, waited, resumed. At 60fps that's 10,440 stalls per second.
The fix is obvious: pack everything into one sprite sheet. One texture bind, one draw call, done.
I opened TexturePacker. $39.99. For a hobby project. I tried the free alternatives — most were abandoned Java apps from 2014 or web tools that crashed on 50+ images. So I wrote my own.
The Problem Is Harder Than It Looks
Sprite sheet packing is a variant of 2D bin packing. You have N rectangles of different sizes. You need to fit them into the smallest possible bounding rectangle with no overlaps.
This is NP-hard. There is no efficient algorithm that guarantees the optimal solution. But there are heuristics that get close enough.
Three common ones:
Shelf packing — stack sprites in horizontal rows like books on a shelf. Dead simple. Wastes a ton of space because short sprites leave gaps above them. ~65% utilization on mixed-size sets.
Guillotine — place a sprite, then cut the remaining space with a single straight line (like a paper guillotine). Better than shelves, but the cuts fragment the free space into awkward slivers. ~75% utilization.
MaxRects — the one TexturePacker uses. Instead of cutting, it maintains a list of overlapping "maximum empty rectangles." When you place a sprite, every free rectangle that overlaps gets split into up to four new rectangles. Redundant ones get pruned. ~88% utilization on the same data set.
MaxRects wins. Let's build it.
MaxRects in 150 Lines
The entire algorithm fits in one file. Here's the core.
Data structure
We track free rectangles — empty areas where sprites can go. Initially there's one free rect covering the entire sheet.
let freeRects = [{ x: 0, y: 0, width: 2048, height: 2048 }];
Sorting matters
Before packing, sort sprites by their longest side, descending. This puts the big awkward pieces first when there's plenty of space, and leaves the small ones to fill gaps.
sprites.sort((a, b) => {
return Math.max(b.w, b.h) - Math.max(a.w, a.h);
});
Finding the best slot
For each sprite, scan every free rectangle. If the sprite fits, calculate how much leftover space there is on the short side and the long side. Pick the free rect with the smallest short-side leftover. This is called Best Short Side Fit (BSSF) — it minimizes wasted edges.
function findBestFit(width, height, freeRects) {
let best = null;
let bestShort = Infinity;
for (const rect of freeRects) {
// try unrotated
if (rect.width >= width && rect.height >= height) {
const leftH = rect.width - width;
const leftV = rect.height - height;
const shortFit = Math.min(leftH, leftV);
if (shortFit < bestShort) {
best = { x: rect.x, y: rect.y, w: width, h: height, rotated: false };
bestShort = shortFit;
}
}
// try rotated 90°
if (rect.width >= height && rect.height >= width) {
const leftH = rect.width - height;
const leftV = rect.height - width;
const shortFit = Math.min(leftH, leftV);
if (shortFit < bestShort) {
best = { x: rect.x, y: rect.y, w: height, h: width, rotated: true };
bestShort = shortFit;
}
}
}
return best;
}
Rotation support is two extra lines. Try both orientations, keep whichever scores better.
Splitting free rects
This is where MaxRects gets interesting. When you place a sprite, you don't just remove one free rectangle — you check every free rect that overlaps the placed sprite and split each one into up to four new rects (top, bottom, left, right of the placed sprite).
function splitFreeRect(freeRect, placed) {
const splits = [];
// top remainder
if (placed.y > freeRect.y) {
splits.push({
x: freeRect.x, y: freeRect.y,
width: freeRect.width,
height: placed.y - freeRect.y
});
}
// bottom remainder
const placedBottom = placed.y + placed.h;
const freeBottom = freeRect.y + freeRect.height;
if (placedBottom < freeBottom) {
splits.push({
x: freeRect.x, y: placedBottom,
width: freeRect.width,
height: freeBottom - placedBottom
});
}
// left remainder
if (placed.x > freeRect.x) {
splits.push({
x: freeRect.x, y: freeRect.y,
width: placed.x - freeRect.x,
height: freeRect.height
});
}
// right remainder
const placedRight = placed.x + placed.w;
const freeRight = freeRect.x + freeRect.width;
if (placedRight < freeRight) {
splits.push({
x: placedRight, y: freeRect.y,
width: freeRight - placedRight,
height: freeRect.height
});
}
return splits;
}
These split rects overlap on purpose. That's the key insight — overlapping free rects give the algorithm more placement options. After splitting, you prune: if rect A is entirely inside rect B, throw away A.
function prune(rects) {
return rects.filter((a, i) => {
return !rects.some((b, j) => {
if (i === j) return false;
return a.x >= b.x && a.y >= b.y &&
a.x + a.width <= b.x + b.width &&
a.y + a.height <= b.y + b.height;
});
});
}
Auto-growing
If sprites don't fit at the current size, double the smaller dimension and retry. Start small (256x256), grow until everything fits or you hit the max (4096x4096).
let w = 256, h = 256;
while (!packed && w <= 4096) {
packed = tryPack(sprites, w, h);
if (!packed) {
if (w <= h) w *= 2;
else h *= 2;
}
}
Compositing the output
Once the algorithm assigns (x, y) to every sprite, compositing is straightforward. Create a blank transparent image, paste each sprite at its coordinates.
const Jimp = require('jimp');
async function compose(width, height, sprites) {
const sheet = new Jimp(width, height, 0x00000000);
for (const s of sprites) {
const img = await Jimp.read(s.filePath);
if (s.rotated) img.rotate(90, false);
sheet.composite(img, s.x, s.y);
}
await sheet.writeAsync('spritesheet.png');
}
Then emit a JSON atlas so your engine knows where each frame lives:
{
"frames": {
"player_idle_01": {
"frame": { "x": 0, "y": 0, "w": 32, "h": 64 },
"rotated": false,
"sourceSize": { "w": 32, "h": 64 }
}
},
"meta": {
"image": "spritesheet.png",
"size": { "w": 1024, "h": 512 }
}
}
Phaser and PixiJS both load this format natively. Drop the PNG + JSON into your project and you're done.
Results
I ran the packer on my roguelike assets. 187 sprites. Mixed sizes from 8x8 to 128x96.
## Release v1.0.0 – First Stable Build
✅ **Free, open‑source CLI** that packs a folder of PNG sprites into a single, optimized texture atlas.
### Key features
- **MaxRects BSSF** algorithm (~89 % space utilization)
- **--trim** – removes transparent pixels (saves 10‑15 % space)
- **--rotate** – rotates sprites 90° when it improves packing
- **Auto‑grow** – expands canvas up to 4096 × 4096 px
- **Watch mode** (`--watch`) – automatically repacks when source files change
- **Power‑of‑Two** (`--pot`) – GPU‑friendly texture dimensions
- **Multiple output formats** – generic JSON, Phaser, PixiJS
### Installation
bash
npm (requires Node 18+)
npm i -g packsprite
Stand‑alone Windows binary (no Node.js needed)
Download packsprite.exe (Windows)
### Build from source
bash
git clone https://github.com/nicejundev/packsprite.git
cd packsprite
npm install
npm run build # → dist/packsprite.exe
### Why this exists?
TexturePacker costs **$40**, and the free alternatives are outdated Java tools. `packsprite` provides the same MaxRects packing quality for **zero cost**, perfect for indie game developers.
📦 **Full release notes** – see the GitHub release page:
[https://github.com/nicejundev/packsprite/releases/tag/v1.0.0](https://github.com/nicejundev/packsprite/releases/tag/v1.0.0)
plaintext
- Shelf packing: needed 2048x1024. 62% utilization.
- Guillotine: 1024x1024. 77% utilization.
I Packaged It
I turned this into a CLI tool called packsprite so I wouldn't have to copy-paste the algorithm into every project.
npx packsprite ./sprites -o ./build --trim --format phaser
It reads a folder of PNGs, runs MaxRects, outputs a sprite sheet + atlas JSON. Supports Phaser, PixiJS, and generic formats. Has --trim to strip transparent pixels before packing (saves another 10-15% atlas space), --watch to repack on file changes, and --pot for power-of-two textures.
nicejundev
/
packsprite
Free CLI sprite sheet packer with MaxRects bin packing. TexturePacker alternative for indie gamedevs.
📦 packsprite
Free CLI sprite sheet packer for game developers.
Pack hundreds of sprites into a single optimized texture atlas. One command. No GUI. No license key.
Why
TexturePacker costs $40. Free alternatives are abandoned Java apps from 2014. packsprite uses the same MaxRects BSSF algorithm as TexturePacker, runs from the terminal, and costs nothing.
Features
- MaxRects Best Short Side Fit — ~89% atlas space utilization
- Trim — strips transparent pixels before packing, saves 10-15% space
- Rotation — rotates sprites 90° when it saves space
- Auto-grow — starts small, expands until everything fits
- Watch mode — repacks automatically when source files change
- Power-of-two — optional POT texture dimensions for GPU compatibility
- Multiple formats — JSON (generic), Phaser, PixiJS
Install
npm (requires Node.js 18+)
npm install -g packsprite
Standalone exe (no Node.js required)
Download packsprite.exe from Releases.
Usage
# Basic — pack all PNGs in ./sprites, output to ./output
packsprite…It's free. It's one command. It replaced a $40 tool in my workflow.
What I Learned
Bin packing is one of those problems that sounds academic until your game stutters at 45fps on a laptop. The algorithm is ~150 lines. The hard part isn't the code — it's knowing the problem exists in the first place.
If your 2D game loads sprites individually, open your dev tools right now and count the draw calls. If that number is anywhere near your sprite count, you need a texture atlas. Build one yourself or grab packsprite — either way, your GPU will stop fighting you.
Top comments (0)