Documentation

Universal Sort Framework

Ball sort, liquid sort and your own sort puzzles for Unity: rules, a solver with hints, a level generator and editor, and ball, liquid and particle liquid renderers in 2D and 3D.

  • Version 1.0.0
  • Unity 6000.0+
  • Platforms Android, iOS, WebGL, Desktop
  • Updated Nov 2, 2026
Universal Sort Framework

Overview

Universal Sort Framework is everything behind a sort puzzle, as clean and tested systems you can reuse: Ball Sort, Water/Liquid Sort and your own variants. It has the game rules, a fast solver that proves levels solvable and gives hints, a level generator that fills packs along a difficulty curve, a level editor, and renderers for balls, layered liquid and real particle liquid, in 2D and 3D.

The game rules are plain C# with no Unity dependency, so they run anywhere, including on background threads. The renderers are MonoBehaviours you can replace. Six demo scenes show every renderer with 30 levels each.

Requirements

ItemVersionNotes
Unity6000.0+
Universal Render Pipeline17.0+For the demos, the Liquid shaders and the particle liquid. The core, solver, generator, level tools and ball renderer work without it.
com.unity.ugui2.0+The demos' HUD and the tween library's UI helpers
com.unity.inputsystem1.7+Optional Detected automatically; the legacy Input Manager works too

The Liquid shader works with the Universal Renderer (Forward, Forward+ and Deferred, with or without depth priming and SSAO) and with the 2D Renderer. Platforms: Android, iOS, WebGL and desktop. On WebGL the solver runs on the main thread, since there are no background threads.

Quick start

  1. Import the package Everything lands in Assets/HardArtcore: the framework in UniversalSortFramework and the small tween library it uses in Common/Tween.
  2. Open a demo Open any scene in UniversalSortFramework/Samples and press Play.
  3. Play Tap a tube to lift its top, then tap another tube to move. The HUD has Undo, Restart, Hint and + Tube (one extra empty tube per level).
  4. Make it yours Copy a demo folder and change the copy, or build a scene from scratch in five pieces (Your first scene).

The demo scenes

SceneRendererCameraLevels
BallSort2DObjectStackAdapter + spritesOrthographicBall levels: one ball per move
BallSort3DObjectStackAdapter + meshesPerspectiveBall levels
LiquidSort2DLiquidAdapter, liquid in a masked quadOrthographicLiquid levels: whole runs pour
LiquidSort3DLiquidAdapter, liquid in an inner tube meshPerspectiveLiquid levels
LiquidSort2DParticlesLiquidAdapter + ParticleLiquid2DOrthographicLiquid levels
LiquidSort3DParticlesLiquidAdapter + ParticleLiquid2D in glass tubesPerspectiveLiquid levels

Each pack has 30 generated levels of rising difficulty: levels 1–10 score 0–30, 11–20 score 25–55 and 21–30 score 50–100, with more colours in each tier. Every level stores its solution, which the hints use.

Concepts

A sort puzzle in this framework is a few simple pieces of data and one rule set:

Location
A tube, bottle or column. It holds up to capacity elements, bottom first.
Element
A ball, a liquid unit, a block: a number from 1 (0 means empty). The palette turns numbers into colours, sprites or meshes.
Capacity
Slots per location, 1–65535. The same for every location in a level.
Transfer mode
TransferMode.Single moves one element per move (Ball Sort). TransferMode.Run moves the whole run of identical top elements that fits (Liquid Sort).
Move
MoveCommand { Source, Target, Quantity }. Legal when the source has elements, the target has room, the top elements match (or the target is empty) and every rule agrees.
Tag
A number per location for special tubes (locked, one-colour-only, frozen...). Rules read it, views show it.
Win condition
By default: every location is empty, or full of one element.
Extra location
The "+ Tube" booster: maxExtraLocations empty tubes the player may add during a level.

Architecture

The framework is split into assemblies, each depending only on the ones above it:

