Skip to content

Instantly share code, notes, and snippets.

@mtso
Last active March 4, 2022 02:40
Show Gist options
  • Save mtso/e0518b14a9160a872fcb0df0132988ea to your computer and use it in GitHub Desktop.
Save mtso/e0518b14a9160a872fcb0df0132988ea to your computer and use it in GitHub Desktop.
Dots 'n' Boxes is a text console game for two players. https://mtso.io/dots-n-boxes

dots-n-boxes

Dots 'n' Boxes is a text console game for two players.

The game starts with an empty grid of dots. Two players take turns adding a single horizontal or vertical line between two unjoined adjacent dots specified by algebraic notation (e.g. A1-B1). A player who completes the fourth side of a 1×1 box earns one point and takes another turn.

Buffer Shots

4x4 Game

 A B C D
1•-•-•-•
 |2|1|
2•-•-• •
 |2|
3•-• • •
 |2|   |
4•-• • •

P2 > C2-D2
 A B C D
1•-•-•-•
 |2|1|2|
2•-•-•-•
 |2|2|1|
3•-•-•-•
 |2|2|1|
4•-•-•-•
Player 2 won!
enum C {
NOWALL = 0,
NOP = 1,
WALL = 2,
DOT = 3,
P1 = 4,
P2 = 5,
}
type Coord = [number, number];
type Board = C[][];
type State = {
isP1: boolean;
p1Score: number;
p2Score: number;
isOver: boolean;
};
function coord(note: string): Coord {
const x = note.toUpperCase()[0].charCodeAt(0) - 65;
const y = +note.toUpperCase().slice(1) - 1;
return [x * 2, y * 2];
}
function note(coord: Coord): string {
const xAxis = String.fromCharCode(65 + Math.floor(coord[0] / 2));
const yAxis = Math.floor(coord[1] / 2);
return xAxis + yAxis;
}
function getCell(board: Board, coord: Coord): C {
const [width, height] = getSize(board);
const [x, y] = coord;
if (x >= width || x < 0 || y >= height || y < 0) {
throw new Error(`getCell invalid coord (${x}, ${y})`);
}
return board[y][x];
}
function setCell(board: Board, coord: Coord, cell: C): Board {
return board.map((r, y) =>
r.map((c, x) => {
if (y === coord[1] && x === coord[0]) {
return cell;
} else {
return c;
}
})
);
}
/** @return [from, wall, to] */
function getSpan(fromS: string, toS: string): [Coord, Coord, Coord] {
const from = coord(fromS);
const to = coord(toS);
if (from[0] === to[0] && Math.abs(from[1] - to[1]) === 2) {
if (from[1] - to[1] > 0) {
return [from, [from[0], to[1] + 1], to];
} else {
return [from, [from[0], from[1] + 1], to];
}
} else if (from[1] === to[1] && Math.abs(from[0] - to[0]) === 2) {
if (from[0] - to[0] > 0) {
return [from, [to[0] + 1, from[1]], to];
} else {
return [from, [from[0] + 1, from[1]], to];
}
} else {
throw new Error(`Invalid move ${fromS}-${toS}`);
}
}
function getSize(board: Board): [number, number] {
if (board.length < 1) {
return [0, 0];
} else {
return [board[0].length, board.length];
}
}
function getRoomsSurroundingWall(board: Board, wall: Coord): Coord[] {
const [width, height] = getSize(board);
const rooms: Coord[] = [];
if (wall[1] % 2 === 0) {
if (wall[1] + 1 < height) {
rooms.push([wall[0], wall[1] + 1]);
}
if (wall[1] - 1 >= 0) {
rooms.push([wall[0], wall[1] - 1]);
}
} else if (wall[1] % 2 === 1) {
if (wall[0] + 1 < width) {
rooms.push([wall[0] + 1, wall[1]]);
}
if (wall[0] - 1 >= 0) {
rooms.push([wall[0] - 1, wall[1]]);
}
}
return rooms;
}
function getWallsSurroundingRoom(board: Board, room: Coord): Coord[] {
const [width, height] = getSize(board);
const walls: Coord[] = [];
if (room[1] + 1 < height) {
walls.push([room[0], room[1] + 1]);
}
if (room[1] - 1 >= 0) {
walls.push([room[0], room[1] - 1]);
}
if (room[0] + 1 < width) {
walls.push([room[0] + 1, room[1]]);
}
if (room[0] - 1 >= 0) {
walls.push([room[0] - 1, room[1]]);
}
return walls;
}
function isEnclosed(board: Board, room: Coord): boolean {
const walls = getWallsSurroundingRoom(board, room);
return walls.every((wall) => getCell(board, wall) === C.WALL);
}
function getRoomSize(board: Board): number {
const [width, height] = getSize(board);
return Math.floor(width / 2) * Math.floor(height / 2);
}
function flatMap<T>(l: T[][]): T[] {
return l.reduce((a, n) => a.concat(n), []);
}
function isOutOfBounds(board: Board, coord: Coord): boolean {
const [width, height] = getSize(board);
const [x, y] = coord;
return x >= width || x < 0 || y >= height || y < 0;
}
export function getInitialState(isP1Start?: boolean): State {
return {
isP1: isP1Start !== false,
p1Score: 0,
p2Score: 0,
isOver: false,
};
}
export function stepGame(
board: Board,
state: State,
answer: string
): [Board, State] {
// 1. Check input.
if (state.isOver) {
throw new Error("Game is over!");
}
const [fromS, toS, ..._] = answer.toUpperCase().split(/\-|\s/);
const [from, wall, to] = getSpan(fromS, toS);
if (isOutOfBounds(board, from) || isOutOfBounds(board, to)) {
throw new Error(`Input "${answer}" is out of bounds!`);
}
if (getCell(board, wall) !== C.NOWALL) {
throw new Error("A wall is already there! Please choose another.");
}
// 2. Place wall.
const boardP = setCell(board, wall, C.WALL);
const rooms = getRoomsSurroundingWall(boardP, wall);
// 3. Mark enclosed rooms from the latest wall placement.
const roomsEnclosed = rooms.filter(
(room) => isEnclosed(boardP, room) && getCell(boardP, room) === C.NOP
);
const score = roomsEnclosed.length;
const boardPP = roomsEnclosed.reduce(
(board, room) => setCell(board, room, state.isP1 ? C.P1 : C.P2),
boardP
);
// 4. Calculate new scores.
const p1Score = state.p1Score + (state.isP1 ? score : 0);
const p2Score = state.p2Score + (state.isP1 ? 0 : score);
const newState = {
isP1: score > 0 ? state.isP1 : !state.isP1,
p1Score,
p2Score,
isOver: p1Score + p2Score >= getRoomSize(board),
roomSize: getRoomSize(board),
};
return [boardPP, newState];
}
export function generateBoard(w: number, h: number): Board {
const board = [];
for (let r = 0; r < h - 1; r++) {
const cornerRow = flatMap(Array(w - 1).fill([C.DOT, C.NOWALL])).concat([
C.DOT,
]) as C[];
const noCornerRow = flatMap(Array(w - 1).fill([C.NOWALL, C.NOP])).concat([
C.NOWALL,
]) as C[];
board.push(cornerRow);
board.push(noCornerRow);
}
const cornerRow = flatMap(Array(w - 1).fill([C.DOT, C.NOWALL])).concat([
C.DOT,
]) as C[];
board.push(cornerRow);
return board;
}
const cellToChar = (c: C, y: number): string => {
switch (c) {
case C.NOWALL:
return " ";
case C.NOP:
return " ";
case C.WALL:
return y % 2 === 0 ? "-" : "|";
case C.DOT:
return "•";
case C.P1:
return "1";
case C.P2:
return "2";
default:
return " ";
}
};
export function formatBoard(board: Board): string {
const rows = board.map((r, y) => r.map((c) => cellToChar(c, y)).join(""));
// Add axes for algebraic notation.
const [width, _] = getSize(board);
const xAxis =
" " +
Array(Math.ceil(width / 2))
.fill(0)
.map((_, i) => String.fromCharCode(i + 65))
.join(" ");
const formatYAxis = (y: number) => {
const yN = Math.floor(y / 2) + 1;
return y % 2 === 0 ? `${yN}` : " ";
};
return [xAxis].concat(rows.map((row, y) => formatYAxis(y) + row)).join("\n");
}
import { createInterface } from "readline";
import {
formatBoard,
stepGame,
generateBoard,
getInitialState,
} from "./dots-n-boxes";
const readline = createInterface({
input: process.stdin,
output: process.stdout,
});
async function question(render: string): Promise<string> {
return new Promise((resolve) => {
readline.question(render, (answer) => {
resolve(answer);
});
});
}
async function main() {
const width = Math.min(Math.max(+(process.env["W"] || 5), 0), 26);
const height = Math.min(Math.max(+(process.env["H"] || 5), 0), 26);
let board = generateBoard(width, height);
let state = getInitialState();
let error: null | string = null;
while (true) {
const view = formatBoard(board);
const info = !!error ? "!! " + error : "";
const prompt = `${state.isP1 ? "P1" : "P2"} > `;
console.clear();
const answer = await question([view, info, prompt].join("\n"));
try {
const [newBoard, newState] = stepGame(board, state, answer.trim());
board = newBoard;
state = newState;
error = null;
} catch (err: any) {
error = err.message;
}
if (state.isOver) {
let result = "";
if (state.p1Score === state.p2Score) {
result = "Tie!";
} else if (state.p1Score > state.p2Score) {
result = "Player 1 won!";
} else if (state.p2Score > state.p1Score) {
result = "Player 2 won!";
}
const finalResult = formatBoard(board) + `\n${result}`;
console.clear();
console.log(finalResult);
break;
}
}
}
main().catch(console.error);
{
"scripts": {
"start": "npx ts-node --transpile-only main.ts",
"fmt": "npx prettier --write ."
},
"dependencies": {
"typescript": "^4.6.2"
},
"devDependencies": {
"@types/node": "^17.0.21"
}
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment