Sortaballs‑style Ball‑Sort Puzzle Game Clone

A minimal JavaScript clone of the Sortaballs puzzle in ~45 minutes

Welcome to another official UVF IT guide! In this quick build you’ll create a playable ball‑sort puzzle inspired by Sortaballs, using only plain JavaScript, HTML, and a tiny Express server on the free tier. No external APIs, just local storage for level data.

We’ll scaffold a Node project, add the game board logic, expose a simple API to fetch a level, and wire up a minimal UI that runs in the browser. By the end you’ll have a working clone you can extend with more levels or styling.

Before you start

Clone Architecture
LOCAL DEVELOPMENT fetch /level read JSON Express Server (Node) serves API + static files levels.json static puzzle data Browser UI (HTML/JS) renders board
1

Phase 1: Scaffold the project

First we create a fresh Node project so we have a predictable package.json and a place to store dependencies. Express will serve our static HTML, JavaScript, and the tiny API that returns level data, keeping everything on one local server.

Next we lay out a simple folder structure: public/ for all client‑side files, and data/ for the JSON level definitions. This separation makes it easy to add more assets later without cluttering the server code.

# initialize a new Node project
npm init -y
# install Express as a dependency
npm install express
# create folder hierarchy
mkdir -p public data
# add a starter index.html so the server has something to serve
cat > public/index.html <<'EOF'
<!DOCTYPE html>
<html lang="en">
<head><meta charset="UTF-8"><title>Sortaballs Clone</title></head>
<body><h1>Loading…</h1><script src="app.js"></script></body>
</html>
EOF
2

Phase 2: Core model – load a level

We need a tiny module that can read a level description from data/levels.json and hand it to the server route. Using Node’s native fs/promises keeps the code async and future‑proof, while exporting a single loadLevel(id) function gives a clean API for any future routes.

The JSON file will contain an array of levels; each level stores tube counts, ball colors, and the target configuration. Our loader simply finds the requested index (or falls back to the first level) and returns the parsed object, ready for the front‑end to render.

levelModel.js

import { readFile } from 'fs/promises';
import { fileURLToPath } from 'url';
import { dirname, join } from 'path';

// Resolve the directory of this module (ESM style)
const __filename = fileURLToPath(import.meta.url);
const __dirname = dirname(__filename);
const LEVELS_PATH = join(__dirname, 'data', 'levels.json');

/**
 * Load a level by its numeric id (0‑based). Returns a Promise that resolves to the level object.
 * If the id is out of range, the first level is returned as a safe default.
 */
export async function loadLevel(id = 0) {
  const raw = await readFile(LEVELS_PATH, 'utf8');
  const levels = JSON.parse(raw);
  if (!Array.isArray(levels) || levels.length === 0) {
    throw new Error('levels.json must contain a non‑empty array');
  }
  const index = Number.isInteger(id) && id >= 0 && id < levels.length ? id : 0;
  return levels[index];
}
3

Phase 3: API & static server

In this step we spin up a tiny Express server that will serve our static UI files and expose a simple JSON API for level data. The /level/:id endpoint reads a hard‑coded level definition and returns it so the front‑end can draw the puzzle. Serving the public folder lets the browser load index.html, app.js, and any assets without extra configuration.

server.mjs

import express from 'express';
import path from 'path';
import { fileURLToPath } from 'url';

const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
const app = express();
const PORT = 3000;

// Serve static UI
app.use(express.static(path.join(__dirname, 'public')));

// Simple level store (you can expand later)
const levels = {
  1: {
    tubes: 4,
    capacity: 4,
    balls: [
      ['red','red','blue','green'],
      ['green','blue','red','red'],
      ['blue','green','green','blue'],
      []
    ]
  }
};

app.get('/level/:id', (req, res) => {
  const level = levels[req.params.id];
  if (level) res.json(level);
  else res.status(404).json({error:'Level not found'});
});

app.listen(PORT, () => console.log(`🚀 Server listening at http://localhost:${PORT}`));
4

Phase 4: Minimal UI – draw tubes & balls

Now we create a tiny front‑end that fetches a level from the API and paints the tubes and balls onto an HTML5 canvas. The code keeps the drawing logic straightforward: each tube is a vertical rectangle and each ball is a colored circle positioned based on its index in the tube array. This gives you a visual representation of the puzzle without any interaction yet.

public/app.js

// app.js – fetch level and render simple board
const canvas = document.getElementById('board');
const ctx = canvas.getContext('2d');
const tubeWidth = 80;
const tubeHeight = 320;
const ballRadius = 30;
const gap = 10;

function drawBoard(level) {
  const {tubes, capacity, balls} = level;
  canvas.width = tubes * (tubeWidth + gap) + gap;
  canvas.height = tubeHeight + 2 * gap;
  ctx.clearRect(0,0,canvas.width,canvas.height);
  // draw tubes
  for(let i=0;i<tubes;i++){
    const x = gap + i*(tubeWidth+gap);
    ctx.strokeStyle = '#555';
    ctx.lineWidth = 4;
    ctx.strokeRect(x, gap, tubeWidth, tubeHeight);
    // draw balls inside tube
    const tubeBalls = balls[i]||[];
    for(let j=0;j<tubeBalls.length;j++){
      const color = tubeBalls[j];
      const cx = x + tubeWidth/2;
      const cy = canvas.height - gap - ballRadius - j*(ballRadius*2+5);
      ctx.beginPath();
      ctx.arc(cx, cy, ballRadius, 0, Math.PI*2);
      ctx.fillStyle = color;
      ctx.fill();
      ctx.strokeStyle = '#333';
      ctx.stroke();
    }
  }
}

fetch('/level/1')
  .then(r=>r.json())
  .then(drawBoard)
  .catch(err=>console.error('Failed to load level',err));
5

Phase 5: Run & verify

Start the server, open your browser, and you should see a canvas with four tubes and the colored balls from the sample level. The API endpoint can be tested with curl to confirm it returns the expected JSON structure. If everything renders, your minimal Sortaballs clone is up and running.

node --experimental-modules server.mjs

Verifying the Stack

CheckCommand / Why it matters
Server starts without errorsnode --experimental-modules server.mjs prints the listening URL
Browser loads the page at http://localhost:3000Open the URL; you should see a canvas with three colored balls in the first tube
API returns JSON for a levelcurl http://localhost:3000/level/1 shows the level object

What’s next?

You now have a functional ball‑sort board. Add drag‑and‑drop moves, level progression, or a scoring system to evolve this clone toward the full Sortaballs experience.