Maackesgeist: A Chess Engine for Raumschach

Version 2 — A Fully Documented Engine with Quiescence Search, Transposition Table, Iterative Deepening, Allocation-Free Search, Incremental Hashing, Aspiration Windows, and Null-Move Pruning

International Raumschach Federation  ·  2026

Maackesgeist (German: the “Spirit of Maack”) is the AI engine embedded in the browser-based Raumschach implementation at raumschach.org. This article describes Version 2 of the engine, which supersedes a simpler alpha-beta minimax implementation. Version 2 adds ten major improvements: in-place make/unmake throughout the entire search (including legal move generation), Zobrist hashing with incremental O(1) hash updates, a transposition table, iterative deepening with aspiration windows, null-move pruning, enhanced move ordering (MVV/LVA, killer moves, and a history heuristic), a mobility term in the evaluation function, and quiescence search. The article sets out the complete algorithm in pseudo-code, situates Maackesgeist among other known Raumschach engines, and identifies remaining weaknesses along with a roadmap for Version 3.

1. Introduction

Raumschach — literally “space chess” in German — was invented by Ferdinand Maack of Hamburg and first published in 1907. It is played on a 5×5×5 board comprising five stacked 5×5 levels, and introduces two pieces absent from orthodox chess: the Bishop, restricted to face-diagonals, and the Unicorn, restricted to space-diagonals (triagonals). The Queen combines all three sliding directions of three-dimensional space. Despite more than a century of theoretical attention — most authoritatively chronicled by Anthony Dickins (1914–1987) in A Guide to Fairy Chess (1969–71) — computer opposition for Raumschach has remained underdeveloped and underdocumented.

Maackesgeist is the AI component of a browser-based Raumschach implementation, written entirely in JavaScript and executable without server infrastructure. This paper documents Version 2 of the engine, a substantial revision of the original. It is organized as follows: §2 corrects a priority claim made in the Version 1 documentation; §3 describes the board representation and geometry; §4 covers move generation; §5 presents the complete algorithm with pseudo-code; §6 describes the evaluation function; §7 identifies current weaknesses; and §8 proposes improvements for Version 3.

2. The Existing Landscape of Raumschach Engines

Maackesgeist Version 1 was not the first publicly playable AI opponent for Raumschach. Most have been described as using minimax with alpha-beta pruning, and at least one was claimed to use a custom algorithm for Raumschach. To the best of the knowledge of the author, all have were introduced without publishing implementation details, evaluation functions, or pseudo-code.

In contrast, Maackesgeist is — to the best of the present author’s knowledge — the most fully documented Raumschach engine, and the first for which a complete technical account (including board representation, direction geometry, legal move generation, evaluation function, and the full search algorithm with pseudo-code) has been made publicly available. No published account of any other Raumschach engine’s internals is known. The absence of such documentation in other implementations makes meaningful comparison of playing strength or algorithmic novelty impossible; Maackesgeist Version 2 is presented here in the spirit of reproducibility and open scholarship.

It should also be noted that Maackesgeist Version 2 goes considerably further than bare minimax with alpha-beta pruning. The additions of quiescence search, a Zobrist transposition table with incremental hashing, iterative deepening with aspiration windows, null-move pruning, an allocation-free search tree (make/unmake throughout, including legal move generation), killer move heuristics, a history heuristic, and mobility evaluation collectively represent a substantial advance in documented technique for this game, regardless of what undocumented engines may have implemented.

3. Board Representation

The board is a three-dimensional JavaScript array board[l][r][f], where l ∈ {0–4} indexes the level (A through E), r ∈ {0–4} the rank (1–5), and f ∈ {0–4} the file (a–e). Each occupied cell holds a plain object { type, color }. For the game logic (rendering, move execution, legality checking), the board is still cloned via deep copy. Inside the search tree, however, Version 2 replaces all cloning with in-place make/unmake, as described in §5.1.

3.1 Geometric Direction Sets

