-
Thin Ice
+
+
← Back
+
+
+
+
+
+ You start on safe ice, breaking ice where you walk, trying to reach the finish tile before trapping yourself.
+
+
- A game inspired by the Club Penguin mini-game of the same name.
+ See if you can get a gold medal on all 6 levels by breaking every single tile of ice!
-
-
-
+
+
-
-
-
+
+
+
-
- Resize canvas
- Lock/hide mouse pointer
-
- Fullscreen
-
-
+
+ Resize canvas in fullscreen
+ Lock pointer in fullscreen
+
-
-
+
Downloading...
+
-
+
+ Show console output
+ Console output
+
+
+
+
+
+ How to Play
+
+ Use WASM keys to move across the ice.
+ Plan ahead so you do not strand yourself on broken tiles.
+ Reach the end tile without falling into the water.
+
-
+
+
+
diff --git a/projects/tsp/canvas.js b/projects/tsp/canvas.js
new file mode 100644
index 0000000..5b9be57
--- /dev/null
+++ b/projects/tsp/canvas.js
@@ -0,0 +1,19 @@
+export function setupCanvas(gl, canvas) {
+ canvas.width = canvas.clientWidth * window.devicePixelRatio;
+ canvas.height = canvas.clientHeight * window.devicePixelRatio;
+ gl.viewport(0, 0, canvas.width, canvas.height);
+
+ return {
+ canvas,
+ gl,
+ aspectRatio: canvas.width / canvas.height
+ };
+}
+
+export function resizeCanvas(gl, canvas, program) {
+ canvas.width = canvas.clientWidth * window.devicePixelRatio;
+ canvas.height = canvas.clientHeight * window.devicePixelRatio;
+ gl.viewport(0, 0, canvas.width, canvas.height);
+ gl.useProgram(program);
+ gl.uniform2f(gl.getUniformLocation(program, "u_resolution"), canvas.width, canvas.height);
+}
diff --git a/projects/tsp/index.html b/projects/tsp/index.html
new file mode 100644
index 0000000..79e77ae
--- /dev/null
+++ b/projects/tsp/index.html
@@ -0,0 +1,74 @@
+
+
+
+
+
+
Travelling Salesman Problem
+
+
+
+
+
← Back
+
+
+
+
+
+ After writing one of my first tutorials on the Travelling Salesman Problem, I wanted to revisit it with a slightly more technical stack.
+ The backend is written in Zig (compiled to WebAssembly), and the rendering is done directly in WebGL instead of p5.js or Raylib.
+
+
+ This project was also inspired by sphaerophoria who started a series on creating his own map.
+
+
+
+
+ Drag points around the canvas and watch the route update in real time.
+
+
+
+
+
+
+
+
+
+ HTML5 canvas not supported in browser
+
+
+
+
+
36 points
+
Tip: click and drag a point to recompute the route.
+
+
+
+
+ Technical Details
+
+ The backend is written in Zig, and the route is built in two passes: a nearest-neighbour pass followed by a 2-opt local search.
+
+
+ Point coordinates are stored in WebAssembly memory, and the route order is recalculated whenever the layout changes.
+ Rendering is handled in WebGL to keep interaction smooth as the point count increases.
+
+
+
+
+
+
+
+
+
+
diff --git a/projects/tsp/main.js b/projects/tsp/main.js
new file mode 100644
index 0000000..5eac282
--- /dev/null
+++ b/projects/tsp/main.js
@@ -0,0 +1,245 @@
+import { initWasm } from "./wasm.js";
+import { setupCanvas, resizeCanvas } from "./canvas.js";
+import { createShaderProgram } from "./shaders.js";
+import { draw } from "./tsp-gl.js";
+import { initPoints } from "./utils.js";
+
+const DEFAULT_POINT_COUNT = 36;
+const MIN_POINTS = 4;
+const MAX_POINTS = 96;
+const RADIUS_SIZE = 0.006;
+
+function clampPointCount(value) {
+ if (Number.isNaN(value)) {
+ return DEFAULT_POINT_COUNT;
+ }
+
+ return Math.max(MIN_POINTS, Math.min(MAX_POINTS, value));
+}
+
+function createPointFragmentShader(pointCount) {
+ return `
+ precision mediump float;
+
+ uniform vec2 u_resolution;
+ uniform vec2 u_centers[${pointCount}];
+
+ void main() {
+ vec2 position = gl_FragCoord.xy / u_resolution;
+ float aspect = u_resolution.x / u_resolution.y;
+ vec2 scaledPosition = vec2(position.x * aspect, position.y);
+ float radius = ${RADIUS_SIZE};
+
+ for (int i = 0; i < ${pointCount}; i++) {
+ vec2 center = u_centers[i];
+ vec2 scaledCenter = vec2(center.x * aspect, center.y);
+ float distance = length(scaledPosition - scaledCenter) * (1.0 / aspect);
+
+ if (distance <= radius) {
+ gl_FragColor = vec4(0.5, center, 1.0);
+ return;
+ }
+ }
+
+ gl_FragColor = vec4(0.1, 0.1, 0.1, 1.0);
+ }
+ `;
+}
+
+function createLineFragmentShader() {
+ return `
+ precision mediump float;
+
+ void main(void) {
+ gl_FragColor = vec4(1.0, 1.0, 1.0, 1.0);
+ }
+ `;
+}
+
+async function run() {
+ const canvas = document.getElementById("tsp-canvas");
+ const pointCountInput = document.getElementById("pointCountInput");
+ const applyPointCountButton = document.getElementById("applyPointCountButton");
+ const pointCountStatus = document.getElementById("pointCountStatus");
+
+ if (!canvas || !pointCountInput || !applyPointCountButton || !pointCountStatus) {
+ return;
+ }
+
+ const gl = canvas.getContext("webgl2");
+ if (!gl) {
+ pointCountStatus.textContent = "WebGL2 unavailable in this browser.";
+ return;
+ }
+
+ gl.clearColor(0.1, 0.1, 0.1, 1);
+
+ const {
+ initialisePoints,
+ getPointOrder,
+ memory
+ } = await initWasm();
+
+ const vertexCode = `
+ attribute vec2 a_position;
+ void main(void) {
+ gl_Position = vec4(a_position, 0.0, 1.0);
+ }
+`;
+
+ setupCanvas(gl, canvas);
+
+ const positionBuffer = gl.createBuffer();
+ const lineBuffer = gl.createBuffer();
+
+ if (!positionBuffer || !lineBuffer) {
+ pointCountStatus.textContent = "Unable to initialise WebGL buffers.";
+ return;
+ }
+
+ gl.bindBuffer(gl.ARRAY_BUFFER, positionBuffer);
+ const positions = [-1, -1, 3, -1, -1, 3];
+ gl.bufferData(gl.ARRAY_BUFFER, new Float32Array(positions), gl.STATIC_DRAW);
+
+ const state = {
+ scene: null,
+ draggingIndex: -1
+ };
+
+ function updateStatus(pointCount) {
+ pointCountStatus.textContent = `${pointCount} point${pointCount === 1 ? "" : "s"}`;
+ }
+
+ function recalculateRoute(scene) {
+ const pointOrderPtr = getPointOrder(scene.pointsPtr, scene.pointCount);
+ scene.pointOrder = Array.from(new Uint32Array(memory.buffer, pointOrderPtr, scene.pointCount));
+ }
+
+ function syncPointsToMemory(scene) {
+ scene.pointArray = new Float32Array(memory.buffer, scene.pointsPtr, scene.pointCount * 2);
+
+ for (let i = 0; i < scene.points.length; i++) {
+ scene.pointArray[i * 2] = scene.points[i].cx;
+ scene.pointArray[i * 2 + 1] = scene.points[i].cy;
+ }
+ }
+
+ function initialiseScene(rawPointCount) {
+ const pointCount = clampPointCount(rawPointCount);
+
+ if (state.scene) {
+ gl.deleteProgram(state.scene.program);
+ gl.deleteProgram(state.scene.lineProgram);
+ }
+
+ const pointsPtr = initialisePoints(pointCount, Date.now());
+ const pointArray = new Float32Array(memory.buffer, pointsPtr, pointCount * 2);
+ const points = initPoints(pointArray);
+ const program = createShaderProgram(gl, vertexCode, createPointFragmentShader(pointCount));
+ const lineProgram = createShaderProgram(gl, vertexCode, createLineFragmentShader());
+
+ state.scene = {
+ pointCount,
+ pointsPtr,
+ pointArray,
+ points,
+ pointOrder: [],
+ program,
+ lineProgram
+ };
+
+ recalculateRoute(state.scene);
+ resizeCanvas(gl, canvas, program);
+ updateStatus(pointCount);
+ pointCountInput.value = String(pointCount);
+ }
+
+ canvas.addEventListener("mousedown", (e) => {
+ if (!state.scene) {
+ return;
+ }
+
+ const rect = canvas.getBoundingClientRect();
+ const x = ((e.clientX - rect.left) / rect.width) * 2 - 1;
+ const y = 1 - ((e.clientY - rect.top) / rect.height) * 2;
+
+ for (let i = 0; i < state.scene.points.length; i++) {
+ const point = state.scene.points[i];
+ const dx = (0.5 * x + 0.5) - point.cx;
+ const dy = (0.5 * y + 0.5) - point.cy;
+ const distance = Math.hypot(dx, dy);
+
+ if (distance <= RADIUS_SIZE) {
+ state.draggingIndex = i;
+ break;
+ }
+ }
+ });
+
+ canvas.addEventListener("mousemove", (e) => {
+ if (!state.scene || state.draggingIndex === -1) {
+ return;
+ }
+
+ const rect = canvas.getBoundingClientRect();
+ state.scene.points[state.draggingIndex].cx = (e.clientX - rect.left) / rect.width;
+ state.scene.points[state.draggingIndex].cy = 1 - (e.clientY - rect.top) / rect.height;
+
+ syncPointsToMemory(state.scene);
+ recalculateRoute(state.scene);
+ });
+
+ function stopDragging() {
+ state.draggingIndex = -1;
+ }
+
+ canvas.addEventListener("mouseup", stopDragging);
+ canvas.addEventListener("mouseleave", stopDragging);
+
+ function applyPointCount() {
+ const nextPointCount = clampPointCount(Number.parseInt(pointCountInput.value, 10));
+ initialiseScene(nextPointCount);
+ }
+
+ applyPointCountButton.addEventListener("click", applyPointCount);
+ pointCountInput.addEventListener("keydown", (event) => {
+ if (event.key === "Enter") {
+ applyPointCount();
+ }
+ });
+
+ pointCountInput.addEventListener("blur", () => {
+ pointCountInput.value = String(clampPointCount(Number.parseInt(pointCountInput.value, 10)));
+ });
+
+ window.addEventListener("resize", () => {
+ if (!state.scene) {
+ return;
+ }
+
+ resizeCanvas(gl, canvas, state.scene.program);
+ });
+
+ initialiseScene(DEFAULT_POINT_COUNT);
+
+ function render() {
+ if (state.scene) {
+ draw(
+ gl,
+ state.scene.points,
+ state.scene.program,
+ state.scene.lineProgram,
+ positionBuffer,
+ lineBuffer,
+ state.scene.pointOrder,
+ 1
+ );
+ }
+
+ requestAnimationFrame(render);
+ }
+
+ render();
+}
+
+await run();
diff --git a/projects/tsp/main.wasm b/projects/tsp/main.wasm
new file mode 100755
index 0000000..25a2a22
Binary files /dev/null and b/projects/tsp/main.wasm differ
diff --git a/projects/tsp/point.js b/projects/tsp/point.js
new file mode 100644
index 0000000..7e6147e
--- /dev/null
+++ b/projects/tsp/point.js
@@ -0,0 +1,6 @@
+class Point {
+ constructor(x, y) {
+ this.cx = x;
+ this.cy = y;
+ }
+}
diff --git a/projects/tsp/shaders.js b/projects/tsp/shaders.js
new file mode 100644
index 0000000..704e2d3
--- /dev/null
+++ b/projects/tsp/shaders.js
@@ -0,0 +1,20 @@
+// shaders.js
+export function loadShader(gl, type, source) {
+ const shader = gl.createShader(type);
+ gl.shaderSource(shader, source);
+ gl.compileShader(shader);
+ return shader;
+}
+
+export function createShaderProgram(gl, vertexCode, fragCode) {
+ const vertexShader = loadShader(gl, gl.VERTEX_SHADER, vertexCode);
+ const fragmentShader = loadShader(gl, gl.FRAGMENT_SHADER, fragCode);
+
+ const program = gl.createProgram();
+ gl.attachShader(program, vertexShader);
+ gl.attachShader(program, fragmentShader);
+ gl.linkProgram(program);
+
+ return program;
+}
+
diff --git a/projects/tsp/style.css b/projects/tsp/style.css
new file mode 100644
index 0000000..5582b95
--- /dev/null
+++ b/projects/tsp/style.css
@@ -0,0 +1,202 @@
+* {
+ margin: 0;
+ padding: 0;
+ box-sizing: border-box;
+}
+
+body {
+ background: white;
+ color: black;
+ font-family: Arial, sans-serif;
+ font-size: 16px;
+ line-height: 1.6;
+ padding: 20px;
+}
+
+.container {
+ max-width: 900px;
+ margin: 0 auto;
+}
+
+header {
+ text-align: center;
+ margin-bottom: 30px;
+ border-bottom: 1px solid #ccc;
+ padding-bottom: 20px;
+}
+
+h1 {
+ font-size: 2.5rem;
+ margin-bottom: 10px;
+}
+
+h2 {
+ font-size: 1.8rem;
+ margin: 20px 0 15px;
+}
+
+h3 {
+ font-size: 1.3rem;
+ margin: 15px 0;
+}
+
+p {
+ margin-bottom: 15px;
+ color: #333;
+}
+
+.subtitle {
+ color: #666;
+}
+
+.back-button {
+ display: inline-block;
+ margin-bottom: 20px;
+ padding: 10px 15px;
+ background: #f0f0f0;
+ color: black;
+ text-decoration: none;
+ border: 1px solid #ccc;
+ border-radius: 4px;
+}
+
+.back-button:hover,
+.action-button:hover {
+ background: #e0e0e0;
+}
+
+.info-box {
+ background: #f9f9f9;
+ border-left: 4px solid #333;
+ padding: 15px;
+ margin: 20px 0;
+}
+
+.canvas-container {
+ background: #f9f9f9;
+ border: 1px solid #ddd;
+ border-radius: 4px;
+ padding: 20px;
+ margin: 30px 0;
+}
+
+.canvas-toolbar {
+ display: flex;
+ justify-content: space-between;
+ align-items: center;
+ gap: 12px;
+ margin-bottom: 15px;
+ flex-wrap: wrap;
+}
+
+.canvas-toolbar p {
+ margin: 0;
+ color: #666;
+ font-size: 0.95rem;
+}
+
+.control-group {
+ display: flex;
+ align-items: center;
+ gap: 10px;
+ flex-wrap: wrap;
+}
+
+.control-group label {
+ color: #555;
+ font-size: 0.95rem;
+}
+
+.control-group input {
+ width: 90px;
+ padding: 8px 10px;
+ border: 1px solid #ccc;
+ border-radius: 4px;
+ font: inherit;
+ background: white;
+ color: black;
+}
+
+.action-button {
+ padding: 8px 12px;
+ background: #f0f0f0;
+ color: black;
+ border: 1px solid #ccc;
+ border-radius: 4px;
+ cursor: pointer;
+ font: inherit;
+}
+
+.canvas-shell {
+ display: flex;
+ justify-content: center;
+ align-items: center;
+ min-height: 500px;
+ overflow: auto;
+ background: white;
+ border: 1px solid #ddd;
+}
+
+#tsp-canvas {
+ display: block;
+ margin: 0 auto;
+ width: 100%;
+ height: 560px;
+ max-width: 100%;
+ background: black;
+ outline: none;
+ image-rendering: crisp-edges;
+}
+
+.status-row {
+ display: flex;
+ justify-content: space-between;
+ align-items: center;
+ gap: 12px;
+ flex-wrap: wrap;
+ margin-top: 15px;
+}
+
+.status {
+ min-height: 24px;
+ color: #666;
+ margin: 0;
+}
+
+.canvas-note {
+ margin: 0;
+ color: #666;
+ font-size: 0.95rem;
+}
+
+footer {
+ text-align: center;
+ margin-top: 40px;
+ padding-top: 20px;
+ border-top: 1px solid #ccc;
+ font-size: 0.85rem;
+ color: #666;
+}
+
+@media (max-width: 768px) {
+ body {
+ padding: 16px;
+ }
+
+ h1 {
+ font-size: 2rem;
+ }
+
+ .canvas-container {
+ padding: 15px;
+ }
+
+ .canvas-shell {
+ min-height: 360px;
+ }
+
+ #tsp-canvas {
+ height: 400px;
+ }
+}
+
diff --git a/projects/tsp/tsp-gl.js b/projects/tsp/tsp-gl.js
new file mode 100644
index 0000000..02ae3b5
--- /dev/null
+++ b/projects/tsp/tsp-gl.js
@@ -0,0 +1,46 @@
+// tsp-gl.js
+import { updateLines } from "./utils.js";
+
+export function draw(gl, points, program, lineProgram, positionBuffer, lineBuffer, pointOrder, aspectRatio) {
+ gl.clear(gl.COLOR_BUFFER_BIT);
+
+ // Draw Circles
+ updateCircleUniforms(gl, points, program, positionBuffer);
+
+ // Draw Lines
+ const lineVertices = updateLines(points, pointOrder);
+ drawLines(gl, lineProgram, lineBuffer, lineVertices);
+}
+
+function updateCircleUniforms(gl, points, program, positionBuffer) {
+ const centerArray = new Float32Array(points.length * 2);
+
+ points.forEach((point, i) => {
+ centerArray[i * 2] = point.cx;
+ centerArray[i * 2 + 1] = point.cy;
+ });
+
+ gl.useProgram(program);
+
+ gl.uniform2fv(gl.getUniformLocation(program, "u_centers"), centerArray);
+
+ gl.bindBuffer(gl.ARRAY_BUFFER, positionBuffer);
+ const positionAttribLoc = gl.getAttribLocation(program, "a_position");
+ gl.enableVertexAttribArray(positionAttribLoc);
+ gl.vertexAttribPointer(positionAttribLoc, 2, gl.FLOAT, false, 0, 0);
+
+ gl.drawArrays(gl.TRIANGLE_STRIP, 0, 3);
+}
+
+function drawLines(gl, program, buffer, lineVertices) {
+ gl.useProgram(program);
+
+ gl.bindBuffer(gl.ARRAY_BUFFER, buffer);
+ gl.bufferData(gl.ARRAY_BUFFER, lineVertices, gl.DYNAMIC_DRAW);
+
+ const positionAttribLoc = gl.getAttribLocation(program, "a_position");
+ gl.enableVertexAttribArray(positionAttribLoc);
+ gl.vertexAttribPointer(positionAttribLoc, 2, gl.FLOAT, false, 0, 0);
+
+ gl.drawArrays(gl.LINES, 0, lineVertices.length / 2);
+}
diff --git a/projects/tsp/utils.js b/projects/tsp/utils.js
new file mode 100644
index 0000000..192f952
--- /dev/null
+++ b/projects/tsp/utils.js
@@ -0,0 +1,19 @@
+// utils.js
+export function initPoints(points) {
+ let cs = [];
+ for (let i = 0; i < points.length; i += 2) {
+ cs.push(new Point(points[i], points[i + 1]));
+ }
+ return cs;
+}
+
+export function updateLines(points, pointOrder) {
+ const lineVertices = [];
+ for (let i = 0; i < pointOrder.length - 1; i++) {
+ const j = pointOrder[i];
+ const k = pointOrder[i + 1];
+ lineVertices.push(points[j].cx * 2 - 1, points[j].cy * 2 - 1);
+ lineVertices.push(points[k].cx * 2 - 1, points[k].cy * 2 - 1);
+ }
+ return new Float32Array(lineVertices);
+}
diff --git a/projects/tsp/wasm.js b/projects/tsp/wasm.js
new file mode 100644
index 0000000..5d969e6
--- /dev/null
+++ b/projects/tsp/wasm.js
@@ -0,0 +1,24 @@
+export async function initWasm() {
+
+ let wasmMemory = new WebAssembly.Memory({
+ initial: 256,
+ maximum: 256
+ });
+
+ let importObject = {
+ env: {
+ memory: wasmMemory,
+ }
+ };
+
+ const response = await fetch("./main.wasm");
+ const wasmModule = await WebAssembly.instantiateStreaming(response, importObject);
+ const { initialisePoints, getPointOrder, memory } = wasmModule.instance.exports;
+
+ return {
+ initialisePoints,
+ getPointOrder,
+ memory,
+ wasmMemory
+ }
+}