Ukodus: Building a Sudoku Galaxy in Rust and WebAssembly
Ukodus: Building a Sudoku Galaxy in Rust and WebAssembly
Every Sudoku app on iOS is annoying. Ads after every puzzle. Subscriptions to unlock “hard” mode. Hints that just fill in the answer without teaching you anything. I got tired of it, so I did what any reasonable person would do: spent months building an entire Sudoku platform from scratch.
The result is Ukodus — a Rust-powered Sudoku engine that runs natively on iOS, compiles to WebAssembly for the browser, and includes a force-directed galaxy visualization where every puzzle ever played becomes a star. Because apparently I can’t just solve a problem without also building a cosmos around it.
The Engine: 45 Techniques in Rust
The core of Ukodus is a Sudoku engine written in Rust. It implements 45 human-style solving techniques organized into 10 families:
- Singles — Hidden Single, Naked Single (the basics)
- Pairs & Triples — Naked Pair through Hidden Quad
- Intersections — Pointing Pairs, Box/Line Reduction
- Fish — X-Wing, Swordfish, Jellyfish, plus finned and mutant variants
- Wings — XY-Wing, XYZ-Wing, W-Wing, WXYZ-Wing
- Chains — X-Chain, 3D Medusa, AIC
- Rectangles — Unique Rectangles (6 types), Hidden Rectangle, Empty Rectangle
- ALS — Almost Locked Sets: ALS-XZ, ALS-XY-Wing, ALS Chain
- Forcing — Nishio, Cell/Region Forcing Chains, Dynamic Forcing Chains
- Other — Sue de Coq, Aligned Pair Exclusion, Death Blossom, BUG+1, Backtracking
Each technique has a Sudoku Explainer (SE) difficulty rating. Hidden Single is 1.5 (beginner territory), Dynamic Forcing Chain is 9.3 (you need a PhD or a lot of patience), and Backtracking sits at 11.0 as the last resort. The engine uses these ratings to classify every puzzle into difficulty tiers: Beginner, Easy, Medium, Intermediate, Hard, Expert, Master, and Extreme.
Why does this matter? Because most Sudoku apps rate puzzles by counting givens or using some vague internal metric. SE ratings map directly to the hardest technique you’d need to solve the puzzle. A puzzle rated 3.2 means you’ll need X-Wings. A 7.5 means ALS Chains or Nishio. You know exactly what you’re getting into.
The engine also generates puzzles with guaranteed unique solutions, rates them on generation, and encodes them as 8-character short codes for sharing. The same short code works across web, iOS, and terminal.
WASM for the Browser
The Rust engine compiles to WebAssembly via wasm-pack. The output is a 554KB .wasm binary and a JS glue module that exposes the SudokuGame class. The game renders to an HTML <canvas> — the Rust side owns all the drawing logic, which means the rendering is identical regardless of platform.
Loading WASM in a SvelteKit app requires some care. You can’t let Vite try to bundle the WASM module at build time, so the loader uses a dynamic import with a @vite-ignore pragma:
const wasmJsPath = '/wasm/sudoku_wasm.js';
const mod = await import(/* @vite-ignore */ wasmJsPath);
await mod.default({
module_or_path: new URL('/wasm/sudoku_wasm_bg.wasm', window.location.origin)
});
The WASM files live in static/wasm/ and get served as plain static assets. No Vite WASM plugin, no special bundler config. The /play/ route sets ssr = false so SvelteKit generates a minimal HTML shell that hydrates client-side — the WASM needs a browser environment with a canvas, so server-side rendering would just blow up.
The game loop is a standard requestAnimationFrame cycle. Every frame calls game.tick() on the WASM side, which handles input processing, animation, and canvas rendering. Keyboard events get forwarded from the Svelte component to the WASM engine via game.handle_key(event). The engine supports vim-style navigation (hjkl), arrow keys, and WASD — because every good application should support at least three ways to move a cursor.
The Galaxy
Here’s where things got a little unhinged.
I wanted a way to visualize all the puzzles that had been played. A leaderboard felt boring. A list felt worse. Then I thought: what if every puzzle was a star, and puzzles with similar techniques formed constellations?
The Galaxy page uses D3’s forceSimulation to create a force-directed graph. Each node is a puzzle, colored by difficulty tier (green for Beginner through near-black for Extreme). Node size scales with play count. Edges connect puzzles that share solving techniques, so similar puzzles cluster together.
The fun part is the convex hulls. D3 computes polygonHull for each technique family and draws translucent overlays around them, so you can see the Fish cluster, the Wings cluster, the Chains cluster. It looks like a star map. Which is the point.
simulation = d3
.forceSimulation<GalaxyNode>(nodes)
.force('link', d3.forceLink<GalaxyNode, GalaxyEdge>(edges)
.id(d => d.id).distance(60).strength(0.3))
.force('charge', d3.forceManyBody().strength(-80))
.force('center', d3.forceCenter(width / 2, height / 2))
.force('collide', d3.forceCollide<GalaxyNode>()
.radius(d => nodeRadius(d) + 2))
.alphaDecay(0.02)
.on('tick', ticked);
The Galaxy also has a live component. When someone completes a puzzle, a WebSocket message pushes the new node into the simulation in real-time. You can literally watch the galaxy grow. The WebSocket connects to /api/v1/ws/galaxy through an Nginx proxy that keeps the connection alive for up to 24 hours:
location /api/v1/ws/ {
proxy_pass $api_upstream;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
proxy_read_timeout 86400s;
}
There’s also a “secrets” system. By default, you only see 22 of the 45 techniques and 6 of the 10 families. The advanced families — Chains, ALS, Forcing, and Other — are hidden until you unlock them by completing harder puzzles. When you unlock secrets, the Galaxy reveals entire new constellations that were invisible before. It’s my favorite feature and probably the most unnecessary one.
One Engine, Many Targets
The beauty of writing the core in Rust is that the same engine works everywhere:
- Browser: Compiled to WASM, loaded in SvelteKit, renders to canvas
- iOS: Compiled natively via Xcode, same Rust core, native UI shell
- Terminal: Rust binary with a TUI interface
- Shared codes: 8-character short codes and 81-character puzzle strings work across all platforms
You can start a puzzle on iOS, share the code, and your friend can play the exact same puzzle in a browser. The engine deterministically generates the same puzzle from the same seed, so there’s no server round-trip needed to decode a shared puzzle.
SvelteKit 5: Runes and Static Generation
The web frontend is SvelteKit 5 (Svelte 5.49) with TypeScript. The entire state management layer uses Svelte 5 runes — class-based stores with $state, $derived, and $effect in .svelte.ts files:
class PlayerStore {
id = $state('');
tag = $state('');
secrets = $state(false);
setTag(value: string) {
this.tag = value;
localStorage.setItem(PLAYER_TAG_KEY, value);
}
}
export const playerStore = new PlayerStore();
The app uses adapter-static with prerender = true and trailingSlash = 'always'. Content pages (home, about, techniques, difficulty, privacy, how-to-play, app) get fully pre-rendered to static HTML at build time. Interactive pages (/play/ and /galaxy/) set ssr = false because they need browser APIs — canvas for the game, D3 DOM manipulation for the galaxy.
This hybrid approach means content pages load instantly as static HTML while the interactive pages get a lightweight shell that hydrates client-side. The build output is a directory of plain HTML, CSS, and JS files that any web server can serve. No Node.js runtime needed in production.
The previous frontend was 9 static HTML files with ~2,100 lines of inline JavaScript. The SvelteKit rewrite gave us proper component architecture, shared layouts (no more duplicated header and footer across 9 files), reactive state management, and PostHog analytics integration — all while maintaining the same zero-runtime deployment model.
Deployment: Kubernetes on a Homelab
Yes, I’m running a Sudoku game on Kubernetes. I know. The cluster runs on Talos Linux on Raspberry Pi 5s connected via 2Gbps LACP-bonded networking on a Turing Pi board. The dev environment is NixOS because of course it is.
The frontend build is a multi-stage Docker image:
FROM node:22-alpine AS build
WORKDIR /app
COPY frontend/package.json frontend/package-lock.json* ./
RUN npm ci
COPY frontend/ .
RUN npm run build
FROM nginx:1-alpine
COPY --from=build /app/build /usr/share/nginx/html
Stage one builds the SvelteKit static site (including WASM assets). Stage two drops the output into Nginx Alpine. The final image is tiny — just Nginx serving static files.
The Nginx config handles the caching strategy:
| Asset Type | Browser Cache | Edge Cache |
|---|---|---|
/_app/ (hashed SvelteKit assets) | 1 year, immutable | 1 year |
/assets/ (images, icons) | 30 days, immutable | 30 days |
/wasm/ | 1 hour, must-revalidate | 24 hours |
| Pages | 60 seconds | 5 minutes, stale-while-revalidate |
/api/ | no-store | no-store |
Hashed assets get immutable caching because the hash changes on every build. WASM gets shorter caching because I might update the engine without changing the filename. API responses are never cached. Pages get short browser caches with longer edge caches and stale-while-revalidate so users see content immediately while the cache refreshes in the background.
The API backend runs as a separate K8s service (api.ukodus.svc.cluster.local:3000) that Nginx proxies to via Kubernetes DNS. Security headers (HSTS, X-Content-Type-Options, X-Frame-Options, Referrer-Policy) are set globally, and the WASM location block adds Cross-Origin-Embedder-Policy and Cross-Origin-Opener-Policy for SharedArrayBuffer compatibility.
SvelteKit’s adapter-static generates pre-compressed .br and .gz files at build time. Nginx serves these directly with gzip_static on, so there’s zero CPU overhead for compression at request time.
What’s Next
The engine still has room for more techniques. There are some fish variants and chain patterns I haven’t implemented yet. The iOS app needs feature parity with the web version’s galaxy view. And I keep thinking about adding a puzzle-of-the-day feature with global leaderboards.
But for now, it’s a Sudoku app that doesn’t have ads, doesn’t require a subscription, and teaches you actual solving techniques instead of just filling in answers. Which is all I wanted in the first place.
Play it: ukodus.now Source: github.com/kcirtapfromspace/sudoku-core iOS App: App Store