Name Count Description Pieces
Orthogonal (RD) 6 ±1 along exactly one axis Rook, Queen
Face-diagonal (BD) 12 ±1 along exactly two axes Bishop, Queen
Space-diagonal (UD) 8 ±1 along all three axes Unicorn, Queen
All (QD) 26 Union of the above Queen, King

The Knight extends to three dimensions via all permutations of (±2, ±1, 0), yielding 24 distinct leap directions. All direction sets are precomputed as constant arrays at startup.

4. Move Generation

Move generation is unchanged from Version 1 at the pseudo-legal level. pieceMoves generates pseudo-legal moves for a single piece via ray-casting (sliders) or single-step enumeration (King, Knight, Pawn). allPseudo aggregates over all pieces of a given color. legalMoves filters pseudo-legal moves using make/unmake — it applies each candidate move in-place, tests whether the moving side’s King is in check, and immediately unmakes the move, producing no heap allocations. isAttacked performs a reverse-ray trace to determine whether a given square is attacked by a given color. The entire search tree, from root to quiescence leaf, is now allocation-free.

5. The Algorithm

5.1 Make / Unmake

Version 2 replaces the clone-based board copy inside the search with in-place make and unmake operations. makeMove moves the piece object directly on the board array and returns an undo record {captured, origType, piece} sufficient to reverse the operation. unmakeMove restores the board to its prior state using that record. Promotion is handled by replacing the piece object; unmake restores the original.

FUNCTION makeMove(board, m) → undoRecord: captured ← board[m.to] piece ← board[m.from] IF m.prom: board[m.to] ← {type: m.prom, color: piece.color} ELSE: board[m.to] ← piece board[m.from] ← null RETURN {captured, origType: piece.type, piece} FUNCTION unmakeMove(board, m, undo): board[m.from] ← undo.piece // restore moving piece (handles promotion) board[m.to] ← undo.captured // restore captured piece (or null)

5.2 Zobrist Hashing

A deterministic linear congruential generator (seed 0xDEADBEEF, multiplier 1664525, addend 1013904223) produces 1,750 pseudo-random 32-bit keys: one per (piece type × color × square) triple, plus one for the side to move. The root hash is computed from scratch once per move decision by iterating all 125 cells. All subsequent hashes inside the search tree are updated incrementally in O(1): hashMove XORs out the moving piece at its origin, XORs out any captured piece at the destination, XORs in the moving piece (or its promotion) at the destination, and flips the side key. Unmake requires no hash work because the parent hash is simply passed down the call stack and the child hash is discarded when the recursive call returns.

FUNCTION hashBoard(board, color) → hash: h ← SIDE_KEY if color = Black else 0 FOR EACH occupied cell (l, r, f): h ← h XOR ZKEYS[piece.type, piece.color, l, r, f] RETURN h (unsigned 32-bit) FUNCTION hashMove(hash, undo, m) → hash: // O(1) incremental update hash ← hash XOR ZKEYS[undo.piece.type, undo.piece.color, m.from] IF undo.captured: hash ← hash XOR ZKEYS[undo.captured.type, undo.captured.color, m.to] hash ← hash XOR ZKEYS[m.prom OR undo.piece.type, undo.piece.color, m.to] RETURN hash XOR SIDE_KEY (unsigned 32-bit)

5.3 Transposition Table

A 131,072-entry (217) flat array serves as the transposition table, indexed by hash & TT_MASK. Each entry stores the hash, search depth, score, a flag (EXACT / LOWER / UPPER), and the best move found at that node. The replacement policy is depth-preferred: a new entry replaces an existing one only if it was computed at greater or equal depth.

FUNCTION ttProbe(hash) → entry | null: e ← tt[hash AND TT_MASK] RETURN e if e exists AND e.hash = hash else null FUNCTION ttStore(hash, depth, score, flag, bestMove): idx ← hash AND TT_MASK IF tt[idx] is empty OR tt[idx].depth ≤ depth: tt[idx] ← {hash, depth, score, flag, bestMove}

