Files
sokoban/docs/system-architecture.md
T
tiennm99 8a3d4b4a9d feat!: rewrite on Svelte 5, drop Phaser
Replace Phaser 3 with Svelte 5 as the rendering and UI layer. The
framework-agnostic core (level parser, board model, progress store,
microban level data) moves from src/game/core → src/lib/core with zero
code changes. Scenes and the hand-rolled button factory are gone; in
their place:

- src/App.svelte            root router (menu / levels / game)
- src/views/MenuView        title + play + progress + hints
- src/views/LevelSelectView paginated 5x4 grid with native <button>s
- src/views/GameView        owns BoardModel, handles input, HUD, win
- src/views/Board           purely presentational DOM renderer
- src/views/AppButton       shared themed wrapper for native <button>
- src/app.css               Nord palette ported to CSS variables

GameView uses a non-reactive BoardModel ref and syncs plain snapshot
fields (player, boxes, moves, won) into $state after every mutation —
Board consumes only plain props, so Svelte reactivity stays predictable
and the core class stays framework-agnostic. GameView is keyed on
levelIndex in App, so changing level remounts with fresh state.

Native <button> everywhere kills the click-hitbox class of bugs.
Animations are now CSS transform transitions (110ms) instead of tweens.

Bundle shrinks from ~1.5 MB Phaser to ~65 kB JS / 23 kB gzipped — about
60x smaller. Removed: phaser, terser, src/game, log.js (analytics
ping), phasermsg vite plugin, manual Phaser chunks, terser config,
public/style.css. Scripts simplified to dev/build.

Docs updated: codebase summary, architecture, code standards,
changelog, roadmap, README.
2026-04-12 00:50:46 +07:00

4.4 KiB

System Architecture

High-level

Single-page static site. No backend. Svelte 5 renders the UI and the game board; Vite bundles everything into a ~25 kB gzipped static site deployed to GitHub Pages. Progress persists in localStorage.

 index.html ──▶ src/main.js ──▶ App.svelte (router)
                                    │
                                    ├── MenuView
                                    ├── LevelSelectView
                                    └── GameView   ── keyed on levelIndex
                                         │
                                         ├── parseLevel(XSB)   ── lib/core/level-parser.js
                                         ├── new BoardModel(level) ── lib/core/board-model.js
                                         ├── Board.svelte      ── plain-prop DOM renderer
                                         └── progressStore     ── lib/core/progress-store.js
                                                                     │
                                                                     └── localStorage

View routing

App.svelte holds two pieces of state: view ('menu' | 'levels' | 'game') and levelIndex. It swaps view components with {#if/else if/else}. GameView is wrapped in {#key levelIndex} so changing the level unmounts and remounts the component with fresh state — no manual reset logic needed.

Reactivity model in GameView

  • model is a non-reactive reference to a BoardModel instance. It's mutated internally by tryMove / undo and reassigned on restart.
  • player, boxes, moves, won, parseError, tileSize are reactive ($state). Every move ends with syncFromModel() which reassigns them from the current model state.
  • best and hasNext are $derived from levelIndex.
  • Board receives only plain props — it has no awareness of the model class, which keeps reactivity predictable and Board fully presentational.

Why this split? A class instance doesn't play nicely with Svelte 5's $state deep-proxy semantics when methods mutate this internally. Driving re-renders through explicit snapshot reassignments is clearer, easier to debug, and keeps the core modules framework-agnostic.

Input → state → render

keydown ─▶ GameView.onKey (repeat-gated at 130ms)
                │
                ├── Escape → onLevels()
                ├── R      → restart() → new BoardModel(level) → syncFromModel()
                ├── U / Z  → undo() → model.undo() → syncFromModel()
                └── Arrow/WASD → tryMove(dx, dy) → model.tryMove() → syncFromModel()
                                                                │
                                                                └── if solved:
                                                                     ├── won = true
                                                                     └── progressStore.recordCompletion()

Board rendering

Board.svelte is a single <div class="board"> with absolutely-positioned children:

  • Floor tiles and walls are rendered once from the Set props. Walls that don't border any floor tile are skipped so the dead outer border of the XSB grid doesn't render.
  • Targets are drawn as circles with a ::after pseudo-element, z-indexed behind boxes.
  • Boxes and the player use transform: translate(Xpx, Ypx) with a 110 ms transition: transform ease. Moving them is a single style reassignment; the browser animates for free.
  • A --tile CSS variable drives all sizing.

Responsive sizing

GameView.computeTileSize() reads window.innerWidth / innerHeight, subtracts margins, divides by level dimensions, and clamps to [16 px, 56 px]. A resize listener updates tileSize live so the board re-layouts when the window changes.

Persistence schema

localStorage['sokoban-progress-v1']:

{
  "completed": { "0": true, "3": true, ... },
  "bestMoves": { "0": 14,   "3": 27,   ... }
}

Keys are 0-based level indices. Values are booleans / numbers. Wrapped in try/catch so private-mode browsers don't explode.

Deployment

Static build via vite build --config vite/config.prod.mjs, base /sokoban/, output pushed to GitHub Pages. No server-side components. Bundle size ≈ 65 kB / 23 kB gzipped — down from ~1.5 MB in the Phaser version.