Skip to content
2026

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.
built with
  • 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 isOver upward.
  • 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.

FragileHeatLover
expression
skin
deep
hair
grey · long
towel
black
build
stocky
accessory
beard
src/content/data/ukko.ts · ported from the game's appearanceData.ts

client:visible

The game's own generator and a subset of its pixel layers, reconstructed. Two of the eight expressions and one of the two body types are not ported.
Stack and source figures

how it fits together

procedural art
  • splitmix32 A 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 grids Avatars 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 files Two 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 second runGameTick() 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 currencies Lö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 migration Persisted 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-kit Workers 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.

  1. 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`.

  2. 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.

  3. 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.

  4. 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.

  5. 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.

  6. 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.

  7. 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.

  8. 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.