AssemblyContentsDepends on
HardArtcore.UniversalSort.CoreBoardState, MoveCommand, rules, LevelData, LevelDefinition, the binary pack codec. No Unity code.nothing
…EngineSortBoard (applies moves, add location, restart) and UndoSystemCore
…SolverA* solver, hints, difficulty, validator, generator, analyzerCore
…UnityAssets: LevelAsset, LevelPackAsset, ColorPalette, JSONCore
…PresentationSortSession (the game flow), ISortRendererAdapter, the animation queueCore, Engine, Solver
…RenderingSortGameController, renderers, views, layouts, liquid, particles, shadersall of the above, Tween, uGUI
…EditorLevel Editor, Batch Level Generator, inspectorsCore, Engine, Solver, Unity
…SamplesThe demos' HUD, progress and backdropRendering, Tween, uGUI
HardArtcore.Common.TweenTimelines, eases and small UI motion componentsuGUI

At runtime, a tap travels like this: the container view raises Tapped → the renderer's LocationTapped → SortSession.Tap, which checks the move against the rules and applies it to the SortBoard at once → the animation queue asks the renderer to animate it, one move after another → SortGameController turns the session's events into UnityEvents. The board is always ahead of the pictures: renderers keep their own visual model and never read the board while animating.

Folder layout

Assets/HardArtcore/
├── Common/Tween/                  Timeline, Tween, Ease, Appear, Punch, PressFeedback, MotionPrefs
└── UniversalSortFramework/
    ├── Documentation.html         this documentation
    ├── Runtime/
    │   ├── Core/                  board, moves, rules, level data, pack codec
    │   ├── Engine/                SortBoard, UndoSystem
    │   ├── Solver/                AStarSolver, HintProvider, LevelGenerator, LevelAnalyzer, LevelValidator
    │   ├── Presentation/          SortSession, ISortRendererAdapter, AnimationQueue, LiquidLayers
    │   ├── Unity/                 LevelAsset, LevelPackAsset, ColorPalette, LevelJson
    │   └── Rendering/             SortGameController, adapters, views, layouts, liquid, particles
    │       └── Shaders/           Liquid, LiquidStream, ParticleLiquid
    ├── Editor/                    Level Editor and Batch Level Generator windows, inspectors
    ├── Samples/                   six demo scenes and their shared assets in Common/
    └── Tests/                     EditMode and PlayMode tests

Your first scene

A sort game scene needs five pieces. Every demo scene is built exactly this way.

  1. A level pack Create › Universal Sort › Level Pack. Fill its inline list in the Level Editor, generate it with the Batch Level Generator, or assign a binary pack file (Level data).
  2. A palette Create › Universal Sort › Color Palette. Element n uses entry n − 1: a colour, and optionally a sprite (2D) or a mesh and material (3D). Elements past the list get generated colours, so levels with more colours still draw.
  3. A container prefab A Collider or Collider2D (taps arrive through the EventSystem) and a view: StackContainerView for balls and objects, or LiquidContainerView with a child renderer using the HardArtcore/UniversalSort/Liquid shader in its Liquid field. Set the view's Size, Bottom Anchor and Mouth Anchor.
  4. A board object A renderer, ObjectStackAdapter (with a StackElement prefab) or LiquidAdapter, with the container prefab, palette and camera assigned. Add a RowsLayout (one is added if missing) and give it a Fit Area in your HUD.
  5. A game object SortGameController with the pack and the renderer assigned. Optional: a rule profile and hint solver options.

On Start the controller loads level Start Index. If the scene has no EventSystem, the renderer creates one (with the Input System's UI module when that package is active), and if the camera has no physics raycaster that matches your colliders, it adds one.

Tip

The quickest start is a copy of the demo folder closest to your game. Copy it and edit the copy, so updating the package never overwrites your changes.

Game controller

SortGameController connects a level pack, a renderer and your UI.

Pack
The LevelPackAsset to play.
Start Index
The level loaded on Start. Set it from your save data before Start, or call LoadLevel yourself.
Renderer
A component implementing ISortRendererAdapter, such as ObjectStackAdapter or LiquidAdapter.
Rules
Optional RuleProfile asset. Empty means the standard rules.
Hint Options
SolverOptions for hints on levels without a stored solution, or once the player leaves it.
Events
LevelStarted(int), MoveMade, InvalidMove(int), LevelCompleted(int), Stuck, HintShown, LocationAdded(int). Wire them in the Inspector or from code.

Driving the game from code

GameFlow.cs
var game = GetComponent<SortGameController>();
game.LoadLevel(0);               // by pack index (wraps around); false if the level can't load
game.LoadLevel(levelData);       // any LevelData, e.g. a daily level from your server
game.NextLevel();
game.Undo();                     // false when there is nothing to undo
game.Restart();
game.RequestHint();              // solves on a background thread (on the main thread on WebGL)
game.AddLocation();              // the "+ Tube" booster; false when none are left

game.LevelCompleted.AddListener(index => ShowWinPanel(index));
game.Stuck.AddListener(() => OfferUndoOrBooster());
int level = game.LevelIndex;     // -1 for a level loaded from LevelData
bool busy = game.IsAnimating;

The session

game.Session is the SortSession underneath: the game flow without any Unity UI. Use it for lower-level control, or drive a session yourself with any ISortRendererAdapter.

Session.cs
SortSession session = game.Session;
session.Tap(2);                          // what a tap on location 2 does
BoardState board = session.Board.State;  // the current board (ahead of the animations)
int selected = session.SelectedLocation; // -1 when nothing is selected

session.MoveMade += move => Debug.Log($"{move.Source} → {move.Target} ×{move.Quantity}");
session.HintShown += move => Analytics.Hint(move);
session.HintFailed += error => Debug.LogException(error);

A tap on a tube with elements selects it. A tap on the selected tube deselects it. A tap on another tube moves there when the move is legal; otherwise a non-empty tube becomes the selection and an empty one refuses with a shake (InvalidMove). Stuck fires when no legal move is left: offer Undo, Restart or the booster.

Level data

A LevelData is plain serializable data:

capacity
Slots per location.
mode
TransferMode.Single or TransferMode.Run.
locations
One LocationData { elements, tag } per tube; elements bottom first, ids from 1.
maxExtraLocations
How many empty tubes the booster may add.
solution
Optional stored solution (MoveData[]). The hints follow it while the player stays on it.
difficulty
Optional DifficultyData: score (0–100), optimalLength, branchingFactor, trapRate, boosterRequired.

Assets and formats

  • LevelAsset (Create › Universal Sort › Level): one level. Double-click it to open the Level Editor.
  • LevelPackAsset (Create › Universal Sort › Level Pack): an inline list of levels, or a binary pack file. Count, GetLevel(i), SetLevels, SetBinaryPack.
  • JSON: LevelJson.ToJson / FromJson for one level, PackToJson / PackFromJson for a pack. Good for servers and level sharing.
  • Binary pack: LevelPackCodec.Write(stream, levels) / Read(stream). Compact, for hundreds of levels. Save it as a .bytes file and assign it to a pack. A corrupt pack is reported in the Console and LoadLevel returns false.
Levels.cs
// A level by hand: two colours, capacity 4, two empty tubes.
var level = new LevelData {
    capacity = 4,
    mode = TransferMode.Run,
    maxExtraLocations = 1,
    locations = new[] {
        new LocationData { elements = new[] { 1, 2, 1, 2 } },
        new LocationData { elements = new[] { 2, 1, 2, 1 } },
        new LocationData { elements = new int[0] },
        new LocationData { elements = new int[0] },
    }
};
string json = LevelJson.ToJson(level, pretty: true);
LevelData copy = LevelJson.FromJson(json);
game.LoadLevel(copy);

Level Editor

Tools › Universal Sort › Level Editor, a double-click on a LevelAsset, or Open in Level Editor on a level or pack inspector. Drop a level or a pack into the field at the top. A pack lists its levels (index, colours, stored score); Add, Duplicate, Delete, Up and Down work on the selected one. A pack that reads a binary file is read-only until you Convert to inline levels.

Settings and painting

  • Capacity, Mode and Max extra locations as in Level data. Lowering the capacity trims locations from the top.
  • Pick a colour in the swatch row; pick a Palette to see your game's colours. Type any id into Color id; 0 erases.
  • Left-click a slot to paint it; clicking the first free slot adds an element on top, so stacks never have gaps. Right-click removes the top element.
  • + Location adds an empty tube; the × above a column removes it.
  • The colour counts and issue list flag a colour whose count isn't a multiple of the capacity, a location over capacity, and more colours than tubes to finish them in.

Every edit is one Undo step and clears the stored solution and difficulty, which no longer match. Changes made elsewhere (Inspector, another editor, the generator) are picked up before your next edit.

Validate and store

Validate solves the level in the background (Cancel stops it):

StatusMeaning
SolvableShows a shortest solution's moves, whether it needs the booster, and the difficulty score with its branching and trap rate.
UnsolvableNo sequence of moves finishes it, even with one extra tube when the level allows one.
Not proven solvableThe solver ran out of budget. Simplify the level, or treat it as unsolvable.
InvalidThe level data is malformed; the message says why.

Store result writes the solution and difficulty into the level. Export / Import read and write JSON for the level, or for the whole pack.

Batch Level Generator

Tools › Universal Sort › Batch Level Generator fills a pack along a difficulty curve. A curve is a list of tiers; each tier makes a run of levels with the same settings:

FieldMeaning
LevelsHow many levels the tier makes.
Colors from / toColours rise evenly across the tier (3→4 over 10 levels: five with 3, five with 4).
Capacity, Empty locations, Max extra locations, ModeAs in the Level Editor.
Min / Max scoreThe difficulty window (0–100) a level must land in.
Max attemptsRandom layouts tried per level before giving up on the window.
PlayoutsRandom players used to measure traps: more is slower and steadier.
Solver node budgetHow hard the solver tries per attempt.

Level i uses seed Seed base + i, so the same tiers always give the same pack. A level that finds nothing in its window takes any solvable score, with a warning. Output: Fill pack (inline, with or without Append) or Write binary pack (a .bytes file, optionally assigned to a pack). The defaults are the demos' curve.

Solver, hints and difficulty

All of the level tools are thin layers over public APIs you can call yourself, in the Editor or at runtime.

Analyze.cs
// Solve, check the booster, score, and store the result in the level.
LevelAnalysis analysis = LevelAnalyzer.Analyze(levelData);
if (analysis.Status == ValidationStatus.Solvable) {
    Debug.Log($"{analysis.Solution.Length} moves, score {analysis.Difficulty.Score}");
    analysis.WriteTo(levelData);
}

// With custom rules (e.g. from a RuleProfile). Rules that depend on a location's index need symmetry off.
var options = new SolverOptions { UseSymmetryReduction = false };
LevelAnalyzer.Analyze(levelData, options,
    rules: profile.CreateRules(levelData.mode), winCondition: profile.CreateWinCondition());

Generating levels

Generate.cs
var settings = new GeneratorSettings {
    Colors = 5, Capacity = 4, EmptyLocations = 2, MaxExtraLocations = 1,
    Mode = TransferMode.Run, MinScore = 25, MaxScore = 55
};
if (LevelGenerator.TryGenerate(settings, seed: 42, out LevelData level)) {
    // solvable, scored, with its solution stored: the same seed always gives the same level
}

// Editor only: a whole pack along the demos' curve.
BatchResult result = BatchGenerator.Generate(BatchGenerator.DefaultTiers(TransferMode.Run), seedBase: 2000);
BatchGenerator.WriteInline(myPack, result.Levels, append: false);
GeneratorSettings
Colors, Capacity, EmptyLocations, MaxExtraLocations, Mode, MinScore / MaxScore, MaxAttempts (500), Playouts (200), SolverOptions, Weights.
DifficultyWeights
How the score mixes OptimalLength (0.35), BranchingFactor (0.15), TrapRate (0.4) and BoosterRequired (0.1). Change them to match how your players feel difficulty.
SolverOptions
MaxNodes (2,000,000), MaxMilliseconds (0 = no limit), HeuristicWeight (1 = shortest solutions; higher is faster, less optimal), UseSymmetryReduction.

Hints

RequestHint follows the level's stored solution while the player is on it; once they leave it, the solver searches from the current board with the controller's Hint Options. The renderer pulses the two tubes (HintShown). HintProvider gives the same answers outside a session: GetHint(board) or GetHintAsync(board), returning Found, AlreadySolved, Unsolvable, BudgetExceeded or Cancelled.

Rules and special tubes

A move is legal when every IMoveRule allows it; the smallest cap any rule returns limits how many elements move. The physical limits (source not empty, target has room, not the same tube) are already enforced.

SameTagOnlyRule.cs
public sealed class SameTagOnlyRule : IMoveRule {
    public bool CanExecuteMove(in BoardState board, int source, int target, out int maxTransferQuantity) {
        maxTransferQuantity = int.MaxValue;               // no limit from this rule
        return board.Tag(source) == board.Tag(target);
    }
}
Contract

A rule must be a pure, deterministic, thread-safe function of the board: the solver, the hints and the generator evaluate the same rules, on background threads. A rule that depends on a location's index (grid adjacency, for example) needs SolverOptions.UseSymmetryReduction = false.

The win condition is IWinCondition.IsSolved(in BoardState), with the same contract. BoardState gives rules what they need: Count, Get, Top, TopRunLength, FreeSpace, IsUniform, IsComplete and Tag.

Rules as an asset

TagRuleProfile.cs
[CreateAssetMenu(menuName = "My Game/Tag Rules")]
public sealed class TagRuleProfile : RuleProfile {
    public override IMoveRule[] CreateRules(TransferMode mode) {
        var rules = new List<IMoveRule>(RuleSets.Default(mode)) { new SameTagOnlyRule() };
        return rules.ToArray();
    }
    // Optional: public override IWinCondition CreateWinCondition() => new MyWinCondition();
}

Assign the asset to the controller's Rules field. RuleSets.Default(mode) is the standard set (TopMatchRule + TransferModeRule); Create › Universal Sort › Standard Rules makes it an asset.

Special tubes with tags

LocationData.tag reaches rules through BoardState.Tag(location) and views through ContainerView.Tag. A locked tube is a tag, a rule that refuses moves into or out of it, and a view that shows a lock: override Bind in your view to pick a sprite or material by tag.

Renderers

SortRendererBase is the default ISortRendererAdapter. It pools one ContainerView per location, lays them out with the SortLayout on the same object, keeps each view's visual model (ContainerView.Elements), tracks running animations so the game never waits on a broken one, and, in the Editor and development builds, checks the visual model against the board after every animation and logs the first difference. Its fields: Container Prefab, Palette, Camera (defaults to the main camera), Board Root (defaults to itself) and Motion.

Balls and objects

ObjectStackAdapter + StackContainerView + a StackElement prefab. Elements are pooled objects that lift, arc and drop between tubes. A StackElement with a SpriteRenderer takes the palette entry's colour and sprite; with a MeshRenderer it takes the entry's mesh and material, tinted through _BaseColor or _Color. Empty fields keep the prefab's own sprite, mesh or material.

Motion settings

Every renderer has a Motion block. Durations are seconds; distances are fractions of a container's height or width.

FieldDefaultWhat it changes
Lift Height0.18How far above the mouth a lifted ball travels (a selected liquid tube rises by it)
Select Duration0.12Lifting or lowering the selection
Lift / Arc / Drop Duration0.1 / 0.26 / 0.2The three parts of a ball's move
Arc Height0.3Extra height of the arc between tubes
Stagger0.05Delay between elements moving together
Reflow Duration0.3Tubes sliding when one is added
Invalid Duration / Invalid Shake0.3 / 0.08The refusal shake
Hint Duration / Hint Scale0.8 / 0.08The hint pulse
Completed Duration / Completed Punch0.3 / 0.12The punch when a tube is finished
Pour Arc Duration0.3Liquid: travel to the target
Pour Per Unit Duration0.2Liquid: pour time per poured slot
Return Duration0.28Liquid: travel back home
Undo Pour Duration0.25Liquid: an undo drains back in place
Stream Fade Duration0.08Liquid: the stream appearing and disappearing
Pour Clearance0.08Gap between the pouring lip and the target's mouth
Pour Side Offset0.2How far beside the target's mouth the lip sits
Tilt By Fill AftercurvePour tilt in degrees by how full the source is after the pour (1 full, 0 empty)
Arc Tilt Share0.6Share of the tilt reached by the end of the arc; the rest turns while pouring

With reduced motion on (MotionPrefs.Reduced, see Tween library) every animation jumps to its end and still reports back, so the game plays the same, instantly.

Layout and screen fit

RowsLayout places tubes in balanced, centered rows.

Max Per Row
Most tubes in one row. With Fit To Camera, the row count that gives the biggest tubes is picked within this limit: one long row in landscape, more rows in portrait.
Spacing
Gap between tubes and between rows, in the tube's units.
Fit To Camera
Scales and centers the board to fit the camera view (orthographic or perspective) and re-fits when the screen rotates or resizes.
Fit Area
Optional UI rect the board must fit inside: an empty, stretched RectTransform in your HUD between the header and the buttons. Without it, the whole view.
Padding
Share of the fit area kept free on each side.

For another arrangement (a circle, a grid, hand-placed tubes) subclass SortLayout: implement GetLocalPosition(index, count, viewSize), and optionally GetFitScale(count, viewSize, camera, root), which the renderer calls first for the same count. Call RaiseChanged() when your arrangement goes stale; the renderer lays the board out again once nothing is moving.

Liquid

LiquidAdapter + LiquidContainerView. A pour lifts the source, arcs it over the target, tips it about its lip by a tilt planned from how full it is, pours while the layers drain and fill, and returns. The liquid's layers are fractional (LiquidLayers), so pours animate smoothly and always end exact.

Stream Material
LiquidAdapter: the falling stream, using the HardArtcore/UniversalSort/LiquidStream shader or any material coloured through _BaseColor. Empty: no stream.
Stream Width
LiquidAdapter: stream width as a fraction of the tube's width.
Travel Depth
LiquidAdapter, orthographic cameras: how far a moving tube comes toward the camera, so it draws over the others.
Particles
LiquidAdapter: an optional ParticleLiquid2D that draws the liquid as particles (below).
Liquid
LiquidContainerView: the renderer using the Liquid shader, a quad behind the 2D outline or an inner mesh slightly smaller than the 3D glass.
Full At
LiquidContainerView: how far from the bottom anchor toward the mouth a full tube's liquid reaches (0.88).
Wobble
LiquidContainerView: the slosh, see below.

The Liquid shader

HardArtcore/UniversalSort/Liquid (URP) draws up to 32 layers per tube (more are merged at the bottom, with one warning), with soft joins, a meniscus, ripples, depth, bubbles and foam while agitated, and a dip, crown and splash where a stream lands. LiquidFill solves the surface and the layer joins on the CPU, so the liquid keeps its volume at any tilt. Material settings:

PropertyUse
_FoamColor, _FoamWidthBand under the surface (alpha = strength; width in world units)
_RimStrength, _Specular3D shading (unmasked mode)
_MaskMode0 none (3D inner mesh), 1 rounded rectangle, 2 texture alpha
_MaskRect, _MaskRadiusThe rounded rectangle in object space: center xy, half size zw
_MaskTexAlpha mask for any 2D tube shape (mode 2)
_CullKeep Off in 3D: the back faces draw the surface seen from above

Liquid renderers get their data through MaterialPropertyBlocks, which turns the SRP Batcher off for them. That is fine at sort-game counts.

Slosh

LiquidWobble is a spring that leans the surface the way the tube's acceleration and spin push it, and rocks it back to level. In 2D the shader draws the slosh as the tube's first sloshing mode: a curve that meets the glass flat.

Stiffness
The rocking speed: the slosh frequency squared. About 32 suits water in a tube one unit wide.
Damping
Lower sloshes longer; real water rocks three or four times before it settles.
Smoothing
Seconds the tube's motion is averaged over, so a sudden change of pace leans the liquid over a few frames.
Response, Spin
How strongly acceleration and turning slosh it.
Max Tilt
The largest lean; the surface eases into it instead of stopping dead.
Ripples, Settle
How much sloshing stirs up ripples and bubbles, and how fast they die down.

Particle liquid

Add ParticleLiquid2D next to a LiquidAdapter and assign it to the adapter's Particles field. The liquid becomes real particles: tubes tip, the liquid spills over the lip, falls as a stream that breaks into drops and splashes into the next tube. Each tube's layers stay the truth: a tube always settles back to exactly the particles its layers ask for, in their colours, so the game logic never depends on the simulation.

The simulation is 2D in the board's plane (world X/Y at the board's Z), so the board must face the camera along Z. The camera may be orthographic (2D) or perspective (3D, where the liquid is a sheet through the middle of each glass tube). The particles are splatted into a field texture each frame and drawn by the HardArtcore/UniversalSort/ParticleLiquid shader as one smooth liquid.

