<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:content="http://purl.org/rss/1.0/modules/content/">
  <channel>
    <title>SpritePilot Blog</title>
    <link>https://spritepilot.com/blog</link>
    <atom:link href="https://spritepilot.com/blog/rss.xml" rel="self" type="application/rss+xml" />
    <description>Practical articles on sprite sheets, pixel-art animation, and getting 2D art into Unity, Godot, and Phaser — with code samples and examples made in SpritePilot.</description>
    <language>en</language>
    <lastBuildDate>Wed, 14 Oct 2026 12:00:00 GMT</lastBuildDate>
    <item>
      <title>Free Animated Pickup Sprites: Coin, Gem and Heart (CC0)</title>
      <link>https://spritepilot.com/blog/free-animated-pickup-sprites</link>
      <guid isPermaLink="true">https://spritepilot.com/blog/free-animated-pickup-sprites</guid>
      <pubDate>Wed, 14 Oct 2026 12:00:00 GMT</pubDate>
      <description>Three matching 32 × 32 animated pickups — a spinning coin, a shining gem and a beating heart — free under CC0, with an atlas and setup for Godot, Unity and Phaser.</description>
      <category>pixel art</category>
      <category>animation</category>
      <category>sprite sheets</category>
      <content:encoded><![CDATA[<p>Almost every game needs something to pick up, and placeholder squares make a prototype hard to judge. Here are three 32 × 32 pixel-art pickups — a spinning coin, a shining gem and a beating heart — drawn to match each other and free to use in any project. Below you&#39;ll find what&#39;s in the pack, how to use it in Godot 4, Unity and Phaser, and how the animations were designed, so you can adapt them or draw your own.</p>
<p><figure><img src="https://spritepilot.com/blog/free-animated-pickup-sprites/pickups-preview.gif" alt="The coin spinning, the gem shining with a sparkle, and the heart beating, side by side" width="532" height="190" loading="lazy" decoding="async"><figcaption>The three pickups at 4×. The coin runs at 10 fps; the gem holds its first frame for 0.9 s and the heart for 0.3 s, then both play at 10 fps.</figcaption></figure></p>
<h2 id="whats-in-the-pack">What&#39;s in the pack</h2>
<table>
<thead>
<tr>
<th>File</th>
<th>Frames</th>
<th>Size</th>
<th>Suggested timing</th>
</tr>
</thead>
<tbody><tr>
<td><a href="https://spritepilot.com/blog/free-animated-pickup-sprites/coin-spin-strip.png">coin-spin-strip.png</a></td>
<td>8</td>
<td>256 × 32</td>
<td>10 fps</td>
</tr>
<tr>
<td><a href="https://spritepilot.com/blog/free-animated-pickup-sprites/gem-shine-strip.png">gem-shine-strip.png</a></td>
<td>8</td>
<td>256 × 32</td>
<td>10 fps, frame 0 held 0.6–1 s</td>
</tr>
<tr>
<td><a href="https://spritepilot.com/blog/free-animated-pickup-sprites/heart-pulse-strip.png">heart-pulse-strip.png</a></td>
<td>6</td>
<td>192 × 32</td>
<td>10 fps, frame 0 optionally held 0.3 s</td>
</tr>
<tr>
<td><a href="https://spritepilot.com/blog/free-animated-pickup-sprites/sprite-sheet.png">sprite-sheet.png</a> + <a href="https://spritepilot.com/blog/free-animated-pickup-sprites/sprite-sheet.json">sprite-sheet.json</a></td>
<td>22</td>
<td>704 × 32</td>
<td>all three, with an atlas file</td>
</tr>
</tbody></table>
<ul>
<li>Every frame is 32 × 32 pixels on a transparent background, with no margin or spacing between frames. The art is at most 25 pixels across and centered, so a pivot in the middle of the frame works for all three, and nothing touches the frame&#39;s edge.</li>
<li>The combined sheet has all 22 frames in one row — coin, then gem, then heart — and a TexturePacker JSON (hash) file. Its frames are named <code>coin_00.png</code> to <code>coin_07.png</code>, <code>gem_00.png</code> to <code>gem_07.png</code> and <code>heart_00.png</code> to <code>heart_05.png</code>, each with a centered pivot, and three animation tags list the ranges: coin 0–7, gem 8–15, heart 16–21.</li>
<li>The whole pack uses 22 colors.</li>
</ul>
<h2 id="license-cc0">License: CC0</h2>
<p>All files are released under <a href="https://creativecommons.org/publicdomain/zero/1.0/" target="_blank" rel="noopener noreferrer">CC0 1.0</a>, a public domain dedication. You may copy, change and ship them in any project, free or commercial, &quot;all without asking permission&quot;. No credit is required. The deed adds two notes worth knowing: the work comes with no warranties, and you shouldn&#39;t suggest that the author endorses your project.</p>
<p>The coin, gem and heart are also part of the <a href="https://spritepilot.com/free-game-assets/pickups">Pickups Pack</a>, together with a key, a star, a potion, a treasure chest, a shield, a lightning bolt and an extra-life orb in the same style, each at 16 and 32 px.</p>
<h2 id="how-the-animations-were-designed">How the animations were designed</h2>
<p>Three pickups in one game should look like a set, and each should be recognizable in a fraction of a second. Three rules did most of the work.</p>
<p><figure><img src="https://spritepilot.com/blog/free-animated-pickup-sprites/pickup-design-notes.png" alt="The coin, gem and heart enlarged with an arrow showing light from the top-left, each with its color ramp and hex codes, a white silhouette, and the sprite at 1× and 2× on light and dark backgrounds" width="1536" height="784" loading="lazy" decoding="async"><figcaption>One light direction, one outline rule, and a hue-shifted ramp for each material. The silhouettes alone tell the three apart.</figcaption></figure></p>
<p><strong>One light, one outline.</strong> All three are lit from the top-left: highlights sit on the upper-left, shadows on the lower-right. Each has a one-pixel outline in a dark version of its own hue — brown for the coin, deep indigo for the gem, wine for the heart — instead of black, which separates the sprite from the background without making it look heavy.</p>
<p><strong>Hue-shifted ramps.</strong> Each material is a ramp from dark to light whose hue rotates as it brightens: the coin from 30° (brown) to 50° (pale yellow), the gem from 244° (indigo) to 188° (pale cyan), the heart from 332° (wine) through red to 25° (warm white). <a href="https://spritepilot.com/blog/pixel-art-color-palettes">Limited Color Palettes for Pixel Art</a> explains why that reads better than darkening one color.</p>
<p><strong>Shapes first.</strong> A round coin, a faceted gem and a heart are different even as flat silhouettes, so a player can tell them apart at 1× before any color registers. Inside each sprite, facets and shading are separated by value rather than by internal lines, which keeps 22 pixels from looking busy.</p>
<p><figure><img src="https://spritepilot.com/blog/free-animated-pickup-sprites/pickup-frames.png" alt="All 22 frames enlarged: the coin turning from face to edge and back, the gem's shine sweeping across before a sparkle, and the heart's beat, ease back, dip and rest" width="1536" height="750" loading="lazy" decoding="async"><figcaption>Every frame, labeled by what it does. Frame 0 of each strip is the resting pose.</figcaption></figure></p>
<h3 id="the-coin-a-turn-that-reads-as-rotation">The coin: a turn that reads as rotation</h3>
<p>The coin is the one from <a href="https://spritepilot.com/blog/sprite-sheet-frame-size">How to Find the Frame Size of a Sprite Sheet</a>. Its eight frames show half a turn: face-on, edge-on at frame 4, face-on again. Because both faces are drawn alike, the loop looks like a continuous spin. Its width across the eight frames is 22, 21, 17, 10, 4, 10, 17 and 21 pixels — barely changing near face-on and collapsing quickly toward the edge, the way a real turning disc does. Evenly shrinking widths would look like squashing instead of turning. The rim shows on one side as the coin turns away and on the other as it turns back.</p>
<h3 id="the-gem-the-light-moves-not-the-gem">The gem: the light moves, not the gem</h3>
<p>A gem that bobs or rotates competes with everything else on screen; a gem that catches the light only now and then draws the eye without noise. So the gem never moves. In frames 1 to 4, a diagonal band of light sweeps from its top-left corner to its bottom-right, brightening the pixels it crosses by up to two steps of the ramp. In frames 5 to 7, a four-pointed sparkle appears on the corner where the light hits first, grows and fades.</p>
<p>The timing matters more than the drawing. Played at an even 10 fps, the shine repeats every 0.8 seconds and reads as flicker. Held on frame 0 for 0.6 to 1 second, it becomes an event. Most engines can hold a single frame — see <a href="https://spritepilot.com/blog/pixel-art-frame-rate">How Fast Should Pixel Art Animate?</a> for each engine&#39;s setting.</p>
<h3 id="the-heart-a-beat-not-a-breath">The heart: a beat, not a breath</h3>
<p>The heart has six frames but only four drawings, 19, 21, 23 and 25 pixels wide. At rest it&#39;s 21. On frame 1 it jumps straight to 25 — the beat — then eases back through 23 and 21, dips to 19 and settles at 21 again. Rising in one frame and falling over three is what makes it feel like a thump rather than a slow breath. Frames 5 and 0 are the same drawing, a short hold built into the strip. At 10 fps one beat takes 0.6 seconds, 100 beats per minute; holding frame 0 for 0.3 seconds slows it to 0.8 seconds, 75 per minute.</p>
<p>Each size is drawn as its own shape, not scaled from one heart: enlarging a 21-pixel heart to 25 is a factor of about 1.19, and <a href="https://spritepilot.com/blog/scale-pixel-art-without-blur">scaling by fractions</a> makes some pixels wider than others. All four sizes have odd widths and share a center column, so the heart grows evenly and never shifts sideways.</p>
<h2 id="use-them-in-godot-4">Use them in Godot 4</h2>
<ol>
<li>Make sure textures use <strong>Nearest</strong> filtering (Project Settings → Rendering → Textures → Canvas Textures → Default Texture Filter), or the pixels will blur.</li>
<li>Add an <strong>AnimatedSprite2D</strong>, and in its SpriteFrames property select <strong>New SpriteFrames</strong>.</li>
<li>In the SpriteFrames panel, click <strong>Add frames from a Sprite Sheet</strong>, open a strip, set <strong>Horizontal</strong> to 8 (6 for the heart) and <strong>Vertical</strong> to 1, select all frames and add them.</li>
<li>Set <strong>Speed (FPS)</strong> to 10, then start the animation with the node&#39;s <strong>Autoplay</strong> property (the animation to play when the scene loads) or from code:</li>
</ol>
<pre><code class="language-gdscript">$Coin.play(&quot;default&quot;) # Coin is the AnimatedSprite2D node</code></pre>
<p>These steps follow Godot&#39;s own <a href="https://docs.godotengine.org/en/stable/tutorials/2d/2d_sprite_animation.html" target="_blank" rel="noopener noreferrer">2D sprite animation tutorial</a>. To skip them, load the frames into SpritePilot and download a Godot 4 SpriteFrames (.tres) file from <a href="https://spritepilot.com/export-download">Export &amp; Download</a> instead. The <a href="https://spritepilot.com/blog/godot-4-sprite-animation">Godot 4 sprite animation article</a> covers per-frame durations, which give the gem its hold.</p>
<h2 id="use-them-in-unity">Use them in Unity</h2>
<ol>
<li>Select a strip and set <strong>Texture Type</strong> to <strong>Sprite (2D and UI)</strong>, <strong>Sprite Mode</strong> to <strong>Multiple</strong>, <strong>Filter Mode</strong> to <strong>Point (no filter)</strong> and <strong>Compression</strong> to <strong>None</strong>, then click <strong>Apply</strong>.</li>
<li>Open the <strong>Sprite Editor</strong>, choose <strong>Slice</strong>, set the type to <strong>Grid By Cell Count</strong> with 8 columns (6 for the heart) and 1 row, click <strong>Slice</strong>, then <strong>Apply</strong>.</li>
<li>Select the sliced frames in the Project window and drag them into the Scene. <a href="https://learn.unity.com/tutorial/introduction-to-sprite-animations" target="_blank" rel="noopener noreferrer">Unity&#39;s sprite animation tutorial</a> describes how this creates an Animation Clip and adds an Animator for you.</li>
</ol>
<p>Timing and holds in Unity&#39;s Animation window are covered in <a href="https://spritepilot.com/blog/unity-sprite-sheet-animation">the Unity sprite animation article</a>.</p>
<h2 id="use-them-in-phaser">Use them in Phaser</h2>
<p>Load a strip as a sprite sheet, or the combined sheet as an atlas — Phaser&#39;s <a href="https://docs.phaser.io/api-documentation/class/loader-loaderplugin" target="_blank" rel="noopener noreferrer">loader</a> accepts TexturePacker&#39;s JSON Hash and JSON Array formats:</p>
<pre><code class="language-js">// in preload()
this.load.spritesheet('gem', 'gem-shine-strip.png', { frameWidth: 32, frameHeight: 32 })
this.load.atlas('pickups', 'sprite-sheet.png', 'sprite-sheet.json')

// in create()
const shine = this.anims.generateFrameNumbers('gem', { start: 0, end: 7 })
shine[0].duration = 900 // hold the resting frame for 0.9 s
this.anims.create({ key: 'gem-shine', frames: shine, frameRate: 10, repeat: -1 })

this.anims.create({
  key: 'heart-beat',
  frames: this.anims.generateFrameNames('pickups', { prefix: 'heart_', start: 0, end: 5, zeroPad: 2, suffix: '.png' }),
  frameRate: 10,
  repeat: -1,
})

this.add.sprite(100, 90, 'gem').play('gem-shine')
this.add.sprite(140, 90, 'pickups', 'heart_00.png').play('heart-beat')</code></pre>
<p>A frame&#39;s <code>duration</code>, in milliseconds, replaces the frame rate for that one frame. Keep <code>pixelArt: true</code> in the game config so the sprites stay sharp. More options are in <a href="https://spritepilot.com/blog/phaser-sprite-sheets-and-atlases">Phaser sprite sheets and atlases</a>.</p>
<h2 id="how-the-atlas-was-made">How the atlas was made</h2>
<p>The combined sheet and its JSON were exported by SpritePilot, and you can do the same with your own frames:</p>
<ol>
<li>The 22 frames were laid out in an 8 × 3 grid and cut with Grid Slice in <a href="https://spritepilot.com/sprite-detection">Sprite Detection</a>: 8 columns, 3 rows. <strong>Skip empty (fully transparent) cells</strong> dropped the two empty cells at the end of the heart row, leaving 22 sprites.</li>
<li>In <a href="https://spritepilot.com/export-download">Export &amp; Download</a>: <strong>Horizontal Strip</strong> layout, <strong>Trim transparency</strong> off, <strong>Padding</strong> 0, <strong>Extrude</strong> 0, TexturePacker metadata. The frames were renamed under <strong>Rename &amp; Reorder Sprites</strong> — those names go into the JSON — and the three ranges were added under <strong>Animation Tags</strong>.</li>
<li><strong>Download Sprite Sheet</strong> saved <code>sprite-sheet.png</code> and <code>sprite-sheet.json</code>. Compared with the three strips placed side by side, the exported sheet matched pixel for pixel.</li>
</ol>
<p>To try other speeds before you commit, load any strip into the <a href="https://spritepilot.com/sprite-player">Sprite Player</a> and drag the FPS slider while it plays; everything runs in your browser.</p>
]]></content:encoded>
    </item>
    <item>
      <title>GIF or Video to Sprite Sheet Without a Huge File</title>
      <link>https://spritepilot.com/blog/gif-video-to-sprite-sheet-file-size</link>
      <guid isPermaLink="true">https://spritepilot.com/blog/gif-video-to-sprite-sheet-file-size</guid>
      <pubDate>Wed, 14 Oct 2026 12:00:00 GMT</pubDate>
      <description>Three seconds of 1080p video can need 746 MB as one sprite sheet. The math behind sheet size, the settings that shrink it, and measured results.</description>
      <category>sprite sheets</category>
      <category>animation</category>
      <category>file formats</category>
      <content:encoded><![CDATA[<p>A three-second clip doesn&#39;t sound like much until it becomes a sprite sheet. At 1080p and 30 frames per second it&#39;s 90 full-HD images side by side, and once loaded that sheet needs about 746 MB of memory, far more than any one texture should take.</p>
<p>The size of a sheet is decided before you convert, by two numbers you choose: how many frames and how big each one is. The file size on disk then depends on what&#39;s in the frames. Here is the math, four ways to shrink a sheet, and what they did to real clips in SpritePilot.</p>
<h2 id="the-math-frames-frame-size-4-bytes">The math: frames × frame size × 4 bytes</h2>
<ul>
<li><strong>Frames</strong> = clip length × sampling rate. A 3-second clip sampled at 10 frames per second gives 30 frames.</li>
<li><strong>Pixels</strong> = frames × frame width × frame height.</li>
<li><strong>Memory</strong> = pixels × 4 bytes, one each for red, green, blue and alpha.</li>
</ul>
<p>Those 4 bytes are what an image costs once decoded, whatever its file size: a browser&#39;s <a href="https://developer.mozilla.org/en-US/docs/Web/API/ImageData/data" target="_blank" rel="noopener noreferrer">ImageData</a> holds four values per pixel, and Unity lists uncompressed RGBA textures at 32 bits per pixel. A sheet is stored on disk as a compressed PNG, but it&#39;s drawn from memory.</p>
<p>Worked through for that 3-second 1080p clip:</p>
<table>
<thead>
<tr>
<th>Sampling, frame size</th>
<th>Frames</th>
<th>Pixels</th>
<th>Memory</th>
</tr>
</thead>
<tbody><tr>
<td>30 fps, 1920 × 1080</td>
<td>90</td>
<td>186,624,000</td>
<td>746 MB</td>
</tr>
<tr>
<td>10 fps, 1440 × 810</td>
<td>30</td>
<td>34,992,000</td>
<td>140 MB</td>
</tr>
<tr>
<td>10 fps, 480 × 270</td>
<td>30</td>
<td>3,888,000</td>
<td>15.6 MB</td>
</tr>
</tbody></table>
<p>Halving the sampling rate halves the memory. Halving the frame width and height quarters it.</p>
<h2 id="four-ways-to-shrink-a-sheet">Four ways to shrink a sheet</h2>
<p><strong>1. Frame size.</strong> The strongest lever, because it works on both dimensions: 25% of the size is one sixteenth of the pixels. Pick the size the sprite will actually be drawn at in the game; anything bigger is memory the player never sees. For a screen recording of pixel art, choose the size that undoes the upscaling. A 1080p recording of a 480 × 270 pixel-art scene, converted at 25%, came back at its original resolution: all but one of its 3,888,000 pixels were within 6% of the original color, and the sheet had a ninth of the pixels of the 75% size that Auto suggested.</p>
<p><strong>2. Sampling rate.</strong> Game animations rarely need the video&#39;s full frame rate; 8 to 12 frames per second is a common range for sprites (see <a href="https://spritepilot.com/blog/pixel-art-frame-rate">How Fast Should Pixel Art Animate?</a>). Never sample faster than the video itself: a 10 fps test clip sampled at 30 fps gave 60 frames, but only 20 different pictures, each repeated three times.</p>
<p><strong>3. Clip length.</strong> Convert the one loop you need, not the whole recording. SpritePilot samples the entire clip, up to 300 frames, so trim the video first.</p>
<p><strong>4. Cropping.</strong> Most video frames are mostly background. Cutting the frame down to the subject before converting keeps its full resolution while dropping everything around it. This <a href="https://ffmpeg.org/ffmpeg-filters.html#crop" target="_blank" rel="noopener noreferrer">FFmpeg</a> command keeps 1.5 seconds starting at 0.6 s and crops a 960 × 960 square whose top-left corner is at x 440, y 60:</p>
<pre><code class="language-bash">ffmpeg -ss 0.6 -t 1.5 -i input.mp4 -vf &quot;crop=960:960:440:60&quot; -an clip.mp4</code></pre>
<p>Any video editor can do the same. Here&#39;s what these settings did to a 3-second 1080p clip of camera-like footage, all measured in SpritePilot&#39;s <a href="https://spritepilot.com/video-to-sprite-sheet">Video → Sprite Sheet</a>:</p>
<table>
<thead>
<tr>
<th>Settings</th>
<th>Frames</th>
<th>Sheet</th>
<th>Memory</th>
<th>PNG file</th>
</tr>
</thead>
<tbody><tr>
<td>30 fps, Auto (50%)</td>
<td>90 of 960 × 540</td>
<td>7680 × 6480</td>
<td>199 MB</td>
<td>91.2 MB</td>
</tr>
<tr>
<td>10 fps, Auto (75%)</td>
<td>30 of 1440 × 810</td>
<td>7200 × 4860</td>
<td>140 MB</td>
<td>68.8 MB</td>
</tr>
<tr>
<td>5 fps, Auto (100%)</td>
<td>15 of 1920 × 1080</td>
<td>7680 × 4320</td>
<td>133 MB</td>
<td>61.1 MB</td>
</tr>
<tr>
<td>10 fps, 25%</td>
<td>30 of 480 × 270</td>
<td>2880 × 1350</td>
<td>15.6 MB</td>
<td>7.1 MB</td>
</tr>
<tr>
<td>cropped to 960 × 960 and 1.5 s, 10 fps, Auto (100%)</td>
<td>15 of 960 × 960</td>
<td>3840 × 3840</td>
<td>59.0 MB</td>
<td>26.3 MB</td>
</tr>
</tbody></table>
<p>The cropped clip keeps the subject at full resolution and still needs less than half the memory of the full frame at 75%.</p>
<p><figure><img src="https://spritepilot.com/blog/gif-video-to-sprite-sheet-file-size/sheet-memory-by-sampling-and-frame-size.png" alt="Bar chart of memory for one 3-second 1080p clip: 746 MB at 30 fps and full size, refused; 199 MB at 30 fps Auto; 140 MB at 10 fps Auto; 133 MB at 5 fps Auto; 15.6 MB at 10 fps and 25%" width="1536" height="654" loading="lazy" decoding="async"><figcaption>Memory once loaded, set only by the sheet's width and height. Frame size moves it the most.</figcaption></figure></p>
<h2 id="sizes-engines-can-actually-load">Sizes engines can actually load</h2>
<p>SpritePilot&#39;s converters won&#39;t save a sheet wider or taller than 8192 pixels, because many engines and GPUs can&#39;t load bigger textures. Its video converter also refuses to extract more frame pixels than one 8192 × 8192 sheet holds, about 268 MB, and <strong>Auto</strong> frame size picks the largest size at which your frames fit:</p>
<p><figure><img src="https://spritepilot.com/blog/gif-video-to-sprite-sheet-file-size/spritepilot-video-memory-warning.png" alt="SpritePilot's Video to Sprite Sheet settings at 30 frames per second and 100%, showing 90 frames of 1920 × 1080 px and a warning that they would take about 746 MB of memory" width="1684" height="520" loading="lazy" decoding="async"><figcaption>The 746 MB case: the plan would be a 19200 × 9720 sheet, so SpritePilot asks for a smaller frame size or fewer frames per second.</figcaption></figure></p>
<p>8192 is a ceiling, not a target. Godot&#39;s <a href="https://docs.godotengine.org/en/stable/tutorials/assets_pipeline/importing_images.html" target="_blank" rel="noopener noreferrer">importing images documentation</a> says mobile and web platforms &quot;usually can&#39;t display textures larger than 4096×4096&quot;, and Unity scales any texture larger than its import <strong>Max Size</strong> down when it imports it. If you target phones or browsers, keep sheets at 4096 or below, or split the animation over several sheets. <a href="https://spritepilot.com/blog/sprite-sheet-size-limits">Sprite Sheet Size Limits</a> covers texture size limits in more depth.</p>
<h2 id="why-the-png-size-depends-on-the-picture">Why the PNG size depends on the picture</h2>
<p>Memory follows the pixel count, but the PNG file doesn&#39;t. Here are three clips with the same length, resolution and settings, so the same 7200 × 4860 sheet:</p>
<p><figure><img src="https://spritepilot.com/blog/gif-video-to-sprite-sheet-file-size/same-sheet-size-different-png-size.png" alt="Three clips converted to the same 7200 × 4860 sheet: each needs 140 MB in memory, but the PNG files are 0.67 MB for a test pattern, 3.00 MB for pixel art and 68.8 MB for camera-like footage" width="1536" height="820" loading="lazy" decoding="async"><figcaption>Same sheet size and memory; a hundredfold difference on disk.</figcaption></figure></p>
<p>PNG is lossless. As the <a href="https://www.w3.org/TR/png-3/" target="_blank" rel="noopener noreferrer">PNG specification</a> describes, it filters each row of pixels and then compresses the result with deflate, which works by finding repetition. Flat colors and transparent areas are almost pure repetition, so they nearly vanish. Gradients and grain never repeat exactly, so nearly every pixel costs bytes, and a lossless format has to keep all of them. That&#39;s why the camera-like clip is 100 times larger on disk than the test pattern while needing exactly the same memory.</p>
<p>Two practical consequences. A small PNG doesn&#39;t mean a light sheet: check width × height × 4. And for camera footage, shrinking the frame size cuts both numbers; in the table, 25% took the PNG from 68.8 MB to 7.1 MB. The sheets in these tests were all 32-bit RGBA PNGs, even from video, which has no transparency. For when a lossy format makes sense instead, see <a href="https://spritepilot.com/blog/png-webp-jpg-for-sprites">PNG, WebP or JPG for Sprites</a>.</p>
<h2 id="gifs-the-frames-are-already-chosen">GIFs: the frames are already chosen</h2>
<p>A GIF has its frame count and frame size baked in, so <a href="https://spritepilot.com/gif-to-sprite-sheet">GIF → Sprite Sheet</a> has no sampling rate or frame size to choose. It decodes up to 300 frames at the GIF&#39;s own size and keeps each frame&#39;s delay in the JSON. The levers are the column count, and editing the GIF itself before you convert.</p>
<p>The column count matters more than it looks. An 8-frame, 64 × 64 GIF gives a 3 × 3 grid by default, 192 × 192 pixels with one empty cell; 4 columns make a 256 × 128 sheet with none, 11% less memory. Pick a column count that divides the frame count when the sheet stays under the size limit.</p>
<p>Don&#39;t be surprised if the PNG is larger than the GIF. That GIF is 2,627 bytes and the sheet 6,272. A GIF stores each pixel as an index into a table of at most 256 colors, while the sheet stores full RGBA. Transparent backgrounds still compress well, and if the frames have empty borders, sending them to <a href="https://spritepilot.com/export-download">Export &amp; Download</a> and <a href="https://spritepilot.com/blog/trim-transparent-pixels">trimming the transparent pixels</a> shrinks the sheet further.</p>
<h2 id="a-checklist-before-you-convert">A checklist before you convert</h2>
<ol>
<li>Cut the clip to the loop you need, and crop it to the subject.</li>
<li>Sample at 8–12 fps, and never faster than the video&#39;s own frame rate.</li>
<li>Choose the frame size the sprite is drawn at in the game.</li>
<li>Estimate the memory: frames × width × height × 4 bytes.</li>
<li>Keep sheets at 4096 px or less for phones and browsers; SpritePilot stops at 8192.</li>
<li>Judge the PNG by the content: flat art compresses well, footage doesn&#39;t.</li>
</ol>
<p>SpritePilot&#39;s converters run in your browser, so nothing is uploaded, and the video converter&#39;s plan line shows the frame count, frame size and sheet size before you extract a single frame. For a walkthrough of the controls, see <a href="https://spritepilot.com/guides/video-to-sprite-sheet">How to Convert a Video to a Sprite Sheet</a>.</p>
]]></content:encoded>
    </item>
    <item>
      <title>Onion Skinning: Animate Pixel Art Without Guesswork</title>
      <link>https://spritepilot.com/blog/onion-skinning-pixel-art</link>
      <guid isPermaLink="true">https://spritepilot.com/blog/onion-skinning-pixel-art</guid>
      <pubDate>Wed, 14 Oct 2026 12:00:00 GMT</pubDate>
      <description>What onion skinning shows, a key-poses-first workflow for pixel animation, how to check arcs and spacing against the ghost, and the mistakes it can cause.</description>
      <category>pixel art</category>
      <category>animation</category>
      <content:encoded><![CDATA[<p>Drawing the second frame of an animation without seeing the first is guesswork. Where was the hand? How far did the ball move? Was the head this big? Guess a pixel wrong on every frame and the result wobbles: shapes swell and shrink, and motion speeds up and slows down for no reason. Onion skinning replaces the guessing with a reference you can see while you draw.</p>
<h2 id="what-onion-skinning-shows">What onion skinning shows</h2>
<p>Onion skinning displays one or more neighboring frames faintly behind the frame you&#39;re editing. The name comes from traditional animation, where frames were drawn on thin, translucent onionskin paper over a light, so the animator could see the drawings underneath (<a href="https://en.wikipedia.org/wiki/Onion_skinning" target="_blank" rel="noopener noreferrer">Wikipedia</a>).</p>
<p>The ghost answers three questions while you draw:</p>
<ul>
<li><strong>Position:</strong> where each part was on the previous frame.</li>
<li><strong>Spacing:</strong> how far it moved since then. Gaps that grow mean speeding up; gaps that shrink mean slowing down.</li>
<li><strong>Volume:</strong> whether the shape is still the same size.</li>
</ul>
<p>It does not tell you <strong>timing</strong>, how long each frame stays on screen. For that you have to play the animation.</p>
<h2 id="spritepilots-onion-skin-exactly">SpritePilot&#39;s onion skin, exactly</h2>
<p>In SpritePilot&#39;s <a href="https://spritepilot.com/pixel-art-editor">Pixel Art Editor</a>, the ghost button in the toolbar turns onion skin on and off:</p>
<p><figure><img src="https://spritepilot.com/blog/onion-skinning-pixel-art/pixel-art-editor-onion-skin.png" alt="SpritePilot's Pixel Art Editor with the onion skin button on: frame 3 of 8 shows a pendulum hanging straight down, with a faint copy of frame 2, swung to the right, behind it" width="1888" height="1850" loading="lazy" decoding="async"><figcaption>Frame 3 of an 8-frame pendulum swing, with frame 2 showing through at 35% opacity.</figcaption></figure></p>
<p>Exactly what it does:</p>
<ul>
<li>It shows the <strong>previous frame only</strong>, at <strong>35% opacity</strong>, in its own colors. There&#39;s no tint, so a faded yellow can look olive against the checkerboard.</li>
<li>The ghost sits <strong>under</strong> the current frame: wherever you&#39;ve drawn a pixel, the pixel hides the ghost.</li>
<li><strong>Frame 1 has no ghost.</strong> The editor doesn&#39;t wrap around to show the last frame under the first.</li>
<li>The ghost is <strong>display only</strong>. It is never saved into a frame or an export, the eyedropper ignores it, and the fill tool doesn&#39;t treat it as a boundary: filling inside a ghost outline fills every connected empty pixel of the frame.</li>
<li>The setting is remembered for your next visit, like the editor&#39;s other options.</li>
</ul>
<p>Other editors offer more. In Aseprite, for example, F3 toggles onion skinning, and the timeline&#39;s configuration sets how many previous and next frames to show and can tint them red and blue (<a href="https://www.aseprite.org/docs/onion-skinning/" target="_blank" rel="noopener noreferrer">Aseprite docs</a>). The workflow below works with either.</p>
<h2 id="draw-the-keys-first-then-the-frames-between">Draw the keys first, then the frames between</h2>
<p>Onion skin is most useful when you draw the important poses first and fill in the rest, instead of drawing frame after frame from the start and hoping the end lands in the right place:</p>
<ol>
<li><strong>Draw the key poses.</strong> For the pendulum these are the two ends of the swing: frame 1 at 34° to the right and frame 5 at 34° to the left. For a walk they&#39;re the contact poses; for a jump, the crouch, the peak and the landing.</li>
<li><strong>Add the breakdowns,</strong> the main drawings between the keys. Here that&#39;s frame 3, hanging straight down, halfway through the swing.</li>
<li><strong>Fill the in-betweens.</strong> Select a key, press <strong>Duplicate frame</strong> to copy it in after itself, and move only what changes, with the previous frame showing underneath.</li>
<li><strong>Flip through the frames</strong> with onion skin on and watch how each one relates to the one before it:</li>
</ol>
<p><figure><img src="https://spritepilot.com/blog/onion-skinning-pixel-art/onion-skin-stepping-through-frames.gif" alt="A pendulum stepping through its 8 frames one at a time; from frame 2 on, the previous frame shows faintly underneath, and frame 1 has none" width="236" height="252" loading="lazy" decoding="async"><figcaption>Stepping through the swing as the editor shows it: each frame over a 35% ghost of the one before, and no ghost on frame 1.</figcaption></figure></p>
<p>An in-between depends on the frames on <em>both</em> sides, but SpritePilot only ghosts the previous one. To check the next frame, use the move buttons above the frame strip. Say frame 2 sits between frames 1 and 3: select frame 2 and press <strong>Move frame right</strong>. Frames 2 and 3 swap places, your frame stays selected, and frame 3 is now the ghost underneath. Compare, then press <strong>Move frame left</strong> to put it back. Adding, duplicating, moving or deleting a frame clears the undo history, so finish a correction before you rearrange.</p>
<h2 id="check-arcs-and-spacing">Check arcs and spacing</h2>
<p>Two things often go wrong between key poses, and the ghost shows both.</p>
<p><strong>Arcs.</strong> Most natural motion follows curved paths, a point Disney&#39;s animation principles make explicitly (<a href="https://en.wikipedia.org/wiki/Twelve_basic_principles_of_animation" target="_blank" rel="noopener noreferrer">twelve principles</a>). Arms swing from the shoulder, heads turn on the neck, a pendulum swings from its pivot. The tempting in-between is the one halfway along the straight line between the two keys. It&#39;s easy to place and it&#39;s wrong:</p>
<p><figure><img src="https://spritepilot.com/blog/onion-skinning-pixel-art/onion-skin-arcs-and-spacing.png" alt="Three views of a pendulum: an in-between placed halfway along the straight line between the two end keys makes the rod shorter; one placed on the arc keeps the rod's length; and the five frames of a half swing sit along the arc, closer together near the ends" width="1536" height="588" loading="lazy" decoding="async"><figcaption>Left: halfway along the straight line, the rod shrinks from 27 to 22.4 px. Middle: on the arc it keeps its length. Right: frames 1 to 5 of the swing, bunched where it slows down.</figcaption></figure></p>
<p>With the two keys ghosted, the straight-line in-between puts the bob 22.4 pixels from the pivot instead of 27, and the rod visibly shrinks in the middle of every swing. On the arc, the rod keeps its length. The same mistake shortens arms and legs in character animation. Before drawing an in-between, find the pivot, such as the shoulder for an arm or the hip for a leg, and keep the distance from it.</p>
<p><strong>Spacing.</strong> The right-hand panel shows where the bob sits in frames 1 to 5. The gaps between them are 4.7, 11.2, 11.2 and 4.7 pixels: small at the ends, where a pendulum slows down to turn, and large through the middle, where it&#39;s fastest. The angles come from 34° × cos(k × 45°), with k running from 0 to 7 for frames 1 to 8, which produces exactly that easing. With onion skin on you can see the gap to the previous frame directly, so uneven spacing is easy to spot: a gap that is suddenly bigger or smaller than its neighbors is a lurch in the motion.</p>
<h2 id="mistakes-that-onion-skinning-can-cause">Mistakes that onion skinning can cause</h2>
<ul>
<li><strong>Tracing the ghost.</strong> Copying the previous frame&#39;s outline and nudging it tends to give stiff motion, and small tracing differences from frame to frame make edges flicker. Use the ghost to place things, then draw the shape the way it should look in this pose.</li>
<li><strong>Drifting volume.</strong> If every frame is drawn only against the one before it, a one-pixel error on each frame adds up: by frame 8 the head can be two pixels smaller than on frame 1. Count the pixels of something that shouldn&#39;t change, such as head width or the size of a ball, on the first and last frames.</li>
<li><strong>Mistaking the ghost for real pixels.</strong> At 35% the ghost can look like part of the drawing. Toggle onion skin off now and then to see what&#39;s actually on the frame.</li>
<li><strong>Judging timing from the ghost.</strong> Even spacing on the canvas can still play too fast or too slow. Press <strong>Play</strong> in the Preview panel and adjust the speed. For choosing a frame rate, see <a href="https://spritepilot.com/blog/pixel-art-frame-rate">How Fast Should Pixel Art Animate?</a></li>
</ul>
<h2 id="try-it">Try it</h2>
<p>Open the <a href="https://spritepilot.com/pixel-art-editor">Pixel Art Editor</a>, start a new drawing with the <strong>32×32</strong> button (or type 48 into W and H for more room), and animate something simple with an arc: a pendulum, a waving arm or a bouncing ball. Draw the two ends of the motion first, add the middle drawing between them, then fill the rest with onion skin on. Play the preview at 8 to 12 FPS and fix whatever lurches. The drawing saves itself in your browser, and <strong>Download GIF</strong> or <strong>Download Sheet PNG</strong> exports it. The <a href="https://spritepilot.com/guides/draw-your-own-sprite">drawing guide</a> covers the editor&#39;s tools, and the same approach applies to walks (<a href="https://spritepilot.com/blog/walk-cycle-frames">How Many Frames Does a Walk Cycle Need?</a>) and bounces (<a href="https://spritepilot.com/blog/squash-and-stretch-sprites">Squash and Stretch for Sprite Animation</a>).</p>
]]></content:encoded>
    </item>
    <item>
      <title>Limited Color Palettes for Pixel Art: Why They Help and How to Pick One</title>
      <link>https://spritepilot.com/blog/pixel-art-color-palettes</link>
      <guid isPermaLink="true">https://spritepilot.com/blog/pixel-art-color-palettes</guid>
      <pubDate>Wed, 14 Oct 2026 12:00:00 GMT</pubDate>
      <description>Why a small palette makes pixel art read better, how to build hue-shifted color ramps, check them in grayscale, and start from palettes like DB32.</description>
      <category>pixel art</category>
      <category>basics</category>
      <content:encoded><![CDATA[<p>Pick every color from a full color picker and your sprites slowly drift apart: three slightly different browns for wood, shadows that turn muddy, a highlight that matches nothing else on screen. A limited palette — a fixed set of 16 or 32 colors you draw everything with — stops that before it starts. Here&#39;s why the limit helps, how to build color ramps that shade well, how to check them, and where to find a good palette to start from.</p>
<h2 id="why-a-small-palette-makes-better-pixel-art">Why a small palette makes better pixel art</h2>
<p>A palette isn&#39;t a restriction you put up with. It does three jobs for you:</p>
<ul>
<li><strong>Consistency.</strong> Every sprite drawn from the same palette shares the same skin tones, the same shadow color and the same highlight. Characters drawn weeks apart, or by different people, still look like one game.</li>
<li><strong>Readability.</strong> A 32 × 32 sprite is read by its shapes, and shapes come from clear steps between colors. Ten nearly identical reds don&#39;t add detail at that size; they make edges soft and look like noise.</li>
<li><strong>Speed.</strong> Shading becomes &quot;pick the next darker swatch&quot; instead of nudging three sliders until something looks right.</li>
</ul>
<p>Some platforms make the limit part of their identity. The PICO-8 fantasy console&#39;s <a href="https://www.lexaloffle.com/dl/docs/pico-8_manual.html" target="_blank" rel="noopener noreferrer">official manual</a> gives it a fixed palette of 16 colors, a limit its games are designed around.</p>
<p>How many colors do you need? A well-built palette of 16 to 32 colors can cover a game&#39;s characters, tiles and interface. A single sprite uses far fewer: the coin in this article uses 8.</p>
<h2 id="ramps-the-part-of-a-palette-you-actually-shade-with">Ramps: the part of a palette you actually shade with</h2>
<p>A <strong>ramp</strong> is a run of colors for one material, from its deepest shadow to its brightest highlight. Shading a sprite means moving up and down a ramp: the side facing the light gets the lighter steps, the side facing away gets the darker ones, and the outline is often the darkest step of all.</p>
<p>Good palettes are built out of ramps, even when they don&#39;t look like it. Here is DawnBringer&#39;s 32-color palette, DB32, which SpritePilot&#39;s <a href="https://spritepilot.com/pixel-art-editor">Pixel Art Editor</a> loads by default:</p>
<p><figure><img src="https://spritepilot.com/blog/pixel-art-color-palettes/db32-palette.png" alt="The 32 colors of the DB32 palette with their hex codes, and the first row's colors with their hue and lightness" width="1536" height="756" loading="lazy" decoding="async"><figcaption>DB32 in its original order. Read as a ramp, the first row (after black) climbs from 16% to 77% lightness while its hue moves from 246° (blue-violet) through 319° and 9° to about 30° (orange).</figcaption></figure></p>
<p>Read the first row from left to right and it works as a ramp for skin, wood or earth. The interesting part is the hue: the darkest steps aren&#39;t dark orange, they are dark blue-violet and plum. That isn&#39;t a quirk; it&#39;s a technique called hue shifting.</p>
<p>The full palette, ready to paste into any editor:</p>
<pre><code class="language-text">#000000 #222034 #45283c #663931 #8f563b #df7126 #d9a066 #eec39a
#fbf236 #99e550 #6abe30 #37946e #4b692f #524b24 #323c39 #3f3f74
#306082 #5b6ee1 #639bff #5fcde4 #cbdbfc #ffffff #9badb7 #847e87
#696a6a #595652 #76428a #ac3232 #d95763 #d77bba #8f974a #8a6f30</code></pre>
<h2 id="hue-shifting-shadows-arent-just-darker">Hue shifting: shadows aren&#39;t just darker</h2>
<p>The obvious way to build a ramp is to keep one hue and change only the lightness. It works, but it tends to look flat and a little muddy. The usual alternative is <strong>hue shifting</strong>: as a color gets darker, rotate its hue toward blue or purple; as it gets lighter, rotate it toward yellow.</p>
<p><figure><img src="https://spritepilot.com/blog/pixel-art-color-palettes/straight-vs-hue-shifted-ramp.png" alt="Two pixel-art spheres: one shaded with a single green hue at six lightness levels, one with a ramp whose hue moves from teal in the shadows to yellow-green in the highlights" width="1536" height="676" loading="lazy" decoding="async"><figcaption>Same six lightness values in both ramps. Only the hue changes: fixed at 125° on the left, moving from 172° to 66° on the right.</figcaption></figure></p>
<p>The left sphere is fine; the right one looks lit. Its shadows lean toward teal, as if they were filled with cool light from the sky, and its highlights lean toward a warm yellow, like sunlight.</p>
<p>There&#39;s no fixed amount to shift. The coin&#39;s gold moves only 20° from outline to highlight (30° to 50°); the green ramp above moves about 20° per step; DB32&#39;s first row swings more than 100° across its three darkest steps. Small shifts read as a richer version of one color; large ones read as a deliberate stylistic choice. Both are fine; a ramp whose hue jumps around without a direction is not.</p>
<h2 id="check-the-values-with-a-grayscale-copy">Check the values with a grayscale copy</h2>
<p>Hue and saturation give a sprite its character, but <strong>value</strong> — how light or dark each color is — is what makes it readable. The quickest check is to look at it in grayscale: each step of a ramp should be clearly lighter than the one before it, and the outline, shadow, base and highlight should stay apart.</p>
<p><figure><img src="https://spritepilot.com/blog/pixel-art-color-palettes/coin-recolored-with-different-ramps.png" alt="The coin in five ramps: gold, gold with a single hue, gold with lightness values squeezed together, silver and emerald, each above a grayscale copy" width="1536" height="730" loading="lazy" decoding="async"><figcaption>The same sprite, five ramps. In grayscale, the one-hue gold looks just like the original — hue shifting changes color, not value. The squeezed gold turns into a flat disc.</figcaption></figure></p>
<p>Two things stand out:</p>
<ul>
<li><strong>&quot;Gold, one hue&quot;</strong> keeps the original&#39;s lightness, so its grayscale copy is nearly identical. In color, though, its dark steps turn olive and the coin looks duller. Hue shifting is about richness, not readability.</li>
<li><strong>&quot;Gold, values too close&quot;</strong> looks acceptable in color, because the hues still differ. In grayscale the outline, rim and face merge, and that is how it will read at small sizes and on a busy background.</li>
</ul>
<p>Any editor can desaturate a copy of your sprite. In a browser, add <code>filter: grayscale(1)</code> to the image in the developer tools; it&#39;s what made the grayscale row above, and <a href="https://developer.mozilla.org/en-US/docs/Web/CSS/filter-function/grayscale" target="_blank" rel="noopener noreferrer">MDN documents the function</a>. Also look at the sprite at its real size, 1×, on the background it will appear on.</p>
<h2 id="palettes-worth-starting-from">Palettes worth starting from</h2>
<p>You don&#39;t have to invent a palette to get these benefits. Three well-known starting points:</p>
<ul>
<li><strong>DB32</strong> — the 32 colors above, by the pixel artist DawnBringer. Wide enough for a whole game, and built from ramps.</li>
<li><strong>DB16</strong> — DawnBringer&#39;s 16-color palette, for when 32 colors are more than you need.</li>
<li><strong>PICO-8</strong> — 16 fixed colors, listed in the manual by name, from black and dark blue to pink and peach. A good exercise in doing a lot with a little.</li>
</ul>
<p><a href="https://lospec.com/palette-list" target="_blank" rel="noopener noreferrer">Lospec&#39;s palette list</a> is a database of pixel-art palettes that you can filter by number of colors, and it offers each palette as a PNG image, which is exactly what the import below needs.</p>
<h2 id="build-your-own-ramp">Build your own ramp</h2>
<p>When no palette fits, build ramps one material at a time:</p>
<ol>
<li><strong>Pick the base color</strong>, the one most of the material will be. For the coin that&#39;s the face, <code>#f6bd3a</code>.</li>
<li><strong>Add two or three darker steps and one or two lighter ones.</strong> Space them evenly in lightness; the green ramps above step 12 to 16 points of HSL lightness at a time.</li>
<li><strong>Rotate the hue</strong> a little at every step — toward blue or purple going darker, toward yellow going lighter.</li>
<li><strong>Check it in grayscale.</strong> Fix any step that merges with its neighbor before you shade anything with it.</li>
<li><strong>Share colors between ramps.</strong> One dark purple can outline the wood, the leaves and the stone, which keeps the whole palette small.</li>
</ol>
<p>Most materials need 3 to 6 steps. A sprite with a single material, like the coin, can afford a longer ramp: its gold has 8.</p>
<h2 id="import-a-palette-into-spritepilots-pixel-art-editor">Import a palette into SpritePilot&#39;s Pixel Art Editor</h2>
<p>In the <a href="https://spritepilot.com/pixel-art-editor">Pixel Art Editor</a>, the palette panel shows DB32 until you click <strong>Import</strong> and choose an image. What happens next matters for picking the right image:</p>
<ul>
<li>The editor reads the image&#39;s pixels as they are, with no blending, and skips pixels that are more than half transparent.</li>
<li>It counts how often each exact color appears and keeps the <strong>32 most used</strong>, most used first. Colors used equally keep the order in which they first appear: left to right, top to bottom.</li>
<li>Images bigger than 512 px on their longest side are reduced first (again without blending), so small palette images are read pixel for pixel.</li>
</ul>
<p><figure><img src="https://spritepilot.com/blog/pixel-art-color-palettes/pixel-art-editor-palette-import.png" alt="SpritePilot's palette panel showing DB32, a 32 × 32 palette image with four ramps of four colors, and the panel after the import showing those 16 colors in order" width="1536" height="482" loading="lazy" decoding="async"><figcaption>Importing a palette image: four ramps of four equal swatches come in as 16 colors, in reading order.</figcaption></figure></p>
<p>So the best source is a palette image with one swatch per color, all the same size — such as a palette downloaded as a PNG, or <a href="https://spritepilot.com/blog/pixel-art-color-palettes/four-ramps-16-colors.png">this 16-color example</a> with the gold, silver, emerald and ruby ramps used in these articles (free to use). A photo or an anti-aliased painting is a poor source: it has thousands of slightly different colors, and the 32 most common are usually near-duplicates from its largest areas.</p>
<p>The import changes the swatches you pick from, not what you&#39;ve already drawn. <strong>Reset</strong> brings DB32 back, and the editor remembers your palette in this browser for next time. You can also type any hex value into the Color field next to the color picker.</p>
<h2 id="in-short">In short</h2>
<ul>
<li>Draw from a fixed palette: 16 to 32 colors for a game, 3 to 6 steps per material.</li>
<li>Build ramps that rotate the hue — toward blue or purple in the shadows, toward yellow in the light.</li>
<li>Check every ramp in grayscale; value is what makes a small sprite readable.</li>
<li>Start from DB32, DB16 or PICO-8 if you don&#39;t want to build your own.</li>
<li>To use a palette in SpritePilot, import an image with one equal-sized swatch per color; it reads up to 32 colors.</li>
</ul>
<p>Want to see a ramp in motion? The <a href="https://spritepilot.com/blog/free-animated-pickup-sprites">free animated pickup sprites</a> use three hue-shifted ramps — gold, blue and red — and list every color they use.</p>
]]></content:encoded>
    </item>
    <item>
      <title>PNG, WebP or JPG for Game Sprites? File Size, Quality and Memory Compared</title>
      <link>https://spritepilot.com/blog/png-webp-jpg-for-sprites</link>
      <guid isPermaLink="true">https://spritepilot.com/blog/png-webp-jpg-for-sprites</guid>
      <pubDate>Wed, 14 Oct 2026 12:00:00 GMT</pubDate>
      <description>Which format to save sprite sheets in, with measured file sizes for PNG, lossless and lossy WebP and JPG, what JPG does to pixel art, and engine support.</description>
      <category>file formats</category>
      <category>sprite sheets</category>
      <category>pixel art</category>
      <category>web</category>
      <content:encoded><![CDATA[<p>Every sprite export ends with the same question: PNG, WebP or JPG? Pick wrong and you either ship files several times bigger than they need to be, or you smear the pixels you placed by hand. The choice is easy once you know three things: whether a format is lossless, whether it keeps transparency, and the fact that none of them changes how much memory a sprite takes in the game.</p>
<p>To put numbers on it, four sheets were saved in all four encodings with Chrome&#39;s built-in encoders (the ones SpritePilot&#39;s export uses in Chrome), then decoded again and compared pixel by pixel.</p>
<h2 id="what-each-format-can-and-cant-do">What each format can and can&#39;t do</h2>
<table>
<thead>
<tr>
<th></th>
<th>PNG</th>
<th>JPG</th>
<th>WebP</th>
</tr>
</thead>
<tbody><tr>
<td>Compression</td>
<td>lossless</td>
<td>lossy</td>
<td>lossless or lossy</td>
</tr>
<tr>
<td>Transparency</td>
<td>yes</td>
<td>no</td>
<td>yes, in both modes</td>
</tr>
<tr>
<td>Browsers</td>
<td>all</td>
<td>all</td>
<td>all current ones</td>
</tr>
</tbody></table>
<p><strong>Lossless</strong> means you get every pixel back exactly as it was. <strong>Lossy</strong> means the encoder throws away detail it expects you won&#39;t notice. That works on photos, where neighboring pixels blend into each other anyway. It fails on pixel art, where every hard edge and every lone pixel is deliberate.</p>
<p>WebP does both. Google&#39;s <a href="https://developers.google.com/speed/webp" target="_blank" rel="noopener noreferrer">WebP page</a> puts lossless WebP at about 26% smaller than PNG, and notes that lossy WebP can keep transparency too. Browser support is no longer a worry: <a href="https://developer.mozilla.org/en-US/docs/Web/Media/Guides/Formats/Image_types" target="_blank" rel="noopener noreferrer">MDN&#39;s image format guide</a> lists WebP in every current browser, with one catch on Macs, where Safari needs version 14 or later running on macOS 11 Big Sur or later. One limit worth knowing for big sheets: a WebP image can&#39;t be wider or taller than 16,383 pixels.</p>
<h2 id="what-jpg-and-lossy-webp-do-to-pixel-art">What JPG and lossy WebP do to pixel art</h2>
<p><figure><img src="https://spritepilot.com/blog/png-webp-jpg-for-sprites/png-webp-jpg-pixel-art.png" alt="A pixel-art coin and four tiles saved as PNG, lossy WebP and JPG, enlarged 7 times: the PNG is exact, the WebP has shifted colors, the JPG has smeared colors and a white background" width="1536" height="1272" loading="lazy" decoding="async"><figcaption>Real encodes from Chrome, enlarged 7× with no smoothing. The pixel counts are for the whole sheet.</figcaption></figure></p>
<p>Look at the red and yellow flower on the grass tile. JPG can store color at a lower resolution than brightness, and Chrome&#39;s encoder does: at its default quality of 0.92 it keeps one color sample for every 2 × 2 pixels, so a flower drawn with single pixels turns into a brownish smudge. The rest of the compression works on blocks of 8 × 8 pixels and rounds away fine detail inside them, so green leaks into the water along tile edges and the stone lines go soft. Lossy WebP has the same weakness: Google&#39;s WebP FAQ says lossy mode works only with 4:2:0 color, the same half-resolution color Chrome&#39;s JPG uses.</p>
<p>Transparency is the other problem. JPG has none, so every transparent pixel must become a solid color. SpritePilot fills them with white before it saves a JPG; Chrome&#39;s encoder on its own turns them black. Either way, the coin gets a box around it. Lossy WebP kept the coin&#39;s transparency intact in the test, but still changed the color of 2,213 of its 8,192 pixels.</p>
<p>And none of that damage buys a smaller file. For the coin, the JPG was 2.8 times the size of the PNG.</p>
<h2 id="measured-file-sizes">Measured file sizes</h2>
<p>The four sheets: the 8-frame <a href="https://spritepilot.com/blog/sprite-sheet-frame-size/coin-spin-strip.png">coin strip</a>, the 8-tile <a href="https://spritepilot.com/blog/lines-between-tiles/meadow-tileset-16px.png">meadow tileset</a>, SpritePilot&#39;s <a href="https://spritepilot.com/samples/ball-bounce.png">bouncing-ball sample</a>, and a 1024 × 512 sheet of a smoothly shaded orb, standing in for painted, high-resolution art. Sizes are in bytes. Lossy WebP used Chrome&#39;s default quality of 0.8, and JPG its default of 0.92.</p>
<table>
<thead>
<tr>
<th>Sheet</th>
<th>PNG</th>
<th>WebP lossless</th>
<th>WebP lossy</th>
<th>JPG</th>
</tr>
</thead>
<tbody><tr>
<td>Coin strip, 256 × 32</td>
<td>1,833</td>
<td>1,274</td>
<td>2,722</td>
<td>5,203</td>
</tr>
<tr>
<td>Meadow tileset, 64 × 32</td>
<td>1,537</td>
<td>1,130</td>
<td>1,232</td>
<td>1,720</td>
</tr>
<tr>
<td>Ball sample, 256 × 128</td>
<td>3,092</td>
<td>1,186</td>
<td>2,608</td>
<td>7,190</td>
</tr>
<tr>
<td>Shaded orb, 1024 × 512</td>
<td>481,730</td>
<td>297,266</td>
<td>85,548</td>
<td>82,903</td>
</tr>
</tbody></table>
<p><figure><img src="https://spritepilot.com/blog/png-webp-jpg-for-sprites/file-size-by-format.png" alt="Bar chart of file sizes as a percentage of the PNG for the four sheets: lossless WebP is smaller on all of them, JPG is larger on the three small sheets, and both lossy formats are about 17 to 18 percent of the PNG on the shaded orb" width="1536" height="936" loading="lazy" decoding="async"><figcaption>Each sheet's PNG is 100%. KB here means 1,000 bytes.</figcaption></figure></p>
<p>What the numbers say:</p>
<ul>
<li><strong>Lossless WebP beat PNG on every sheet</strong>, by 26% to 62%, and both gave back every pixel exactly.</li>
<li><strong>On the three hard-edged sheets, lossy formats don&#39;t pay.</strong> JPG came out bigger than the PNG every time, up to 2.8 times. Lossy WebP was bigger on the coin and 16–20% smaller on the other two, while changing 2,031 of the tileset&#39;s 2,048 pixels. Hard edges and flat colors are what PNG and lossless WebP compress best, and exactly what lossy encoders struggle with.</li>
<li><strong>On smooth, painted art, lossy formats win big.</strong> The orb went from 482 KB as PNG to 86 KB as lossy WebP and 83 KB as JPG, about a sixth. Only the WebP kept the transparent background.</li>
<li><strong>The encoder matters as much as the format.</strong> Chrome&#39;s encoders favor speed; Chromium&#39;s source code sets its PNG encoder to a low compression level. ImageMagick with its strongest PNG settings saved the coin in 915 bytes, half of Chrome&#39;s PNG, and Google&#39;s <code>cwebp</code> tool with <code>-z 9</code> made a 558-byte lossless WebP. Going the other way, re-saving the ball sample through Chrome grew it from 918 to 3,092 bytes. All of those files are lossless, so run your final web assets through an optimizer.</li>
</ul>
<h2 id="in-memory-every-format-is-the-same-size">In memory, every format is the same size</h2>
<p>The file format decides the download and the disk space, not the memory. To draw a sprite, the GPU needs plain pixels, so the image is decoded first, and an uncompressed texture costs 4 bytes per pixel: red, green, blue and alpha.</p>
<p><strong>Memory = width × height × 4 bytes.</strong></p>
<ul>
<li>Coin strip: 256 × 32 × 4 = 32,768 bytes, for a file of 1,274 to 5,203 bytes.</li>
<li>Shaded orb: 1024 × 512 × 4 = 2,097,152 bytes (2 MiB), whether it arrived as an 83 KB JPG or a 482 KB PNG.</li>
</ul>
<p>The engines say the same. Unity&#39;s manual explains that GPUs don&#39;t use formats like JPEG or PNG at runtime; when you import a texture, the Editor picks a GPU format for each build platform. Godot&#39;s import documentation says of its Lossy mode: &quot;Video memory usage isn&#39;t decreased by this mode.&quot; Texture memory only goes down with a smaller sheet or a GPU format that uses fewer bits per pixel, and those formats usually spoil pixel art. <a href="https://spritepilot.com/blog/sprite-sheet-size-limits">How Big Can a Sprite Sheet Be?</a> has the memory math for whole atlases.</p>
<h2 id="which-formats-your-engine-accepts">Which formats your engine accepts</h2>
<ul>
<li><strong>Unity 6</strong> <a href="https://docs.unity3d.com/6000.0/Documentation/Manual/ImportingTextures.html" target="_blank" rel="noopener noreferrer">imports</a> BMP, EXR, GIF, HDR, IFF, JPG, PICT, PNG, PSD, TGA and TIFF. WebP isn&#39;t on the list, so give Unity PNGs. Unity converts every texture to its own GPU format anyway, so a PNG source costs nothing in the game, while a JPG source would bring its artifacts along.</li>
<li><strong>Godot 4</strong> imports PNG, JPEG and WebP, among others. With the default Lossless compression, which the <a href="https://docs.godotengine.org/en/stable/tutorials/assets_pipeline/importing_images.html" target="_blank" rel="noopener noreferrer">Godot docs</a> recommend for pixel art, the imported texture is stored as lossless WebP or PNG whatever you started from. PNG and WebP both work.</li>
<li><strong>Phaser</strong> loads images through the browser: its loader turns each downloaded file into a regular browser image. So it reads anything the browser can display, WebP included. The same goes for CSS and canvas animation.</li>
</ul>
<p>For engines that re-encode on import, use PNG and stop worrying. For games that run in a browser, the file you ship is the file players download, and lossless WebP is the smaller choice.</p>
<h2 id="what-spritepilots-export-writes">What SpritePilot&#39;s export writes</h2>
<p><a href="https://spritepilot.com/export-download">Export &amp; Download</a> offers all three formats for the sheet and for the ZIP of separate sprites:</p>
<ul>
<li><strong>PNG</strong> comes from your browser&#39;s encoder: lossless, though not the smallest possible file.</li>
<li><strong>WebP</strong> is saved at quality 1, which Chrome and other Chromium-based browsers encode as lossless WebP. In the test, every exported pixel came back identical. If your browser can&#39;t write WebP from a canvas (MDN&#39;s compatibility data lists Safari among them), the WebP button is disabled.</li>
<li><strong>JPG</strong> gets a white background where the sprites were transparent, then the browser&#39;s default quality: 0.92 in Chrome.</li>
</ul>
<p>The other tools, including the Sheet Maker, the GIF and video converters and the <a href="https://spritepilot.com/pixel-art-editor">Pixel Art Editor</a>, save their sheets as PNG. For what each metadata file contains, see <a href="https://spritepilot.com/guides/export-formats-explained">Sprite Sheet Export Formats Explained</a>.</p>
<h2 id="in-short">In short</h2>
<ul>
<li><strong>Pixel art, any engine:</strong> PNG. For the web, lossless WebP is smaller, by 26–62% on these sheets.</li>
<li><strong>Unity:</strong> PNG; it can&#39;t import WebP and re-encodes everything anyway.</li>
<li><strong>Large painted sprites on the web:</strong> lossy WebP, checked at 100% zoom. JPG only if nothing in the sheet is transparent.</li>
<li><strong>Never JPG for pixel art:</strong> bigger files, smeared colors, no transparency.</li>
<li><strong>Optimize final files:</strong> a good encoder halved some of these PNGs without changing a pixel.</li>
<li><strong>Memory is width × height × 4 bytes</strong> no matter which format the sheet came from.</li>
</ul>
]]></content:encoded>
    </item>
    <item>
      <title>Removing a Solid Background From Sprites Without a Halo</title>
      <link>https://spritepilot.com/blog/remove-background-without-halo</link>
      <guid isPermaLink="true">https://spritepilot.com/blog/remove-background-without-halo</guid>
      <pubDate>Wed, 14 Oct 2026 12:00:00 GMT</pubDate>
      <description>Why removing a magenta or white background leaves a colored fringe around sprites, what tolerance can and can't fix, and how soft edges remove it.</description>
      <category>sprite sheets</category>
      <category>pixel art</category>
      <content:encoded><![CDATA[<p>You remove the magenta background from an old sprite sheet, drop the sprites into your game, and every one of them has a thin purple outline that was never in the art. On a sheet with a white background it&#39;s a pale gray rim instead. That fringe is called a halo. It doesn&#39;t come from a wrong setting; it comes from the way the sprite&#39;s edges were drawn. Once you see where it comes from, it&#39;s easy to remove without damaging the sprites.</p>
<h2 id="why-old-sprites-sit-on-magenta-or-white">Why old sprites sit on magenta or white</h2>
<p>Before every format and engine handled transparency per pixel, a common trick was a <strong>color key</strong>: one color that means &quot;transparent&quot;. It still exists in libraries today. pygame&#39;s <a href="https://www.pygame.org/docs/ref/surface.html#pygame.Surface.set_colorkey" target="_blank" rel="noopener noreferrer"><code>Surface.set_colorkey</code></a>, for example, makes &quot;any pixels that have the same color as the colorkey&quot; transparent when the surface is drawn — the same color, exactly.</p>
<p>Magenta, (255, 0, 255), is the classic key because it almost never appears in the art itself. White backgrounds turn up in sheets exported for print or the web, and in screenshots. Either way, the job is to turn that one color into real transparency, and for crisp pixel art that is simple. The trouble starts with smooth edges.</p>
<h2 id="where-the-halo-comes-from">Where the halo comes from</h2>
<p>A smooth, anti-aliased edge is drawn with pixels that are only partly covered by the sprite. Their color is a mix of the sprite and whatever was behind it — the standard <a href="https://www.w3.org/TR/compositing-1/#simplealphacompositing" target="_blank" rel="noopener noreferrer">alpha compositing formula</a>:</p>
<blockquote>
<p>color = coverage × sprite color + (1 − coverage) × background color</p>
</blockquote>
<p>Take an edge pixel that is 38% dark outline, (32, 32, 32), and 62% background. On magenta it was saved as 0.38 × 32 + 0.62 × 255 = 170 for red and blue, and 0.38 × 32 + 0.62 × 0 = 12 for green: <strong>(170, 12, 170)</strong>, a purple pixel. On white, the same pixel is (170, 170, 170), a light gray.</p>
<p>A simple key removes pixels that are close to the background color and keeps everything else, fully opaque. (170, 12, 170) is 121 RGB units away from magenta, so it stays — as a solid purple pixel around a sprite that no longer sits on magenta.</p>
<p><figure><img src="https://spritepilot.com/blog/remove-background-without-halo/halo-hard-key-vs-soft-edges.png" alt="Zoomed edge of a red sprite on magenta and on white: the original, the result of a hard key on a dark background with a purple or gray fringe, and the result with soft edges, clean" width="1536" height="1006" loading="lazy" decoding="async"><figcaption>Results from SpritePilot's Remove Background on two test sheets. The marked pixel is 38% outline: the hard key keeps it as an opaque purple or gray pixel, soft edges turn it back into the outline color at 38% opacity.</figcaption></figure></p>
<p>To measure it, we used two test sheets: eight anti-aliased sprites flattened onto magenta and onto white, so the correct transparent result is known exactly. The original sprites have 832 partly transparent edge pixels. A hard key — Remove Background with soft edges switched off — at the default tolerance left <strong>608</strong> of them fully opaque, and <strong>256</strong> of those are more background than sprite. That&#39;s the halo you see.</p>
<h2 id="tolerance-is-a-trade-off-not-a-fix">Tolerance is a trade-off, not a fix</h2>
<p>The natural reaction is to raise the tolerance. In SpritePilot&#39;s <a href="https://spritepilot.com/remove-background">Remove Background</a>, a pixel matches the key when its RGB distance from the key color is at most the tolerance × √3, so the default of 30 allows a distance of 52 — about 30 levels in each channel. Here is what higher values did to the magenta sheet, with soft edges switched off:</p>
<table>
<thead>
<tr>
<th>Tolerance</th>
<th>Halo pixels (mostly background, still opaque)</th>
<th>Edge pixels left fully opaque (of 832)</th>
<th>Sprite pixels lost with &quot;This color everywhere&quot;</th>
</tr>
</thead>
<tbody><tr>
<td>30</td>
<td>256</td>
<td>608</td>
<td>0</td>
</tr>
<tr>
<td>60</td>
<td>64</td>
<td>416</td>
<td>480</td>
</tr>
<tr>
<td>90</td>
<td>0</td>
<td>352</td>
<td>964</td>
</tr>
<tr>
<td>120</td>
<td>0</td>
<td>192</td>
<td>1,480</td>
</tr>
</tbody></table>
<p>The halo shrinks, but every edge pixel is still either deleted or kept at full strength. The 38% outline pixel only goes away from a tolerance of 70, and then it disappears completely, so outlines get thinner and more jagged while the pixels that remain keep their tint.</p>
<p>Higher tolerances also start to match the sprites&#39; own colors. The pink sprite, (240, 50, 230), is only 58 units from magenta, so any tolerance from 34 up matches it; the purple one matches from 79 and the red one from 106. Removed everywhere, their bodies vanish:</p>
<p><figure><img src="https://spritepilot.com/blog/remove-background-without-halo/tolerance-and-mode-on-magenta.png" alt="The pink test sprite after hard keys at tolerance 34, 60, 90 and 120: with This color everywhere only its outline and eye are left; with Background touching the edges its body stays" width="1536" height="838" loading="lazy" decoding="async"><figcaption>Top: the key removed everywhere, and the pink body goes with the background. Bottom: removed only where it touches the image's edges, the body is enclosed by the outline and survives.</figcaption></figure></p>
<h2 id="edges-only-or-everywhere">Edges only, or everywhere?</h2>
<p>That bottom row is the second setting that matters. The tool&#39;s default, <strong>Background touching the edges</strong>, starts at every pixel on the border of the image and spreads through matching pixels, like a magic wand clicked all around the edge. Color enclosed by a sprite&#39;s outline is never reached, so it stays. The fill moves only up, down, left and right — never diagonally — so it can&#39;t slip through the diagonal steps of a one-pixel outline.</p>
<p>That&#39;s how white eyes survive on a white sheet:</p>
<p><figure><img src="https://spritepilot.com/blog/remove-background-without-halo/white-eyes-edges-vs-everywhere.png" alt="The white test sheet's red sprite near its eye: with Background touching the edges the white eye is kept, with This color everywhere it becomes a hole" width="1536" height="498" loading="lazy" decoding="async"><figcaption>Same sheet, same tolerance. Removing the background from the edges keeps all 152 eye pixels; removing the color everywhere deletes every one of them.</figcaption></figure></p>
<p>The tool tells you when it has kept matching pixels — &quot;152 pixels of this color are enclosed inside shapes and were kept&quot; — so you can decide. Switch to <strong>This color everywhere</strong> only when the background really does show through closed holes, like the middle of a ring or a gap between an arm and a body.</p>
<p>Edge mode protects colors only when something encloses them. A sprite with no outline, whose own edge is close to the key color, can still be eaten from the outside.</p>
<h2 id="what-soft-edges-do">What soft edges do</h2>
<p>The <strong>Soft edges</strong> option (on by default) doesn&#39;t delete or keep edge pixels; it estimates how much of each one was sprite and rebuilds it:</p>
<ol>
<li>It starts at the kept pixels that touch the removed background and works inward, at most 3 pixels, only through pixels that turn out to be part background. A solid outline stops it, so a sprite&#39;s interior is never touched.</li>
<li>A pixel whose color carries on into the sprite — a nearly identical neighbor that isn&#39;t on the edge — is solid paint and stays as it is.</li>
<li>Otherwise it looks within 2 pixels for a stronger sprite color that explains the pixel as a blend of that color and the background, allowing a little for rounding and compression. How far the pixel sits along that blend is its coverage.</li>
<li>Coverage of 92% or more counts as solid. Below that, the coverage becomes the pixel&#39;s opacity, and the background is subtracted from its color: (pixel − background) ÷ coverage + background.</li>
<li>A pixel that no blend explains but that is just outside the tolerance fades from transparent to opaque across a narrow band — that catches JPEG specks.</li>
</ol>
<p>For the marked pixel above, that gives an opacity of 97 out of 255 (38%) and the color (32, 32, 32): exactly the original. Across the whole magenta sheet, at the default settings, soft edges left <strong>no</strong> halo pixels; edge opacity was off by 2.4 levels out of 255 on average, and color by about 1. The white sheet came out almost the same: 2.7 and 1.2.</p>
<p>Crisp pixel art is left alone. On a test sheet with one-pixel outlines, diagonal steps, a near-magenta pink sprite and a magenta gem inside every sprite, the default settings reproduced the original transparent sheet pixel for pixel — and kept the 32 gem pixels, which are enclosed. You only need to switch soft edges off if you want every edge pixel kept exactly as it is, partial pixels included.</p>
<p>Soft edges assume one flat background color. Gradients, textures and heavy JPEG noise won&#39;t un-mix cleanly, and glows wider than 3 pixels are only partly restored. For photos or complex backgrounds, use the AI background removal in <a href="https://spritepilot.com/sprite-detection">Sprite Detection</a> instead (that one runs on SpritePilot&#39;s server).</p>
<h2 id="a-second-halo-dark-edges-in-the-engine">A second halo: dark edges in the engine</h2>
<p>A clean PNG can still show a dark rim in the game. Fully transparent pixels still store a color — in the PNG that Remove Background saves, it&#39;s black, (0, 0, 0, 0) — and when an engine scales or moves a sprite with smooth filtering, it can blend that hidden color into the edge. Two fixes:</p>
<ul>
<li>For pixel art, use nearest filtering (see <a href="https://spritepilot.com/blog/lines-between-tiles">Why Lines Appear Between Your Tiles</a>); nothing gets blended.</li>
<li>For smooth art, let the engine pad the colors. Godot&#39;s import option <strong>Fix Alpha Border</strong> surrounds opaque areas with pixels of their own color and is on by default, according to the <a href="https://docs.godotengine.org/en/stable/tutorials/assets_pipeline/importing_images.html" target="_blank" rel="noopener noreferrer">documentation</a>; in Unity, <strong>Alpha Is Transparency</strong> <a href="https://docs.unity3d.com/6000.0/Documentation/Manual/texture-type-sprite.html" target="_blank" rel="noopener noreferrer">dilates the color channels</a> for the same reason.</li>
</ul>
<h2 id="a-checklist">A checklist</h2>
<ol>
<li>Pick the background from a corner, and check the RGB value the tool shows.</li>
<li>Keep <strong>Background touching the edges</strong> unless background shows through closed holes.</li>
<li>Leave the tolerance low (around 30) and let <strong>Soft edges</strong> handle the fringe; raise it only for noisy backgrounds, and watch the percentage of removed pixels.</li>
<li>Check the result zoomed in, on a background unlike the original: dark for a white sheet, light for a magenta one.</li>
<li>Read the enclosed-pixels message before you switch to <strong>This color everywhere</strong>.</li>
</ol>
<p>The click-by-click version is in <a href="https://spritepilot.com/guides/remove-sprite-background">How to Remove the Background from a Sprite Sheet</a>. The tool runs entirely in your browser, and <strong>Send to Sprite Detection</strong> takes the cleaned sheet straight on to be split.</p>
]]></content:encoded>
    </item>
    <item>
      <title>How to Scale Pixel Art Without Blurring It</title>
      <link>https://spritepilot.com/blog/scale-pixel-art-without-blur</link>
      <guid isPermaLink="true">https://spritepilot.com/blog/scale-pixel-art-without-blur</guid>
      <pubDate>Wed, 14 Oct 2026 12:00:00 GMT</pubDate>
      <description>Why scaled pixel art turns blurry or lumpy, and how to keep it crisp with nearest-neighbor, whole-number scales, CSS, canvas and engine settings.</description>
      <category>pixel art</category>
      <category>web</category>
      <content:encoded><![CDATA[<p>You scale a 32-pixel sprite up to fill the screen and it comes out soft and blurry. Or it stays sharp but looks lumpy, with some pixels fatter than others. These are two different problems with two different fixes: the <strong>resampling method</strong> decides whether the result blurs, and the <strong>scale factor</strong> decides whether every pixel ends up the same size. Get both right and your art looks exactly as drawn, at any size.</p>
<h2 id="why-scaled-pixel-art-goes-blurry">Why scaled pixel art goes blurry</h2>
<p>When an image is drawn bigger than it is, the computer has to invent the new pixels. There are two basic ways to do it:</p>
<ul>
<li><strong>Smooth</strong> (bilinear) scaling blends neighboring pixels according to how close they are. That suits photos, but on pixel art it softens every edge and creates in-between colors that you never drew.</li>
<li><strong>Nearest-neighbor</strong> scaling copies the closest source pixel. Every pixel becomes a solid block and no new colors appear.</li>
</ul>
<p><figure><img src="https://spritepilot.com/blog/scale-pixel-art-without-blur/nearest-vs-smooth-2x-2.5x-3x.png" alt="The coin scaled to 2×, 2.5× and 3×: crisp with nearest-neighbor and 7 colors in each, blurred with smooth scaling and 113, 362 and 174 colors" width="1536" height="1112" loading="lazy" decoding="async"><figcaption>Real canvas renders in Chromium. The coin has 7 colors; smooth scaling turned them into 113 to 362, counting the blended edge pixels.</figcaption></figure></p>
<p>Smooth is usually the default: a canvas&#39;s <code>imageSmoothingEnabled</code> starts out <code>true</code>, and Phaser smooths until you set <code>pixelArt: true</code>. For pixel art you want nearest-neighbor everywhere — in the browser, in the engine, and in any tool that resizes your files.</p>
<h2 id="whole-number-scales-keep-every-pixel-the-same-size">Whole-number scales keep every pixel the same size</h2>
<p>Nearest-neighbor fixes the blur, not the size. At 2× each source pixel becomes a 2 × 2 block, and at 3× a 3 × 3 block. At 2.5× a pixel can&#39;t be two and a half screen pixels wide, so the columns alternate between 3 and 2:</p>
<p><figure><img src="https://spritepilot.com/blog/scale-pixel-art-without-blur/uneven-pixels-at-2.5x.png" alt="Rows of eight source pixels scaled with nearest-neighbor: 2 px each at 2×, 3 px each at 3×, alternating 3 and 2 px at 2.5×, and also 3 and 2 device pixels for a 2× CSS scale on a 125% display" width="1536" height="492" loading="lazy" decoding="async"><figcaption>Measured in Chromium: at 2.5× the columns go 3, 2, 3, 2… The last row is the same thing in disguise — a 2× CSS scale on a screen set to 125%.</figcaption></figure></p>
<p>On a sprite, that makes one-pixel outlines thicker in some places than others and turns curves lumpy. When the sprite moves, the wide and narrow columns move across it, so the art seems to wobble. The fix is to scale by whole numbers only — 2×, 3×, 4× — and fill any leftover screen with a border.</p>
<p>That&#39;s the appeal of base resolutions like 320 × 180 and 640 × 360: they multiply evenly into common screens.</p>
<table>
<thead>
<tr>
<th>Screen</th>
<th>320 × 180 base</th>
<th>640 × 360 base</th>
</tr>
</thead>
<tbody><tr>
<td>1280 × 720</td>
<td>4×</td>
<td>2×</td>
</tr>
<tr>
<td>1366 × 768</td>
<td>4× (1280 × 720, with a 43 px border left and right, 24 px top and bottom)</td>
<td>2×</td>
</tr>
<tr>
<td>1920 × 1080</td>
<td>6×</td>
<td>3×</td>
</tr>
<tr>
<td>2560 × 1440</td>
<td>8×</td>
<td>4×</td>
</tr>
<tr>
<td>3840 × 2160</td>
<td>12×</td>
<td>6×</td>
</tr>
</tbody></table>
<h2 id="in-the-browser-image-rendering-pixelated">In the browser: <code>image-rendering: pixelated</code></h2>
<p>For an image on a web page, one CSS property switches the browser to nearest-neighbor:</p>
<pre><code class="language-html">&lt;img src=&quot;coin.png&quot; width=&quot;96&quot; height=&quot;96&quot; alt=&quot;Coin&quot; class=&quot;pixel-art&quot;&gt;</code></pre>
<pre><code class="language-css">.pixel-art {
  image-rendering: pixelated;
}</code></pre>
<p>That&#39;s a 32-pixel coin shown at exactly 3×. In a test in Chromium, every screen pixel came out identical to its source pixel; without the property the same image had 280 colors. MDN&#39;s <a href="https://developer.mozilla.org/en-US/docs/Web/CSS/image-rendering" target="_blank" rel="noopener noreferrer">image-rendering page</a> lists support for <code>pixelated</code> since Chrome 41, Firefox 93 and Safari 10. Don&#39;t rely on <code>crisp-edges</code> for the same job: Chrome only added it in version 148, and the Chromium 145 we tested still smoothed it.</p>
<p>The CSS specification allows <code>pixelated</code> to add a little smoothing when the size isn&#39;t a whole multiple; Chromium 145 didn&#39;t, and drew plain 3- and 2-pixel columns at 2.5×. Either way, a fractional scale can&#39;t be both sharp and even.</p>
<p><strong>Watch out for display scaling.</strong> CSS pixels aren&#39;t screen pixels. On a laptop with display scaling at 125%, <code>window.devicePixelRatio</code> is typically 1.25, so an image drawn at 2× in CSS covers 2.5 screen pixels per source pixel — the uneven pattern again, even though your CSS says 2×. At 150%, the same 2× happens to be an even 3.</p>
<h2 id="in-a-canvas-game-draw-small-then-scale-by-whole-screen-pixels">In a canvas game: draw small, then scale by whole screen pixels</h2>
<p>The reliable way to get crisp pixels in a canvas game is to draw everything at the game&#39;s base resolution, then scale the whole canvas up by the largest whole number of <em>screen</em> pixels that fits:</p>
<pre><code class="language-css">#game {
  image-rendering: pixelated;
}</code></pre>
<pre><code class="language-js">const GAME_W = 320
const GAME_H = 180
const canvas = document.getElementById('game')
const ctx = canvas.getContext('2d')
canvas.width = GAME_W
canvas.height = GAME_H
ctx.imageSmoothingEnabled = false

// The largest whole-number scale that fits the window, in device pixels
function fit() {
  const dpr = window.devicePixelRatio || 1
  const scale = Math.max(1, Math.floor(Math.min(
    (window.innerWidth * dpr) / GAME_W,
    (window.innerHeight * dpr) / GAME_H,
  )))
  canvas.style.width = (GAME_W * scale) / dpr + 'px'
  canvas.style.height = (GAME_H * scale) / dpr + 'px'
}
window.addEventListener('resize', fit)
fit()</code></pre>
<p>We ran this at device pixel ratios of 1, 1.25, 1.5, 1.75 and 2 and measured the screenshots: every game pixel covered the same number of screen pixels in every case. A version that worked out the scale in CSS pixels instead gave uneven columns at some window sizes: 3 and 2 screen pixels at 125%, 5 and 4 at 150%, 5 and 6 at 175%.</p>
<p>Three details that bite:</p>
<ul>
<li><strong>Set <code>imageSmoothingEnabled</code> again after resizing the canvas.</strong> The <a href="https://html.spec.whatwg.org/multipage/canvas.html" target="_blank" rel="noopener noreferrer">HTML standard</a> resets the context&#39;s state whenever the canvas&#39;s width or height is set; in our test the property went from <code>false</code> back to <code>true</code>.</li>
<li><strong>Round positions.</strong> With smoothing on, drawing the coin at x = 10.5 blurred it into 18 colors. With smoothing off, Chromium drew it exactly as at x = 11 — but <code>Math.round(x)</code> makes that your choice, not the browser&#39;s.</li>
<li><strong>Draw sprites at 1× inside the small canvas.</strong> The canvas scale does the enlarging, once, for everything.</li>
</ul>
<h2 id="in-game-engines">In game engines</h2>
<p>The texture filter settings for each engine are covered in <a href="https://spritepilot.com/blog/lines-between-tiles">Why Lines Appear Between Your Tiles</a>: Nearest in Godot, Point in Unity, <code>pixelArt: true</code> in Phaser. For whole-number scaling:</p>
<ul>
<li><strong>Godot 4.2+:</strong> Project Settings → Display → Window → Stretch: set <strong>Mode</strong> to <code>viewport</code> and <strong>Scale Mode</strong> to <code>integer</code>. The <a href="https://docs.godotengine.org/en/stable/tutorials/rendering/multiple_resolutions.html" target="_blank" rel="noopener noreferrer">documentation</a> explains that this rounds the scale factor down to a whole number and fills the rest of the window with black bars.</li>
<li><strong>Unity:</strong> add the <strong>Pixel Perfect Camera</strong> (URP) and set <strong>Asset Pixels Per Unit</strong> to your sprites&#39; value and <strong>Reference Resolution</strong> to your base resolution, such as 320 × 180. <a href="https://docs.unity3d.com/6000.1/Documentation/Manual/urp/2d-pixelperfect-intro.html" target="_blank" rel="noopener noreferrer">Unity&#39;s manual</a> says it keeps pixel art &quot;crisp and clear at different resolutions, and stable in motion&quot;.</li>
<li><strong>Phaser 3.16+:</strong> set the zoom to <code>Phaser.Scale.MAX_ZOOM</code>, which picks the largest whole-number zoom that fits the parent element. It&#39;s worked out when the game starts, so call <code>this.scale.setMaxZoom()</code> again when the window changes size.</li>
</ul>
<pre><code class="language-js">const config = {
  type: Phaser.AUTO,
  width: 320,
  height: 180,
  pixelArt: true,
  scale: {
    mode: Phaser.Scale.NONE,
    zoom: Phaser.Scale.MAX_ZOOM,
  },
  scene: [GameScene],
}</code></pre>
<h2 id="scaling-down-is-a-different-problem">Scaling down is a different problem</h2>
<p>Shrinking pixel art has no good automatic answer. Nearest-neighbor at 50% keeps every other row and column and throws the rest away: on the coin, the one-pixel outline vanished from long stretches of the edge. Smooth shrinking keeps a hint of everything but blurs it, turning the coin&#39;s 7 colors into 24.</p>
<p>So:</p>
<ul>
<li><strong>If you need a smaller sprite, draw it.</strong> By hand you decide which details survive; an algorithm can&#39;t tell an eye from a speck.</li>
<li><strong>If the art was enlarged by a whole number, shrink it by exactly that number.</strong> A coin scaled up 3× with nearest-neighbor and back down to a third came out identical to the original, pixel for pixel. It&#39;s also the fix for art you received already enlarged, such as a 4× preview image: count how many screen pixels one art pixel covers, scale back to 1×, and let the engine do the enlarging.</li>
</ul>
<h2 id="batch-scale-sprites-in-spritepilot">Batch-scale sprites in SpritePilot</h2>
<p>SpritePilot&#39;s <a href="https://spritepilot.com/advanced-processing">Advanced Processing</a> resizes every sprite in your session at once. Load the sprites first — cut a sheet with Grid Slice in <a href="https://spritepilot.com/sprite-detection">Sprite Detection</a>, for example — then choose <strong>Scaling Mode → Custom Size</strong>, enter the target width and height, and keep <strong>Resampling</strong> on <strong>Pixel art: keep every pixel sharp</strong>.</p>
<p><figure><img src="https://spritepilot.com/blog/scale-pixel-art-without-blur/advanced-processing-pixel-resampling.png" alt="SpritePilot's Advanced Processing settings: Scaling Mode set to Custom Size, Maintain aspect ratio ticked, Resampling set to Pixel art: keep every pixel sharp, target 96 × 96" width="854" height="768" loading="lazy" decoding="async"><figcaption>Advanced Processing set up to scale 32 × 32 sprites to exactly 3×.</figcaption></figure></p>
<p>Pixel resampling is nearest-neighbor: every output pixel is a copy of a source pixel. We scaled the eight 32 × 32 coin frames to 96 × 96 and compared them with a perfect 3× enlargement: not one pixel differed. The <strong>Smooth</strong> option, meant for painted or photographic art, gave the same frame 651 colors. The results are saved as lossless PNG; <strong>Apply to Session</strong> passes them on to <a href="https://spritepilot.com/export-download">Export &amp; Download</a>, or <strong>Download ZIP</strong> saves them as files.</p>
<p>Two things to know:</p>
<ul>
<li>There&#39;s no &quot;scale by 3×&quot; field. You type the target size, so make it a whole multiple of the sprite size — 64, 96 or 128 for a 32-pixel sprite. Uniform and Smart scaling resize every sprite to the size of the smallest or largest one, which is rarely a whole-number factor.</li>
<li>Leave <strong>Edge sharpening</strong> and <strong>Noise reduction</strong> off for pixel art; nearest-neighbor already gives you hard edges, and the tool itself labels noise reduction as not meant for pixel art.</li>
</ul>
<h2 id="a-checklist">A checklist</h2>
<ol>
<li>Nearest-neighbor everywhere: <code>image-rendering: pixelated</code>, <code>imageSmoothingEnabled = false</code> (again after every resize), Nearest or Point filtering in the engine.</li>
<li>Whole-number scales, measured in screen pixels — include <code>devicePixelRatio</code> in the math.</li>
<li>A base resolution that divides common screens, such as 320 × 180 or 640 × 360.</li>
<li>Whole-pixel positions for sprites and the camera.</li>
<li>To shrink, redraw — or undo an enlargement by exactly its factor.</li>
</ol>
]]></content:encoded>
    </item>
    <item>
      <title>Make Sprite Animations Loop Without a Hitch</title>
      <link>https://spritepilot.com/blog/seamless-animation-loops</link>
      <guid isPermaLink="true">https://spritepilot.com/blog/seamless-animation-loops</guid>
      <pubDate>Wed, 14 Oct 2026 12:00:00 GMT</pubDate>
      <description>Why looping sprite animations stutter at the seam, from duplicated frames to ping-pong that repeats its ends and easing in the wrong place, and how to fix each.</description>
      <category>animation</category>
      <category>pixel art</category>
      <category>sprite sheets</category>
      <content:encoded><![CDATA[<p>Your animation plays smoothly, except for a tiny stutter once every cycle. It&#39;s always in the same place: the seam, where the last frame hands over to the first. Seams go wrong in a handful of predictable ways, and each one has a simple fix.</p>
<h2 id="a-duplicated-frame-at-the-seam">A duplicated frame at the seam</h2>
<p>The classic hitch is a last frame that is the same as the first. The pose then appears twice in a row, so the motion stops for one frame every cycle:</p>
<p><figure><img src="https://spritepilot.com/blog/seamless-animation-loops/coin-loop-duplicate-vs-hold.gif" alt="Three spinning coins side by side: an 8-frame loop that spins evenly, a 9-frame loop whose last frame repeats the first and pauses briefly every turn, and an 8-frame loop that holds the first frame for half a second" width="592" height="236" loading="lazy" decoding="async"><figcaption>All three run at 10 fps. Middle: frame 9 is a copy of frame 1, so the face-on pose stays up for 0.2 s instead of 0.1 s. Right: a deliberate 0.5 s hold.</figcaption></figure></p>
<p>It tends to sneak in one of three ways: the first frame is copied to the end &quot;to close the loop&quot;, a rotation is rendered from 0° to 360° inclusive, or a captured GIF or video ends where it started.</p>
<p>The fix is to make the last frame one normal step <em>before</em> the first. For a full turn in 8 frames, each step is 360° ÷ 8 = 45°, so the frames sit at 0°, 45°, 90° … 315°. The next frame would be 360°, which looks the same as 0°, and that one is frame 1 of the next cycle. The same goes for any loop: if a bob moves 2 pixels per frame, the last frame should be 2 pixels away from the first, not on top of it.</p>
<p>To check a sheet, load it in SpritePilot&#39;s <a href="https://spritepilot.com/sprite-player">Sprite Player</a> and press <strong>Home</strong> and <strong>End</strong> to flip between the first and last frames. If they look identical, delete the last one.</p>
<h2 id="ping-pong-do-the-end-frames-repeat">Ping-pong: do the end frames repeat?</h2>
<p>Ping-pong plays the frames forward, then backward, then forward again. It suits motions that reverse on their own, such as breathing, swaying or a pendulum, and it nearly halves the drawings they need: 8 drawings give a 14-step cycle. Whether it hitches depends on how the turnaround is done:</p>
<p><figure><img src="https://spritepilot.com/blog/seamless-animation-loops/ping-pong-frame-order.png" alt="A diagram of which frame is on screen at each step: a loop runs 1 to 8 and starts again; SpritePilot's ping-pong runs 1 to 8 and back to 2 in 14 steps, showing each end frame once; a reversed copy appended to the frames runs 1 to 8 then 8 to 1, showing frames 8 and 1 twice in a row" width="1536" height="512" loading="lazy" decoding="async"><figcaption>For 8 frames, a clean ping-pong takes 14 steps. Appending the reversed frames makes 16 steps, with each end frame shown twice.</figcaption></figure></p>
<ul>
<li><strong>Turning without repeating</strong> shows frame 8 once, goes straight to 7, and back down to 1 before heading to 2. A full back-and-forth of 8 frames takes 2 × 8 − 2 = <strong>14 steps</strong>.</li>
<li><strong>Appending a reversed copy</strong> of all the frames (1–8 followed by 8–1) takes 16 steps and shows frames 8 and 1 twice in a row each, a hitch at both ends.</li>
</ul>
<p>SpritePilot&#39;s Sprite Player does the first. Its <strong>Ping-pong</strong> option steps 1 → 8, then 7 → 1, then 2 → 8, with each end frame on screen for a single step, and it keeps bouncing whether or not <strong>Loop</strong> is ticked. Phaser&#39;s <a href="https://docs.phaser.io/api-documentation/class/animations-animationstate" target="_blank" rel="noopener noreferrer"><code>yoyo</code></a> option turns around the same way: in Phaser&#39;s source, the animation steps from the last frame straight to the one before it, and when it repeats, from the first frame straight to the second.</p>
<p>The trap is exporting. The player&#39;s GIF and APNG buttons write the frames once, in sheet order, so the ping-pong isn&#39;t in the file. If you need a ping-pong GIF, or an engine that only loops forward, put the return trip in the sheet: frames 1 to 8 followed by 7 to 2, for 14 frames. Adding 8 to 1 instead brings back both doubled frames.</p>
<p>Don&#39;t ping-pong motions that only go one way. A spinning coin, a walk, fire or a waterfall played backward is visibly reversed: the coin spins back, the character walks backward.</p>
<h2 id="spacing-across-the-seam">Spacing across the seam</h2>
<p>Even with no duplicated frame, a loop can slow down at the seam. That happens when the spacing, how far things move between frames, changes there.</p>
<p><strong>One-way motion</strong> (spinning, walking, scrolling, a flag rippling) should keep the same spacing across the seam as everywhere else. The mistake to avoid is easing a loop as if it were a one-shot animation: slowing into the last frame and out of the first. Played once, that looks natural. Looped, it slows down and speeds up again every cycle.</p>
<p><strong>Back-and-forth motion</strong> (hovering, bobbing, breathing) is the opposite: it <em>should</em> slow down, at the top and bottom where it turns around. With even spacing it bounces off the ends like it hit a wall:</p>
<p><figure><img src="https://spritepilot.com/blog/seamless-animation-loops/coin-hover-even-vs-eased.gif" alt="Two hovering coins side by side: one moves up and down the same distance every frame and bounces at the ends, the other slows down near the top and bottom and floats; a track beside each marks the 12 heights" width="588" height="266" loading="lazy" decoding="async"><figcaption>Both loops have 12 frames at 10 fps. The tracks show each frame's height: even on the left, bunched at the ends on the right.</figcaption></figure></p>
<p>A sine curve gives you that easing for free, and it loops cleanly, because the step from the last frame back to the first is the same size as the step after it. This function returns the vertical offsets, in pixels, for each frame of a hover:</p>
<pre><code class="language-js">// Vertical offsets (in pixels) for a hover loop of `frames` drawings.
// The sine curve slows down at the top and bottom, and the last frame
// leads straight back into the first.
function hoverOffsets(frames, amplitude) {
  const offsets = []
  for (let i = 0; i &lt; frames; i++) {
    offsets.push(Math.round(amplitude * Math.sin((2 * Math.PI * i) / frames)))
  }
  return offsets
}

console.log(hoverOffsets(12, 6)) // [0, 3, 5, 6, 5, 3, 0, -3, -5, -6, -5, -3]</code></pre>
<p>Those are the heights of the eased coin above. The loop runs from <code>i = 0</code> to <code>frames − 1</code>, never to <code>frames</code> itself, which would duplicate frame 1. The steps between frames are 3, 2 and 1 pixels: large in the middle of the motion, small at the turns, and 3 again across the seam from −3 back to 0.</p>
<h2 id="holds-that-look-deliberate">Holds that look deliberate</h2>
<p>A pause isn&#39;t always a mistake. A coin that rests face-on for half a second, an idle that holds still before a blink, a breath held at the top: holds give a loop rhythm. The difference between a hold and a hitch is mostly length. One extra frame, 0.1 s at 10 fps, looks like a skipped beat. Five frames&#39; worth, as in the right-hand coin above, looks intended.</p>
<p>There are two ways to make one:</p>
<ul>
<li><strong>Per-frame durations</strong> in the engine or editor that plays the animation. <a href="https://spritepilot.com/blog/pixel-art-frame-rate">How Fast Should Pixel Art Animate?</a> shows how in Godot, Phaser, Unity and Aseprite.</li>
<li><strong>Repeating the frame</strong> in the sheet. That works everywhere, and it&#39;s the way to preview a hold in the Sprite Player, which shows every frame for the same time.</li>
</ul>
<h2 id="a-loop-checklist">A loop checklist</h2>
<p>Load the sheet in the <a href="https://spritepilot.com/sprite-player">Sprite Player</a> (no sheet handy? The <a href="https://spritepilot.com/blog/sprite-sheet-frame-size/coin-spin-strip.png">CC0 coin strip</a> is 8 frames of 32 × 32) and go through these:</p>
<ol>
<li><strong>End, then Home:</strong> flip between the last frame and the first. If they&#39;re identical, delete the last one.</li>
<li><strong>← and →:</strong> step through the frames and note how far things move each time. The jump from the last frame to the first (End, then Home again) should be the same size.</li>
<li><strong>Speed:</strong> play it slowly, at 2 to 4 FPS, where a hitch is easy to see, then at full speed.</li>
<li><strong>Ping-pong:</strong> only for motions that reverse. To export it, add the return frames without the ends: 8 frames become 14.</li>
<li><strong>Holds:</strong> make them long enough to look intended, or remove them.</li>
</ol>
<p>When it loops cleanly, the <strong>GIF</strong> and <strong>APNG</strong> buttons export it as an endlessly looping animation. <a href="https://spritepilot.com/guides/sprite-sheet-to-gif">How to Convert a Sprite Sheet to a GIF</a> explains when to pick which.</p>
]]></content:encoded>
    </item>
    <item>
      <title>Sprite Pivot Points Explained: Center, Feet and Flipping</title>
      <link>https://spritepilot.com/blog/sprite-pivot-points</link>
      <guid isPermaLink="true">https://spritepilot.com/blog/sprite-pivot-points</guid>
      <pubDate>Wed, 14 Oct 2026 12:00:00 GMT</pubDate>
      <description>Where a sprite's pivot belongs, why characters jump when they flip or change frames, and how to set it in Unity, Godot, Phaser and SpritePilot.</description>
      <category>animation</category>
      <category>unity</category>
      <category>godot</category>
      <category>phaser</category>
      <content:encoded><![CDATA[<p>Your character turns around and jumps sideways. It crouches and floats off the ground. It swings a staff and the whole body slides. The art is fine and the code moves the character smoothly; the problem is the point the sprite is positioned by, its <strong>pivot</strong>.</p>
<p>This article explains what a pivot is, where to put it, why the wrong one makes sprites jump, and how to set it in Unity, Godot, Phaser and SpritePilot.</p>
<h2 id="what-a-pivot-is">What a pivot is</h2>
<p>A sprite is a rectangle of pixels, but the game positions it by a single point. That point is the pivot: whatever coordinates you give the object, the pivot lands exactly there. Unity&#39;s documentation calls it the point &quot;Unity uses for transformations such as rotation&quot;, so it is also what the sprite turns and scales around.</p>
<p>Pivots are usually written as fractions of the frame, from 0 to 1, so they work for any frame size. The same point gets different names and conventions:</p>
<table>
<thead>
<tr>
<th></th>
<th>Center</th>
<th>Bottom center (feet)</th>
</tr>
</thead>
<tbody><tr>
<td>SpritePilot&#39;s JSON, Phaser <code>setOrigin</code></td>
<td>0.5, 0.5</td>
<td>0.5, 1</td>
</tr>
<tr>
<td>Unity, Normalized</td>
<td>0.5, 0.5</td>
<td>0.5, 0</td>
</tr>
<tr>
<td>Unity, Pixels (32 × 40 frame)</td>
<td>16, 20</td>
<td>16, 0</td>
</tr>
</tbody></table>
<p>Unity counts y upward from the bottom-left corner of the sprite; Phaser and SpritePilot count downward from the top-left. Mixing them up flips the pivot to the character&#39;s head.</p>
<p><figure><img src="https://spritepilot.com/blog/sprite-pivot-points/pivot-presets-on-a-frame.png" alt="A 32 × 40 pixel-art character in its frame with nine pivot points; the center at 0.5, 0.5 and the bottom center at 0.5, 1 are highlighted" width="1536" height="650" loading="lazy" decoding="async"><figcaption>The nine common presets. The center suits things that spin; the bottom center suits things that stand.</figcaption></figure></p>
<h2 id="where-the-pivot-should-go">Where the pivot should go</h2>
<ul>
<li><strong>Center</strong> for anything that spins, scales or has no ground: coins, projectiles, explosions, floating pickups. They rotate around their middle, which is what you want.</li>
<li><strong>Feet</strong> (bottom center) for characters, enemies and props that stand on something. The object&#39;s position becomes the point where it touches the ground, so placing it on a platform, snapping it to a tile or sorting it by depth works directly. Unity can sort by it too: set the Sprite Renderer&#39;s <strong>Sprite Sort Point</strong> to <strong>Pivot</strong>.</li>
<li><strong>Top-left</strong> for UI and anything aligned to a grid, where you think in corners.</li>
</ul>
<p>For pixel art, make sure the pivot lands on a whole pixel. A centered 25-pixel-wide sprite has its middle at 12.5 pixels, between two pixels, and Godot&#39;s documentation notes that centered pixel art can look deformed for exactly that reason. Even frame sizes avoid it.</p>
<h2 id="why-sprites-jump-between-frames">Why sprites jump between frames</h2>
<p>A pivot is defined per frame. When every frame has the same size and the character stands in the same spot in each, any pivot works. Trouble starts when frames differ: an attack frame drawn wider to fit a weapon, or a crouch drawn shorter.</p>
<p><figure><img src="https://spritepilot.com/blog/sprite-pivot-points/pivot-center-vs-feet-frame-sizes.gif" alt="Two copies of a character switching between idle, cast and crouch frames: with the pivot at each frame's center the body slides and the crouch floats; with the pivot at the feet only the pose changes" width="514" height="230" loading="lazy" decoding="async"><figcaption>Left: each frame's center is placed on the cross. Right: each frame's feet are. The dashed line is the ground.</figcaption></figure></p>
<p>Here the idle frame is 32 × 40 pixels, the cast frame 48 × 40 (16 pixels wider, all on the staff side) and the crouch frame 32 × 30. With the pivot at each frame&#39;s center, the body slides 8 pixels to the left during the cast and the crouch hangs 5 pixels above the ground. With the pivot on the feet in every frame, nothing moves except the pose.</p>
<p>There are two fixes. Give each frame its own pivot at the feet, which Unity&#39;s Sprite Editor allows, as do atlas files with a pivot per frame. Or, simpler and supported everywhere: <strong>draw or export every frame of a character on the same canvas, with the feet at the same spot</strong>, ideally the bottom center. Then one pivot is right for all of them.</p>
<h2 id="why-sprites-jump-when-they-flip">Why sprites jump when they flip</h2>
<p>Turning a character around is usually a mirror image instead of new art. A mirror needs an axis, and engines don&#39;t agree on where it is.</p>
<p><figure><img src="https://spritepilot.com/blog/sprite-pivot-points/flip-around-center-vs-feet.gif" alt="Two copies of the wide cast frame flipping left and right: mirrored around the frame's center the character jumps sideways; mirrored around the feet it stays in place while the staff swings over" width="554" height="230" loading="lazy" decoding="async"><figcaption>The same 48 × 40 frame mirrored two ways. The cyan line is the mirror axis.</figcaption></figure></p>
<p>In the cast frame the feet are 16 pixels from the left edge, not in the middle. Mirrored around the frame&#39;s center they move to 32 pixels, so the character jumps 16 pixels each time it turns. Mirrored around the feet, it doesn&#39;t move.</p>
<ul>
<li><strong>Unity</strong> mirrors around the pivot. The pivot is the origin of the sprite&#39;s own coordinates, and the Sprite Renderer&#39;s <strong>Flip</strong> mirrors those coordinates; per the documentation, it doesn&#39;t move the GameObject.</li>
<li><strong>Godot 4</strong>&#39;s <code>flip_h</code> mirrors the texture inside its own drawing rectangle, wherever <code>centered</code> and <code>offset</code> put that rectangle. In the <a href="https://github.com/godotengine/godot/blob/master/scene/2d/sprite_2d.cpp" target="_blank" rel="noopener noreferrer">Sprite2D source</a>, flipping negates the rectangle&#39;s width but leaves the offset alone.</li>
<li><strong>Phaser</strong>&#39;s documentation for <a href="https://github.com/phaserjs/phaser/blob/master/src/gameobjects/components/Flip.js" target="_blank" rel="noopener noreferrer">flipX</a> says &quot;Flipping always takes place from the middle of the texture&quot;. The origin you set doesn&#39;t change that. The exception is a frame that got its pivot from an atlas file, which flips around that pivot.</li>
</ul>
<p>The rule that makes all three behave: keep the feet in the horizontal middle of every frame. Then the frame&#39;s center and the feet share one axis, and a flip never moves the character.</p>
<h2 id="setting-the-pivot-in-each-engine">Setting the pivot in each engine</h2>
<h3 id="unity">Unity</h3>
<p>For a sheet sliced into several sprites, open the <strong>Sprite Editor</strong>, select a sprite and set <strong>Pivot</strong>: one of nine presets, or <strong>Custom</strong>. With Custom, <strong>Pivot Unit Mode</strong> chooses between <strong>Normalized</strong> and <strong>Pixels</strong>, and <strong>Custom Pivot</strong> takes the values. The <a href="https://docs.unity3d.com/Manual/sprite/sprite-editor/sprite-editor-window-reference.html" target="_blank" rel="noopener noreferrer">Sprite Editor reference</a> confirms that both modes start at 0, 0 in the bottom-left corner, so the feet of a 64 × 40 frame are 0.5, 0 normalized or 32, 0 in pixels. A single-sprite texture has the same <strong>Pivot</strong> setting in its import settings.</p>
<p>The pivot is in sprite pixels; <strong>Pixels Per Unit</strong> turns pixels into world units. With Pixels Per Unit at 32, a 64 × 40 frame is 2 units wide and 1.25 units tall, and with the pivot at the feet it extends 1 unit to each side of the GameObject&#39;s position and 1.25 units up. If a character&#39;s frames come from several textures, give them all the same Pixels Per Unit, or frames will change size as they play.</p>
<h3 id="godot-4">Godot 4</h3>
<p>Sprite2D and AnimatedSprite2D don&#39;t have a pivot property; they have <strong>Centered</strong> and <strong>Offset</strong>. With Centered on (the default), the texture&#39;s middle sits on the node&#39;s position, and Offset moves the texture from there, with positive y pointing down. To put the feet of 64 × 40 frames on the node&#39;s position, keep Centered on and set Offset to (0, -20), half the frame height upward. With Centered off, the same point needs (-32, -40).</p>
<p>Because <code>flip_h</code> doesn&#39;t mirror the offset, keep the feet horizontally centered in the frame. If you can&#39;t, mirror a parent node with a negative x scale instead, which mirrors around that node&#39;s origin. The Node2D documentation warns that a negative x scale reads back as a negative y scale plus a 180° rotation, so don&#39;t be surprised by the numbers.</p>
<h3 id="phaser">Phaser</h3>
<p>Phaser calls the pivot the <strong>origin</strong>. It runs from 0 to 1 and defaults to 0.5, the center:</p>
<pre><code class="language-js">// a grid sheet of 64 × 40 frames with the feet at the bottom center of each
this.load.spritesheet('wanderer', 'wanderer-poses-64x40.png', { frameWidth: 64, frameHeight: 40 })

// in create(): (160, 120) is now the point between the character's feet
const hero = this.add.sprite(160, 120, 'wanderer', 0).setOrigin(0.5, 1)
hero.setFlipX(true) // mirrors inside the frame; the feet are in its middle, so they stay put</code></pre>
<p>When a frame comes from an atlas whose JSON has a <code>pivot</code> (or <code>anchor</code>) for it, Phaser applies it as the origin every time that frame is shown.</p>
<h3 id="plain-canvas">Plain canvas</h3>
<p>Drawing sprites yourself, translate to the position first and draw the frame offset by its pivot; a flip is then a scale around the pivot. This runs as written:</p>
<pre><code class="language-js">// Draw a frame so its pivot lands on (x, y). pivotX and pivotY run from 0 to 1,
// from the top-left. A flip mirrors around the pivot, so the feet stay put.
function drawWithPivot(ctx, sheet, r, x, y, pivotX, pivotY, flipX = false) {
  ctx.save()
  ctx.translate(x, y)
  if (flipX) ctx.scale(-1, 1)
  ctx.drawImage(sheet, r.x, r.y, r.w, r.h, -pivotX * r.w, -pivotY * r.h, r.w, r.h)
  ctx.restore()
}</code></pre>
<h2 id="spritepilots-pivot-setting">SpritePilot&#39;s pivot setting</h2>
<p><figure><img src="https://spritepilot.com/blog/sprite-pivot-points/spritepilot-export-pivot-point.png" alt="SpritePilot's Export metadata options, with TexturePacker selected and the Pivot Point grid set to the bottom center, x 0.5 and y 1" width="1536" height="1120" loading="lazy" decoding="async"><figcaption>Export &amp; Download's Pivot Point: nine presets, written into the metadata.</figcaption></figure></p>
<p>In <a href="https://spritepilot.com/export-download">Export &amp; Download</a>, <strong>Pivot Point</strong> offers the same nine presets as a 3 × 3 grid, with x and y at 0, 0.5 or 1; the default is the center. It appears when the metadata format is TexturePacker, Phaser 3 or Godot JSON, and SpritePilot writes it into every frame, for example <code>&quot;pivot&quot;: { &quot;x&quot;: 0.5, &quot;y&quot;: 1 }</code> for the bottom center. SpriteFrames (.tres), Aseprite JSON, Basic JSON and CSS don&#39;t carry a pivot.</p>
<p>What happens next depends on the reader. Phaser applies the pivot automatically, as described above. PixiJS reads a field called <code>anchor</code> rather than <code>pivot</code>, so set the anchor in code. Godot JSON is for your own import script, and in Unity you set the pivot in the Sprite Editor.</p>
<p>One pivot applies to the whole export, which is another reason to put every frame on the same canvas with the feet at the bottom center. The character in this article is drawn that way and free to use (CC0): <a href="https://spritepilot.com/blog/sprite-pivot-points/wanderer-poses-64x40.png">download the three poses</a>, 64 × 40 pixels each.</p>
<h2 id="a-checklist">A checklist</h2>
<ol>
<li>Things that spin get a center pivot; things that stand get a feet pivot.</li>
<li>Draw all frames of a character on one canvas size, feet at the same pixel in each.</li>
<li>Keep the feet in the horizontal middle of the frame so flips don&#39;t move the character.</li>
<li>Check the convention: Unity counts y from the bottom, Phaser and SpritePilot from the top.</li>
<li>Play the animation and flip it once before building a level around it.</li>
</ol>
<p>For the related problem of frames that lost their position when their empty border was cut off, see <a href="https://spritepilot.com/blog/trim-transparent-pixels">Trimming Transparent Pixels From Sprites</a>.</p>
]]></content:encoded>
    </item>
    <item>
      <title>How Big Can a Sprite Sheet Be? Texture Size Limits, Memory and Power of Two</title>
      <link>https://spritepilot.com/blog/sprite-sheet-size-limits</link>
      <guid isPermaLink="true">https://spritepilot.com/blog/sprite-sheet-size-limits</guid>
      <pubDate>Wed, 14 Oct 2026 12:00:00 GMT</pubDate>
      <description>Maximum texture sizes on real devices, how much memory a sprite sheet takes, when to split it, and when power-of-two sizes still matter in WebGL, Unity and Godot.</description>
      <category>sprite sheets</category>
      <category>web</category>
      <category>unity</category>
      <category>godot</category>
      <content:encoded><![CDATA[<p>Your sprite sheet looks perfect on your own computer, then turns into a black box on a phone, or the engine quietly shrinks it and your pixel art goes soft. Three limits decide how big a sheet can be: the largest texture the graphics chip accepts, the memory the sheet takes once it&#39;s loaded, and, in a few remaining cases, the old power-of-two rule. Here is each one in numbers, and how to size and split sheets so they work everywhere you ship.</p>
<h2 id="the-hard-limit-maximum-texture-size">The hard limit: maximum texture size</h2>
<p>Every GPU has a largest texture it will accept, measured as the maximum width or height in pixels. WebGL reports it as <code>MAX_TEXTURE_SIZE</code>. Go one pixel over and the texture simply isn&#39;t created. In a Chromium test, uploading a texture one pixel wider than the limit failed with an <code>INVALID_VALUE</code> error, and the sprite drawn with it came out solid black.</p>
<p>How big is the limit on players&#39; devices? <a href="https://web3dsurvey.com/webgl2/parameters/MAX_TEXTURE_SIZE" target="_blank" rel="noopener noreferrer">web3dsurvey.com</a> collects WebGL capabilities from visitors&#39; browsers. Its WebGL 2 figures, checked in October 2026:</p>
<table>
<thead>
<tr>
<th>Limit of at least</th>
<th>All reports</th>
<th>Android</th>
<th>iOS</th>
<th>Windows</th>
<th>macOS</th>
</tr>
</thead>
<tbody><tr>
<td>4096</td>
<td>100%</td>
<td>100%</td>
<td>100%</td>
<td>100%</td>
<td>100%</td>
</tr>
<tr>
<td>8192</td>
<td>97%</td>
<td>76%</td>
<td>100%</td>
<td>100%</td>
<td>100%</td>
</tr>
<tr>
<td>16384</td>
<td>85%</td>
<td>3%</td>
<td>98%</td>
<td>97%</td>
<td>94%</td>
</tr>
</tbody></table>
<p>So:</p>
<ul>
<li><strong>4096 × 4096 works everywhere.</strong> MDN&#39;s <a href="https://developer.mozilla.org/en-US/docs/Web/API/WebGL_API/WebGL_best_practices" target="_blank" rel="noopener noreferrer">WebGL best practices</a> lists 4096 among the limits you can count on, and Godot&#39;s import documentation warns that mobile and web platforms usually can&#39;t display textures larger than 4096 × 4096.</li>
<li><strong>8192 is safe on Windows, Mac and iOS</strong>, but about one Android device in four can&#39;t load it.</li>
<li><strong>16384 rules out Android.</strong> Only 3% of Android reports reach it, against 94–98% on iOS, Windows and macOS.</li>
</ul>
<p>Native engines have their own ceilings. Unity&#39;s texture importer goes up to 16384, and its manual lists the maximum per class of GPU: 4096 for OpenGL ES 3.0 devices, 8192 for OpenGL ES 3.1 and 16384 for desktop GPUs from the DirectX 11 era on. Unity also scales any texture larger than the importer&#39;s <strong>Max Size</strong> setting down at import time, so a 4096 sheet imported with Max Size 2048 loses half its resolution, and pixel art comes out blurred. Godot&#39;s <strong>Size Limit</strong> import option does the same on purpose, which is one way to make a sheet fit a phone.</p>
<p>To see the limit of the device in front of you, paste this into the browser&#39;s console:</p>
<pre><code class="language-js">const canvas = document.createElement('canvas')
const gl = canvas.getContext('webgl2') || canvas.getContext('webgl')
console.log(gl.getParameter(gl.MAX_TEXTURE_SIZE))</code></pre>
<h2 id="memory-width-height-4-bytes-plus-a-third-for-mipmaps">Memory: width × height × 4 bytes, plus a third for mipmaps</h2>
<p>A sheet that fits the limit can still be too heavy. Once loaded, an uncompressed texture costs 4 bytes per pixel (red, green, blue and alpha), whatever file format it came from; <a href="https://spritepilot.com/blog/png-webp-jpg-for-sprites">PNG, WebP or JPG for Game Sprites?</a> shows why. Memory grows with the square of the side, so doubling the width and height of a sheet multiplies its memory by four:</p>
<p><figure><img src="https://spritepilot.com/blog/sprite-sheet-size-limits/texture-memory-by-size.png" alt="Nested squares drawn to scale for 1024, 2048, 4096 and 8192 pixel textures: 4, 16, 64 and 256 MiB, or 5.33, 21.33, 85.33 and 341.33 MiB with mipmaps" width="1536" height="1102" loading="lazy" decoding="async"><figcaption>Area is memory: the 8192 square holds sixty-four 1024 squares.</figcaption></figure></p>
<table>
<thead>
<tr>
<th>Sheet</th>
<th>Memory</th>
<th>With mipmaps</th>
<th>Compressed, 8 bits per pixel</th>
</tr>
</thead>
<tbody><tr>
<td>1024 × 1024</td>
<td>4 MiB</td>
<td>5.33 MiB</td>
<td>1 MiB</td>
</tr>
<tr>
<td>2048 × 2048</td>
<td>16 MiB</td>
<td>21.33 MiB</td>
<td>4 MiB</td>
</tr>
<tr>
<td>4096 × 4096</td>
<td>64 MiB</td>
<td>85.33 MiB</td>
<td>16 MiB</td>
</tr>
<tr>
<td>8192 × 8192</td>
<td>256 MiB</td>
<td>341.33 MiB</td>
<td>64 MiB</td>
</tr>
</tbody></table>
<p>The arithmetic for 2048: 2048 × 2048 × 4 = 16,777,216 bytes, which is 16 MiB.</p>
<p><strong>Mipmaps</strong> are pre-shrunk copies of the texture at half, a quarter, an eighth of the size, and so on, used when the texture is drawn small. Each level has a quarter of the pixels of the one before, so the whole chain adds 1/4 + 1/16 + 1/64 + … = 1/3 to the memory. Godot&#39;s documentation lists the same 21.33 MiB for a 2048 × 2048 RGBA texture with mipmaps. Most 2D games don&#39;t need them: Godot&#39;s default import settings for 2D leave them off, and they soften pixel art.</p>
<p>GPU compression formats such as DXT (also called BC), ETC2 and ASTC store 8 bits per pixel or fewer instead of 32, according to Unity&#39;s table of formats: a quarter of the memory at most. They are lossy and work in small blocks of pixels, though, and Godot&#39;s documentation recommends keeping VRAM compression off for pixel art. One more multiplier to watch: Unity&#39;s <strong>Read/Write</strong> import option keeps an extra copy for scripts, which the manual says doubles the memory the texture needs.</p>
<h2 id="when-to-split-one-sheet-into-several">When to split one sheet into several</h2>
<p>Sheets exist to save work for the GPU. Unity&#39;s manual puts it plainly: it only needs one draw call for all the sprites in a sprite atlas. Every time the game draws from a different texture, it has to switch. So split along the lines of what is drawn together:</p>
<ul>
<li><strong>Split when a sheet passes your platform&#39;s limit.</strong> For web and mobile, that means 4096.</li>
<li><strong>Split by when things are needed.</strong> A level&#39;s tiles and enemies don&#39;t need to sit in memory while another level plays.</li>
<li><strong>Keep each animation on one texture</strong>, and keep sprites that appear on screen together on the same one.</li>
</ul>
<p>Big frames fill sheets quickly. A 1920 × 1080 video frame fits 4 across and 7 down on an 8192 × 8192 sheet, so that sheet holds 28 frames. A 4096 sheet holds 6.</p>
<p><figure><img src="https://spritepilot.com/blog/sprite-sheet-size-limits/pad-repack-or-split.png" alt="The same 120 frames of 128 × 128 shown three ways: padded to a 2048 × 2048 power of two with 53% empty, re-packed into 2048 × 1024 with 6% empty, and split into two 1024 × 1024 pages" width="1536" height="920" loading="lazy" decoding="async"><figcaption>Padding to a power of two can double the memory; choosing the layout to fill one costs almost nothing.</figcaption></figure></p>
<h2 id="power-of-two-when-it-still-matters">Power of two: when it still matters</h2>
<p>Power-of-two sizes are 256, 512, 1024, 2048, 4096 and so on. Older graphics hardware required them. Today it depends on where the sheet runs:</p>
<ul>
<li><strong>WebGL 1</strong> can&#39;t use mipmaps or repeating (tiling) wrap modes on textures whose sides aren&#39;t powers of two; <a href="https://developer.mozilla.org/en-US/docs/Web/API/WebGL_API/Tutorial/Using_textures_in_WebGL" target="_blank" rel="noopener noreferrer">MDN</a> says they must use <code>CLAMP_TO_EDGE</code>. In the Chromium test, a 96 × 64 texture set to repeat drew solid black and <code>generateMipmap</code> failed, while 128 × 64 worked. A sprite sheet drawn the usual way, clamped and without mipmaps, works at any size.</li>
<li><strong>WebGL 2</strong> lifts the restriction: Khronos listed non-power-of-two textures among WebGL 2.0&#39;s additions, and the same 96 × 64 texture repeated and mipmapped without errors.</li>
<li><strong>Unity</strong> accepts other sizes, but <a href="https://docs.unity3d.com/6000.0/Documentation/Manual/ImportingTextures.html" target="_blank" rel="noopener noreferrer">its manual</a> notes that they &quot;generally take slightly more memory and might be slower for the GPU to sample&quot;. On a platform without support, Unity scales and pads the texture up to the next power of two, and the <strong>Non Power of 2</strong> import option does that on purpose. PVRTC compression on iOS also needs square textures: Unity&#39;s own example turns a 257 × 511 texture into 512 × 512.</li>
<li><strong>Godot</strong> recommends power-of-two sizes but doesn&#39;t require them, and textures don&#39;t have to be square.</li>
</ul>
<p>Where you do need a power of two, don&#39;t pad blindly. The figure above shows a 1536 × 1280 sheet padded to 2048 × 2048: 16 MiB, more than half of it empty. The same 120 frames re-packed 16 across fill 2048 × 1024 almost exactly, for 8 MiB.</p>
<h2 id="what-spritepilot-does-with-sheet-sizes">What SpritePilot does with sheet sizes</h2>
<p>SpritePilot&#39;s <a href="https://spritepilot.com/bulk-sprite-sheets">Sheet Maker</a> packs separate images into one sheet. Its <strong>Max Width</strong> and <strong>Max Height</strong> go from 256 to 8192 pixels, starting at 2048, and the sheet comes out as the tight box around the packed images. It doesn&#39;t pad to a power of two. When the images don&#39;t fit, it tells you the size that would hold them all, or, if even 8192 × 8192 is too small, that they need to be split over several sheets.</p>
<p>The <a href="https://spritepilot.com/gif-to-sprite-sheet">GIF to Sprite Sheet</a> and <a href="https://spritepilot.com/video-to-sprite-sheet">Video to Sprite Sheet</a> converters also stop at 8192 pixels a side. Over that, the download is disabled and a warning says which column counts would fit. For video, the <strong>Auto</strong> frame size picks the largest frames that still fit one 8192 sheet. Treat 8192 as SpritePilot&#39;s ceiling, not a target: for Android and the web, set Max Width and Max Height to 4096 or less. To fit more frames into the same sheet, see <a href="https://spritepilot.com/blog/trim-transparent-pixels">trimming transparent pixels</a>.</p>
<h2 id="a-sizing-checklist">A sizing checklist</h2>
<ul>
<li><strong>Web and mobile:</strong> keep each sheet at 4096 × 4096 or less. <strong>Windows and Mac only:</strong> 8192 is safe.</li>
<li><strong>Memory = width × height × 4 bytes.</strong> Mipmaps add a third; most 2D games can leave them off.</li>
<li><strong>Split by use:</strong> one animation per texture, things on screen together on one sheet, levels loaded separately.</li>
<li><strong>Powers of two</strong> matter for WebGL 1 with mipmaps or tiling, for PVRTC, and on platforms without support. Re-pack to fill one rather than padding.</li>
<li><strong>Check a device&#39;s real limit</strong> with <code>gl.getParameter(gl.MAX_TEXTURE_SIZE)</code>.</li>
</ul>
]]></content:encoded>
    </item>
    <item>
      <title>Sprite Sheet vs. Texture Atlas: What's the Difference?</title>
      <link>https://spritepilot.com/blog/sprite-sheet-vs-texture-atlas</link>
      <guid isPermaLink="true">https://spritepilot.com/blog/sprite-sheet-vs-texture-atlas</guid>
      <pubDate>Wed, 14 Oct 2026 12:00:00 GMT</pubDate>
      <description>Both put many sprites in one image. How a grid sheet differs from a packed atlas, what the atlas data file holds, and when each is the better choice.</description>
      <category>sprite sheets</category>
      <category>basics</category>
      <category>file formats</category>
      <content:encoded><![CDATA[<p>Search for either term and you&#39;ll find them used for the same thing: one image with many sprites in it. Engines and tools are stricter. The difference isn&#39;t the picture, it&#39;s how you find a sprite inside it. A <strong>sprite sheet</strong> is a grid, described by a frame size. A <strong>texture atlas</strong> is packed, and a separate data file lists where every sprite is.</p>
<p>That difference decides how you load the file, how much memory it takes, and what it can hold. Here are both, with real measurements from the same set of sprites.</p>
<h2 id="a-sprite-sheet-is-a-grid-you-can-describe-in-one-line">A sprite sheet is a grid you can describe in one line</h2>
<p>In a grid sheet every cell is the same size. Give your engine the frame width and height (plus the margin and spacing, if the sheet has gaps) and it can work out every frame&#39;s rectangle with arithmetic. Frames are referred to by number: frame 0, frame 1, and so on, left to right and top to bottom.</p>
<p><a href="https://github.com/phaserjs/phaser/blob/master/src/loader/filetypes/SpriteSheetFile.js" target="_blank" rel="noopener noreferrer">Phaser&#39;s loader documentation</a> draws the line clearly: in Phaser, a sprite sheet means a fixed-size sheet whose frames are referenced by number, while in a texture atlas frames are packed to take up the least space and are referenced by name. It also warns that some software uses &quot;sprite sheet&quot; to mean an atlas.</p>
<p>Unity, Godot and Phaser all cut a grid by themselves:</p>
<ul>
<li><strong>Unity:</strong> Sprite Editor → Slice → <strong>Grid By Cell Size</strong> or <strong>Grid By Cell Count</strong>, with <strong>Offset</strong> and <strong>Padding</strong> for sheets with gaps.</li>
<li><strong>Godot 4:</strong> a Sprite2D&#39;s <strong>Hframes</strong> and <strong>Vframes</strong>, or <strong>Add frames from a Sprite Sheet</strong> in the SpriteFrames panel.</li>
<li><strong>Phaser:</strong> <code>this.load.spritesheet()</code> with <code>frameWidth</code> and <code>frameHeight</code>.</li>
</ul>
<p>The catch is that every cell is as big as the biggest sprite. That&#39;s fine for an animation whose frames all share one canvas, and wasteful for anything else. If you have a grid sheet and no notes, <a href="https://spritepilot.com/blog/sprite-sheet-frame-size">How to Find the Frame Size of a Sprite Sheet</a> shows how to recover the numbers.</p>
<h2 id="a-texture-atlas-is-packed-and-comes-with-a-map">A texture atlas is packed and comes with a map</h2>
<p>An atlas places sprites of any size wherever they fit. Because no formula can find them again, the packer writes a data file next to the image: usually JSON, sometimes XML or a .plist. Each entry names a sprite and gives its rectangle.</p>
<p><figure><img src="https://spritepilot.com/blog/sprite-sheet-vs-texture-atlas/atlas-json-describes-each-rectangle.png" alt="A packed sprite sheet with one sprite outlined in amber, next to its JSON entry giving x 42, y 0, width 48 and height 40" width="1536" height="652" loading="lazy" decoding="async"><figcaption>The engine never measures anything: it looks the name up in the JSON and copies that rectangle.</figcaption></figure></p>
<p>This is a real entry, plus the file&#39;s <code>meta</code> section, from a sheet packed by SpritePilot&#39;s <a href="https://spritepilot.com/bulk-sprite-sheets">Sheet Maker</a> in the TexturePacker &quot;JSON (Hash)&quot; layout, which Phaser and PixiJS read directly:</p>
<pre><code class="language-json">&quot;chest.png&quot;: {
  &quot;frame&quot;: { &quot;x&quot;: 130, &quot;y&quot;: 102, &quot;w&quot;: 28, &quot;h&quot;: 22 },
  &quot;rotated&quot;: false,
  &quot;trimmed&quot;: false,
  &quot;spriteSourceSize&quot;: { &quot;x&quot;: 0, &quot;y&quot;: 0, &quot;w&quot;: 28, &quot;h&quot;: 22 },
  &quot;sourceSize&quot;: { &quot;w&quot;: 28, &quot;h&quot;: 22 }
},
…
&quot;meta&quot;: {
  &quot;app&quot;: &quot;SpritePilot&quot;,
  &quot;version&quot;: &quot;1.0&quot;,
  &quot;image&quot;: &quot;sprite-sheet.png&quot;,
  &quot;format&quot;: &quot;RGBA8888&quot;,
  &quot;size&quot;: { &quot;w&quot;: 164, &quot;h&quot;: 162 },
  &quot;scale&quot;: &quot;1&quot;
}</code></pre>
<p>What each field means:</p>
<ul>
<li><strong>frame</strong> — where the sprite&#39;s pixels are in the sheet.</li>
<li><strong>rotated</strong> — whether the packer turned the sprite 90° to make it fit. Some packers do; SpritePilot never does.</li>
<li><strong>trimmed</strong>, <strong>spriteSourceSize</strong>, <strong>sourceSize</strong> — whether the transparent border around the sprite was cut off, and where the remaining pixels sat in the original image, so the engine can put them back in the same place. <a href="https://spritepilot.com/blog/trim-transparent-pixels">Trimming Transparent Pixels From Sprites</a> covers this in detail.</li>
<li><strong>meta</strong> — the image file the rectangles refer to and its size.</li>
</ul>
<h2 id="the-same-30-sprites-packed-both-ways">The same 30 sprites, packed both ways</h2>
<p>Here is one set of 30 original sprites packed twice by SpritePilot&#39;s Sheet Maker: a character in three poses, eleven props from 14 × 12 to 40 × 56 pixels, eight 32 × 32 coin frames and eight 16 × 16 tiles. Same images, same 2 px padding; only the <strong>Algorithm</strong> setting changed.</p>
<p><figure><img src="https://spritepilot.com/blog/sprite-sheet-vs-texture-atlas/grid-vs-bin-packing.png" alt="The 30 sprites packed by Simple Grid into 346 × 288 pixels, 78.9% empty, and by Bin Packing into 164 × 162 pixels, 20.9% empty" width="1536" height="808" loading="lazy" decoding="async"><figcaption>Both sheets at the same scale. Rose marks pixels that no sprite covers.</figcaption></figure></p>
<table>
<thead>
<tr>
<th></th>
<th>Simple Grid</th>
<th>Bin Packing</th>
</tr>
</thead>
<tbody><tr>
<td>Sheet size</td>
<td>346 × 288 px</td>
<td>164 × 162 px</td>
</tr>
<tr>
<td>Pixels</td>
<td>99,648</td>
<td>26,568</td>
</tr>
<tr>
<td>Not covered by a sprite</td>
<td>78.9%</td>
<td>20.9%</td>
</tr>
<tr>
<td>Memory as RGBA (4 bytes per pixel)</td>
<td>399 KB</td>
<td>106 KB</td>
</tr>
<tr>
<td>PNG file</td>
<td>11.0 KB</td>
<td>8.5 KB</td>
</tr>
</tbody></table>
<p>The sprites themselves cover 21,020 pixels. The grid makes every cell 56 × 56, the widest sprite by the tallest, so a 16 × 16 tile uses 8% of its cell. The packer puts each sprite against its neighbors; most of what&#39;s left is the padding. Packed again with 0 padding, the atlas comes out at 152 × 150 pixels and only 7.8% empty.</p>
<p>Notice the last two rows. The PNG files differ by less than 3 KB, because PNG compresses big transparent areas to almost nothing. Memory is different: unless the engine converts it to a compressed GPU format, a loaded texture costs 4 bytes per pixel. Unity&#39;s texture format reference lists RGBA 32 bit at 32 bits per pixel, 4 MB for a 1024 × 1024 texture, and Godot&#39;s import documentation describes lossless textures as small on disk but large in memory. Judge a sheet by its pixel count, not its file size: here the grid costs 3.75 times as much memory.</p>
<h2 id="atlases-and-draw-calls">Atlases and draw calls</h2>
<p>The other reason atlases exist is speed. Unity&#39;s manual on <a href="https://docs.unity3d.com/6000.4/Documentation/Manual/sprite/atlas/atlas-introduction.html" target="_blank" rel="noopener noreferrer">sprite atlases</a> explains that when each sprite has its own texture, Unity sends a separate draw call to the GPU for each one, while sprites in one atlas need only one draw call.</p>
<p>How that plays out depends on the engine:</p>
<ul>
<li><strong>Unity</strong> packs atlases for you. Import sprites or grid sheets as usual, add them to a <strong>Sprite Atlas</strong> asset, and Unity combines them. Its packing options include <strong>Tight Packing</strong> (packing each sprite by its mesh shape instead of its rectangle), <strong>Allow Rotation</strong> and <strong>Padding</strong>, 4 pixels by default.</li>
<li><strong>Godot 4</strong> has a <strong>TextureAtlas</strong> import type; the <a href="https://docs.godotengine.org/en/stable/tutorials/assets_pipeline/importing_images.html" target="_blank" rel="noopener noreferrer">importing images documentation</a> says it &quot;can be used to reduce memory usage for animated 2D sprites&quot;.</li>
<li><strong>Phaser</strong> loads a packed atlas with <code>this.load.atlas()</code>, which accepts the JSON Hash and JSON Array formats that TexturePacker and SpritePilot write, and PixiJS reads the same fields. In web engines like these you usually ship a ready-made atlas file.</li>
</ul>
<h2 id="what-changes-in-code">What changes in code</h2>
<p>Under the hood, the only difference is where the rectangle comes from. This plain JavaScript draws from either kind of sheet onto a canvas, and was checked pixel for pixel against SpritePilot&#39;s real output:</p>
<pre><code class="language-js">// A grid: the rectangle comes from arithmetic.
function gridFrame(index, { frameWidth, frameHeight, columns, margin = 0, spacing = 0 }) {
  const col = index % columns
  const row = Math.floor(index / columns)
  return {
    x: margin + col * (frameWidth + spacing),
    y: margin + row * (frameHeight + spacing),
    w: frameWidth,
    h: frameHeight,
  }
}

// An atlas: the rectangle comes from the data file, by name.
function atlasFrame(atlas, name) {
  return atlas.frames[name].frame
}

// Drawing is the same call either way.
function drawRect(ctx, sheet, r, x, y) {
  ctx.drawImage(sheet, r.x, r.y, r.w, r.h, x, y, r.w, r.h)
}</code></pre>
<p>A grid needs a few numbers for the whole sheet; an atlas needs one entry per sprite, and that list has to travel with the image.</p>
<h2 id="which-one-to-use">Which one to use</h2>
<p>Use a <strong>grid sheet</strong> when:</p>
<ul>
<li>the sprites are frames of one animation drawn on the same canvas size,</li>
<li>the image is a tileset: tile map editors and engines expect tiles on a regular grid,</li>
<li>you want the simplest import: every engine slices a grid without extra files.</li>
</ul>
<p>Use a <strong>packed atlas</strong> when:</p>
<ul>
<li>the sprites have different sizes: items, props, UI icons, effects,</li>
<li>you have many animations and want them in one texture,</li>
<li>frames have lots of empty space that trimming could remove,</li>
<li>memory or draw calls matter, as they do on phones and in browsers.</li>
</ul>
<p>You can also mix them: an atlas can hold animation frames named <code>walk_0</code>, <code>walk_1</code> and so on, next to everything else.</p>
<h2 id="try-it">Try it</h2>
<p><figure><img src="https://spritepilot.com/blog/sprite-sheet-vs-texture-atlas/sheet-maker-bin-packing.png" alt="SpritePilot's Sheet Maker configuration: Max Width and Max Height 2048, Padding 2, Algorithm Bin Packing, Background Fill transparent and JSON format TexturePacker JSON (Hash)" width="1720" height="732" loading="lazy" decoding="async"><figcaption>The Sheet Maker's settings. Switch Algorithm between Bin Packing and Simple Grid and the sheet rebuilds at once.</figcaption></figure></p>
<p>Load your images into SpritePilot&#39;s <a href="https://spritepilot.com/bulk-sprite-sheets">Sheet Maker</a>, generate the sheet once, then switch <strong>Algorithm</strong> between <strong>Bin Packing</strong> and <strong>Simple Grid</strong> and compare the dimensions it reports. It downloads the PNG with a TexturePacker JSON (Hash) file or a simple list, runs in your browser and uploads nothing. The step-by-step is in <a href="https://spritepilot.com/guides/make-a-sprite-sheet">How to Make a Sprite Sheet</a>, and <a href="https://spritepilot.com/guides/export-formats-explained">Sprite Sheet Export Formats Explained</a> covers which data format suits which engine.</p>
]]></content:encoded>
    </item>
    <item>
      <title>Squash and Stretch for Sprite Animation (with a Bouncing Ball)</title>
      <link>https://spritepilot.com/blog/squash-and-stretch-sprites</link>
      <guid isPermaLink="true">https://spritepilot.com/blog/squash-and-stretch-sprites</guid>
      <pubDate>Wed, 14 Oct 2026 12:00:00 GMT</pubDate>
      <description>Why squash and stretch make sprites feel heavy and fast, how to keep the volume right with simple arithmetic, and when to use it, frame by frame.</description>
      <category>animation</category>
      <category>pixel art</category>
      <content:encoded><![CDATA[<p>A ball that keeps its perfect circle all the way down and all the way back up looks like it&#39;s made of glass, or like it bounced off an invisible floor. Change a single frame so the ball flattens on impact and it suddenly has weight. That change is squash and stretch, the first of the twelve principles of animation that Disney animators Ollie Johnston and Frank Thomas set out in their 1981 book <em>The Illusion of Life</em> (<a href="https://en.wikipedia.org/wiki/Twelve_basic_principles_of_animation" target="_blank" rel="noopener noreferrer">summary on Wikipedia</a>).</p>
<p>This article takes a real 8-frame bouncing ball apart, measures it, and shows the three things that make squash and stretch work: where it goes, how much, and for how long.</p>
<h2 id="what-squash-and-stretch-tells-the-viewer">What squash and stretch tells the viewer</h2>
<ul>
<li><strong>Squash</strong> is a shape compressing against something: a ball hitting the floor, a character landing, a fist meeting a wall. It shows force. How much the shape gives tells the viewer what it&#39;s made of: a rubber ball flattens a lot, a bowling ball hardly at all.</li>
<li><strong>Stretch</strong> is a shape lengthening along its direction of travel. It shows speed, and in a sprite it also bridges the gap between two positions that are far apart, so a fast drop reads as one motion instead of a jump.</li>
</ul>
<p>Both only work if the object still looks like the same object. That comes down to keeping its size, which you can measure.</p>
<h2 id="the-bouncing-ball-frame-by-frame">The bouncing ball, frame by frame</h2>
<p>SpritePilot&#39;s bundled sample, <code>ball-bounce.png</code>, is a 256 × 128 sheet with 8 frames of 64 × 64 pixels, 4 columns by 2 rows. Here is every frame, with the ball&#39;s size and the number of pixels it covers:</p>
<p><figure><img src="https://spritepilot.com/blog/squash-and-stretch-sprites/ball-bounce-frames-measured.png" alt="Eight frames of a bouncing ball, each labeled with the ball's width and height and its pixel count; frame 5, the flattened impact frame at 37 by 17 pixels, is highlighted" width="1536" height="518" loading="lazy" decoding="async"><figcaption>Frame 1 is the top of the bounce, frames 2 to 4 fall, frame 5 is the squash, and frames 6 to 8 reuse 4, 3 and 2 on the way up.</figcaption></figure></p>
<p>Three things are going on:</p>
<ol>
<li><strong>Spacing.</strong> The ball&#39;s top edge moves 3, then 5, then 9 pixels between frames 1 and 4. The gaps grow because the ball is accelerating under gravity, and the frames after the impact repeat them in reverse.</li>
<li><strong>Stretch.</strong> Without the one-pixel bumps at its edges, frame 1&#39;s ball is 23 × 25 pixels. The falling frames are 23 × 26: one pixel taller, a light stretch along the direction of travel.</li>
<li><strong>Squash.</strong> Frame 5 is 37 × 17 pixels, 14 pixels wider and 9 pixels shorter than the falling frames. Its bottom edge sits on the lowest row the ball reaches, its highlight flattens with it, and its shadow widens from 17 to 33 pixels.</li>
</ol>
<p>The squash lasts exactly one frame. Frame 6 is already the stretched rising shape again.</p>
<h2 id="keep-the-area-the-same">Keep the area the same</h2>
<p>The rule that keeps a squashed ball looking like the same ball is to keep its volume. On a flat sprite, volume means area, and the area of an ellipse is π ÷ 4 × width × height. The π ÷ 4 never changes, so the rule becomes simple arithmetic: <strong>keep width × height the same.</strong></p>
<p><figure><img src="https://spritepilot.com/blog/squash-and-stretch-sprites/squash-stretch-area.png" alt="Four pixel-art ellipses on a ground line: a 24 by 24 ball, a 36 by 16 squash and a 20 by 29 stretch all cover about 450 pixels, while a 24 by 16 squash without widening covers 304 pixels, 68 percent of the original" width="1536" height="554" loading="lazy" decoding="async"><figcaption>Width × height stays at 576 (or near it) for the squash and the stretch. Flatten without widening and a third of the ball disappears.</figcaption></figure></p>
<p>For a 24 × 24 ball, width × height is 576:</p>
<ul>
<li><strong>Squash</strong> it to 16 pixels tall and it needs 576 ÷ 16 = <strong>36 pixels</strong> of width.</li>
<li><strong>Stretch</strong> it to 20 pixels wide and it needs 576 ÷ 20 = 28.8, so <strong>29 pixels</strong> of height.</li>
<li><strong>Flatten it to 16 without widening</strong> and it keeps only 24 × 16 = 384, two thirds of its area. It reads as the ball shrinking, not squashing.</li>
</ul>
<p>Pixel art can&#39;t hit these numbers exactly, and it doesn&#39;t need to. The sample&#39;s squash frame covers 445 pixels against 487 for the round frame, 91% of the area, and it reads as the same ball. Aim for within about 10%, and count pixels when a frame looks off: a squash that &quot;looks wrong&quot; is often just too small.</p>
<h2 id="timing-squash-on-impact-stretch-when-its-fast">Timing: squash on impact, stretch when it&#39;s fast</h2>
<p>Here is the sample next to two edited copies. The only difference is frame 5:</p>
<p><figure><img src="https://spritepilot.com/blog/squash-and-stretch-sprites/squash-vs-no-squash.gif" alt="Three bouncing balls side by side: one with no squash on impact, one with the sample's 37 by 17 squash, and one squashed to 17 pixels tall without getting wider" width="592" height="238" loading="lazy" decoding="async"><figcaption>Played at 10 fps. Left: frame 5 replaced by frame 1's round ball, moved down to the ground. Middle: the sample. Right: frame 5 squashed to 25 × 17 without widening.</figcaption></figure></p>
<p>With no squash, the ball touches the ground and leaves as if it hit something rigid. With the sample&#39;s squash, the impact has weight. The squash without widening looks like the ball shrinks for a moment, which reads as an error.</p>
<p>Some timing guidelines that follow from this:</p>
<ul>
<li><strong>Squash on the contact frame.</strong> That&#39;s the frame where the force is. At 10 to 12 fps, one frame lasts about 83 to 100 ms, which is enough for the eye to register it.</li>
<li><strong>Hold the squash longer for a softer object.</strong> Two frames of squash suggest something that takes time to recover its shape, such as a slime or a sack.</li>
<li><strong>Stretch on the fastest frames,</strong> just before and just after the impact, where the gaps between positions are largest. Don&#39;t stretch at the top of the bounce, where the ball is barely moving.</li>
<li><strong>Match the amount to the material.</strong> The sample squashes to about two thirds of its height. A bowling ball might lose a pixel or two; a water balloon could lose half.</li>
</ul>
<p>If you want different frames to stay on screen for different times, for example a two-frame hold on the squash, <a href="https://spritepilot.com/blog/pixel-art-frame-rate">How Fast Should Pixel Art Animate?</a> shows how to set per-frame durations in Godot, Phaser, Unity and Aseprite.</p>
<h2 id="squash-and-stretch-at-pixel-art-sizes">Squash and stretch at pixel-art sizes</h2>
<p>Small sprites have fewer pixels to give, so every pixel of squash counts:</p>
<ul>
<li><strong>One pixel is a lot.</strong> On a 16 × 16 ball, losing one pixel of height is a 6% squash and already visible. Squashing it from 16 to 12 pixels tall needs 256 ÷ 12 ≈ 21 pixels of width.</li>
<li><strong>Squash from the contact point.</strong> The edge that touches the ground stays on the same row of pixels and the shape grows outward from there. In the sample, the squash frame&#39;s bottom edge is the lowest row of the whole bounce.</li>
<li><strong>Squash the details too.</strong> The sample&#39;s highlight goes from 9 × 9 to 11 × 5 pixels. A highlight left round on a flat ball breaks the illusion.</li>
<li><strong>For characters, think in body parts.</strong> On landing, drop the head and body a pixel or two and spread the feet or widen the torso by the same amount. On take-off, squash into a crouch first, then stretch upward as the feet leave the ground.</li>
</ul>
<p>Two engine details matter once the frames have different shapes:</p>
<ul>
<li><strong>Keep the pivot at the bottom.</strong> If your frames are <a href="https://spritepilot.com/blog/trim-transparent-pixels">trimmed to their content</a>, the squash frame&#39;s rectangle is shorter than the others. With a centered pivot it would pop up into the air; with a bottom-center pivot it stays on the ground. <a href="https://spritepilot.com/blog/sprite-pivot-points">Sprite pivot points</a> covers how to set one.</li>
<li><strong>Draw it rather than scaling in code.</strong> Many games fake squash by scaling the sprite, for example to 120% wide and 80% tall. That works for smooth art, but on pixel art a non-integer scale makes some pixels wider than others (<a href="https://spritepilot.com/blog/scale-pixel-art-without-blur">more on scaling pixel art</a>). Drawn frames keep the pixel grid intact.</li>
</ul>
<h2 id="try-it">Try it</h2>
<p>The ball from this article is SpritePilot&#39;s sample sheet. Open the <a href="https://spritepilot.com/sprite-player">Sprite Player</a>, click <strong>Try a sample</strong> (it sets 4 columns and 2 rows for you), then press the right arrow key four times to step to frame 5:</p>
<p><figure><img src="https://spritepilot.com/blog/squash-and-stretch-sprites/sprite-player-squash-frame.png" alt="SpritePilot's Sprite Player showing frame 5 of 8 of the sample bouncing ball, the flattened squash frame, at 10 FPS with Loop on" width="1720" height="1220" loading="lazy" decoding="async"><figcaption>The Sprite Player on frame 5 of the sample: the squash, 37 × 17 pixels.</figcaption></figure></p>
<p>Play it at 8, 10 and 12 fps and watch how long the squash stays visible. To try your own version, download the <a href="https://spritepilot.com/samples/ball-bounce.png">sample sheet</a>, edit frame 5 in any pixel editor and load it back into the player. The player runs in your browser and nothing is uploaded.</p>
<h2 id="in-short">In short</h2>
<ul>
<li>Squash shows force and material; stretch shows speed and bridges big gaps between frames.</li>
<li>Keep width × height roughly constant, within about 10% of the round shape.</li>
<li>Squash on the contact frame, for one frame unless the object is soft; stretch on the fastest frames.</li>
<li>At pixel-art sizes, squash from the contact point, squash the details too, and keep the pivot at the bottom.</li>
</ul>
]]></content:encoded>
    </item>
    <item>
      <title>Trimming Transparent Pixels From Sprites: Smaller Sheets Without Jitter</title>
      <link>https://spritepilot.com/blog/trim-transparent-pixels</link>
      <guid isPermaLink="true">https://spritepilot.com/blog/trim-transparent-pixels</guid>
      <pubDate>Wed, 14 Oct 2026 12:00:00 GMT</pubDate>
      <description>Trimming cuts the empty border off every frame. How much it saves, the three numbers an atlas keeps so frames stay put, and which engines use them.</description>
      <category>sprite sheets</category>
      <category>animation</category>
      <category>file formats</category>
      <content:encoded><![CDATA[<p>Animation frames are usually drawn on one fixed canvas, say 64 × 64 pixels, and most of that canvas is empty. Trimming cuts the transparent border off each frame before it&#39;s packed, and the sheet shrinks accordingly. But a trimmed frame has lost one thing: where it sat on its canvas. If the engine doesn&#39;t put that back, the animation shakes, slides or floats.</p>
<p>This article measures what trimming saves, shows the numbers an atlas stores to undo it, and lists which loaders use them.</p>
<h2 id="how-much-trimming-saves">How much trimming saves</h2>
<p>Take SpritePilot&#39;s <a href="https://spritepilot.com/samples/ball-bounce.png">bouncing-ball sample</a>: eight frames of 64 × 64 pixels, a ball falling, squashing on the ground and rising again, with its shadow on the floor in every frame.</p>
<p><figure><img src="https://spritepilot.com/blog/trim-transparent-pixels/what-trimming-removes.png" alt="Two sprite sheets of the same ball animation: the untrimmed one is 192 × 192 pixels, the trimmed one 111 × 180, with each frame's visible area outlined" width="1536" height="1000" loading="lazy" decoding="async"><figcaption>SpritePilot's Export with Trim transparency off and on. Amber boxes are what each frame keeps.</figcaption></figure></p>
<table>
<thead>
<tr>
<th></th>
<th>Untrimmed</th>
<th>Trimmed</th>
<th>Saved</th>
</tr>
</thead>
<tbody><tr>
<td>Ball: pixels in the 8 frames</td>
<td>32,768</td>
<td>9,528</td>
<td>71%</td>
</tr>
<tr>
<td>Ball: exported sheet</td>
<td>192 × 192 px</td>
<td>111 × 180 px</td>
<td>46%</td>
</tr>
<tr>
<td>Coin (8 frames of 32 × 32): pixels in the frames</td>
<td>8,192</td>
<td>2,684</td>
<td>67%</td>
</tr>
<tr>
<td>Coin: exported sheet</td>
<td>96 × 96 px</td>
<td>66 × 66 px</td>
<td>53%</td>
</tr>
</tbody></table>
<p>Two details explain the gap between the rows. First, trimming only cuts the outer border: the ball frames keep the empty column of air between the ball and its shadow, so the tallest one is still 60 pixels high. Second, SpritePilot&#39;s <a href="https://spritepilot.com/export-download">Export &amp; Download</a> lays trimmed frames out in a grid whose cells fit the largest trimmed frame (37 × 60 here), so the sheet saves less than the frames do. A packer that places each trimmed frame individually gets closer to the frame total; SpritePilot&#39;s Export keeps the simpler grid. <a href="https://spritepilot.com/blog/sprite-sheet-vs-texture-atlas">Sprite Sheet vs. Texture Atlas</a> measures that difference for untrimmed sprites.</p>
<p>The savings depend entirely on the art. Small objects on big canvases gain the most; frames that fill their canvas, like video frames or full-screen backgrounds, gain nothing.</p>
<h2 id="the-three-numbers-an-atlas-keeps">The three numbers an atlas keeps</h2>
<p>A trimmed frame can&#39;t be drawn correctly on its own, so the atlas stores the information needed to put it back. This is a real entry from SpritePilot&#39;s TexturePacker JSON with <strong>Trim transparency</strong> on, for the first ball frame:</p>
<pre><code class="language-json">&quot;sprite_0001.png&quot;: {
  &quot;frame&quot;: { &quot;x&quot;: 6, &quot;y&quot;: 0, &quot;w&quot;: 25, &quot;h&quot;: 60 },
  &quot;rotated&quot;: false,
  &quot;trimmed&quot;: true,
  &quot;spriteSourceSize&quot;: { &quot;x&quot;: 20, &quot;y&quot;: 3, &quot;w&quot;: 25, &quot;h&quot;: 60 },
  &quot;sourceSize&quot;: { &quot;w&quot;: 64, &quot;h&quot;: 64 },
  &quot;pivot&quot;: { &quot;x&quot;: 0.5, &quot;y&quot;: 0.5 }
}</code></pre>
<ul>
<li><strong>sourceSize</strong> is the original canvas: 64 × 64.</li>
<li><strong>spriteSourceSize</strong> is where the kept pixels sat on that canvas: 20 pixels from the left, 3 from the top, 25 × 60 in size.</li>
<li><strong>frame</strong> is where those pixels are now in the sheet.</li>
<li><strong>trimmed: true</strong> tells the loader the first two differ.</li>
</ul>
<p><figure><img src="https://spritepilot.com/blog/trim-transparent-pixels/trim-offset-fields.png" alt="One 64 × 64 ball frame with its trimmed area at x 20, y 3, and the same 25 × 60 pixels highlighted at x 6, y 0 in the trimmed sheet" width="1536" height="908" loading="lazy" decoding="async"><figcaption>Same 25 × 60 pixels, two positions: where they were on the canvas (spriteSourceSize) and where they are in the sheet (frame).</figcaption></figure></p>
<p>SpritePilot writes the same information into its other formats: Phaser 3 and Aseprite JSON use the same fields, Godot JSON and Basic JSON store an <code>offset</code> and a <code>sourceSize</code>, the Godot 4 SpriteFrames (.tres) file gives each trimmed frame an AtlasTexture <code>margin</code>, and CSS sprites get padding that restores the original box. Trimming keeps any pixel with even the faintest opacity, so soft glows and shadows survive.</p>
<h2 id="why-trimmed-frames-jitter">Why trimmed frames jitter</h2>
<p>A trimmed frame is a different size in almost every pose. If the engine ignores the offsets and positions each frame by its own size, every frame lands somewhere slightly different.</p>
<p><figure><img src="https://spritepilot.com/blog/trim-transparent-pixels/trimmed-frames-jitter-without-offsets.gif" alt="Two copies of the ball animation: with offsets ignored the shadow jumps up and down and the squashed ball floats above the ground; with offsets applied the bounce is correct" width="494" height="266" loading="lazy" decoding="async"><figcaption>Left: each trimmed frame centered on the sprite's position. Right: drawn at its spriteSourceSize offset. The dashed line is the ground.</figcaption></figure></p>
<p>On the left, each frame is centered by its trimmed size. The tallest frame is 60 pixels high and the squashed one only 28, so the shadow&#39;s bottom edge swings 16 pixels up and down and the squashed ball hangs in the air. The pixels are all correct; only their placement is wrong.</p>
<p>If you draw sprites yourself, the fix is one addition. This canvas code puts each trimmed frame back on its original canvas; drawing all eight frames this way rebuilds the original 64 × 64 frames pixel for pixel:</p>
<pre><code class="language-js">// Draw a frame from a TexturePacker-style atlas exactly where the
// untrimmed frame would have been. (x, y) is that frame's top-left corner.
function drawFrame(ctx, sheet, f, x, y) {
  ctx.drawImage(
    sheet,
    f.frame.x, f.frame.y, f.frame.w, f.frame.h,          // where the pixels are now
    x + f.spriteSourceSize.x, y + f.spriteSourceSize.y,  // where they were
    f.frame.w, f.frame.h,
  )
}

// To center a sprite on (cx, cy), use the original size, not the trimmed one.
function drawFrameCentered(ctx, sheet, f, cx, cy) {
  drawFrame(ctx, sheet, f, cx - f.sourceSize.w / 2, cy - f.sourceSize.h / 2)
}</code></pre>
<p>Centering by <code>f.frame.w</code> and <code>f.frame.h</code> instead is exactly the mistake in the left half of the animation: it matched the original frames zero times out of eight.</p>
<h2 id="which-engines-use-the-offsets">Which engines use the offsets</h2>
<ul>
<li><strong>Phaser</strong> (<code>this.load.atlas</code>, JSON Hash or JSON Array): the <a href="https://github.com/phaserjs/phaser/blob/master/src/textures/parsers/JSONHash.js" target="_blank" rel="noopener noreferrer">atlas parser</a> reads <code>sourceSize</code> and <code>spriteSourceSize</code> for every frame marked <code>trimmed</code>, and the frame keeps its untrimmed size for positioning. Trimmed atlases work without extra code.</li>
<li><strong>PixiJS</strong>: the <a href="https://github.com/pixijs/pixijs/blob/dev/src/spritesheet/Spritesheet.ts" target="_blank" rel="noopener noreferrer">Spritesheet parser</a> uses <code>sourceSize</code> as the texture&#39;s original size and <code>spriteSourceSize</code> as its trim, unless the frame says <code>trimmed: false</code>.</li>
<li><strong>Godot 4</strong>: an <a href="https://docs.godotengine.org/en/stable/classes/class_atlastexture.html" target="_blank" rel="noopener noreferrer">AtlasTexture</a> is drawn at its region size plus its margin size, with the region offset by the margin&#39;s position. That&#39;s how SpritePilot&#39;s SpriteFrames export restores trimmed frames: assign the .tres to an AnimatedSprite2D&#39;s <strong>Sprite Frames</strong> and every frame keeps its original 64 × 64 box.</li>
<li><strong>Unity</strong> works the other way round. You normally import untrimmed frames and let a <a href="https://docs.unity3d.com/6000.3/Documentation/Manual/sprite/atlas/sprite-atlas-reference.html" target="_blank" rel="noopener noreferrer">Sprite Atlas</a> pack them; its <strong>Tight Packing</strong> option packs each sprite by its mesh shape instead of its rectangle, so the empty space is dealt with inside Unity.</li>
</ul>
<p>If your loader only reads <code>frame</code>, expect the left half of the GIF. In that case export without trimming, or position frames with the offsets yourself.</p>
<h2 id="when-not-to-trim">When not to trim</h2>
<ul>
<li><strong>Tiles.</strong> A tileset has to stay a regular grid with every tile the same size. Trimming a tile with transparent edges breaks the grid.</li>
<li><strong>Anything sliced as a grid.</strong> Phaser&#39;s <code>load.spritesheet</code>, a Godot Sprite2D&#39;s Hframes and Vframes, and Unity&#39;s Grid By Cell Size never see the offsets. SpritePilot&#39;s trimmed export still looks like a grid, because each frame is centered in a cell, but slicing it as one gives you the left half of the animation above.</li>
<li><strong>Frames that fill their canvas.</strong> Video frames and opaque backgrounds have no transparent border to cut. SpritePilot marks such frames <code>trimmed: false</code> and leaves them alone.</li>
<li><strong>Tiny sprites.</strong> Trimming a 16 × 16 icon saves at most 1 KB of memory and adds a rule every loader must follow.</li>
</ul>
<h2 id="try-it">Try it</h2>
<p>Get frames into SpritePilot, for example by slicing a sheet with <a href="https://spritepilot.com/sprite-detection">Grid Slice</a> or sending them from the GIF or video converter, then open <a href="https://spritepilot.com/export-download">Export &amp; Download</a>. Tick <strong>Trim transparency</strong>, choose TexturePacker, Phaser 3, Godot JSON or SpriteFrames metadata, and download. The preview&#39;s heading adds &quot;trimmed&quot; so you can see it&#39;s on. Everything runs in your browser.</p>
<p>Before you ship, check one thing: play the trimmed animation in your engine next to the untrimmed one. If the shadow or the feet move, the loader isn&#39;t applying the offsets. For what each metadata format is for, see <a href="https://spritepilot.com/guides/export-formats-explained">Sprite Sheet Export Formats Explained</a>; for choosing the point a sprite is positioned by, see <a href="https://spritepilot.com/blog/sprite-pivot-points">Sprite Pivot Points Explained</a>.</p>
]]></content:encoded>
    </item>
    <item>
      <title>How Many Frames Does a Walk Cycle Need? 4, 6 and 8 Compared</title>
      <link>https://spritepilot.com/blog/walk-cycle-frames</link>
      <guid isPermaLink="true">https://spritepilot.com/blog/walk-cycle-frames</guid>
      <pubDate>Wed, 14 Oct 2026 12:00:00 GMT</pubDate>
      <description>What 4-, 6- and 8-frame walk cycles keep and lose, how sprite size changes the choice, and how to match a walk to movement speed so feet don't slide.</description>
      <category>animation</category>
      <category>pixel art</category>
      <content:encoded><![CDATA[<p>Four frames, six or eight? It&#39;s the first decision in a walk cycle, and it sets how many drawings you make, how smooth the walk looks and how fast the character can move before its feet start to slide. There&#39;s no single right number, but there is a reliable way to choose: know which poses each count keeps, look at the size of your sprite, and check how far the character travels per frame.</p>
<h2 id="a-walk-is-two-steps-of-four-key-poses">A walk is two steps of four key poses</h2>
<p>A walk cycle is one full repeat of the motion: a step with the right leg, then a step with the left. The second step is the first with the legs and arms swapped, so a cycle is two mirrored halves and its frame count is almost always even.</p>
<p>Each step passes through four key poses. The names below are the ones popularized by Richard Williams&#39; <em>The Animator&#39;s Survival Kit</em>:</p>
<ul>
<li><strong>Contact:</strong> the front heel touches down. The legs are spread their widest, and so are the arms, which swing opposite the legs.</li>
<li><strong>Down:</strong> the front foot takes the weight, its knee bends and the body drops to its lowest point.</li>
<li><strong>Passing:</strong> the supporting leg is straight under the body while the other leg passes it with the foot lifted.</li>
<li><strong>Up:</strong> the back foot pushes off its toes and the body rises to its highest point as the free leg swings forward.</li>
</ul>
<p><figure><img src="https://spritepilot.com/blog/walk-cycle-frames/walk-cycle-key-poses.png" alt="A walking figure in five poses, contact, down, passing, up and contact, with a dashed line through the hips that dips at down and peaks at up" width="1536" height="706" loading="lazy" decoding="async"><figcaption>One step in four key poses. The dashed line is the hip height: lowest on the down, highest on the up.</figcaption></figure></p>
<p>That rise and fall of the hips is what gives a walk its weight. Keep an eye on it: it&#39;s the first thing you lose when you cut frames.</p>
<h2 id="what-4-6-and-8-frames-keep">What 4, 6 and 8 frames keep</h2>
<p>Frames in a cycle are evenly spaced in time, so the frame count decides exactly where each drawing lands within a step:</p>
<p><figure><img src="https://spritepilot.com/blog/walk-cycle-frames/walk-cycle-4-6-8-frames.png" alt="Three rows of walk drawings placed by time: 8 frames show contact, down, passing and up twice; 6 frames show contact and drawings at a third and two thirds of a step; 4 frames show only contact and passing, with an almost flat hip line" width="1536" height="910" loading="lazy" decoding="async"><figcaption>The same walk sampled 8, 6 and 4 times per cycle. The dashed hip line shows how much bounce survives.</figcaption></figure></p>
<table>
<thead>
<tr>
<th>Frames per cycle</th>
<th>Drawings per step</th>
<th>What you get</th>
<th>What you lose</th>
</tr>
</thead>
<tbody><tr>
<td>8</td>
<td>4</td>
<td>contact, down, passing, up</td>
<td>nothing essential</td>
</tr>
<tr>
<td>6</td>
<td>3</td>
<td>contact, a low pose, a high pose</td>
<td>the exact down and up poses</td>
</tr>
<tr>
<td>4</td>
<td>2</td>
<td>contact, passing</td>
<td>the bounce and the push-off</td>
</tr>
</tbody></table>
<ul>
<li><strong>8 frames</strong> hold all four key poses, so the weight lands on the down and the push-off shows on the up.</li>
<li><strong>6 frames</strong> put the two drawings after each contact at a third and two thirds of a step, between the classic poses. Draw them as a low pose and a high pose and most of the bounce survives: in the walk drawn for this article, the hips rise and fall by 3.6% of the figure&#39;s height, against 4.4% with 8 frames.</li>
<li><strong>4 frames</strong> keep only contact and passing. The legs still alternate, but the hips move just 0.4% of the height, so the walk glides. A simple fix is to raise the body by a pixel on both passing frames, which puts back some bounce.</li>
</ul>
<p>Here is the same walk at the same pace, 0.72 seconds per cycle, with each frame count:</p>
<p><figure><img src="https://spritepilot.com/blog/walk-cycle-frames/walk-4-6-8-frames.gif" alt="The same walking figure animated side by side with 4, 6 and 8 frames per cycle, all taking 0.72 seconds per cycle" width="592" height="226" loading="lazy" decoding="async"><figcaption>Same walk, same pace. 4 frames at 180 ms each, 6 at 120 ms, 8 at 90 ms.</figcaption></figure></p>
<p>Four frames read as walking but look stiff at this pace. Six come surprisingly close to eight. Playing a 4-frame walk faster doesn&#39;t make it smoother; it makes the character walk faster, which brings us to speed further down.</p>
<h2 id="small-sprites-and-large-sprites-need-different-counts">Small sprites and large sprites need different counts</h2>
<p>The size of the sprite changes the answer, because pixel art can only move in whole pixels.</p>
<p>Take the planted foot. Over one step it moves back by one step length, and it should do so evenly, the same number of pixels every frame. That gives a simple test: <strong>step length ÷ drawings per step should be a whole number.</strong></p>
<ul>
<li><strong>16 px tall:</strong> a step might be 6 px. With 4 frames the foot moves 3 px per frame, and with 6 frames it moves 2 px. With 8 frames it would move 1.5 px, which can&#39;t be drawn: it alternates between 1 and 2 px and the foot wobbles. Small sprites often look cleaner with 4 or 6 frames than with 8.</li>
<li><strong>32 px tall:</strong> a 12 px step works with all three: 6, 4 or 3 px per frame. Six or eight frames suit this size well.</li>
<li><strong>64 px and taller:</strong> the down and up poses are several pixels apart, and arms, hair and clothes have room to follow through. Eight frames is a sensible minimum, and large, smooth characters can use 12 or 16.</li>
</ul>
<p>If your step doesn&#39;t divide evenly, change the step: a 14 px step won&#39;t split into 4 drawings per step, but 12 or 16 will.</p>
<h2 id="match-the-cycle-to-the-movement-speed">Match the cycle to the movement speed</h2>
<p>Feet slide when the character moves at a different speed from the one the walk was drawn for. In a walk cycle the body stays in place on the sprite sheet and the planted foot moves backward. In the game the body moves forward. When the two distances match, the planted foot stays fixed on the ground.</p>
<p>So the speed a walk is drawn for is:</p>
<p><strong>speed = foot travel per frame × frame rate = 2 × step length × frame rate ÷ frames per cycle</strong></p>
<p>For a 32 px character with a 12 px step, drawn with 8 frames and played at 10 fps:</p>
<ul>
<li>foot travel per frame: 12 px ÷ 4 drawings per step = <strong>3 px</strong></li>
<li>speed: 3 px × 10 fps = <strong>30 px per second</strong>, which is 0.5 px per tick in a game running at 60 ticks per second</li>
</ul>
<p><figure><img src="https://spritepilot.com/blog/walk-cycle-frames/walk-speed-foot-slide.gif" alt="Two walking figures on a scrolling ground with footprints: on the left the ground moves at the matched speed and each planted foot stays on its print; on the right the ground moves 50 percent faster and the planted feet skate forward off their prints" width="592" height="236" loading="lazy" decoding="async"><figcaption>Left: the body moves exactly as far as the planted foot travels per frame. Right: 50% faster, and the feet skate.</figcaption></figure></p>
<p>If your game moves the character at a different speed, change one of three things:</p>
<ol>
<li><strong>The frame rate.</strong> At 48 px per second, the same walk needs 48 ÷ 3 = 16 fps. That works for small adjustments, but a walk played much faster than it was drawn starts to look frantic.</li>
<li><strong>The step length.</strong> Redraw with a 16 px step and the foot travels 4 px per frame, so 12 fps gives 48 px per second.</li>
<li><strong>The movement speed.</strong> Sometimes the honest fix is to move the character at the speed the animation suggests.</li>
</ol>
<p>When the speed varies, for example with an analog stick, scale the playback rate by current speed ÷ drawn speed. Engines expose this directly: <a href="https://docs.godotengine.org/en/stable/classes/class_animatedsprite2d.html" target="_blank" rel="noopener noreferrer"><code>speed_scale</code></a> on Godot 4&#39;s AnimatedSprite2D, <a href="https://docs.unity3d.com/ScriptReference/Animator-speed.html" target="_blank" rel="noopener noreferrer"><code>Animator.speed</code></a> in Unity and <a href="https://docs.phaser.io/api-documentation/class/animations-animationstate" target="_blank" rel="noopener noreferrer"><code>timeScale</code></a> on a Phaser sprite&#39;s <code>anims</code>.</p>
<p>One last detail: if the character moves smoothly every tick but the drawing changes only 10 times a second, the planted foot creeps forward with the body while each drawing is on screen, then snaps back by the foot travel on the next one. In the example that&#39;s 3 px. More frames make the creep smaller. Some games instead move the character in steps that line up with the frame changes, which keeps the feet perfectly planted at the cost of slightly jerkier movement.</p>
<h2 id="try-it">Try it</h2>
<p>Draw a walk in SpritePilot&#39;s <a href="https://spritepilot.com/pixel-art-editor">Pixel Art Editor</a>: start with the two contact poses, then add the passing poses between them (a working 4-frame walk), then the downs and ups if the sprite is big enough. <strong>Duplicate frame</strong> copies a pose so you only move the limbs. Onion skin shows the previous frame underneath, which makes even foot travel easy to check (<a href="https://spritepilot.com/blog/onion-skinning-pixel-art">how onion skinning works</a>). The Preview plays the frames at 1 to 30 FPS, and <strong>Download Sheet PNG</strong> saves them as a strip you can check in the <a href="https://spritepilot.com/sprite-player">Sprite Player</a>. New to the editor? <a href="https://spritepilot.com/guides/draw-your-own-sprite">How to Draw Your Own Pixel Art Sprite</a> covers the tools. For picking the playback rate, see <a href="https://spritepilot.com/blog/pixel-art-frame-rate">How Fast Should Pixel Art Animate?</a></p>
<h2 id="in-short">In short</h2>
<ul>
<li>A walk cycle is two mirrored steps; each step has four key poses: contact, down, passing, up.</li>
<li>8 frames keep all four poses, 6 frames keep most of the bounce, 4 frames keep contact and passing only.</li>
<li>Small sprites: choose a frame count that divides the step length into whole pixels.</li>
<li>Drawn speed = foot travel per frame × frame rate. Match it, or scale the playback rate with the speed.</li>
</ul>
]]></content:encoded>
    </item>
    <item>
      <title>Sprite Sheet Animation in JavaScript With Canvas (No Engine)</title>
      <link>https://spritepilot.com/blog/canvas-sprite-animation</link>
      <guid isPermaLink="true">https://spritepilot.com/blog/canvas-sprite-animation</guid>
      <pubDate>Tue, 13 Oct 2026 12:00:00 GMT</pubDate>
      <description>Play a sprite sheet on a canvas in plain JavaScript, with time-based frames, the 9-argument drawImage, crisp pixel art, sharp high-DPI output and a JSON atlas.</description>
      <category>animation</category>
      <category>sprite sheets</category>
      <category>web</category>
      <content:encoded><![CDATA[<p>You don&#39;t need a game engine to play a sprite animation in the browser. A canvas, an image and about 30 lines of JavaScript will do it. Four things tend to go wrong: the animation runs at a different speed on a 144 Hz monitor, the pixel art comes out blurry, it looks soft on a phone or a high-resolution laptop, or the frames come out of a packed atlas in the wrong place.</p>
<p>This article builds the program once and then explains each of those parts. All the code was run in Chromium, and the frames it drew were checked pixel by pixel.</p>
<h2 id="the-whole-program">The whole program</h2>
<p>The sheet is the bouncing-ball sample, <a href="https://spritepilot.com/samples/ball-bounce.png">ball-bounce.png</a>: 8 frames of 64 × 64 pixels in 4 columns and 2 rows. Put it next to an HTML file with this in it:</p>
<pre><code class="language-html">&lt;canvas id=&quot;game&quot;&gt;&lt;/canvas&gt;
&lt;script type=&quot;module&quot;&gt;
  const canvas = document.getElementById('game')
  const ctx = canvas.getContext('2d')

  const FRAME_W = 64       // one frame, in sheet pixels
  const FRAME_H = 64
  const COLUMNS = 4        // frames per row in the sheet
  const FRAME_COUNT = 8
  const FRAME_MS = 100     // 10 frames per second
  const SCALE = 3          // each sheet pixel becomes 3 × 3 CSS pixels

  const sheet = new Image()
  sheet.src = 'ball-bounce.png'
  await sheet.decode()

  // A whole number of screen pixels per sheet pixel, on any screen
  const zoom = Math.max(1, Math.round(SCALE * window.devicePixelRatio))
  canvas.width = FRAME_W * zoom
  canvas.height = FRAME_H * zoom
  canvas.style.width = canvas.width / window.devicePixelRatio + 'px'
  canvas.style.height = canvas.height / window.devicePixelRatio + 'px'
  ctx.imageSmoothingEnabled = false   // after sizing: resizing the canvas resets it

  let start = null
  function tick(now) {
    if (start === null) start = now
    const frame = Math.floor((now - start) / FRAME_MS) % FRAME_COUNT
    const sx = (frame % COLUMNS) * FRAME_W
    const sy = Math.floor(frame / COLUMNS) * FRAME_H

    ctx.clearRect(0, 0, canvas.width, canvas.height)
    ctx.drawImage(sheet, sx, sy, FRAME_W, FRAME_H, 0, 0, FRAME_W * zoom, FRAME_H * zoom)
    requestAnimationFrame(tick)
  }
  requestAnimationFrame(tick)
&lt;/script&gt;</code></pre>
<p><code>type=&quot;module&quot;</code> is what allows <code>await</code> at the top level, so the loop only starts once the image is ready to draw. The sections below go through the program from the loop outwards.</p>
<h2 id="let-time-pick-the-frame-not-the-frame-counter">Let time pick the frame, not the frame counter</h2>
<p><code>requestAnimationFrame</code> calls <code>tick</code> once before each repaint of the screen. How often that happens depends on the display: <a href="https://developer.mozilla.org/en-US/docs/Web/API/Window/requestAnimationFrame" target="_blank" rel="noopener noreferrer">MDN</a> notes that 60 Hz is the most common rate, but 75, 120 and 144 Hz are also widely used. So the number of calls tells you nothing about how much time has passed.</p>
<p>What does tell you is the timestamp the browser passes in, <code>now</code>, in milliseconds. The program measures the time since the first call and divides it by the frame duration:</p>
<ul>
<li>after 0–99 ms: <code>floor(elapsed / 100) % 8</code> = frame 0,</li>
<li>after 100–199 ms: frame 1,</li>
<li>… after 800 ms it wraps back to frame 0.</li>
</ul>
<p>The common mistake is advancing a counter on every call (<code>frame++</code>, perhaps every sixth call). That ties the speed to the monitor: on a 144 Hz screen it plays 144 ÷ 60 = 2.4 times as fast as on a 60 Hz one. With the timestamp, the speed is the same everywhere. In the test, the program was driven at 30, 60 and 144 calls per second, and at every moment all three showed the same frame.</p>
<p>Two more details come free. Most browsers pause <code>requestAnimationFrame</code> in background tabs; when the tab comes back, the elapsed time has jumped and the animation simply carries on in step. And if you later want per-frame timing, the frame can come from a list of durations instead of one <code>FRAME_MS</code>.</p>
<h2 id="choosing-the-frame-and-drawing-it-with-drawimage">Choosing the frame and drawing it with drawImage</h2>
<p>The frame number becomes a position in the sheet. With 4 columns, frame 7 (the last of the eight, counting from 0) is in column 7 % 4 = 3 and row floor(7 ÷ 4) = 1, so its top-left corner is at x = 3 × 64 = 192, y = 1 × 64 = 64.</p>
<p>The 9-argument form of <code>drawImage</code> copies a rectangle of one image onto the canvas, scaling it on the way. The <strong>source</strong> rectangle comes first, then the <strong>destination</strong>:</p>
<pre><code class="language-js">ctx.drawImage(image, sx, sy, sWidth, sHeight, dx, dy, dWidth, dHeight)</code></pre>
<p><figure><img src="https://spritepilot.com/blog/canvas-sprite-animation/drawimage-source-destination.png" alt="The ball sprite sheet with frame 7 outlined at sx 192, sy 64, 64 × 64, and an arrow to a canvas where the frame is drawn at dx 14, dy 14, 192 × 192" width="1536" height="948" loading="lazy" decoding="async"><figcaption>The four numbers after the image pick the frame on the sheet; the last four say where it lands on the canvas and how big. The program itself draws at 0, 0.</figcaption></figure></p>
<p>When the destination is bigger than the source, the frame is scaled up. That is how the program draws a 64-pixel frame 192 pixels wide (64 × 3) on a normal screen. Swap the two groups, for example by passing the destination first, and you get the wrong part of the sheet, in the wrong place, at the wrong size.</p>
<h2 id="keep-pixel-art-crisp-imagesmoothingenabled">Keep pixel art crisp: imageSmoothingEnabled</h2>
<p>By default the canvas smooths images it scales, blending neighboring pixels. That is right for photos and wrong for pixel art. <a href="https://developer.mozilla.org/en-US/docs/Web/API/CanvasRenderingContext2D/imageSmoothingEnabled" target="_blank" rel="noopener noreferrer"><code>imageSmoothingEnabled</code></a> is <code>true</code> by default. Set it to <code>false</code> and scaled pixels stay hard-edged squares:</p>
<p><figure><img src="https://spritepilot.com/blog/canvas-sprite-animation/image-smoothing-on-off.gif" alt="The bouncing ball drawn at 3× on two canvases: blurry with imageSmoothingEnabled true, sharp with false" width="548" height="284" loading="lazy" decoding="async"><figcaption>Captured from the program's own canvas. Left: the default. Right: imageSmoothingEnabled = false.</figcaption></figure></p>
<p>One trap: <strong>setting <code>canvas.width</code> or <code>canvas.height</code> resets the whole drawing context</strong>, smoothing included. In Chromium, <code>ctx.imageSmoothingEnabled</code> reads <code>true</code> again straight after a resize. That&#39;s why the program turns smoothing off <em>after</em> sizing the canvas. If your game resizes the canvas when the window changes size, set it again every time.</p>
<h2 id="sharp-on-high-dpi-screens-devicepixelratio">Sharp on high-DPI screens: devicePixelRatio</h2>
<p>A canvas has two sizes: the bitmap it draws into (<code>canvas.width</code> and <code>canvas.height</code>) and the size it takes on the page (its CSS width and height). On a phone or a high-resolution laptop screen, one CSS pixel often covers 2 or 3 screen pixels. <a href="https://developer.mozilla.org/en-US/docs/Web/API/Window/devicePixelRatio" target="_blank" rel="noopener noreferrer"><code>window.devicePixelRatio</code></a> says how many. If the bitmap is only as big as the CSS size, the browser stretches it to fill the screen pixels, and the stretch is smooth: your carefully crisp pixels get soft edges.</p>
<p><figure><img src="https://spritepilot.com/blog/canvas-sprite-animation/device-pixel-ratio.png" alt="Zoomed screenshots from a 2× screen: a canvas with a 192-pixel bitmap has soft edges, a canvas with a 384-pixel bitmap is sharp" width="1536" height="738" loading="lazy" decoding="async"><figcaption>Both canvases are 192 CSS pixels wide on a screen with devicePixelRatio 2. Only the right one has a bitmap of 384 × 384.</figcaption></figure></p>
<p>So the program makes the bitmap big enough for the screen and shrinks the element back with CSS. It also rounds: <code>zoom</code> is the number of screen pixels per sheet pixel, and it has to be a whole number, or some art pixels come out one screen pixel wider than others. On a 2× screen, zoom is 3 × 2 = 6, so the canvas is 384 × 384 bitmap pixels shown at 192 × 192 CSS pixels. On a 1.5× screen, 3 × 1.5 = 4.5 rounds to 5: the bitmap is 320 pixels and the sprite comes out about 213 CSS pixels wide, a little over 3×, with every art pixel exactly 5 × 5 screen pixels. In Chromium screenshots at ratios 1, 1.25, 1.5, 2 and 3, the screen pixels matched the canvas bitmap exactly: square art pixels, nothing resampled.</p>
<p>The ratio can change while the page is open, when a window moves to another monitor or the visitor zooms. MDN&#39;s devicePixelRatio page shows how to listen for that with <code>matchMedia</code>; when it fires, run the sizing code again.</p>
<h2 id="reading-frames-from-a-json-atlas">Reading frames from a JSON atlas</h2>
<p>Grid math only works for a grid. A packed or trimmed sheet needs its data file, which lists every frame&#39;s rectangle. SpritePilot&#39;s <a href="https://spritepilot.com/export-download">Export &amp; Download</a> can write it in TexturePacker&#39;s &quot;JSON hash&quot; layout. This is one entry from an export of the ball with <strong>Trim transparency</strong> on:</p>
<pre><code class="language-json">&quot;sprite_0001.png&quot;: {
  &quot;frame&quot;: { &quot;x&quot;: 6, &quot;y&quot;: 0, &quot;w&quot;: 25, &quot;h&quot;: 60 },
  &quot;rotated&quot;: false,
  &quot;trimmed&quot;: true,
  &quot;spriteSourceSize&quot;: { &quot;x&quot;: 20, &quot;y&quot;: 3, &quot;w&quot;: 25, &quot;h&quot;: 60 },
  &quot;sourceSize&quot;: { &quot;w&quot;: 64, &quot;h&quot;: 64 },
  &quot;pivot&quot;: { &quot;x&quot;: 0.5, &quot;y&quot;: 0.5 }
}</code></pre>
<ul>
<li><code>frame</code> is where the pixels are in the sheet: the source rectangle for <code>drawImage</code>.</li>
<li><code>sourceSize</code> is the frame&#39;s original size, 64 × 64, before the empty border was trimmed off.</li>
<li><code>spriteSourceSize</code> says where the trimmed pixels sat inside that original frame: 20 pixels from the left and 3 from the top. Draw them there and the ball stays put instead of jumping around.</li>
</ul>
<p>The program becomes:</p>
<pre><code class="language-html">&lt;canvas id=&quot;game&quot;&gt;&lt;/canvas&gt;
&lt;script type=&quot;module&quot;&gt;
  const canvas = document.getElementById('game')
  const ctx = canvas.getContext('2d')
  const FRAME_MS = 100
  const SCALE = 3

  async function loadImage(src) {
    const image = new Image()
    image.src = src
    await image.decode()
    return image
  }

  const [sheet, atlas] = await Promise.all([
    loadImage('sprite-sheet.png'),
    fetch('sprite-sheet.json').then(response =&gt; response.json()),
  ])
  const frames = Object.values(atlas.frames)   // in the order they were exported
  const { w, h } = frames[0].sourceSize        // the untrimmed frame size

  const zoom = Math.max(1, Math.round(SCALE * window.devicePixelRatio))
  canvas.width = w * zoom
  canvas.height = h * zoom
  canvas.style.width = canvas.width / window.devicePixelRatio + 'px'
  canvas.style.height = canvas.height / window.devicePixelRatio + 'px'
  ctx.imageSmoothingEnabled = false

  function drawFrame(f, x, y) {
    ctx.drawImage(
      sheet,
      f.frame.x, f.frame.y, f.frame.w, f.frame.h,   // where the pixels are in the sheet
      x + f.spriteSourceSize.x * zoom,              // trimmed frames: put the pixels back
      y + f.spriteSourceSize.y * zoom,              // where they sat in the full frame
      f.frame.w * zoom,
      f.frame.h * zoom,
    )
  }

  let start = null
  function tick(now) {
    if (start === null) start = now
    const index = Math.floor((now - start) / FRAME_MS) % frames.length
    ctx.clearRect(0, 0, canvas.width, canvas.height)
    drawFrame(frames[index], 0, 0)
    requestAnimationFrame(tick)
  }
  requestAnimationFrame(tick)
&lt;/script&gt;</code></pre>
<p>It was tested with three SpritePilot exports of the same 8 frames: trimmed (111 × 180 pixels), untrimmed (a 192 × 192 grid with an empty last cell) and from the Sheet Maker (262 × 130, with 2-pixel gaps). All three drew exactly the same pixels as the original sheet.</p>
<p>A few things to know:</p>
<ul>
<li><code>fetch</code> doesn&#39;t work on a page opened straight from disk (<code>file://</code>). Serve the folder from any local web server.</li>
<li><code>Object.values</code> keeps the order the frames appear in the file, which is the export order. Frame names that are plain numbers (&quot;1&quot;, &quot;2&quot;) are the exception: JavaScript puts those in numeric order.</li>
<li>If you add <strong>Animation Tags</strong> in SpritePilot (walk, idle…), the export lists them in <code>atlas.meta.frameTags</code> with a <code>from</code> and <code>to</code> frame index. <code>frames.slice(tag.from, tag.to + 1)</code> is that animation&#39;s frame list.</li>
<li>SpritePilot never rotates frames. Some packers do, and mark it with <code>&quot;rotated&quot;: true</code>; this code doesn&#39;t handle rotated frames.</li>
</ul>
<p>To make an atlas from separate frame images, the <a href="https://spritepilot.com/bulk-sprite-sheets">Sheet Maker</a> packs them in your browser and writes this same JSON, with the frames in the order you added them. To re-export frames that are already in SpritePilot, with trimming, pivots or animation tags, use Export &amp; Download; <a href="https://spritepilot.com/guides/export-formats-explained">Sprite Sheet Export Formats Explained</a> compares its other formats. To check a sheet&#39;s frame size and speed before writing any code, play it in the <a href="https://spritepilot.com/sprite-player">Sprite Player</a>.</p>
<h2 id="in-short">In short</h2>
<ul>
<li>Pick the frame from elapsed time: <code>Math.floor((now - start) / FRAME_MS) % frameCount</code>. Never count calls.</li>
<li><code>drawImage(image, sx, sy, sw, sh, dx, dy, dw, dh)</code>: source rectangle first, destination second.</li>
<li><code>ctx.imageSmoothingEnabled = false</code> for pixel art, set again after every canvas resize.</li>
<li>Bitmap size = CSS size × devicePixelRatio, with a whole number of screen pixels per art pixel.</li>
<li>From an atlas: draw <code>frame</code> at <code>spriteSourceSize</code> inside a box of <code>sourceSize</code>.</li>
<li>For the same animation without JavaScript, see <a href="https://spritepilot.com/blog/css-sprite-sheet-animation">Animate a Sprite Sheet With CSS steps()</a>.</li>
</ul>
]]></content:encoded>
    </item>
    <item>
      <title>Animate a Sprite Sheet With CSS steps() Without Sliding or Skipped Frames</title>
      <link>https://spritepilot.com/blog/css-sprite-sheet-animation</link>
      <guid isPermaLink="true">https://spritepilot.com/blog/css-sprite-sheet-animation</guid>
      <pubDate>Mon, 12 Oct 2026 12:00:00 GMT</pubDate>
      <description>Play a sprite sheet with background-position and steps(), get the end value right, stop on the last frame, handle multi-row sheets and scale pixel art crisply.</description>
      <category>animation</category>
      <category>sprite sheets</category>
      <category>web</category>
      <content:encoded><![CDATA[<p>You have a sprite sheet and want it to play on a web page: a spinning coin, a loading character, a hover effect. You don&#39;t need a GIF or JavaScript for that. CSS does it with two pieces, <code>background-position</code> to pick the frame and the <code>steps()</code> timing function to jump from frame to frame instead of gliding. The catch is one number that is easy to get wrong, and it makes frames slide sideways or go missing.</p>
<p>Every snippet below was run in Chromium and checked frame by frame.</p>
<h2 id="the-recipe-for-a-one-row-strip">The recipe for a one-row strip</h2>
<p>The example is an 8-frame coin, 32 × 32 pixels per frame, in a strip 256 pixels wide (<a href="https://spritepilot.com/blog/sprite-sheet-frame-size/coin-spin-strip.png">coin-spin-strip.png</a>, free to use). The element is exactly one frame big and shows the strip as its background. The animation moves the background left, one frame at a time.</p>
<pre><code class="language-html">&lt;div class=&quot;coin&quot;&gt;&lt;/div&gt;</code></pre>
<pre><code class="language-css">.coin {
  width: 32px;
  height: 32px;
  background: url(coin-spin-strip.png) 0 0 no-repeat;
  animation: coin-spin 0.8s steps(8) infinite;
}

@keyframes coin-spin {
  from { background-position: 0 0; }
  to   { background-position: -256px 0; }
}</code></pre>
<p>Three numbers have to agree with the sheet:</p>
<ul>
<li><strong>The element is one frame:</strong> 32 × 32 pixels.</li>
<li><strong><code>steps(8)</code> is the number of frames.</strong> Without it the background would glide smoothly past the frames. With it, the move is cut into 8 equal jumps of 32 pixels.</li>
<li><strong>The duration is frames ÷ frame rate:</strong> 8 frames at 10 frames per second = 0.8 s. <a href="https://spritepilot.com/blog/pixel-art-frame-rate">How Fast Should Pixel Art Animate?</a> helps you pick the rate.</li>
</ul>
<p>Now look at the end value. It is −256px, the <strong>full width of the strip</strong>, not −224px, the position of the last frame. The next section shows why that matters.</p>
<h2 id="why-frames-slide-or-skip-the-end-value-rule">Why frames slide or skip: the end-value rule</h2>
<p><code>steps(8)</code> cuts the move from the start value to the end value into 8 equal parts, and by default it shows the value at the <strong>start</strong> of each part. Sampled at the middle of each 100 ms slice, <code>to -256px</code> shows 0, −32, −64 and so on to −224: every frame, once each. The end value itself, an empty spot just past the strip, would only be reached at the very end of the cycle. That is exactly when the loop starts over at 0, so it never shows.</p>
<p>Both classic mistakes come from ending on the last frame&#39;s position instead:</p>
<table>
<thead>
<tr>
<th>Timing</th>
<th>End value</th>
<th>What Chromium shows</th>
</tr>
</thead>
<tbody><tr>
<td><code>steps(8)</code></td>
<td>−256px</td>
<td>0, −32, −64 … −224: all 8 frames</td>
</tr>
<tr>
<td><code>steps(8)</code></td>
<td>−224px</td>
<td>0, −28, −56 … −196: jumps of 28 px, so each frame lands 4 px further off</td>
</tr>
<tr>
<td><code>steps(7)</code></td>
<td>−224px</td>
<td>0, −32 … −192: frame 8 never shows, and each frame lasts 114 ms</td>
</tr>
</tbody></table>
<p><figure><img src="https://spritepilot.com/blog/css-sprite-sheet-animation/end-position-off-by-one.gif" alt="Three copies of a spinning coin animated with CSS: steps(8) to −256px spins in place, steps(8) to −224px slides sideways, steps(7) to −224px skips the last frame" width="600" height="244" loading="lazy" decoding="async"><figcaption>Captured from the running CSS in Chromium. Only the left coin ends at the full strip width.</figcaption></figure></p>
<p>The rule: <strong><code>steps(N)</code>, with the end value at −(N × frame width).</strong> A sprite that drifts sideways while it plays means the end value is short. An animation that always seems to miss its last drawing means N is one too small.</p>
<h2 id="jump-start-jump-end-jump-none-and-jump-both">jump-start, jump-end, jump-none and jump-both</h2>
<p><code>steps()</code> takes an optional second argument that says where the jumps fall (<a href="https://developer.mozilla.org/en-US/docs/Web/CSS/easing-function/steps" target="_blank" rel="noopener noreferrer">MDN&#39;s steps() reference</a> lists four options), and <code>steps(8)</code> is short for <code>steps(8, jump-end)</code>. With jump-end, the last jump happens when the cycle ends. With jump-start, the first jump happens when it begins. jump-none puts neither jump at the edges, and jump-both puts jumps at both. Here is what each one shows on the coin strip:</p>
<p><figure><img src="https://spritepilot.com/blog/css-sprite-sheet-animation/steps-timing-options.png" alt="Rows of coin frames showing what steps(8), steps(7) and the four jump keywords display during one 0.8 s cycle" width="1536" height="1420" loading="lazy" decoding="async"><figcaption>Each block is the real CSS animation paused in the middle of its slice. Red-outlined blocks show the element cut between two frames.</figcaption></figure></p>
<ul>
<li><strong>jump-end</strong> (the default) is the one for loops.</li>
<li><strong>jump-start</strong> jumps as soon as the cycle starts, so frame 1 is skipped, and the last slice shows −256px, the empty spot past the strip. The sprite blinks out once per loop.</li>
<li><strong>jump-none</strong> makes 8 steps that include both ends: the start value and the end value each get a full slice.</li>
<li><strong>jump-both</strong> makes 8 positions 28.4 px apart (256 ÷ 9), so it never lines up with 32 px frames. It has no use for sprite sheets.</li>
</ul>
<p>jump-none is the one for an animation that <strong>plays once and stops on its last frame</strong>, like a chest opening or a one-off burst. End on the last frame&#39;s position this time, and add <code>forwards</code> so the animation stays there when it finishes:</p>
<pre><code class="language-css">.play-once {
  width: 32px;
  height: 32px;
  background: url(coin-spin-strip.png) 0 0 no-repeat;
  animation: play-once 0.8s steps(8, jump-none) forwards;
}

@keyframes play-once {
  to { background-position: -224px 0; }
}</code></pre>
<p>Do the same with the default jump-end and the animation finishes on −256px, so the sprite vanishes the moment it ends.</p>
<p>The jump keywords work in Chrome 77, Firefox 65 and Safari 14 and later. The older <code>start</code> and <code>end</code> spellings mean the same as jump-start and jump-end.</p>
<h2 id="sheets-with-more-than-one-row">Sheets with more than one row</h2>
<p>The bouncing-ball sample, <a href="https://spritepilot.com/samples/ball-bounce.png">ball-bounce.png</a>, has 8 frames of 64 × 64 pixels in 4 columns and 2 rows (256 × 128 pixels). There are two ways to play it.</p>
<p><strong>Two animations</strong>, one walking across the columns and one walking down the rows:</p>
<pre><code class="language-css">.ball {
  width: 64px;
  height: 64px;
  background: url(ball-bounce.png) 0 0 no-repeat;
  animation:
    ball-x 0.4s steps(4) infinite,
    ball-y 0.8s steps(2) infinite;
}

@keyframes ball-x { to { background-position-x: -256px; } }
@keyframes ball-y { to { background-position-y: -128px; } }</code></pre>
<p>The column animation takes one row&#39;s worth of time (4 frames × 0.1 s) and repeats. The row animation takes the whole cycle, 0.8 s, and steps down once halfway through. The end-value rule still holds: the full width and the full height. Sampled live in Chromium, this plays frames 1 to 8 in reading order. It only works when every row is full, though. With 7 frames in a 4 × 2 grid, the empty eighth cell would flash once per loop.</p>
<p><strong>One keyframe per frame</strong> works with any layout, including a half-empty last row, and it lets you time each frame separately. In a CSS animation, the timing function applies between each pair of keyframes, so <code>steps(1)</code> holds each keyframe&#39;s value until the next one:</p>
<pre><code class="language-css">.ball-hold {
  width: 64px;
  height: 64px;
  background: url(ball-bounce.png) 0 0 no-repeat;
  animation: bounce 1s steps(1) infinite;
}

@keyframes bounce {
  0%  { background-position: 0 0; }         /* held for 300 ms */
  30% { background-position: -64px 0; }
  40% { background-position: -128px 0; }
  50% { background-position: -192px 0; }
  60% { background-position: 0 -64px; }     /* the squash */
  70% { background-position: -64px -64px; }
  80% { background-position: -128px -64px; }
  90% { background-position: -192px -64px; }
}</code></pre>
<p>Each percentage is the moment that frame appears. Here the ball hangs at the top for 300 ms, then every other frame gets 100 ms. Holding key poses like this is what makes an animation feel weighted instead of mechanical.</p>
<h2 id="scale-it-up-without-blur">Scale it up without blur</h2>
<p>Pixel art at 32 pixels is tiny on a modern screen. To show it 4× larger, multiply every pixel number by 4 (the element, the size of the sheet and the end value) and switch off smoothing:</p>
<pre><code class="language-css">.coin-4x {
  width: 128px;    /* 32 × 4 */
  height: 128px;
  background: url(coin-spin-strip.png) 0 0 / 1024px 128px no-repeat;
  image-rendering: pixelated;
  animation: coin-spin-4x 0.8s steps(8) infinite;
}

@keyframes coin-spin-4x {
  to { background-position: -1024px 0; }
}</code></pre>
<ul>
<li><code>/ 1024px 128px</code> in the shorthand is <code>background-size</code>: the 256 × 32 sheet drawn at 4×. The end value is the new strip width, 8 × 128 = 1024.</li>
<li><a href="https://developer.mozilla.org/en-US/docs/Web/CSS/image-rendering" target="_blank" rel="noopener noreferrer"><code>image-rendering: pixelated</code></a> tells the browser to scale with nearest-neighbor instead of blending. In a Chromium screenshot of the first frame, the pixelated coin uses only the 7 colors drawn in the sheet. Without it, the same screenshot has 336 colors, most of them blends along the edges. It works in Chrome 41, Firefox 93, Safari 10 and later.</li>
<li>Use whole-number scales (2×, 3×, 4×) so every art pixel covers the same number of screen pixels. For more on this, see <a href="https://spritepilot.com/blog/scale-pixel-art-without-blur">How to Scale Pixel Art Without Blurring It</a>.</li>
</ul>
<h2 id="pause-it-and-respect-reduced-motion">Pause it, and respect reduced motion</h2>
<p>Two small rules make a looping sprite friendlier: one freezes it under the mouse, the other turns it off for visitors who have asked for less motion.</p>
<pre><code class="language-css">.coin:hover {
  animation-play-state: paused;
}

@media (prefers-reduced-motion: reduce) {
  .coin {
    animation: none;
  }
}</code></pre>
<p>A paused CSS animation resumes from where it stopped. In the test, the hovered coin held its frame for as long as the mouse stayed on it, then carried on with the next frame when the mouse left.</p>
<p><a href="https://developer.mozilla.org/en-US/docs/Web/CSS/@media/prefers-reduced-motion" target="_blank" rel="noopener noreferrer"><code>prefers-reduced-motion: reduce</code></a> matches when the visitor has turned on the reduce-motion setting of their operating system. macOS, iOS, Windows, Android and the main Linux desktops all have one. With <code>animation: none</code>, the element falls back to its own <code>background-position</code>, so it shows frame 1. To rest on a better pose, set a different position in the same rule, such as <code>background-position: -64px 0</code> for frame 3.</p>
<h2 id="getting-a-strip-and-its-positions-from-spritepilot">Getting a strip and its positions from SpritePilot</h2>
<p>If your sheet has several rows or gaps between frames, SpritePilot can turn it into a plain strip. Cut it into frames with Grid Slice in <a href="https://spritepilot.com/sprite-detection">Sprite Detection</a>, then open <a href="https://spritepilot.com/export-download">Export &amp; Download</a> and choose <strong>Horizontal Strip</strong> under Sprite Sheet Layout, PNG as the image format and <strong>CSS Sprites</strong> as the metadata. You get <code>sprite-sheet.png</code> plus <code>sprite-sheet.css</code>.</p>
<p>The CSS file isn&#39;t an animation. It holds a <code>.sprite</code> base class and one class per frame with that frame&#39;s position and size:</p>
<pre><code class="language-css">.sprite { display: inline-block; box-sizing: content-box; background-image: url('sprite-sheet.png'); background-repeat: no-repeat; background-origin: content-box; background-clip: content-box; }
.sprite-sprite_0001 { background-position: -0px -0px; width: 32px; height: 32px; }
.sprite-sprite_0002 { background-position: -32px -0px; width: 32px; height: 32px; }</code></pre>
<p>That makes a good base for an animation. The first frame&#39;s class gives the element its size and its resting frame, and the animation does the rest:</p>
<pre><code class="language-html">&lt;div class=&quot;sprite sprite-sprite_0001 coin&quot;&gt;&lt;/div&gt;</code></pre>
<pre><code class="language-css">.coin { animation: coin-spin 0.8s steps(8) infinite; }

@keyframes coin-spin { to { background-position: -256px 0; } }</code></pre>
<p>While the animation runs, it overrides the class&#39;s <code>background-position</code>; with reduced motion on, the class shows frame 1. Leave <strong>Trim transparency</strong> off for animation frames: trimmed frames are centered in their cells, so any sprite that isn&#39;t symmetrical shifts from frame to frame. To find the right speed before you write it into the duration, play the sheet in the <a href="https://spritepilot.com/sprite-player">Sprite Player</a> and move its speed slider anywhere from 1 to 60 fps. Grid Slice, Export &amp; Download and the Sprite Player all run in your browser; nothing is uploaded.</p>
<h2 id="a-checklist">A checklist</h2>
<ul>
<li>The element is one frame: width and height of a single frame.</li>
<li><code>steps(N)</code>, where N is the number of frames in the row.</li>
<li>End value = −(N × frame width): the full strip, not the last frame.</li>
<li>Duration = N ÷ frames per second.</li>
<li>Play once and stop: <code>steps(N, jump-none)</code>, end on the last frame, add <code>forwards</code>.</li>
<li>Several rows: two animations if every row is full, otherwise one keyframe per frame with <code>steps(1)</code>.</li>
<li>Bigger: multiply every number by a whole-number scale and add <code>image-rendering: pixelated</code>.</li>
<li>Add a <code>prefers-reduced-motion</code> rule that turns the animation off.</li>
</ul>
]]></content:encoded>
    </item>
    <item>
      <title>Sprite Sheet Animation in Pygame, Step by Step</title>
      <link>https://spritepilot.com/blog/pygame-sprite-sheet-animation</link>
      <guid isPermaLink="true">https://spritepilot.com/blog/pygame-sprite-sheet-animation</guid>
      <pubDate>Sun, 11 Oct 2026 12:00:00 GMT</pubDate>
      <description>Load a sprite sheet in pygame, cut it into frames, animate at the same speed at any frame rate, flip and scale pixel art crisply, and draw with sprite groups.</description>
      <category>animation</category>
      <category>sprite sheets</category>
      <category>pixel art</category>
      <content:encoded><![CDATA[<p>Pygame has no animation system. A sprite sheet is just one image, and animation is your code showing a different part of it several times a second. The good news is that the code is short, and you control every detail of it.</p>
<p>This walkthrough builds it piece by piece: loading the sheet, cutting frames, an <code>Animation</code> class that runs at the same speed at any frame rate, several animations from one sheet, flipping, crisp scaling and sprite groups. It follows the pygame 2.6 documentation, and the pieces add up to one complete program.</p>
<h2 id="load-the-sheet-and-cut-out-the-frames">Load the sheet and cut out the frames</h2>
<p>You need two numbers first: the frame size and how fast the animation should play. If the sheet came without them, load it into SpritePilot&#39;s <a href="https://spritepilot.com/sprite-player">Sprite Player</a>, enter the columns and rows, and it shows the frame size and plays the animation at any speed from 1 to 60 FPS. The coin strip used here is 256 × 32 pixels with 8 frames, so each frame is 32 × 32, and it looks right at 10 FPS.</p>
<pre><code class="language-python">import pygame

pygame.init()
screen = pygame.display.set_mode((640, 360))

sheet = pygame.image.load(&quot;coin-spin-strip.png&quot;).convert_alpha()
frames = [sheet.subsurface((i * 32, 0, 32, 32)) for i in range(8)]</code></pre>
<p>Two calls do the work:</p>
<ul>
<li><strong><code>convert_alpha()</code></strong> makes a copy of the image in the window&#39;s pixel format, keeping per-pixel transparency, so it draws quickly. Because it converts to the window&#39;s format, call it after <code>pygame.display.set_mode()</code>.</li>
<li><strong><code>subsurface(rect)</code></strong> returns a Surface for one rectangle of the sheet. As <a href="https://www.pygame.org/docs/ref/surface.html#pygame.Surface.subsurface" target="_blank" rel="noopener noreferrer">the documentation</a> explains, it shares its pixels with the sheet, so cutting a sheet into 100 frames copies nothing. If you plan to edit frames individually, blit each one into its own <code>pygame.Surface((32, 32), pygame.SRCALPHA)</code> instead.</li>
</ul>
<p><figure><img src="https://spritepilot.com/blog/pygame-sprite-sheet-animation/cutting-frames-with-subsurface.png" alt="A coin strip cut into eight 32-pixel frames, each labeled with its index and x position, and the slime sheet with its idle, hop and attack rows outlined" width="1536" height="1090" loading="lazy" decoding="async"><figcaption>Every frame is a rectangle on the sheet. A sheet with one animation per row is cut the same way, row by row.</figcaption></figure></p>
<h2 id="a-function-for-any-grid">A function for any grid</h2>
<p>Real sheets have several rows and sometimes a border (margin) or gaps (spacing). This function handles all of them and returns the frames row by row. Save it, and every piece after it, in one file, <code>game.py</code>, next to the PNGs:</p>
<pre><code class="language-python">import pygame

SCALE = 4  # draw the 32 x 32 pixel art four times larger


def slice_grid(sheet, frame_w, frame_h, margin=0, spacing=0):
    &quot;&quot;&quot;Cut a sheet into rows of frames. Each frame shares the sheet's pixels.&quot;&quot;&quot;
    cols = (sheet.get_width() - 2 * margin + spacing) // (frame_w + spacing)
    rows = (sheet.get_height() - 2 * margin + spacing) // (frame_h + spacing)
    return [
        [
            sheet.subsurface((margin + c * (frame_w + spacing),
                              margin + r * (frame_h + spacing),
                              frame_w, frame_h))
            for c in range(cols)
        ]
        for r in range(rows)
    ]


def enlarge(frames, factor=SCALE):
    &quot;&quot;&quot;Nearest-neighbor scaling: crisp at whole-number factors.&quot;&quot;&quot;
    return [pygame.transform.scale(f, (f.get_width() * factor, f.get_height() * factor))
            for f in frames]</code></pre>
<p>For a sheet with a 4-pixel border and 2-pixel gaps, call <code>slice_grid(sheet, 32, 32, margin=4, spacing=2)</code>. If you don&#39;t know those numbers, <a href="https://spritepilot.com/blog/sprite-sheet-frame-size">How to Find the Frame Size of a Sprite Sheet</a> shows how to work them out. <code>enlarge()</code> is explained under scaling below.</p>
<h2 id="an-animation-class-that-keeps-time">An Animation class that keeps time</h2>
<p>The simplest version adds 1 to the frame index on every pass through the game loop. At 60 loops a second, an 8-frame coin then spins seven and a half times a second, and on a 144 Hz monitor it spins faster still. The animation&#39;s speed has to come from elapsed time, not from the number of loops.</p>
<p><figure><img src="https://spritepilot.com/blog/pygame-sprite-sheet-animation/time-based-frames.png" alt="Half a second of a 10 FPS coin animation drawn three ways: at 60 and 144 loops a second the coin changes frame every 100 milliseconds in both cases; adding one per loop cycles through all eight frames every 133 milliseconds" width="1536" height="810" loading="lazy" decoding="async"><figcaption>With elapsed time, the coin changes frame every 100 ms whether the loop runs at 60 or 144 FPS. Counting loops ties the animation to the frame rate.</figcaption></figure></p>
<p>Pygame gives you the elapsed time for free: <a href="https://www.pygame.org/docs/ref/time.html#pygame.time.Clock.tick" target="_blank" rel="noopener noreferrer"><code>clock.tick()</code></a> returns the milliseconds since its previous call. The class adds those up and moves to the next frame each time a frame&#39;s worth of time has passed:</p>
<pre><code class="language-python">class Animation:
    &quot;&quot;&quot;A list of frames played at a fixed rate, driven by elapsed milliseconds.&quot;&quot;&quot;

    def __init__(self, frames, fps, loop=True):
        self.frames = frames
        self.mirrored = [pygame.transform.flip(f, True, False) for f in frames]
        self.frame_ms = 1000 / fps
        self.loop = loop
        self.reset()

    def reset(self):
        self.index = 0
        self.elapsed = 0.0
        self.done = False

    def update(self, dt_ms):
        if self.done:
            return
        self.elapsed += dt_ms
        while self.elapsed &gt;= self.frame_ms:
            self.elapsed -= self.frame_ms
            if self.index &lt; len(self.frames) - 1:
                self.index += 1
            elif self.loop:
                self.index = 0
            else:
                self.done = True
                break

    def current(self, flipped=False):
        return (self.mirrored if flipped else self.frames)[self.index]</code></pre>
<p>Three details matter. Subtracting <code>frame_ms</code> instead of resetting <code>elapsed</code> to 0 carries the leftover milliseconds into the next frame, so the timing doesn&#39;t drift. The <code>while</code> loop catches up if one slow frame took longer than a whole animation frame. And <code>loop=False</code> makes a one-shot animation, such as an attack, stop on its last frame and set <code>done</code>.</p>
<h2 id="several-animations-in-one-sheet">Several animations in one sheet</h2>
<p>The slime sheet holds three animations, one per row: idle (4 frames), hop (6) and attack (5). The rows have different lengths, so slice the whole grid and trim each row to its frame count. The same file also needs a coin:</p>
<pre><code class="language-python">class Coin(pygame.sprite.Sprite):
    def __init__(self, sheet, pos):
        super().__init__()
        frames = enlarge(slice_grid(sheet, 32, 32)[0])  # one row of 8 frames
        self.anim = Animation(frames, fps=10)
        self.image = self.anim.current()
        self.rect = self.image.get_rect(center=pos)

    def update(self, dt_ms, *args):
        self.anim.update(dt_ms)
        self.image = self.anim.current()


class Slime(pygame.sprite.Sprite):
    SPEED = 120  # pixels per second

    def __init__(self, sheet, pos):
        super().__init__()
        idle, hop, attack = slice_grid(sheet, 32, 32)  # one animation per row
        self.anims = {
            &quot;idle&quot;: Animation(enlarge(idle[:4]), fps=6),
            &quot;hop&quot;: Animation(enlarge(hop), fps=10),
            &quot;attack&quot;: Animation(enlarge(attack[:5]), fps=12, loop=False),
        }
        self.state = &quot;idle&quot;
        self.facing_left = False
        self.x = float(pos[0])
        self.image = self.anims[&quot;idle&quot;].current()
        self.rect = self.image.get_rect(midbottom=pos)

    def play(self, state):
        if state != self.state:
            self.state = state
            self.anims[state].reset()

    def update(self, dt_ms, move, attack_pressed):
        if self.state != &quot;attack&quot;:
            if attack_pressed:
                self.play(&quot;attack&quot;)
            elif move:
                self.facing_left = move &lt; 0
                self.x += move * self.SPEED * dt_ms / 1000
                self.play(&quot;hop&quot;)
            else:
                self.play(&quot;idle&quot;)

        anim = self.anims[self.state]
        anim.update(dt_ms)
        if anim.done:  # a one-shot animation finished
            self.play(&quot;idle&quot;)
            anim = self.anims[&quot;idle&quot;]

        bottom = self.rect.bottom
        self.image = anim.current(self.facing_left)
        self.rect = self.image.get_rect(midbottom=(round(self.x), bottom))</code></pre>
<p><code>play()</code> restarts an animation only when the state actually changes, so calling it every frame keeps a running animation going. Movement uses the same elapsed time as the animation, so the slime crosses the screen at 120 pixels per second at any frame rate. The rectangle is re-anchored at the bottom center each frame, which keeps the slime&#39;s feet in place even if frames had different sizes.</p>
<h2 id="flip-and-scale-once-at-load-time">Flip and scale once, at load time</h2>
<p>The slime is drawn facing right. For left, <a href="https://www.pygame.org/docs/ref/transform.html#pygame.transform.flip" target="_blank" rel="noopener noreferrer"><code>pygame.transform.flip(surface, True, False)</code></a> returns a mirrored copy, so the <code>Animation</code> class builds the mirrored frames once, in <code>__init__</code>, and <code>current(flipped=True)</code> picks them. Flipping every frame would allocate a new Surface 60 times a second for nothing.</p>
<p>Scaling works the same way: once, when loading. The function you choose decides how pixel art looks:</p>
<p><figure><img src="https://spritepilot.com/blog/pygame-sprite-sheet-animation/scale-vs-smoothscale.png" alt="The slime enlarged three ways: four times with scale, crisp; four times with smoothscale, blurred; and 1.5 times with scale, with pixels of uneven width" width="1536" height="726" loading="lazy" decoding="async"><figcaption>Drawn with the same two filters: scale() copies pixels, smoothscale() blends them. Whole-number factors keep every pixel the same size.</figcaption></figure></p>
<ul>
<li><strong><code>scale()</code></strong> is a fast resize that, in the documentation&#39;s words, &quot;does not sample the results&quot;: each source pixel is copied, so at 2×, 3× or 4× every pixel becomes a clean square block. This is the one for pixel art.</li>
<li><strong><code>smoothscale()</code></strong> uses a bilinear filter when enlarging, which blends neighboring pixels and blurs hard edges. Keep it for photos and painted art.</li>
<li><strong><code>scale_by(surface, 4)</code></strong>, added in pygame 2.1.3, is <code>scale()</code> with a factor instead of a size. The documentation still marks it experimental.</li>
</ul>
<p>Whatever the function, stick to whole-number factors: at 1.5× some pixels come out one pixel wide and others two. <a href="https://spritepilot.com/blog/scale-pixel-art-without-blur">How to Scale Pixel Art Without Blurring It</a> covers the same problem in other tools.</p>
<h2 id="draw-everything-with-a-sprite-group">Draw everything with a sprite group</h2>
<p>A <code>pygame.sprite.Group</code> calls <code>update()</code> on every sprite with whatever arguments you pass it, and its <code>draw()</code> blits each sprite&#39;s <code>image</code> at its <code>rect</code>. That&#39;s why both classes keep those two attributes up to date. The last piece is the game loop:</p>
<pre><code class="language-python">def main():
    pygame.init()
    screen = pygame.display.set_mode((640, 360))
    clock = pygame.time.Clock()

    # convert_alpha() converts to the window's pixel format, so call it after set_mode()
    coin_sheet = pygame.image.load(&quot;coin-spin-strip.png&quot;).convert_alpha()
    slime_sheet = pygame.image.load(&quot;slime-sheet.png&quot;).convert_alpha()
    sprites = pygame.sprite.Group(Coin(coin_sheet, (480, 150)), Slime(slime_sheet, (200, 300)))

    running = True
    while running:
        dt = clock.tick(60)  # milliseconds since the previous frame
        attack = False
        for event in pygame.event.get():
            if event.type == pygame.QUIT:
                running = False
            elif event.type == pygame.KEYDOWN and event.key == pygame.K_SPACE:
                attack = True

        keys = pygame.key.get_pressed()
        move = keys[pygame.K_RIGHT] - keys[pygame.K_LEFT]

        sprites.update(dt, move, attack)
        screen.fill((31, 41, 55))
        sprites.draw(screen)
        pygame.display.flip()

    pygame.quit()


if __name__ == &quot;__main__&quot;:
    main()</code></pre>
<p><code>clock.tick(60)</code> both limits the loop to 60 frames a second and returns the time since the last frame, which is the <code>dt</code> every sprite receives. The arrow keys move the slime, which hops and turns to face its direction; Space attacks once. A plain <code>Group</code> draws its sprites in no particular order, so if sprites overlap and the order matters, use <code>pygame.sprite.LayeredUpdates</code> instead.</p>
<h2 id="a-checklist">A checklist</h2>
<ul>
<li><code>convert_alpha()</code> every sheet, after <code>set_mode()</code>.</li>
<li>One <code>subsurface()</code> per frame; separate Surfaces only if you edit frames.</li>
<li>Advance frames by elapsed milliseconds from <code>clock.tick()</code>, never once per loop.</li>
<li>Flip and scale when loading, with <code>scale()</code> at whole-number factors.</li>
<li>Keep <code>image</code> and <code>rect</code> current, and let a group update and draw.</li>
</ul>
<p>Both sheets are free to use in any project (CC0): <a href="https://spritepilot.com/blog/sprite-sheet-frame-size/coin-spin-strip.png">the coin strip</a> (256 × 32, 8 frames) and <a href="https://spritepilot.com/blog/pygame-sprite-sheet-animation/slime-sheet.png">the slime sheet</a> (192 × 96, three rows of 32 × 32 frames). Save them next to <code>game.py</code> and run it. The same slime is animated in <a href="https://spritepilot.com/blog/godot-4-sprite-animation">Godot 4</a>, <a href="https://spritepilot.com/blog/unity-sprite-sheet-animation">Unity</a> and <a href="https://spritepilot.com/blog/phaser-sprite-sheets-and-atlases">Phaser</a> too. For more, the <a href="https://spritepilot.com/free-game-assets">Free Pixel Art Game Assets</a> page has 70 animated CC0 strips, such as <a href="https://spritepilot.com/free-game-assets/pickups">pickups</a> and <a href="https://spritepilot.com/free-game-assets/creatures">creatures</a>, that slice with the same code.</p>
]]></content:encoded>
    </item>
    <item>
      <title>Phaser: Loading Sprite Sheets and Texture Atlases (and Animating Them)</title>
      <link>https://spritepilot.com/blog/phaser-sprite-sheets-and-atlases</link>
      <guid isPermaLink="true">https://spritepilot.com/blog/phaser-sprite-sheets-and-atlases</guid>
      <pubDate>Sat, 10 Oct 2026 12:00:00 GMT</pubDate>
      <description>load.spritesheet, load.atlas or load.aseprite? How each one names its frames in Phaser 3 and 4, how to build animations from them, and when to use which.</description>
      <category>phaser</category>
      <category>animation</category>
      <category>sprite sheets</category>
      <category>web</category>
      <content:encoded><![CDATA[<p>Phaser can load animation frames three ways: <code>load.spritesheet</code>, <code>load.atlas</code> and <code>load.aseprite</code>. The one you choose decides how you refer to frames for the rest of the project, by number or by name, and mixing the two up is how you end up with &quot;frame not found&quot; warnings.</p>
<p>This article loads the same character both ways, builds its animations, and covers playing, flipping and reacting to animation events. Everything here works in Phaser 3.60 and later, including Phaser 4; the details were checked against the Phaser source.</p>
<h2 id="sprite-sheet-or-atlas">Sprite sheet or atlas?</h2>
<p><figure><img src="https://spritepilot.com/blog/phaser-sprite-sheets-and-atlases/sprite-sheet-vs-atlas-in-phaser.png" alt="Left: the slime sprite sheet as a 6 by 3 grid with frames numbered 0 to 17, including three empty cells. Right: the same frames as a trimmed atlas, each frame a different size. The hop frames are highlighted in both" width="1536" height="910" loading="lazy" decoding="async"><figcaption>Same 15 drawings. The sprite sheet gives them numbers by position; the atlas gives them names from its JSON file.</figcaption></figure></p>
<p>A <strong>sprite sheet</strong>, in Phaser&#39;s terms, is one image where every frame is the same size on a grid. There is no data file: you tell Phaser the frame size, and it numbers the cells 0, 1, 2… left to right, top to bottom.</p>
<p>A <strong>texture atlas</strong> is an image plus a JSON file that lists each frame&#39;s name and rectangle. Frames can be any size, trimmed of empty space and packed tightly, and one atlas can hold a whole character, or several.</p>
<p>Use a sprite sheet when all frames share one size and you want no extra files: one character, a prototype, a sheet from an artist. Use an atlas when frames vary in size, when trimming saves real space, when you&#39;d rather write <code>&#39;attack_0002.png&#39;</code> than remember that the attack starts at frame 12, or when you want many sprites in one texture.</p>
<h2 id="loading-a-sprite-sheet">Loading a sprite sheet</h2>
<pre><code class="language-js">preload() {
  this.load.spritesheet('slime-grid', 'assets/slime-sheet.png', {
    frameWidth: 32,
    frameHeight: 32,
  })
}</code></pre>
<p>The <a href="https://docs.phaser.io/phaser/concepts/loader" target="_blank" rel="noopener noreferrer">frame config</a> takes six options:</p>
<ul>
<li><strong>frameWidth</strong> is required; <strong>frameHeight</strong> defaults to the same value.</li>
<li><strong>margin</strong> is a border around the whole sheet, and <strong>spacing</strong> is the gap between frames. If the frame size doesn&#39;t divide the image evenly, the sheet probably has one or both; <a href="https://spritepilot.com/blog/sprite-sheet-frame-size">How to Find the Frame Size of a Sprite Sheet</a> shows how to work them out.</li>
<li><strong>startFrame</strong> and <strong>endFrame</strong> load only part of the grid. The frames you keep are numbered from 0 again, so <code>startFrame: 6</code> makes the seventh cell frame 0.</li>
</ul>
<p><strong>Empty cells get numbers too.</strong> In the slime sheet above, idle is frames 0–3, cells 4 and 5 are empty, hop is 6–11 and attack is 12–16. Forget them and every animation after them is off by two frames.</p>
<h2 id="loading-an-atlas">Loading an atlas</h2>
<pre><code class="language-js">preload() {
  this.load.atlas('slime', 'assets/sprite-sheet.png', 'assets/sprite-sheet.phaser.json')
}</code></pre>
<p>Phaser reads both TexturePacker layouts, the <strong>JSON Hash</strong> (frames as an object) and the <strong>JSON Array</strong> (frames as a list), and <a href="https://github.com/phaserjs/phaser/blob/v4.2.1/src/textures/TextureManager.js#L791" target="_blank" rel="noopener noreferrer">detects which one</a> you gave it. SpritePilot&#39;s <a href="https://spritepilot.com/export-download">Export &amp; Download</a> writes both: its <strong>Phaser 3</strong> format is the array and its <strong>TexturePacker</strong> format is the hash. Here is one frame from the slime atlas:</p>
<pre><code class="language-json">{
  &quot;filename&quot;: &quot;hop_0001.png&quot;,
  &quot;rotated&quot;: false,
  &quot;trimmed&quot;: true,
  &quot;sourceSize&quot;: { &quot;w&quot;: 32, &quot;h&quot;: 32 },
  &quot;spriteSourceSize&quot;: { &quot;x&quot;: 1, &quot;y&quot;: 16, &quot;w&quot;: 30, &quot;h&quot;: 14 },
  &quot;frame&quot;: { &quot;x&quot;: 0, &quot;y&quot;: 32, &quot;w&quot;: 30, &quot;h&quot;: 14 },
  &quot;pivot&quot;: { &quot;x&quot;: 0.5, &quot;y&quot;: 1 }
}</code></pre>
<p>Three fields do useful work in Phaser:</p>
<ul>
<li><strong>filename</strong> is the frame&#39;s name. SpritePilot uses the names you type under <strong>Rename &amp; Reorder Sprites</strong>, plus <code>.png</code>; untouched frames are <code>sprite_0001.png</code>, <code>sprite_0002.png</code> and so on.</li>
<li><strong>sourceSize</strong> and <strong>spriteSourceSize</strong> describe the trimming. Phaser puts the 30 × 14 pixels back at their place in the original 32 × 32 frame, so trimmed frames don&#39;t jump around.</li>
<li><strong>pivot</strong> becomes the sprite&#39;s origin. With the pivot at bottom center (0.5, 1), a sprite&#39;s x and y mark the bottom center of its frame, where a character&#39;s feet usually are, which makes placing characters on the ground easy.</li>
</ul>
<p><figure><img src="https://spritepilot.com/blog/phaser-sprite-sheets-and-atlases/spritepilot-phaser-export.png" alt="SpritePilot's Export settings with the Phaser 3 JSON array format selected and the Rename and Reorder Sprites list showing idle_0001 to idle_0004, hop_0001 and hop_0002" width="1536" height="814" loading="lazy" decoding="async"><figcaption>Names typed here become the frame names in the JSON. Number them with the same zero padding so generateFrameNames can find them.</figcaption></figure></p>
<h2 id="building-animations-frame-numbers-or-frame-names">Building animations: frame numbers or frame names</h2>
<p>For a sprite sheet, <code>generateFrameNumbers</code> turns a range into frames:</p>
<pre><code class="language-js">this.anims.create({
  key: 'hop',
  frames: this.anims.generateFrameNumbers('slime-grid', { start: 6, end: 11 }),
  frameRate: 10,
  repeat: -1,
})</code></pre>
<p>For an atlas, <code>generateFrameNames</code> builds each name from a prefix, a zero-padded number and a suffix, so this produces <code>attack_0001.png</code> to <code>attack_0005.png</code>:</p>
<pre><code class="language-js">this.anims.create({
  key: 'attack',
  frames: this.anims.generateFrameNames('slime', {
    prefix: 'attack_',
    start: 1,
    end: 5,
    zeroPad: 4,
    suffix: '.png',
  }),
  frameRate: 12,
})</code></pre>
<p>If a name doesn&#39;t exist, Phaser logs <code>Frame &quot;attack_1.png&quot; not found in texture &quot;slime&quot;</code> and leaves that frame out. The usual suspects are a missing <code>suffix: &#39;.png&#39;</code> and a wrong <code>zeroPad</code>. Both helpers also take a <code>frames</code> array, such as <code>frames: [6, 7, 8, 7]</code>, when the order isn&#39;t a simple range.</p>
<p>The rest of the config:</p>
<ul>
<li><strong>frameRate</strong> defaults to 24 when you give neither it nor a <code>duration</code>, which is fast for pixel art. <a href="https://spritepilot.com/blog/pixel-art-frame-rate">How Fast Should Pixel Art Animate?</a> has better starting points.</li>
<li><strong>repeat: -1</strong> loops forever; leave it out for one-shot animations. <strong>yoyo: true</strong> plays forward, then backward.</li>
<li><strong>Animations are global.</strong> <code>this.anims</code> is the game&#39;s <a href="https://docs.phaser.io/phaser/concepts/animations" target="_blank" rel="noopener noreferrer">Animation Manager</a>, so an animation created in one scene can be played by sprites in any scene. Create each key once; creating it again only logs a warning and returns the existing one.</li>
</ul>
<h3 id="shortcut-spritepilots-animation-tags">Shortcut: SpritePilot&#39;s animation tags</h3>
<p>If you define <strong>Animation Tags</strong> in SpritePilot&#39;s Export (idle 0–3, hop 4–9, attack 10–14), they&#39;re written into the JSON&#39;s <code>meta.frameTags</code>. Phaser keeps the JSON&#39;s extra data in <code>texture.customData</code>, and <code>getFrameNames()</code> returns the frames in the order the JSON lists them, so you can build every animation in one loop:</p>
<pre><code class="language-js">const texture = this.textures.get('slime')
const names = texture.getFrameNames()
for (const tag of texture.customData.meta?.frameTags ?? []) {
  this.anims.create({
    key: tag.name,
    frames: names.slice(tag.from, tag.to + 1).map(frame =&gt; ({ key: 'slime', frame })),
    frameRate: 10,
    repeat: -1,
    yoyo: tag.direction === 'pingpong',
  })
}</code></pre>
<p>Override one-shot animations when you play them, for example <code>sprite.play({ key: &#39;attack&#39;, repeat: 0 })</code>.</p>
<h2 id="playing-flipping-and-listening">Playing, flipping and listening</h2>
<pre><code class="language-js">create() {
  // create the idle, hop and attack animations first (see above)
  this.slime = this.add.sprite(160, 120, 'slime', 'idle_0001.png')
  this.cursors = this.input.keyboard.createCursorKeys()
  this.attacking = false
  this.slime.on('animationcomplete-attack', () =&gt; {
    this.attacking = false
  })
}

update() {
  const { left, right, space } = this.cursors
  if (this.attacking) return

  if (Phaser.Input.Keyboard.JustDown(space)) {
    this.attacking = true
    this.slime.play('attack')
  } else if (left.isDown || right.isDown) {
    this.slime.setFlipX(left.isDown)
    this.slime.play('hop', true)
  } else {
    this.slime.play('idle', true)
  }
}</code></pre>
<p>The <code>true</code> in <code>play(&#39;hop&#39;, true)</code> is <code>ignoreIfPlaying</code>. Without it, each call restarts the animation, so a <code>play()</code> inside <code>update()</code> shows only the first frame forever. The attack call leaves it out on purpose, so a new attack always starts from its first frame.</p>
<p><code>animationcomplete-attack</code> is the per-key form of the <code>animationcomplete</code> event. It fires only for animations with a finite repeat count; an animation with <code>repeat: -1</code> never completes. To react to a particular frame, such as a hit or a footstep, listen for <code>animationupdate</code>; its listener receives the animation and the current frame, and <code>frame.textureFrame</code> is the frame&#39;s name or number.</p>
<p>For pixel art, add <code>pixelArt: true</code> to the game config. Phaser&#39;s documentation says it turns antialiasing off and <code>roundPixels</code> on, which keeps scaled sprites crisp.</p>
<h2 id="what-about-load-aseprite">What about load.aseprite?</h2>
<p><code>this.load.aseprite(&#39;paladin&#39;, &#39;paladin.png&#39;, &#39;paladin.json&#39;)</code> followed by <code>this.anims.createFromAseprite(&#39;paladin&#39;)</code> turns every tag in an Aseprite export into an animation, with Aseprite&#39;s frame durations, reverse and ping-pong directions. The catch is in the source: <code>createFromAseprite</code> looks frames up by their number, <code>&quot;0&quot;</code>, <code>&quot;1&quot;</code> and so on. That only works if you exported from Aseprite with the <strong>Item Filename</strong> field set to just <code>{frame}</code>, as the Phaser source comments explain. Files that copy Aseprite&#39;s layout but give their frames names, such as SpritePilot&#39;s Aseprite JSON, load fine with <code>load.atlas</code> instead.</p>
<h2 id="checklist">Checklist</h2>
<ul>
<li>Same-size frames and no data file: <code>load.spritesheet</code> with <code>generateFrameNumbers</code>, and count the empty cells.</li>
<li>Trimmed, packed or named frames: <code>load.atlas</code> with <code>generateFrameNames</code>, including the <code>.png</code> suffix and the zero padding.</li>
<li>Inside <code>update()</code>, use <code>play(key, true)</code>.</li>
<li>One-shot animations: no <code>repeat</code>, then <code>animationcomplete-&lt;key&gt;</code>.</li>
<li>Pixel art: <code>pixelArt: true</code>.</li>
</ul>
<p>The files from this article are free to use in any project (CC0): <a href="https://spritepilot.com/blog/phaser-sprite-sheets-and-atlases/slime-sheet.png">the grid sheet</a> (192 × 96, 32 × 32 frames), and SpritePilot&#39;s atlas export of the same frames, <a href="https://spritepilot.com/blog/phaser-sprite-sheets-and-atlases/sprite-sheet.png">sprite-sheet.png</a> with <a href="https://spritepilot.com/blog/phaser-sprite-sheets-and-atlases/sprite-sheet.phaser.json">sprite-sheet.phaser.json</a>. For a step-by-step export, see <a href="https://spritepilot.com/guides/sprite-sheet-for-phaser">How to Export a Phaser Texture Atlas from SpritePilot</a>. The same slime is animated in <a href="https://spritepilot.com/blog/godot-4-sprite-animation">Godot 4</a>, <a href="https://spritepilot.com/blog/unity-sprite-sheet-animation">Unity</a> and <a href="https://spritepilot.com/blog/pygame-sprite-sheet-animation">pygame</a> too. Seventy more animated CC0 sprites are on the <a href="https://spritepilot.com/free-game-assets">Free Pixel Art Game Assets</a> page; every strip loads with <code>load.spritesheet</code> and the frame size in its file name.</p>
]]></content:encoded>
    </item>
    <item>
      <title>Unity 2D Sprite Sheet Animation: From Sheet to Animated Character</title>
      <link>https://spritepilot.com/blog/unity-sprite-sheet-animation</link>
      <guid isPermaLink="true">https://spritepilot.com/blog/unity-sprite-sheet-animation</guid>
      <pubDate>Fri, 09 Oct 2026 12:00:00 GMT</pubDate>
      <description>Import, slice and animate a sprite sheet in Unity 6, with crisp import settings, Sprite Editor slicing, clips, Animator transitions and a C# script.</description>
      <category>unity</category>
      <category>animation</category>
      <category>sprite sheets</category>
      <content:encoded><![CDATA[<p>Turning a sprite sheet into a character that idles, runs and attacks takes five steps in Unity: import settings, slicing, animation clips, an Animator Controller and a script. Several of them have a default that leaves the result blurry, too fast, sluggish or stuck in an animation. This walkthrough covers all five in Unity 6, with a free sheet to follow along with (the download is at the end).</p>
<p><figure><img src="https://spritepilot.com/blog/unity-sprite-sheet-animation/slime-idle-run-attack.gif" alt="A pixel-art slime idles, hops to the right, turns and hops back to the left, then lunges in an attack, with the current Animator state and parameters shown above it" width="560" height="176" loading="lazy" decoding="async"><figcaption>The finished character: Idle, Run while Speed is above 0.1, flipX when moving left, and Attack on a trigger.</figcaption></figure></p>
<h2 id="1-import-settings-that-keep-pixel-art-crisp">1. Import settings that keep pixel art crisp</h2>
<p>Drop the PNG into your project, select it, and set these in the Inspector before anything else:</p>
<table>
<thead>
<tr>
<th>Setting</th>
<th>Value</th>
<th>Why</th>
</tr>
</thead>
<tbody><tr>
<td>Texture Type</td>
<td>Sprite (2D and UI)</td>
<td>Makes the texture usable as sprites</td>
</tr>
<tr>
<td>Sprite Mode</td>
<td>Multiple</td>
<td>The texture holds many frames</td>
</tr>
<tr>
<td>Pixels Per Unit</td>
<td>Your frame or tile size, e.g. 32</td>
<td>One 32-pixel frame becomes one world unit</td>
</tr>
<tr>
<td>Filter Mode</td>
<td>Point (no filter)</td>
<td>No blending between pixels</td>
</tr>
<tr>
<td>Compression</td>
<td>None</td>
<td>Compressed formats smear small pixel art</td>
</tr>
<tr>
<td>Max Size</td>
<td>At least the sheet&#39;s longest side</td>
<td>Larger textures are scaled down at import</td>
</tr>
</tbody></table>
<p>Press <strong>Apply</strong>. Use the same Pixels Per Unit for every sprite in the game, so characters and tiles stay to scale, and leave <strong>Generate Mipmap</strong> (under Advanced) off for 2D art. If lines appear between your tiles later, <a href="https://spritepilot.com/blog/lines-between-tiles">Why Lines Appear Between Your Tiles</a> covers the remaining fixes.</p>
<h2 id="2-slice-the-sheet-in-the-sprite-editor">2. Slice the sheet in the Sprite Editor</h2>
<p>Slicing needs the frame size. If the sheet came without notes, load it into SpritePilot&#39;s <a href="https://spritepilot.com/sprite-player">Sprite Player</a> first and enter the columns and rows: it reports the frame size, warns about leftover pixels, and plays the animation so you can check the speed before you set anything in Unity.</p>
<p><figure><img src="https://spritepilot.com/blog/unity-sprite-sheet-animation/sprite-player-slime-grid.png" alt="SpritePilot's Sprite Player with 6 columns and 3 rows, reporting 18 frames of 32 by 32 pixels, and the grid overlay numbering the cells on the slime sheet, three of them empty" width="1536" height="850" loading="lazy" decoding="async"><figcaption>The slime sheet: 18 cells of 32 × 32, three of them empty. Those are the numbers Unity's slicer needs.</figcaption></figure></p>
<p>Then click <strong>Open Sprite Editor</strong> in the texture&#39;s Inspector (if it says <strong>Install 2D Sprite Package</strong> instead, click that first) and choose <strong>Slice</strong>:</p>
<ol>
<li><strong>Type:</strong> Grid By Cell Size.</li>
<li><strong>Pixel Size:</strong> the frame size, 32 × 32 here.</li>
<li><strong>Offset:</strong> the border around the sheet, if there is one. <strong>Padding:</strong> the gap between frames. Both are 0 for this sheet.</li>
<li><strong>Pivot:</strong> Bottom, so the GameObject&#39;s position sits at the bottom center of every frame. Characters then stand on their position, and frames of different poses line up.</li>
<li><strong>Keep Empty Rects:</strong> off, so the three empty cells don&#39;t become sprites.</li>
<li>Click <strong>Slice</strong>, then <strong>Apply</strong> in the toolbar.</li>
</ol>
<p><strong>Grid By Cell Count</strong> takes columns and rows instead, which is fine for a sheet without gaps. For one with a margin and spacing, Grid By Cell Size with Offset and Padding is the reliable choice; <a href="https://spritepilot.com/blog/sprite-sheet-frame-size">How to Find the Frame Size of a Sprite Sheet</a> shows how to work those numbers out. The sliced sprites appear as children of the texture in the Project window.</p>
<h2 id="3-make-one-clip-per-animation">3. Make one clip per animation</h2>
<p>Unity has a shortcut for the first clip. Expand the texture in the Project window, select the four idle sprites and drag them into the Scene view. Unity asks where to save the new clip, then creates a GameObject with a <strong>Sprite Renderer</strong> and an <strong>Animator</strong>, a clip containing those frames at 12 samples per second, and an <strong>Animator Controller</strong> that plays the clip. It orders the frames by name in natural order, so a sprite ending in <code>_2</code> comes before one ending in <code>_10</code>.</p>
<p>For the other animations, keep that GameObject selected and open <strong>Window → Animation → Animation</strong> (Ctrl+6, or Cmd+6 on a Mac). Pick <strong>Create New Clip</strong> from the clip menu under the playback controls, save it as <code>run</code>, and drag the six hop sprites onto the timeline. Do the same for <code>attack</code>. Each new clip is added to the same controller as a new state.</p>
<p>Now set the speed. Each sprite key sits one sample apart, so the clip&#39;s sample rate is its frame rate. Open the Animation window&#39;s <strong>⋮</strong> menu, turn on <strong>Show Sample Rate</strong>, and set each clip&#39;s sample rate (the <strong>Samples</strong> field): 6 for idle, 10 for run and 12 for attack are good starting points. Clips created in the Animation window start at 60, which plays a 6-frame hop in a tenth of a second. To hold one drawing longer, drag the keys after it to the right; <a href="https://spritepilot.com/blog/pixel-art-frame-rate">How Fast Should Pixel Art Animate?</a> covers the timing choices.</p>
<p>Last, select <code>attack.anim</code> in the Project window and untick <strong>Loop Time</strong>. Unity creates every new clip with looping on, and an attack should play once.</p>
<h2 id="4-wire-the-states-in-the-animator">4. Wire the states in the Animator</h2>
<p><figure><img src="https://spritepilot.com/blog/unity-sprite-sheet-animation/animator-idle-run-attack.png" alt="An Animator Controller with Entry leading to Idle, two-way transitions between Idle and Run on Speed, transitions from Idle and Run to Attack on a trigger, and a dashed exit-time transition from Attack back to Idle" width="1536" height="828" loading="lazy" decoding="async"><figcaption>The whole controller: two parameters and five transitions.</figcaption></figure></p>
<p>Open the controller (double-click it) and add two parameters in the <strong>Parameters</strong> tab: <strong>Speed</strong> (Float) and <strong>Attack</strong> (Trigger). Then right-click a state, choose <strong>Make Transition</strong>, and click the target state:</p>
<ul>
<li><strong>Idle → Run:</strong> condition Speed Greater 0.1.</li>
<li><strong>Run → Idle:</strong> condition Speed Less 0.1.</li>
<li><strong>Idle → Attack</strong> and <strong>Run → Attack:</strong> condition Attack.</li>
<li><strong>Attack → Idle:</strong> no conditions.</li>
</ul>
<p>Now the two settings that matter most for sprites. On every transition except Attack → Idle, untick <strong>Has Exit Time</strong> and set <strong>Transition Duration</strong> to 0 under <strong>Settings</strong>. With Has Exit Time on, <a href="https://docs.unity3d.com/Manual/class-Transition.html" target="_blank" rel="noopener noreferrer">Unity waits</a> until the clip reaches its exit time before it even checks the conditions, so the character keeps running after you let go. And a sprite can&#39;t be half one drawing and half another, so a blend has nothing useful to do; a duration of 0 switches drawings at once.</p>
<p>Attack → Idle is the exception: leave Has Exit Time on with <strong>Exit Time</strong> 1, so the whole attack plays before the character returns to Idle.</p>
<p>A trigger stays set until a transition uses it. Press attack during an attack and a second one plays right after; call <code>ResetTrigger(&quot;Attack&quot;)</code> when the first attack starts if you don&#39;t want that buffering.</p>
<h2 id="5-drive-it-from-a-script">5. Drive it from a script</h2>
<p>Add this script to the GameObject. <code>RequireComponent</code> adds a <strong>Rigidbody2D</strong> if it&#39;s missing; set its <strong>Gravity Scale</strong> to 0 for a top-down game.</p>
<pre><code class="language-csharp">using UnityEngine;
using UnityEngine.InputSystem;

[RequireComponent(typeof(Rigidbody2D), typeof(Animator), typeof(SpriteRenderer))]
public class SlimeController : MonoBehaviour
{
    [SerializeField] float moveSpeed = 3f;
    [SerializeField] Collider2D hitbox; // a trigger collider on a child object

    static readonly int SpeedId = Animator.StringToHash(&quot;Speed&quot;);
    static readonly int AttackId = Animator.StringToHash(&quot;Attack&quot;);

    Rigidbody2D body;
    Animator animator;
    SpriteRenderer spriteRenderer;
    InputAction moveAction;
    InputAction attackAction;

    void Awake()
    {
        body = GetComponent&lt;Rigidbody2D&gt;();
        animator = GetComponent&lt;Animator&gt;();
        spriteRenderer = GetComponent&lt;SpriteRenderer&gt;();
        moveAction = InputSystem.actions.FindAction(&quot;Move&quot;);
        attackAction = InputSystem.actions.FindAction(&quot;Attack&quot;);
    }

    void Update()
    {
        float x = moveAction.ReadValue&lt;Vector2&gt;().x;
        body.linearVelocity = new Vector2(x * moveSpeed, body.linearVelocity.y);

        animator.SetFloat(SpeedId, Mathf.Abs(x));
        if (x != 0f)
            spriteRenderer.flipX = x &lt; 0f;

        if (attackAction.WasPressedThisFrame())
            animator.SetTrigger(AttackId);
    }

    // Called by Animation Events on the attack clip
    public void HitboxOn() =&gt; hitbox.enabled = true;
    public void HitboxOff() =&gt; hitbox.enabled = false;
}</code></pre>
<p>A few Unity 6 details are built in here:</p>
<ul>
<li><strong>Input:</strong> Unity 6 projects use the Input System package, and the script reads its default <strong>Move</strong> and <strong>Attack</strong> actions, as in the package&#39;s <a href="https://docs.unity3d.com/Packages/com.unity.inputsystem@1.14/manual/QuickStartGuide.html" target="_blank" rel="noopener noreferrer">quick start guide</a>. If your project has no project-wide actions yet, create them under Edit → Project Settings → Input System Package → Input Actions. Older scripts that read <code>Input.GetAxis</code> throw an error in a project set to use only the new system.</li>
<li><strong>Velocity:</strong> Unity 6 renamed <code>Rigidbody2D.velocity</code> to <code>linearVelocity</code>; the old name still compiles but is marked obsolete.</li>
<li><strong>Flipping:</strong> <code>flipX</code> mirrors the drawing only. <a href="https://docs.unity3d.com/ScriptReference/SpriteRenderer-flipX.html" target="_blank" rel="noopener noreferrer">Unity&#39;s reference</a> says to use a negative Transform scale when colliders should flip too, so a hitbox on a child object needs its own mirroring.</li>
<li><strong>Parameter IDs:</strong> <code>Animator.StringToHash</code> turns the names into IDs once, instead of looking up the strings every frame.</li>
</ul>
<p>The two hitbox methods are for <strong>Animation Events</strong>. Put a trigger collider on a child object, disable it, and drag it into the script&#39;s <strong>Hitbox</strong> field. Then open <code>attack.anim</code>, move the playhead to the lunge frame, click the <strong>Event</strong> button, and choose <code>HitboxOn</code> in the Inspector&#39;s <strong>Function</strong> field. Add <code>HitboxOff</code> on the recovery frame. The <a href="https://docs.unity3d.com/Manual/script-AnimationWindowEvent.html" target="_blank" rel="noopener noreferrer">Animation Event page</a> lists what such a method may take: no parameter, or one float, int, string, object reference or AnimationEvent. The same trick plays footstep sounds on exactly the frame a foot lands.</p>
<h2 id="checklist">Checklist</h2>
<ul>
<li>Import: Sprite (2D and UI), Multiple, Point (no filter), Compression None, consistent Pixels Per Unit.</li>
<li>Slice: Grid By Cell Size, with Offset for a border and Padding for gaps; Pivot Bottom for characters.</li>
<li>Clips: Samples equals frames per second; Loop Time off for one-shot animations.</li>
<li>Transitions: Has Exit Time off and Transition Duration 0, except the one leaving a one-shot animation.</li>
<li>Script: Input System actions, <code>linearVelocity</code>, <code>flipX</code> for the drawing only.</li>
</ul>
<p>Unity doesn&#39;t read atlas JSON files on its own, so this workflow uses a grid sheet. For trimmed or mixed-size frames, <a href="https://spritepilot.com/guides/sprite-sheet-for-unity">How to Export Sprites from SpritePilot for Unity</a> shows how to bring them in as separate sprites instead. The slime sheet from this article is free to use in any project (CC0): <a href="https://spritepilot.com/blog/unity-sprite-sheet-animation/slime-sheet.png">download slime-sheet.png</a>, 192 × 96, six columns and three rows of 32 × 32 frames. The same sheet is set up in <a href="https://spritepilot.com/blog/godot-4-sprite-animation">Godot 4</a>, <a href="https://spritepilot.com/blog/phaser-sprite-sheets-and-atlases">Phaser</a> and <a href="https://spritepilot.com/blog/pygame-sprite-sheet-animation">pygame</a> too. More free sprites to practice with, all one-row strips that slice with Grid By Cell Size: <a href="https://spritepilot.com/free-game-assets">Free Pixel Art Game Assets</a>.</p>
]]></content:encoded>
    </item>
    <item>
      <title>Godot 4 Sprite Animation: AnimatedSprite2D or AnimationPlayer?</title>
      <link>https://spritepilot.com/blog/godot-4-sprite-animation</link>
      <guid isPermaLink="true">https://spritepilot.com/blog/godot-4-sprite-animation</guid>
      <pubDate>Thu, 08 Oct 2026 12:00:00 GMT</pubDate>
      <description>When to animate a sprite sheet with AnimatedSprite2D or AnimationPlayer in Godot 4, how to set up each, and the GDScript to play, switch and flip.</description>
      <category>godot</category>
      <category>animation</category>
      <category>sprite sheets</category>
      <content:encoded><![CDATA[<p>You have a sprite sheet, and Godot 4 offers two ways to animate it: <strong>AnimatedSprite2D</strong> and <strong>AnimationPlayer</strong>. Both work, but they solve different problems, and picking the right one up front saves you rebuilding animations later.</p>
<p>This article explains the difference, sets both up from the same sheet, and gives you the GDScript that drives them. It applies to all of Godot 4; the one feature that is new in Godot 4.7 is marked.</p>
<h2 id="which-one-to-use">Which one to use</h2>
<p><figure><img src="https://spritepilot.com/blog/godot-4-sprite-animation/animatedsprite2d-vs-animationplayer.png" alt="Left: AnimatedSprite2D with idle, hop and attack frame lists, each with its own FPS. Right: an AnimationPlayer timeline with keys for the sprite's frame, two method calls that switch a hitbox on and off, and a sound" width="1536" height="820" loading="lazy" decoding="async"><figcaption>The slime from this article both ways. AnimatedSprite2D plays lists of frames; AnimationPlayer puts the frame on a timeline next to everything that must happen with it.</figcaption></figure></p>
<p><strong>AnimatedSprite2D</strong> plays a <strong>SpriteFrames</strong> resource: named animations, each a list of frames with its own speed. <strong>AnimationPlayer</strong> is a general timeline. Point it at a <strong>Sprite2D</strong> and it can key the sprite&#39;s frame number, plus any other property, method call or sound at an exact time.</p>
<table>
<thead>
<tr>
<th></th>
<th>AnimatedSprite2D</th>
<th>Sprite2D + AnimationPlayer</th>
</tr>
</thead>
<tbody><tr>
<td>You edit</td>
<td>Frame lists and an FPS per animation</td>
<td>Keys on a timeline, in seconds</td>
</tr>
<tr>
<td>Holding a frame longer</td>
<td>A relative duration per frame</td>
<td>Move the next key later</td>
</tr>
<tr>
<td>Sheets with a border or gaps</td>
<td>Supported when you import frames</td>
<td>Not supported: Hframes × Vframes must be an even grid</td>
</tr>
<tr>
<td>Changing other things on a frame</td>
<td>In code, through signals</td>
<td>On the same timeline, no code</td>
</tr>
<tr>
<td>Looping</td>
<td>On or off (ping-pong added in Godot 4.7)</td>
<td>None, loop or ping-pong</td>
</tr>
</tbody></table>
<p>A good rule: start with AnimatedSprite2D. Move an animation to AnimationPlayer when something besides the picture has to change on a particular frame: a hitbox during a swing, a footstep sound, a muzzle flash. The two also combine, since an AnimationPlayer can key an AnimatedSprite2D&#39;s properties too.</p>
<h2 id="set-up-animatedsprite2d-from-a-sheet">Set up AnimatedSprite2D from a sheet</h2>
<p>The example is a 192 × 96 slime sheet (free to download at the end): three rows of 32 × 32 frames for idle (4 frames), hop (6) and attack (5).</p>
<ol>
<li>Add an <strong>AnimatedSprite2D</strong> node. In the Inspector, click its empty <strong>Sprite Frames</strong> property, choose <strong>New SpriteFrames</strong>, then click the new resource. The <strong>SpriteFrames</strong> panel opens at the bottom of the editor.</li>
<li>Double-click the <code>default</code> animation and rename it <code>idle</code>.</li>
<li>Click <strong>Add Frames from Sprite Sheet</strong> (the grid icon above the frame list; Ctrl+Shift+O, or Cmd+Shift+O on a Mac) and pick the PNG.</li>
<li>In the <strong>Select Frames</strong> dialog, set <strong>Horizontal</strong> to 6 and <strong>Vertical</strong> to 3. Click the four idle frames, then <strong>Add 4 Frame(s)</strong>.</li>
<li>Set the speed in the <strong>FPS</strong> box beside the animation list; new animations start at 5 FPS. The <strong>Animation Looping</strong> button next to it turns looping on or off. In Godot 4.7 it has a third state, ping-pong.</li>
<li>Click <strong>Add Animation</strong> and repeat for <code>hop</code> and <code>attack</code>.</li>
</ol>
<p>Two settings in the panel do most of the timing work. <strong>FPS</strong> sets the base speed, and <strong>Frame Duration</strong> stretches individual frames. Frame Duration is relative, and the <a href="https://docs.godotengine.org/en/stable/classes/class_spriteframes.html#class-spriteframes-method-get-frame-duration" target="_blank" rel="noopener noreferrer">SpriteFrames documentation</a> gives the formula: seconds on screen = duration ÷ (FPS × playing speed). At 10 FPS, a frame with duration 1.0 shows for 0.1 s and one with 3.0 holds for 0.3 s, which is how you give an attack a pause before the strike. For choosing the numbers, see <a href="https://spritepilot.com/blog/pixel-art-frame-rate">How Fast Should Pixel Art Animate?</a></p>
<h3 id="sheets-with-a-border-or-gaps">Sheets with a border or gaps</h3>
<p>If the sheet has a border around it (a margin) or gaps between frames (spacing), the dialog&#39;s other fields matter: <strong>Size</strong> is the frame size, <strong>Separation</strong> is the gap and <strong>Offset</strong> is the border. The order you fill them in decides the result.</p>
<p><figure><img src="https://spritepilot.com/blog/godot-4-sprite-animation/add-frames-dialog-size-first.png" alt="A coin sheet with a 4-pixel border and 2-pixel gaps, labeled with Godot's Offset, Separation and Size fields. Typing Size first gives 4 by 2 frames of 32 by 32; typing Horizontal and Vertical gives 33 by 34 frames" width="1536" height="1322" loading="lazy" decoding="async"><figcaption>When the sheet has a border, type the Size. If you type the counts instead, the right and bottom border end up inside the frames.</figcaption></figure></p>
<p>When you type <strong>Horizontal</strong> and <strong>Vertical</strong>, Godot works out the size by dividing what&#39;s left of the image after the Offset and the gaps. The Offset only comes off the left and top, so the border on the right and bottom is counted as frame. On this 142 × 74 coin sheet that gives 33 × 34 frames, and each cut lands a little further off target. Type <strong>Size</strong> first instead (32 × 32), then Separation (2) and Offset (4), and Godot works out the counts itself. If you don&#39;t know the numbers, <a href="https://spritepilot.com/blog/sprite-sheet-frame-size">How to Find the Frame Size of a Sprite Sheet</a> shows how to work them out.</p>
<h2 id="play-switch-and-flip-from-gdscript">Play, switch and flip from GDScript</h2>
<p>This character idles, hops while moving and attacks once on a key press:</p>
<pre><code class="language-gdscript">extends CharacterBody2D

const SPEED := 120.0

@onready var sprite: AnimatedSprite2D = $AnimatedSprite2D

var attacking := false


func _ready() -&gt; void:
	sprite.animation_finished.connect(_on_animation_finished)


func _physics_process(_delta: float) -&gt; void:
	var direction := Input.get_axis(&quot;ui_left&quot;, &quot;ui_right&quot;)
	velocity.x = direction * SPEED
	move_and_slide()

	if direction != 0.0:
		sprite.flip_h = direction &lt; 0.0

	if attacking:
		return
	if Input.is_action_just_pressed(&quot;ui_accept&quot;):
		attacking = true
		sprite.play(&quot;attack&quot;)
	elif direction != 0.0:
		sprite.play(&quot;hop&quot;)
	else:
		sprite.play(&quot;idle&quot;)


func _on_animation_finished() -&gt; void:
	if sprite.animation == &amp;&quot;attack&quot;:
		attacking = false</code></pre>
<p>Four details make this work:</p>
<ul>
<li><strong>Calling <code>play()</code> every frame is safe.</strong> If the animation is already playing, <code>play()</code> with the same name carries on instead of restarting, so you don&#39;t need to check first.</li>
<li><strong><code>animation_finished</code> never fires for a looping animation</strong>, as the <a href="https://docs.godotengine.org/en/stable/classes/class_animatedsprite2d.html#class-animatedsprite2d-signal-animation-finished" target="_blank" rel="noopener noreferrer">AnimatedSprite2D reference</a> notes. Turn looping off for <code>attack</code>, or the character stays stuck in it.</li>
<li><strong><code>flip_h</code> mirrors the drawing only.</strong> Child nodes such as a hitbox keep their position, so mirror their <code>position.x</code> in the same place you set <code>flip_h</code>.</li>
<li><strong>Per-frame events need a signal.</strong> For a footstep, connect <code>frame_changed</code> and check <code>sprite.animation</code> and <code>sprite.frame</code> there. If you find yourself writing many of these checks, that animation belongs in an AnimationPlayer.</li>
</ul>
<h2 id="the-animationplayer-route">The AnimationPlayer route</h2>
<p>Here the picture comes from a plain <strong>Sprite2D</strong>, and the AnimationPlayer changes its frame number on a timeline.</p>
<ol>
<li>Add a <strong>Sprite2D</strong> and drag the sheet onto its <strong>Texture</strong>. Under <strong>Animation</strong> in the Inspector, set <strong>Hframes</strong> to 6 and <strong>Vframes</strong> to 3. Frames are numbered left to right, top to bottom from 0, so the attack row is frames 12 to 16. Sprite2D cuts the texture into equal cells, so it can&#39;t skip a border or gaps; give it a sheet without them.</li>
<li>Add an <strong>AnimationPlayer</strong>, open the Animation panel and choose <strong>Animation → New</strong>. Name it <code>attack</code> and set its length to 0.5 s: five frames at 10 FPS.</li>
<li>At the bottom of the panel, switch the snap step from <strong>Seconds</strong> to <strong>FPS</strong> and enter 10, so keys land every 0.1 s.</li>
<li>Select the Sprite2D and set <strong>Frame</strong> to 12. Click the key icon next to <strong>Frame</strong> and confirm the new track. Godot adds the key, raises Frame to 13 and moves the playhead one step. Click it four more times to key 13 to 16. The track is created in <strong>Discrete</strong> mode, so it jumps between whole frames instead of blending.</li>
<li>To sync gameplay, add a <strong>Call Method Track</strong> for the character node (Add Track → Call Method Track…). Right-click it at 0.1 s, choose <strong>Insert Key…</strong>, and pick <code>enable_hitbox</code>; insert <code>disable_hitbox</code> at 0.3 s. An <strong>Audio Playback Track</strong> can start the swipe sound at 0.1 s too.</li>
</ol>
<p>The methods the track calls live in the character&#39;s script. Here the hitbox is an Area2D child named <code>Hitbox</code>:</p>
<pre><code class="language-gdscript">func enable_hitbox() -&gt; void:
	$Hitbox/CollisionShape2D.set_deferred(&quot;disabled&quot;, false)


func disable_hitbox() -&gt; void:
	$Hitbox/CollisionShape2D.set_deferred(&quot;disabled&quot;, true)</code></pre>
<p><code>set_deferred()</code> is there because <a href="https://docs.godotengine.org/en/stable/classes/class_collisionshape2d.html#class-collisionshape2d-property-disabled" target="_blank" rel="noopener noreferrer">Godot&#39;s documentation</a> says a collision shape&#39;s <code>disabled</code> property should be changed that way. Start the animation with <code>$AnimationPlayer.play(&quot;attack&quot;)</code> and listen for its <code>animation_finished(anim_name)</code> signal; like the AnimatedSprite2D signal, it never fires while the animation loops. One more trap from the <a href="https://docs.godotengine.org/en/stable/tutorials/2d/2d_sprite_animation.html" target="_blank" rel="noopener noreferrer">2D sprite animation tutorial</a>: <code>play()</code> takes effect the next time the player processes. If you flip the sprite in the same frame and see one glitched frame, call <code>advance(0)</code> after <code>play()</code>.</p>
<h2 id="exporting-a-ready-made-spriteframes-from-spritepilot">Exporting a ready-made SpriteFrames from SpritePilot</h2>
<p>If you cut your sheet with SpritePilot&#39;s <a href="https://spritepilot.com/sprite-detection">Grid Slice</a>, or bring frames in from a GIF or the Pixel Art Editor, <a href="https://spritepilot.com/export-download">Export &amp; Download</a> can write the SpriteFrames resource for you. Choose <strong>SpriteFrames</strong> (Godot 4 .tres resource) and add an <strong>Animation Tag</strong> for each animation.</p>
<p><figure><img src="https://spritepilot.com/blog/godot-4-sprite-animation/spritepilot-spriteframes-tags.png" alt="SpritePilot's Export settings with the SpriteFrames format selected and three animation tags: idle frames 0 to 3, hop 4 to 9, attack 10 to 14" width="1536" height="642" loading="lazy" decoding="async"><figcaption>Each tag becomes a named animation in the .tres file.</figcaption></figure></p>
<p>The download is the packed sheet, <code>sprite-sheet.png</code>, and <code>sprite-frames.tres</code>, a text resource that starts like this:</p>
<pre><code class="language-text">[gd_resource type=&quot;SpriteFrames&quot; load_steps=17 format=3]

[ext_resource type=&quot;Texture2D&quot; path=&quot;res://sprite-sheet.png&quot; id=&quot;1&quot;]

[sub_resource type=&quot;AtlasTexture&quot; id=&quot;AtlasTexture_1&quot;]
atlas = ExtResource(&quot;1&quot;)
region = Rect2(0, 0, 32, 32)</code></pre>
<p>What&#39;s inside:</p>
<ul>
<li><strong>One AtlasTexture per frame</strong>, pointing at that frame&#39;s rectangle in the sheet. With <strong>Trim transparency</strong> on, each also gets a margin that restores its original size, so trimmed frames don&#39;t shift.</li>
<li><strong>One animation per tag</strong>, using the frames from–to counted from 0 in the export order. With no tags you get a single animation called <code>default</code> containing every frame.</li>
<li><strong>12 FPS, looping, duration 1.0</strong> for every animation. Adjust them in the SpriteFrames panel afterwards, and switch looping off for <code>attack</code>. A tag&#39;s reverse or ping-pong direction is not carried into the file.</li>
</ul>
<p>The resource expects the sheet at <code>res://sprite-sheet.png</code>, so put both files in the project root, or edit the path on the <code>ext_resource</code> line. Then load <code>sprite-frames.tres</code> into the AnimatedSprite2D&#39;s <strong>Sprite Frames</strong> property. The <a href="https://spritepilot.com/guides/sprite-sheet-for-godot">Godot import guide</a> covers the JSON alternative.</p>
<h2 id="a-checklist">A checklist</h2>
<ul>
<li>Only the picture changes: AnimatedSprite2D.</li>
<li>Something must happen on a specific frame: AnimationPlayer, or <code>frame_changed</code> in code for one or two cases.</li>
<li>Sheet with a border: type the <strong>Size</strong> in the Select Frames dialog, not the counts.</li>
<li>Anything you wait for, such as an attack or a death, must not loop.</li>
<li><code>flip_h</code> mirrors the drawing, not its children.</li>
<li>For crisp pixel art, set the project&#39;s default texture filter to Nearest (<a href="https://spritepilot.com/blog/lines-between-tiles">why lines appear between tiles</a> explains it).</li>
</ul>
<p>The slime sheet is free to use in any project (CC0): <a href="https://spritepilot.com/blog/godot-4-sprite-animation/slime-sheet.png">download slime-sheet.png</a>, 192 × 96, with idle in the top row (4 frames), hop in the middle row (6) and attack in the bottom row (5), all 32 × 32. Check its timing in the <a href="https://spritepilot.com/sprite-player">Sprite Player</a> before you build the animations. The same sheet is set up in <a href="https://spritepilot.com/blog/unity-sprite-sheet-animation">Unity</a>, <a href="https://spritepilot.com/blog/phaser-sprite-sheets-and-atlases">Phaser</a> and <a href="https://spritepilot.com/blog/pygame-sprite-sheet-animation">pygame</a> too. For more to practice with, the <a href="https://spritepilot.com/free-game-assets">Free Pixel Art Game Assets</a> page has 70 animated CC0 sprites, from <a href="https://spritepilot.com/free-game-assets/creatures">creatures</a> to <a href="https://spritepilot.com/free-game-assets/effects">effects</a>, as one-row strips that load the same way: Horizontal = the frame count, Vertical = 1.</p>
]]></content:encoded>
    </item>
    <item>
      <title>How Fast Should Pixel Art Animate? Choosing a Frame Rate</title>
      <link>https://spritepilot.com/blog/pixel-art-frame-rate</link>
      <guid isPermaLink="true">https://spritepilot.com/blog/pixel-art-frame-rate</guid>
      <pubDate>Wed, 07 Oct 2026 12:00:00 GMT</pubDate>
      <description>Pixel art usually animates far slower than the game runs. How to pick a frame rate, when to hold frames instead, and why some rates look uneven.</description>
      <category>pixel art</category>
      <category>animation</category>
      <content:encoded><![CDATA[<p>Your game probably runs at 60 frames per second, but a pixel-art walk cycle usually changes its drawing only 8 to 12 times a second. That isn&#39;t laziness: in pixel art every drawing is placed by hand, and a few strong poses held for the right time read better than many in-betweens.</p>
<p>So &quot;how fast should it animate?&quot; is really two questions: how many drawings the animation has, and how long each one stays on screen. This article covers both, plus two traps that make a well-timed animation look wrong once it&#39;s in a game or a GIF.</p>
<h2 id="frame-count-frame-rate-and-duration">Frame count, frame rate and duration</h2>
<p>Three numbers describe an animation, and any two give you the third:</p>
<ul>
<li><strong>frame count</strong> — how many drawings there are,</li>
<li><strong>frame rate</strong> — how many drawings are shown per second (fps),</li>
<li><strong>duration</strong> — how long one cycle takes: <strong>duration = frame count ÷ frame rate</strong>.</li>
</ul>
<table>
<thead>
<tr>
<th>Animation</th>
<th>Frames</th>
<th>Frame rate</th>
<th>One cycle</th>
</tr>
</thead>
<tbody><tr>
<td>Coin spin</td>
<td>8</td>
<td>10 fps</td>
<td>0.8 s</td>
</tr>
<tr>
<td>Walk cycle</td>
<td>8</td>
<td>12 fps</td>
<td>0.67 s</td>
</tr>
<tr>
<td>Idle (breathing)</td>
<td>4</td>
<td>5 fps</td>
<td>0.8 s</td>
</tr>
<tr>
<td>Run cycle</td>
<td>6</td>
<td>12 fps</td>
<td>0.5 s</td>
</tr>
</tbody></table>
<p>This is why &quot;more frames&quot; and &quot;faster&quot; are different fixes. If a walk looks choppy, add drawings and keep the duration. If it looks sluggish, raise the frame rate or cut frames — the cycle gets shorter either way.</p>
<h2 id="what-the-frame-rate-does">What the frame rate does</h2>
<p>Here is the same 8-frame coin at three frame rates:</p>
<p><figure><img src="https://spritepilot.com/blog/pixel-art-frame-rate/coin-at-5-10-20-fps.gif" alt="The same spinning coin animated at 5, 10 and 20 frames per second, side by side" width="484" height="186" loading="lazy" decoding="async"><figcaption>Same eight drawings. At 5 fps one spin takes 1.6 s and every drawing is visible; at 20 fps it takes 0.4 s and blurs into a quick flash.</figcaption></figure></p>
<p>At 5 fps you can see every drawing — fine for something slow, choppy for something that should move. At 20 fps the spin turns into a quick flash, and the individual drawings stop mattering. In between, at 10 fps, the coin spins at a pace the eye can follow while the pixel work still shows. That middle zone is where most pixel art lives.</p>
<p>These aren&#39;t rules, but they are common starting points in 2D games:</p>
<ul>
<li><strong>Idle and breathing:</strong> 4–8 fps, often with long holds.</li>
<li><strong>Walk cycles:</strong> 8–12 fps.</li>
<li><strong>Runs:</strong> 10–15 fps — more speed comes from fewer, longer strides, not just a higher rate.</li>
<li><strong>Pickups and UI loops</strong> (coins, gems, blinking prompts): 8–15 fps.</li>
<li><strong>Effects</strong> (sparks, explosions, magic): 12–24 fps, often with frames of different lengths.</li>
</ul>
<p>Set a starting rate, play it next to your character&#39;s movement speed, and adjust by eye. Feet that slide across the ground mean the animation and the movement don&#39;t match — change one of them.</p>
<h2 id="hold-frames-instead-of-using-one-rate">Hold frames instead of using one rate</h2>
<p>Real motion isn&#39;t evenly timed. An attack has a slow wind-up, a strike that&#39;s over almost instantly, and a recovery. Showing every frame for the same time makes it look mechanical. The fix is to time each drawing:</p>
<table>
<thead>
<tr>
<th>Attack frame</th>
<th>Time on screen</th>
</tr>
</thead>
<tbody><tr>
<td>1–2: wind-up</td>
<td>100 ms each</td>
</tr>
<tr>
<td>3: hold before the strike</td>
<td>200 ms</td>
</tr>
<tr>
<td>4–5: strike</td>
<td>40 ms each</td>
</tr>
<tr>
<td>6–8: recovery</td>
<td>80 ms each</td>
</tr>
</tbody></table>
<p>Every engine can do this:</p>
<ul>
<li><strong>Godot 4:</strong> in the SpriteFrames panel, each frame has a <strong>Duration</strong>, and it is relative: 2.0 shows a frame twice as long as 1.0 at the animation&#39;s FPS. <a href="https://docs.godotengine.org/en/stable/classes/class_spriteframes.html" target="_blank" rel="noopener noreferrer">The documentation</a> gives the exact rule: absolute duration = relative duration ÷ (animation FPS × playback speed).</li>
<li><strong>Phaser:</strong> give a frame a <code>duration</code> in milliseconds; in current versions, that is how long the frame stays on screen, and frames without one follow the animation&#39;s <code>frameRate</code>. (If you override the frame rate when calling <code>play()</code>, Phaser ignores the per-frame durations.)</li>
</ul>
<pre><code class="language-js">this.anims.create({
  key: 'attack',
  frameRate: 12,
  frames: [
    { key: 'hero', frame: 0, duration: 100 },
    { key: 'hero', frame: 1, duration: 100 },
    { key: 'hero', frame: 2, duration: 200 }, // the hold
    { key: 'hero', frame: 3, duration: 40 },
    { key: 'hero', frame: 4, duration: 40 },
    { key: 'hero', frame: 5, duration: 80 },
    { key: 'hero', frame: 6, duration: 80 },
    { key: 'hero', frame: 7, duration: 80 },
  ],
})</code></pre>
<ul>
<li><strong>Unity:</strong> in the Animation window, a sprite stays on screen until the next keyframe, so you hold a drawing by moving the following keyframe later on the timeline. The clip&#39;s sample rate sets the grid the keyframes snap to.</li>
<li><strong>Aseprite:</strong> every frame has its own duration in milliseconds (Frame Properties), and its JSON export includes them.</li>
</ul>
<h2 id="the-60-hz-catch-some-rates-look-uneven">The 60 Hz catch: some rates look uneven</h2>
<p>A monitor shows a new image at a fixed rate — usually 60 times a second. Your animation can only change drawings on one of those refreshes, so a drawing is always on screen for a whole number of refreshes. When the frame rate divides 60 evenly, every drawing gets the same share. When it doesn&#39;t, some drawings get one refresh more than others:</p>
<p><figure><img src="https://spritepilot.com/blog/pixel-art-frame-rate/frame-rates-on-a-60hz-screen.png" alt="A chart of 30 screen refreshes: at 12 fps each drawing lasts 5 refreshes and at 10 fps 6, but at 8 fps drawings alternate between 8 and 7 refreshes and at 24 fps between 3 and 2" width="1536" height="412" loading="lazy" decoding="async"><figcaption>12 and 10 fps divide 60 evenly. 8 fps alternates 8 and 7 refreshes; 24 fps alternates 3 and 2.</figcaption></figure></p>
<p>The difference is small — a sixtieth of a second — but in a slow, smooth motion like a floating platform or a spinning coin it shows up as a faint stutter. On a 60 Hz screen, the even rates are 5, 6, 10, 12, 15, 20 and 30 fps.</p>
<p>Many players now have 120 Hz or 144 Hz screens, where the even rates are different (8 fps is even at 120 and 144 Hz, but not at 60). If you want a rate that is even on all three, pick <strong>12, 6, 4, 3, 2 or 1 fps</strong>. That is one more reason 12 fps is such a popular choice.</p>
<h2 id="gifs-round-your-timing">GIFs round your timing</h2>
<p>GIF stores each frame&#39;s time in hundredths of a second. Two consequences:</p>
<ul>
<li><strong>Only some frame rates are exact.</strong> A GIF plays exactly at 100 ÷ n fps: 50, 33.3, 25, 20, 16.7, 14.3, 12.5, 11.1, 10 … — 12 and 24 fps aren&#39;t on the list. A 12 fps animation (0.083 s per frame) is stored as 0.08 s and plays at 12.5 fps.</li>
<li><strong>Very short delays get slower, not faster.</strong> Browsers treat a delay of 0 or 0.01 s as 0.1 s, so a &quot;60 fps&quot; GIF crawls along at 10 fps. The fastest reliable GIF is 0.02 s per frame — 50 fps.</li>
</ul>
<p>SpritePilot&#39;s GIF export rounds to the nearest hundredth of a second and never goes below 0.02 s, so the GIF you download plays at the speed you expect. If exact timing matters, export an <strong>APNG</strong> instead: it stores each frame&#39;s time as a fraction (1/12 of a second), keeps full transparency, and plays in every modern browser.</p>
<h2 id="try-it">Try it</h2>
<p><figure><img src="https://spritepilot.com/blog/pixel-art-frame-rate/sprite-player-speed-controls.png" alt="SpritePilot's Sprite Player playing an 8-frame spinning coin at 12 FPS, with Loop, Ping-pong, background and GIF and APNG export controls" width="1720" height="1220" loading="lazy" decoding="async"><figcaption>The Sprite Player: a speed slider from 1 to 60 fps, loop and ping-pong, three backgrounds, and GIF or APNG export.</figcaption></figure></p>
<p>Load a sheet into SpritePilot&#39;s <a href="https://spritepilot.com/sprite-player">Sprite Player</a>, set its columns and rows, and drag the speed between 8, 10, 12 and 15 fps while it plays — the right rate is usually obvious within a few seconds. <strong>Ping-pong</strong> plays the frames forwards and back, which halves the drawings a breathing or hovering loop needs. The player runs in your browser and nothing is uploaded.</p>
<p>No sheet handy? The coin from this article is free to use (CC0): <a href="https://spritepilot.com/blog/sprite-sheet-frame-size/coin-spin-strip.png">download the coin sheet</a>, 8 frames of 32 × 32 pixels in a row. If you&#39;re not sure how a downloaded sheet is laid out, <a href="https://spritepilot.com/blog/sprite-sheet-frame-size">How to Find the Frame Size of a Sprite Sheet</a> walks through it. The <a href="https://spritepilot.com/free-game-assets">Free Pixel Art Game Assets</a> page has 70 more, each with the speed it was timed for.</p>
<h2 id="in-short">In short</h2>
<ul>
<li>Duration = frame count ÷ frame rate. Choppy: add drawings. Sluggish: raise the rate or cut frames.</li>
<li>Most pixel art sits between 8 and 12 fps; tune by eye against the movement.</li>
<li>Time drawings individually for anything with weight — wind-ups, impacts, landings.</li>
<li>On 60 Hz screens, rates that divide 60 play evenly; 12 fps is even at 60, 120 and 144 Hz.</li>
<li>GIFs round to hundredths of a second and can&#39;t go faster than 50 fps; APNG keeps exact timing.</li>
</ul>
]]></content:encoded>
    </item>
    <item>
      <title>How to Find the Frame Size of a Sprite Sheet</title>
      <link>https://spritepilot.com/blog/sprite-sheet-frame-size</link>
      <guid isPermaLink="true">https://spritepilot.com/blog/sprite-sheet-frame-size</guid>
      <pubDate>Tue, 06 Oct 2026 12:00:00 GMT</pubDate>
      <description>A sprite sheet with no notes? Work out its frame size, margin and spacing in a minute, check them, and enter them in Unity, Godot or Phaser.</description>
      <category>sprite sheets</category>
      <category>basics</category>
      <content:encoded><![CDATA[<p>You found a great sprite sheet — on itch.io, OpenGameArt, or from an artist friend — but it arrived as one PNG with no notes. Your engine wants the frame width and height, or the number of columns and rows. Here is how to work them out in a minute, and how to check you got them right before you build an animation on top.</p>
<h2 id="step-1-count-the-frames">Step 1: Count the frames</h2>
<p>Most sheets are a grid: one animation per row, frames running left to right. Count the frames across and down. An empty slot at the end of the last row is normal — the animation simply had fewer frames than the row has room for.</p>
<p>You also need the size of the whole image. Your file manager shows it: on a Mac, select the file and choose File → Get Info (look under More Info); on Windows, right-click → Properties → Details.</p>
<h2 id="step-2-divide">Step 2: Divide</h2>
<p>If the frames touch each other, the arithmetic is one line:</p>
<ul>
<li>frame width = image width ÷ columns</li>
<li>frame height = image height ÷ rows</li>
</ul>
<p>Example: <a href="https://spritepilot.com/blog/sprite-sheet-frame-size/coin-spin-strip.png">coin-spin-strip.png</a> is 256 × 32 pixels with 8 frames in one row, so each frame is 256 ÷ 8 = <strong>32 pixels wide</strong> and 32 ÷ 1 = <strong>32 pixels high</strong>.</p>
<p>When both divisions come out as whole numbers, you are done. A result that is a familiar size — 16, 24, 32, 48, 64, 96 or 128 — is a good sign. A result like 35.5 means something else is in the image.</p>
<p>If you can&#39;t tell how many columns there are, work backwards: try the familiar sizes and keep the one that divides both the width and the height exactly and matches the art you see.</p>
<h2 id="when-it-doesnt-divide-margin-and-spacing">When it doesn&#39;t divide: margin and spacing</h2>
<p>Many sheets leave a border around the whole image (the <strong>margin</strong>) and a gap between neighboring frames (the <strong>spacing</strong>). Sprite packers add them on purpose, to keep frames from bleeding into each other when the game scales them. They are invisible on a transparent background, which is why the simple division goes wrong.</p>
<p><figure><img src="https://spritepilot.com/blog/sprite-sheet-frame-size/coin-sheet-margin-spacing.png" alt="A 142 × 74 sprite sheet of a spinning coin: a 4 px margin highlighted in red around the edge, 2 px gaps between frames in cyan, and one 32 × 32 frame outlined" width="1536" height="918" loading="lazy" decoding="async"><figcaption>The same coin, packed 4 × 2 with a 4 px margin and 2 px spacing. On a transparent background none of it shows.</figcaption></figure></p>
<p>This sheet is 142 × 74 pixels, 4 columns by 2 rows. 142 ÷ 4 = 35.5, so it can&#39;t be gap-free. With a margin and spacing, the full formula is:</p>
<ul>
<li>frame width = (image width − 2 × margin − (columns − 1) × spacing) ÷ columns</li>
<li>frame height = (image height − 2 × margin − (rows − 1) × spacing) ÷ rows</li>
</ul>
<p>With a margin of 4 and spacing of 2: (142 − 8 − 6) ÷ 4 = <strong>32</strong>, and (74 − 8 − 2) ÷ 2 = <strong>32</strong>.</p>
<p>How do you find the margin and spacing if nobody tells you? Counting transparent pixels in an image editor helps, but it can mislead: the art inside a frame rarely touches the frame&#39;s edges, so the empty pixels you count are part margin and part frame. Two better clues:</p>
<ol>
<li><strong>Use both directions.</strong> Assume the familiar frame size (32 here). The width says 2 × margin + 3 × spacing = 14 pixels; the height says 2 × margin + 1 × spacing = 10. Only margin 4 with spacing 2 fits both. A guess that works for the width but not the height is wrong.</li>
<li><strong>Try small values.</strong> Packers almost always use 0 to 4 pixels for each. A handful of tries usually finds the pair that makes both formulas come out whole.</li>
</ol>
<h2 id="check-it-by-playing-the-animation">Check it by playing the animation</h2>
<p>The real test is watching the animation. If the numbers are even slightly off, every frame gets cut at a slightly different spot, and the sprite drifts or jitters while it plays — even though each frame looks almost right on its own.</p>
<p><figure><img src="https://spritepilot.com/blog/sprite-sheet-frame-size/wrong-margin-makes-frames-drift.gif" alt="Two copies of the coin animation: cut with margin 0 and spacing 0 the coin slides sideways each frame; cut with margin 4 and spacing 2 it spins in place" width="472" height="250" loading="lazy" decoding="async"><figcaption>Left: cut with the simple division (35 × 37 frames), the coin wanders off the dashed center line. Right: cut with the right margin and spacing, it stays put.</figcaption></figure></p>
<p>SpritePilot&#39;s <a href="https://spritepilot.com/sprite-player">Sprite Player</a> does this check for you. Load the sheet and enter the columns and rows; it shows the frame size it worked out and warns you when the grid doesn&#39;t use up the whole image:</p>
<p><figure><img src="https://spritepilot.com/blog/sprite-sheet-frame-size/sprite-player-leftover-warning.png" alt="SpritePilot's Sprite Player Frame Grid panel: 4 columns, 2 rows, margin 0, spacing 0, reporting 8 frames of 35×37 px and a warning that 2 px are left over on the right" width="1720" height="476" loading="lazy" decoding="async"><figcaption>With margin and spacing at 0, the player reports 35 × 37 frames and 2 leftover pixels — the hint that the sheet has gaps.</figcaption></figure></p>
<p>Set the margin to 4 and the spacing to 2 and the warning disappears: 8 frames, 32 × 32 pixels each, and the coin spins in place. Press <strong>Grid overlay</strong> to see exactly where each frame is cut on the sheet itself. When it looks right, the <a href="https://spritepilot.com/sprite-sheet-to-gif">Sheet to GIF</a> tool can turn it into a GIF to share.</p>
<h2 id="when-it-never-divides-the-sheet-is-packed">When it never divides: the sheet is packed</h2>
<p>If no margin and spacing make the numbers work, or the frames are clearly different sizes, the sheet isn&#39;t a grid. It&#39;s a <strong>packed atlas</strong>: frames placed wherever they fit, often with their empty borders trimmed off. An atlas almost always ships with a data file — JSON, XML or .plist — that lists each frame&#39;s rectangle. Look for one next to the PNG; your engine can usually read it directly.</p>
<p>No data file? Split the sheet by its transparent background instead. SpritePilot&#39;s <a href="https://spritepilot.com/sprite-detection">Sprite Detection</a> finds each sprite automatically and can export them with the metadata your engine needs. For the step-by-step, see <a href="https://spritepilot.com/guides/split-sprite-sheet">How to Split a Sprite Sheet</a>.</p>
<h2 id="where-the-numbers-go-in-your-engine">Where the numbers go in your engine</h2>
<ul>
<li><strong>Unity:</strong> select the texture, set <strong>Sprite Mode</strong> to <strong>Multiple</strong>, open the <strong>Sprite Editor</strong> and choose <strong>Slice</strong>. With <strong>Grid By Cell Size</strong>, enter the frame size as <strong>Pixel Size</strong>, the margin as <strong>Offset</strong> and the spacing as <strong>Padding</strong>. <strong>Grid By Cell Count</strong> takes the columns and rows instead.</li>
<li><strong>Godot 4:</strong> in the SpriteFrames panel, use <strong>Add frames from a Sprite Sheet</strong>. Set <strong>Horizontal</strong> and <strong>Vertical</strong> to the columns and rows; for a sheet with gaps, set the frame <strong>Size</strong>, the <strong>Separation</strong> (spacing) and the <strong>Offset</strong> (margin).</li>
<li><strong>Phaser:</strong> pass all four numbers when you load the sheet (<a href="https://docs.phaser.io/api-documentation/typedef/types-loader-filetypes#imageframeconfig" target="_blank" rel="noopener noreferrer">frame config reference</a>):</li>
</ul>
<pre><code class="language-js">this.load.spritesheet('coin', 'coin-spin-grid.png', {
  frameWidth: 32,
  frameHeight: 32,
  margin: 4,
  spacing: 2,
})</code></pre>
<h2 id="quick-reference">Quick reference</h2>
<ul>
<li>No gaps: <strong>frame size = image size ÷ number of frames</strong> across (or down).</li>
<li>With gaps: <strong>frame size = (image size − 2 × margin − (count − 1) × spacing) ÷ count</strong>.</li>
<li>Familiar frame sizes: 16, 24, 32, 48, 64, 96, 128.</li>
<li>Check by playing it: a sprite that drifts means the margin or spacing is off.</li>
<li>Frames of different sizes: it&#39;s an atlas — find its data file or use automatic detection.</li>
</ul>
<h2 id="practice-sheets">Practice sheets</h2>
<p>Both coin sheets from this article are free to use in any project (CC0): <a href="https://spritepilot.com/blog/sprite-sheet-frame-size/coin-spin-strip.png">the 8 × 1 strip</a> (256 × 32, no gaps) and <a href="https://spritepilot.com/blog/sprite-sheet-frame-size/coin-spin-grid.png">the 4 × 2 grid</a> (142 × 74, margin 4, spacing 2). Load the grid into the <a href="https://spritepilot.com/sprite-player">Sprite Player</a> and see if you can make the warning go away. For 70 more animated sprites, all one-row strips named with their frame size and count, see <a href="https://spritepilot.com/free-game-assets">Free Pixel Art Game Assets</a>.</p>
]]></content:encoded>
    </item>
    <item>
      <title>Why Lines Appear Between Your Tiles (and How to Get Rid of Them)</title>
      <link>https://spritepilot.com/blog/lines-between-tiles</link>
      <guid isPermaLink="true">https://spritepilot.com/blog/lines-between-tiles</guid>
      <pubDate>Mon, 05 Oct 2026 12:00:00 GMT</pubDate>
      <description>Thin lines between tiles come from texture filtering at tile edges. Here's why they appear and how to fix them in Unity, Godot and Phaser.</description>
      <category>tilemaps</category>
      <category>texture bleeding</category>
      <category>pixel art</category>
      <content:encoded><![CDATA[<p>You build a level, press play, and there they are: thin lines between your tiles. Sometimes they&#39;re dark, sometimes they&#39;re the color of a completely different tile, and they often flicker as the camera moves. Open the tileset in an image editor and every tile is perfect. So where do the lines come from?</p>
<p>They come from the way the graphics card reads your texture, and they have a handful of well-understood fixes. This article shows what is happening, then goes through the fixes in order — from a one-line settings change to the fix that works for any art style.</p>
<h2 id="what-the-lines-really-are">What the lines really are</h2>
<p><figure><img src="https://spritepilot.com/blog/lines-between-tiles/lines-between-tiles.png" alt="Thin blue and brown lines run along the tile edges of a grassy tile map; a zoomed inset shows them clearly" width="1536" height="728" loading="lazy" decoding="async"><figcaption>A tile map drawn at 2.65× with smooth filtering. The blue and brown lines are pixels borrowed from the water and dirt tiles that sit next to the grass in the tileset.</figcaption></figure></p>
<p>When your game draws a tile, the GPU doesn&#39;t copy pixels one to one. For every pixel on screen it works out which spot of the texture that pixel covers and <em>samples</em> the texture there. With smooth filtering (also called linear or bilinear), a sample that falls between texture pixels is a blend of the four nearest ones.</p>
<p>That is harmless in the middle of a tile. At the edge, though, some of those four neighbors belong to <strong>the next tile in the sheet</strong>. In the picture above, the grass tile sits next to water and above dirt in the tileset, so the grass picks up a thin line of blue on one side and brown on the other. The tileset is fine; the sampling reaches across its tile borders.</p>
<p>You will see it when:</p>
<ul>
<li>tiles are drawn at a <strong>zoom that isn&#39;t a whole number</strong> (2.65× here, or any zoom a camera eases through),</li>
<li>the camera or a tile sits at a <strong>fractional position</strong>, such as x = 120.5 — smooth camera follow produces these constantly,</li>
<li><strong>mipmaps</strong> are on: the smaller versions of the texture blend whole neighborhoods of pixels, tiles included,</li>
<li>the texture is <strong>compressed</strong>: compression works in small blocks that ignore your tile borders.</li>
</ul>
<p>A related artifact is a hairline <em>gap</em> where the background shows through, when two neighboring tiles&#39; edges round to different screen pixels. The fixes below deal with both.</p>
<h2 id="fix-1-nearest-neighbor-filtering-for-pixel-art">Fix 1: Nearest-neighbor filtering for pixel art</h2>
<p>If your art is pixel art, tell the engine to take the nearest texture pixel instead of blending four. Pixels stay crisp and nothing blends in from the neighbors.</p>
<ul>
<li><strong>Unity:</strong> select the texture, set <strong>Filter Mode</strong> to <strong>Point (no filter)</strong> and <strong>Compression</strong> to <strong>None</strong>, then press Apply.</li>
<li><strong>Godot 4:</strong> Project Settings → Rendering → Textures → Canvas Textures → <strong>Default Texture Filter</strong> → <strong>Nearest</strong>. Any node can still override it with its own Texture → Filter property.</li>
<li><strong>Phaser:</strong> add <code>pixelArt: true</code> to the game config. <a href="https://docs.phaser.io/api-documentation/typedef/types-core#gameconfig" target="_blank" rel="noopener noreferrer">Phaser&#39;s documentation</a> says it turns antialiasing off and <code>roundPixels</code> on, and calls it &quot;the best setting for pixel-art games&quot;.</li>
</ul>
<pre><code class="language-js">const config = {
  type: Phaser.AUTO,
  width: 320,
  height: 180,
  pixelArt: true,
  scene: [GameScene],
}</code></pre>
<p>Nearest filtering removes the blending, but on its own it can&#39;t stop a sample at a fractional position from landing on the wrong side of a tile edge now and then. That is what the next fix is for.</p>
<h2 id="fix-2-keep-everything-on-whole-pixels">Fix 2: Keep everything on whole pixels</h2>
<p>Most lines appear only at fractional zooms and positions, so the second fix is to avoid them:</p>
<ul>
<li>Zoom by whole numbers — 2×, 3×, 4× — rather than 2.5×.</li>
<li>Round the camera position to whole pixels after smoothing it.</li>
<li><strong>Unity:</strong> add the <strong>Pixel Perfect Camera</strong> component to your camera and set its <em>Assets Pixels Per Unit</em> to match your sprites.</li>
<li><strong>Godot 4:</strong> Project Settings → Rendering → 2D → Snap → turn on <strong>Snap 2D Transforms to Pixel</strong>, and <strong>Snap 2D Vertices to Pixel</strong> if you still see wobble.</li>
<li><strong>Phaser:</strong> <code>roundPixels: true</code> draws sprites at whole-pixel positions (already on with <code>pixelArt: true</code>).</li>
</ul>
<p>Snapping trades a little smoothness of movement for stable pixels — usually the right trade for pixel art.</p>
<h2 id="fix-3-pad-and-extrude-the-sheet-works-for-any-art">Fix 3: Pad and extrude the sheet (works for any art)</h2>
<p>Painted or high-resolution art needs smooth filtering, and some games zoom freely. For those, change the sheet instead of the sampling. Two settings do it:</p>
<ul>
<li><strong>Padding</strong> leaves empty pixels between the sprites, so no two tiles touch.</li>
<li><strong>Extrusion</strong> copies each sprite&#39;s edge pixels outward by one or more pixels.</li>
</ul>
<p>Now a sample that reaches just past a tile&#39;s edge lands on a copy of that same edge — the same color — instead of on the neighboring tile.</p>
<p><figure><img src="https://spritepilot.com/blog/lines-between-tiles/padding-fixes-the-lines.png" alt="The same tile map twice: on the left, lines between the tiles; on the right, the tiles padded and extruded, with no lines" width="1536" height="568" loading="lazy" decoding="async"><figcaption>Same zoom, same position, same smooth filtering. Only the sheet changed.</figcaption></figure></p>
<p><figure><img src="https://spritepilot.com/blog/lines-between-tiles/tileset-padded-and-extruded.png" alt="The original 64×32 tileset next to SpritePilot's export of the same tiles, each surrounded by a one-pixel copy of its edge and spaced apart" width="1536" height="796" loading="lazy" decoding="async"><figcaption>Left: the original tileset. Right: SpritePilot's export with Padding 2 and Extrude 1. Each dashed square is a tile; the ring of pixels around it is its copied edge.</figcaption></figure></p>
<p>To make a sheet like this in SpritePilot:</p>
<ol>
<li>Cut your tileset into tiles with <a href="https://spritepilot.com/sprite-detection">Grid Slice</a>, or load your separate tile images. If the tileset has empty cells, untick <strong>Skip empty (fully transparent) cells</strong> so the tile numbers stay the same.</li>
<li>Open <a href="https://spritepilot.com/export-download">Export &amp; Download</a>, set <strong>Padding</strong> to 2 and <strong>Extrude</strong> to 1, leave <strong>Trim transparency</strong> off, and download the sheet with its metadata.</li>
</ol>
<p><img src="https://spritepilot.com/blog/lines-between-tiles/spritepilot-padding-extrude.png" alt="SpritePilot's Export settings with Padding set to 2 and Extrude set to 1" width="888" height="268" loading="lazy" decoding="async"></p>
<p>The metadata file (TexturePacker, Phaser, Godot or Aseprite JSON) points at each tile&#39;s own 16 × 16 pixels, not at the copied border, so sprites draw exactly as before. The tiles also keep their order, so tile numbers in an existing map still match.</p>
<p>If your engine reads a tileset as a grid instead of a JSON file, give it the sheet&#39;s <strong>margin</strong> and <strong>spacing</strong>. For a sheet exported this way, <strong>margin = extrude</strong> and <strong>spacing = 2 × extrude + padding</strong>. With Extrude 1 and Padding 2 that is margin 1 and spacing 4. In Phaser:</p>
<pre><code class="language-js">const map = this.make.tilemap({ key: 'level' })
// tileset name in the map, image key, tile width, tile height, margin, spacing
const tiles = map.addTilesetImage('meadow', 'meadow-extruded', 16, 16, 1, 4)
map.createLayer('Ground', tiles)</code></pre>
<p>In Tiled, the same two numbers go in the tileset&#39;s <strong>Margin</strong> and <strong>Spacing</strong> properties.</p>
<p>The engines also have their own versions of this fix:</p>
<ul>
<li><strong>Godot 4</strong> does it for tile maps automatically. A TileSet atlas source has <strong>Use Texture Padding</strong>, on by default; <a href="https://docs.godotengine.org/en/stable/classes/class_tilesetatlassource.html" target="_blank" rel="noopener noreferrer">the documentation</a> says it &quot;generates an internal texture with an additional one pixel padding around each tile&quot; because &quot;texture padding avoids a common artifact where lines appear between tiles&quot;. Leave it on. For sprites cut from a sheet with AtlasTexture, the <strong>Filter Clip</strong> option keeps sampling inside each frame&#39;s region.</li>
<li><strong>Unity&#39;s Sprite Atlas</strong> has a <strong>Padding</strong> setting (4 pixels by default) that, in <a href="https://docs.unity3d.com/Manual/sprite/atlas/sprite-atlas-reference.html" target="_blank" rel="noopener noreferrer">Unity&#39;s words</a>, prevents &quot;pixels from one sprite appearing in an adjacent sprite&quot;. Packing your tiles into a Sprite Atlas is the usual fix for lines in a Unity tilemap.</li>
</ul>
<h2 id="fix-4-mipmaps-and-compression">Fix 4: Mipmaps and compression</h2>
<p>Mipmaps help 3D textures seen from far away; in 2D they mostly blur edges and blend tiles together. Lossy compression does the same in small blocks.</p>
<ul>
<li><strong>Unity:</strong> in the texture&#39;s import settings, untick <strong>Generate Mip Maps</strong> (under Advanced) and set <strong>Compression</strong> to <strong>None</strong>.</li>
<li><strong>Godot 4:</strong> in the Import dock, keep <strong>Mipmaps → Generate</strong> off and the compression mode on <strong>Lossless</strong> — the defaults, and what <a href="https://docs.godotengine.org/en/stable/tutorials/assets_pipeline/importing_images.html" target="_blank" rel="noopener noreferrer">Godot recommends for pixel art</a>.</li>
<li><strong>Phaser:</strong> textures get no mipmaps unless you ask for them in the game config, so there is nothing to change.</li>
</ul>
<p>If you do need mipmaps — for a 2D game that zooms far out — extrude more than one pixel, because each smaller mip level reaches further across tile borders.</p>
<h2 id="a-checklist">A checklist</h2>
<p>For <strong>pixel art</strong>:</p>
<ol>
<li>Nearest (point) filtering.</li>
<li>Whole-number zoom, and the camera snapped to whole pixels.</li>
<li>No mipmaps, no lossy compression.</li>
<li>Still seeing lines? Pad and extrude the sheet (in Godot, keep Use Texture Padding on).</li>
</ol>
<p>For <strong>smooth or painted art</strong>:</p>
<ol>
<li>Pad and extrude the sheet — 2 px padding and 1 px extrusion to start.</li>
<li>Mipmaps only if you need them, and then a wider extrusion.</li>
<li>Snap the camera if movement shimmers at tile edges.</li>
</ol>
<h2 id="try-it-yourself">Try it yourself</h2>
<p>The tileset in this article is free to use in any project (CC0): <a href="https://spritepilot.com/blog/lines-between-tiles/meadow-tileset-16px.png">download the tileset</a> (8 tiles, 16 × 16 px) or <a href="https://spritepilot.com/blog/lines-between-tiles/meadow-tileset-16px-extruded.png">the padded and extruded version</a>. For animated tiles that repeat seamlessly on every frame, water, lava and a waterfall, see the free <a href="https://spritepilot.com/free-game-assets/environment">Environment Pack</a>.</p>
<p>To pad and extrude your own sheet, open <a href="https://spritepilot.com/export-download">Export &amp; Download</a> — it runs in your browser and nothing is uploaded. For what each metadata format is for, see <a href="https://spritepilot.com/guides/export-formats-explained">Sprite Sheet Export Formats Explained</a>.</p>
]]></content:encoded>
    </item>
  </channel>
</rss>