5.4 Move Ordering

Moves are scored and sorted in descending order before being searched. The priority stack is:

PriorityConditionScore
1TT best move2,000,000
2Capture (MVV/LVA: victim value × 10 − attacker value)1,000,000 + Δ
3Killer move slot 0 at current ply900,000
4Killer move slot 1 at current ply890,000
5History heuristic (accumulated depth² on beta cutoffs)0 – 32,000

Killers are stored per ply in a two-slot array; each beta cutoff on a quiet move promotes that move into the killer table and increments its history score. The history table is aged (halved) between root calls to favour recent search experience.

FUNCTION scoreMv(board, m, ply, ttBest) → integer: IF m = ttBest: RETURN 2000000 victim ← board[m.to] IF victim exists: attacker ← board[m.from] RETURN 1000000 + VALUE[victim.type] × 10 − VALUE[attacker.type] IF m = killers[ply][0]: RETURN 900000 IF m = killers[ply][1]: RETURN 890000 RETURN history[from_sq × 125 + to_sq]

5.5 Quiescence Search

At depth zero, rather than calling evalBoard directly, the search enters quiescence. Quiescence computes a stand-pat score from the static evaluation, then extends the search through all available captures (up to 6 additional ply) until no captures remain. This eliminates the horizon effect: the engine no longer mistakes a tactically unstable position for a quiet one merely because it lies at the nominal search boundary.

FUNCTION quiesce(board, alpha, beta, isMaximising, qdepth) → score: standPat ← evalBoard(board) IF qdepth = 0: RETURN standPat IF isMaximising: IF standPat ≥ beta: RETURN standPat // stand-pat cutoff alpha ← max(alpha, standPat) ELSE: IF standPat ≤ alpha: RETURN standPat // stand-pat cutoff beta ← min(beta, standPat) captures ← allPseudo(board, color) FILTERED TO captures only IF captures is empty: RETURN standPat orderMoves(board, captures, MAX_PLY−1, null) best ← standPat FOR EACH cap IN captures: undo ← makeMove(board, cap) IF inCheck(board, color): unmakeMove(board, cap, undo); CONTINUE score ← quiesce(board, alpha, beta, NOT isMaximising, qdepth−1) unmakeMove(board, cap, undo) IF isMaximising: best ← max(best, score); alpha ← max(alpha, best) IF alpha ≥ beta: BREAK ELSE: best ← min(best, score); beta ← min(beta, best) IF beta ≤ alpha: BREAK RETURN best

5.6 Minimax with Alpha-Beta, Transposition Table, and Null-Move Pruning

The search receives the current board hash as a parameter rather than recomputing it at every node. Each recursive call derives the child hash via hashMove (O(1)). Null-move pruning is applied before the move loop: if the position is not in check, is not King-and-Pawns-only (which would risk a spurious zugzwang cutoff), and the remaining depth is at least 3, the engine grants the opponent a free move by flipping the side key without altering the board, then searches at depth−3 with a one-move-wide window. A cutoff at this reduced depth indicates the position is already strong enough that full search is unnecessary. The nullOk flag prevents consecutive null moves.

