Sauna Ukko
An idle-management game about Finnish sauna culture, with every visual generated at runtime.
Overview
I built a browser-based sauna management game around procedural pixel art, worker traits and layered progression. The prototype combines a state-driven simulation with drag-and-drop interaction and local saves; development is currently paused.
- context
- Personal project
- status
- Paused prototype
- role
- Solo
- started
- Mar 2026
- setting
- Browser-based idle-game prototype with a public demo and source repository.
- TypeScript 5.9
- React 19.2
- Vite 7.3
- Tailwind CSS 4.2
- Zustand 5
- dnd-kit
Key decisions
- Generate appearance from palette layers and a seed
- Character visuals are composed from token grids and a deterministic generator, allowing palettes and appearance to vary without a sprite asset pipeline.
- Keep simulation logic separate from the view
- The tick engine takes game state and returns the next state; calculations and generators are distinct from the React components and store.
- Use the drag library's own hover state
- Hover-to-open follows dnd-kit's droppable isOver state, with a ref-held timer, instead of browser pointer-enter events captured during a drag.
Demonstrated results
- Appearance is reproducible for a fixed generator
- The same seed and draw order produce the same character appearance. The portfolio model ports a subset of the game's generator and pixel layers.
- Worker interactions received concrete bug fixes
- The history records fixes for hover selection, dead workers surviving resets, autorest moving dead workers and breeding parents leaving too early.
- The prototype can be played and inspected
- A public browser build and source repository expose the simulation, procedural art and progression systems directly.
Known limitations
- Simulation testing is manual
- There are no automated tests or CI in the reviewed game. The test-named formula script duplicates its calculation rather than testing the imported implementation.
- Some declared features and components are unfinished
- HeavyDrinker is displayed but has no gameplay effect, and unused components remain. The paused prototype should not imply those features are complete.
- Incompatible saves are reset
- The versioned persistence path can wipe an incompatible save and tell the player why. That is recovery by reset, not a lossless migration of progress.
The four currencies
| Where it comes from | What it pays for | |
|---|---|---|
| Löyly | Saunas running: a burst per water throw, then a stream each tick while the room steams | Saunas, The Spa, the Spirit Uuni, the Heritage Hut, and the bench, porch, stone and insulation upgrades |
| Sisu | An Ukko's death, priced from their lifetime Löyly by rarity and age | The draft board, and Draft Prestige |
| Henki | One per corpse forged at the Spirit Uuni | Altar tiers: +50 °C each, at ×1, ×5, ×10, ×20 construction cost |
| Maine | Ukkos in The Spa, Social / 10 per seat per tick |
A global multiplier on all Löyly, and two more village columns, to 30 |
Pixel art generated in code
The game's pixel art is authored in code rather than loaded from sprite sheets. The two tracked binary images are scaffold leftovers: the Vite favicon and an unreferenced React logo. Palette-token matrices describe the characters and buildings.
A Ukko's face is a 24 × 16 grid of single characters:
const grid = Array.from({ length: 24 }, () => Array(16).fill(null));
Each character is a token, not a colour. 0 is transparent, 1 is dark hair, l is light
hair, s/sl/sd/ss/sb are the five steps of a skin ramp, th/tw/tg/ts the towel
ramp. The resolution happens at paint time:
const resolve = (token) => {
if (token === 0) return null;
if (token === 1) return hair.H;
if (token === "l") return hair.Hl;
return tones[token] ?? null;
};
That indirection keeps the system small. FACE_BASE is 79 lines and serves all five skin
palettes, all seven hair colours and all six towel colours. None of those appear in the matrix.
Layers paint in a fixed order, each at its own offset:
| Layer | Rows | Source |
|---|---|---|
| Hair | 0 | HAIR_STYLES (five styles, 40 lines) |
| Face | +4 | FACE_BASE (79 lines) |
| Expression | 6, 7, 9 | EXPR (three single rows, not a block) |
| Body | +11 | BODY_NORMAL (237 lines) |
The expression layer is the detail worth noting: it is three separate rows at fixed indices, not a block that starts somewhere. The face block is only seven rows tall, so the brows and mouth sit inside it rather than being composable.
Reproducing appearance from a seed
The generator is splitmix32, a well-known 32-bit PRNG. Eight lines:
function splitmix32(seed: number) {
return () => {
seed |= 0;
seed = (seed + 0x9e3779b9) | 0;
let t = seed ^ (seed >>> 16);
t = Math.imul(t, 0x21f0aaad) | 0;
t ^= t >>> 15;
t = Math.imul(t, 0x735a2d97) | 0;
return ((t ^ (t >>> 15)) >>> 0) / 4294967296;
};
}
generateAppearance(seed) draws from it in a fixed order: skin, then hair (with bald
appended to the pool as a real candidate), then hair style, towel, build, then a single
rng() call whose value is compared against a chain of thresholds to pick one of ten
accessories.
The order is the fragile part. Swap the towel and the hair and every face in the game changes. They still look correct. Nothing fails. The widget above ports the function call for call for that reason. The order is the specification.
The same seed gives the same appearance for the same generator, palette data and draw order. It is enough to reproduce appearance, not the entire game save.
The face is decoration; the traits are the game
generateAppearance decides what a worker looks like. What it does comes from 17 traits
rolled from the same generator, continuing the stream past the appearance's draws:
{ trait: "HeatLover", weight: 15 },
{ trait: "IronLungs", weight: 12 },
{ trait: "JealousGhost", weight: 18 }, // Akka pool
Two separate pools: eight traits for Ukkos, nine for Akkas, rather than one list with a gender field on it. Weights are relative and unnormalised, so the table reads as "more likely than that" rather than as percentages. The roll is rejection sampling against a cumulative total, so a trait cannot appear twice. Several interact rather than stack independently.
JealousGhost is worth reading. It subtracts from the Löyly of everyone in the sauna when
another female is present. IronLungs is a hidden gate on the smoke sauna. HeavyDrinker
has a weight of 15, shows in the roster with an alcohol tolerance, and does nothing at all.
Drag-and-drop fights the library
One piece of this is genuinely awkward: hover-to-select. Dragging a worker over a building
should open that building. @dnd-kit's PointerSensor takes capture of the pointer when
a drag starts, so onPointerEnter never fires on whatever is underneath. The elements being
dragged over are, by definition, not receiving events.
The fix was to stop using browser events:
const handleBuildingHoverChange = useCallback((buildingId, isOver) => {
if (!isOver) {
// Only clear if we are leaving the building that actually started the timer
if (activeHoverIdRef.current === buildingId) {
clearTimeout(hoverTimeoutRef.current);
activeHoverIdRef.current = null;
}
return;
}
// Start 300ms timer to auto-select
}, []);
Three details, all of them load-bearing:
- Each droppable reports its own
isOverupward. - The timer lives in a
useRef, not component state. A state timer resets on every re-render, and crossing the grid re-renders constantly. - The leave handler checks the id before clearing. Without that, brushing past two buildings quickly cancels the first timer and selects the second.
The reasoning is written up in docs/technical/dnd-architecture.md: the pointer-capture
problem, the three-part fix, and why each part is needed.
Worker-state fixes
The recorded fixes include clearing the roster during a prestige burn, checking liveness before autorest moves a worker and keeping both breeding parents in the hut for the full operation. These are specific state-transition corrections, alongside the drag-hover fix above, rather than evidence of comprehensive testing.
Simulation and save behaviour
The simulation and the view are separate. tickEngine, calculations, the generators and the
visual builder are distinct modules. The tick engine takes a GameState and returns a new
one, importing the store's type rather than the store.
Three prestige layers run over those currencies. Each reset discards a different thing.
The Zustand persistence path is versioned. For an incompatible save it can reset the state and display a recovery explanation, rather than preserve all old progress. That behaviour needs to be considered separately from intentional prestige resets.
docs/ is 1,498 lines across fourteen files: the formulas the code implements, balance
tables, and a dated change log with thirteen entries over five days.
Testing is manual. No runner, no CI, and the one file that looks like a test re-implements the formula instead of importing it.
The history is granular rather than swept. Most commits name one trait or one bug:
Added trait: Melancholy: -50% Social, +50% Wisdom
Fixed the bug of dead ukkos staying after burnsauna
Fixed bug: autorest was missing check for dead ukkos before moving back to hot slot
Drag and drop logic is now working and consistent no matter where drag starts Interactive models
Explore the model below to inspect the implementation. This is a reconstruction from the project, not a live service.
Seeded character appearance and traits
UkkoForge
A seed, a face, forever — the game's PRNG and palette, reconstructed.
- skin
- deep
- hair
- grey · long
- towel
- black
- build
- stocky
- accessory
- beard
client:visible
Stack and source figures
how it fits together
- procedural art
-
-
splitmix32A 32-bit integer PRNG, seeded per worker. Every visual property comes from it in a fixed order, so a face is reproducible from one integer and nothing needs storing. The order is load-bearing: move one pick and every face in the game changes while still looking correct. -
Token gridsAvatars are 24 × 16 cells. Each holds a palette character, not a colour. Layers paint in order: hair at row 0, face at +4, expression at 6, 7 and 9, body at +11. A token resolves to a hex at paint time, so one matrix serves five skin palettes. -
No image filesTwo binary assets are tracked. Both are Vite and React scaffold logos. The rest is code: roughly 2,400 lines of hand-authored matrices for avatars, sauna buildings and the stove.
-
- simulation
-
-
One tick per secondrunGameTick() is 675 lines and drives everything. Heat ramps toward a target instead of snapping. A thrown bucket decays over 3 + insulation × 6 ticks, not a fixed number. -
Four currenciesLöyly (output), Sisu (prestige), Henki (from the dead), Maine (reputation). Three separate prestige layers burn for each, so a reset is a strategy choice, not a pause.
-
- state
-
-
Zustand with a versioned migrationPersisted to localStorage at version 2. An incompatible save is wiped and raises a flag that renders a recovery modal, rather than loading into a shape the code half understands.
-
- input
-
-
dnd-kitWorkers are dragged between the yard, the hot bench and the cooling slots. Buildings are not draggable. Placement is select-then-click, and the README claims otherwise.
-
- LOC
- 13,944 Across 59 TypeScript files, excluding the one hand-run script described below.
- commits
- 213 2026-03-09 to 2026-06-14. 231 of the 245 commits on all branches landed in the first three weeks.
- art assets
- 0 The only two tracked binary files are the Vite and React scaffold logos. Nothing references one of them.
- design docs
- 1,498 lines 14 markdown files, including balance tables and a dated change log.
- traits
- 17 Eight for Ukkos, nine for Akkas, in separate pools. Traits change how a worker behaves. The face is decoration.
- dead code
- ~2,700 lines About 19% of the source. A 609-line thermometer with a commented-out render site, and a folder named thrashfornow that nothing imports.
Full issue record · 8 entries
what broke, and what happened
- 4 fixed / 8
- 4 unresolved / 8
Every entry below says what broke and what became of it. An unresolved one is listed as unresolved rather than dropped.
- fixed
Dragging a worker over a building did not select it
Once a drag starts, dnd-kit's PointerSensor captures the pointer, so `onPointerEnter` never fires on whatever is underneath.
then: Stopped using browser enter events. Each droppable reports its own `isOver` state upward, and `VillageMap` runs a 300 ms timer in a `useRef` rather than component state, because a state timer resets on every re-render. The leave handler clears the timer only when the id matches the one that started it. Written up in `docs/technical/dnd-architecture.md`.
- fixed
Dead workers survived a prestige burn
Commit 39b06b9: "Fixed the bug of dead ukkos staying after burnsauna"
then: The burn now clears the roster, not just the resource, so a reset cannot inherit someone who was already dead.
- fixed
Autorest moved corpses back onto the hot bench
Commit a901f66: "Fixed bug: autorest was missing check for dead ukkos before moving back to hot slot"
then: Autorest checks liveness before moving a worker between the hot and cooling slots.
- fixed
Breeding could pull a parent out of the hut while their partner was still needed
Commit 11aba06: "Fixed the breeding bug, parents now forced to stay"
then: Both parents are pinned for the duration, and still age while they wait.
- unresolved
There are no automated tests
No `test` script in `frontend/package.json`, no runner dependency, no `.github/` directory. The one `sisuCalculator.test.ts` is a hand-run `console.log` script. It re-implements the formula locally, its own comment says "copied from calculations.ts", and it names `ts-node`, which is not a dependency.
then: Not fixed. A 675-line tick engine with no tests is the biggest gap in the project, and the file that looks like a test is why it is easy to miss.
- unresolved
HeavyDrinker is a declared trait that does nothing
`types/game.ts:38` says "(not yet implemented)". The fields `drunkenness` and `autoDrink` exist, get set at generation and get displayed. Nothing consumes them.
then: Not fixed. It rolls, it shows in the roster, and it changes nothing.
- unresolved
About 19% of the source is unreachable
`SaunaThermometer2.tsx` is 609 lines with its render site commented out in `SaunaCard.tsx`. Its `isRoundThermometer` toggle stays declared. `components/thrashfornow/` has three files and zero imports.
then: Not fixed.
- unresolved
The README documents a renderer and a component library the project does not use
It claims Phaser (not a dependency; appears only in `docs/GDD_Main.md` as a plan) and shadcn/ui (no radix, no cva, no `components/ui/`). It also says buildings are draggable, and `moveSauna` is never called.
then: The page you are reading does not repeat any of the three.