You Might Also Like
Color Sort Water Puzzle — Free HTML CSS JS Snippet
Color Sort Water Puzzle · Games · Plain HTML, CSS & JS · Live preview
What's included
Features
About this UI Snippet
Color Sort Water Puzzle — Stack-Based Pour Logic, Reverse-Shuffle Generation & Undo

Water sort puzzles became one of the most-downloaded mobile game genres of the last few years precisely because the rules are so simple to state and so satisfying to reason through: pour a colour from one tube into another, and you may only pour onto empty space or a matching colour. This snippet implements the real mechanic in vanilla JavaScript — genuine stack data structures, real pour validation, and a shuffle algorithm that mathematically guarantees every generated puzzle is solvable.
Modelling tubes as LIFO stacks
Each tube is represented as a plain JavaScript array where index 0 is the bottom of the tube and the last index is the top — a textbook LIFO (last-in, first-out) stack. topColor(tube) reads tube[tube.length - 1], and pouring uses pop() on the source and push() on the destination, which is exactly how a real stack data structure is manipulated. Rendering reverses this visually with CSS flex-direction: column-reverse on .tube, so segment index 0 (the bottom of the logical stack) renders at the visual bottom of the tube even though it is the first child in the DOM.
Generating a puzzle that is provably solvable
It's tempting to "scramble" a water sort board the same way a 15-puzzle is scrambled — start from the solved state and repeatedly apply random *legal* moves in reverse. For this game that approach quietly fails: a legal pour can only add liquid to an empty tube or onto a matching top colour, so starting from monochrome tubes and only ever applying legal pours can never produce a tube containing two different colours — every tube stays monochrome-or-empty no matter how many legal pours you replay, which also means a naive "is this scramble trivial" check would answer yes every single time. Genuinely mixed tubes have to come from somewhere else: dealRandomState() builds a flat list of every colour unit (colorCount * TUBE_CAPACITY of them), shuffles it with a real Fisher–Yates shuffle, then deals each unit into a random tube that still has spare capacity — the same way you'd deal a shuffled deck of cards into hands. That produces authentic mixed-colour tubes, but a random deal isn't automatically solvable. So every deal is checked with isSolvable(), a breadth-first search over legal-pour states (canonicalised by sorting tubes before hashing, since which physical tube holds what doesn't affect solvability) capped at a few thousand explored states — a board is only accepted once the search actually finds a path to every tube being monochrome-or-empty; an unproven deal is discarded and scramblePuzzle() simply deals again.
Real pour validation, including multi-segment pours
canPour(from, to) enforces the actual water sort ruleset: pouring is illegal from an empty tube, into a tube whose top colour differs from the source's top colour, or into a tube without enough remaining capacity. Critically, a pour is not limited to one unit — topRunLength() walks down from the top of the source tube counting how many consecutive segments share the same top colour, and pour() transfers Math.min(run, availableSpace) segments in one action, exactly matching how the real genre works: pouring a tube with three stacked red segments onto an empty tube moves all three at once, not one at a time.
Selection, undo, and win detection
Clicking a tube with liquid selects it (visually lifted with a translateY and indigo border via .tube.selected); clicking a second tube attempts canPour and, if valid, calls pour() and records { from, to, count } onto a moveHistory stack. The Undo button pops that history and pushes the exact segment count back from destination to source, cleanly reversing any pour including multi-segment ones. checkWin() calls isTubeSolved() on every tube — a tube counts as solved if it is empty, or if it is completely full and every segment shares the same colour — and the puzzle is won only when every single tube satisfies that condition simultaneously.
Why this teaches real constraint-based game logic
Water sort is a clean example of a puzzle whose entire challenge lives in state and move validation rather than visuals. Building it correctly forces you to reason about stack semantics, legal-move generation, and reversible shuffles — the same conceptual toolkit used in the Mini Sudoku Puzzle's constraint checking, just applied to a different rule set.
Build with AI
Build, Understand, Optimize, and Extend It With AI
Paste this snippet's HTML, CSS and JS into an AI coding assistant like Claude and ask it to walk through exactly why a legal-pour reverse-shuffle (the technique that works for a 15-puzzle) can never produce a mixed-colour tube here, and how dealRandomState() plus the isSolvable() breadth-first search work together to guarantee a solvable board anyway — it's a genuinely interesting bit of state-space reasoning to have explained in plain language. You could also ask it to add a move-limit or star-rating scoring mode based on how close your move count comes to an optimal solve, extend isSolvable() to also return the shortest solving path so it can double as a hint system, or add a difficulty selector that adjusts colorCount and the empty-tube margin together. It's a solid exercise in state-space search once the core pour mechanics already work.
Prompt to recreate it
Copy this into your AI assistant of choice to build the effect from scratch, or as a jumping-off point for your own variant:
Build a water-sort colour puzzle in plain HTML, CSS, and JavaScript with real stack-based tube logic and a guaranteed-solvable shuffle — no frameworks, no libraries.
Requirements:
- Model each tube as a stack (array) holding up to a fixed number of coloured segments, with pours implemented via pop/push semantics rather than direct array splicing.
- Generate puzzles by dealing colours into tube slots at random (not by reverse-shuffling legal pours from a solved state — that approach can never produce a mixed-colour tube in this ruleset), and verify each random deal is actually solvable with a bounded search before showing it to the player, re-dealing on the rare unsolvable draw.
- Implement pour validation that checks the destination is either empty or has a matching top colour AND has enough free capacity, and moves the full contiguous run of matching top-colour segments in one action rather than one unit at a time.
- Click-to-select-source, click-to-attempt-pour interaction: selecting a tube highlights it, and clicking a second tube either performs a valid pour or reselects/deselects appropriately if the pour is illegal.
- A move counter that increments only on successful pours (decide explicitly whether undo also counts as a move, and document that choice).
- An undo feature that exactly reverses the most recent pour, including multi-segment pours, using a recorded move history rather than re-deriving state.
- Win detection that checks every tube is either empty or completely full with one uniform colour, with a clear success message showing the final move count.
- A "New Puzzle" action that regenerates a fresh guaranteed-solvable layout on demand.Want to tighten it up first? Run this prompt through the AI Prompt Studio to score it across 8 quality dimensions, catch anti-patterns, and tune the wording for Claude, ChatGPT, or Gemini before you paste it in.
Step by step
How to Use
- 1Select a source tubeClick any tube that contains at least one segment. It lifts slightly and gets an indigo border via the .selected class to show it is the active source for your next pour.
- 2Pour into a destination tubeClick a second tube. If it is empty, or its top colour matches the source tube's top colour and it has free capacity, canPour() approves the move and pour() transfers the maximum valid contiguous run of matching segments in one action.
- 3Watch the move counter updateEvery successful pour increments the Moves counter in the HUD. Invalid pour attempts (mismatched colours, full destination) simply reselect or clear your selection without counting as a move.
- 4Undo a mistakeClick "Undo" to reverse your most recent pour exactly, using the {from, to, count} record stored on the moveHistory stack — it pushes the same number of segments back to their original tube.
- 5Win the puzzleWhen every tube is either empty or holds a single uniform colour across its full capacity, checkWin() triggers the "Solved!" banner showing your total move count.
- 6Start a new puzzleClick "New Puzzle" to call scramblePuzzle() again, which deals a fresh random layout via dealRandomState() and verifies it with isSolvable() before showing it, guaranteeing another solvable configuration.
Real-world uses
Common Use Cases
Got questions?
Frequently Asked Questions
It deals colours into tubes at random with dealRandomState() — a legal-pour reverse-shuffle like a 15-puzzle uses cannot work here, because a legal pour can only merge onto an empty tube or a matching top colour, so it can never create a mixed-colour tube in the first place. Instead every random deal is run through isSolvable(), a breadth-first search over legal-pour states, and only accepted once that search actually finds a path to a fully solved board; an unproven deal is discarded and a new one is dealt.
topRunLength() counts how many consecutive segments at the top of the source tube share the same colour, and pour() transfers the minimum of that run length and the destination's remaining capacity in a single action. This matches the real water sort ruleset, where three stacked segments of the same colour pour together as one contiguous mass rather than one unit per click.
Every tube has a fixed TUBE_CAPACITY (4 in this snippet). canPour() computes the destination's remaining space as TUBE_CAPACITY - dst.length and rejects the pour outright if that space is zero; when the pour is valid but the run is longer than the remaining space, pour() only transfers Math.min(run, space) segments rather than overflowing.
Yes. Change TUBE_CAPACITY for taller or shorter tubes, and edit the colorCount value inside scramblePuzzle() for more or fewer colours. The totalTubes value (colorCount + 2) controls how many spare empty tubes exist beyond what each colour strictly needs — raise that +2 to make the puzzle easier (more manoeuvring room) or lower it to make it harder, and isSolvable() will keep verifying whatever combination you choose.
Yes, by design — undoMove() increments moveCount just like a forward pour, so the displayed move count always reflects total actions taken rather than being gamed by repeated undo/redo. If you want undo to be free, remove the moveCount++ line inside undoMove().