Imported from Yang-Yiming/TSokoban (
AGENTS.md). Install upstream withnpx skills add Yang-Yiming/TSokoban. Copyright stays with the author.
AGENTS.md
This file provides guidance to AI coding agents (opencode, Claude Code, etc.) when working with code in this repository.
Project Overview
TSokoban is a browser-based Sokoban puzzle game rebuilt from a JavaFX version, written in TypeScript with zero runtime dependencies. It uses canvas rendering, supports predefined and procedurally generated levels, LAN multiplayer, and persists progress via localStorage.
Commands
bun install— install dependenciesbun run dev— start Vite dev serverbun run build— type-check withtscthen build with Vite (multiplayer enabled)bun run build:single— single-player-only build (MULTIPLAYER=0); the multiplayer mode buttons compile down to a "run the local server" reminder dialog. Cloudflare Pages uses this build command.bun run preview— preview production buildbun run server— start the LAN multiplayer relay + static host fordist/(port 8787, prints join URLs; runbun run buildfirst)bun run test:mp— server protocol smoke test (scripts/smoke_mp.ts, spawns the server on :8791)
There are no lint commands. Type checking is done via tsc (strict mode with noUnusedLocals/noUnusedParameters, verbatimModuleSyntax — use import type for type-only imports, erasableSyntaxOnly — no enums/namespaces). tsconfig.json only includes src/; server/ is plain Bun TypeScript, not typechecked by tsc.
Architecture
Entry point: src/main.ts → instantiates Menu which owns the top-level UI.
Core modules
-
src/game/— game logic, exported as a barrel module viaindex.tsSokobanMap— game state using a bitmask tile system (WALL=1, BOX=2, PLAYER=4, GOAL=8); tiles combine via bitwise ORGameController— orchestrates input handling, move queue/animation, UI updates; tracks all event listeners for cleanup on destroy. Also hosts the multiplayer level mode (MpContext,loadMultiplayerLevel, peer-cat tracking)GameScene— dual-canvas renderer (map canvas + UI overlay) with a camera system that follows the player and supports drag-to-pan; renders a second (peer) cat viaCatRenderState/peerImgwith CSS-filter tintingAStarSolver— facade overAStarSolverV2(≤5 boxes, Manhattan heuristic) andAStarSolverF2(>5 boxes, BFS push-distance tables, weighted A*); used for hints, step-limit calibration, and puzzle validationLevelSelect— world map with chunk-based terrain generation, A* pathfinding, and biome-aware rendering; in multiplayer also renders the peer cat and runs the level-entry/ready-check UIbiomes— biome system defining 4 terrain types (grassland, lake, highlands, dark forest) with per-biome colors, water/rock densities, sprites, and boundary blending via 120×120 tile unitspuzzleGenerator— seed-based procedural level generation (reverse-play + A* validation)mapData— predefined level definitions +SPECIAL_LEVEL_LIBRARYtypes—Coordinate,TileType,TILE_MASK,Equipment
-
src/net/— multiplayer client layerprotocol.ts— message types (transport + game payloads),LevelRef(handcrafted/special/generated reference),WorldFlags,PEER_TINT(CSS filter for the guest cat)NetClient.ts— thin WebSocket wrapper for the/roomendpoint (same origin as the page)MultiplayerSession.ts— one room session: roles (host/guest), peer overworld state, the ready-check handshake FSM,computeSpawns(BFS for the guest's connected spawn tile), and payload routing via public callback fields
-
server/room.ts— Bun WebSocket relay + static file server fordist/. Intentionally a "dumb relay": it only manages room membership (max 2 players) and broadcasts opaque payloads. Host leaving dissolves the room. All game logic lives in the clients because everything is deterministic from a seed (see Multiplayer design). -
src/menu.ts— main menu with animated elements (clouds, box, cat), mode select (经典模式 / 创建房间 / 加入房间, iconschoice1-3.png), the character/world selection dialogs (Terraria-style), create/join room dialogs, and?room=XXXXdirect-link auto-join (character select → join) -
src/save.ts— the v2 save split (oldtsokoban_progresskey abandoned, no migration):characterManager— player save: multiple characters{id, name, fishCount, itemCounts, equipment, discoveredStructures}intsokoban_characters; all item/equipment/fish mutations operate on the active character (chosen at menu time); per-character export/import codes (base64) because localStorage is origin-scoped and the LAN host's IP is part of the originworldManager— world save: a world IS a seed;tsokoban_world:<seed>holds{completedLevels, chestOpened, completedGeneratedLevels, lastPlayedAt}.setActiveSeed()on world entry;setMirror(flags)puts it in guest mode (reads from the host's snapshot, writes no-op);applyDelta()applies hostworldUpdatedeltas to the mirror
-
src/settings.ts—SettingsManagerwith listener-based reactivity (move duration, volume, A* toggle, map seed — the seed doubles as a world switcher and is kept in sync withworldManagerby the flows) -
src/theme.ts—ThemeManagerwith 6 predefined themes (RGB color sets), listener-based reactivity -
src/ui/— dialog components (settings dialog, theme dialog, genericcreateDialogwith optionalonClose) -
src/utils.ts— seeded PRNG (myRand— all procedural decoration derives from it), color utilities
Multiplayer design (v1)
- Determinism is the core trick: world chunks (seed + cellular automata), handcrafted/special levels (static data), and generated puzzles (
generatePuzzle(worldX, worldY, seed, difficulty)) are all byte-identical on every client. The server never syncs game state — only player intents (pos,move,enter,exit,readyReq…). - Roles: host = room creator.
levelStartis always sent by the host; both sides enter the level upon receiving/sending it. The guest's cat wears the tint on both screens. - Level entry: solo entry broadcasts
enter(the peer renders that cat standing on the tile); both cats on the same tile + Enter triggers the ready-check handshake (确认 1/2, 15 s timeout, walking away cancels). Shared spawns: host at the map's default player tile, guest at the nearest connected free tile (overlap fallback). - In-level: the peer cat is tracked outside
SokobanMap(GameController.peerPos) — remote moves apply optimistically and are validated against walls/boxes. Rules: step limit = optimalSteps + 25 with a shared counter, undo/hint disabled (plus works), A* deadlock check off (corner deadlock still loses). - World vs player state (Terraria split): the host's world save is the world truth. On join the host sends a full
WorldFlagssnapshot (guest enters mirror mode viaworldManager.setMirror); afterwards the host broadcastsworldUpdatedeltas (level/chest/generated completed) and the guest applies them to the mirror without ever persisting them locally. Fish/items/equipment stay personal (active character). Guests visiting the same seed solo later see their own un-beaten world — by design. - Character/world selection: 经典模式 = pick character → pick world → play. 创建房间 = pick character → pick world (room seed = world seed) → host.
?room=link = pick character (can create) → join.tsokoban_lastremembers the last picks for preselecting. - Disconnect = session void: host-left / peer-left / connection-lost all route to
Menu.handleMpDisconnect, which tears down whatever is on screen and returns to the menu. - Callback ownership:
MultiplayerSessionexposes public callback fields. Menu ownsonLevelStart/onJoined/onPeerJoined/onDisconnected; LevelSelect ownsonPeerWorldUpdate/ready prompts; GameController ownsonPeerMove/onPeerWin/onPeerRestart/onPeerExitLevel. The active screen assigns them;destroy()resets them to no-ops. - Build flag:
__MULTIPLAYER__(Vitedefine, setMULTIPLAYER=0for single builds). Dead branches at call sites are eliminated by the bundler; class methods stay in the bundle but are unreachable. - Known v1 limitations: no desync repair if both players push the same box in the same frame (LAN-latency rare); no reconnect (disconnect voids the session); star rating uses the combined step count.
Key patterns
- Observer/listener pattern:
SettingsManager,ThemeManager, andProgressManagerall use callback subscriptions for reactive updates. - Bitmask tiles: tile state is composed with bitwise ops (e.g. a box on a goal =
BOX | GOAL). Check tile properties with& TILE_MASK.X. - Animation queue: moves are queued so rapid input doesn't drop commands; animations are throttled by the
moveAnimDurationsetting. - Event cleanup:
GameControllerregisters all DOM listeners in a tracked list and removes them ondestroy()to prevent leaks when switching between menu and game. - Deterministic decoration: all grass/flower/butterfly placement derives from
myRand(cell coords, salt)— no stored decoration state.
Reward system
- 小鱼干 (dried fish): lightweight currency earned by completing generated levels. Drop formula: base 1 + 1 if 3-star + 1 if difficulty ≥ 4 (range 1–3 per level). Stored in
Progress.fishCount. In multiplayer both clients settle rewards locally. - Generated level completion: tracked via
Progress.completedGeneratedLevels(keys:"worldX,worldY"). Completed levels render asDECORATIONtiles (golden 🌸) on the world map and cannot be re-entered. - World map tile constants in
LevelSelect:WATER=-3, ROCK=-2, CHEST=-1, SPECIAL_LEVEL=-4, DECORATION=-5, GENERATED_LEVEL=50. Positive values = handcrafted level index + 1.