FUNCTION minimax(board, depth, alpha, beta, isMaximising, ply, hash, nullOk=true) → score: color ← White if isMaximising else Black entry ← ttProbe(hash) ttBest ← entry.bestMove if entry exists else null // Transposition table cutoff IF entry exists AND entry.depth ≥ depth: IF entry.flag = EXACT: RETURN entry.score IF entry.flag = LOWER: alpha ← max(alpha, entry.score) IF entry.flag = UPPER: beta ← min(beta, entry.score) IF alpha ≥ beta: RETURN entry.score // Leaf node: enter quiescence IF depth = 0: RETURN quiesce(board, alpha, beta, isMaximising, 6) // Null-move pruning (R = 2; skip if in check or K+P-only) IF nullOk AND depth ≥ 3 AND NOT inCheck(board, color) AND NOT onlyKingAndPawns(board, color): nmAlpha ← beta−1 if isMaximising else alpha nmBeta ← beta if isMaximising else alpha+1 nmScore ← minimax(board, depth−3, nmAlpha, nmBeta, NOT isMaximising, ply+1, hash XOR SIDE_KEY, false) IF isMaximising AND nmScore ≥ beta: RETURN beta IF NOT isMaximising AND nmScore ≤ alpha: RETURN alpha moves ← legalMoves(board, color) IF moves is empty: RETURN (−20000 − depth×50) if isMaximising AND inCheck // spacemate (+20000 + depth×50) if NOT isMaximising AND inCheck 0 // stalemate orderMoves(board, moves, ply, ttBest) origAlpha ← alpha best ← −∞ if isMaximising else +∞ bestMove ← moves[0] cutoff ← false FOR EACH m IN moves: undo ← makeMove(board, m) score ← minimax(board, depth−1, alpha, beta, NOT isMaximising, ply+1, hashMove(hash, undo, m)) unmakeMove(board, m, undo) IF isMaximising: IF score > best: best ← score; bestMove ← m alpha ← max(alpha, best) ELSE: IF score < best: best ← score; bestMove ← m beta ← min(beta, best) IF beta ≤ alpha: IF move is quiet: addKiller(m, ply) history[m] ← min(32000, history[m] + depth²) cutoff ← true; BREAK flag ← LOWER if cutoff else (UPPER if best ≤ origAlpha else EXACT) ttStore(hash, depth, best, flag, bestMove) RETURN best

5.7 Iterative Deepening with Aspiration Windows

Each depth pass is wrapped in a narrow aspiration window centred on the score returned by the previous pass. For depth 1, a full [−∞, +∞] window is used since no prior score exists. For depth 2 and beyond, the window is [prevScore−50, prevScore+50]. If the search returns a score outside that window (a fail-low or fail-high), the pass is immediately re-searched at full width. The full-width re-search benefits from the improved move ordering and TT entries deposited by the failed narrow search, so its cost is modest. The root hash is computed once from scratch; all recursive calls derive child hashes incrementally via hashMove.

FUNCTION getBestMove(board, color, maxDepth) → move: moves ← legalMoves(board, color) IF moves is empty: RETURN null IF maxDepth = 0: RETURN randomChoice(moves) // Beginner // Reset killers; age history killers[0..MAX_PLY] ← [null, null] history[all] ← history[all] >> 1 isMaximising ← (color = White) rootHash ← hashBoard(board, color) // computed once bestMove ← moves[0] prevScore ← 0 FUNCTION searchRoot(d, lo, hi) → {bs, bm}: bs ← −∞ if isMaximising else +∞ bm ← moves[0] FOR EACH m IN moves: undo ← makeMove(board, m) score ← minimax(board, d−1, lo, hi, NOT isMaximising, 1, hashMove(rootHash, undo, m)) unmakeMove(board, m, undo) IF isMaximising AND score > bs: bs ← score; bm ← m IF NOT isMaximising AND score < bs: bs ← score; bm ← m RETURN {bs, bm} FOR d ← 1 TO maxDepth: orderMoves(board, moves, 0, bestMove) // seed with last iteration's best IF d = 1: result ← searchRoot(d, −∞, +∞) // full window on first pass ELSE: lo ← prevScore − 50; hi ← prevScore + 50 result ← searchRoot(d, lo, hi) IF result.bs ≤ lo OR result.bs ≥ hi: // fail: re-search full width result ← searchRoot(d, −∞, +∞) prevScore ← result.bs bestMove ← result.bm RETURN bestMove

5.8 Difficulty Levels

Level Name Max Depth Character
0 Beginner 0 Random legal move; no search
1 Weak 2 Iterative deepening to 2 plies + quiescence
2 Average 3 Iterative deepening to 3 plies + quiescence
3 Strong 4 Iterative deepening to 4 plies + quiescence
4 RSM 5 Iterative deepening to 5 plies + quiescence
5 GRSM 6 Iterative deepening to 6 plies + quiescence

6. Evaluation Function

The evaluation function returns a score in centipawn units from White’s perspective. It combines material, centrality bonuses, piece-specific positional adjustments, and a mobility term.

FUNCTION evalBoard(board) → score: score ← 0; wMobility ← 0; bMobility ← 0 FOR EACH occupied cell (l, r, f): p ← board[l][r][f] cen ← 6 − (|l−2| + |r−2| + |f−2|) // taxicab centrality, ∈ [0,6] SWITCH p.type: Queen: v ← 900 + cen × 100 Rook: v ← 300 + cen × 67 Bishop: v ← 250 + cen × 33 + (2 − |l−2|) × 8 Unicorn: v ← 200 + cen × 100 Knight: v ← 200 + cen × 50 Pawn: ra ← r if White else (4−r) // rank advancement la ← l if White else (4−l) // level advancement v ← 100 + ra×20 + la×15 + 25 if l = 2 // central-level bonus + (2 − |f−2|) × 5 // file centrality King: v ← 20000 score ← score + v if p.color = White score − v if p.color = Black mob ← count(pieceMoves(board, l, r, f)) IF p.color = White: wMobility += mob ELSE: bMobility += mob score ← score + (wMobility − bMobility) × 5 // 5 cp per extra move RETURN score

7. Weaknesses

7.1 32-Bit Zobrist Keys and Hash Collisions

True Zobrist hashing, as described in the original 1970 paper, uses 64-bit random keys to make hash collisions negligibly rare. Maackesgeist uses 32-bit keys because JavaScript’s native integers are 32-bit. With a 217-entry table and 32-bit keys, the probability of a spurious collision — where two different positions share the same hash index and the same 32-bit key fragment — is non-trivial over the course of a long game. Collisions cause the engine to return a cached score for the wrong position, potentially corrupting the search. 64-bit emulation via two 32-bit integers (as used by engines such as Stockfish in pure JavaScript ports) would reduce collision risk dramatically.

7.2 Transposition Table Replacement Policy

The current replacement policy (depth-preferred: replace only if new entry is at least as deep) has a known pathology: it allows old, deep entries from earlier positions to persist and block newer, shallower but more relevant entries for the current position. In practice, engines use hybrid replacement schemes — for example, a two-bucket system with one always-replace slot and one depth-preferred slot — to ensure the TT remains fresh without discarding valuable deep results.

7.3 No Late Move Reductions

Late move reductions (LMR) apply a reduced search depth to moves that appear late in the ordered move list, on the reasoning that well-ordered moves leave only poor or irrelevant moves toward the end and these do not require full-depth verification. LMR is one of the most effective single techniques in modern chess engine design and is absent from Maackesgeist.

7.4 Quiescence Search Does Not Check Checks

The quiescence search in Version 2 extends only on captures. Check evasions and checking moves, which may be as tactically forcing as captures, are not searched in quiescence. This means that a position where the opponent is forced to deal with an immediate check — but where no captures are involved — may be misevaluated at the leaf. Extending quiescence on checks (with a depth limit to prevent explosion) would improve accuracy in tactical positions involving sacrifices and forcing check sequences.

7.5 No Delta Pruning in Quiescence

Delta pruning is a quiescence-specific optimization: if even capturing the most valuable available piece cannot raise the score above alpha, the position is hopeless and further capture search is skipped. Without delta pruning, the quiescence search explores captures that have no chance of improving the score, wasting time on clearly losing capture attempts.

7.6 Evaluation Remains Blind to King Safety and Pawn Structure