Material
A material using the ParticleLiquid shader (Rim Lightness sets the edge highlight).
Particles Per Unit
Particles per unit of liquid; raise it if full tubes look short.
Resolution
Field texels per particle spacing: sharper costs more texture upload.
Simulation
Spacing (particle size: smaller is smoother and slower), Stiffness, NearStiffness (surface tension), Viscosity, GravityX/Y, MaxSpeed, LipKeep (how fast liquid leaves the lip), FreeCohesion (how a stream breaks into drops), AirDrag. Changes apply live in Play mode.
Keeping the liquid in the tubes

Real liquid spills when tubes move fast or tip too far. The particle demos use slower moves (Lift 0.18, Pour Arc 0.55, Return 0.5, Pour Per Unit 0.75), a pour tilt of 100°–118° with Arc Tilt Share 0.68, Pour Clearance 0.12 and Pour Side Offset 0.05, and calmer liquid (Gravity Y −24, Max Speed 28, Lip Keep 0.08, Air Drag 9). Start from those.

Cost: about 2,500 particles take a few milliseconds per frame on the main thread, enough for a sort game's tube counts on mobile.

Custom renderers

A new look is one SortRendererBase subclass. Each hook returns a Timeline; the base plays it, and moves the element ids between the views' Elements lists for you.

