How to Create a Sprite Sheet for Your Game
A well-made sprite sheet is the foundation of every 2D web game's animation system. Getting the process right saves file size, prevents rendering bugs, and makes adding new animations straightforward throughout development.
Step 1: Plan Your Animations
Before drawing a single pixel, list every animation state your character needs. A typical platformer character needs: idle (4 to 6 frames), walk (6 to 8 frames), run (6 to 8 frames), jump ascent (2 to 3 frames), fall (2 to 3 frames), land (2 frames), attack (5 to 8 frames), hurt (3 to 4 frames), and death (6 to 8 frames). That is 9 animation states totaling 36 to 52 frames. Write this list out before starting because adding animation states after the sheet is packed means repacking everything.
Decide on the frame resolution based on your game's display size and target devices. If the character appears on screen at 32x32 pixels on a standard display, draw at 32x32 for crisp pixel art or 64x64 for 2x retina support. Drawing at 128x128 when the character displays at 32x32 wastes 75% of the texture memory for no visible benefit. Match the resolution to the display size, accounting for the highest-DPI display you want to support.
Establish the animation frame rate. Pixel art games typically animate at 8 to 12 frames per second. Higher-resolution art can go to 15 to 24 fps. This frame rate is independent of the game's render rate (which should be 60fps). Faster animation frame rates require more frames per animation to fill the same duration, which increases sprite sheet size. For web games, 10 to 12fps is the sweet spot for pixel art: smooth enough to look good, sparse enough to keep sheets compact.
Step 2: Draw the Frames
Aseprite ($20 on Steam or compiled free from source) is the standard tool for pixel art sprite animation. It provides onion skinning (seeing the previous and next frames as translucent overlays), animation timeline management, layer support, and direct sprite sheet export. Each animation state gets its own tag in the timeline, making it easy to manage dozens of animations in a single Aseprite file.
Maintain a consistent canvas size across all frames. Every frame should use the same width and height, even if some frames do not fill the entire canvas. A walking frame where the character leans forward and an idle frame where the character stands upright should both be the same canvas dimensions, with the character positioned relative to a fixed origin point (typically bottom-center for platformer characters, center-center for top-down games). Inconsistent canvas sizes cause the character to visually shift position when switching animations.
Draw key poses first, then fill in the in-between frames. For a walk cycle, start with the two contact poses (feet fully extended) and the two passing poses (one leg straight, the other bent passing). These four key poses establish the motion. Then add intermediate frames between each key pose to smooth out the movement. This approach, called pose-to-pose animation, produces more consistent results than drawing each frame sequentially.
Use reference. Study walk cycles, run cycles, and attack animations from games with similar art styles. SlowMotion on YouTube provides frame-by-frame breakdowns of classic game animations. Spriter's Resource archives thousands of sprite sheets from commercial games. Analyzing existing animations tells you how many frames professional animators use for each action and what key poses they hit.
Step 3: Export Individual Frames
Export each frame as a separate PNG file with a naming convention that the packing tool can parse. The standard convention is {animation_name}_{frame_number}.png: idle_00.png, idle_01.png, idle_02.png, walk_00.png, walk_01.png, and so on. Leading zeros ensure the files sort correctly (walk_09 before walk_10). Group files into folders by animation state if you prefer: idle/00.png, idle/01.png, walk/00.png. Both approaches work with major packing tools.
Aseprite can export frames directly from its File menu with the "Export Sprite Sheet" dialog, or you can export individual frames with File -> Export -> Sequence. For other tools (Photoshop, GIMP, Krita), export each frame layer or timeline frame as a separate file. Ensure the export uses the canvas size as the frame size, not the trimmed content size, because the packing tool handles trimming in the next step.
Keep the original Aseprite file (or PSD or XCF) as your source of truth. Individual PNGs are intermediary files for the packing step. When you need to modify an animation later, edit the source file and re-export, not the individual PNGs. This prevents inconsistencies between the source art and the exported frames.
Step 4: Pack into a Sprite Sheet
TexturePacker is the standard packing tool. Point it at your folder of individual frame PNGs, configure the output format (JSON Hash for Phaser, JSON Array for PixiJS, or generic JSON), set the packing algorithm to MaxRects (which produces the smallest sheets), enable trim (which removes transparent borders from each frame and records the offset), and hit publish. The output is one PNG sprite sheet plus one JSON data file that maps each frame name to its position, size, and trim offset on the sheet.
Free alternatives include Free Texture Packer (open source, similar UI to TexturePacker), ShoeBox (free, Adobe Air based), and Spritesheet.js (command line, Node.js). These tools produce equivalent output for basic sprite sheets. TexturePacker's advantages are its multipack feature (automatically splitting oversized sheets into multiple files), its polygon trimming (which trims to the sprite's actual outline rather than its rectangular bounding box, saving additional space), and its integrated WebP/AVIF compression.
Set the maximum texture size to 2048x2048 for mobile compatibility. If your sprite sheet exceeds this, either reduce frame resolution, reduce frame count, or use TexturePacker's multipack feature to split across multiple sheets. Mark which animations share a sheet so your game can batch-render sprites that use the same texture. Ideally, all animations for one character fit on a single 2048x2048 or smaller sheet.
Enable padding of 1 to 2 pixels between frames. Without padding, WebGL texture filtering can bleed pixels from adjacent frames into the current frame, producing thin lines of wrong-color pixels at the edges of sprites. This bleeding is especially visible during scaling or rotation. One pixel of transparent padding between frames eliminates the problem entirely.
Step 5: Optimize for Web
Convert the PNG sprite sheet to WebP for a 25% to 40% file size reduction with minimal quality loss. All modern browsers support WebP including Safari 14+. Use a fallback PNG for any remaining browsers that do not support WebP. TexturePacker can export WebP directly. For manual conversion, cwebp (Google's WebP encoder) with quality 85 to 90 produces excellent results for game sprites. Compare the original and converted versions at actual game display size to verify no visible artifacts.
Verify the sprite sheet dimensions are within your target devices' texture size limits. On mobile WebGL, check that the sheet fits within 2048x2048 (or 4096x4096 for newer devices). An oversized texture causes silent quality degradation or rendering failure on constrained devices. If your game targets budget Android phones, test on those devices specifically because their WebGL texture limits are the lowest.
Check the final file size against your loading budget. A character sprite sheet for a web game should typically be 50KB to 200KB (WebP). A full game with 5 characters, environment tiles, effects, and UI elements should aim for under 2MB total sprite data. These numbers vary by game complexity, but keeping the total under 3MB ensures sub-3-second load times on average mobile connections.
Step 6: Load and Test in Your Engine
In Phaser, load the sprite sheet with this.load.atlas('player', 'player.webp', 'player.json') in the preload function. Then create animations: this.anims.create({ key: 'walk', frames: this.anims.generateFrameNames('player', { prefix: 'walk_', start: 0, end: 7, zeroPad: 2 }), frameRate: 12, repeat: -1 }). Play the animation with sprite.play('walk'). Phaser handles frame timing, looping, and completion callbacks automatically.
In PixiJS, load the sprite sheet with Assets.load('player.json'), then create an AnimatedSprite from the parsed frames: const frames = spriteSheet.animations['walk']; const character = new AnimatedSprite(frames); character.animationSpeed = 0.2; character.play(). PixiJS's animationSpeed is a multiplier relative to the ticker speed, so 0.2 at a 60fps ticker plays at 12fps. Adjust to match your intended animation frame rate.
Test three things after loading: frame alignment (does the character stay in position during animation changes, or does it jump?), frame timing (does the walk cycle speed match the character's movement speed?), and edge rendering (do frames show bleeding from adjacent sheet frames?). Frame alignment issues mean the origin point is inconsistent across animations. Timing issues mean the animation framerate does not match the gameplay speed. Bleeding means the sheet needs more inter-frame padding.
The sprite sheet workflow is: plan animations and frame counts, draw frames in Aseprite with consistent canvas size and origin, export as numbered PNGs, pack with TexturePacker using MaxRects and trim, convert to WebP, and load the atlas JSON in your game engine. Keep sheets under 2048x2048 for mobile, add 1-2 pixels of padding between frames, and target 50KB to 200KB per character sheet for fast web loading.