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.
Play in one click
Open a demo scene and press Play: 30 levels, undo, restart, hints and an extra-tube booster.
Any sort puzzle
One element per move or whole runs, custom move rules, win conditions and special tubes.
Every level solvable
A solver proves each level, finds a shortest solution, scores difficulty and powers the hints.
Levels in bulk
Paint and validate levels in the Level Editor, or generate packs along a difficulty curve.
Liquid that pours
Layers that keep their volume at any tilt, slosh, ripple and splash where the stream lands.
Real particle liquid
A fluid simulation that spills over the lip and splashes into the next tube, in 2D or 3D.
Your own look
One subclass and a few timeline hooks give a sort game a new renderer.
Fits every screen
Boards fit between your HUD's header and buttons, in portrait and landscape.
Requirements
| Item | Version | Notes |
|---|---|---|
| Unity | 6000.0+ | |
| Universal Render Pipeline | 17.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.ugui | 2.0+ | The demos' HUD and the tween library's UI helpers |
com.unity.inputsystem | 1.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
- Import the package Everything lands in
Assets/HardArtcore: the framework inUniversalSortFrameworkand the small tween library it uses inCommon/Tween. - Open a demo Open any scene in
UniversalSortFramework/Samplesand press Play. - 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).
- 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
| Scene | Renderer | Camera | Levels |
|---|---|---|---|
BallSort2D | ObjectStackAdapter + sprites | Orthographic | Ball levels: one ball per move |
BallSort3D | ObjectStackAdapter + meshes | Perspective | Ball levels |
LiquidSort2D | LiquidAdapter, liquid in a masked quad | Orthographic | Liquid levels: whole runs pour |
LiquidSort3D | LiquidAdapter, liquid in an inner tube mesh | Perspective | Liquid levels |
LiquidSort2DParticles | LiquidAdapter + ParticleLiquid2D | Orthographic | Liquid levels |
LiquidSort3DParticles | LiquidAdapter + ParticleLiquid2D in glass tubes | Perspective | Liquid 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.Singlemoves one element per move (Ball Sort).TransferMode.Runmoves 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:
maxExtraLocationsempty tubes the player may add during a level.
Architecture
The framework is split into assemblies, each depending only on the ones above it:
| Assembly | Contents | Depends on |
|---|---|---|
HardArtcore.UniversalSort.Core | BoardState, MoveCommand, rules, LevelData, LevelDefinition, the binary pack codec. No Unity code. | nothing |
…Engine | SortBoard (applies moves, add location, restart) and UndoSystem | Core |
…Solver | A* solver, hints, difficulty, validator, generator, analyzer | Core |
…Unity | Assets: LevelAsset, LevelPackAsset, ColorPalette, JSON | Core |
…Presentation | SortSession (the game flow), ISortRendererAdapter, the animation queue | Core, Engine, Solver |
…Rendering | SortGameController, renderers, views, layouts, liquid, particles, shaders | all of the above, Tween, uGUI |
…Editor | Level Editor, Batch Level Generator, inspectors | Core, Engine, Solver, Unity |
…Samples | The demos' HUD, progress and backdrop | Rendering, Tween, uGUI |
HardArtcore.Common.Tween | Timelines, eases and small UI motion components | uGUI |
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.
- A 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).
- A palette . Element
nuses entryn − 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. - A container prefab A
ColliderorCollider2D(taps arrive through the EventSystem) and a view:StackContainerViewfor balls and objects, orLiquidContainerViewwith a child renderer using theHardArtcore/UniversalSort/Liquidshader in its Liquid field. Set the view's Size, Bottom Anchor and Mouth Anchor. - A board object A renderer,
ObjectStackAdapter(with aStackElementprefab) orLiquidAdapter, with the container prefab, palette and camera assigned. Add aRowsLayout(one is added if missing) and give it a Fit Area in your HUD. - A game object
SortGameControllerwith 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.
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
LevelPackAssetto play. - Start Index
- The level loaded on Start. Set it from your save data before Start, or call
LoadLevelyourself. - Renderer
- A component implementing
ISortRendererAdapter, such asObjectStackAdapterorLiquidAdapter. - Rules
- Optional
RuleProfileasset. Empty means the standard rules. - Hint Options
SolverOptionsfor 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
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.
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.SingleorTransferMode.Run.- locations
- One
LocationData { elements, tag }per tube;elementsbottom 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 (): one level. Double-click it to open the Level Editor.
- LevelPackAsset (): an inline list of levels, or a binary pack file.
Count,GetLevel(i),SetLevels,SetBinaryPack. - JSON:
LevelJson.ToJson/FromJsonfor one level,PackToJson/PackFromJsonfor 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.bytesfile and assign it to a pack. A corrupt pack is reported in the Console andLoadLevelreturns false.
// 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
, 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):
| Status | Meaning |
|---|---|
| Solvable | Shows a shortest solution's moves, whether it needs the booster, and the difficulty score with its branching and trap rate. |
| Unsolvable | No sequence of moves finishes it, even with one extra tube when the level allows one. |
| Not proven solvable | The solver ran out of budget. Simplify the level, or treat it as unsolvable. |
| Invalid | The 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
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:
| Field | Meaning |
|---|---|
| Levels | How many levels the tier makes. |
| Colors from / to | Colours rise evenly across the tier (3→4 over 10 levels: five with 3, five with 4). |
| Capacity, Empty locations, Max extra locations, Mode | As in the Level Editor. |
| Min / Max score | The difficulty window (0–100) a level must land in. |
| Max attempts | Random layouts tried per level before giving up on the window. |
| Playouts | Random players used to measure traps: more is slower and steadier. |
| Solver node budget | How 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.
// 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
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) andBoosterRequired(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.
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);
}
}
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
[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); 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.
| Field | Default | What it changes |
|---|---|---|
| Lift Height | 0.18 | How far above the mouth a lifted ball travels (a selected liquid tube rises by it) |
| Select Duration | 0.12 | Lifting or lowering the selection |
| Lift / Arc / Drop Duration | 0.1 / 0.26 / 0.2 | The three parts of a ball's move |
| Arc Height | 0.3 | Extra height of the arc between tubes |
| Stagger | 0.05 | Delay between elements moving together |
| Reflow Duration | 0.3 | Tubes sliding when one is added |
| Invalid Duration / Invalid Shake | 0.3 / 0.08 | The refusal shake |
| Hint Duration / Hint Scale | 0.8 / 0.08 | The hint pulse |
| Completed Duration / Completed Punch | 0.3 / 0.12 | The punch when a tube is finished |
| Pour Arc Duration | 0.3 | Liquid: travel to the target |
| Pour Per Unit Duration | 0.2 | Liquid: pour time per poured slot |
| Return Duration | 0.28 | Liquid: travel back home |
| Undo Pour Duration | 0.25 | Liquid: an undo drains back in place |
| Stream Fade Duration | 0.08 | Liquid: the stream appearing and disappearing |
| Pour Clearance | 0.08 | Gap between the pouring lip and the target's mouth |
| Pour Side Offset | 0.2 | How far beside the target's mouth the lip sits |
| Tilt By Fill After | curve | Pour tilt in degrees by how full the source is after the pour (1 full, 0 empty) |
| Arc Tilt Share | 0.6 | Share 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
RectTransformin 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/LiquidStreamshader 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
ParticleLiquid2Dthat 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:
| Property | Use |
|---|---|
_FoamColor, _FoamWidth | Band under the surface (alpha = strength; width in world units) |
_RimStrength, _Specular | 3D shading (unmasked mode) |
_MaskMode | 0 none (3D inner mesh), 1 rounded rectangle, 2 texture alpha |
_MaskRect, _MaskRadius | The rounded rectangle in object space: center xy, half size zw |
_MaskTex | Alpha mask for any 2D tube shape (mode 2) |
_Cull | Keep 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.
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.
| Hook | Default |
|---|---|
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) |
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
doneexactly once, also when motion is reduced (synchronously is fine), and never afterKillAnimations. - A move consumes the selection: after
SetSelected(source, true)the session callsAnimateMovewithout deselecting, so the transfer starts from the lifted pose. Build(board)snaps everything with no animation;KillAnimationsis always followed byBuild.
Tween library
HardArtcore.Common.Tween is a small, allocation-light timeline library that every renderer uses. Use it
in your own hooks and UI:
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
RowsLayoutfitting the board into the HUD'sBoardArea(up to 12 tubes a row) and the renderer, with the tube prefab, palette and camera. - Game:
SortGameControllerandSampleProgress, which remembers the level reached inPlayerPrefsunderUSF.<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
Paletteasset inSamples/Common; add entries for more colours. - Balls: give palette entries a
sprite(2D) or ameshandmaterial(3D), or change theStackElementprefab. - 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
SampleHUDprefab 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
ushortarray; 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.MaxNodesorMaxMilliseconds. - 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) orCollider2D(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 .
- The board goes under the UI
- Give
RowsLayouta 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
LoadLevelreturns 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.
| Component | Location | License |
|---|---|---|
| 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.
Last updated November 2, 2026.