import type { Direction, GameState, MoveResult, NewGameOptions, Position, Tile, } from './types.js' import { nextInt, randomSeed } from './rng.js' const DEFAULT_SIZE = 4 const DEFAULT_WINNING_VALUE = 2048 const DEFAULT_STARTING_TILES = 2 /** * What a spawn may produce, drawn uniformly. Three 2s to one 4, so a 4 turns up * a quarter of the time rather than the tenth that standard 2048 uses. * * Not a tuning choice. It is what the engine behind `blue.2048.game` does, and * a recording only replays correctly against the exact spawn rule that produced * it. The game is faster and luckier for it: value accumulates quicker per * square of board, so 2048 arrives sooner and games run shorter. */ const SPAWN_VALUES = [2, 2, 2, 4] as const /** * Pure 2048 rules. Nothing here touches the DOM, the clock or `Math.random`, * so a game is a value: same seed plus same moves always gives the same board. * * Every function returns a new state and never mutates its input, which is what * makes undo a one-liner in the caller — keep the previous state and put it * back. The engine deliberately owns no undo stack of its own. */ /** Row/column deltas for each direction. */ const VECTORS: Record = { up: { row: -1, col: 0 }, right: { row: 0, col: 1 }, down: { row: 1, col: 0 }, left: { row: 0, col: -1 }, } type Grid = (Tile | null)[][] function emptyGrid(size: number): Grid { return Array.from({ length: size }, () => Array.from({ length: size }, () => null)) } function toGrid(state: GameState): Grid { const grid = emptyGrid(state.size) for (const tile of state.tiles) { const row = grid[tile.row] if (row) row[tile.col] = tile } return grid } function cellAt(grid: Grid, pos: Position): Tile | null | undefined { return grid[pos.row]?.[pos.col] } function withinBounds(size: number, pos: Position): boolean { return pos.row >= 0 && pos.row < size && pos.col >= 0 && pos.col < size } function emptyCells(grid: Grid): Position[] { const cells: Position[] = [] for (let row = 0; row < grid.length; row++) { const cols = grid[row] if (!cols) continue for (let col = 0; col < cols.length; col++) { if (cols[col] === null) cells.push({ row, col }) } } return cells } /** * Order in which cells must be visited for a given direction: always start from * the edge the tiles are moving towards, so the tile nearest the wall settles * first and later tiles stack behind it. */ function traversalOrder(size: number, direction: Direction): Position[] { const rows = [...Array(size).keys()] const cols = [...Array(size).keys()] if (direction === 'down') rows.reverse() if (direction === 'right') cols.reverse() const order: Position[] = [] for (const row of rows) { for (const col of cols) order.push({ row, col }) } return order } /** * Walk from `from` along `vector` while the next cell is empty. Returns the * last empty cell reached (`farthest`) and the cell just past it (`next`), which * is the merge candidate. */ function findFarthestPosition( grid: Grid, size: number, from: Position, vector: Position, ): { farthest: Position; next: Position } { let previous: Position = from let cell: Position = { row: from.row + vector.row, col: from.col + vector.col } while (withinBounds(size, cell) && cellAt(grid, cell) === null) { previous = cell cell = { row: cell.row + vector.row, col: cell.col + vector.col } } return { farthest: previous, next: cell } } function spawnTile( grid: Grid, rngState: number, nextTileId: number, ): { tile: Tile | null; rngState: number; nextTileId: number } { const cells = emptyCells(grid) if (cells.length === 0) return { tile: null, rngState, nextTileId } const pick = nextInt(rngState, cells.length) const cell = cells[pick.value] if (!cell) return { tile: null, rngState: pick.state, nextTileId } // Two draws per spawn, cell first then value, because that is the order the // shared format's engine uses and the order decides every later spawn. const roll = nextInt(pick.state, SPAWN_VALUES.length) const value = SPAWN_VALUES[roll.value] ?? 2 const tile: Tile = { id: nextTileId, value, row: cell.row, col: cell.col, previous: null, mergedFrom: null, } const row = grid[cell.row] if (row) row[cell.col] = tile return { tile, rngState: roll.state, nextTileId: nextTileId + 1 } } /** True if any move in any direction would change the board. */ function hasAvailableMoves(grid: Grid, size: number): boolean { for (let row = 0; row < size; row++) { for (let col = 0; col < size; col++) { const tile = cellAt(grid, { row, col }) if (!tile) return true // Only need to look right and down; left and up are covered by symmetry. for (const vector of [VECTORS.right, VECTORS.down]) { const neighbour = cellAt(grid, { row: row + vector.row, col: col + vector.col }) if (neighbour && neighbour.value === tile.value) return true } } } return false } function gridTiles(grid: Grid): Tile[] { const tiles: Tile[] = [] for (const row of grid) { for (const cell of row) { if (cell) tiles.push(cell) } } return tiles } export function createGame(options: NewGameOptions = {}): GameState { const size = options.size ?? DEFAULT_SIZE const seed = options.seed ?? randomSeed() const winningValue = options.winningValue ?? DEFAULT_WINNING_VALUE const startingTiles = options.startingTiles ?? DEFAULT_STARTING_TILES if (size < 2) throw new RangeError(`Board size must be at least 2, got ${size}`) const grid = emptyGrid(size) let rngState = seed let nextTileId = 1 for (let i = 0; i < startingTiles; i++) { const spawned = spawnTile(grid, rngState, nextTileId) rngState = spawned.rngState nextTileId = spawned.nextTileId } const tiles = gridTiles(grid) return { size, tiles, score: 0, moves: 0, won: tiles.some((tile) => tile.value >= winningValue), over: !hasAvailableMoves(grid, size), winningValue, seed, rngState, nextTileId, } } /** * Apply one move. Returns `moved: false` and the state untouched when nothing * would change — no tile is spawned and the move counter does not advance, so a * swipe into a wall costs the player nothing. */ export function move(state: GameState, direction: Direction): MoveResult { if (state.over) { return { state, moved: false, gained: 0, merged: [] } } const { size } = state const vector = VECTORS[direction] const grid = toGrid(state) // Tiles produced by a merge this move. A tile may only merge once per move, // so these are excluded as merge candidates for the rest of the pass. const mergedThisMove = new Set() const merged: number[] = [] let gained = 0 let moved = false // Strip last move's animation metadata before recomputing it. for (const position of traversalOrder(size, direction)) { const tile = cellAt(grid, position) if (!tile) continue const row = grid[position.row] if (row) { row[position.col] = { ...tile, previous: null, mergedFrom: null, } } } for (const position of traversalOrder(size, direction)) { const tile = cellAt(grid, position) if (!tile) continue const { farthest, next } = findFarthestPosition(grid, size, position, vector) const target = withinBounds(size, next) ? cellAt(grid, next) : null if (target && target.value === tile.value && !mergedThisMove.has(target.id)) { const value = tile.value * 2 const mergedTile: Tile = { id: state.nextTileId + merged.length, value, row: next.row, col: next.col, previous: null, // Both sources keep their pre-move coordinates. The renderer animates // each from its own row/col to the merged tile's row/col, then swaps. mergedFrom: [tile, target], } const sourceRow = grid[position.row] if (sourceRow) sourceRow[position.col] = null const targetRow = grid[next.row] if (targetRow) targetRow[next.col] = mergedTile mergedThisMove.add(mergedTile.id) merged.push(value) gained += value moved = true continue } if (farthest.row !== position.row || farthest.col !== position.col) { const sourceRow = grid[position.row] if (sourceRow) sourceRow[position.col] = null const destRow = grid[farthest.row] if (destRow) { destRow[farthest.col] = { ...tile, row: farthest.row, col: farthest.col, previous: { row: position.row, col: position.col }, mergedFrom: null, } } moved = true } } if (!moved) { return { state, moved: false, gained: 0, merged: [] } } let nextTileId = state.nextTileId + merged.length const spawned = spawnTile(grid, state.rngState, nextTileId) nextTileId = spawned.nextTileId const tiles = gridTiles(grid) const score = state.score + gained return { state: { ...state, tiles, score, moves: state.moves + 1, won: state.won || tiles.some((tile) => tile.value >= state.winningValue), over: !hasAvailableMoves(grid, size), rngState: spawned.rngState, nextTileId, }, moved: true, gained, merged, } } /** True when at least one direction would change the board. */ export function canMove(state: GameState): boolean { return hasAvailableMoves(toGrid(state), state.size) } /** The highest tile value currently on the board. 0 for an empty board. */ export function highestTile(state: GameState): number { return state.tiles.reduce((max, tile) => (tile.value > max ? tile.value : max), 0) } /** * Build a state from a literal grid of values, 0 meaning empty. * * For tests and for replaying a stored board. Spawning stays seeded, so a game * resumed this way still behaves deterministically from here on. */ export function createGameFromGrid( values: readonly (readonly number[])[], options: Omit & { score?: number } = {}, ): GameState { const size = values.length if (size < 2) throw new RangeError(`Board size must be at least 2, got ${size}`) for (const row of values) { if (row.length !== size) { throw new RangeError(`Grid must be square; expected ${size} columns, got ${row.length}`) } } const seed = options.seed ?? randomSeed() const winningValue = options.winningValue ?? DEFAULT_WINNING_VALUE const grid = emptyGrid(size) let nextTileId = 1 for (let row = 0; row < size; row++) { for (let col = 0; col < size; col++) { const value = values[row]?.[col] ?? 0 if (value === 0) continue const target = grid[row] if (target) { target[col] = { id: nextTileId++, value, row, col, previous: null, mergedFrom: null } } } } const tiles = gridTiles(grid) return { size, tiles, score: options.score ?? 0, moves: 0, won: tiles.some((tile) => tile.value >= winningValue), over: !hasAvailableMoves(grid, size), winningValue, seed, rngState: seed, nextTileId, } } /** Board as a plain number grid. For tests, debugging and the plain-numbers mode. */ export function toValueGrid(state: GameState): number[][] { const grid = emptyGrid(state.size) for (const tile of state.tiles) { const row = grid[tile.row] if (row) row[tile.col] = tile } return grid.map((row) => row.map((cell) => cell?.value ?? 0)) }