HookDefault
CreateTransfer(move, from, to)Required. Elements travel from from to to.
CreateUndoTransfer(move, from, to)Same as CreateTransfer (from is the move's target)
CreateSelect(view, selected)Nothing
CreateInvalid(view)Horizontal shake
CreateHint(from, to)Scale pulse on both
CreateCompleted(view)Scale punch
CreateReflow(count)Slides the views to the layout for count views. Extend with Join, OnComplete, OnAbort, not Append.
OnViewCreated(view) / OnViewReleased(view)Nothing
OnAnimationsKilled()Nothing; clear what a timeline would have cleaned up (restart mid-move)
SquashAdapter.cs: for views that redraw themselves in Snap() (your own ContainerView)
public sealed class SquashAdapter : SortRendererBase {
    protected override Timeline CreateTransfer(MoveCommand move, ContainerView from, ContainerView to) {
        // from.Elements still holds the moving ids here: read them, don't change them.
        var source = from.transform;
        var rest = source.localScale;
        return new Timeline()
            .Append(0.12f, Ease.InCubic, v => source.localScale = rest * (1f - 0.1f * v))
            .AppendCallback(() => { from.Snap(); to.Snap(); })   // after the base moved the ids
            .Append(0.12f, Ease.OutBack, v => source.localScale = rest * (0.9f + 0.1f * v));
    }
}

Custom views

A ContainerView subclass draws one tube. Override Snap() (redraw from Elements, no animation), Bind(index, capacity, tag, palette) (pick the look, e.g. by tag) and SetHighlighted(bool). Positions come from GetSlotPosition(slot), BottomPosition, MouthPosition and Above(fraction), all from the view's Size, Bottom Anchor and Mouth Anchor.

A renderer from scratch

Anything that implements ISortRendererAdapter can play the game: UI Toolkit, a 3D shelf, a server bot. The contract:

  • Never read the board inside Animate*: the board may already be several moves ahead. Keep your own visual model and update it when each animation starts.
  • Call each done exactly once, also when motion is reduced (synchronously is fine), and never after KillAnimations.
  • A move consumes the selection: after SetSelected(source, true) the session calls AnimateMove without deselecting, so the transfer starts from the lifted pose.
  • Build(board) snaps everything with no animation; KillAnimations is always followed by Build.

Tween library

HardArtcore.Common.Tween is a small, allocation-light timeline library that every renderer uses. Use it in your own hooks and UI:

Timelines.cs
var timeline = new Timeline()
    .Append(0.2f, Ease.OutCubic, v => t.position = Vector3.LerpUnclamped(a, b, v))   // after the previous step
    .Join(0.2f, Ease.Linear, v => t.localScale = Vector3.one * (1f + 0.1f * v))     // with the previous step
    .Insert(0.05f, 0.1f, Ease.Linear, v => glow.alpha = v)                          // at an absolute time
    .AppendCallback(() => sound.Play())
    .OnComplete(() => Debug.Log("done"))
    .OnAbort(() => Debug.Log("a step threw, or the owner was destroyed"));
TweenHandle handle = Tween.Play(timeline, owner: this);   // stops with its owner
handle.Kill(complete: true);                               // jump to the end

Tween.Move(transform, target, 0.3f);                      // one-liners: Move, Rotate, Scale, Value

// Reduced motion: point it at your setting; animations then jump to their end.
MotionPrefs.Reduced = () => PlayerPrefs.GetInt("ReduceMotion") == 1;

UI helpers: Appear (pop in and out), Punch (a quick scale punch, Punch.On(component)) and PressFeedback (hover and press scale on buttons).

Customising the demos

Each demo scene has:

  • Main Camera with the raycaster matching the tube colliders.
  • Board: a RowsLayout fitting the board into the HUD's BoardArea (up to 12 tubes a row) and the renderer, with the tube prefab, palette and camera.
  • Game: SortGameController and SampleProgress, which remembers the level reached in PlayerPrefs under USF.<Sample>.Level.
  • SampleHUD: the level title on a highlighter, a status chip, Undo / Restart / Hint / Tube and a win card, wired to the controller through SampleHud.
  • Backdrop: a canvas far behind the board with a gradient (SampleBackdrop, colours in its Inspector). It has no raycaster, so taps reach the tubes.

Reskinning

  • Colours: the Palette asset in Samples/Common; add entries for more colours.
  • Balls: give palette entries a sprite (2D) or a mesh and material (3D), or change the StackElement prefab.
  • Tubes: replace the meshes in the tube prefab with any model or sprite. Keep a collider, and set the view's size and anchors.
  • Liquid: keep the Liquid material on the inner renderer. For a custom 2D tube shape use mask mode 2 with an alpha texture.
  • Motion: each renderer's Motion settings.
  • UI: the SampleHUD prefab is plain uGUI with Nunito; restyle it or replace it with your own and wire it to the controller's methods and events.

Performance

  • The board is a flat ushort array; moves, undo and the solver allocate nothing per move.
  • Hints run on a background thread (on the main thread on WebGL, where a stored solution answers instantly and the fallback solver uses the hint budget).
  • Generation and validation are Editor-time or background work: bound them with SolverOptions.MaxNodes or MaxMilliseconds.
  • Views and elements are pooled; loading a level reuses them.
  • Particle liquid costs a few milliseconds per frame for about 2,500 particles. Raise the simulation's Spacing or lower Resolution on low-end devices.

Troubleshooting

Taps do nothing
The tube prefab needs a Collider (3D) or Collider2D (2D), and nothing in front of it may block raycasts (a full-screen UI image with Raycast Target on).
No liquid is drawn
Assign the liquid renderer to the view's Liquid field (the Console says so), and make sure the project uses URP.
Pink materials
The demos need URP. Set a URP asset in Project Settings › Graphics.
The board goes under the UI
Give RowsLayout a Fit Area rect between your header and buttons.
Hints say "unsolvable"
The player reached a dead end: offer Undo or Restart. "Budget exceeded" means the hint solver gave up: raise Hint Options › Max Nodes, or store solutions in your levels.
A level won't load
LoadLevel returns false and the Console says why: malformed data, or a corrupt binary pack.
Particles spill out of tubes
Slow the moves and lower the pour tilt, as in the particle demos.

Third-party notices

The demos include two third-party components. Everything else in the package, including the scripts, shaders, meshes and the rest of the demo art, is original work by HardArtcore.

ComponentLocationLicense
Nunito font
Copyright 2014 The Nunito Project Authors (github.com/googlefonts/nunito)
Samples/Common/UI/Fonts/SIL Open Font License 1.1
Material Symbols icons
Copyright Google LLC (github.com/google/material-design-icons)
Samples/Common/UI/Icons/Apache License 2.0

The full licence texts ship in the package's Documentation.html, and online: SIL Open Font License 1.1, Apache License 2.0.

Release notes

1.0.0

  • First release: engine, solver, hints, generator, Level Editor and Batch Level Generator, ball, liquid and particle liquid renderers in 2D and 3D, six demos with 30 levels each.

Need help?

Questions, bug reports or feature requests: I usually reply within a day.

support@hardartcore.com

Last updated November 2, 2026.