The evaluation function now includes material, centrality, piece-specific bonuses, and mobility, but remains blind to King safety, pawn structure, and game-phase awareness. The engine has no incentive to avoid exposing its King beyond what is enforced by check detection at the leaves. Doubled, isolated, and passed pawns are unrecognised. Piece values and weights do not adjust as the game transitions from opening to middlegame to endgame.

7.7 No Opening Book or Endgame Tablebases

The engine plays the opening from scratch on each game, without a library of sound opening sequences. This makes its early play random-seeming and often structurally weak relative to even a modest opening book. In the endgame, tablebases could provide perfect play in positions with few pieces; the engine currently relies entirely on search, which at 4-ply depth may fail to find the correct winning plan in positions requiring precise technique.

7.8 Fixed Depth, Not Time-Based

The difficulty levels correspond to fixed search depths. The engine does not adapt to how much time remains on the clock. A player using the Classical time control (90+30) can engage the Strong engine and experience slow thinking; a player on Blitz (5+3) faces the same Strong engine playing at the same depth, regardless of time pressure. A time-management layer that allocates a fraction of remaining clock time to each move, and terminates iterative deepening when that budget is exhausted, would be a substantial practical improvement.

8. Proposed Improvements for Version 3

The improvements are ordered by estimated impact.

Priority 1 (highest): Two-bucket transposition table. Maintain two entries per TT slot: one depth-preferred (the current behavior) and one always-replace. This keeps the table responsive without sacrificing deep results.

Priority 2: Delta pruning in quiescence. Before searching a capture, check whether even the maximum possible material gain can raise the score above alpha; if not, skip the move. This eliminates clearly futile capture searches.

Priority 3: Check extensions in quiescence. Extend quiescence search when the side to move is in check, up to a small additional depth (2 plies). This prevents misevaluation of positions where the only forcing sequence involves checks without captures.

Priority 4: Late move reductions. After the first few moves at each node, reduce the search depth for moves ranked low in the ordered list. Re-search at full depth only if the reduced search raises alpha. LMR is among the most effective techniques in modern engine design and scales well with the high branching factor of Raumschach’s 3D search tree.

Priority 5: Time management. Replace fixed-depth search with an iterative-deepening loop that terminates when a per-move time budget is exhausted. Allocate time dynamically based on remaining clock and estimated moves to the control, with bonus time for positions where the previous iteration’s score fluctuated significantly.

Priority 6: 64-bit Zobrist keys. Emulate 64-bit integers using pairs of 32-bit values (or adopt BigInt) to reduce hash collision probability to negligible levels.

9. Conclusion

Maackesgeist Version 2 represents a substantial advance over Version 1. The addition of quiescence search eliminates the most serious class of tactical errors. The Zobrist transposition table prevents redundant computation across transpositions, and its hash is now maintained incrementally in O(1) per node rather than recomputed from scratch. Iterative deepening with aspiration windows provides better move ordering at each depth while pruning substantially more nodes on narrowed passes. Null-move pruning delivers an approximate two-ply effective-depth bonus at minimal cost. The three-tier ordering system (MVV/LVA, killer moves, history heuristic) further improves alpha-beta pruning efficiency. The mobility term adds a meaningful positional signal beyond material and centrality. In-place make/unmake — now applied throughout the entire search, including legal move generation — makes the search tree entirely allocation-free.

At the same time, the weaknesses identified in §7 are real. The most pressing are the absence of late move reductions, delta pruning in quiescence, and check extensions. Beyond that, the absence of time management, a two-bucket transposition table, and 64-bit Zobrist keys leaves Version 2 short of what is achievable within the constraints of a browser-native single-file implementation. Version 3 should address at least the first four priorities in §8.

Among documented Raumschach engines — the only class on which comparison is possible — Maackesgeist remains the most technically detailed implementation on public record. It is offered in the hope that further documentation of Raumschach computation, by this and other authors, will eventually bring to three-dimensional chess the analytical rigour that a century of theoretical literature has always deserved.

Maackesgeist.v1 documentation is available.

References