For Wannibe Manisha’s “Do You Wanna Jam 2026”, I made something I’ve never attempted before: individual tiles that are built or destroyed. I received a lot of positive feedback about the effect, along with a lot of questions. So this article breaks down how that system works.
The Core Difficulty System
Everything ties directly to the difficulty system. As the player progresses, the game tracks how many tiles the player has traveled. The more you travel, the higher an internal difficulty setting gets. This is the core that drives other systems.
private void Update()
{
if (!isActive) return;
Difficulty = CalculateDifficulty(ScoreManager.Instance.MaxDistance);
}
private float CalculateDifficulty(float distance)
{
return distance / distanceDivisor;
}
Camera Example
For example, the camera asks for this difficulty value and multiplies its base speed by the difficulty (up to a cap). This is how I get the game to speed up, but also not get so fast the player can’t keep up.
private void Update()
{
if (!isActive) return;
if (isScaling)
{
currentSpeed = CalculateScrollSpeed(DifficultyManager.Instance.Difficulty);
if (currentSpeed >= maxSpeed)
{
currentSpeed = maxSpeed;
isScaling = false;
}
}
transform.position += Vector3.right * currentSpeed * Time.deltaTime;
}
private float CalculateScrollSpeed(float difficulty)
{
return baseSpeed + difficulty * difficultySpeedMultiplier;
}
Build and Decay Lines
Similar to the camera, there are two invisible lines. What I call the build line and decay line. Both function in slightly different ways.
The build line is actually identical to the camera. In fact, I set it as a child object of the camera so it moves at the exact same speed. This keeps it so tiles build in the same spot no matter the camera speed. Though it does get affected at higher speeds because of the distance tiles need to travel as they are built. Very slight trade off.
The decay line functions identically. But it sits as a separate game object for one important reason. The base speed multiplier is slightly higher than the camera’s. As the game progresses, this causes the decay line to creep forward faster than the camera. Effectively, this reduces the available play area and makes the game a little harder in the later difficulties.
Same code as the camera above. Just change the multiplier by .01.
Decay Kills Collision Before It Kills the Visual
A TileDecayer component reads the decay line’s position every frame. As tiles cross it, the tile’s collider gets disabled first. Then the visual crumble happens after.
public void DecayAll()
{
isActive = false;
foreach (Vector3Int cell in bounds.allPositionsWithin)
{
if (!masterTilemap.HasTile(cell)) continue;
masterTilemap.SetColliderType(cell, Tile.ColliderType.None);
decayedCells.Add(cell);
}
}
That ordering is necessary. If the visual and the collision changed at the same instant, the ground looks solid for one more frame while the collider is already gone. Disabling the collider first means the mechanical issue resolves before the tile visually breaks apart.
It’s extremely subtle. But it makes the player avoid feeling like deaths are unfair.
The Crumble Effect
Each tile that decays needs geometry the shader can break along. A regular quad doesn’t give the shader enough vertices to fragment convincingly. So I wrote a procedural subdivided quad mesh builder that generates that geometry per tile.
public static Mesh Build(int cellsPerAxis, float size)
{
if (cellsPerAxis < 1) cellsPerAxis = 1;
int cellCount = cellsPerAxis * cellsPerAxis;
int vertexCount = cellCount * 4;
Vector3[] vertices = new Vector3[vertexCount];
Vector2[] uv0 = new Vector2[vertexCount];
Color[] colors = new Color[vertexCount];
int[] triangles = new int[cellCount * 6];
float cellSize = size / cellsPerAxis;
float halfSize = size * 0.5f;
int vertIndex = 0;
int triIndex = 0;
for (int y = 0; y < cellsPerAxis; y++)
{
for (int x = 0; x < cellsPerAxis; x++)
{
float xMin = x * cellSize - halfSize;
float xMax = xMin + cellSize;
float yMin = y * cellSize - halfSize;
float yMax = yMin + cellSize;
float uMin = x / (float)cellsPerAxis;
float uMax = (x + 1) / (float)cellsPerAxis;
float vMin = y / (float)cellsPerAxis;
float vMax = (y + 1) / (float)cellsPerAxis;
vertices[vertIndex + 0] = new Vector3(xMin, yMin, 0f);
vertices[vertIndex + 1] = new Vector3(xMax, yMin, 0f);
vertices[vertIndex + 2] = new Vector3(xMin, yMax, 0f);
vertices[vertIndex + 3] = new Vector3(xMax, yMax, 0f);
uv0[vertIndex + 0] = new Vector2(uMin, vMin);
uv0[vertIndex + 1] = new Vector2(uMax, vMin);
uv0[vertIndex + 2] = new Vector2(uMin, vMax);
uv0[vertIndex + 3] = new Vector2(uMax, vMax);
Color cellColor = new Color(Random.value, 0f, 0f, 1f);
colors[vertIndex + 0] = cellColor;
colors[vertIndex + 1] = cellColor;
colors[vertIndex + 2] = cellColor;
colors[vertIndex + 3] = cellColor;
triangles[triIndex + 0] = vertIndex + 0;
triangles[triIndex + 1] = vertIndex + 2;
triangles[triIndex + 2] = vertIndex + 1;
triangles[triIndex + 3] = vertIndex + 1;
triangles[triIndex + 4] = vertIndex + 2;
triangles[triIndex + 5] = vertIndex + 3;
vertIndex += 4;
triIndex += 6;
}
}
Mesh mesh = new Mesh { name = $"CrumbleMesh_{cellsPerAxis}x{cellsPerAxis}" };
mesh.vertices = vertices;
mesh.uv = uv0;
mesh.colors = colors;
mesh.triangles = triangles;
mesh.RecalculateBounds();
return mesh;
}
A dedicated tile crumble shader reads that mesh and drives how the fragments break apart.
DecayFragmentController manages the individual fragments. They’re spawned from TileDecayer events and pulled from an object pooler instead of instantiated fresh each time. Pooling was necessary here. I had prototyped this idea once already with substantial performance issues. Pooling corrected this.
Fragment motion went through two passes. First I added backward drift so fragments fall at a diagonal instead of straight down. Then I tied that drift speed to the camera’s scroll speed. Fragments now read as being left behind by the world’s movement instead of just falling under gravity.
Shader Script
Shader "Custom/TileCrumble"
{
Properties
{
_MainTex ("Sprite", 2D) = "white" {}
_NoiseTex ("Dissolve Noise", 2D) = "white" {}
_DecayProgress ("Decay Progress", Range(0, 1)) = 0
_FallDistance ("Fall Distance", Float) = 1.5
_Scatter ("Horizontal Scatter", Float) = 0.5
_BackwardDrift ("Backward Drift", Float) = 0.4
_EdgeWidth ("Dissolve Edge Width", Range(0.001, 0.5)) = 0.08
_EdgeColor ("Dissolve Edge Glow", Color) = (0, 1, 1, 1)
}
SubShader
{
Tags { "RenderType"="Transparent" "Queue"="Transparent" "IgnoreProjector"="True" }
Cull Off
ZWrite Off
Blend SrcAlpha OneMinusSrcAlpha
Pass
{
CGPROGRAM
#pragma vertex vert
#pragma fragment frag
#include "UnityCG.cginc"
struct appdata
{
float4 vertex : POSITION;
float2 uv : TEXCOORD0;
float4 color : COLOR;
};
struct v2f
{
float4 pos : SV_POSITION;
float2 uv : TEXCOORD0;
float seed : TEXCOORD1;
};
sampler2D _MainTex;
float4 _MainTex_ST;
sampler2D _NoiseTex;
float _DecayProgress;
float _FallDistance;
float _Scatter;
float _BackwardDrift;
float _EdgeWidth;
float4 _EdgeColor;
float hash(float n)
{
return frac(sin(n * 12.9898) * 43758.5453);
}
v2f vert(appdata v)
{
v2f o;
float seed = v.color.r;
// Each cell starts falling at a slightly different point in the decay
// window so the tile doesn't crumble as one rigid slab.
float cellStart = seed * 0.35;
float cellProgress = saturate((_DecayProgress - cellStart) / (1.0 - cellStart));
float fallCurve = cellProgress * cellProgress;
float dirX = hash(seed * 17.0 + 3.0) * 2.0 - 1.0;
// Backward drift is a constant velocity term (moves with cellProgress,
// same as the random scatter) layered under gravity's acceleration
// (fallCurve) on the vertical axis, so fragments trace a diagonal path
// instead of falling straight down.
float3 offset = float3(
(dirX * _Scatter - _BackwardDrift) * cellProgress,
-_FallDistance * fallCurve,
0.0
);
float4 displaced = v.vertex + float4(offset, 0.0);
o.pos = UnityObjectToClipPos(displaced);
o.uv = TRANSFORM_TEX(v.uv, _MainTex);
o.seed = seed;
return o;
}
fixed4 frag(v2f i) : SV_Target
{
fixed4 tex = tex2D(_MainTex, i.uv);
fixed noise = tex2D(_NoiseTex, i.uv + i.seed).r;
clip(noise - _DecayProgress);
float edge = smoothstep(0.0, _EdgeWidth, noise - _DecayProgress);
fixed4 col = lerp(_EdgeColor, tex, edge);
col.a = tex.a;
return col;
}
ENDCG
}
}
}
Rule Tile Bug
I am using Unity’s rule tiles for autotiling. My first version of decay removed the tile outright once it crossed the decay line. That broke the rule tile neighbor calculations on adjacent tiles. Rule tiles decide which sprite variant to show based on which of their neighbors are present. Deleting a tile mid-frame meant its neighbors recalculated against a hole that shouldn’t have existed yet, which showed up as visible pop-in on tiles nowhere near the decay line.
The fix was to fade the tile instead of removing it, and to snapshot each tile’s sprite before clearing it so the fade has something stable to reference. Rule tiles and destructive tile removal don’t mix. If you’re building any kind of tile based decay, erosion, or destruction system on top of Unity’s rule tiles, fade first and remove later, or don’t remove at all until the tile is fully off the visible boundary.
Build Is the Mirror System
The build line uses the same structure as decay, just running in the other direction.
A tile assembly shader handles the visual, BuildFragmentController manages the fragments, and they come from their own pooled prefab entry. TileBuilder sweeps the build line across chunk tiles as they load. Newly spawned chunk tiles stay hidden until the build line actually reaches them. BuildFragmentSpawner reveals the tile once assembly finishes.
That hidden until reached behavior means the build line isn’t just an overlay sitting on top of already visible tiles. It gates whether the tile is visible or interactable at all.
Fragment motion for build got tuned across a few passes too. I started with horizontal jitter, switched to vertical jitter, then added a bias so fragments enter from the right side of the screen instead of materializing in place. That entry bias matters more than it sounds like it should. Without it, fragments read as randomly appearing. With it, they read as arriving from off screen, which matches the direction the player is moving.
Decay and build share the same underlying material, shader, and noise texture. The jitter axis and drift direction are just different parameters on the same pipeline. I built one effect with two parameter sets.
Hazards Live Inside the Same Lifecycle
Spikes, toggle blocks, and shake and fall platforms all gate their spawn off the build line instead of spawning the moment their chunk loads. When their column decays, their colliders disable the same way a regular tile’s collider does.
They also reuse the same pooled fragment shaders as regular tiles when they spawn or decay. However, since they do not sit directly on the tilemap, I had to write a slightly modified build/decay controller.
Summary
The decay and build lines are two instances of the same mechanism, just with slightly modified parameters. A moving threshold, a shared shader and pooling pipeline, and hazards and tiles treated as citizens of the same lifecycle instead of separate systems. The rule tile bug is the one piece to remember even outside this project. Fading instead of removing is the safer default anytime autotiling and destruction are in the same system.
For anyone that is interested, you can view the entire repository as a public archive here: https://github.com/Brainfart-Studio/Defrag.Run.
0 Comments