Created
May 26, 2026 00:43
-
-
Save tlhakhan/84f0905b43fe598b9fa789b383b9bc52 to your computer and use it in GitHub Desktop.
Implementation of Multi Color Knights by Claude
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| """ | |
| Multi-player generalization of the Red & Black Knights construction. | |
| Same square spiral, same "smallest unoccupied cell not threatened by a | |
| hostile piece" rule, but the number of players, the move set of each | |
| player's piece, and the directed threat relation between players are all | |
| configurable. The two-knight case (OEIS A392177) is recovered with two | |
| knight players that mutually threaten each other. | |
| See https://jonka364.github.io/stendhal/stendhal.html for Karlsson's gallery | |
| of variants, including the three-knight case implemented here. | |
| """ | |
| import math | |
| from dataclasses import dataclass, field | |
| from collections import namedtuple | |
| from red_black_knights import cell_to_xy, KNIGHT_OFFSETS | |
| # Other piece types from Karlsson's gallery, in case you want to experiment. | |
| # Each piece's "offsets" are the squares it can attack from its current cell. | |
| PIECES = { | |
| "knight": [( 1, 2), ( 1, -2), (-1, 2), (-1, -2), | |
| ( 2, 1), ( 2, -1), (-2, 1), (-2, -1)], | |
| "fers": [( 1, 1), ( 1, -1), (-1, 1), (-1, -1)], | |
| "vazir": [( 1, 0), (-1, 0), ( 0, 1), ( 0, -1)], | |
| "camel": [( 1, 3), ( 1, -3), (-1, 3), (-1, -3), | |
| ( 3, 1), ( 3, -1), (-3, 1), (-3, -1)], | |
| "zebra": [( 2, 3), ( 2, -3), (-2, 3), (-2, -3), | |
| ( 3, 2), ( 3, -2), (-3, 2), (-3, -2)], | |
| "antelope": [( 3, 4), ( 3, -4), (-3, 4), (-3, -4), | |
| ( 4, 3), ( 4, -3), (-4, 3), (-4, -3)], | |
| "satrap": [( 2, 0), (-2, 0), ( 0, 2), ( 0, -2)], | |
| "aspbad": [( 2, 2), ( 2, -2), (-2, 2), (-2, -2)], | |
| "spehbed": [( 3, 0), (-3, 0), ( 0, 3), ( 0, -3)], | |
| } | |
| @dataclass | |
| class Player: | |
| name: str | |
| color_rgb: tuple # (r, g, b) for visualization | |
| offsets: list # attack vectors of this player's piece | |
| SimResult = namedtuple("SimResult", ["players", "owner_of_cell", "xy_of_cell", | |
| "cells_by_player"]) | |
| def simulate(players, threatens, num_moves): | |
| """Run the multi-player construction for `num_moves` total turns. | |
| `threatens[i][j]` should be truthy iff a piece of player i threatens a | |
| piece of player j (and therefore player j must avoid squares attacked by | |
| player i's existing pieces). The diagonal is conventionally False | |
| (a piece doesn't threaten itself). | |
| Players move in round-robin order: turn k is taken by player k % len(players). | |
| """ | |
| n = len(players) | |
| assert len(threatens) == n and all(len(row) == n for row in threatens) | |
| owner_of_cell = {} # cell index -> player index | |
| xy_of_cell = {} # cell index -> (x, y) | |
| cells_by_player = [[] for _ in range(n)] | |
| # For each player j, the set of (x, y) squares that *some hostile piece* | |
| # currently threatens. (Hostile to j, that is.) | |
| threats_against = [set() for _ in range(n)] | |
| # Per-player pointer: smallest cell index this player might legally take. | |
| pointer = [0] * n | |
| for turn in range(num_moves): | |
| pi = turn % n | |
| p = players[pi] | |
| my_threats = threats_against[pi] | |
| c = pointer[pi] | |
| while True: | |
| if c not in owner_of_cell: | |
| xy = cell_to_xy(c) | |
| if xy not in my_threats: | |
| break | |
| c += 1 | |
| owner_of_cell[c] = pi | |
| xy_of_cell[c] = xy | |
| cells_by_player[pi].append(c) | |
| # This new piece extends the threat sets of every player it threatens. | |
| for j in range(n): | |
| if threatens[pi][j]: | |
| tj = threats_against[j] | |
| x0, y0 = xy | |
| for dx, dy in p.offsets: | |
| tj.add((x0 + dx, y0 + dy)) | |
| pointer[pi] = c + 1 | |
| return SimResult(players, owner_of_cell, xy_of_cell, cells_by_player) | |
| # -------------------------------------------------------------------------- | |
| # Self-check: the two-knight case should reproduce A392177. | |
| # -------------------------------------------------------------------------- | |
| def verify_two_knight_matches_oeis(): | |
| from red_black_knights import A392177_HEAD | |
| black = Player("Black", (17, 17, 17), PIECES["knight"]) | |
| red = Player("Red", (200, 30, 30), PIECES["knight"]) | |
| threatens = [[False, True], [True, False]] | |
| sim = simulate([black, red], threatens, num_moves=2 * 250) | |
| blacks = sorted(sim.cells_by_player[0])[:len(A392177_HEAD)] | |
| assert blacks == A392177_HEAD, "two-knight regression failed" | |
| print("two-knight self-check passed (matches A392177).") | |
| # -------------------------------------------------------------------------- | |
| # Visualization | |
| # -------------------------------------------------------------------------- | |
| def plot(sim, out_path, title=None, figsize=10, dpi=200): | |
| import numpy as np | |
| import matplotlib.pyplot as plt | |
| radius = max(max(abs(x), abs(y)) for x, y in sim.xy_of_cell.values()) | |
| side = 2 * radius + 1 | |
| img = np.full((side, side, 3), 255, dtype=np.uint8) | |
| colors = np.array([p.color_rgb for p in sim.players], dtype=np.uint8) | |
| for c, (x, y) in sim.xy_of_cell.items(): | |
| row = radius - y | |
| col = radius + x | |
| img[row, col] = colors[sim.owner_of_cell[c]] | |
| fig, ax = plt.subplots(figsize=(figsize, figsize), facecolor="white") | |
| ax.imshow(img, interpolation="nearest") | |
| ax.axhline(radius, color="#888", linewidth=0.4) | |
| ax.axvline(radius, color="#888", linewidth=0.4) | |
| ax.set_xticks([]); ax.set_yticks([]) | |
| for spine in ax.spines.values(): | |
| spine.set_visible(False) | |
| if title: | |
| ax.set_title(title, fontsize=11) | |
| fig.tight_layout() | |
| fig.savefig(out_path, dpi=dpi, bbox_inches="tight") | |
| plt.close(fig) | |
| counts = " ".join(f"{p.name} {len(sim.cells_by_player[i]):,}" | |
| for i, p in enumerate(sim.players)) | |
| print(f"wrote {out_path} (radius {radius}, {counts})") | |
| # -------------------------------------------------------------------------- | |
| # Pre-built scenarios | |
| # -------------------------------------------------------------------------- | |
| def three_knights_full_enmity(): | |
| """Three knight-players, every pair mutually hostile. | |
| Turn order: Black, Red, Cyan, Black, Red, Cyan, ... | |
| """ | |
| players = [ | |
| Player("Black", ( 17, 17, 17), PIECES["knight"]), | |
| Player("Red", (200, 30, 30), PIECES["knight"]), | |
| Player("Cyan", ( 30, 170, 200), PIECES["knight"]), | |
| ] | |
| # Full enmity: everyone threatens everyone else; nobody threatens themselves. | |
| threatens = [[i != j for j in range(3)] for i in range(3)] | |
| return players, threatens | |
| def three_knights_cyclic_enmity(): | |
| """Asymmetric variant: Black threatens Red, Red threatens Cyan, | |
| Cyan threatens Black. Each player only worries about ONE opponent. | |
| """ | |
| players = [ | |
| Player("Black", ( 17, 17, 17), PIECES["knight"]), | |
| Player("Red", (200, 30, 30), PIECES["knight"]), | |
| Player("Cyan", ( 30, 170, 200), PIECES["knight"]), | |
| ] | |
| threatens = [ | |
| [False, True, False], # Black -> Red | |
| [False, False, True ], # Red -> Cyan | |
| [True, False, False], # Cyan -> Black | |
| ] | |
| return players, threatens | |
| if __name__ == "__main__": | |
| verify_two_knight_matches_oeis() | |
| for N in (60_000, 600_000): | |
| players, threatens = three_knights_full_enmity() | |
| sim = simulate(players, threatens, num_moves=N) | |
| plot(sim, f"/home/claude/three_knights_full_{N}.png", | |
| title=f"Three knights, full enmity — {N:,} moves") | |
| for N in (60_000, 600_000): | |
| players, threatens = three_knights_cyclic_enmity() | |
| sim = simulate(players, threatens, num_moves=N) | |
| plot(sim, f"/home/claude/three_knights_cyclic_{N}.png", | |
| title=f"Three knights, cyclic enmity (B→R→C→B) — {N:,} moves") |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
multi_color_knights
A small generalization of the Red & Black Knights construction featured in
[Numberphile (May 2026)](https://youtu.be/UiX4CFIiegM) with Neil Sloane —
OEIS sequences [A392177](https://oeis.org/A392177),
[A392178](https://oeis.org/A392178), and [A395357](https://oeis.org/A395357).
The original construction has two players (Black and Red) take turns placing
knights on a square spiral, each placing on the smallest unoccupied cell not
attacked by an existing opposing knight. Same-color knights are allowed to
attack each other; only the opposing color matters. This script lifts that
into an arbitrary number of players, with arbitrary chess-piece move sets,
and an arbitrary directed threat relation between players.
The rule
Pick:
name, a display color, and a list ofattack
offsets(e.g. the eight knight(±1,±2)/(±2,±1)moves);threatenswherethreatens[i][j]isTrueiff a pieceof player
ithreatens a piece of playerj.Players move in round-robin order. On turn
k, playerk % npicks thesmallest-indexed unoccupied cell on the square spiral that is not currently
attacked by any piece belonging to a player who threatens them.
The diagonal of
threatensis conventionallyFalse— a piece doesn'tthreaten itself, and same-color pieces can attack each other freely.
Run it
That runs the two-knight regression (matches the first 75 terms of A392177
exactly) and writes four PNGs: full enmity and cyclic enmity, at 60k and
600k moves each.
Depends on
red_black_knights.pyfrom the same repo forcell_to_xyandthe OEIS reference data, plus
numpy+matplotlibfor rendering.Built-in scenarios
three_knights_full_enmity()— three knight players, every pair mutuallyhostile. Produces a clean three-region territorial split with sharp
diagonal boundaries, structurally analogous to the two-knight result.
three_knights_cyclic_enmity()— three knight players in a directedthreat cycle B→R→C→B. Each player only avoids one rival, so the spiral
fills much more densely; the picture is dominated by tight zebra-stripe
patterns with chevron-shaped distortions, with the territorial split
broken.
Customizing
Different pieces. The
PIECESdict has knight, fers (1,1), vazir(1,0), camel (3,1), zebra (2,3), antelope (3,4), satrap (2,0), aspbad
(2,2), spehbed (3,0) — Karlsson's naming. Mix freely:
When piece types differ, the threat relation becomes geometrically
asymmetric — X threatens Y iff X is at one of X's own offsets from Y. The
simulator handles this correctly because each player carries its own
offset list.
Different threat graphs. Any boolean matrix works. A few worth trying:
threatens[i][i] = Trueand same-color knights alsoblock each other (this collapses to a single-color trapped-knight-style
variant for that player).
More than three players. The simulator is
n-agnostic. Whether theterritorial-split pattern survives at n=4 with full enmity is, as far as
I can tell, an open empirical question.
Scaling
Roughly 7 seconds per million half-moves, linear in$\sqrt{N}$ .
N, dominated by hashlookups into the per-player attack sets. Image side grows as
Memory is the real ceiling — the attack sets hold ~5 (x, y) tuples per
placed piece on average, at ~60 bytes per tuple in a Python set:
process
Past ~15–20M, switch the tuple sets to a single
set[int]with packedkeys like
x * OFFSET + y, or move to a fixed-radius numpy boolean grid.Reference
Karlsson's gallery of variants, including selective-enmity and mixed-piece
examples, is at https://jonka364.github.io/stendhal/stendhal.html. The
convergence proof for the two-knight case (Branicky, Karlsson, Sloane) is
in preparation as of May 2026.